todo/Zapret2/video/16-vnutri-programmy.md
loop-uh ae1d0be970
Some checks failed
Published content check / validate (push) Failing after 7s
Видеосерия: статьи к видео 1, 2, 3, 4, 5, 6, 16
Полный текст выпусков с анимированными SVG-схемами: «Письмо в сортировочном центре», «Что видит наблюдатель», «Читаем командную строку zapret2», «Где резать: метки», «multisplit: резать на части», «Перемешать: disorder», «Внутри программы». Список видео в Zapret2/Zapret2.md обновлён.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 14:24:56 -04:00

185 KiB
Executable file
Raw Permalink Blame History

title date tags aliases description
Видео 16. Внутри программы 2026-10-25
zapret2
видео
сценарий
nfqws2
nfqueue
raw-socket
loop
Видео 16
Видео 16 серии о Zapret 2: правило ядра, очередь, главный цикл, обратный вызов, отправка через сырой сокет и защита от петли, с анимированными схемами.

[!info] О чём заметка Это полный текст видео номер 16 из серии о том, как работает Zapret2/Zapret2, разбитый по сценам, с анимированными схемами. Каждую сцену можно читать отдельно.

[!tip] Как читать пометки «В нашем тесте» значит настоящий запуск в лаборатории проекта: клиент, роутер, сервер. «По коду» значит, что мы прочитали исходники, но не запускали. «По общим сведениям» значит, что это вне нашего кода и лаборатории. О настоящем оборудовании ТСПУ мы ничего не утверждаем: наш «инспектор» это модель.

Повтор и вопрос части

Добро пожаловать в третий выпуск. Короткий повтор, чтобы часть можно было смотреть отдельно.

[!warning] Номера выпусков в видео В озвучке выпуски названы по номерам из первоначального плана серии. Сейчас порядок другой, поэтому читайте номера по таблице: «первый выпуск» это «Письмо в сортировочном центре», «второй» это «Что видит наблюдатель», «третий» это видео, «Внутри программы». «Четвёртый» это «От байтов к таблице», «пятый» это «Память о соединении», «шестой» это «Тип нагрузки и имя сайта», «седьмой» это «Профили: как программа выбирает», «восьмой» это «Lua внутри программы», «десятый» это «multisplit: резать на части». «Следующий выпуск» в конце видео это «От байтов к таблице».

В первом выпуске мы представили сортировочный центр. Письма едут по ленте, а сортировщик не вскрывает конверты: он смотрит в окошко с адресом и решает, пропустить письмо или бросить в корзину. Так мы описали наблюдателя на пути пакетов, которого называем ТСПУ. Это только модель. Настоящее оборудование мы не видели и не знаем, как оно устроено, поэтому говорим осторожно.

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

Во втором выпуске мы вскрыли конверт. Пакет это вложенные слои: Ethernet, четырнадцать байт; IP, двадцать; TCP, тридцать два; и данные, у нашего запроса двести пятьдесят. Вместе триста шестнадцать байт.

Внутри запроса есть имя сайта, www.example.com, пятнадцать байт. Если запрос разрезан на две части, сто четыре и сто сорок шесть байт, получатель склеивает их по номерам, а игрушечный инспектор, читающий пакеты по одному, целого имени не находит.

Но один вопрос мы обходили. Обычные пакеты создаёт не zapret2, а сетевая часть операционной системы. Как же пакет, уже собранный и готовый уйти в сеть, попадает в отдельную программу? Как она возвращает его обратно: целым, изменённым или выброшенным? И как сделать, чтобы она не перехватывала вообще всё? Это три вопроса выпуска.

!wiki-anim-video16-s01-three-questions.svg

Мы разберём их в два слоя. Сначала картинка на сортировочном центре, потом настоящие правила ядра, настоящий код и один настоящий пакет, наш запрос на двести пятьдесят байт. Исходные тексты мы открывали и проверяли. Там, где мы измерили что-то сами, мы говорим «в нашем тесте». Там, где речь об устройстве ядра Linux и мы этого не проверяли, мы говорим осторожно или молчим.

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

!wiki-anim-video16-s01-three-parts-map.svg

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

Ядро, окошко, очередь и три штампа

Первый вопрос: где программа zapret2 находится по отношению к пакетам, и как пакет попадает к ней и возвращается?

Представим выдуманный пример. Большая почта, внутри неё лента: письма едут одно за другим, там их нумеруют и отправляют дальше. Рядом отдельная контора, где сидит умный клерк. К ленте он не допущен, двери между зданиями нет. Чтобы он увидел письмо, письмо кто-то должен вынести и принести ему. Для почты это лишняя работа.

Теперь с настоящими названиями. У компьютера с Linux есть ядро, главная часть операционной системы. По общим сведениям, ядро само работает с сетевой картой, принимает и отправляет пакеты и держит протокол TCP из второго выпуска. Мы этого в лаборатории отдельно не проверяли. В нашей картинке ядро это здание с лентой.

Остальные программы живут снаружи: браузер, редактор и основная часть zapret2, программа nfqws2. Эту область называют user space, пользовательское пространство. В документации сказано прямо: nfqws2 работает не в режиме ядра, это пользовательский процесс, а передача пакетов между ядром и пользовательским пространством связана со значительными накладными расходами. Чем больше лишнего трафика отсеяно, тем ниже нагрузка на процессор. Вывод: в контору надо выносить не все письма, а только нужные, и выбирать их лучше прямо в ядре.

Как это выглядит. На ленте есть пункт досмотра, окошко в стене. К окошку ядро подвозит только письма, выбранные правилом, остальные едут мимо. Оригинал выбранного письма ждёт у окошка, а его копию протягивают клерку. Клерк пишет на листке своё решение, и ядро поступает с письмом по этому решению. Пока листка нет, письмо стоит. Что ядро делает с очередью внутри, мы не открывали, и опыта с длинной очередью ожидающих пакетов у нас не было, так что это упрощение.

!wiki-anim-video16-s02-detour.svg

Этот механизм в Linux называется NFQUEUE, «очередь пакетов для программы». Приставка NF, по общим сведениям, от слова netfilter: так называется часть ядра, которая работает с пакетами.

У очереди есть номер, любой от нуля до шестидесяти пяти тысяч пятисот тридцати пяти. Это самое большое число, которое помещается в шестнадцать бит. В наших примерах номер двести. Правило в ядре отправляет пакеты в очередь двести, а программа подписывается на очередь двести. Номера должны совпасть, иначе письма будут направлены в одну комнату, а клерк будет ждать в другой; что при этом случится, разберём на слове bypass.

Есть и второй номер, его не надо путать с первым: у каждого пакета в очереди своя бирка. По общим сведениям, её выдаёт ядро, и по ней программа говорит, к какому пакету относится её решение. В нашем тесте бирки в каждом прогоне идут с единицы подряд, в девяноста двух журналах без пропусков. Это не номер из заголовка IP и не порядковый номер TCP.

Решение клерка называется вердиктом, и на листке он пишет одно из трёх. Пропустить как есть: письмо едет дальше тем же, в программе это PASS. Пропустить, но с заменой: письмо едет с другими байтами, это MODIFY. Выбросить: это DROP. PASS совсем не значит «подозрительно», это самый обычный ответ: в нашем опыте t01, «тэ-ноль-один», где стратегия pass ничего не делает, все десять пакетов очереди получили именно его. Будем называть их тремя штампами.

!wiki-anim-video16-s02-numbers-verdicts.svg

Проверь себя. Что будет, если запустить программу с номером очереди шестьдесят пять тысяч пятьсот тридцать шесть? В коде при разборе параметров стоит проверка: если число меньше нуля или больше шестидесяти пяти тысяч пятисот тридцати пяти, программа пишет в журнал bad qnum, «плохой номер очереди», и завершается.

Итак, nfqws2 сидит в соседней конторе, и ядро выносит ему только выбранные пакеты через очередь с номером. У каждого пакета своя бирка, а ответ один из трёх: PASS, MODIFY или DROP.

Проверка в лаборатории: путь пакета и цена захода

Как проверить в нашей лаборатории, что программа стоит на пути пакетов, и сколько стоит один заход к ней?

Выдуманная картинка: у здания две камеры, одна на входной двери, другая на выходной, окошко клерка посередине. Входящее письмо камера у входа снимет раньше клерка, а исходящее камера у выхода снимет позже.

В лаборатории камера это запись пакетов на сетевом интерфейсе клиента, он называется c0, «цэ-ноль». По общим сведениям, она видит входящие пакеты до правил ядра, а исходящие после. Сама программа тоже записывает в файл каждый пакет, который до неё дошёл, и ставит время. Значит, у пакета два времени, и их можно сравнить.

Возьмём опыт t10, «тэ-один-ноль», где запрос режется на две части. Входящий SYN-ACK сервера интерфейс записал раньше программы: пакет сначала пришёл на интерфейс, потом попал к программе. Исходящий SYN клиента наоборот: программа записала его раньше, интерфейс позже.

Так мы сравнили все пакеты, которые удалось сопоставить. В t01 это десять пакетов. В t10 программа получила одиннадцать, но один, наш запрос, выброшен и сам на проводе не появляется, поэтому сопоставимых десять. Десять и десять будет двадцать, и в двадцати случаях из двадцати порядок такой, как должен быть. Он согласуется с тем, что программа стоит на пути пакетов, а прямо это показывает выброшенный запрос, о нём дальше.

Теперь выброшенный запрос. В t10 программа получила шесть исходящих. Один, запрос, выброшен, остаётся пять. Программа вставила две части запроса, сто четыре и сто сорок шесть байт: пять и два будет семь. На интерфейсе клиента ровно семь исходящих, сходится. Входящих программа получила пять, и на интерфейсе пять: их она не выбрасывала и не добавляла. Как так получается, что данные не теряются, разберём во второй и третьей частях.

!wiki-anim-video16-s03-capture-order-counts.svg

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

Мы смотрели на пару пакетов: входящий SYN-ACK и исходящее подтверждение. Без программы, опыт t00, «тэ-ноль-ноль», в трёх сохранённых прогонах от прихода SYN-ACK до ухода подтверждения проходит от двенадцати до пятнадцати тысячных миллисекунды; в новых шести повторах было от одиннадцати до двадцати. С программой, опыт t01: в нашем тесте шесть повторов на локальном диске дали от двухсот шестидесяти четырёх до пятисот пятидесяти пяти тысячных. В сохранённом прогоне та же пара занимала в сотню и более раз дольше, чем в повторах, а менялась только папка вывода.

Вычтем по медианам. Около двухсот восьмидесяти тысячных с программой, около тринадцати без неё: разность около двухсот семидесяти, в пяти типичных прогонах от двухсот пятидесяти до трёхсот, в худшем около пятисот сорока. В паре два захода к клерку. Если считать их равными, а это наше допущение, получится порядка ста тридцати микросекунд на заход, в типичных прогонах от ста двадцати пяти до ста пятидесяти. Заход здесь это весь крюк: вынос пакета, работа программы с записью журнала и файла пакетов, возврат вердикта. Наша оценка, с включённым журналом.

!wiki-anim-video16-s03-cost-of-detour.svg

И осторожность: это один компьютер, WSL2, Linux внутри Windows, шесть прогонов повтора. Ориентир, а не закон. Настоящее железо, реальную сеть и большой поток пакетов мы не измеряли.

Проверь себя. Опыт t01, стратегия pass: программа получила шесть исходящих пакетов, ничего не выбросила и не вставила. Сколько исходящих на интерфейсе? Шесть: ничего не вычитается и не добавляется.

Итак, в нашем тесте входящий пакет интерфейс записывает раньше программы, а исходящий позже, и так для двадцати пакетов из двадцати. Выброшенный запрос на провод не вышел, вышли две его части. Один заход к программе, весь крюк с включённым журналом, стоит, по нашей оценке, порядка ста тридцати микросекунд, если входящий и исходящий заходы равны, на одном компьютере, это ориентир.

Правило nftables: таблица, цепочка, точка на пути

Теперь настоящие правила, которые стоят в ядре и выбирают письма для окошка. Мы берём те, что использует наша лаборатория. Они взяты из документации zapret2 с небольшими отличиями в имени таблицы, порядке строк и записи порта UDP, смысл тот же.

Сначала выдуманная картотека: папка с ярлыком, в папке листы, на листах строки. Одна строка это одна инструкция: если письмо такое-то, сделать с ним то-то.

Правила задаются в nftables, это современный способ настраивать фильтрацию пакетов в Linux. По общим сведениям, в ней папка называется таблицей, лист цепочкой, а строка правилом.

Цепочка это список правил, которые ядро проходит в одной определённой точке пути пакета.

Первая строка нашего набора создаёт таблицу с именем z, «зэт». По общим сведениям, слово inet значит, что она работает и для IPv4, и для IPv6, а имя произвольное.

На каком месте пути висит лист? Выдуманная картинка: пакет проходит несколько дверей, у первой он только что вошёл с улицы, у последней вот-вот выйдет. Лист вешают на любую. В netfilter такая дверь называется hook, «крючок», точка перехвата. Нам нужны две: prerouting, куда пакет попадает только что принятым, и postrouting, где исходящий пакет уже выбрал дорогу и вот-вот уйдёт в интерфейс. Для самого клиента исходящие идут через output к postrouting, входящие через prerouting к input. Это общие сведения о netfilter.

Вторая строка создаёт цепочку post на точке postrouting: исходящие пакеты, на выходе.

В этой же строке слово priority, приоритет. Выдуманный пример: на одной двери висят два листа, на одном написано «сто», на другом «сто один». Кто смотрит на письмо первым? Тот, у кого число меньше. Приоритет это число, по которому ядро упорядочивает цепочки на одной точке, чем меньше, тем раньше. Наше число сто один значит «чуть позже перевода адресов, у которого сто»; в лаборатории перевода адресов нет, число нужно лишь для порядка. Это общие сведения.

Следующая цепочка, pre, висит на точке prerouting с приоритетом минус сто один. По общим сведениям, это после отслеживания соединений, которое нам скоро понадобится, но до перевода адресов назначения. Главное, что эта цепочка смотрит на только что пришедшие пакеты.

!wiki-anim-video16-s04-rules-code.svg

Согласуется с этим журнал программы: у исходящих пакетов указан выходной интерфейс c0 и не указан входной, а у входящих наоборот.

!wiki-anim-video16-s04-doors-priority.svg

Проверь себя. Пакет только что пришёл снаружи: какую цепочку он проходит? Цепочку pre, потому что она на точке prerouting, и в журнале у него указан входной интерфейс c0.

Итак, правила лежат в таблице, таблица состоит из цепочек, цепочка висит на точке пути пакета, и у неё есть приоритет. У нас две цепочки: post для исходящих и pre для входящих.

Правило слева направо: метка и порты

Теперь само правило, слева направо. Строка длинная, поэтому в этой сцене только первые две части, остальные дальше. Первая часть: meta mark and ноль-икс четыре и семь нулей равно нулю.

Сначала зачем это нужно, на выдуманном примере. Клерк тоже отправляет письма, и мы клеим на них красную наклейку «своё». Правило говорит: письма с красной наклейкой к окошку не возить. Иначе, по документации, клерк получит собственное письмо, отправит снова, получит снова, и так по кругу. О петле подробно в третьей части, сейчас важно, что наклейка выбирает письма.

Как читать такие числа. Во втором выпуске мы говорили о шестнадцатеричной записи, но приставку не объясняли. Приставка «ноль-икс» значит, что дальше идёт шестнадцатеричная запись, и цифры мы читаем по одной: ноль-икс, четыре и семь нулей.

Что это за число? В нём восемь цифр, каждая это четыре бита, всего тридцать два бита.

Старшая цифра, четвёрка, в двоичной записи ноль, один, ноль, ноль, и единица стоит на месте номер тридцать, если считать справа налево от нуля. Остальные цифры нули. Значит, единица стоит в одном-единственном месте: тридцатый бит.

Слово mark, метка. По общим сведениям, это число, которое ядро хранит рядом с пакетом, в памяти. В самих байтах пакета, которые уходят в сеть, его нет.

Слово and, побитовое «и». Представим трафарет, лист бумаги с одним окошком: он лежит на ленте из тридцати двух клеток, и видна только клетка номер тридцать. «Метка and ноль-икс четыре и семь нулей» значит «оставить только тридцатый бит», а потом правило проверяет, равен ли результат нулю.

Два примера. Метка ноль, в нашем журнале восемь нулей: оставили тридцатый бит, получили ноль, правило подходит, идём дальше. Метка ноль-икс четыре и семь нулей: оставили тридцатый бит, получили то же число, не ноль, правило не подходит, пакет идёт мимо окошка. Это пакеты самой программы, у них этот бит стоит.

Проверь себя. Метка равна единице, стоит только самый младший бит. Подойдёт ли пакет под эту часть правила? Тридцатый бит не стоит, трафарет его не видит, результат ноль, значит, подходит.

!wiki-anim-video16-s05-mark-bit30.svg

Вторая часть: tcp dport, фигурные скобки, восемьдесят и четыреста сорок три. Это пакеты TCP, у которых порт назначения восемьдесят или четыреста сорок три. Порты мы разбирали во втором выпуске, но порт восемьдесят там не объяснялся: это стандартный порт обычного HTTP, где запрос идёт открытым текстом. А четыреста сорок три порт защищённых соединений. По общим сведениям, фигурные скобки значат «любой из списка». Наш клиент ходит на четыреста сорок три в опытах с TLS и на восемьдесят в опытах с HTTP, а порт восемь тысяч восемьдесят не подойдёт: его нет в списке.

!wiki-anim-video16-s05-rule-tokens.svg

Итак, две первые части правила. Метка: оставляем тридцатый бит, и если получился ноль, идём дальше. Порт: восемьдесят или четыреста сорок три.

Правило слева направо: очередь, bypass и весь набор

В правиле остались слова queue num двести и bypass; ограничитель ct original packets оставим на следующую сцену.

Queue num двести: это само действие, отправить пакет в очередь с номером двести и ждать вердикта. Программа запускается с параметром qnum равно двести, и числа обязаны совпасть. В журнале программы это строка binding this socket to queue двести: «привязываю этот сокет к очереди двести».

Теперь bypass. Выдуманная картинка: что будет, если клерк не пришёл и в комнате у окошка никого нет? Можно выбросить письма, если ждать некому, а можно пропустить их дальше, как будто окошка нет. Табличка на двери, bypass, включает второе. Здесь это слово значит не обход блокировок, а «пропусти, если некому»: по общим сведениям о netfilter, пакет идёт дальше.

В нашем тесте это видно по опыту t00: правила стоят, nfqws2 не запущен, и соединение работает, десять пакетов TCP проходят, клиент соединился за три десятых миллисекунды. А что без bypass пакеты выбрасываются, показал наш тест на UDP, где очередь никто не слушает: с bypass два пакета из двух дошли, без него ни один.

У bypass есть обратная сторона. Если в правиле написано двести, а программа запущена с номером триста, ядро кладёт пакеты в очередь двести, где никого нет, и bypass пропускает их дальше. Всё работает, программа не получает ничего, и сообщения об ошибке нет. Это наш вывод из смысла слова bypass, опыта с неверным номером у нас не было.

!wiki-anim-video16-s06-queue-bypass.svg

Теперь весь набор: пять правил в трёх цепочках. В цепочке post два правила: TCP на порты восемьдесят и четыреста сорок три, то, что мы разобрали, и UDP на порт четыреста сорок три, это протокол QUIC, о нём в шестом выпуске. В цепочке pre такие же два для входящих: порт источника вместо порта назначения, потому что ответы идут с порта сервера, и ct reply вместо ct original, тот же ограничитель для ответного направления.

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

В нашем тесте на UDP хватало одной проверки метки, и одного notrack тоже хватало. Какая защита действовала в основных опытах, по журналам сказать нельзя.

И последняя строка набора включает «либеральную проверку» в системе отслеживания соединений, это параметр ядра nf_conntrack_tcp_be_liberal, его ставят в единицу. В комментарии к скриптам автор пишет, что российский DPI присылает сброс соединения, пакет с флагами RST и ACK, с неправильным номером подтверждения, ядро иногда считает такой пакет недопустимым, и тогда правило с ограничителем не передаёт его программе; либеральная проверка нужна, чтобы такие входящие сбросы всё же доходили. Это утверждение автора о настоящем оборудовании. Мы его не проверяли и как факт не повторяем.

!wiki-anim-video16-s06-five-rules.svg

Проверь себя. Сколько правил в нашем наборе и сколько цепочек? Пять правил, три цепочки. Где стоит notrack? В цепочке predefrag.

Итак, queue num двести отправляет пакет в очередь, а bypass говорит: если никто не слушает, пропусти. Номер в правиле и в параметре программы обязан совпасть, иначе, по нашему выводу, программа ничего не получит, а соединение продолжит работать; опыта с неверным номером не было. Набор: пять правил в трёх цепочках, исходящая сторона, входящая и цепочка для собственных пакетов программы.

Счётчик первых пакетов: что считает ядро и зачем предел

В правиле осталась одна непрочитанная часть: ct original packets, от одного до двенадцати. Что она считает и зачем нужна?

Сначала выдуманный клуб. У входа турникет со счётчиком, а охранник говорит: к стойке регистрации идут только первые трое гостей, остальные проходят мимо. Первый гость это номер один, четвёртый идёт мимо. Считается и сам гость: первого тоже посчитали.

Теперь без картинки. По общим сведениям, ct значит conntrack, «отслеживание соединений в ядре»: таблица, где ядро хранит известные ему соединения и счётчики пакетов по каждому, одна строка на соединение.

У соединения два направления. Исходное, original, это то, в котором ушёл первый пакет соединения, а ответное, reply, обратное. У нас первым уходит SYN клиента, значит, исходное направление от клиента к серверу, а для каждого направления ядро ведёт свой счётчик.

Исходное и ответное значат «по первому пакету», а не «наружу» и «внутрь». Для клиента исходное исходящее, а для сервера то же направление входящее, поэтому в лаборатории правила на сервере зеркальные.

Условие «ct original packets от одного до двенадцати» значит: счётчик исходного направления сейчас в пределах от одного до двенадцати. Считаются пакеты, а не байты: пустое подтверждение и пакет с двумястами пятьюдесятью байтами данных равны, по одному.

Пройдём по началу опыта t10. Клиент отправляет SYN: до него клиент не отправлял ничего, это первый пакет исходного направления, счётчик единица. Проверяемый пакет уже входит в счёт, и единица подходит под «от одного до двенадцати». Так устроено по общим сведениям, и наш опыт дальше это подтверждает. В очереди это номер один.

Сервер отвечает SYN-ACK. Это ответное направление, там счётчик был нулём и стал единицей, а исходный остался единицей: направления считаются независимо. В очереди это номер два.

Клиент отправляет ACK. Он идёт в исходном направлении, счётчик становится двойкой, в очереди номер три.

Четвёртый пакет, наш запрос с двумястами пятьюдесятью байтами. До него клиент отправил два пакета, SYN и ACK, значит, запрос третий в исходном направлении, счётчик три. Но в очереди у него номер четыре: номера в очереди общие для обоих направлений, и между SYN и ACK клиента в неё попал входящий SYN-ACK.

Целиком t10: в очередь прошло шесть исходящих и пять входящих, шесть и пять будет одиннадцать, каждое направление ниже двенадцати, и в опытах t01 и t10 в очередь попало всё соединение, кроме пакетов, которые программа вставила сама.

И предупреждение. Мы говорим о таблице в ядре. У самой программы тоже есть память о соединениях, о ней в пятом выпуске, и считает она отдельно и по-своему.

!wiki-anim-video16-s07-conntrack-counters.svg

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

Вторая, что нужно программе: для нашего игрушечного инспектора и наших стратегий всё нужное, имя сайта и рукопожатие, лежит в самом начале соединения, а страница, картинки и видео ей ни к чему.

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

Проверь себя. Стратегии нужен пятнадцатый исходящий пакет, а предел двенадцать. Увидит ли программа этот пакет? Нет: пятнадцатый не подойдёт под «от одного до двенадцати», ядро пропустит его мимо очереди, и программа не получит его вообще.

!wiki-anim-video16-s07-three-reasons.svg

Итак, ядро считает пакеты каждого направления отдельно, считая и сам проверяемый пакет, и пускает в очередь, пока счётчик в пределах от одного до двенадцати. Предел нужен, потому что вынос дорог, нужное лежит в начале соединения, а программа видит только то, что ей дали.

Опыт t20: где предел кончается, обгон и откуда число двенадцать

Теперь опыт, где предел действительно кончается и это видно в записи.

Он называется t20, «тэ-два-ноль». Программа вставляет поддельный пакет без защиты, то есть с ненастоящими данными, которые сервер в нашем опыте принимает за настоящие. Поэтому ответа клиент так и не получает, повторяет отправку снова и снова, и поток получается длинным, как раз чтобы дойти до предела.

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

Исходящие. На интерфейсе клиента девятнадцать исходящих, но шесть из них вставила сама программа. Девятнадцать минус шесть будет тринадцать собственных пакетов клиента. В очередь попало двенадцать, значит, по порядку создания, это видно по идентификаторам IP, тринадцатым был ACK, он и прошёл мимо.

Почему последним в очередь попал именно двенадцатый пакет, а не тринадцатый? Правило говорит «от одного», а SYN, первый пакет, в очередь попал. Значит, в тот момент счёт был единицей, а не нулём: пакет считает сам себя.

!wiki-anim-video16-s08-t20-frames.svg

Теперь два независимых счётчика, по номерам очереди. Входящий предел набрался раньше: двенадцатым входящим в очередь попал пакет с номером девятнадцать. Но после него в очередь попали ещё пять пакетов, с двадцатого по двадцать четвёртый, и все исходящие. Исходящий счётчик добрался до своих двенадцати только на номере двадцать четыре.

Это не случайность одного прогона. Те же двенадцать и двенадцать мы видели ещё в пяти опытах: t29 и t29c, «тэ-два-девять» и «тэ-два-девять-цэ», и трёх опытах с IPv6, «вэ-один-три», «вэ-один-пять» и «вэ-один-шесть». А во всех девяноста двух журналах нет ни одного, где в одну сторону было бы больше двенадцати.

Есть следствие. Пакеты сверх предела идут мимо программы целиком, поэтому могут обогнать пакеты, которые она ещё держит. Выдуманный пример: гость, которому регистрация не нужна, проходит мимо стойки и входит в зал раньше того, кто стоит у клерка.

В t20 так и случилось: подтверждение, не попавшее в очередь, ушло в сеть раньше повторного запроса, который клиент создал перед ним и который ждал в очереди. У подтверждения идентификатор в заголовке IP шесть тысяч восемьсот сорок два, у запроса шесть тысяч восемьсот сорок один. По общим сведениям, идентификаторы выдаются в порядке отправки, так что запрос создан раньше, а ушёл позже. Это наш вывод по идентификаторам, а сколько прошло времени между ними, мы не называем: прогон со старым медленным диском.

Теперь о числе. Двенадцать это число из документации и нашей лаборатории, но не значение по умолчанию. В файле настроек по умолчанию, config.default, которым пользуются готовые скрипты установки, для TCP двадцать исходящих и десять входящих, для UDP пять и три.

В примере из руководства пятнадцать и пятнадцать. Это разные назначения, и единственно верного числа в коде нет. В руководстве есть и отдельные правила без ограничителя для пакетов FIN и RST, а в нашем лабораторном наборе их нет.

!wiki-anim-video16-s08-t20-queue-ids.svg

Чего мы не проверяли. В основных опытах для UDP ограничитель не тестировали: QUIC давал в очереди по одному-два пакета. Отдельный тест на UDP в третьей части. И что стало бы с повторным запросом уже после предела, мы не видели: запись заканчивается раньше.

Проверь себя. В опыте t20 какое направление исчерпало предел первым и что попадало в очередь после этого? Первым входящее, его последний пакет в очереди номер девятнадцать. После него в очередь шли только исходящие.

Итак, предел считает проверяемый пакет вместе с предыдущими, поэтому в очередь попадает ровно двенадцать. У направлений свои счётчики, и во всех девяноста двух журналах больше двенадцати в одну сторону не было. Пакеты сверх предела идут мимо программы и могут обогнать те, что она держит. А число двенадцать не значение по умолчанию: у разных источников оно разное.

Итог части: что мы знаем и вопросы к следующей части

Подведём итог первой части.

!wiki-anim-video16-s09-recap-card.svg

Где всё стоит. Ядро это здание с лентой, в нём работают сетевая карта, протокол TCP и правила. А nfqws2 это программа в соседней конторе, в пользовательском пространстве. Чтобы она увидела пакет, ядро должно его вынести, и по нашей оценке это порядка ста тридцати микросекунд на заход, вместе с работой программы и записью журнала, если входящий и исходящий заходы равны, на одном компьютере.

Как пакет попадает в программу. Через очередь NFQUEUE: у неё номер, у нас двести, и он должен совпасть в правиле и в параметре программы. У каждого выбранного пакета своя бирка, а программа отвечает штампом: PASS, MODIFY или DROP. В нашем тесте программа стоит на пути пакета: двадцать из двадцати.

Как ядро выбирает пакеты. Правила лежат в таблице зэт, в цепочках: post на точке postrouting с приоритетом сто один, pre на точке prerouting с приоритетом минус сто один и predefrag на точке output, она защищает от петли. Правило состоит из частей: метка, порт, ограничитель первых пакетов, номер очереди и слово bypass. Всего пять правил в трёх цепочках.

И почему первые пакеты. Ядро считает пакеты каждого направления отдельно, вместе с проверяемым, и пускает до двенадцатого. В t20 на интерфейс пришло двадцать четыре входящих, а программа получила двенадцать. Пакеты сверх предела идут мимо и могут обогнать те, что она держит. И двенадцать не значение по умолчанию: в других источниках числа другие.

!wiki-anim-video16-s09-limit-24-to-12.svg

Проверим себя, три вопроса. Зачем в правиле bypass? Чтобы пакеты проходили, если в очереди никого нет. Что считает ct original packets? Пакеты исходного направления, по одному на пакет, считая проверяемый. Сколько из двадцати четырёх входящих пакетов t20 дошло до программы? Двенадцать, ровно первые.

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

Итак, мы знаем, где в ядре стоит пункт досмотра, как правила выбирают пакеты и почему выбираются только первые пакеты каждого направления, у нас двенадцать.

Повтор, карта программы и порядок запуска

Во второй части мы входим в программу. Сначала короткий повтор, чтобы часть можно было смотреть отдельно. Вопрос такой: что стоит за окошком пункта досмотра, какая цепочка кода берёт пакет и как возвращает ответ?

Вспомним сортировочный центр. Письма едут по ленте внутри большого здания. Здание это ядро, главная часть операционной системы, которая принимает пакеты от сетевой карты и отправляет их обратно в сеть. В стене есть окошко, а за окошком маленькая отдельная комната, в ней сидит программа nfqws2 из набора zapret2. На ленте её нет, она живёт снаружи ядра.

К окошку подъезжают не все письма: перед ним правило ядра. В нашей лаборатории оно выбирает первые двенадцать пакетов каждого направления соединения на порты восемьдесят и четыреста сорок три, и только те, на которых нет метки самой программы. В нашем тесте с долгим соединением в очередь попало ровно двенадцать исходящих и двенадцать входящих, и самый первый, SYN, считался первым. Зачем нужна метка, разберём в третьей части.

Выбранный пакет попадает в очередь с номером, у нас это двести, как номер окошка, и получает ещё собственный номер внутри очереди. Ядро держит пакет и ждёт ответа программы. Ответ называется вердиктом, у программы их три: пропустить как есть, пропустить с заменой байтов или выбросить. Мы зовём их тремя штампами, а в программе они называются PASS, MODIFY и DROP. По нашей оценке, с включённым журналом один заход к окошку стоит порядка ста тридцати микросекунд. Это оценка на одном компьютере, мы предположили, что входящий и исходящий заходы стоят поровну. Для настоящего оборудования цифр у нас нет.

Откуда у нас строки программы? Подробный журнал она пишет, только если запущена с параметром debug, а отдельные строки, например quit requested, дублируются на консоль и без него. Наша лаборатория запускает nfqws2 с этим параметром и сохраняет по журналу на опыт. Всего у нас девяносто два журнала, в них тысяча шестьдесят два пакета очереди.

Программа написана на языке C и с очередью ядра сама не разговаривает. Представьте клерка, который не знает языка ядра и пользуется готовым переводчиком: это библиотека libnetfilter_queue, набор готовых функций, открыть очередь, получить пакет, отправить вердикт. Внутрь библиотеки мы не заглядывали, читали только заголовочные файлы, поэтому говорим о ней лишь то, что видно из имён и описаний там.

Теперь цепочка. Функция main выбирает один из трёх вариантов главного цикла, по одному на операционную систему. Для Linux это nfq_main, мы зовём её главным циклом, а про BSD и Windows поговорим в конце выпуска. Главный цикл всё подготавливает и один раз вызывает настройщика очереди, функцию nfq_init.

Потом цикл бесконечно ждёт пакеты. Каждое сообщение ядра он отдаёт библиотеке, а та вызывает приёмщика, функцию nfq_cb, по разу на пакет. Приёмщик читает пакет и в конце отправляет ядру вердикт.

Между приёмщиком и стратегиями стоит маленькая функция processPacketData, проходная: она проверяет метку и передаёт пакет движку. Движок это главная функция разбора dpi_desync_packet и всё, что она вызывает: он разбирает пакет и запускает стратегии на Lua, а вердикт возвращается по той же цепочке к ядру.

!wiki-anim-video16-s10-call-chain.svg

Проверь себя. Какая из этих функций вызывается один раз за всю жизнь программы, а какие на каждый пакет? Настройщик очереди один раз, при запуске. Приёмщик и проходная работают на каждый пакет, который пришёл из очереди.

Теперь вторая половина вопроса: что главный цикл делает до первого пакета и почему в таком порядке? Клерк не открывает окошко, как только вошёл. Сначала он включает лампу, достаёт печати, кладёт бумагу: иначе первый посетитель придёт, а помочь ему будет нечем. Очередь, то есть окошко, создаётся последней из ресурсов, нужных пакету: пакет не придёт к приёмщику раньше, чем готовы сокеты и Lua.

Первое: права. По общим сведениям, у процесса в Linux есть набор прав. Функция dropcaps, сбрасывающая права, оставляет два: на сетевое администрирование и на работу с сырыми сокетами, остальные убирает. В нашей лабораторной сборке смена пользователя отключена одной строкой, процесс остаётся root в своём изолированном пространстве имён, но урезание прав никуда не делось.

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

Потом два буфера. Приёмный: шестьдесят пять тысяч пятьсот тридцать шесть байт. Ещё четыре тысячи девяносто шесть, по комментарию автора, на служебные заголовки сообщения ядра. Сложим, получится шестьдесят девять тысяч шестьсот тридцать два. Второй буфер, для изменённого пакета, ровно шестьдесят пять тысяч пятьсот тридцать шесть. Ещё вспомогательный сокет, он нужен приёмщику для имён интерфейсов.

И только теперь создаётся очередь. Между шагами есть мелкие проверки, например файлов, их не разбираем. В журнале опыта t10 порядок тот же: строка про сырые сокеты и две группы по три строки про их буферы, потом запуск Lua и LUA INIT DONE, потом восемь строк про очередь и только потом первый пакет.

!wiki-anim-video16-s10-startup-order.svg

Проверь себя. Что будет, если Lua не запустился? За вызовом запуска стоит проверка: при неудаче программа идёт к выходу с ошибкой, очередь не создаётся и пакеты в программу не приходят.

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

Настройщик очереди, первая половина: открыть, привязать, создать очередь

Разберём настройщика очереди, функцию nfq_init. В файле nfqws.c она занимает семьдесят семь строк, и это единственное место, где программа подключается к очереди ядра. Вопрос: как она получает именно ту очередь, в которую правило ядра кладёт пакеты? Пройдём её в два приёма, сейчас первая половина.

Представьте, что клерк устраивается в отделение. Он открывает личное дело, пишет заявления «принимаю письма такого-то вида», и только потом ему выдают окошко с номером. Каждый шаг может не получиться: на одних клерк сдаётся и уходит домой, на других только отмечает неудачу и работает дальше.

Что у функции на входе? Указатели на ручку библиотеки, на ручку очереди и на блок данных для приёмщика, а ещё номер очереди из параметров запуска. На выходе только «да» или «нет». Первой строкой функция вызывает nfq_deinit, «сбросить всё, что было»: в первый раз сбрасывать нечего, а при повторном вызове старая очередь уничтожается до создания новой.

Вызов nfq_open открывает ручку библиотеки, как личное дело: через неё идут все следующие вызовы. По общим сведениям, за ручкой стоит сокет netlink, канал, по которому программа разговаривает с ядром. Мы читали только заголовки библиотеки, так что это общее знание, а не проверенный факт. Не открылась ручка, это фатальная ошибка.

Дальше привязка к IPv4 из двух шагов: «отвяжи прежнего обработчика очередей, если он был», nfq_unbind_pf, и «привяжи нашу очередь для IPv4», nfq_bind_pf. Ошибка любого шага фатальна. Для IPv6 те же два шага, но ошибка только пишется в журнал, так что компьютер без IPv6 всё равно получит очередь для IPv4. Автор в комментарии пишет, что на ядрах начиная с версии три точка восемь эти привязки ничего не делают, а на старых нужны обе. Старые ядра мы не проверяли.

Проверь себя. Привязка к IPv6 не удалась, что будет? Ошибка попадёт в журнал, очередь для IPv4 заработает. А если не удалась привязка к IPv4? Функция сбросит всё сделанное и вернёт «нет», программа остановится с кодом один.

Главная строка: вызов nfq_create_queue, «создать очередь», с четырьмя аргументами. Ручка библиотеки. Номер очереди. Функция nfq_cb: так программа говорит библиотеке «когда придёт пакет, вызови вот эту», и такую функцию называют обратным вызовом, по-английски callback. И блок данных, который библиотека вернёт приёмщику при каждом вызове: в нём вспомогательный сокет и буфер для изменённого пакета. Не удалось создать очередь, фатально.

!wiki-anim-video16-s11-nfq-init-bind.svg

Номер берётся из параметра qnum. Допустимы числа от нуля до шестидесяти пяти тысяч пятисот тридцати пяти, вне диапазона программа пишет bad qnum и останавливается с кодом один. Внутри значение по умолчанию минус один, «не задано»: если параметра нет, а перехват включён, что бывает по умолчанию, программа отказывается стартовать и пишет Need queue number. А с выключенным перехватом очередь не нужна: программа запускает Lua и таймеры и настройщика не вызывает.

Здесь встречаются два числа, из правила ядра и из параметра запуска. В нашем журнале строка binding this socket to queue, апостроф, двести, и в правиле стоит queue num двести. В руководстве сказано, что две программы не могут занять одну очередь, вторая получит ошибку. Мы этого не запускали.

А если никто не слушает очередь двести, например программа упала или в номере опечатка? Слово bypass в конце правила нужно как раз для этого. По общим сведениям, оно значит «если никто не слушает, пропусти пакет». В нашей лаборатории опыт без программы, t00, так и работает: правила стоят, программы нет, соединение проходит. В нашем тесте на пакетах UDP в изолированном пространстве имён без слушателя с этим словом доставлены два пакета из двух, без него ни одного. TCP и флаг fail-open так не проверяли, а опыта с неверным номером у нас нет, поэтому для опечатки это логический вывод, не измерение.

!wiki-anim-video16-s11-qnum-meeting.svg

Итак, первая половина настройщика: сбросить старое, открыть ручку, привязать IPv4, где ошибка фатальна, и IPv6, где она только пишется, и создать очередь вызовом с четырьмя аргументами. Номер приходит из параметра qnum, и в нашей лаборатории это двести, как в правиле ядра.

Настройщик очереди, вторая половина: условия работы, буфер, что фатально

Очередь создана, теперь программа объясняет ядру условия работы. Вопрос: что она просит у очереди и какие шаги настройщика фатальны, а какие нет?

Клерку на трёх карточках объясняют условия. Первая: присылай письмо целиком, а не только конверт. Вторая: в коридоре помещается не больше определённого числа писем. Третья: если не справляешься, письма не выбрасывай.

Первая карточка. Вызов nfq_set_mode, «установить режим», просит ядро копировать в сообщение пакет целиком, не больше шестидесяти пяти тысяч пятисот тридцати пяти байт. Наш запрос с именем сайта, триста два байта от заголовка IP, помещается. Одних заголовков программе не хватило бы: имя лежит внутри данных запроса TLS, мы нашли его байт за байтом во втором выпуске. Сбой этого шага фатален.

Вторая карточка. Вызов nfq_set_queue_maxlen, «длина очереди», задаёт четыре тысячи девяносто шесть пакетов. По общим сведениям, столько пакетов может ждать вердикта в ядре. Что происходит при переполнении, мы не проверяли, и в девяноста двух журналах переполнения не наблюдали: за один опыт в очередь попадало не больше двадцати четырёх пакетов, по двенадцать на направление. Сбой этого шага тоже фатален.

Третья карточка про переполнение: флаг fail-open, по-русски «при отказе открыто», вызов nfq_set_queue_flags. Комментарий автора: «принимать пакеты, если их нельзя обработать». По общим сведениям, это значит, что при невозможности обработать пакет ядро его пропускает. Мы этого не проверяли, ни один наш опыт не переполнял очередь, поэтому флаг мы в деле не видели. Если ядро флаг не поддерживает, программа лишь пишет сообщение. В нём сказано, что на ядрах до версии три точка шесть это нормально, но это слова автора, а не проверка версии: сообщение появится при любом сбое.

Теперь буфер приёма, лоток для сообщений ядра. Программа просит один мебибайт: тысяча двадцать четыре умножить на тысячу двадцать четыре, один миллион сорок восемь тысяч пятьсот семьдесят шесть байт. Библиотека возвращает фактический размер, программа делит его на два и сравнивает с запрошенным. Равно, пишет, что размер выставлен. Не равно, пишет, какой получился.

Почему деление на два? В нашем журнале есть похожий случай, для сырых сокетов. На приём запрошено две тысячи сорок восемь, показано четыре тысячи девяносто шесть. На отправку запрошено тридцать две тысячи семьсот шестьдесят восемь, показано шестьдесят пять тысяч пятьсот тридцать шесть. Ровно вдвое больше. Понятно, зачем программа делит ответ. Для самой очереди удвоение мы отдельно не проверяли и что ядро делает с этим размером, не проверяли тоже. В журнале t10 для очереди равенство выполнено.

Последняя настройка, про переполнение, называется NETLINK_NO_ENOBUFS. По общим сведениям, она просит ядро не сообщать сокету netlink об ошибке переполнения, как это работает, мы не проверяли. Обработка этой ошибки в главном цикле всё равно написана, об этом дальше.

!wiki-anim-video16-s12-queue-conditions.svg

Соберём. Фатальны открытие ручки, привязка IPv4, создание очереди, режим копирования и длина очереди: любой такой сбой сбрасывает всё сделанное и возвращает «нет». Только запись в журнал: привязка IPv6, флаг fail-open, размер буфера и настройка про переполнение.

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

!wiki-anim-video16-s12-buffer-and-fatal.svg

Проверь себя. Ядро выдало буфер приёма меньше запрошенного, остановится ли программа? Нет, напишет, какой размер получился, и продолжит. А если не создастся очередь? Это фатально: сообщение об ошибке и выход с кодом один.

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

Главный цикл: один оборот

Теперь главный цикл: вторая половина функции nfq_main, около девяноста строк, в ней программа проводит почти всю жизнь. Вопрос: что она делает за один оборот?

У клерка рабочий день состоит из одинаковых оборотов. Подождать, пока что-нибудь придёт. Подмести. Посмотреть, нет ли записки «перечитай списки». Взять письмо и разобрать его. И снова с начала.

В коде два цикла, один внутри другого. Внешний нужен, чтобы после сигнала или ошибки переполнения вернуться к началу. Внутренний за оборот делает те самые четыре дела: ждёт, запускает уборку и проверку записки, читает данные из сокета и передаёт их приёмщику. Без таймеров ожидание совпадает с чтением. Если читать не удалось, внутренний цикл выходит, и причину разбирают отдельно.

!wiki-anim-video16-s13-one-turn.svg

Ожидание работает в двух режимах. Без таймеров, если скрипты стратегий их не заводили, select не вызывается вообще: цикл сидит в вызове recv, который блокируется до прихода данных, как клерк спит, пока не позвонят. С таймерами цикл ждёт вызовом select, он вернётся, когда в сокете появились данные или подошёл срок ближайшего таймера, как будильник. Таймеры Lua разберём позже. Какой режим работает в наших опытах, мы не утверждаем, говорим только про устройство кода.

Уборка. Функция lua_do_gc собирает мусор Lua, то есть освобождает память, которую скрипты уже не используют. Если с прошлой сборки прошло шестьдесят секунд, это значение по умолчанию, запускается полная сборка и в журнал пишется строка LUA GARBAGE COLLECT. Отсчёт идёт от включения компьютера, поэтому на самом первом обороте сборка запускается, только если машина работает не меньше шестидесяти секунд. В журнале t10 эта строка стоит прямо перед первым пакетом.

Записка. Функция ReloadCheck смотрит, не пришёл ли сигнал SIGHUP, и если пришёл, программа перечитывает списки доменов и адресов. Уборка и записка стоят до чтения: с таймерами после ожидания в select, без таймеров прямо перед recv, где цикл потом и ждёт.

Чтение. Вызов recv кладёт данные ядра в приёмный буфер, шестьдесят девять тысяч шестьсот тридцать два байта. По комментарию автора, за один вызов в буфер может прийти сразу несколько сообщений подряд, мы такого не наблюдали. Ноль байтов программа считает фатальной ошибкой. Дальше функция библиотеки nfq_handle_packet разбирает данные и вызывает приёмщика. Это наш вывод из кода: потоков в программе нет, значит приёмщик вызывается из этого же цикла, и следующее чтение будет после его возврата. Саму библиотеку мы не читали. Ошибку библиотеки цикл лишь пишет в журнал.

!wiki-anim-video16-s13-gc-and-recv.svg

Проверь себя. Если у скриптов нет таймеров, вызывается ли select? Нет, цикл сидит прямо в recv. Значит, на пустой очереди без таймеров он простаивает в recv и до следующей уборки не доходит. Это вывод из кода, в лаборатории мы его не проверяли.

Итак, главный цикл состоит из двух вложенных. За оборот внутренний ждёт, с таймерами или без, запускает уборку Lua и проверку сигнала перезагрузки списков, читает данные из сокета и передаёт их приёмщику, который вызывается синхронно.

Главный цикл: один поток, ошибки чтения, выход

Остались три вопроса. Почему пакеты обрабатываются строго по одному? Что делает цикл, если чтение не удалось? И как программа останавливается?

В программе один поток. В её исходных текстах нет ни одного вызова для работы с потоками: мы искали pthread, thrd_create и CreateThread и не нашли ничего. Единственное исключение, fork при запуске в режиме демона, но он не создаёт параллельной обработки пакетов. Значит, пакеты идут строго по одному, в порядке прихода. Пока один находится внутри скрипта на Lua, остальные ждут в очереди ядра, а по настройке очереди ждать может до четырёх тысяч девяноста шести.

Что говорят номера? В каждом из девяноста двух журналов номера в очереди идут подряд с единицы без пропусков. Это значит, что ни один пакет из попавших в очередь не остался без записи в журнале и что очередь была одна. Потеряло ли что-то само ядро, номера не показывают, и о потоках они ничего не говорят: о потоках говорит только код.

Теперь опыт t10 на часах. Пакет номер четыре, запрос, программа разрезала на две части, и обе ушли на провод. Сразу после выхода первой части на интерфейс клиента пришли два ответа сервера: подтверждение первой части, номер пять, и короткий ответ в четырнадцать байт, номер шесть. Наш тестовый сервер отвечает на первый же полученный кусок. А программа увидела ответы заметно позже, по нашему объяснению потому, что ещё дописывала обработку запроса, и пакеты пять и шесть ждали в очереди. Подтверждение второй части пришло позже: это пауза до ответа сервера, а не ожидание в очереди, причину мы не исследовали. Величину ожидания не называем: на неё влиял диск журнала. Важен порядок.

!wiki-anim-video16-s14-one-thread-wait.svg

Проверь себя. Что будет с пакетом номер пять, пока обрабатывается номер четыре? Он ждёт в очереди ядра. Пакет номер пять пришёл, когда программа занята четвёртым. Чей вердикт вернётся ядру раньше? Четвёртого: поток один, порядок прихода. Это вывод из кода.

Ошибки чтения разбираются по виду. Чтение прервано сигналом: цикл возвращается к началу и проверяет флаг завершения. Ядро сообщает о переполнении буфера, ошибка называется ENOBUFS: она записывается в журнал, и работа продолжается. Любая другая ошибка завершает программу с кодом один. С вызовом select так же: прерывание сигналом просто повторяет ожидание, любая другая его ошибка завершает программу. ENOBUFS в нашей лаборатории не наблюдался.

Проверь себя. Что сделает программа, если recv вернул ENOBUFS? Запишет в журнал и продолжит. А если вернул ноль байтов? Это фатальная ошибка, остановка с кодом один.

Остановка. Сигналы SIGINT и SIGTERM взводят флаг завершения. Обработчики настроены без флага перезапуска вызова, поэтому, по общим сведениям, ждущий вызов сигналом прерывается, программа просыпается, видит флаг и идёт на выход. Оба обработчика ещё вызывают функцию lua_req_quit. По коду она ставит в Lua ловушку, которая на следующем шаге скрипта вызывает ошибку INTERRUPT, то есть работающий скрипт прерывается. Это чтение кода: посреди скрипта мы программу не останавливали.

Выход. Цикл пишет quit requested, программа освобождает буферы и вызывает nfq_deinit, ту самую функцию из начала настройщика: она пишет unbinding from queue двести и closing nfq library handle. Потом закрывается вспомогательный сокет, останавливается Lua, закрываются сырые сокеты. Код возврата при чистом выходе ноль, при ошибке один.

Лаборатория останавливает nfqws2 командой kill, то есть сигналом SIGTERM. В файле консоли появляется TERM received, а хвост журнала t10 повторяет порядок кода: quit requested, unbinding, closing, LUA SHUTDOWN.

!wiki-anim-video16-s14-errors-and-exit.svg

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

Приёмщик: что он читает, что пишет в журнал, как зовёт проходную

Теперь приёмщик, функция nfq_cb. Она вызывается один раз на каждый пакет, который ядро положило в очередь, и занимает в файле nfqws.c пятьдесят пять строк. Вопрос: что приёмщик достаёт из пакета, что пишет в журнал и как зовёт проходную?

Клерк получает конверт-копию, а к ней приколота бирка. С бирки он снимает номер, метку, длину содержимого и названия комнат, откуда и куда письмо ехало. Всё это приёмщик делает в самом начале, ещё до того, как кому-либо покажет пакет.

Четыре аргумента. Ручка очереди, нужна только чтобы отправить вердикт. Заголовок сообщения, который тело не использует. Пакет в виде структуры библиотеки, из неё достаётся остальное. И блок данных из создания очереди. Возвращает приёмщик результат отправки вердикта, а сам вердикт уходит ядру.

Сначала заголовок пакета. Если его нет, функция возвращает ноль, автор в комментарии пишет, что так не бывает. Из заголовка берётся номер пакета в очереди. По общим сведениям, его присваивает ядро, и по нему ядро узнаёт, к какому пакету относится вердикт. Это не идентификатор в заголовке IP из второго выпуска и не порядковый номер TCP. В опыте t10 номера в очереди шли от одного до одиннадцати. Идентификаторы IP исходящих пакетов клиента на проводе: первый тридцать семь тысяч четыреста семьдесят, последний тридцать семь тысяч четыреста семьдесят пять.

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

Сверим со вторым выпуском. Запрос с двумястами пятьюдесятью байтами данных имеет в нашем журнале len триста два: двадцать байт IP, тридцать два байта TCP и двести пятьдесят данных. Кадр в записи был триста шестнадцать, разница четырнадцать байт, это Ethernet, в очередь он не попадает. Так же у SYN: кадр семьдесят четыре минус четырнадцать, шестьдесят. Простое подтверждение: шестьдесят шесть минус четырнадцать, пятьдесят два.

Проверь себя. В журнале len пятьдесят два. Сколько в пакете байт данных? Допустим, у заголовка TCP, как у наших пакетов после рукопожатия, тридцать два байта. Тогда двадцать плюс тридцать два как раз пятьдесят два, данных нет. В нашем опыте так выглядели простые подтверждения и пакеты закрытия соединения с флагом FIN.

Теперь имена интерфейсов. Ядро знает только номера. Автор оставил комментарий: обычная функция на каждый пакет создавала бы и закрывала сокет, а код держит один сокет на всё время работы, тот самый вспомогательный, и по его словам это экономит процессор в пять-десять раз. Мы этого не измеряли. Нулевой номер или ошибка вызова дают пустое имя. Позже движок по этим именам угадывает направление пакета, когда в памяти программы нет записи о соединении: входное имя непустое, а выходное пустое, значит пакет пришёл из сети.

!wiki-anim-video16-s15-nfq-cb-head.svg

Теперь строка журнала, она стоит в начале каждого пакета. Наш запрос. Поле id равно четыре: третий исходящий пакет клиента, потому что номер два занял ответный SYN-ACK. Поле len триста два. Поле mark восемь нулей, метки нет. Поле ifin: имя пустое, в скобках ноль, входного интерфейса нет, обычно это пакет, который клиент отправляет сам. Поле ifout равно c0, в скобках три: уходит через интерфейс c0, а три это его номер в системе.

Для сравнения пакет номер два: len шестьдесят, ifin равно c0 и три, ifout пусто и ноль. Всё наоборот: пакет пришёл из сети, это ответный SYN-ACK. А mark у каждого из тысячи шестидесяти двух пакетов во всех журналах равен восьми нулям.

!wiki-anim-video16-s15-log-line-fields.svg

Проверь себя. В строке ifin пусто, ifout равно c0. Исходящий пакет или входящий? Исходящий. А если ifin равно c0, а ifout пусто? Входящий.

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

И про суммы. Комментарий автора в начале функции движка: операционная система может положить в очередь пакет с неправильной контрольной суммой, полагаться на неё нельзя. Мы такого в лаборатории не искали. В коде сказано, что Linux передаёт правильные суммы, а исправление суммы в исходном пакете включено только в сборках не для Linux. Как программа считает суммы своих пакетов, разберём в четвёртом выпуске.

Итак, приёмщик читает номер в очереди, метку, байты от заголовка IP и имена интерфейсов, пишет строку packet и вызывает проходную с отдельным буфером. Номер присваивает ядро, он не связан ни с идентификатором IP, ни с номером TCP.

Три штампа: проходная, байт вердикта, слово drop

Теперь что приёмщик делает с ответом движка. Главное правило части: на выходе из проходной всегда один из трёх штампов. Вопрос: как ответ движка превращается в ответ ядру?

У клерка на столе три штампа, PASS, MODIFY и DROP, и на письмо он ставит ровно один. А на полях штампа ещё две маленькие пометки, флажки: они тоже могут стоять, но за штамп не считаются.

Приёмщик вызывает проходную, функцию processPacketData, всего одиннадцать строк. Ей передают адрес метки, имена интерфейсов, байты пакета и длину, буфер для изменённого пакета и адрес его длины. Возвращает она один байт, вердикт. В первой строке проверка метки: если у метки есть бит программы, пакет пропускается без обработки, к этому вернёмся в третьей части. Пока считаем, что проверка не сработала, тогда проходная вызывает движок, dpi_desync_packet, и возвращает его ответ.

Что лежит в байте вердикта? Значение и служебные биты. Ноль это PASS, один MODIFY, два DROP. Число три приёмщик использует как маску: она оставляет два младших бита, а служебные отбрасывает.

Служебные биты в коде называются PRESERVE_NEXT, значение четыре, и NOCSUM, значение восемь, их смысл разберём позже. Допустим, в байте девять: это восемь плюс один, после маски остаётся один, MODIFY. Такое бывает: хитрость с первым пакетом соединения возвращает MODIFY с добавленным битом восемь. А десять, восемь плюс два, после маски два, DROP.

Проверь себя. Какой штамп получится, если в байте двенадцать? Двенадцать это восемь плюс четыре, оба служебных бита и нули в младших разрядах, после маски ноль, PASS. Это арифметика, таких значений в наших журналах не было.

Дальше оператор switch по значению вердикта, в нём два случая из трёх. Первый, MODIFY. Приёмщик пишет pass modified и две длины, исходного и нового пакета, и вызывает nfq_set_verdict2 с шестью аргументами: ручка очереди, номер пакета, вердикт NF_ACCEPT, то есть «принять», метка, длина новых байтов и сами байты из отдельного буфера. Пакет принят, но уйдёт с другими байтами. Второй случай, DROP: журнал drop, вердикт NF_DROP, байтов нет, пакет исчезает.

!wiki-anim-video16-s16-verdict-byte.svg

Ветки PASS нет. Если значение не MODIFY и не DROP, управление выходит из switch и доходит до двух последних строк: журнал pass unmodified и вердикт NF_ACCEPT без новых байтов. Те же строки выполняются, когда содержимое получить не удалось, тогда движок не вызывается. Метка во всех трёх случаях возвращается та же, что пришла: движку передаётся только её значение, так что в коде её никто не меняет. Что ядро делает с этим аргументом, не читали.

Статистика по всей лаборатории. В девяноста двух журналах тысяча шестьдесят два пакета очереди: девятьсот девяносто pass unmodified, двадцать два pass modified и пятьдесят drop. Девятьсот девяносто плюс двадцать два, тысяча двенадцать. Тысяча двенадцать плюс пятьдесят, тысяча шестьдесят два. У каждого пакета ровно одна строка с вердиктом.

Пример MODIFY, опыт h12: клиент отправляет не TLS, а обычный HTTP-запрос. Пакет сто тридцать три байта, из них восемьдесят один запрос: двадцать плюс тридцать два плюс восемьдесят один. Стратегия пишет имя в Host чередующимися большими и малыми буквами: было www.example.com, стало WwW.ExAmPlE.CoM. Длина прежняя: pass modified, len сто тридцать три в сто тридцать три.

Теперь предупреждение: слово drop не значит «данные потеряны». В нашей лаборатории оно встречается как минимум в трёх смыслах. Первый, запрос заменён вставленными частями. Опыт t10: запрос на двести пятьдесят байт получил drop, но программа заранее отправила его двумя частями, сто четыре и сто сорок шесть байт. Проверим: сто четыре плюс сто сорок шесть, двести пятьдесят.

Второй смысл: пакет удержан, пока программа дособирает запрос. Опыт r10: запрос в тысячу четыреста восемьдесят четыре байта в нашем тесте ядро само делит на тысячу четыреста сорок восемь и тридцать шесть. Оба пакета получают drop, а потом программа отправляет их сама, по одному. Посчитаем по проводу клиента: в очередь попало восемь исходящих, два выброшены, два отправлены программой, и на проводе снова восемь.

Третий смысл относится к вердикту стратегии, а не к строке журнала. Когда стратегия выбрасывает самый первый пакет соединения, программа подменяет его, и в журнале вместо drop стоит pass modified. В опыте t40 это SYN длиной шестьдесят: pass modified, шестьдесят в шестьдесят. Как это устроено и зачем, разберём в третьей части. И наоборот, pass unmodified тоже не значит, что ничего не произошло: так пишется и когда скрипт отработал и ничего не изменил, например стратегия pcap, и когда ничего не подошло.

!wiki-anim-video16-s16-three-drops.svg

Проверь себя. В журнале стоит drop. Данные потеряны? Не обязательно, нужно смотреть, что программа отправила сама: в опыте t10 перед строкой drop стоят две строки отправки, а в опыте r10 пакеты удержаны и отправлены позже.

Итак, MODIFY становится NF_ACCEPT с новыми байтами, DROP становится NF_DROP, всё остальное NF_ACCEPT с исходным пакетом, метка прежняя. Выброшенный при этом не значит потерянный.

Один настоящий пакет: вход, разбор, стратегии и вердикт

Теперь всё вместе на одном настоящем пакете. Опыт t10: стратегия multisplit разрезает запрос на сто четыре и сто сорок шесть байт по метке посередине слова example. Что это за стратегия, разберём в десятом выпуске, сейчас важен только путь пакета. Пакет номер четыре, запрос на двести пятьдесят байт, его блок в журнале пройдём по этапам.

Письмо входит в комнату. Клерк принимает его, читает адрес, проверяет печать, читает содержимое и ищет, какие правила на него есть.

Первая строка нам знакома, её печатает приёмщик: packet, id четыре, len триста два, mark восемь нулей, ifin ноль, ifout c0. Вторая от движка, это заголовок IP. От десять точка один точка ноль точка один к десять точка два точка ноль точка два, протокол tcp, время жизни шестьдесят четыре, порты тридцать четыре тысячи пятьсот сорок четыре и четыреста сорок три. Флаги A и P: подтверждение и «протолкнуть данные».

Третья строка про TCP. Len двести пятьдесят и первые байты: один-шесть, ноль-три, ноль-один, ноль-ноль, эф-пять. Это начало записи TLS из второго выпуска: первый байт тип, следующие два версия, последние два длина. Эф-пять в шестнадцатеричной записи это двести сорок пять, прибавим пять байт заголовка записи: двести пятьдесят. Тот же запрос.

!wiki-anim-video16-s17-arrive-lines.svg

Проверь себя. Длина записи двести сорок пять, а len в строке про TCP двести пятьдесят. Противоречие? Нет: длина записи считается без пяти байт самого заголовка записи.

Проходная проверяет метку: восемь нулей, бита программы нет, пакет идёт к движку. Движок разбирает заголовки IP и TCP, как мы делали руками во втором выпуске, и находит профиль, набор настроек для соединения. Профиль взят из кэша: он остался в памяти программы от SYN. Это первый профиль, называется один, noname, то есть без имени, а фильтр на порт четыреста сорок три он получил из параметра filter-tcp. Дальше движок определяет нагрузку: tls_client_hello, первое сообщение клиента в TLS, целое. Версия записи TLS один точка ноль, рукопожатия TLS один точка два, расширение версий называет TLS один точка три.

Движок достаёт имя www.example.com и заново ищет профиль по имени: профиль один найден. Разбор изучим в четвёртом и шестом выпусках, профили в седьмом, память о соединениях в пятом.

Дальше движок для каждой записанной в профиле стратегии сам проверяет диапазон и тип нагрузки, пишет, что нагрузка подходит под фильтр, и вызывает её. Первая, pcap, ничего не меняет, а только записывает пакет в файл, поэтому вердикт остаётся PASS. Вторая, multisplit, сообщает про cutoff. Это отметка внутри Lua: эта стратегия больше не будет вызываться для входящих пакетов этого соединения. Это не перенастройка ядра: пакеты с пятого по одиннадцатый всё равно попадут в очередь. Подробнее в восьмом выпуске.

Потом метка midsld, середина слова example, превращается в позицию сто четыре: номер байта, перед которым запрос будет разрезан.

!wiki-anim-video16-s17-engine-stages.svg

Теперь вставка. Строка sending part один: байты с нулевого по сто третий, длина сто четыре. Сразу за ней rawsend_dissect: size сто пятьдесят шесть, badsum ноль, ifout c0, fwmark ноль-икс четыре и семь нулей. Сто пятьдесят шесть это двадцать байт IP, тридцать два TCP и сто четыре данных. Вторая часть: байты со сто четвёртого по двести сорок девятый, длина сто сорок шесть, size сто девяносто восемь: двадцать плюс тридцать два плюс сто сорок шесть. Про badsum в четвёртом выпуске, про метку в третьей части. Обе части уходят до вердикта по исходному пакету: он всё это время стоит у окошка.

Проверь себя. Если бы метка делила запрос на сто и сто пятьдесят байт, какие были бы size? Двадцать плюс тридцать два плюс сто, сто пятьдесят два, и двадцать плюс тридцать два плюс сто пятьдесят, двести два. Это только арифметика на тех же заголовках, такого прогона у нас не было.

Последняя строка блока: packet id четыре, drop. Стратегия вернула DROP, и ядру ушёл NF_DROP. Данные не пропали: те же двести пятьдесят байт ушли двумя частями, и сервер в нашем тесте собрал из них исходный запрос. Для сравнения опыт t01: стратегия pass только пишет слово pass. Блок тот же до самого разбора, потом строка LUA pass и сразу pass unmodified, строк отправки нет, исходный пакет уходит как был.

!wiki-anim-video16-s17-parts-and-verdict.svg

Итак, движок узнал в пакете номер четыре запрос TLS с именем www.example.com, стратегия multisplit посчитала позицию сто четыре, отправила две вставки по сто четыре и сто сорок шесть байт и вернула DROP, а приёмщик передал ядру NF_DROP.

Счёт пакетов и время одного прогона

Последний вопрос про этот пакет: сходится ли счёт? Сколько пакетов прошло через очередь, сколько на проводе клиента и откуда берётся разница?

Пройдём одиннадцать пакетов очереди опыта t10 по группам. Рукопожатие. Номер один: исходящий SYN, длина шестьдесят. Номер два: входящий SYN-ACK от сервера, тоже шестьдесят. Номер три: исходящий ACK, пятьдесят два. Все три pass unmodified. Номер четыре это запрос, длина триста два, он получил drop, и две вставленные части в очередь не попали вообще: для них в журнале очереди нет ни одной строки packet. Почему вставки не вернулись в очередь, разберём в третьей части, в разговоре о метке.

Ответы сервера. Номер пять: входящее подтверждение первой части, пятьдесят два. Номер шесть: входящий короткий ответ в четырнадцать байт, длина шестьдесят шесть. Номер семь: входящее подтверждение второй части, пятьдесят два. Закрытие. Номер восемь: исходящий ACK. Девять: исходящий с флагами A и F. Десять: входящий такой же. Одиннадцать: исходящий ACK. Все они пятьдесят два байта и все pass unmodified.

Теперь счёт. В очереди одиннадцать пакетов. Исходящих шесть: номера один, три, четыре, восемь, девять и одиннадцать. Входящих пять: два, пять, шесть, семь и десять. Шесть плюс пять, одиннадцать. На проводе клиента двенадцать: семь исходящих и пять входящих. Откуда семь? Исходящих в очереди шесть, минус один выброшенный, плюс две вставленные части: шесть минус один плюс два, семь. Входящих пять, и они не меняются: в этом опыте входящие никто не выбрасывает и не добавляет.

!wiki-anim-video16-s18-queue-vs-wire.svg

Проверь себя. Сколько пакетов пройдёт через очередь и по проводу клиента в опыте t01, где стратегия pass ничего не вставляет? Десять: шесть исходящих и четыре входящих, и столько же на проводе. А почему в t10 входящим пакетом больше? Потому что сервер подтвердил каждую из двух частей отдельным пакетом, это номера пять и семь.

И время. Прогоны, записанные раньше, писали журнал и файл с пакетами на медленный диск; какой из двух файлов виноват, не разделяли. Цифры в них в разных замерах оказались от двадцати до двухсот с лишним раз больше, чем при повторе, по нашему объяснению из-за диска. Поэтому t10 повторили на локальном диске. Один такой повтор: стратегия pcap записала запрос через ноль целых пятьсот двадцать тысячных миллисекунды после первого SYN на проводе. Первая часть появилась на проводе ещё через ноль целых шестьсот девяносто шесть тысячных, вторая через ноль целых четыреста восемь тысячных после первой. Сложим: от записи запроса до выхода второй части одна целая сто четыре тысячных миллисекунды.

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

!wiki-anim-video16-s18-time-one-run.svg

Итак, очередь видела одиннадцать пакетов, провод клиента двенадцать, и разница это ровно одна выброшенная и две вставленные части.

Итог части: что мы знаем и чего не проверяли

Подведём итог второй части. Мы вошли в программу и прошли по ней вслед за пакетом. Соберём, что узнали.

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

!wiki-anim-video16-s19-program-path.svg

У пакета в программе шесть выученных полей: номер в очереди, длина от заголовка IP, метка, входной интерфейс, выходной интерфейс и вердикт. Для нашего запроса: номер четыре, длина триста два, метка восемь нулей, входного интерфейса нет, выходной c0, вердикт drop. Ключевые числа: очередь двести, в очереди до четырёх тысяч девяноста шести пакетов. И тысяча шестьдесят два пакета по всем журналам: девятьсот девяносто пропущено, двадцать два изменено, пятьдесят выброшено.

Теперь честно о том, чего мы не проверяли. Внутрь библиотеки очереди мы не заглядывали, читали только заголовки. Мы не проверяли, что ядро делает при переполнении очереди и как работает fail-open. Мы не запускали программу с неправильным номером очереди и не останавливали её посреди работающего скрипта. Цены на настоящем оборудовании у нас нет, только оценка на одном компьютере. А про настоящее ТСПУ мы по-прежнему ничего не знаем, у нас только модель.

!wiki-anim-video16-s19-card-and-gaps.svg

Что дальше? В третьей части обратная сторона: как программа сама отправляет пакеты через сырые сокеты, что за метка стоит на этих пакетах и почему они не возвращаются в очередь, почему вставленные пакеты не тратят предел в двенадцать, чем отличаются другие системы и что происходит с первым пакетом соединения.

Итак, мы знаем путь пакета через программу и увидели его на одном настоящем запросе. Осталось понять, как программа отправляет свои собственные пакеты.

Повтор и вставка пакета: сырой сокет

Это третья часть третьего выпуска. Её можно смотреть отдельно от первых двух, поэтому сначала короткий повтор.

Представьте комнату с окошком. Окошко это правило ядра: оно выбирает часть пакетов и кладёт их на ленту, это очередь, в нашей лаборатории номер двести. На другом конце сидит клерк, наша программа nfqws2. Он берёт пакеты по одному и ставит штамп: PASS, пропустить как есть, MODIFY, пропустить с заменой содержимого, или DROP, выбросить. Правило лаборатории берёт только начало разговора: по двенадцать первых пакетов в каждую сторону, для TCP на портах восемьдесят и четыреста сорок три и для UDP на порту четыреста сорок три.

В опыте t10 запрос на двести пятьдесят байт получил в очереди номер четыре и штамп DROP, но данные не пропали: вместо запроса с клиента ушли две вставки, в сто четыре и в сто сорок шесть байт. Обе отправила сама программа. Сегодня разберём, как.

Проверьте счёт. Через очередь прошло шесть исходящих пакетов клиента. Один выброшен, две вставки добавились: шесть минус один плюс два, семь. Входящих пять, в этом опыте их не меняют. На проводе клиента двенадцать пакетов, а очередь видела одиннадцать.

У части пять вопросов. Как программа сама отправляет пакеты? Что мешает вставкам вернуться к окошку и пойти по кругу? Тратят ли они двенадцать пакетов предела? Что будет, если выбросить самый первый пакет соединения? И как всё это выглядит на BSD и на Windows?

Начнём с первого. Игрушечный пример, не данные лаборатории. Обычное письмо вы приносите на почту только с вложением, а конверт, адреса и номер оформляет отделение. Но можно принести конверт, оформленный целиком, и тогда на нём всё решаете вы.

С пакетами так же. По общим сведениям о Linux, обычный сокет, через который работает браузер, принимает от программы только данные, а заголовки IP и TCP строит ядро. Сырой сокет принимает пакет целиком, начиная с заголовка IP, который собрала сама программа. В коде сокет создаётся с типом SOCK_RAW и протоколом IPPROTO_RAW, а в отправку уходит буфер, который начинается с заголовка IP. Зачем это нашей программе? Это наше объяснение, не цитата из кода: ей нужны пакеты с полями, которые выбрала она, например часть запроса с нужным порядковым номером, а обычный сокет поставил бы свои значения.

!wiki-anim-video16-s20-ordinary-vs-raw.svg

Пакет, который программа собрала целиком и отправила сама, мы называем вставкой. Первая вставка t10 занимает сто пятьдесят шесть байт: двадцать байт заголовка IP, тридцать два байта заголовка TCP и сто четыре байта данных, начало запроса. Весь буфер готов ещё до отправки. Вставки не приходят из очереди, окошко их не выбирало.

Проверь себя. Кто выбрал порядковый номер TCP в такой вставке, программа или ядро? Программа: номер лежит в заголовке TCP, а заголовок уже внутри готового буфера.

!wiki-anim-video16-s20-insert-buffer.svg

Итак, вставка это пакет, который программа собрала целиком, вместе с заголовками, и отправила через сырой сокет. Дальше пройдём её путь от стратегии на Lua до провода.

Путь вставки: от стратегии до повторителя

Вопрос этой сцены: через какие функции вставка идёт от стратегии до отправщика?

Игрушечная картинка, не данные лаборатории: конвейер из трёх рук. Первая рука, стратегия на Lua, заполняет листок: какие у пакета поля и какие данные. Вторая превращает листок в настоящие байты и узнаёт адрес получателя. Третья отправляет. В программе тоже три таких участника.

Стратегия, например multisplit, сама режет данные на части и каждую отдаёт цепочке вспомогательных функций Lua: они собирают пакет из куска данных, подставляют данные и порядковый номер и при необходимости дробят по размеру сегмента. Последняя вызывает отправку по таблице, в коде rawsend_dissect: ей передают таблицу с полями пакета, о таблице поговорим в следующем выпуске. За этим именем стоит функция на C, переходник из Lua в C, luacall_rawsend_dissect. Он делает шесть шагов.

!wiki-anim-video16-s21-hands-and-chain.svg

Шаг первый: переходник читает настройки отправки, то есть сколько раз повторить (поле repeats), какой интерфейс (ifout) и какая метка (fwmark), и параметры сборки, например badsum, «сделать неправильную контрольную сумму». Правило для повторов такое: ноль заменяется на один, отрицательное число это ошибка «отрицательное число повторов», а если настроек нет вовсе, повтор один, метка программная, интерфейс не задан.

Проверь себя. Стратегия передала повторов ноль: сколько раз уйдёт пакет? Один. Передала два? Два. Минус один? Ни разу, будет ошибка. А если настроек нет? Один раз.

Шаг второй: из таблицы строятся настоящие байты, как именно, разберём в следующем выпуске; если таблица неправильная, ошибка «неверные данные таблицы». Шаг третий: из готового заголовка IP достаётся адрес назначения; если заголовок не распознан ни как IPv4, ни как IPv6, ошибка «неверный заголовок IP».

Шаг четвёртый: строка в журнале. Для первой вставки t10 в ней повторов один, размер сто пятьдесят шесть, порча суммы выключена, интерфейс c0, метка ноль-икс четыре и семь нулей. В журнале эти поля подписаны repeats, size, badsum, ifout и fwmark. Во второй строке размер сто девяносто восемь.

Шаг пятый: вызов повторителя, в коде rawsend_rep. Это цикл: отправить те же байты столько раз, сколько просили, и остановиться на первой неудаче. Второй пример, опыт q12, QUIC поверх UDP. Повторов два, в журнале одна строка с размером шестьсот сорок восемь, а на проводе клиента две одинаковые датаграммы с разницей в шесть сотых миллисекунды. В каждой двадцать байт IP, восемь байт UDP и шестьсот двадцать байт данных. Исходная датаграмма уходит позже.

Шаг шестой: переходник возвращает в Lua «получилось» или «не получилось». Если не получилось, решает стратегия: multisplit возвращает PASS, исходный пакет идёт дальше как есть, а части, которые уже ушли, остаются на проводе. Это по коду, мы такого не запускали. Есть ещё Lua-функция с тем же именем rawsend, не путайте её с функцией на C из сцены про отправщик: она принимает не таблицу, а готовую строку байт, в остальном то же самое.

!wiki-anim-video16-s21-six-steps.svg

Итак, путь вставки: стратегия, вспомогательные функции Lua, отправка по таблице, переходник на C, повторитель, и дальше отправщик сырых пакетов.

Отправщик сырых пакетов: сокеты, пять строк и синхронность

Вопрос этой сцены: что делает отправщик сырых пакетов, в коде rawsend, который кладёт пакет на провод, и в какой момент вставка действительно уходит? Сначала посмотрим, что готовится заранее.

Игрушечная картинка, не данные лаборатории. Курьер не открывает рабочее место заново для каждой посылки: два места, для двух видов адресов, готовы с начала смены, а печать на ящик он ставит перед каждой посылкой. Так и в программе. Подготовка сокетов, в коде rawsend_preinit, при запуске создаёт два сырых сокета, для IPv4 и для IPv6. IPv4 обязателен: без него программа не запустится. IPv6 может не создаться только в одном случае, когда система сообщает, что такие адреса не поддерживаются, как на компьютерах с выключенным IPv6.

Реальный пример, журнал t10. Для каждого из двух сокетов запрошено две тысячи сорок восемь байт на приём. На отправку запрошено тридцать две тысячи семьсот шестьдесят восемь, в коде это половина от шестидесяти четырёх килобайт. Ядро показывает вдвое больше. Почему удвоение, это знание ядра, мы не проверяли, как и остальные настройки сокета. Важно одно: метки среди них нет.

!wiki-anim-video16-s22-preinit-and-buffers.svg

Теперь rawsend по строкам. Первая: взять сокет нужного семейства, а если его ещё нет, создать; не вышло, значит, «не получилось». Вторая: поставить метку, это делает установщик метки, в коде set_socket_fwmark, и делает перед каждой отправкой, а не один раз при создании. Что за метка, разберём в сцене про петлю. Третья: скопировать адрес назначения и для IPv6 обнулить в копии порт, иначе, по комментарию автора, Linux вернёт ошибку «недопустимый аргумент», EINVAL.

Проверь себя. Отправляются подряд две вставки, и для одной нужна одна метка, для другой другая. Нужно ли открывать два сокета для IPv4? Нет: метка меняется на том же сокете перед каждой отправкой.

Четвёртая строка работает не всегда. Только если включена привязка к интерфейсу, параметр bind-fix, для этого семейства адресов, сокет привязывается к интерфейсу из аргумента: по комментарию автора, при правилах маршрутизации Linux может выбрать интерфейс неправильно. В нашей лаборатории оба bind-fix выключены, в журнале два нуля, значит, интерфейс c0 из строки журнала на отправку не влияет. Это вывод из чтения кода, отдельно мы его не проверяли. Пятая строка: сам вызов отправки sendto в цикле; повтор только если вызов прервал сигнал, любая другая ошибка пишется в журнал, и возвращается «не получилось». Один вызов, один пакет.

Отсюда главное свойство для t10: отправка синхронная. Когда rawsend вернулась, пакет уже передан ядру, а стратегия идёт дальше. Исходный пакет при этом ещё не отпущен, его судьбу решит вердикт, когда стратегия закончится. Поэтому в журнале t10 порядок такой: вставка, вставка и только потом «packet id four drop».

Размеры: сто пятьдесят шесть это двадцать плюс тридцать два плюс сто четыре, сто девяносто восемь это двадцать плюс тридцать два плюс сто сорок шесть. Тридцать два байта TCP мы вывели из записи трафика: заголовок длиннее минимального из-за параметров.

!wiki-anim-video16-s22-rawsend-lines-and-order.svg

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

Ловушка петли, метка и её значение

Теперь вопрос, который вы могли задать себе в прошлой сцене. Отправщик отправляет пакеты через то же ядро. Значит, вставка проедет мимо того же окошка?

Игрушечный пример, цифры выдуманные. Клерк режет письмо на две части. Обе едут мимо того же окошка, окошко кладёт обе к клерку, клерк режет каждую снова: два, четыре, восемь, и так по кругу. Это называется петлёй. В документации сказано прямо: без фильтрации по метке система может зависнуть или работать неправильно.

Что такое метка? По общим сведениям о Linux, это число, которое ядро хранит рядом с пакетом, пока тот внутри компьютера. В байты пакета оно не входит и на провод не уходит. В нашем лабораторном правиле стоит условие: meta mark и ноль-икс четыре и семь нулей равно нулю. По-человечески: к клерку пускаем только пакеты, у которых этого бита нет.

Два примера. Сначала правило: побитовое И оставляет только те биты, которые стоят в обоих числах. Обычный пакет, метка восемь нулей: побитовое И с ноль-икс четыре и семь нулей даёт ноль, условие выполнено, пакет идёт в очередь. Помеченный, метка ноль-икс четыре и семь нулей: И даёт само ноль-икс четыре и семь нулей, это не ноль, условие не выполнено, в очередь пакет не идёт. В метке программы стоит один бит, тридцатый, если нумеровать с нуля. Обычный пакет идёт в очередь, если остальные условия правила тоже выполнены.

!wiki-anim-video16-s23-loop-and-mark-bits.svg

Как метка попадает на пакет? Установщик метки просит ядро пометить сокет: вызывает setsockopt с параметром SO_MARK на сыром сокете. Так в Linux ставят метку на все пакеты, уходящие через сокет, это общее знание.

Теперь какое значение ставится. Отправщик ничего не добавляет, ставит ровно переданное. Бит добавляет переходник из Lua в C: он берёт поле fwmark из настроек и объединяет побитовым ИЛИ с меткой программы. ИЛИ значит: бит стоит в результате, если он стоит хотя бы в одном из двух чисел.

Метка программы на Linux по умолчанию ноль-икс четыре и семь нулей, её можно изменить параметром запуска fwmark, но ноль запрещён. Поэтому бит программы стоит всегда; автор написал в Lua в комментарии: бит метки будет установлен безусловно.

Возьмём t10 по шагам. Запрос стоит в очереди с меткой из восьми нулей. Стандартная вспомогательная функция Lua кладёт эту метку в поле fwmark. Переходник считает ноль ИЛИ ноль-икс четыре и семь нулей и получает ноль-икс четыре и семь нулей. Отправщик ставит значение на сокет, и в журнале у обеих вставок fwmark ноль-икс четыре и семь нулей.

Второй пример, арифметика по коду, в лаборатории такого значения не было. Допустим, стратегия передала ноль-икс два и семь нулей: так в готовых скриптах запуска помечаются пакеты режима после трансляции адресов. Ноль-икс два и семь нулей ИЛИ ноль-икс четыре и семь нулей даёт ноль-икс шесть и семь нулей: оба бита, бит программы на месте.

Проверь себя. Стратегия передала в поле fwmark ноль: что в журнале? Ноль-икс четыре и семь нулей. А если параметром запуска изменить метку программы, правило ядра увидит изменённый бит? Только если то же значение стоит и в правиле: так выходит из чтения кода, мы такого не запускали. Готовые скрипты запуска передают одно значение и в правила, и в программу.

!wiki-anim-video16-s23-mark-value-chain.svg

Итак, вставка могла бы пойти по кругу, потому что отправляется через то же ядро. Защита построена на метке: у вставок, которые отправляют стандартные вспомогательные функции Lua, она равна метке исходного пакета, объединённой побитовым ИЛИ с меткой программы, по умолчанию ноль-икс четыре и семь нулей, и правило ядра помеченные пакеты в очередь не берёт.

Три преграды: правило, notrack и проверка в проходной

Вопрос этой сцены: какие три преграды стоят на пути помеченного пакета? Игрушечная картинка: помеченное письмо можно остановить в трёх местах, у окошка, у входа в хранилище записей и у стола клерка, и каждое место проверяет своё.

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

Вторая: отдельная цепочка predefrag на точке output с приоритетом минус четыреста один. В правиле из документации и в нашей лаборатории она отключает отслеживание для пакетов с битом программы; слово для этого notrack, «не отслеживать». По общим сведениям, такие пакеты не попадают в таблицу отслеживания соединений в ядре, в conntrack. Слово conntrack относится только к ядру, не путайте его с памятью программы о соединениях, о ней будет пятый выпуск.

Зачем это? Документация объясняет заботой о NAT: пакеты nfqws2 не должны проходить проверки NAT, иначе стандартные правила могут их выбросить. А почему notrack помогает ещё и от петли, это наш вывод, не слова документации: пакет без записи в таблице не проходит условие про счётчик пакетов соединения, и правило очереди с ним не совпадёт. В нашем тесте на UDP, о нём в сцене с лабораторными данными, одного notrack хватило. Где эта точка стоит среди других точек ядра, это знание ядра, мы не проверяли.

В правилах готовых скриптов notrack ставится не для всех помеченных пакетов, а для части: с битом режима после трансляции адресов, для фрагментов и по условию на флаги TCP.

!wiki-anim-video16-s24-three-gates.svg

Третья преграда уже в самой программе: это первые строки проходной, функции processPacketData, через которую идёт каждый пакет из очереди. Условие: метка пакета, объединённая побитовым И с меткой программы, не равна нулю, то есть у них есть общий бит. Тогда в журнал пишется «ignoring generated packet», игнорируем сгенерированный пакет, и возвращается PASS. Иначе пакет идёт к движку.

Примеры. Метка ноль-икс четыре и семь нулей: И с меткой программы даёт не ноль, пакет игнорируется. Метка восемь нулей: ноль, пакет идёт к движку. Только бит режима после трансляции, ноль-икс два и семь нулей: с битом программы ноль, к движку. Оба бита, ноль-икс шесть и семь нулей: не ноль, игнорируется. Это арифметика по двум строкам кода, таких меток в лаборатории не было.

И две оговорки. Проверка идёт по И, а не на равенство: хватает одного общего бита. И она есть только в сборке для Linux, в вариантах для BSD и Windows её нет.

!wiki-anim-video16-s24-process-packet-data.svg

Итак, преград три. Правило ядра не берёт помеченные пакеты в очередь. Цепочка predefrag в нашем правиле отключает для них отслеживание. А проходная пропускает помеченный пакет без обработки, если у него есть общий бит с меткой программы.

Что показала лаборатория: кто остановил вставки и тратят ли они предел

Вопрос этой сцены: работает ли защита от петли, какая из трёх преград её обеспечивает и тратят ли вставки предел в двенадцать пакетов?

Игрушечный пример. Среди писем у стола клерка нет ни одного с наклейкой «своё». Хорошо ли работает сторож у стола клерка? Не знаем: сторожа у окошка и у входа могли остановить все письма раньше.

Теперь числа. Во всех девяноста двух журналах лаборатории тысяча шестьдесят два пакета очереди, и у каждого метка из восьми нулей. Каждая строка о вставке содержит метку ноль-икс четыре и семь нулей. Строки «игнорируем сгенерированный пакет» нет ни в одном журнале.

Один опыт, t10. В нём одиннадцать записей очереди, номера с первого по одиннадцатый без пропусков. Две вставки ушли, пока шла обработка пакета номер четыре, а следующая запись идёт под номером пять. Вернись вставки в очередь, они заняли бы номера пять и шесть. Но пятый номер это подтверждение сервера.

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

!wiki-anim-video16-s25-t10-ledger.svg

Поэтому мы поставили отдельный тест: изолированные пространства имён, пакеты UDP, то же ядро и та же сборка, программа вставляет две поддельные копии пакета. Только проверка метки в правиле: вставки в очередь не попали. Только notrack: тоже не попали. Убрали и то и другое: помеченные пакеты дошли до очереди, программа записала метку ноль-икс четыре и семь нулей, потом «игнорируем сгенерированный пакет» и «пропущен без изменений», пакеты доставлены, повторной вставки не было. Выходит, в нашем тесте каждой из двух первых преград хватает одной, проверка в проходной срабатывает, и приёмщик читает ту метку, которую поставил отправщик. Оговорка: это UDP, на TCP мы опыт не повторяли.

Проверь себя. Убрать только третью преграду: изменится ли что-то в записанных прогонах? Нет, помеченные пакеты до неё не доходили. Такого опыта мы не ставили, это рассуждение по журналам.

И два замечания по коду, мы их не запускали. Если бит в правилах ядра не совпадает с параметром fwmark программы, вставки попадут в очередь, третья преграда их пропустит, но каждый раз это круг через программу. И есть одно место с меткой ноль: сброс клиенту, который повторяет запрос, в логике автоматических списков доменов. В лаборатории оно не срабатывало, а вернётся ли такой пакет в очередь, зависит от правил.

!wiki-anim-video16-s25-loop-test.svg

Теперь вопрос о пределе: тратят ли его вставки? Напомним, считаются первые двенадцать пакетов каждого направления, SYN это номер один, у каждой стороны счёт свой. Игрушка: у входа турникет с табло на двенадцать человек, курьер проходит через служебную дверь с наклейкой «своё» и деления не зажигает. Проверим.

Опыт t20. Стратегия вставляет поддельные пакеты без обманных приёмов: сервер принимает их за настоящие данные, клиент ответа так и не получает и повторяет отправку, поток получается длинным. На проводе клиента тринадцать собственных исходящих пакетов и шесть вставленных поддельных, по шестьсот восемьдесят байт данных. В очередь попало ровно двенадцать собственных. Тринадцать минус двенадцать: один пакет, подтверждение, вышел за предел.

Теперь главное. Когда в очередь пришёл двенадцатый собственный пакет, пять поддельных уже были вставлены. Если бы они считались, счётчик показал бы двенадцать плюс пять, семнадцать, и двенадцатый вышел бы за предел. Но правило сработало, значит, вставки предел не потратили. Это согласуется с notrack, но по косвенным признакам: по общим сведениям, пакеты, к которым правило применило notrack, не попадают в таблицу соединений ядра, а ограничитель считает именно её.

!wiki-anim-video16-s25-t20-limit.svg

Прямая проверка из того же теста, на пакетах UDP: один обычный пакет, пять вставленных сырым сокетом с меткой, одиннадцать обычных. Сначала посчитаем сами. Если вставки считаются, после шести пакетов счётчик показывает шесть, остаётся шесть, правило сработает на шести из одиннадцати обычных, и всего один плюс шесть, семь. Если не считаются, правило сработает на всех: один плюс одиннадцать, двенадцать.

В основных опытах предел на UDP не проверялся, в нашем тесте проверен: с notrack двенадцать срабатываний, без него семь. Без notrack вставки съедали предел. Учитывается ли выброшенный пакет, мы не проверяли: в t20 ничего не выбрасывалось.

!wiki-anim-video16-s25-udp-budget.svg

Проверь себя. Результат семь: вставки считались или нет? Считались. А двенадцать? Нет.

Итак, в лаборатории ни один помеченный пакет не дошёл до очереди, но какая из трёх преград его остановила, сказать нельзя: стояли все три. Наш тест на UDP показал, что каждой из двух первых преград хватает одной, а третья срабатывает, если убрать первые две. И вставки, судя по t20 и этому тесту, предел в двенадцать пакетов не расходуют: двенадцать срабатываний с notrack и семь без него.

Хитрость с первым пакетом: опыты t40 и t10

Вопрос этой сцены: что делает программа, когда стратегия выбрасывает самый первый пакет соединения, и чем отличается t10, где выброшен не первый?

Игрушечная картинка, наша иллюстрация к комментарию автора, а не проверенный факт. Ядро заводит запись о разговоре по первому пакету; если изъять его совсем, по словам автора, запись может развалиться. Тогда клерк подменяет первый пакет нарочно испорченной копией: по комментарию автора, у неё недопустимый протокол и малое время жизни. Что с ней происходит дальше, мы увидим в опыте.

Теперь реальный код. В конце обработки TCP-пакета в движке стоит строка: если пакет идёт от нашей стороны соединения и стратегия вернула DROP, вызвать хитрость с первым пакетом, в коде ct_new_postnat_fix; иначе вернуть вердикт как есть. То есть только для нашего направления и только для DROP, потому что, по нашему пониманию, DROP единственный штамп, при котором пакет исчезает. Такая же строка есть и в функции для UDP.

!wiki-anim-video16-s26-toy-call-site.svg

Условие внутри состоит из двух частей, достаточно любой. Первая: запись о соединении в памяти программы есть, и счётчик пакетов нашей стороны равен единице, то есть это первый пакет, который программа видела от этой стороны; в режиме сервера берётся счётчик стороны сервера. Про счётчики подробно в пятом выпуске. Вторая: записи нет вовсе, и пакет должен быть SYN или SYN-ACK.

Если условие выполнено и пакет помещается в буфер, функция копирует пакет и меняет два поля. Для IPv4 протокол в заголовке IP становится двести пятьдесят пять, а время жизни единица; для IPv6 то же с полями следующего заголовка и предела переходов. Возвращается MODIFY с флагом «не пересчитывать суммы». Автор пишет, что это, вероятно, делает неверной и контрольную сумму заголовка IPv4.

Если пакет не помещается, в журнал идёт ошибка «обходной путь применить нельзя» и пакет выбрасывается; если условие не выполнено, тоже обычный DROP. Всё это только в сборке для Linux.

Зачем это? Единственное объяснение в коде это комментарий автора: если правила стоят после перевода адресов, выброс самого первого пакета приведёт к разрушению записи о соединении в таблице ядра. Это его слова, мы этого не проверяли и не утверждаем.

!wiki-anim-video16-s26-postnat-walk.svg

Возьмём опыт t40, со стратегией syndata; её разберём в выпуске про хитрости рукопожатия. Первый пакет соединения, SYN длиной шестьдесят байт: двадцать байт IP и сорок байт TCP с параметрами. Стратегия вставляет другой SYN с шестнадцатью байтами данных, длина семьдесят шесть, и возвращает DROP для исходного. Журнал, четыре строки. Пакет номер один, длина шестьдесят. Вставка по таблице, размер семьдесят шесть, метка ноль-икс четыре и семь нулей. Запись о применении обходного пути. И запись «пропущен изменённым, длина шестьдесят в шестьдесят»: стратегия вернула DROP, а приёмщик отдал ядру изменённый пакет той же длины, ведь копируется весь пакет и меняются два поля.

На проводе клиента сначала вышел вставленный SYN, через три целых семь десятых миллисекунды исходный пакет, уже не как TCP SYN, а как пакет IPv4 с протоколом двести пятьдесят пять и временем жизни один. На входе роутера он записан, а на втором интерфейсе роутера и на сервере его нет. Почему, мы не знаем. Известно два факта: у пакета неверная контрольная сумма заголовка IP, и сообщения об ошибке на клиент не пришло. Причину они не показывают, поэтому мы не говорим, что его остановило время жизни, равное единице.

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

!wiki-anim-video16-s26-t40-log-wire.svg

Для сравнения t10: выброшен запрос, но не первый. Номера очереди: один, SYN клиента; два, SYN-ACK сервера; три, подтверждение клиента; четыре, запрос. Счётчик клиента: SYN первый, подтверждение второе, запрос третий. Он равен трём, условие «равен единице» не выполнено, приёмщик получает обычный DROP, в журнале пакет номер четыре и слово drop. Номер четыре и счётчик три различаются потому, что номер два в общей очереди занял ответ сервера. Проверь себя: выброшено подтверждение клиента, хитрость сработает? Нет, счётчик два. А SYN клиента? Да, счётчик единица.

В нашем тесте хитрость сработала и в трёх опытах на стороне сервера, там первым идёт SYN-ACK: строка про обходной путь есть в четырёх записанных прогонах, в t40 и этих трёх.

!wiki-anim-video16-s26-t10-counter.svg

Итак, слово drop в журнале бывает разным. В t10 выброшенный запрос заменили двумя вставками. В t40 выброшенный первый пакет заменили негодной копией, по-настоящему он не выбрасывался. Куда делась копия, мы не выяснили, а причина подмены только в комментарии автора.

Другие платформы: BSD, Windows и что у трёх вариантов общего

Вопрос этой сцены: как выглядят другие системы и что у трёх вариантов программы общего? Сразу оговорка: код вариантов для BSD и Windows мы прочитали, но не запускали, так что это чтение кода, а не наблюдение.

Какой вариант получится, решает сборка. В функции main три ветки, остаётся одна: для Linux главный цикл nfq_main и программа nfqws2, для BSD цикл dvt_main и программа dvtws2, для Windows цикл win_main и программа winws2. Неизвестная система останавливает сборку ошибкой.

Начнём с BSD, то есть FreeBSD и OpenBSD. Игрушечная картинка: вместо ленты почтовая ячейка. Ядро кладёт пакеты в ячейку, программа забирает, и чтобы пакет продолжил путь, его нужно положить обратно. В коде это divert-сокет. Очереди nfqueue там нет: программа открывает divert-сокет и привязывает его к порту из параметра port.

!wiki-anim-video16-s27-pick-and-paths.svg

Нужные пакеты в этот порт направляют пакетные фильтры ядра. По документации, на FreeBSD для этого берут ipfw, а pf там из-за защиты от петли на практике не годится; на OpenBSD используют только pf, и его защита от петли, по документации, работает.

Цикл ждёт вызовом select и читает вызовом recvfrom. Дальше знакомое: пакет идёт в ту же проходную с нулевой меткой. Направление определяется по адресу источника: по комментарию автора, у входящего он выставлен в адрес интерфейса, у исходящего равен нулю.

Вердикта для ядра здесь нет, поэтому три штампа означают действия с сокетом. PASS это отправить пакет обратно в тот же сокет, в журнале запись о возврате без изменений. MODIFY это отправить новые байты, в журнале запись о возврате с изменением и две длины. DROP это ничего не отправлять и только записать drop. Проверь себя: что на BSD значит выбросить пакет? Ничего не отправлять: пакет в ядро не возвращается. А если отправка обратно не удалась? Ошибка возврата пакета, а при частичной отправке сообщение, что не все данные возвращены.

!wiki-anim-video16-s27-bsd-divert-loop.svg

Собственные пакеты на BSD, по комментарию автора, тоже идут через divert-сокет: FreeBSD не разрешает сырые заголовки IP для IPv6, а OpenBSD не разрешает отправлять сырые кадры TCP. Метка там другая настройка сокета, SO_USER_COOKIE, по умолчанию пятьсот двенадцать там, где параметр определён. В правилах ipfw из документации есть условие not diverted, но какую роль в защите от петли играет SO_USER_COOKIE, в проверенных нами файлах не сказано, и мы этого не утверждаем.

Теперь Windows. Вместо очереди драйвер WinDivert, программа открывает его с фильтром, строкой условий. Первое условие «не impostor», по-русски «не самозванец». Что этот флаг значит внутри WinDivert, в исходниках проекта не описано, поэтому говорим только так: фильтр такие пакеты исключает.

Цикл получает пакеты пачками, до ста двадцати восьми за раз, и режет пачку по длине из заголовка IP. Если флаг impostor всё же стоит, цикл пропускает пакет сразу, пишет об этом в журнал и проходной не передаёт; это вторая проверка того же флага. У остальных метка ноль, они идут в проходную. Вердикты, как на BSD, это действия: PASS и MODIFY возвращают пакет вызовом windivert_send, DROP ничего не делает.

Собственные пакеты на Windows отправляет совсем другая функция: она описывает пакет структурой для WinDivert, берёт интерфейс из имени «номер точка подномер», а без имени использует интерфейс один, по комментарию автора обычно петлевой, и вызывает windivert_send. Параметр fwmark у неё есть, но в теле не используется; в документации сказано, что для Windows правильный ifout обязателен. Проверь себя: защищает ли от петли на Windows метка? Нет, метки там нет. В коде есть фильтр «не impostor» и повторная проверка флага в цикле, но что именно они предотвращают, в проекте не описано.

!wiki-anim-video16-s27-windows-loop.svg

Что общего у трёх вариантов? В середине один код: проходная, движок и вердикты те же. Различается способ вернуть пакет: на Linux вердикт ядру, на BSD и Windows отправка обратно.

И последнее, про предел в двенадцать пакетов. В документации сказано: ipfw, pf и WinDivert не умеют ограничивать по номеру пакета в соединении. Если перехватывать порт целиком, весь поток этого направления идёт в программу, а это нагрузка на процессор. Для Windows документация советует писать собственные фильтры WinDivert, которые хотя бы частично проверяют тип нагрузки, а остальное досеивает winws2. Это слова документации, мы их не проверяли.

!wiki-anim-video16-s27-three-compare.svg

Итак, на BSD вместо очереди divert-сокет, на Windows драйвер WinDivert с пачками пакетов, и в обоих случаях пропустить значит вернуть пакет обратно. Метки в проходной там нет; на Windows есть фильтр и проверка флага impostor, а их роль в защите от петли мы не выяснили. Всё это мы знаем только по коду.

Итог части и всего выпуска: что мы знаем и что дальше

Соберём всё в одну картину, сначала эту часть. Вставка это пакет, который программа собрала целиком и отправила через сырой сокет, синхронно, поэтому в t10 обе вставки ушли раньше, чем исходный запрос получил вердикт. Метка на вставке, которую отправляют стандартные помощники Lua, это метка исходного пакета, объединённая побитовым ИЛИ с меткой программы, по умолчанию на Linux ноль-икс четыре и семь нулей. От петли защищают три преграды: правило ядра, notrack в цепочке predefrag и проходная в программе. Вставки, судя по t20 и нашему тесту, предел в двенадцать пакетов не расходуют.

Если на Linux стратегия выбрасывает самый первый пакет соединения, программа подменяет его негодной копией; почему, сказано только в комментарии автора, а что стало с соединением в t40, мы не выяснили. На BSD и Windows механизм другой, и о нём мы знаем только по коду.

Проверим на одном примере, вставка номер один из t10. Метка? Ноль-икс четыре и семь нулей. Размер? Сто пятьдесят шесть байт. Попадёт ли в очередь? Нет, хотя какая именно преграда её остановила, по журналам не видно. Потратит ли предел? Судя по t20, нет.

!wiki-anim-video16-s28-part-checklist.svg

Теперь итог всего выпуска. Правило ядра отправляет первые двенадцать пакетов каждого направления в очередь двести, настройщик очереди её создаёт, главный цикл в одном потоке читает пакеты по одному, приёмщик превращает вердикт в ответ ядру. Запрос из двухсот пятидесяти байт в t10 получил DROP, а вместо него ушли две вставки, в сто четыре и сто сорок шесть байт, с меткой, которая не даёт им вернуться в очередь. Очередь увидела одиннадцать пакетов, провод клиента двенадцать: одиннадцать, минус выброшенный запрос, плюс две вставки.

!wiki-anim-video16-s28-episode-chain.svg

Что дальше. Мы говорили о пакете как о наборе байтов, но брали готовые слова: «заголовок IP», «порядковый номер», «контрольная сумма». Что движок делает с этими байтами, мы ещё не видели. В следующем выпуске: как из байтов получается таблица, с которой работают скрипты на Lua, и как собрать её обратно в байты, пересчитав длины и контрольные суммы.

!wiki-anim-video16-s28-next-bytes-to-table.svg

Итак, пакет прошёл путь от правила ядра до вставок на проводе. Дальше выпуск четыре: из байтов в таблицу.

См. также


[!quote] 🤖 Эти статьи открыты — можно обучать на них ИИ При желании вы можете натренировать ИИ на наших статьях. Исходное форматирование доступно в Forgejo: исходник этой заметки · скачать весь репозиторий одним zip-архивом.