Введение

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

Маршрутизация по метке пакета fwmark — проверенный механизм, он решает подобные задачи средствами nftables, ip rule и ip route. Но конфигурацию приходится готовить заранее. Стоит списку доступных интерфейсов измениться динамически — например, образуется туннель — правила и маршруты придётся доводить руками, скриптами или иными средствами автоматизации.

Метки fwmark тоже не всегда свободны. Выделить под новую задачу биты удаётся не везде, и каждое дополнительное правило лишь усложняет общую картину.

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

Эту идею мы и воплотили в Linux. Ядро запоминает входной интерфейс нового соединения, а при обработке ответного трафика обращается к сохранённым сведениям, чтобы направить пакет обратно по нужному маршруту. Никаких дополнительных битов fwmark, никаких отдельных правил ip rule и таблиц маршрутизации для каждого соединения.

Реализация оказалась сложнее, чем думали. Мы добавили в nftables новое условие и новую цель, расширили conntrack дополнительными данными и внесли шесть изменений в ядро. В сетевом стеке пришлось найти места, где нужно вмешаться в поиск маршрута для IPv4 и IPv6, обработку маршрутов типа blackhole и выбор адреса канального уровня. Внешний модуль получился объёмом чуть больше 1300 строк (srcdynroute.c).

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

Структура статьи:

  • §1. Как работает маршрутизация по источнику и в чём ограничения статической конфигурации.

  • §2. Идея решения, принцип работы и границы применимости.

  • §3. Изменения в ядре и модуле nftables: устройство реализации, точки встраивания и найденные по ходу работы нюансы.

  • §4. Тесты, результаты и ошибки, на которых мы потеряли время.

  • §5. Сценарии, в которых такое решение не нужно или лучше использовать штатные механизмы Linux.

  • §6. Сборка и запуск: от исходного ядра до прохождения тестов.

Динамическая привязка ответа
Динамическая привязка ответа

1. Статическая маршрутизация по источнику

В Linux маршрутизация по источнику (Source-Based Routing) является частным случаем PBR (Policy-Based Routing).

1.1 Компоненты PBR

Маршрутизацию по метке пакета в Linux настраивают тремя механизмами: nftables (Firewall Mark), правилами ip rule (Routing Policy Database) и таблицами ip route (Routing Information Base). Чтобы метка существовала и для ответов, её записывают в соединение и заново копируют на каждый пакет, который должен выбрать особый маршрут.

На стенде шлюзом служит машина deb13. Интерфейс enp0s3 подключён к сети 192.168.200.0/24. Интерфейс enp0s8 подключён к сети 192.168.129.0/24, и через него проходит default маршрут.

На интерфейсе enp4s0 клиента нужно назначить адрес 10.0.0.1/32. Команда ping -I использует его как адрес отправителя, поэтому он должен существовать локально. Получатель запроса 192.168.200.12 находится в том же сегменте.

# на клиенте
ip addr add 10.0.0.1/32 dev enp4s0
ping -I 10.0.0.1 192.168.200.12

Пакет приходит на интерфейс enp0s3, на котором установлен адрес 192.168.200.12. Шлюз формирует ответ, адресованный 10.0.0.1, при этом отсутствует маршрут через enp0s3 для этого адреса. Поэтому ядро выбирает маршрут по умолчанию через enp0s8. В результате клиент, подключённый через enp0s3, не получает ответ.

Маршрут 192.168.200.0/24 не участвует в выборе nexthop-а к 10.0.0.1, но без него ответы для адреса 192.168.200.67 пойдут через маршрут по умолчанию. Запрос приходит через enp0s3 с адресом источника 10.0.0.1, а обратный маршрут к этому адресу ведёт через enp0s8. При включённой проверке обратного пути (rp_filter) ядро считает такой пакет подозрительным и отбрасывает его на приёме. Для примера эту проверку отключают на интерфейсе enp0s3 командой: sysctl -w net.ipv4.conf.enp0s3.rp_filter=0.

# на шлюзе
ip route add default via 192.168.129.1 dev enp0s8
sysctl -w net.ipv4.conf.enp0s3.rp_filter=0

Дальше метка соединения помогает направить ответ, сформированный шлюзом, обратно через enp0s3. Обе копии метки указывают на адрес получателя ответа — 10.0.0.1. В таблице 100 для этого адреса тоже настроен маршрут, который направляет пакет через следующий узел — клиента:

nft add table inet fw
nft add chain inet fw prerouting { type filter hook prerouting priority filter \; }
nft add chain inet fw routemark { type route hook output priority mangle \; }

# Запрос с подменённым адресом: запомнить вход в соединении. На skb метку не ставим.
nft add rule inet fw prerouting iifname "enp0s3" ip saddr 10.0.0.1 \
    icmp type echo-request ct mark set 0x64

# Транзитный пакет того же соединения к 10.0.0.1.
nft add rule inet fw prerouting ct mark 0x64 ip daddr 10.0.0.1 \
    meta mark set ct mark

# Локальный echo-reply в PREROUTING не бывает. Копия метки и пересчёт маршрута.
nft add rule inet fw routemark ct mark 0x64 ip daddr 10.0.0.1 \
    meta mark set ct mark

ip rule add fwmark 0x64 lookup 100
ip route add 10.0.0.1/32 via 192.168.200.67 dev enp0s3 table 100
Запрос с подменённым адресом, ответ по ct mark
Запрос с подменённым адресом, ответ по ct mark

Сначала icmp_reply() ищет маршрут без метки и находит маршрут по умолчанию через enp0s8. Затем срабатывает цепочка type route hook output: она устанавливает skb->mark, из-за чего ядро запускает повторный поиск маршрута через ip_route_me_harder(). На этот раз ядро учитывает fwmark, обращается к таблице 100 и отправляет ответ через enp0s3, указав следующий узел — 192.168.200.67. Обычная цепочка filter output сработала бы слишком поздно: к этому моменту маршрут уже выбран, а сама по себе эта цепочка не заставляет ядро пересчитывать его.

Правило в PREROUTING не обрабатывает этот echo-reply, поскольку ответ формируется самим шлюзом. Это правило нужно для других пакетов того же соединения — тех, которые приходят на шлюз извне и пересылаются клиенту 10.0.0.1. Для таких пакетов ядро выбирает маршрут через ip_route_input() сразу после обработки PREROUTING.

Если в таблице 100 нет маршрута к 10.0.0.1, одного совпадения с правилом ip rule недостаточно. Если поиск в указанной таблице не находит маршрут, ядро переходит к следующему правилу — обычно к таблице main. В результате снова выбирается маршрут по умолчанию. Поэтому в таблице 100 задают отдельный маршрут 10.0.0.1/32, а не маршрут на всю сеть клиента. Метка skb устанавливается для пакетов, адресованных 10.0.0.1, а не для запросов к 192.168.200.12. Запросы и без того доходят до шлюза: в таблице правил маршрутизации (RPDB) таблица local проверяется раньше правила fwmark.

Запрос в INPUT, ответ через route output
Запрос в INPUT, ответ через route output

Запрос входит в enp0s3, получает ct mark 0x64. Ответ рождается на шлюзе, копирует метку в routemark и уходит в enp0s3.

Схема: соединение помнит вход, пакет с 10.0.0.1 получает метку, метка выбирает таблицу, таблица — интерфейс и next hop. Но адрес подмены, значение метки и таблица описаны заранее. Новый адрес или второй вход собираются той же связкой заново.

1.2 Четыре ограничения статической схемы

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

Первое — правила нужно согласовывать с меняющейся топологией.

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

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

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

Второе — для маршрутизации приходится использовать общее поле меток.

Поле skb->mark содержит 32 бита, которые могут использовать разные подсистемы Linux и сторонние компоненты: tc, правила firewall, механизмы QoS, VPN и собственные средства управления трафиком.

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

Мы хотели, чтобы для хранения информации о пути соединения не требовалось резервировать дополнительные биты skb->mark и согласовывать их использование с остальной конфигурацией.

Третье — правила маршрутизации сами по себе не хранят состояние соединения.

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

Для ответного трафика это различие существенно. Если маршрут ответа должен зависеть от пути, по которому пришёл запрос, одного правила для исходного направления может оказаться недостаточно.

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

Мы хотели перенести эту логику ближе к самому соединению: запоминать необходимую информацию при обработке первого пакета и использовать её при выборе пути для последующего трафика.

Четвёртое — выбор маршрута не решает все вопросы канального уровня.

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

Например, это может иметь значение в прозрачных сегментах на базе TAP, при нестандартной обработке ARP или при использовании инкапсуляции. В таких сценариях недостаточно выбрать ожидаемый интерфейс — нужно учитывать и то, как определяется адрес канального уровня получателя.

Обычно Linux разрешает сетевой адрес следующего узла в канальный с помощью ARP для IPv4 или Neighbor Discovery для IPv6. Это отдельный этап работы сетевого стека, связанный с выбранным маршрутом и состоянием таблицы соседей.

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

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

1.3 Ограничения маршрутизации

Вернёмся к пингу из §1.1. Клиент 192.168.200.67 выполняет ping -I 10.0.0.1 192.168.200.12. Пакет приходит на enp0s3. И так как 10.0.0.1 не принадлежит шлюзу, ответ уходит по default через enp0s8. Клиент на enp0s3 его не получает.

Решение — прописать нужный маршрут:

ip route add 10.0.0.1/32 via 192.168.200.67 dev enp0s3

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

Тонкая настройка из §1.1: ct mark на echo-request с адресом отправителя 10.0.0.1, копирование метки в PREROUTING и в route output на пакеты с получателем 10.0.0.1, ip rule и таблица 100 с 10.0.0.1/32 via 192.168.200.67 dev enp0s3. И default маршрут для остального трафика. Но конфигурация по-прежнему состоит из нескольких независимых объектов. nftables по условию устанавливает метку в соединение и копирует её в пакеты, ip rule по метке выбирает таблицу маршрутизации, ip route определяет маршрут. Все эти элементы нужно создать, согласовать и поддерживать.

Второй входной интерфейс — второй ct mark, второе правило ip rule и вторая таблица. Если часть битов fwmark уже заняты, их значения надо согласовать с остальной конфигурацией.

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

1.4 Разные маршруты для входящего и исходящего пакетов

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

Запрос с подменённым адресом попадает в PREROUTING, видит iifname "enp0s3" и пишет ct mark. Дальше ip_route_input() находит 192.168.200.12 в таблице local, пакет попадает в INPUT. Default-маршрут в этом не участвует.

Echo-reply — движется в обратном направлении. icmp_reply() вызывает ip_route_output_flow() ещё до цепочек OUTPUT, с нулевой меткой, поэтому выбирается default-маршрут и enp0s8. Сам ответ PREROUTING не проходит, поэтому метка не копируется. Цепочка type route hook output ставит метку и пересчитывает маршрут. Правило в PREROUTING необходимо для входящих пакетов того же соединения, которые пересылаются на 10.0.0.1. Сам echo-reply этого пинга до неё не доходит.

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

Поэтому принудительный выбор маршрута пришлось реализовать в обеих ветках поиска — см. § 3.2, патч 0004. Вмешиваться нужно именно на этапе выбора маршрута, а не пытаться изменить его после прохождения исходящих цепочек Netfilter.

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

2. Динамическая маршрутизация по источнику

Постановка задачи:

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

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

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

Это даёт несколько преимуществ.

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

Конфигурация описывает политику, а не набор исключений. Вместо согласованной комбинации fwmark, ip rule и отдельной таблицы маршрутизации используется правило nftables с целью DYNSRCROUTE. Оно определяет, к каким пакетам применяется механизм. Конкретное состояние создаётся уже при обработке соединения.

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

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

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

На проверенном стенде к расширению conntrack добавляется список anchor. Без активного выражения ct хуки conntrack на ядре 6.12.111 не регистрируются, и до ответа привязку доносит именно anchor. Подробности — в §3.3.

2.1 Сокращение конфигурации

Возьмём стенд из §1.3. На нём два сегмента, шлюз и задача — направлять ответный трафик по пути, определённому при обработке запроса.

В статической схеме для того же пинга приходится согласовать адрес подмены, default, запись метки в соединение, две копии метки на пакет к адресу 10.0.0.1, правило выбора таблицы и саму таблицу. Таблица inet fw и цепочки — те же, что в §1.1:

ip route add default via 192.168.129.1 dev enp0s8
nft add rule inet fw prerouting iifname "enp0s3" ip saddr 10.0.0.1 \
    icmp type echo-request ct mark set 0x64
nft add rule inet fw prerouting ct mark 0x64 ip daddr 10.0.0.1 \
    meta mark set ct mark
nft add rule inet fw routemark ct mark 0x64 ip daddr 10.0.0.1 \
    meta mark set ct mark
ip rule add fwmark 0x64 lookup 100
ip route add 10.0.0.1/32 via 192.168.200.67 dev enp0s3 table 100

Конфигурация задаёт один интерфейс, один адрес подмены и один обратный префикс. Значение 0x64 ещё нужно удержать свободным от остальной конфигурации. Для второго входа: другая метка, другое правило ip rule, другая таблица.

В нашей схеме ту же задачу — запомнить вход и выбрать выход ответа — задают одним правилом на класс трафика. Правила ниже включают механизм для echo-request к адресам самого шлюза: именно эти потоки тестируются. Таблица inet dynrt и цепочка wr создаются в листинге следом; здесь только сами правила:

nft add rule inet dynrt wr iifname "enp0s3" ip daddr 192.168.200.12 icmp type echo-request DYNSRCROUTE
nft add rule inet dynrt wr iifname "enp0s8" ip daddr 192.168.129.12 icmp type echo-request DYNSRCROUTE

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

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

Для нашего стенда можно ограничиться одним правилом, если нас интересуют только ICMP Echo Request, поступающие через enp0s3 и адресованные шлюзу 192.168.200.12:

table inet dynrt {
  chain wr {
    type filter hook prerouting priority filter; policy accept;
    iifname "enp0s3" ip daddr 192.168.200.12 icmp type echo-request counter DYNSRCROUTE
  }
}

Одна строка определяет, к каким пакетам применяется механизм. Ответный пакет отдельно описывать не требуется: необходимая информация хранится в расширении conntrack и используется при обработке обратного трафика.

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

Как именно это происходит в ядре, разберём в §3.

2.2 Обзор решения и его границы

За фразой «ядро само запоминает» стоят несколько инженерных вопросов. Стоит понимать не только то, как создаётся привязка, но и то, как она хранится, когда перестаёт действовать и что происходит при невозможности применить её.

Где хранится состояние.

В реализации используются два механизма хранения. Первый — собственное расширение conntrack размером 32 байта, которое существует в рамках жизненного цикла записи соединения. Второй — список anchor не длиннее 128 записей. Чтение этого списка защищено RCU, изменение — спинлоком. Запись живёт 60 секунд, и каждое новое срабатывание цели продлевает этот срок.

Эти два механизма имеют разные задачи и жизненные циклы. Удаление правила nftables само по себе не означает немедленного удаления уже созданного состояния. Аналогично, очистка записей состояния не требует удаления правила, которое их создавало.

Это не просто деталь реализации: разница между жизненным циклом правила и жизненным циклом сохранённой привязки проявляется при изменении конфигурации. В §4.2 мы вернёмся к этому на примере испытаний.

Кто создаёт привязку.

Запись создаётся только при выполнении правила, к которому привязана цель DYNSRCROUTE. В модуле за эту операцию отвечает функция dynrt_srcroute_eval.

Модуль не отслеживает весь трафик в фоновом режиме и не пытается самостоятельно обнаруживать новые соединения. Если правило не сработало, вызова этой функции не будет, а значит, не будет и новой привязки через этот путь. В отрицательных фазах испытаний это видно по счётчикам tagop=0.

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

Как обрабатываются конкурентный доступ и выгрузка модуля.

При чтении списка anchor и указателя на операции используется RCU, запись в список — под спинлоком. Расширение conntrack создаётся только у ещё не подтверждённого соединения: на подтверждённом nf_ct_ext_add не вызывается. Если расширение уже есть, следующий пакет того же направления перезаписывает ячейку. Anchor обновляется при каждом срабатывании DYNSRCROUTE.

При выгрузке модуль отписывается от своих операций и дожидается завершения критических секций RCU с помощью synchronize_rcu(). После этого его собственный код больше не должен использоваться сетевым стеком.

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

Что происходит, если привязку применить не удалось.

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

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

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

Что происходит при изменении конфигурации.

Уже созданная привязка может пережить удаление правила, которое её создало. Новые соединения больше не будут получать состояние через это правило, но существующие записи не обязаны исчезать немедленно.

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

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

В тестах этот случай проверяется в фазе S8 b3. Мы покажем, как ведёт себя механизм после удаления правила и почему время жизни конфигурации не совпадает со временем жизни состояния соединения.

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

3. Почему мы патчили ядро

3.1 Чего не хватает готовым средствам

Прежде чем открывать исходники ядра, мы перебрали несколько вариантов: штатные возможности nftables и conntrack, маршрутизацию по меткам, eBPF, VRF и перенаправление пакетов через tc. Подробное сравнение оставим для §5. Здесь важнее объяснить, почему в итоге мы выбрали модификацию ядра.

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

Можно построить такую схему на fwmark, ct mark и ip rule. Мы уже разобрали её ограничения в §1. Проблема не в отсутствии необходимых примитивов, а в том, что их приходится связывать конфигурацией. Мы хотели перенести сохранение и использование привязки в единый механизм.

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

Но наш основной сценарий другой. Ответ на ICMP Echo Request или запрос к локальному TCP-сокету создаётся самим узлом. Маршрут для такого пакета выбирается в исходящей ветке сетевого стека, в частности через ip_route_output_flow(). К моменту, когда пакет попадает в исходящую точку tc, маршрут уже выбран.

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

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

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

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

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

Отсюда требования к архитектуре:

  • Состояние привязки хранится в расширении conntrack и связано с жизненным циклом соединения.

  • Выбор маршрута учитывает сохранённый выходной интерфейс в ветках поиска IPv4 и IPv6.

  • При необходимости механизм использует сохранённый адрес канального уровня вместо адреса из обычной записи соседа.

  • Если принудительный поиск маршрута не удаётся, ядро может вернуться к обычному поиску.

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

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

3.2 Шесть патчей и один внешний модуль

Архитектура получилась из шести патчей ядра и отдельного модуля.

Карта модификаций ядра
Карта модификаций ядра

Патч 0001 — ABI-заголовок include/net/netfilter/dynrt.h — описаны структуры данных и операции, через которые сетевой стек обращается к модулю. Это общий интерфейс между ядром и модулем. Здесь фиксируется интерфейс, от которого зависят остальные патчи. Подробности структур разобраны в §3.3.

Патч 0002 — conntrack-расширение. В перечисление nf_ct_ext_id добавляется NF_CT_EXT_SRC_DYN_RT. В таблице размеров расширений появляется запись для sizeof(struct src_dyn_rt_ext), а дополнительные проверки времени компиляции фиксируют ожидаемую раскладку структур:

+	NF_CT_EXT_SRC_DYN_RT,
 	NF_CT_EXT_NUM,
 };
+	[NF_CT_EXT_SRC_DYN_RT] = sizeof(struct src_dyn_rt_ext),
...
-	BUILD_BUG_ON(NF_CT_EXT_NUM > 10);
+	BUILD_BUG_ON(NF_CT_EXT_NUM > 11);
+	BUILD_BUG_ON(sizeof(struct src_dyn_rt_slot) != 16);
+	BUILD_BUG_ON(sizeof(struct src_dyn_rt_ext) != 32);

Расширение занимает 32 байта на соединение. Внутри находятся две ячейки по 16 байт — по одной на каждое направление.

Патч 0003 — встроенная прослойка nf_dynrt.c. В дерево ядра добавляется отдельный файл, который предоставляет четыре обёртки и хранит под RCU указатель на зарегистрированный набор операций. Обёртки называются dynrt_force_oif_skb, dynrt_force_oif_fl4, dynrt_force_oif_fl6 и dynrt_force_mac.

Главный принцип здесь — инверсия зависимостей. Ядро не обращается непосредственно к ячейкам conntrack и не знает, как модуль вычисляет нужный интерфейс или MAC-адрес. Оно вызывает операции общего интерфейса, а их реализацию предоставляет модуль.

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

Патч 0004 — принудительный выбор выходного интерфейса. Этот патч вносит изменения в net/ipv4/route.c и соответствующий код IPv6. Он подключает сохранённую привязку непосредственно к поиску маршрута. На входящем пути используется fib_lookup, а на исходящем — ip_route_output_flow.

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

+#if IS_ENABLED(CONFIG_NF_DYNRT)
+	{
+		u32 dynrt_oif = dynrt_force_oif_skb(skb);
+
+		if (dynrt_oif) {
+			fl4.flowi4_oif = dynrt_oif;
+			err = fib_lookup(net, &fl4, res, 0);
+			if (err != 0) {
+				fl4.flowi4_oif = 0;
+				err = fib_lookup(net, &fl4, res, 0);
+			}
+		} else
+			err = fib_lookup(net, &fl4, res, 0);
+	}
+#else
 	err = fib_lookup(net, &fl4, res, 0);
+#endif

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

Исходящий путь находится в ip_route_output_flow(). Там вызывается dynrt_force_oif_fl4(), поиск идёт через __ip_route_output_key(), а исходный flowi4_oif перед выходом восстанавливается, чтобы принудительное значение не утекло вызывающему. Для IPv6 те же две стороны — ip6_route_input_lookup() и ip6_route_output_flags(): поиск через fib6_rule_lookup, и при принуждении выставляется RT6_LOOKUP_F_IFACE. Без этого флага более специфичный маршрут может победить закреплённый интерфейс; на этом построен сценарий S2. В патче 0004 четыре врезки: вход и выход для IPv4 и для IPv6.

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

Патч 0005 — подмена адреса канального уровня в neighbour.c. Одного выходного интерфейса недостаточно для всех наших сценариев. В некоторых топологиях необходимо сохранить и L2-адрес узла сети, которому должен быть передан кадр.

Для этого изменения внесены в neighbour.c, в частности в neigh_resolve_output() и neigh_connected_output().

Вместо безусловного использования L2-адреса из neighbour-таблицы ядро сначала запрашивает адрес у модуля. Ниже фрагмент упрощён: в ядре выбор указателя стоит перед циклом read_seqbegin, а не одной линейной вставкой.

+	const unsigned char *dst = neigh->ha;
+#if IS_ENABLED(CONFIG_NF_DYNRT)
+	dst = dynrt_force_mac(skb, dev);
+	if (!dst)
+		dst = neigh->ha;
+#endif
 		...
-		err = dev_hard_header(skb, dev, ntohs(skb->protocol),
-				      neigh->ha, NULL, skb->len);
+		err = dev_hard_header(skb, dev, ntohs(skb->protocol),
+				      dst, NULL, skb->len);

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

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

Патч 0006 — взаимодействие с маршрутами blackhole, unreachable и prohibit. Этот патч появился после тестов. Мы обнаружили, что в некоторых случаях принудительный выбор интерфейса не работает, даже когда привязка уже найдена. Патч правит только net/ipv4/fib_trie.c; для IPv6 такого обхода нет.

Причина оказалась в порядке поиска маршрута. В fib_table_lookup() записи некоторых типов, включая blackhole, обрабатываются до того, как поиск доходит до фильтра, учитывающего допустимый выходной интерфейс. Поэтому более специфичная запись могла завершить поиск ошибкой раньше, чем применялось наше ограничение.

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

Упрощённо изменение выглядит так:

  		err = fib_props[fa->fa_type].error;
  		if (unlikely(err < 0)) {
+#if IS_ENABLED(CONFIG_NF_DYNRT)
+			if (flp->flowi4_oif)
+				goto miss;
+#endif
 		 out_reject:

Под это правило попадают записи с ошибкой: blackhole, unreachable и prohibit. Второе такое же место в патче — blackhole nexthop. Условие — любой уже заданный flowi4_oif, не отдельный флаг dynrt, поэтому тот же обход получают привязка сокета и l3mdev. Поиск без закреплённого интерфейса, в том числе повторный поиск патча 0004 после обнуления oif, по-прежнему чтит blackhole.

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

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

Можно было бы вызвать функции модуля непосредственно из route.c и neighbour.c. Но тогда встроенный код зависел бы от символов внешнего модуля, который может быть не загружен. Это усложнило бы сборку и работу штатного ядра без расширения.

Вместо этого мы оставили в ядре небольшой интерфейс с регистрацией операций. Модуль подключается через dynrt_register() и отключается через dynrt_unregister(), после чего synchronize_rcu() позволяет дождаться завершения использующих старые указатели читателей.

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

Внешний модуль srcdynroute.ko

Основная логика вынесена в модуль, который добавляет в nftables два элемента: цель DYNSRCROUTE и условие DYNRT.

Цель DYNSRCROUTE записывает привязку при обработке подходящего пакета. Условие DYNRT проверяет, есть ли для соединения подходящее состояние. Если нет, оно прекращает обработку текущего правила через NFT_BREAK.

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

Важный фрагмент dynrt_srcroute_eval() — извлечение адреса источника из заголовка Ethernet:

 	if (dev->addr_len != 0 && dev->addr_len <= 8 &&
 	    skb_mac_header_was_set(skb) &&
 	    skb_mac_header(skb) + 2 * dev->addr_len <= skb->data)
 		peer = skb_mac_header(skb) + dev->addr_len;   /* h_source */

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

Условие DYNRT устроено проще. Оно получает tuple пакета и ищет привязку в три шага: ячейка разрешённого направления, затем любая валидная ячейка расширения, затем запись anchor для ICMP, ICMPv6, TCP и UDP. Если ничего нет, возвращается NFT_BREAK, чтобы оставшаяся часть правила не выполнялась. После NAT направление по tuple может не разрешиться, и срабатывает запасной шаг. Один успешный SNAT в S4 этого не покрывает, см. §5.

Цель отвечает за создание привязки, а условие — за проверку её наличия. Сам поиск маршрута и подмена адреса канального уровня выполняются в точках встраивания в ядре.

Для стенда мы собирали модуль отдельно от дерева ядра и устанавливали его в /lib/modules/6.12.111/updates/. Изменения пользовательской части nftables 1.1.3 и libnftnl 1.2.9 размещались в /usr/local, чтобы не перезаписывать дистрибутивные утилиты.

3.3 Две ячейки и таблица anchor

Структура данных получилась небольшой и компактной:

Раскладка данных
Раскладка данных
struct src_dyn_rt_slot {
	__u32 ifindex;	/* принудительный выходной ifindex; 0 = слот пуст */
	__u16 hwtype;	/* ARPHRD_* сохранённого L2-адреса */
	__u8  hwlen;	/* длина L2, 0..8 (0 => только ifindex) */
	__u8  flags;	/* bit0 (SRC_DYN_RT_SLOT_VALID) = слот валиден */
	__u8  hw[8];	/* L2-адрес пира, взятый из входящего кадра */
} __packed;          /* ровно 16 байт */

struct src_dyn_rt_ext {
	struct src_dyn_rt_slot slot[2];   /* ORIG / REPLY, 32 байта */
};

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

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

Если обозначить направление записи через W, а направление чтения через R, правило выглядит так:

запись: slot[W ^ 1], где W — направление записываемого пакета
чтение: slot[R],     где R — направление обрабатываемого пакета

Для ответного пакета R = W ^ 1, поэтому он читает ту же ячейку, в которую запрос записал привязку.

Допустим, входящий запрос обрабатывается в направлении IP_CT_DIR_ORIG, запись производится в slot[1], то есть в ячейку REPLY. Когда ответный пакет распознаётся как IP_CT_DIR_REPLY, чтение обращается к slot[1].

Эта схема не просто сокращает код. Она явно отделяет направление пакета, который создаёт привязку, от направления трафика, который будет её использовать.

При этом NAT требует отдельного внимания: преобразование tuple может повлиять на то, как пакет сопоставляется с состоянием соединения. Поэтому условие DYNRT должно правильно находить соединение и ячейку направления с учётом реальной обработки conntrack.

Зачем понадобилась таблица anchor?

Расширение conntrack оказалось не достаточным для всех тестовых сценариев. Из flowi нельзя восстановить идентификатор ICMP echo, поэтому ответный поиск не находит ячейку ICMP-соединения. На стенде deb13 на ядре 6.12.111 хуки conntrack к тому же регистрируются лениво и без активного выражения ct не стоят: для обычного TCP запись соединения не создаётся, и ответ несёт anchor. Ключ anchor для ICMP и ICMPv6 — пара адресов, порты в ключе нулевые, так что несколько echo между теми же адресами делят одну запись. Для TCP и UDP ключ включает порты.

Для таких случаев модуль использует собственный список anchor. Он хранит ключ обратного потока вместе с информацией о выходном интерфейсе и, если он известен, адрес канального уровня. Длина списка не больше 128 записей. Чтение идёт под RCU, изменение — под спинлоком. Записи живут 60 секунд, повторная запись продлевает срок, при переполнении вытесняется самая старая. При выгрузке модуля список очищается через dynrt_anchor_flush().

У этого решения есть цена: теперь часть состояния живёт отдельно от conntrack. Поэтому время жизни записи в anchor и время жизни самого соединения могут не совпадать. Эта особенность оказалась важной при тестировании и вернётся в §4.2.

3.4 Как мы искали места встраивания

Поиск начался с чтения сетевого стека: ip_route_output_flow(), затем __ip_route_output_key(), fib_lookup() и fib_table_lookup(). На первый взгляд казалось, что достаточно вмешаться в один общий участок поиска маршрута. На практике всё оказалось сложнее.

Первая ловушка — не все пакеты проходят через одну функцию.

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

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

Вторая ловушка — правильный интерфейс ещё не означает правильного получателя.

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

Трассировка через ftrace показала, что заголовок формируется с адресом из neigh->ha. В нашей топологии этого было недостаточно: требовалось использовать адрес, сохранённый при получении исходного кадра.

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

Третья ловушка — blackhole завершает поиск раньше перехвата.

В одном из сценариев привязка существовала, но ядро всё равно не выбирало ожидаемый маршрут. Мы проверяли вызовы через kprobe, изучали fib_table_lookup() и порядок проверок в fib_trie.c.

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

Так появился патч 0006: при поиске с закреплённым выходным интерфейсом неподходящая запись должна позволить продолжить поиск. При этом обычные поиски маршрута не должны менять своё поведение.

Четвёртая ловушка — состояние conntrack и направление пакета.

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

Но при работе с NAT нужно учитывать преобразование tuple и направление соединения. В ходе тестов мы обнаружили, что первоначальная проверка DYNRT не во всех случаях находила ячейку для ответного пакета. Пришлось отдельно проверить обработку направлений и сопоставление состояния после NAT.

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

Как мы проверяли гипотезы.

Для отладки использовали kprobe и ftrace. Нам были нужны не просто длинные журналы, а конкретные наблюдаемые признаки: вызывалась ли цель, находило ли условие привязку, возвращался ли закреплённый интерфейс и подменялся ли адрес канального уровня.

Возвращаемое значение на этом ядре снимает возвратная проба с префиксом r:; префикс p: ставит пробу на вход. Ненулевой MAC и индекс интерфейса видны на функциях модуля dynrt_op_mac и dynrt_op_oif_fl4, а не на ядерных обёртках dynrt_force_*: обёртки вызываются на каждом поиске, пока модуль загружен, и часто возвращают пустой результат.

TP=/sys/kernel/tracing
echo 'p:tagop dynrt_srcroute_eval'          >> $TP/kprobe_events
echo 'p:tgmop dynrt_match_eval'             >> $TP/kprobe_events
echo 'r:macret dynrt_op_mac $retval'        >> $TP/kprobe_events
echo 'r:retdyn4 dynrt_op_oif_fl4 $retval'   >> $TP/kprobe_events
echo 1 > $TP/events/kprobes/tagop/enable
echo 1 > $TP/events/kprobes/tgmop/enable
echo 1 > $TP/events/kprobes/macret/enable
echo 1 > $TP/events/kprobes/retdyn4/enable
echo 1 > $TP/tracing_on
echo 0 > $TP/trace            # чистим буфер перед фазой теста
# ... трафик ...
grep -c ' tagop: ' $TP/trace  # 0 в фазе NEG: запись молчала

Счётчик tagop показывал, вызывалась ли функция создания привязки. tgmop помогал проверить работу условия, а пробы на dynrt_op_mac и dynrt_op_oif_fl4 — увидеть результат соответствующих операций.

Здесь мы тоже допускали ошибки. Например, перед очередной фазой теста нужно было убедиться, что tracing_on снова установлен в 1. Иначе трасса оказывалась пустой, хотя сам механизм продолжал работать. Кроме того, после выгрузки и повторной загрузки модуля пробы на его функции (tagop, tgmop, dynrt_op_*) приходилось устанавливать заново. Обёртки dynrt_force_* вшиты в ядро и выгрузку модуля переживают.

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

Отдельно мы проверили пользовательскую часть nftables. В optimize.c патча 1001 новые операторы добавлены ветками case STMT_DYNRT_SRCROUTE и case STMT_DYNRT в уже существующие switch, по образцу notrack. Отдельной функции stmt_clone_dynrt в финальном патче нет: так назывался промежуточный черновик, в котором мы смотрели выделение и копирование оператора. Мы также проверили слияние правил в optimize.c, чтобы исключить потерю условия при оптимизации.

В финальной серии испытаний ошибка пользовательской части не подтвердилась. Проблемы, которые выглядели как дефекты реализации, в ряде случаев оказались ошибками тестирования или неверными предположениями о поведении сетевого стека. Подробнее об этом — в §4.

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

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

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

4. Восемь тестов и поиск своих ошибок

4.1 Стенд и методика тестирования

Все тестирования проводились на стенде с физическим хостом и виртуальной машиной deb13. Виртуальная машина оснащена четырьмя виртуальными процессорами и работает на ядре 6.12.111 с нашими патчами. Между хостом и виртуальной машиной настроены два независимых сегмента канального уровня.

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

Тестовый стенд
Тестовый стенд

Серия состоит из восьми тестов. У S1 и S5 общий каркас: b0 — исходный замер, b1 — отрицательная фаза NEG без правила, b2 — положительная фаза POS с правилом. S6 разбит на типы устройств, у S8 фазы b0–b5. Критерий не один на всех. Для S1 это захват echo-reply на двух сегментах хоста. S2 смотрит локально порождённый IPv6 и захват echo-request: ответ ядра на ping хоста и без правила привязан к входному интерфейсу (icmp6_iif). S5 опирается на счётчики ICMP, S8 — на таймаут TCP.

В основе методики три принципа.

Независимое подтверждение результата. Там, где ответ можно увидеть на проводе, главный критерий — захват на двух сегментах хоста: кадр с enp0s8 виден на brvm0 и не виден на enp4s0. На госте tcpdump в обвязке нет, съём идёт на хосте. Счётчики nftables и kprobe дополняют результат: показывают, сколько пакетов прошло через правило и вызывался ли код механизма.

Проверка отрицательной предпосылки. В фазе NEG недостаточно удалить правило и убедиться, что тест не обнаружил явного сбоя. Нужно проверить, что механизм действительно не участвовал в обработке пакетов. Для этого используются счётчики kprobe: tagop=0 и tgmop=0 означают, что функции записи и проверки не вызывались. Если эти условия не выполнены, отрицательную фазу нельзя считать чистой.

Очистка состояния перед каждым прогоном. Перед тестированием удаляются остаточные таблицы nftables, выполняется conntrack -F, модуль перезагружается. Это очищает состояние соединений и хранилище anchor. Без такой подготовки данные предыдущего запуска могут повлиять на фазу NEG следующего. К этому обстоятельству мы вернёмся в §4.2.

Обвязка написана на Bash и использует SSH для управления стендом; основные функции находятся в lib.sh. Обёртки r и rs ограничивают время выполнения команд и повторяют операции при необходимости. Это оказалось важно из-за периодически зависавших SSH-сессий.

Функции nft_apply и nft_drop проверяют фактическое появление и удаление таблиц. kp_arm и kp_count управляют kprobe. hpings генерирует трафик из сетевого пространства имён. Для сценария с чужим MAC используется Python-скрипт m6.py: он поднимает TAP, вбрасывает echo-request с исходным MAC FAKEA и на ARP отвечает адресом WRONG. Вердикт ok или bad — это MAC назначения ответа.

Состав серии и результат фактического прогона:

Тест

Что проверяет

Результат прогона 2026-10-11

S1

IPv4, вход и выход: испорченный маршрут 192.168.200.0/24 → enp0s8 без правила и с правилом

PASS: b1NEG seg1=0/seg2=5, b2POS seg1=5/seg2=0, tagop=5

S2

IPv6: более специфичный /128 на fd00:db8:beef::67, локальный ping6 шлюза

PASS: в прогоне dCNT=10, tagop=5. Доказательство скрипта — echo-request на первом сегменте; префикс и точка наблюдения другие, чем в S1

S3

Зонд FORWARD: blackhole на ответе, доставка в положительной фазе не обязательна

PASS: b1NEG 0/5. K1 и K2 оставляют ответ недоставленным (0/5), K3 — это обход (5/5). Обход blackhole на локальном выходе доказывает S5

S4

FORWARD, явный SNAT и blackhole на 10.0.1.67

PASS: b1NEG 0/3, b2POS 5/5, dCNT=5

S5

«Чёрная дыра» на локальном исходящем пути: принудительный интерфейс обходит blackhole (патч 0006)

PASS: 0/5 → 5/5

S6

Типы устройств: veth, TAP с отравленным ARP, VXLAN, dummy, bond

PASS: veth, TAP и VXLAN — PASS; dummy — SKIPPED-BY-DESIGN; bond в том прогоне — нет CONFIG_NET_BONDING

S7

100 новых соединений, до 10 одновременно

PASS: 100/100. Длительность короче секунды скрипт печатает как 0; lockdep замечаний не дал, если он в ядре выключен — это не проверка блокировок

S8

Смена правил на ходу: установленный TCP переживает удаление таблицы, новый 4-tuple — нет

PASS: b3 = held-delivered, b4 = timeout (свежий ephemeral port)

Итоговый прогон завершился с результатом 8/8 PASS. Журнал прогона в репозиторий не входит; цифры ниже сняты с прогона 2026-10-11. Пороги скриптов местами мягче этих цифр: чужой сегмент в S1 допускается до двух кадров, а не обязательно ноль.

Разберём некоторые результаты подробнее.

S1. Без правила ответы уходят по изменённому маршруту: seg1=0, seg2=5. Счётчики kprobe молчат: tagop=0, tgmop=0. С правилом результат меняется на seg1=5, seg2=0; счётчик правила DYNRT равен пяти, tagop=5. Запись сработала ровно на пяти пакетах.

S5. Без правила blackhole блокирует доставку: 0/5. После применения правила все пять ответов проходят через принудительный выход: 5/5. Это подтверждает работу патча 0006.

S7. Все 100 соединений прошли через принудительный выход, не больше десяти ping одновременно. Скрипт меряет время целыми секундами, поэтому короче секунды в журнале стоит 0. Сообщений lockdep в dmesg не было; если lockdep в ядре выключен, это не проверка блокировок.

S8. Уже установленное соединение после удаления таблицы по-прежнему получает echo: фаза b3 — held-delivered. Для удерживаемого сокета маршрут уже сидит в кэше dst, и следующие данные не обязаны заново входить в поиск. Новое TCP-соединение в b4 уходит в таймаут: у него другой ephemeral port, и точный ключ anchor от старого потока ему не достаётся. ICMP между теми же адресами так не разделён и ещё 60 секунд обслуживается старой записью, см. §4.2.

4.2 Когда NEG фаза начинала давать ложный результат

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

Первое предположение было естественным: возможно, наш код записывает состояние даже без правила. Чтобы отделить этот вариант от других, мы провели серию опытов, в которых менялась только одна переменная — возраст записи в anchor.

Ход отладки инверсии
Ход отладки инверсии

фаза

anchor

подстановка чужого MAC

вердикт

P1 NEG (после 75 с)

мёртв

ok=0, bad=5

CLEAN

P2 POS

пишется

ok=5

модуль работает

P3 NEG (сразу после удаления, внутри 60 с)

жив

ok=5, bad=0

INVERTED

P4 NEG (после 75 с)

мёртв

ok=0, bad=5

CLEAN

Само по себе такое сравнение показывало зависимость результата от возраста записи. Для подтверждения механизма мы провели ещё один опыт, добавив retprobe на dynrt_op_mac — функцию модуля, которая возвращает сохранённый адрес. Ядерная обёртка dynrt_force_mac для этого не годится: она вызывается на каждом выходе соседа.

фаза

что сделали

подстановка чужого MAC

macret (retprobe)

вердикт

D0

без предварительной записи, anchor мёртв

ok=0, bad=5

nonzero=0

CLEAN

D1

предварительная запись, удаление таблицы, NEG короче TTL

ok=5, bad=0

nonzero=5

INVERTED

D2

предварительная запись, пауза 72 с дольше TTL, затем NEG

ok=0, bad=5

nonzero=0

CLEAN

D3

предварительная запись, rmmod/modprobe, хранилище очищено, затем NEG

ok=0, bad=5

nonzero=0

CLEAN

Фаза D1 — наиболее прямое доказательство причины. При полном отсутствии правил dynrt_op_mac пять раз вернула ненулевой указатель на MAC. Значит, модуль продолжал использовать сохранённую привязку, хотя конфигурация, которая её создала, уже была удалена.

Это не ошибка самого механизма, а следствие его семантики. Удаление таблицы nftables прекращает создание новых привязок, но не удаляет уже записанные данные соединений и anchor. Запись anchor продолжает действовать до истечения TTL — 60 секунд. В течение этого времени она может влиять на маршрутизацию и выбор MAC.

То же поведение обнаружилось и в обычном прогоне run_all.sh. Сразу после ручного теста POS фаза S1 b1 неожиданно получила ответы через enp0s3, несмотря на испорченный маршрут. Правил уже не было, tagop=0, но результат продолжала определять запись anchor, созданная 22 секунды назад.

Исправление потребовало изменить не код ядра, а подготовку испытаний. Перед каждым прогоном теперь выполняются rmmod и modprobe, очищающие anchor через dynrt_anchor_flush(), затем conntrack -F и удаление остаточных таблиц.

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

4.3 Ошибка в самих тестах

Самая затратная ошибка расследования оказалась не в ядре, а в тестовой конфигурации.

Часть тестов записывала правило nftables в несколько строк, чтобы его было проще читать:

chain t {
    type filter hook prerouting priority filter; policy accept;
    iifname "enp0s3"
    ip daddr 192.168.200.12
    icmp type echo-request
    DYNSRCROUTE
}

Выглядит это как одно правило с несколькими условиями. Однако в синтаксисе nftables каждая строка внутри цепочки представляет отдельное правило. Мы проверили поведение через --debug=netlink на обеих версиях libnftables: нашей сборке и штатной версии 1.1.3. Результат оказался одинаковым.

Вместо одного правила создавались несколько:

  • iifname "enp0s3" — отдельное правило без вердикта;

  • ip daddr 192.168.200.12 — ещё одно правило без вердикта;

  • icmp type echo-request — третье правило;

  • DYNSRCROUTE — безусловный вызов цели, не ограниченный предыдущими строками;

  • counter — безусловный счётчик, если он записан отдельной строкой.

Вывод --debug=netlink list подтверждал, что парсер создаёт отдельные правила с последовательными номерами:

ip mltest c 2          # iifname        — отдельное правило, без вердикта
ip mltest c 3 2        # ip daddr       — отдельное правило, без вердикта
ip mltest c 4 3        # counter        — отдельное правило, безусловное

Это штатная семантика разбора nftables, а не дефект нашей сборки.

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

Разобраться помог минимальный эксперимент: однострочное правило

iifname "t6a" ip daddr 192.168.200.12 icmp type echo-request counter

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

Вторая проблема находилась в скрипте очистки. Остаточные таблицы удалялись с помощью фильтра awk: $2 ~ /^dynrt_/

Но nft list tables выводит строки в формате table <family> <name>. Имя таблицы находится в поле $3, а $2 содержит семейство. Фильтр не находил ни одной нужной таблицы, хотя скрипт сообщал об очистке. Таблицы с активной целью DYNSRCROUTE переживали прогоны и могли влиять на следующие фазы NEG.

Исправление оказалось простым: заменить $2 на $3. После этого очистка стала удалять ожидаемые таблицы, а отрицательные фазы начали давать правильные результаты.

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

Проверка --debug=netlink и правило «одно правило — одна строка» обошлись бы значительно дешевле, чем последующее расследование предполагаемого дефекта ядра.

4.4 Из чего складывается доказательство

Итоговый результат 8/8 PASS имеет смысл только вместе с проверками, которые исключают ложное прохождение тестов.

  • Журнал фиксирует конкретные результаты: счётчики, число доставленных пакетов и значения kprobe.

  • Сценарий определяет, что именно измеряется, какие условия должны выполняться и как интерпретировать результат.

  • Код объясняет, где формируется измеряемая величина и почему она подтверждает проверяемое свойство.

Например, утверждение «фаза S1 b1 NEG чиста» опирается на три свидетельства: tagop=0 в журнале, сценарий S1 с проверкой направления доставки и код, в котором вызывается функция записи. Без любого из этих звеньев результат остаётся предположением.

Именно так мы разбирали инверсии. Сначала установили, что они действительно происходят: журнал D1, отдельный сценарий и retprobe показали использование сохранённого MAC при отсутствии правил. Только после этого стали искать причину и обнаружили, что поведение соответствует заложенной семантике, а не свидетельствует о самопроизвольной записи.

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

Поэтому испытания намеренно допускают неудачные результаты. В S6 отсутствие поддержки bond приводит к SKIPPED, а не к фиктивному PASS. S1 дважды обнаруживал проблемы с подготовкой: сначала остаточное состояние anchor, затем неработающий фильтр очистки. Оба случая были полезны, поскольку выявили разные дефекты — один в предпосылках тестирования, другой в обвязке.

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

5. Когда ядро лучше не трогать

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

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

Инструмент

Что даёт

Когда его достаточно

Маршрутизация по политике (fwmark)

Выбор таблицы маршрутизации по метке, адресу источника и другим условиям ip rule

Потоки и префиксы известны заранее, значения mark распределены, а привязка к L2-адресу не требуется

eBPF (bpf_fib_lookup, tc/XDP)

Программируемый поиск маршрута и управление трафиком на точках tc и XDP

Нужна гибкая обработка на границе L2/L3, а выбранная архитектура допускает работу в этих точках вместо изменения штатного пути

VRF

Разделение таблиц маршрутизации между виртуальными маршрутизаторами

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

MPTCP

Использование нескольких путей в рамках одного транспортного соединения

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

tc mirred и redirect

Перенаправление пакетов или кадров на другой интерфейс

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

Сетевые пространства имён и macvlan

Изоляция сетевых стеков и разделение трафика по интерфейсам

Допустимо разделить сценарии по пространствам имён или отдельным сетевым контурам

Наш подход: расширение conntrack, принудительный выбор интерфейса и сохранение L2-адреса

Динамическая привязка обратного трафика к контексту входа

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

У этих инструментов разные области применения. Маршрутизация по политике хорошо решает задачу, если нужные потоки можно описать заранее. eBPF и tc дают больше свободы при обработке трафика на границах сетевого стека. VRF и пространства имён изолируют конфигурацию, но сами по себе не создают привязку обратного направления к конкретному входному интерфейсу. MPTCP решает другую задачу — работу транспортного соединения через несколько путей.

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

Цена собственного решения

У этого выбора есть несколько издержек, и они не исчезают после того, как механизм заработал.

Сопровождение ядра. Шесть патчей для версии 6.12.111 необходимо переносить на новые выпуски и основные ветки ядра. При каждом переносе нужно заново проверять места встраивания, взаимодействие с маршрутизацией и conntrack, а также поведение при отсутствии модуля. Это постоянная работа, а не разовая цена разработки.

Дополнительная логика на пути пакета. Изменения затрагивают поиск маршрута и разрешение соседа — участки, через которые проходит обычный сетевой трафик. Мы ограничили область вмешательства параметром CONFIG_NF_DYNRT, пустым поведением при отсутствии модуля и проверкой сохранённой привязки. Принудительный выбор интерфейса применяется только тогда, когда для пакета действительно есть действующая ячейка или запись anchor. Тем не менее эти изменения требуют отдельного тестирования после обновления ядра.

Собственное хранилище состояния. Список anchor нужен для случаев, в которых одной записи conntrack недостаточно: на стенде это обычный путь, а не редкий запасной. В списке не больше 128 записей. Для ICMP и ICMPv6 ключ не различает идентификатор echo, и несколько потоков между одной парой адресов делят одну запись на время TTL. Вместе со списком появляется ответственность за время жизни записей, продление TTL, очистку при выгрузке модуля и согласованность испытаний. История из §4.2 показала, что даже корректное сохранение состояния способно исказить результаты тестирования, если забыть о его жизненном цикле.

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

Зависимость от конкретной реализации ядра. Принудительный выбор интерфейса и обработка blackhole опираются на внутреннюю логику FIB, а подмена адреса соседа — на точки вызова в neighbour.c. Эти детали могут меняться между версиями ядра. Поэтому перенос патчей нельзя сводить к устранению конфликтов при применении: необходимо проверить, что сохранены сами инварианты механизма.

Где проходит граница

Если задача решается известными префиксами, метками и таблицами маршрутизации, используйте ip rule и fwmark. Если требуется программируемая обработка на сетевых границах, оцените eBPF и tc. Если нужно разделить маршрутизацию между независимыми контурами, начните с VRF или сетевых пространств имён.

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

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

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

6. Сборка и конфигурация

В репозитории лежат модуль, патчи, тесты. Дерево ядра клонируется отдельно, архивы libnftnl 1.2.9 и nftables 1.1.3 нужно скачать в корень репозитория. Ниже — полный цикл: проверить исходники, наложить патчи на ядро, собрать ядро и модуль, подготовить nftables, поднять правила и проверить результат.

Версии зафиксированы по факту испытаний: ядро 6.12.111, Debian 13, nftables 1.1.3, libnftnl 1.2.9. Именно на этом наборе мы получили финальный ALL TESTS PASS.

6.0. Сначала проверьте репозиторий

Прежде чем менять ядро, сверьте содержимое репозитория с манифестом. В нём зафиксированы SHA-256 исходников, патчей, тестов, README. Комментарии содержат MD5 патчей для дополнительной сверки с уже наложенными копиями.

# из корня репозитория
sha256sum -c MANIFEST

Каждая строка должна завершиться результатом OK. Если манифест сообщает о расхождении, сначала выясните, что изменилось.

6.1. Подготовьте исходники ядра

Работаем с исходниками Linux 6.12.111. Клонируем нужный тег и последовательно накладываем шесть патчей проекта:

# из корня репозитория
git clone --depth 1 --branch v6.12.111 \
    https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git \
    linux-6.12.111

cd linux-6.12.111

for p in 0001-dynrt-abi-header \
         0002-ct-ext-src-dyn-rt \
         0003-nf-dynrt-helper \
         0004-fib-oif-forcing \
         0005-neighbour-l2-override \
         0006-fib-blackhole-beats-forced-oif; do
    git apply ../patches/kernel/$p.patch
done

make olddefconfig
scripts/config --enable NF_DYNRT
make olddefconfig
make -j"$(nproc)" bindeb-pkg

Последняя команда собирает пакеты ядра Debian. Их ставят на гостя и перезагружаются в 6.12.111. Пока работает старое ядро, модуль из §6.3 загружать бессмысленно. Имена файлов зависят от локальной версии сборки; лежат они на каталог выше дерева исходников:

sudo dpkg -i ../linux-image-6.12.111*.deb ../linux-libc-dev-6.12.111*.deb
sudo reboot

Если требуется только подготовить дерево для сборки внешнего модуля, можно использовать modules_prepare, но это не заменяет полноценную сборку ядра с нашими изменениями. Опция CONFIG_NF_DYNRT в патче 0003 и так default y; второй olddefconfig после scripts/config нужен, чтобы .config устаканился.

Патчи сняты командой diff -ruN с чистого дерева. Их целостность проверяется через MANIFEST из корня репозитория. Порядок наложения имеет значение: каждый следующий патч опирается на предыдущие изменения.

6.2. Соберите пользовательские утилиты nftables

Одного модуля недостаточно. Он регистрирует два выражения nftables — DYNSRCROUTE и DYNRT, — но пользовательские утилиты тоже должны знать их синтаксис и уметь передавать соответствующие операции ядру.

Сначала собираем libnftnl 1.2.9 с патчем 1002, добавляющим поддержку операций без атрибутов:

# из корня репозитория
tar xf libnftnl-1.2.9.tar.bz2
cd libnftnl-1.2.9

git apply ../patches/userspace/1002_libnftnl-dynrt.patch

./configure --prefix=/usr/local
make -j"$(nproc)"
make install
cd ..

Затем собираем nftables 1.1.3 с грамматикой новых выражений:

tar xf nftables-1.1.3.tar.bz2
cd nftables-1.1.3

git apply ../patches/userspace/1001_nft-dynrt.patch

export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig
./configure --prefix=/usr/local
make -j"$(nproc)"
make install
cd ..

ldconfig

Каталог /usr/local выбран намеренно. Дистрибутивный /usr/sbin/nft остаётся нетронутым, а исправленная версия устанавливается в /usr/local/sbin/nft.

Это важно помнить при диагностике. Если команда nft не понимает DYNSRCROUTE, проблема может быть не в модуле, а в том, что оболочка запускает штатную утилиту вместо нашей сборки. Проверяйте command -v nft и зависимости исправленного исполняемого файла через ldd /usr/local/sbin/nft.

6.3. Соберите и загрузите модуль

Команды этого шага выполняются на deb13, уже загруженном в собранное ядро. make install в Makefile вызывает depmod для uname -r той машины, где запущена сборка.

make KDIR=/путь/к/linux-6.12.111 INSTALL_MOD_DIR=updates install

Модуль srcdynroute.ko устанавливается в /lib/modules/6.12.111/updates/. Без INSTALL_MOD_DIR=updates ядро 6.12 кладёт внешний модуль в extra/.

Загружаем его и проверяем, что он появился в списке:

modprobe srcdynroute
lsmod | grep srcdynroute

В выводе должна быть строка с srcdynroute.

Модуль зависит от nf_conntrack и nf_tables, распространяется под лицензией GPL и регистрирует псевдонимы nft-expr-DYNSRCROUTE и nft-expr-DYNRT. Благодаря им ядро может запросить загрузку модуля при обращении к соответствующему выражению nftables.

6.4. Поднимите минимальный сценарий

Возьмём топологию из §4.1. Шлюз — deb13, первый сегмент подключён через enp0s3, адрес узла назначения — 192.168.200.12.

Создадим таблицу и цепочку, затем добавим одно правило:

nft add table inet dynrt

nft add chain inet dynrt wr \
    '{ type filter hook prerouting priority filter; policy accept; }'

nft add rule inet dynrt wr \
    iifname "enp0s3" ip daddr 192.168.200.12 \
    icmp type echo-request counter DYNSRCROUTE

Смысл правила: запоминать контекст входа для ICMP echo-request к указанному адресу, если запрос пришёл через enp0s3. Ответ отдельно описывать не требуется.

Здесь есть одна ловушка, на которой мы уже потеряли немало времени. Внутри блока chain { } каждая строка является отдельным правилом nftables. Перенос условия на несколько строк может превратить его в несколько самостоятельных правил, а DYNSRCROUTE — в безусловную цель. Подробнее этот случай разобран в §4.3.

Поэтому проверяйте не только то, что команда выполнилась, но и то, что получилось на выходе:

/usr/local/sbin/nft --debug=netlink list table inet dynrt

Условия, счётчик и DYNSRCROUTE должны принадлежать одному правилу.

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

sysctl -w net.ipv4.conf.all.rp_filter=0
sysctl -w net.ipv4.conf.enp0s3.rp_filter=0

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

6.5. Проверьте результат

Начнём с простого опыта. Намеренно направим сеть назначения через неправильный интерфейс:

ip route replace 192.168.200.0/24 dev enp0s8

Без правила ответ должен уйти через enp0s8. После добавления правила привязка должна направить ответ через enp0s3.

Проверять это лучше одновременно на обоих сегментах хоста:

# на хосте, в соседнем терминале
tcpdump -ni enp4s0 'icmp and icmp[icmptype] == 0' &
tcpdump -ni brvm0 'icmp and icmp[icmptype] == 0' &

Затем с хоста 192.168.200.67 отправьте пять запросов к 192.168.200.12. При работающем правиле ответы должны наблюдаться на enp4s0, а не на brvm0.

Для формальной проверки полезны три независимых наблюдения.

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

Второе — счётчик nftables.

nft list chain inet dynrt wr

Число пакетов в counter должно соответствовать количеству запросов, обработанных правилом. Но само по себе совпадение счётчиков ничего не доказывает: безусловное правило тоже может честно насчитать пять пакетов. Сначала убедитесь через --debug=netlink, что условия собраны правильно.

Третье — наблюдение за ядром. Счётчики kprobe, установленные на функциях модуля, позволяют проверить, вызывалась ли запись привязки и возвращался ли принудительный выходной интерфейс. Подробная схема инструментирования приведена в §3.4.

Для справки можно посмотреть и обычный маршрут:

ip route get 192.168.200.67 from 192.168.200.12

Но не принимайте эту команду за доказательство работы механизма. ip route get выполняет самостоятельный поиск маршрута и не читает ячейку conntrack так, как это делает наш код при обработке реального пакета.

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

Теперь можно запускать всю серию:

cd tests
./run_all.sh

Итоговый журнал сохраняется в /tmp/dynrt_results.log. На проверенном стенде финальный прогон завершился результатом ALL TESTS PASS.

Перед запуском вручную ничего не пропускайте из подготовки состояния. Обвязка сбрасывает остаточные таблицы, очищает conntrack и перезагружает модуль, чтобы очистить хранилище anchor. Без этого отрицательная фаза NEG может получить результат от предыдущего запуска. Мы разбирали такую инверсию в §4.2.

Если запускаете отдельные тесты вне run_all.sh, воспроизводите ту же подготовку самостоятельно.

6.6. Ловушки, которые встретятся снова

nft не понимает DYNSRCROUTE.

Сначала проверьте, какой исполняемый файл запускается:

command -v nft
ldd /usr/local/sbin/nft

Исправленная утилита находится в /usr/local/sbin/nft. Важно также убедиться, что она использует собранную нами libnftables и установленную версию libnftnl.

rmmod сообщает Module in use.

Обычно на узле осталось правило nftables, использующее выражение модуля. Посмотрите таблицы:

nft list tables

Удалите таблицы, содержащие такие правила, и повторите выгрузку. Здесь rmmod нужен не только для перезагрузки кода: обработчик выхода модуля вызывает dynrt_anchor_flush(), очищая собственное хранилище состояния.

После rmmod и modprobe пропали kprobe.

Это штатное поведение: ядро снимает пробы с выгружаемого кода. После повторной загрузки модуля установите их заново. Пример команд приведён в §3.4.

Фаза NEG показывает перенаправление, хотя правил нет.

Проверьте остаточное состояние в следующем порядке:

  1. Живые записи anchor могут пережить удаление таблицы nftables. Очистить их можно выгрузкой и повторной загрузкой модуля либо ожиданием дольше 60 секунд.

  2. Состояние соединений очищается отдельно через conntrack -F (должен быть установлен пакет conntrack).

  3. Остаточные таблицы с правилами записи проверяются командой nft list tables.

Это три разных источника состояния. Удаление правил не обязано удалять уже созданные привязки, а очистка conntrack не заменяет очистку собственной базы anchor.

Счётчики учитывают чужой трафик.

Первым делом проверьте результат разбора:

/usr/local/sbin/nft --debug=netlink list table inet dynrt

Если условия разбиты на несколько строк, каждое из них могло превратиться в отдельное правило. Проверьте это до исследования ядра. Для наших сценариев используется правило: одно nftables-правило — одна строка.

Заключение

Динамическая маршрутизация по источнику начиналась с простой идеи: привязывать обратный путь к контексту входа и хранить эту привязку в conntrack/anchor, а не в конфигурации маршрутизации.

Реализация получилась компактной по данным: 32 байта на соединение, шесть узких патчей ядра под CONFIG_NF_DYNRT и один внешний модуль с двумя выражениями nftables. Но компактность кода не означает, что механизм можно встроить в ядро одной очевидной правкой. Пришлось учитывать порядок проверок FIB, отдельно решать вопрос с адресом соседа на канальном уровне и обеспечивать безопасное поведение при отсутствии модуля.

Не меньше времени ушло на проверку самого решения. Принудительный выходной интерфейс упирался в обработку blackhole. Правильный интерфейс ещё не гарантировал правильного соседа: мешал кэш ARP. А некоторые предполагаемые дефекты ядра оказались дефектами тестов — многострочной записью правил nftables и одной цифрой в фильтре awk.

Если проходить этот путь заново, я бы начал с двух вещей: проверки разобранных правил через --debug=netlink и автоматической очистки состояния перед каждым прогоном. Это не вспомогательная гигиена, а часть воспроизводимого эксперимента.

Остальные выводы тоже вполне практические.

Различающие опыты нужно ставить раньше. В сериях P1–P4 и D0–D3 мы меняли одну переменную — возраст записи anchor — и получали результаты, которые позволяли отделить ошибку теста от поведения ядра. Это оказалось полезнее часов чтения исходников без проверяемой гипотезы.

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

Ограничения NAT нужно проверять на конкретных сценариях. У нас такую проверку обеспечивает S4 с явным SNAT, но это не повод считать произвольные цепочки NAT автоматически покрытыми.

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

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

Код модуля, патчи и тестовая серия находятся в корне репозитория github: srcdynroute.c, include/, patches/, tests/. Полный цикл можно повторить не на всех версиях ядра — при условии, что исходники, конфигурация стенда и подготовка состояния совпадают с проверенными.

Комментарии (0)