Пометка трафика из одной cgroup — простая часть. Три отдельных решения должны совпасть, прежде чем одно приложение выйдет через физический интерфейс, а всё остальное останется в туннеле: какой сокет относится к исключению, какой поиск маршрута запускает метка и какой адрес источника несёт пакет при выходе. Большинство неудачных попыток правильно делают первое, наполовину второе и никогда не смотрят на третье.

Три решения, а не одно

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

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

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

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

Сопоставь cgroup, которая будет существовать и завтра

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

nft list ruleset
systemctl status session.slice 2>/dev/null
cat /proc/self/cgroup

Копирование имени scope из одной сессии и жёсткая запись дают правило, которое работает сегодня и молча перестаёт совпадать завтра. Вместо этого помести приложение в осознанный, стабильный контекст выполнения, например в отдельный scope или unit, которым ты управляешь, и сопоставляй с ним. Тогда правило переживает перезагрузки и обновления пакетов. Оно также становится тем, о чём коллега может рассуждать без истории твоей сессии.

Будь также точен в том, какой механизм используешь. Классификация net_cls в cgroup v1 и сопоставление сокетов в cgroup v2 — разные вещи, и похожее по названию поле в документации не делает их взаимозаменяемыми.

Второй поиск запускает цепочка route

Для локально созданного трафика тип цепочки решает, произойдёт ли что-то после установки метки. Цепочка output типа filter может пометить пакет, и ядро продолжит использовать уже выбранный маршрут. Цепочка типа route — это механизм, который запрашивает новый поиск.

table inet bypass {
  chain mark {
    type route hook output priority mangle; policy accept;
    socket cgroupv2 level 2 "app.slice" meta mark set 0x1 comment "app bypass"
  }
}
ip rule add fwmark 0x1 lookup 200
ip route add default via 203.0.113.1 dev wlan0 table 200

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

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

Адрес источника не следует за маршрутом

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

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

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

table inet bypass {
  chain postrouting {
    type nat hook postrouting priority srcnat; policy accept;
    meta mark 0x1 oifname "wlan0" masquerade
  }
}

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

Что всё ещё ускользает от исключения

Разрешение имён может приходить от отдельного процесса resolver в другой cgroup, поэтому классификация сокета браузера не классифицирует его запросы. Если запросы resolver остаются в туннеле, а соединение выходит за его пределы, ответы split-horizon могут не совпадать с путём. Это несовпадение даёт сбои, похожие на ошибки DNS, которыми они не являются.

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

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

Похожие статьи