image

Описание проблемы


Ситуация: у нас имеется один интерфейс 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 и отдельных правил роутинга для подобных ситуаций. В нашем случае:

  1. Создаём новую 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
  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
  3. О чудо, всё работает!

Суть этого решения в том, что только пакеты, которые не удовлетворили ни единому правилу, обрабатываются стандартной роутинг-таблицей. Те же, что удовлетворяют внесённым правилам о приходе или уходе пакетов с конкретного 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 таблицу!

  1. Так же как и в «классическом» варианте, настраиваем альтернативную routing-таблицу:

    ip route add default via 192.168.11.1 dev eth0 src 192.168.11.11 table 2
  2. Добавляем routing policy rule, который все пакеты с маркировкой «2» будет отправлять в эту таблицу:

    ip rule add fwmark 2 table 2
  3. А теперь самая сложная магия. Мы помечаем пакеты, пришедшие на 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
  4. И вновь — всё работает!

Но позвольте, скажете вы, мы всё равно указывали в настройках адрес шлюза для eth0, а не использовал полученный от DHCP! Как же так?

Используем systemd-networkd файлы настройки


Да, я знаю, что далеко не все любят systemd, поэтому оставил это напоследок. Лично мне очень нравится возможность настроить интерфейсы при помощи *.network файлов, и сейчас я соединю все идеи, высказанные в этой статье в синтаксисе именно этих файлов. Мы не будем использовать ни единой команды ip, хоть нам всё ещё и понадобится настроить iptables-правила — один раз и «навсегда».

Также, именно такая конфигурация позволяет сформировать таблицу «2» в полностью автоматическом режиме, с подхватыванием настройки от DHCP.

  1. Конфигурация 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
  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
  3. Конфигурация 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
  4. Для применения изменений конфигурации, загрузите новые *.network-файлы:

    networkctl reload
  5. Всё работает!

Заключение


При желании, это решение можно расширить и на несколько интерфейсов аналогичным образом. Просто используйте раздельные table и fwmark-и для каждого отдельного интерфейса.

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

Если существует ещё какое-то решение для динамических случаев (не через iptables) и совместимых с конфигурацией через systemd-networkd — буду рад их услышать.

А всем, кто боится systemd — рекомендую пересмотреть своё отношение к нему. Это штука мощная и позволяющая сильно упростить конфигурирование и читаемость этих конфигов. Да, она слабо соответствует принципу KISS и является своеобразным комбайном, но systemd сейчас всё равно уже ставится по умолчанию почти во всех популярных дистрибутивах.

Спасибо за внимание!

Комментарии (15)


  1. kolu4iy
    17.11.2021 21:04

    А вот dnsmasq он мне так и не заменил... А задача была простейшая: запрос с рабочим днс-суффиксом отправить через впн к рабочему же днс-серверу, а все остальное - мимо впн, к стандартному. А если уж вспомнить про кеширование днс-запросов... Впрочем, настраивал я это года три назад в минте, возможно systemd уже научился так.


    1. Evengard Автор
      18.11.2021 00:28

      Хех, я кстати пытался использовать systemd в качестве DHCP-сервера... Действительно, очень уж он урезанный. Так что я остановился на нём именно в качестве DHCP-клиента, используя тот же dnsmasq там где нужен DHCP и/или DNS сервер. Хотя, честно, он тоже странно себя ведёт местами, но как-то непереусложнённой альтернативы не особо нет. Иногда хочется написать свой, но где столько времени и сил-то найти =) Худо бедно его хватает, с оговорками.


      1. lorc
        18.11.2021 00:52
        +1

        В последних версиях networkd DHCP сервер сильно раздался в фичах, даже появилась возможность статической конфигурации по MAC-адресам, чего нам не хватало и из-за чего мы сидели на dnsmasq. Теперь вот подумываю выбросить dnsmasq и обходиться одним networkd.


    1. lorc
      18.11.2021 00:50
      +2

      Странно. У меня с этим проблем не было. Достаточно добавить Domains=~. в секцию [Network].

      В результате resolvctl у меня показывает вот это:

      Global
                 Protocols: +LLMNR +mDNS -DNSOverTLS DNSSEC=no/unsupported
          resolv.conf mode: uplink
      Fallback DNS Servers: 1.1.1.1 9.9.9.10 8.8.8.8 2606:4700:4700::1111 2620:fe::10 2001:4860:4860::8888
      
      Link 3 (wlo1)
          Current Scopes: DNS LLMNR/IPv4 LLMNR/IPv6 mDNS/IPv4 mDNS/IPv6
               Protocols: +DefaultRoute +LLMNR +mDNS -DNSOverTLS DNSSEC=no/unsupported
      Current DNS Server: 192.168.0.254
             DNS Servers: 192.168.0.254
              DNS Domain: ~.
      
      Link 9 (tun0)
          Current Scopes: DNS LLMNR/IPv4 LLMNR/IPv6
               Protocols: +DefaultRoute +LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
      Current DNS Server: xxx.xxx.xxx.xxx 
             DNS Servers: xxx.xxx.xxx.xxx yyy.yyy.yyy.yyy
              DNS Domain: zone1.my-corp.com zone2.my-coprp.com my-corp.com

      И все что из my-corp.com резолвится через tun0, в то время как другие домены - через wlo1


      1. Evengard Автор
        18.11.2021 01:05

        Кстати, у меня с systemd-resolved есть одна странная проблема, при использовании его совместно с docker... После ребута хоста, видимо, DNS не успевает по DHCP подтянуть сервера, и после запуска контейнеров с автозапуском - у них нет DNS-а.

        Приходится либо контейнеры перезапускать (после чего они таки подхватывают DNS), либо весь докер демон.

        Я от отчаяния уже написал вот такой drop-in для докера, но он не помог...

        [Unit]
        Requires=systemd-resolved.service
        After=systemd-resolved.service
        
        [Service]
        ExecStartPre=-/usr/bin/sleep 10

        Что я делаю не так? Есть возможность как-то решить это раз и навсегда?


        1. lorc
          18.11.2021 03:09
          +1

          Я бы попробовал поставить After=systemd-networkd-wait-online.service


          1. Evengard Автор
            18.11.2021 03:26

            Спасибо, при очередном обслуживании сервиса опробую =)


          1. Evengard Автор
            20.11.2021 19:20

            Ох, проблема оказалась вообще в другом. Я в своё время заменил resolvconf на systemd-resolved но не менял саму конфигурацию через debian-овские интерфейсные файлы на systemd-networkd. Как оказалось, systemd-resolved не особо хорошо дружит с дебиановским дефолтным нетворкингом, потому что тот пишет DNS-сервера напрямую в /etc/resolv.conf, что и вызывает полный бардак... Докером подхватывался какой-то промежуточный resolv.conf, который, само собой, не работал. Изменив всё на systemd-networkd всё взлетело.


            1. lorc
              20.11.2021 22:55
              +1

              Ааа, ну да, там могут быть самые разные приколы. Я поэтому стараюсь использовать полный стек от systemd, включая systemd-tmpfiles чтобы быть уверенным что /etc/resolv.conf указывает на правильный resolv.conf сгенерированный systemd-resolved.


      1. AlexGluck
        18.11.2021 02:08

        Я в отличии от комментария выше просто забил на проблему (лень было перенастраивать каждый раз), спасибо вам за это решение, оно очень мне помогло. Настроил через NetworkManager. Только есть проблема, что пришлось вручную домен поиска прописывать ведь ~. мешает почему-то использовать и вписанное значение и значение из DHCP.


      1. kolu4iy
        19.11.2021 15:37

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


  1. Grommy
    18.11.2021 10:31

    Спасибо за постановку проблемы и решение!

    Но в Debian 11 теперь nftables обязательно, или нет? Я настроил пока у себя на новом сервере правила без VPN, но на будущее хочется знать, куда смотреть :)

    То есть, остаёмся с iptables через "прокладки, как (возможно) сейчас, или всё же всё переводим на nft?


    1. Evengard Автор
      18.11.2021 10:42

      Ну, на самом деле вроде iptables-compat никто пока убирать не собирается. Но вообще есть некоторые вещи, которые nft пока всё ещё не может, так что даже iptables-legacy всё ещё жив.

      Я бы пока не беспокоился на эту тему, но думаю при желании это решение можно и на nft переписать без особых проблем.

      Даже сейчас, nft list ruleset вполне выдаёт валидную конфигурацию:

      table ip mangle {
              chain PREROUTING {
                      type filter hook prerouting priority mangle; policy accept;
                      counter packets 1268889 bytes 206776017 meta mark set ct mark
                      mark 0x2 counter packets 1225490 bytes 191328440 accept
                      iifname "eth0" counter packets 329 bytes 15363 meta mark set 0x2
                      counter packets 43399 bytes 15447577 ct mark set mark
              }
      
              chain INPUT {
                      type filter hook input priority mangle; policy accept;
              }
      
              chain FORWARD {
                      type filter hook forward priority mangle; policy accept;
              }
      
              chain OUTPUT {
                      type route hook output priority mangle; policy accept;
                      counter packets 1133182 bytes 168075102 meta mark set ct mark
                      mark 0x2 counter packets 1085533 bytes 161248520 accept
                      oifname "eth0" counter packets 6964 bytes 2185183 meta mark set 0x2
                      counter packets 47649 bytes 6826582 ct mark set mark
              }
      
              chain POSTROUTING {
                      type filter hook postrouting priority mangle; policy accept;
              }
      }

      Этот дамп скорее всего "перегружен" и его можно упростить (статистику количества пакетов/байтов убрать), но я думаю суть ясна примерно =)


      1. Grommy
        18.11.2021 11:54

        О, спасибо!!


  1. pmcode
    22.11.2021 12:28
    +1

    Мне кажется проблема немного надуманная. Дефолтным шлюзом VPN обычно выбирают на клиентских машинах и следовательно вопрос потери удаленного доступа не стоит. На серверах ты обычно совершенно точно знаешь какие конкретно подсети заворачивать в VPN.

    Странно, что не сказано, что статья описывает в общем-то обычный source based routing (SBR), приходится продираться через описание. Если кому непонятно, что вообще происходит: раз, два.