Обход VPN для одного порта требует policy routing
Правило NAT переписывает адреса, но не выбирает исходящий канал. Вот правило, таблица и метка, которые действительно удерживают один порт TCP вне туннеля.
Запрос всегда является вариацией одной и той же фразы: оставьте VPN для браузера, но разрешите SSH или заданию резервного копирования использовать обычное соединение. Совет, который ходит по этому поводу, — пробросить порт правилами NAT в iptables, — этого не делает. Правило NAT переписывает адреса и порты внутри потока; оно никогда не выбирает интерфейс, через который уходит пакет. Это решение относится к поиску маршрута, а поиску маршрута нужна помощь, чтобы увидеть порт.
Почему обычный маршрут не может совпасть с портом
Linux выбирает маршрут, ища адрес назначения в таблице, после фильтрации таблиц-кандидатов правилами политики. Эти правила могут совпасть с меткой firewall, адресом источника, входящим интерфейсом или UID. У самой записи маршрута нет поля порта, потому что маршрут выбирается раньше, чем заголовок транспорта начинает иметь значение.
Текущие iproute2 и ядра расширяют селектор правил: ipproto, sport и dport принимаются, поэтому ip rule add ipproto tcp dport 22 table 100 выражает намерение одной строкой. Запустите ip rule add help на реальном хосте, чтобы увидеть, перечисляет ли ваша сборка dport. Если да, вы обходитесь одной командой; если нет, путь через метку ниже является эквивалентом.
Так или иначе, правило — лишь указатель. Таблице 100 всё ещё нужен маршрут, и именно эту часть люди забывают.
Три части или одна
Современная форма — одно правило выше плюс маршрут в альтернативной таблице. Классическая форма добавляет netfilter посередине: пометьте пакет, затем сопоставьте метку в правиле. Оба варианта заканчиваются одним и тем же поиском; различаются они тем, где читается порт.
Подход с меткой существует потому, что netfilter видит заголовок транспорта, а код маршрутизации исторически его не видел. В этом весь механизм, и это объясняет, почему запасной путь — три взаимодействующие части, а не одна хитрая команда.
Три части по порядку
Сначала пометьте потоки, которые хотите отклонить, в цепочке, которая запускается достаточно рано, чтобы метка существовала до решения о маршруте:
nft add table inet vpnexempt
nft 'add chain inet vpnexempt out { type route hook output priority mangle; }'
nft add rule inet vpnexempt out tcp dport 22 meta mark set 0x1Во-вторых, отправьте помеченные пакеты в отдельную таблицу:
ip rule add fwmark 0x1/0x1 table 100В-третьих, дайте этой таблице маршрут по умолчанию через физический канал. Адреса приведены для примера; подставьте свои:
ip route add default via 192.168.1.1 dev wlan0 table 100Пропустите третий шаг, и помеченный пакет не найдёт маршрута в таблице 100, поэтому он не пройдёт. Симптом выглядит так, будто правило сломало сеть, хотя на деле оно убрало единственный оставшийся у пакета путь.
Что удерживает это в рабочем состоянии после первого пакета
Метки применяются к исходящим пакетам. Ответ приходит на физический интерфейс с адресом, который назначил этот канал, и основная таблица может по-прежнему предпочитать туннель для этого назначения. Два механизма сохраняют согласованность обратного пути: правило на основе источника для адреса канала и connection tracking, который сохраняет решение на всё время жизни потока. Сохранение метки в conntrack и её восстановление при ответе — обычный способ сделать исключение с состоянием устойчивым.
Фильтрация обратного пути — второй молчаливый убийца. Если ядро строго проверяет обратный путь, а ответ приходит на интерфейс, который основная таблица не выбрала бы, пакет отбрасывается без сообщения. rp_filter значения по умолчанию различаются по дистрибутивам и зависят от того, пересылается ли интерфейс, поэтому читайте действующее значение, а не предполагайте, что оно выключено.
Не помечайте собственный транспорт туннеля. Туннель WireGuard или UDP OpenVPN — это не TCP 22, поэтому рецепт выше безопасен, но исключение, записанное как всё, кроме назначения туннеля, может захватить зашифрованные пакеты и сломать туннель так, что это будет выглядеть как петля маршрутизации.
Проверяйте, а не предполагайте
Спросите ядро, какой путь оно выберет, для назначения и для порта, затем выполните реальную передачу:
ip route get 203.0.113.10
ip rule show
nft list rulesetЕсли метка есть, но счётчики никогда не растут, правило не совпадает. Счётчики — это разница между конфигурацией, которую вы написали, и политикой, которую применяет ядро.
Когда это неподходящий инструмент
Исключения по порту хрупки по своей природе. Службы меняют порты, HTTP/3 приходит как UDP 443, а одно приложение может открыть несколько потоков разных типов. Если настоящее требование в том, чтобы эта программа использовала обычный канал, уровень маршрутизации — неподходящее место; сетевое пространство имён или прокси на приложение подходят лучше.
Если требование в том, чтобы эта подсеть использовала обычный канал, вовсе откажитесь от меток и добавьте более конкретный маршрут. Это проще, это видно в одной таблице, и следующий человек сможет проверить это, не читая набор правил.
Запишите, какое из трёх требований у вас на самом деле, прежде чем трогать firewall. Механизм выше точен, а точность, применённая не к тому уровню, — это просто более длинный простой.