Правила для портов 80 и 443 не отправляют браузер через VPN
Совпадение по порту выбирает TCP-потоки, а не браузер. HTTP/3 работает поверх UDP, DNS может пойти другим путём, а средство обновления попадает под то же правило, которое вы написали.
Кто-то просит, чтобы браузер использовал туннель, и ответ возвращается в виде правила межсетевого экрана, совпадающего с портами назначения 80 и 443. Оно выглядит точным и таковым не является. Совпадение по порту выбирает пакеты по заголовку транспорта, а не по тому, какая программа открыла сокет, и современный просмотр не ограничивается этими двумя портами на TCP. Правило пропустит часть трафика, который вы хотели включить, и поймает трафик, который вы никогда не собирались трогать. Прежде чем писать его, решите, какая из трёх разных границ вам действительно нужна.
Правило описывает класс транспорта, а не приложение
В Linux совпадение по порту назначения проверяет заголовок TCP и ничего выше него. Маркировка этих пакетов и направление их по политическому маршруту работает точно так, как описано, и ни один из этих механизмов не знает, что означает полезная нагрузка и какой процесс её отправил. Метка — это число, прикреплённое к пакету, а не идентичность, прикреплённая к приложению.
nft add rule inet mangle output tcp dport { 80, 443 } meta mark set 0x1
ip rule add fwmark 0x1 table 100
ip route add default dev wg0 table 100
Сразу следуют два последствия. Средство обновления программ, которое обращается к TCP 443, получает такое же обращение, как браузер, и это может быть нормально, а может быть сбоем, ждущим следующего цикла обновления. А веб-служба, намеренно опубликованная на другом порту, скажем 8443, полностью выпадает из правила, хотя это всё ещё тот веб-трафик, который был важен пользователю.
Числа — законные селекторы, когда требование действительно о тех портах. Они становятся фикцией, когда в заявке написано «браузер», а в наборе правил реализован диапазон портов. Этот разрыв не является мелочью документации. Это разница между правилом, которое вы можете защитить на разборе, и таким, которое никто не может проверить.
HTTP/3 тихо покидает ваше правило TCP
HTTP/3 работает поверх QUIC, а QUIC переносится в UDP. Правило, совпадающее только с TCP, не может его увидеть, поэтому тот же браузер, который проходит вашу тестовую страницу, может отправить более поздний запрос по UDP и обойти туннель, никто при этом не меняет настройку. RFC 9114 описывает HTTP/3 как протокол на основе QUIC, а QUIC использует UDP с портом по умолчанию 443 в обычном развёртывании.
Попытается ли конкретный браузер HTTP/3, зависит от его конфигурации, от сайта и от того, удалась ли предыдущая попытка. Поэтому рассуждать об этом по памяти плохо. Спросите инструмент:
curl --http3 -sS -o /dev/null -w '%{http_version} %{time_total}\n' https://example.com
ss -tunp | grep -E ':443\b'
Строка curl печатает согласованную версию, поэтому 3 подтверждает, что использовался путь через UDP. Затем список сокетов показывает, привязано ли это соединение к интерфейсу туннеля или к физическому. Если HTTP/3 нет в вашей сборке curl, флаг громко падает, а не тихо отступает, и это его собственная маленькая милость.
DNS — отдельное решение, которое нужно принять
Правило порта для веб-трафика ничего не говорит о разрешении имён. Браузер может использовать системный распознаватель, распознаватель, настроенный внутри браузера, или тот, который подсунуло последнее соединение. Любой из них может отправлять запросы другим путём, не тем, который выбирает ваше правило, и это даёт классический раскол: страница загружается, имя разрешается где-то, где вы не ожидали, а внутренний сайт разрешается в публичный адрес или не разрешается вовсе.
Решите это явно. Если внутренние имена должны разрешаться через туннель, распознаватель должен быть внутри него, а набор правил должен разрешать DNS к этому распознавателю, удерживая остальные запросы снаружи. Если браузер использует DNS over HTTPS к публичному провайдеру, запрос полностью обходит ваш распознаватель, и правило порта не может помочь. Запишите выбор в тот же журнал изменений, что и правило маршрутизации, потому что оба читает вместе тот, кто будет отлаживать следующий сбой.
Затем проверьте распознавателем, а не страницей:
resolvectl query intranet.example.internal
resolvectl status | grep -A3 'Current DNS Server'
Отсутствующий ответ — это проблема разрешения, даже когда сообщение об ошибке браузера обвиняет сеть. Эти два сбоя выглядят для пользователя одинаково и являются совершенно разными заявками.
Три границы, три разных механизма
Большая часть путаницы исчезает, как только вы назовёте нужную границу. Они не взаимозаменяемы, и у каждой свой режим отказа при обрыве туннеля.
| What you actually want | Mechanism that fits | What it does not cover |
|---|---|---|
| These transport ports use this exit | Firewall mark plus policy route | Applications, HTTP/3 over UDP, DNS |
| This browsing session stays separate | Application-aware VPN, or a separate network namespace plus a proxy | Other applications in the same session |
| Only these private sites are reachable | Destination-based routes with private DNS | Public sites on the same address family |
Отдельный профиль браузера — это ловушка в средней строке. Он разделяет файлы cookie и историю и ничего не меняет в таблице маршрутизации хоста, поэтому оба профиля идут одним путём. Изоляция требует того, что обеспечивает ядро, то есть пространства имён, контейнера или приложения с собственным интерфейсом туннеля.
Какая бы строка ни относилась к делу, запишите, что должно происходить при падении туннеля. Маршрут на основе метки, исчезающий вместе с интерфейсом, обычно возвращается к физическому маршруту по умолчанию, а это поведение fail-open. Это может быть приемлемо для просмотра и неприемлемо для всего остального, и это решение, а не деталь реализации.
Проверяйте потоки, которые якобы охватываете
Приёмочный тест должен следовать границе, которую вы записали, а не удобным номерам портов. Выберите один поток, который должен идти через туннель, один, который не должен, и один, использующий транспорт, о котором вы не думали:
ip route get 203.0.113.40
curl -sS -o /dev/null -w 'v4 %{http_version} %{remote_ip}\n' https://example.com
nft list ruleset | grep -A2 'dport'
Поиск маршрута сообщает, какой интерфейс назначение действительно использует, и это тот факт, который большинство отчётов указывают неверно. Строка curl подтверждает согласованную версию и семейство адресов. Выгрузка набора правил ловит правило, добавленное во время тестирования и забытое ограничить, и так временная метка становится постоянной маршрутизацией fail-open.
Зафиксируйте все три вывода с отметкой времени и храните их вместе с изменением. Когда позже кто-то сообщит, что «VPN сломал интранет», разница между задуманной и реализованной границей будет видна в этой записи, и исправлением станет изменение правила, а не спор.