Короткий ответ
В stateful filter пакет идёт сверху вниз в цепи и берёт первое совпадение. Нижнее allow не лечит верхний drop; нижний drop не закрывает верхний any-any. Shadowing — когда широкое правило выше узкого и всегда срабатывает первым. Лечение: выгрузить цепь, пометить пересечения, переставить, удалить мёртвое. Не flush, не policy accept, не дублировать allow «на всякий» в конце.
На pfSense/OPNsense та же идея first-match на интерфейсе; floating с порядком выше интерфейсных. Не выдумывайте отдельный CLI вендора: читайте номер правила и states.
WAN 203.0.113.10, LAN 10.0.10.0/24, DMZ 10.0.30.0/24, mgmt 10.0.99.0/24.
Симптомы и как отличить
Админ добавляет accept LAN→DMZ:443 в конец, сервис мёртв. В середине цепи уже есть drop ip saddr 10.0.10.0/24 oifname dmz0. Или наоборот: в начале leftover accept ip protocol tcp, а «жёсткий» drop внизу никогда не работает — лишние порты.
| Признак | Смысл |
|---|---|
| Allow counter = 0, drop выше растёт | shadowing allow |
| Drop counter = 0, allow выше широкий | shadowing drop, дыра |
| Два одинаковых allow | мёртвый дубль |
| Работает после disable правила №3 | №3 было первым матчем |
Отличие от «сервис не слушает»: на хосте ss LISTEN, пакет виден на in-iface tcpdump до фильтра и не виден на out после. Отличие от блокирует сервис: там ищем какой drop; здесь — почему правильный allow не бьётся.
Возможные причины
- Копипаст «временного» drop VLAN выше рабочих allow.
- Any-any или
accept in-interface=LANв начале — any-any в проде. - Пересечение address-list: хост в двух листах, первое правило шире.
- IPv4-правило vs клиент IPv6 (разные цепи, «противоречие» ложное).
- raw/prerouting drop до filter (не видно в chain forward).
- На RouterOS jump в custom chain, return, затем drop — человек смотрит не ту цепь.
- FastTrack: пакет не доходит до нижних правил с action=log.
Диагностика
1. Снимок цепи
sudo nft list ruleset -a > /root/fw-shadow-$(date +%F).nft/export file=fw-shadow
/ip firewall filter print without-paging
/ip firewall raw print
/ip firewall nat printРазложите одну цепь (forward или input). Не мешайте NAT и filter в одной «простыне без номеров».
2. Выберите один 5-tuple заявки
Пример: 10.0.10.50 → 10.0.30.10 tcp/443. Пройдите глазами каждое правило: матчится? Если да — это победитель, всё ниже для этого пакета мертво.
Запишите в таблицу:
| № / handle | match 5-tuple? | action | counter |
|---|---|---|---|
| established | да, если state | accept | большой |
| drop LAN→DMZ | да для new | drop | растёт при клике |
| allow :443 | да, но не достижимо | accept | 0 |
3. Трассировка без отключения фильтра
nft:
sudo nft add rule inet filter forward position 0 \
ip saddr 10.0.10.50 ip daddr 10.0.30.10 tcp dport 443 \
counter log prefix "SHDW " flags allОдин пакет — одна серия log с hook. Удалите handle после съёма.
RouterOS: /tool torch или временный action=log вверху, затем сравнение с stats нижних. Не action=accept на всё.
4. Address-list и множества
/ip firewall address-list print where address=10.0.10.50sudo nft list set inet filter mgmt_netХост в mgmt и в guests одновременно — первое правило с этим множеством решает судьбу.
На концептуальном pf: pfctl -s rules с номерами, pfctl -v -s rules — evaluations. Первое matching pass/block.
Решение
Сценарий A. Узкий allow ниже широкого drop
Переставьте allow выше drop, сохраняя established первым:
Порядок forward:
- ct/connection established,related accept
- allow LAN
10.0.10.0/24→10.0.30.10tcp 443 - drop LAN → DMZ (остальное)
- drop invalid / drop all
nft: правьте файл, nft -c -f, reload. Не набор nft insert вслепую без выгрузки.
RouterOS: move номера allow перед drop. Комментарий на обоих.
Сценарий B. Широкий allow выше «запрета админки»
Если в input первым стоит accept tcp dport 80,443 с WAN — GUI торчит, нижний drop www бесполезен. Сужайте верхнее: не WAN, а LAN/mgmt, либо уберите www с 0.0.0.0. Это чинится как админка, но корень — порядок.
Сценарий C. Дубли
Два одинаковых accept — удалите нижний после сверки counters (должен быть 0). Документируйте в ревизии.
Сценарий D. Raw/prerouting
sudo nft list table inet raw/ip firewall raw print statsdrop в raw notrack обходит ожидания по filter. Перенесите политику в filter либо явно опишите raw в матрице.
Сценарий E. Jump-цепи
Нарисуйте: forward → jump LAN-in → return → drop. Allow должен жить в той цепи, куда пакет jump'ает. Allow в корне ниже jump+drop не спасёт, если jump не вернулся.
Откат: nft -f снимка, /import export, backup RouterOS.
Как проверить, что проблема устранена
- Для контрольного 5-tuple растёт один осмысленный allow, не drop выше.
- Старый широкий drop всё ещё ловит нежелательное (проверьте отрицательный тест: порт не из матрицы с LAN в DMZ должен падать).
- Скан своего WAN
203.0.113.10не стал шире. - В выгрузке нет двух правил с одинаковым match и разным action.
sudo nft list ruleset -aПередайте ревьюеру таблицу handle/match/action/counter по заявке.
Если не помогло
- Пакет в другой цепи (input vs forward, Docker/bridge).
- NAT меняет dst до filter — смотрите conntrack
dstпосле DNAT. - IPS/GeoIP вне этой таблицы.
- Клиент идёт на другое имя/VIP.
- Правило на
tcp, трафикudp(QUIC).
Профилактика
- Стиль: established → allow узкие → drop групповые → drop all.
- Запрет двух правил на один 5-tuple без комментария «shadow intentional» (таких почти не бывает).
- Ревизия раз в квартал: нулевые allow старше 90 дней — кандидат на удаление.
- Лог критичных drop помогает ловить регресс порядка.
- Имена interface-list, не голые ether, после перепатчевания портов.
FAQ
nft не first-match?
Обычные filter-цепи с accept/drop — да, первое совпадение (если нет continue/вердикта, который пропускает дальше). Не пишите набор accept ниже drop того же матча.
Нужно ли «последнее правило wins» как в ACL Cisco interface в другую сторону?
На IOS некоторые ACL разные. На nft/RouterOS filter/pf — first-match. Не переносите привычку «нижнее перекрывает» без проверки.
Можно ли оставить shadowed allow «для документации»?
Нет. Документация — матрица и комментарий. Мёртвое правило врёт на аудите.
insert vs add в RouterOS
add без place-before — в конец. Именно так allow оказывается под drop. Всегда указывайте место.
Почему после move «починилось», но ночью снова сломалось?
Автоскрипт/Ansible заливает старый порядок. Чините источник, не только live.