Описание проблемы
Ситуация: у нас имеется один интерфейс eth0, «смотрящий» в интернет, с IP-адресом 192.168.11.11/24 и шлюзом 192.168.11.1. Нам нужно организовать интерфейс vpn0, который будет через VPN соединяться с неким сервером, и весь исходящий с этой машины трафик должен идти через этот интерфейс vpn0.
Примечание: я оставляю за скобками детали работы с IPv6, поскольку там хватает своих особенностей. Рассматривается только ситуация с IPv4.
Итак, мы берём в руки программу для подключения в VPN-у — она соединяется с неким VPN-сервером по адресу 10.10.10.10 и поднимает нам интерфейс vpn0 например с таким адресом: 192.168.120.10/24, шлюз 192.168.120.1. Казалось бы, всё хорошо, пинги через vpn0 ходят, коннект есть, он стабильный, осталось только прописать нечто вроде
ip route add default dev vpn0 metric 1000
чтобы перенаправить все соединения через новый интерфейс и…И всё благополучно падает. Пропадает интернет, отваливается VPN, отключается ssh (если вы по нему подключены к хосту). Если приложение VPN-а не выключит интерфейс при потере соединения, то извне вы до этого хоста до ребута больше не подсоединитесь.
Что случилось?
Прежде всего, небольшое предупреждение: данная статья, скорее всего, не будет являться чем-то новым для опытных системных администраторов. Однако, для тех кто не столь глубоко погружён в то, как работает роутинг в Linux, она может оказаться полезной. Впрочем, быть может второй способ решения этой проблемы, описанный здесь, будет интересен и тем, кто разбирался с ней, но более «статически».
О роутинге в общем
На самом деле, такое поведение было ожидаемо. Более того, часть популярных VPN приложений выполняют специальные действия, чтобы избежать ровно этой же проблемы. Проблема истекает из того, как именно работает роутинг.
Дело в том, что правила роутинга действуют на все пакеты, исходящие с хоста. И когда вы меняете default правила, пакеты VPN-приложения тоже начинают идти через новый интерфейс. Получается ситуация, которую можно вкратце описать словом «уроборос» — да-да, та самая мифическая змея, кусающая сама себя за хвост. VPN пытается соединиться со своим сервером через сам себя. Конечно же, это у него не получится.
Но это полбеды. Дело в том, что ещё и все исходящие пакеты, которые отправляются в ответ на входящие, также подчиняются роутингу. И даже если входящий пакет пришёл с одного интерфейса, то ответ ему может уйти вообще с другого. Для того, кто пытается подключиться извне отправленные и исходящие пакеты могут вообще приходить с разных IP-серверов, из-за чего он даже не сможет найти вообще эти ответы — ведь с точки зрения отправителя это будут абсолютно разные коннекты! Именно по этой причине вы не сможете подсоединиться по ssh к серверу, чтобы исправить ошибку.
Решение «в лоб»
Это решение, которое применяет большинство VPN-приложений в автоматическом режиме, и которое решает только и исключительно вопрос соединения с VPN-сервером.
Давайте просто добавим более специфическое правило роутинга до IP-адреса, на котором у нас находится наш VPN-сервер! По сути, нужно выполнить нечто вроде
ip route add 10.10.10.10 dev eth0
Теперь VPN-приложение больше не будет «кусать себя за хвост», потому что любые коннекты к VPN-серверу по адресу 10.10.10.10 будут безусловно направляться через eth0. Проблема, правда, в том, что и любые другие коннекты к 10.10.10.10 также не будут завёрнуты в VPN-соединение, но это не та проблема, которая сильно критична? Наверное?.. Может быть?.. Исключая очень редкие ситуации?..Но есть одна проблема, которая никуда не исчезнет. Вы так и не сможете соединиться с сервером извне, только разве что обращаясь через тот же VPN к адресу 192.168.120.10, да и то это зависит от настроек этого самого VPN-сервера. То есть при соединении с VPN-сервером, вы опять потеряете ssh к этому хосту.
«Классическое» решение
Проблема эта не сказать что новая — она существует, наверное, со времён появления систем с двумя интерфейсами (неважно, реальными или виртуальными), но как ни странно, нагуглить решение хоть и получается, но не прямо «сходу». Лично я это решение нашёл вот в этой статье.
Суть заключается в использовании routing policy rules и отдельных правил роутинга для подобных ситуаций. В нашем случае:
- Создаём новую routing таблицу, которая по дефолту не будет использоваться для принятия решений по роутингу, и внесём в неё eth0 как default gateway. Так, в Linux можно сделать 4294967295 таких таблиц (в ядрах постарше, 2.2 и 2.4 — до 255 штук). Пусть наша таблица будет таблица номер 2:
ip route add default via 192.168.11.1 dev eth0 src 192.168.11.11 table 2
- Создаём новые routing policy rule для применения этого правила ко всем соответствующим соединениям:
# Использовать таблицу 2 для всех соединений, исходящих с IP ip rule add from 192.168.11.11/32 table 2 # Использовать таблицу 2 для всех соединений, приходящих на IP ip rule add to 192.168.11.11/32 table 2
- О чудо, всё работает!
Суть этого решения в том, что только пакеты, которые не удовлетворили ни единому правилу, обрабатываются стандартной роутинг-таблицей. Те же, что удовлетворяют внесённым правилам о приходе или уходе пакетов с конкретного IP, обрабатываются таблицей 2 — где, как мы видим, eth0 всё ещё является default gateway.
Это решение отлично подходит в случае, если вы заведомо знаете IP-адрес eth0 и адрес его маршрутизатора — по сути подходит только для решений, сконфигурированных статически.
А что делать, если у вас, например, и eth0, и vpn0 получают информацию об IP и маршрутах по DHCP? То есть у вас полностью динамическая конфигурация? Нет, можно, конечно, использовать dhcpc вручную с кастомным скриптом настройки интерфейса, но, честно, это геморрой тот ещё. Особенно, когда вместо настройки «на коленке» хочется использовать systemd-networkd конфигурацию — у неё как раз есть DHCP-клиент. Возможно ли это? Да, возможно!
На помощь приходит iptables
Дело всё в том, что routing policy rules может применять правила не только на основании статических правил вроде IP-адреса. Нет, у него имеется ещё одна очень гибкая опция — fwmark. Эта опция позволяет принимать решения на основе решений, принятых netfilter-ом — в народе его чаще знают под именем iptables (хотя, я полагаю, аналогичный конфиг можно написать и на nftables). Достаточно с помощью iptables отмаркировать нужные нам пакеты и на основе этой маркировки отправить их на обработку в нужную routing таблицу!
- Так же как и в «классическом» варианте, настраиваем альтернативную routing-таблицу:
ip route add default via 192.168.11.1 dev eth0 src 192.168.11.11 table 2
- Добавляем routing policy rule, который все пакеты с маркировкой «2» будет отправлять в эту таблицу:
ip rule add fwmark 2 table 2
- А теперь самая сложная магия. Мы помечаем пакеты, пришедшие на eth0, либо ушедшие с eth0, маркировкой «2», и при этом сохраняем эту маркировку для всех связанных пакетов (ведь iptables — это stateful фаерволл, и мы можем на основании этого пометить все пакеты, относящиеся к одному соединению — для этого используется CONNMARK):
# Сначала правила для входящих пакетов iptables -t mangle -A PREROUTING -j CONNMARK --restore-mark iptables -t mangle -A PREROUTING -m mark --mark 2 -j ACCEPT iptables -t mangle -A PREROUTING -i eth0 -j MARK --set-mark 2 iptables -t mangle -A PREROUTING -j CONNMARK --save-mark # Затем правила для исходящих пакетов iptables -t mangle -A OUTPUT -j CONNMARK --restore-mark iptables -t mangle -A OUTPUT -m mark --mark 2 -j ACCEPT iptables -t mangle -A OUTPUT -o eth0 -j MARK --set-mark 2 iptables -t mangle -A OUTPUT -j CONNMARK --save-mark
- И вновь — всё работает!
Но позвольте, скажете вы, мы всё равно указывали в настройках адрес шлюза для eth0, а не использовал полученный от DHCP! Как же так?
Используем systemd-networkd файлы настройки
Да, я знаю, что далеко не все любят systemd, поэтому оставил это напоследок. Лично мне очень нравится возможность настроить интерфейсы при помощи *.network файлов, и сейчас я соединю все идеи, высказанные в этой статье в синтаксисе именно этих файлов. Мы не будем использовать ни единой команды
ip
, хоть нам всё ещё и понадобится настроить iptables-правила — один раз и «навсегда».Также, именно такая конфигурация позволяет сформировать таблицу «2» в полностью автоматическом режиме, с подхватыванием настройки от DHCP.
- Конфигурация eth0 (
eth0.network
, размещается в/etc/systemd/network
):
[Match] Name=eth0 [Network] DHCP=true # Определяем содержимое таблицы "2" # Поскольку мы не указываем Destination, по дефолту он считается как default # Ровно то что нам нужно! # Аналог команды ip route add default via <шлюз, полученный по DHCP> dev eth0 table 2 [Route] Gateway=_dhcp4 Table=2 # А это - аналог команды ip rule add fwmark 2 table 2 [RoutingPolicyRule] Table=2 FirewallMark=2
- Конфигурация vpn0 (
vpn0.network
, размещается в/etc/systemd/network
):
[Match] Name=vpn0 [Network] DHCP=true [DHCPv4] # Отключаем получение classless routes от DHCP # Если удалённый DHCP-сервер предоставляет classless routes, # DHCP-клиент игнорирует настройку default gateway, поэтому # нужно её принудительно отключить UseRoutes=false # Включаем настройку default gateway # По дефолту UseGateway = UseRoutes, но поскольку мы поменяли UseRoutes # необходимо включить обратно здесь UseGateway=true # По дефолту метрика - 1024 для default gw для всех интерфейсов, # ставим любую ниже чем 1024 RouteMetric=1000 [Link] # Это нужно чтобы хост при загрузке не подвисал, пытаясь настроить интерфейс, # которого ещё нет - он появится позже, при запуске VPN-клиента, # и автоматически подхватится systemd-networkd RequiredForOnline=no
- Конфигурация iptables-persistent правил (для Debian 11, при условии установленного пакета iptables-persistent) — перед выполнением убедитесь, что в настоящее время у вас нет активных правил iptables, которые вы НЕ хотите сохранять:
iptables -t mangle -A PREROUTING -j CONNMARK --restore-mark iptables -t mangle -A PREROUTING -m mark --mark 2 -j ACCEPT iptables -t mangle -A PREROUTING -i eth0 -j MARK --set-mark 2 iptables -t mangle -A PREROUTING -j CONNMARK --save-mark iptables -t mangle -A OUTPUT -j CONNMARK --restore-mark iptables -t mangle -A OUTPUT -m mark --mark 2 -j ACCEPT iptables -t mangle -A OUTPUT -o eth0 -j MARK --set-mark 2 iptables -t mangle -A OUTPUT -j CONNMARK --save-mark iptables-save > /etc/iptables/rules.v4
- Для применения изменений конфигурации, загрузите новые *.network-файлы:
networkctl reload
- Всё работает!
Заключение
При желании, это решение можно расширить и на несколько интерфейсов аналогичным образом. Просто используйте раздельные table и fwmark-и для каждого отдельного интерфейса.
Надеюсь, эта статья сэкономит несколько седых волос в попытках понять, что происходит и как решить эту, пусть и достаточно простую, но почему-то не очень хорошо освещённую проблему одновременного использования нескольких интерфейсов.
Если существует ещё какое-то решение для динамических случаев (не через iptables) и совместимых с конфигурацией через systemd-networkd — буду рад их услышать.
А всем, кто боится systemd — рекомендую пересмотреть своё отношение к нему. Это штука мощная и позволяющая сильно упростить конфигурирование и читаемость этих конфигов. Да, она слабо соответствует принципу KISS и является своеобразным комбайном, но systemd сейчас всё равно уже ставится по умолчанию почти во всех популярных дистрибутивах.
Спасибо за внимание!