Пакет виден в tcpdump, но Linux его отбрасывает: разбираем rp_filter
На второй сетевой карте виден входящий SYN, сервис слушает порт, а правила firewall разрешают соединение. Ответа всё равно нет. Я, Семёнов Евгений Сергеевич, чаще всего начинаю не с очередного просмотра nftables, а с проверки обратного маршрута и `rp_filter`. Ниже покажу, где именно исчезает пакет, как доказать причину и почему я предпочитаю исправлять маршрутизацию, а не навсегда отключать защиту ядра.
tcpdump подтверждает приём кадра, но не доставку пакета
Главная ловушка — считать строку в tcpdump доказательством того, что пакет прошёл сетевой стек Linux. tcpdump использует пакетный сокет AF_PACKET и получает копию входящего трафика на канальном уровне до передачи пакета протоколам ядра. Поэтому утилита честно показывает SYN, который сетевая карта уже приняла, но который IPv4-стек через несколько шагов признает недопустимым. tcpdump здесь не врёт. Просто администратор приписывает его выводу больше смысла, чем в нём есть.
Для локального IPv4-трафика упрощённый путь выглядит так: драйвер сетевой карты → AF_PACKET, где пакет видит tcpdump → Netfilter PREROUTING → поиск входного маршрута и проверка источника → Netfilter INPUT → сокет приложения. Это важная точность: rp_filter срабатывает не «до любого firewall». Правила и счётчики в PREROUTING могут увидеть пакет, а цепочка INPUT и приложение — уже нет. Поэтому отсутствие события в INPUT при наличии пакета на интерфейсе является хорошей подсказкой.
Я проверяю одновременно три точки. На интерфейсе смотрю SYN через tcpdump, в INPUT — изменение счётчика подходящего правила, а на сервере — наличие слушающего сокета. Команды для нашего примера такие:
sudo tcpdump -ni enp2s0 'host 192.0.2.44 and tcp dst port 443'
sudo nft list ruleset -a
sudo ss -lntp 'sport = :443'Если SYN есть в первой команде, Nginx слушает 0.0.0.0:443, но счётчик разрешающего правила INPUT не растёт, перестаю перебирать порты и начинаю проверять FIB и reverse path validation.
- Видимость в `tcpdump` означает, что кадр дошёл до Linux на указанном интерфейсе.
- Она не означает прохождение проверки маршрута, INPUT, LSM-политик и доставку сокету.
- Счётчик PREROUTING может увеличиваться даже у пакета, который позднее отбрасывает `rp_filter`.
Что на самом деле проверяет rp_filter
rp_filter — это IPv4-проверка обратного пути по таблицам маршрутизации ядра. Linux берёт адрес источника входящего пакета и выполняет обратный FIB lookup. В строгом режиме результат должен указывать на тот же интерфейс, через который пакет пришёл. Допустим, запрос от 192.0.2.44 вошёл через enp2s0, а лучший маршрут к 192.0.2.44 проходит через основной default route на enp1s0. Для режима 1 это несоответствие. Пакет отбрасывается ещё до INPUT, хотя прямой маршрут к локальному адресу и правило firewall совершенно исправны.
Значения простые: 0 отключает проверку источника, 1 включает строгий режим, 2 — свободный, или loose mode. В loose mode достаточно, чтобы источник считался достижимым хотя бы через один интерфейс. Документация ядра рекомендует строгий режим для обычной маршрутизации и loose mode при предусмотренной асимметрии. Но не надо приписывать значению 2 магическую защиту: если в таблице есть обычный маршрут default, почти любой глобальный unicast-адрес уже считается достижимым.
Есть неприятная особенность, на которой я регулярно ловлю даже опытных администраторов. Для входного интерфейса ядро использует максимальное значение из net.ipv4.conf.all.rp_filter и net.ipv4.conf.<интерфейс>.rp_filter. Если all=1, а на карте выставили 0, проверка фактически остаётся строгой. Параметр default — это шаблон для создаваемых позднее интерфейсов, а не универсальный переключатель уже существующих карт. Поэтому я всегда читаю все уровни сразу:
sysctl net.ipv4.conf.all.rp_filter
sysctl net.ipv4.conf.default.rp_filter
sysctl net.ipv4.conf.enp1s0.rp_filter
sysctl net.ipv4.conf.enp2s0.rp_filterИмена интерфейсов, конечно, подставляйте свои.
В проверке участвует не только таблица main. Ядро использует Routing Policy Database: правила ip rule, отдельные таблицы и, при соответствующей настройке, метку пакета. Именно поэтому вывод одного ip route недостаточен. ip route show перечисляет записи, а ip route get выполняет разрешение маршрута с заданными атрибутами почти так, как его видит ядро.
- `0` — проверка источника отключена; это значение ядра по умолчанию, но не дистрибутива.
- `1` — strict: обратный маршрут к источнику обязан вести через интерфейс, на который пришёл пакет.
- `2` — loose: достаточно, чтобы источник был достижим через любой интерфейс.
- Эффективный режим на карте — `max(conf.all, conf.<интерфейс>)`; `conf.default` влияет только на интерфейсы, созданные позже.
- `log_martians` вычисляется иначе: логирование включено, если единица стоит хотя бы на одном из двух уровней.
Практика: две линии у «Актив-Эксперта» и пропавший HTTPS
Покажу случай компании «Актив-Эксперт» — финансовое консультирование, 24 рабочих места. Название условное, топология и цифры сохранены без данных, позволяющих определить организацию. На виртуальном сервере было 2 vCPU, 4 Гбайт RAM и две VirtIO-карты по 1 Гбит/с. Система — Ubuntu Server 24.04.1 LTS, ядро 6.8.0-55-generic, Nginx 1.24.0. Через сервер публиковались клиентский портал для обмена отчётностью и API, через который учётная система клиентов забирала подготовленные документы.
Первая линия была основной: enp1s0, адрес 198.51.100.10/24, шлюз 198.51.100.1, метрика default route — 100. Вторая использовалась для отдельного DNS-имени API: enp2s0, адрес 203.0.113.10/24, шлюз 203.0.113.1, метрика — 200. Адреса здесь заменены на документальные диапазоны RFC 5737. Оба провайдера доставляли трафик до своих адресов, но в таблице main лучший маршрут в интернет всегда проходил через enp1s0. Это нормально для исходящих соединений, но не для ответа на запрос, пришедший через вторую линию.
После усиления sysctl на обеих картах оказался строгий rp_filter=1. Внешняя проверка с адреса 192.0.2.44 отправляла SYN на 203.0.113.10:443. За минуту мы увидели в tcpdump 30 повторных SYN и ни одного SYN-ACK. ss подтверждал слушающий порт, конфигурация Nginx проходила nginx -t, разрешающее правило nftables было на месте, но его счётчик в INPUT не изменился. В access log и error log Nginx — пусто. Именно в этот момент обычно начинают без пользы переустанавливать сертификаты или переставлять правило firewall выше.
Обратный lookup сразу показал конфликт:
ip -4 rule show
ip -4 route show table main
ip -4 route get 192.0.2.44 from 203.0.113.10
ip -4 route get 203.0.113.10 from 192.0.2.44 iif enp2s0Предпоследняя команда моделирует ответ с адреса второй линии и до исправления выбирала via 198.51.100.1 dev enp1s0. Последняя моделирует сам входящий SYN, включая проверку источника, и честно возвращала RTNETLINK answers: Invalid cross-device link — так ip показывает отказ reverse path filter. Реальный пакет пришёл на enp2s0, а обратный маршрут указывал на enp1s0, и ядро выбрасывало пакет. Маршруты выглядели аккуратно, firewall был правильным, а сервис не получал даже первого SYN.
- 30 входящих SYN за 60 секунд на `enp2s0`.
- 0 SYN-ACK, 0 попаданий в INPUT, 0 записей Nginx.
- Обратный lookup к источнику выбирал `enp1s0` из-за default route с меньшей метрикой.
Как доказать причину, а не угадать её
Сначала я включаю логирование martian-пакетов и повторяю один контролируемый запрос. Настройка log_martians также учитывает глобальное и интерфейсное значения: логирование включено, если хотя бы одно из них равно единице. Сообщения ядра ограничиваются по частоте, поэтому число строк не обязано совпасть с числом SYN.
sudo sysctl -w net.ipv4.conf.all.log_martians=1
sudo journalctl -kfВ нашем случае ядро 6.8 записало martian source 203.0.113.10 from 192.0.2.44, on dev enp2s0 и следом строку ll header с канальным заголовком: первым идёт адрес назначения, после from — источник. В свежих ядрах формат переписан на martian source (src=192.0.2.44, dst=203.0.113.10, dev=enp2s0), поэтому grep по журналу лучше строить по словам martian source, а не по порядку полей. Параллельно смотрю счётчик отбрасываний по обратному пути — он растёт независимо от log_martians и rate limit:
nstat -az TcpExtIPReversePathFilterЕсли значение увеличивается синхронно с повторами SYN, причина доказана без чтения журнала.
Затем провожу короткий тест с loose mode только на проблемной карте. Поскольку глобальное значение участвовало в вычислении максимума, его пришлось обнулить, сохранив строгий режим на первой карте:
sudo sysctl -w net.ipv4.conf.all.rp_filter=0
sudo sysctl -w net.ipv4.conf.enp1s0.rp_filter=1
sudo sysctl -w net.ipv4.conf.enp2s0.rp_filter=2HTTPS сразу ответил. Это диагностический эксперимент, а не мой окончательный проект. После теста я вернул enp2s0 в режим 1 и исправил policy routing. Если вы работаете удалённо, заранее держите вторую консоль или доступ через гипервизор.
Иду всегда в одном порядке: фиксирую входящий интерфейс и адреса пакета; читаю все значения rp_filter; смотрю ip rule; проверяю таблицы командой ip -4 route show table all; моделирую входящий пакет командой ip route get <локальный адрес> from <удалённый адрес> iif <карта> и ответ — командой ip route get <удалённый адрес> from <локальный адрес>; только потом меняю sysctl. Простая команда ip route get 192.0.2.44 хуже: она не передаёт ни локальный адрес второй линии, ни входной интерфейс, поэтому при policy routing проверяет не тот сценарий. Важно не перепутать направление: from с локальным адресом вместе с iif описывает пакет, который якобы пришёл снаружи с нашим же адресом, и ядро отвергнет его по совсем другой причине.
Не считаю conntrack решающим свидетелем в такой диагностике. Connection tracking обрабатывает пакет в PREROUTING, до поиска маршрута, поэтому правила с ct state new в этой цепочке срабатывают и для SYN, который потом отбросит rp_filter. Запись при этом остаётся неподтверждённой и в conntrack -L не появляется — отсутствие записи тоже ничего не доказывает. Надёжная связка здесь — захват на конкретной карте, отсутствие INPUT, результат обратного lookup и martian-сообщение ядра.
- `tcpdump -ni <карта>` — пакет физически пришёл на ожидаемый интерфейс.
- `nstat -az TcpExtIPReversePathFilter` — счётчик растёт вместе с повторами SYN.
- `journalctl -k | grep 'martian source'` — ядро назвало интерфейс и адреса.
- `ip route get <локальный> from <удалённый> iif <карта>` — возвращает `Invalid cross-device link`.
- Кратковременный перевод карты в loose mode немедленно восстанавливает ответ.
Моё решение: отдельная таблица для каждого исходного адреса
Если асимметрия возникла случайно из-за двух default route, я не маскирую её значением rp_filter=0. Мой выбор — source-based policy routing: ответ с адреса каждой линии должен уходить через шлюз этой же линии. Это одновременно удовлетворяет строгой обратной проверке, сохраняет предсказуемый путь ответов и не заставляет надеяться, что второй провайдер пропустит пакет с чужим исходным адресом. Кроме того, диагностика через полгода остаётся понятной.
Для «Актив-Эксперта» мы создали таблицы 101 и 102. В каждую положили подключённую сеть и свой default route, а правила привязали к исходным адресам. Постоянная конфигурация Netplan выглядела так:
network:
version: 2
renderer: networkd
ethernets:
enp1s0:
addresses:
- 198.51.100.10/24
routes:
- to: default
via: 198.51.100.1
metric: 100
- to: 198.51.100.0/24
scope: link
table: 101
- to: default
via: 198.51.100.1
table: 101
routing-policy:
- from: 198.51.100.10/32
table: 101
priority: 101
enp2s0:
addresses:
- 203.0.113.10/24
routes:
- to: default
via: 203.0.113.1
metric: 200
- to: 203.0.113.0/24
scope: link
table: 102
- to: default
via: 203.0.113.1
table: 102
routing-policy:
- from: 203.0.113.10/32
table: 102
priority: 102Числовые таблицы удобны: ради двух записей не требуется редактировать rt_tables. Приоритеты задаю явно, потому что меньший номер правила обрабатывается раньше.
Конфигурацию мы проверили и применили с автоматическим откатом:
sudo netplan generate
sudo netplan try --timeout 120
ip -4 rule show
ip -4 route show table 101
ip -4 route show table 102
ip -4 route get 192.0.2.44 from 203.0.113.10
ip -4 route get 203.0.113.10 from 192.0.2.44 iif enp2s0После изменения обратный lookup показывал таблицу 102, шлюз 203.0.113.1 и enp2s0, а модель входящего пакета вместо ошибки возвращала local 203.0.113.10 ... dev lo, то есть локальную доставку. Строгий rp_filter=1 перестал отбрасывать запрос, а SYN-ACK пошёл через того же провайдера. Важно проверить не только подключение к серверу, но и оба направления с двух внешних точек: локальный тест с самого сервера входной путь не воспроизводит.
За первые сутки мониторинг выполнил 1440 HTTPS-проверок через каждую линию — 2880 запросов без потерь. За следующую неделю через вторую линию прошло 6 912 обращений к API; событий martian source для разрешённых внешних адресов и ответов через неверную карту не было. Работы вместе с диагностикой заняли 47 минут — для компании на 24 места это один вечер без простоя портала в рабочие часы. Главное, что мы не просто «разрешили пакет», а убрали случайную асимметрию, из-за которой позже ломались бы ответы, stateful firewall провайдера и анализ инцидентов.
- Отдельная таблица на каждую линию: подключённая сеть плюс свой default route.
- Правило `from <адрес линии>` с явным приоритетом ниже 32766, чтобы оно срабатывало раньше `main`.
- Default route в `main` остаётся для исходящих соединений, инициированных сервером.
- Проверка с двух внешних точек, по одной через каждого провайдера.
- Контроль после перезагрузки: `ip rule`, `ip route get` и счётчик `IPReversePathFilter`.
Откуда берётся rp_filter=2 после установки: дефолты дистрибутивов и sysctl.d
Частый вопрос после разбора: «Мы ничего не включали, откуда строгий или loose-режим?». В самом ядре rp_filter по умолчанию равен 0, и так написано в ip-sysctl.rst. Всё остальное делают пакеты дистрибутива. systemd поставляет /usr/lib/sysctl.d/50-default.conf, где есть три строки: net.ipv4.conf.default.rp_filter = 2, net.ipv4.conf.*.rp_filter = 2 и -net.ipv4.conf.all.rp_filter. Последняя строка без знака равенства не задаёт значение, а исключает all из маски — поэтому глобальный параметр остаётся ядерным 0, а каждая карта получает 2. В Ubuntu поверх этого procps кладёт 10-network-security.conf с 2 и для all, и для default; до Ubuntu 20.04 там стояла единица. В Debian 13 файл 50-default.conf переехал в пакет linux-sysctl-defaults, который ставится по рекомендации systemd, а /etc/sysctl.conf systemd-sysctl больше не читает. RHEL 9 добавляет 50-redhat.conf с default.rp_filter = 1, и на свежей системе легко увидеть all=0 рядом с 1 на картах.
Отсюда два практических следствия. Первое: sysctl -w меняет значение только до перезагрузки, а маска net.ipv4.conf.* применяется systemd-sysctl ещё и в момент появления каждого нового интерфейса — VPN-туннель или VLAN, поднятый позже, получит значение из файлов, а не то, что вы вручную выставили на соседней карте. Второе: файлы из /usr/lib/sysctl.d, /run/sysctl.d и /etc/sysctl.d сортируются вместе по имени, и при конфликте побеждает файл с лексикографически последним именем. Одноимённый файл в /etc целиком заменяет вендорский. Поэтому свои настройки я называю с префиксом 99- и после правки проверяю итог, а не содержимое своего файла:
sudo sysctl --system 2>&1 | grep -E '^\* Applying|rp_filter'
systemd-analyze cat-config sysctl.d | grep -n rp_filter
sysctl -a 2>/dev/null | grep '\.rp_filter'Вторая команда показывает все фрагменты в порядке применения, третья — что в итоге оказалось в ядре на каждой карте.
- Ядро: `rp_filter = 0` для всех уровней.
- systemd `50-default.conf`: `default` и `*` = 2, `all` исключён из маски.
- Ubuntu: `10-network-security.conf` задаёт 2 для `all` и `default`.
- Debian 13: тот же `50-default.conf` из пакета `linux-sysctl-defaults`.
- RHEL 9: `50-redhat.conf` задаёт `default.rp_filter = 1`.
Когда loose mode оправдан и что оставить в эксплуатации
Иногда асимметрия является частью архитектуры: несколько uplink с динамической маршрутизацией, ECMP, туннели, TPROXY или разнесённые входной и выходной маршрутизаторы. Тогда симметризовать каждый поток нельзя или не нужно. В таком проекте я выставляю rp_filter=2 на задействованных интерфейсах и документирую причину. Это не капитуляция перед безопасностью: фильтрацию spoofed-источников разумнее делать там, где известны допустимые префиксы, — ACL на пограничном маршрутизаторе, firewall или правилами провайдера.
Полностью отключаю проверку, то есть использую 0, только когда даже loose lookup противоречит задумке либо источник появляется до установки необходимого маршрута. Ставить all=0 и забывать о параметре на всех картах я не люблю. Для обычных внешних интерфейсов оставляю строгий режим, для осознанно асимметричных — loose. Постоянный файл после исправления нашего стенда был таким:
# /etc/sysctl.d/99-rp-filter.conf
net.ipv4.conf.all.rp_filter = 0
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.enp1s0.rp_filter = 1
net.ipv4.conf.enp2s0.rp_filter = 1
net.ipv4.conf.all.log_martians = 1Применение и контроль:
sudo sysctl --system
sysctl net.ipv4.conf.all.rp_filter \
net.ipv4.conf.default.rp_filter \
net.ipv4.conf.enp1s0.rp_filter \
net.ipv4.conf.enp2s0.rp_filterГлобальный all=0 здесь выбран намеренно, чтобы эффективный режим задавался каждой картой. default=1 задаёт режим для интерфейсов, созданных позже, но на Ubuntu и Debian маска net.ipv4.conf.*.rp_filter = 2 из 50-default.conf применяется при появлении каждой карты и перекрывает default; поэтому для VPN, VLAN и контейнерных устройств значение надо задавать явно или проверять после их подъёма.
Если маршрутизация строится по fwmark, помните о net.ipv4.conf.*.src_valid_mark. По умолчанию метка пакета не включается в обратный lookup. Значение 1 нужно, когда одна и та же маркировка действительно участвует в маршрутизации обоих направлений; одной строки sysctl без корректной установки и восстановления mark недостаточно. Я не включаю этот параметр «на всякий случай», потому что ошибочная метка способна создать новую, гораздо менее очевидную асимметрию.
Мой порядок приоритетов короткий. Сначала докажите, какой обратный маршрут рассчитывает ядро. Затем, если асимметрия случайна, исправьте policy routing и сохраните strict mode. Если она предусмотрена проектом — используйте loose mode и перенесите реальную антиспуфинг-проверку на границу сети. На бесконечное перекладывание правил INPUT можно смело забить: пока не проходит проверка источника, до этих правил пакет всё равно не доберётся.
- Случайная асимметрия: отдельные таблицы и правила по исходному адресу.
- Предусмотренная асимметрия: `rp_filter=2` на конкретных интерфейсах.
- Маршрутизация по меткам: проверить `src_valid_mark` и жизненный цикл `fwmark`.
- После перезагрузки: повторить `sysctl`, `ip rule`, `ip route get` и внешний тест.
Частые вопросы
Почему tcpdump видит пакет, если Linux его отбросил?
`tcpdump` получает копию кадра через AF_PACKET раньше, чем IPv4-стек завершает поиск входного маршрута и проверку источника. Захват подтверждает приём интерфейсом, но не доставку в INPUT или приложение.
Почему разрешающее правило nftables не помогает?
При провале `rp_filter` пакет не доходит до цепочки INPUT. При этом правила PREROUTING могут его увидеть, поэтому важно понимать, счётчик какой именно цепочки вы проверяете.
Можно ли просто установить rp_filter=0?
Для короткого диагностического теста — да, если есть безопасный доступ к серверу. Постоянно я предпочитаю исправить policy routing. При намеренной асимметрии обычно достаточно loose mode со значением `2`.
Почему значение 0 на интерфейсе не отключило проверку?
Ядро берёт максимальное значение из `conf/all/rp_filter` и `conf/<интерфейс>/rp_filter`. Если глобально стоит `1`, интерфейсное значение `0` не даст эффекта.
Какое значение rp_filter стоит по умолчанию?
В самом ядре — `0`. Но дистрибутивы переопределяют его: systemd в `50-default.conf` ставит `2` для `default` и всех интерфейсов по маске, Ubuntu дополнительно задаёт `2` в `10-network-security.conf`, RHEL 9 — `1` в `50-redhat.conf`. Смотрите фактические значения через `sysctl`, а не документацию.
Почему значение, выставленное через sysctl -w, пропало?
`sysctl -w` меняет только текущее состояние ядра. После перезагрузки, а для новых интерфейсов и при их появлении, systemd-sysctl снова применит файлы из `/usr/lib/sysctl.d` и `/etc/sysctl.d`. Постоянную настройку кладите в `/etc/sysctl.d/` с именем, которое по алфавиту идёт после `50-default.conf`.
Достаточно ли посмотреть обычный ip route?
Нет. Нужно учитывать `ip rule` и все задействованные таблицы. Входящий пакет моделируйте командой `ip route get <локальный адрес> from <удалённый адрес> iif <карта>` — при провале проверки она вернёт `Invalid cross-device link`, а ответ — командой `ip route get <удалённый адрес> from <локальный адрес>`.
Работает ли rp_filter для IPv6?
Нет, описанный sysctl находится в пространстве `net.ipv4.conf`. Политику проверки источников IPv6 нужно реализовывать и тестировать отдельно.
Источники
- Linux Kernel Documentation — IP Sysctl (Documentation/networking/ip-sysctl.rst), разделы `rp_filter` (правило max из conf/{all,interface}), `log_martians`, `src_valid_mark`, документация ядра 6.18: https://www.kernel.org/doc/html/v6.18/networking/ip-sysctl.html
- Linux man-pages — packet(7), описание AF_PACKET и передачи входящих пакетов пакетному сокету до протоколов ядра, man-pages 6.16: https://man7.org/linux/man-pages/man7/packet.7.html
- iproute2 manual — ip-rule(8), Routing Policy Database, селекторы, таблицы и порядок приоритетов: https://man7.org/linux/man-pages/man8/ip-rule.8.html
- iproute2 manual — ip-route(8), таблицы маршрутизации и режим `ip route get`: https://man7.org/linux/man-pages/man8/ip-route.8.html
- Netplan Documentation — YAML configuration, раздел Routing: `routes`, `table`, `routing-policy` и `priority`: https://netplan.readthedocs.io/en/latest/netplan-yaml/#routing
- RFC Editor — RFC 3704, Ingress Filtering for Multihomed Networks, strict и loose Reverse Path Forwarding: https://www.rfc-editor.org/rfc/rfc3704.html
- systemd manual — sysctl.d(5): порядок применения файлов, маски `net.ipv4.conf.*` и исключение ключа префиксом `-`: https://man7.org/linux/man-pages/man5/sysctl.d.5.html
- systemd source — sysctl.d/50-default.conf, строки `rp_filter` для `default`, `*` и исключение `all`: https://github.com/systemd/systemd/blob/main/sysctl.d/50-default.conf
- Debian Release Notes — Debian 13 trixie, Issues to be aware of: пакет `linux-sysctl-defaults` и отказ systemd-sysctl от `/etc/sysctl.conf`: https://www.debian.org/releases/trixie/release-notes/issues.html
