Выборочная маршрутизация трафика по доменным именам
В этой статье я хочу рассказать, как пришёл к выборочной маршрутизации трафика для целой локальной сети. Идея простая: обычный трафик идёт напрямую в интернет, а выбранные по доменным именам ресурсы автоматически отправляются через рабочий туннель. При этом на компьютерах пользователей ничего дополнительно настраивать не нужно. Здесь пока не буду начинать сразу с готового конфига. Сначала хочу рассказать, откуда вообще появилась эта задача, какие варианты я пробовал и почему в итоге остановился на связке DNS, dnsmasq, nftables и policy routing.
С чего всё началось
Несколько лет назад я работал в компании, основной офис которой находился в Москве, а сам я работал из небольшого офиса в Томске. Для доступа к части корпоративных ресурсов нужно было подключаться к рабочему VPN. Сам по себе VPN работал нормально. Неудобство было в другом: его постоянно нужно было включать и выключать. Нужно открыть внутренний сервис — включил VPN. Закончил работать с ним — выключил. Появился новый ноутбук — снова настрой VPN. Плюс всё это нужно было делать на каждом устройстве отдельно. В какой-то момент мне это просто надоело и я решил попробовать сделать так, чтобы пользователю вообще не приходилось думать о VPN. Я хотел, чтобы человек просто подключался к сети нашего офиса и работал. Если он открывает внутренний GitLab или другой корпоративный сервис, трафик сам идёт через московский офис. Если открывает обычный сайт — трафик идёт напрямую в интернет. Причём мне было важно соблюсти несколько условий.
Во-первых, я хотел иметь одно место, где задаётся список нужных ресурсов. Не хотелось потом ходить по компьютерам и менять конфигурацию на каждом из них.
Во-вторых, я не хотел привязываться к конкретным IP-адресам. Мне хотелось просто написать названия нужных сервисов и больше об этом не думать.
Например:
gitlab.company.local
jira.company.local
grafana.company.localПоменялся IP — ничего менять руками не нужно.
И в-третьих, всё это должно было работать для любого устройства в офисе. Windows, Linux, macOS, телефоны — неважно. Пришёл новый сотрудник, подключился к сети и всё уже работает.
На тот момент я в основном занимался backend-разработкой. С Linux я работал, общее представление о сетях было, но глубоко в маршрутизацию трафика я до этого не погружался. Поэтому начал искать, как такую задачу вообще можно решить.
Первый вариант — маршрутизировать нужные подсети
Первое, что пришло в голову, — просто отправлять через VPN определённые корпоративные подсети. Допустим, я знаю, что часть внутренней инфраструктуры находится в сети: 192.168.16.0/20 Тогда можно указать эту сеть в конфигурации WireGuard и направлять через туннель только трафик к ней. В принципе, рабочий вариант. Но тут довольно быстро появились проблемы. Не вся инфраструктура находилась в одной подсети. Можно, конечно, добавить вторую, третью, потом отдельные IP-адреса. Но тогда за всем этим списком нужно следить. Кроме того, оставалась проблема, от которой я как раз хотел избавиться: WireGuard всё равно нужно было настраивать на устройствах пользователей. Если появляется новый сотрудник, ему опять нужно что-то устанавливать, передавать конфигурацию, объяснять, когда включать туннель. Меня такой вариант не устраивал.
Переносим всё на маршрутизатор
Тогда появилась следующая довольно очевидная мысль. Если весь трафик офиса и так проходит через один маршрутизатор, зачем вообще поднимать WireGuard на каждом ноутбуке? Можно поднять туннель непосредственно на роутере и уже там решать, какой трафик отправлять через него. Это решало проблему с клиентскими устройствами. Пользователю больше ничего не нужно устанавливать. Для него есть обычная локальная сеть и обычный шлюз. Но проблема с IP никуда не делась. Я всё ещё мог написать: вот эту подсеть отправляем через туннель, вот этот IP тоже, этот ещё один. Технически всё работает, но получается конфигурация, которую нужно постоянно поддерживать. А я хотел другого. Мне хотелось указывать не IP-адреса, а имена сервисов. То есть условно добавить: gitlab.company.local и больше ничего не делать.
А можно ли вообще маршрутизировать по доменному имени?
Как разработчику мне было привычнее работать именно с именами. Есть nginx, есть DNS, есть SNI. Поэтому я начал смотреть, нельзя ли принять решение о маршрутизации на основании доменного имени. Но тут есть важный момент. Обычная IP-маршрутизация работает с IP-адресами. К моменту, когда пакет приходит на маршрутизатор, там уже есть адрес назначения. Самого gitlab.company.local, по которому пользователь изначально обращался к сервису, в IP-заголовке нет. Получается, что непосредственно маршрутизировать по доменному имени не выйдет. В какой-то момент домен всё равно нужно превратить в IP. Сначала я посмотрел в сторону SNI.
Вариант с SNI
Идея была примерно такая: смотреть имя сервера при установлении TLS-соединения и в зависимости от него решать, куда дальше отправлять соединение. Я начал читать про SNI proxy и похожие решения. Довольно быстро понял, что для моей задачи это лишнее усложнение. Мне не хотелось ставить между пользователем и сервисом ещё один компонент, через который будет проходить трафик, а потом отдельно разбираться с его настройкой и диагностикой. Тем более что мне в итоге всё равно нужно было решить довольно простую задачу: получить IP нужных сервисов и сказать Linux, что трафик к этим адресам должен идти по другому маршруту. Тогда я начал подробнее смотреть в сторону DNS.
DNS и dnsmasq
И вот здесь нашлось то, что мне было нужно. На нашем роутере использовался dnsmasq. Изучая его возможности, я обнаружил, что результаты DNS-резолвинга можно автоматически складывать в набор nftables. То есть у меня есть доменное имя: gitlab.company.local Кто-то делает DNS-запрос, dnsmasq получает актуальный IP этого сервиса и добавляет его в отдельный набор nftables. А дальше мне уже не нужно работать с доменным именем. Для Linux это обычный список IP-адресов. Можно проверить, входит ли адрес назначения пакета в этот список, пометить нужный трафик и с помощью policy routing отправить его через отдельный маршрут. Именно в этот момент всё наконец сложилось. Получалось, что конфигурацию я веду в удобном для себя виде — обычным списком доменных имён. А непосредственно Linux продолжает работать так, как и должен: с IP-адресами, маршрутами и метками пакетов. Если IP какого-нибудь сервиса поменяется, новый адрес можно получить через DNS и автоматически добавить в нужный набор. То есть следить за IP вручную уже не требуется.
Оставалась ещё одна проблема
Чтобы dnsmasq добавил адрес в нужный набор, кто-то сначала должен сделать DNS-запрос. Самый очевидный вариант — сделать роутер DNS-сервером для всей локальной сети. Его адрес можно раздавать клиентам через DHCP, и тогда запросы пользователей будут проходить через dnsmasq. Пользователь открывает корпоративный сервис, компьютер резолвит его через роутер, а полученный IP одновременно попадает в нужный набор. Но полагаться только на клиентские запросы мне не хотелось. Например, после перезагрузки роутера набор будет пустым до тех пор, пока кто-нибудь не запросит нужный домен. Плюс не все устройства обязательно используют DNS, который мы им выдали: сейчас тот же браузер вполне может использовать DoH. Поэтому появился второй вариант, который сначала кажется немного костыльным, но на практике оказался вполне удобным. Можно просто периодически резолвить все домены из нашего списка. То есть есть один список корпоративных сервисов, а отдельная задача время от времени проходит по нему и делает DNS-запросы. dnsmasq получает ответы и заполняет набор актуальными IP-адресами. Мы в итоге так и сделали.
Что получилось в итоге
В итоге получилась довольно простая в использовании система. Для пользователя вообще ничего не поменялось. Не нужно устанавливать WireGuard, импортировать конфигурации или вспоминать, включён сейчас VPN или нет. Человек просто подключается к сети офиса и работает. При этом обычный трафик идёт напрямую в интернет, а нужные корпоративные ресурсы автоматически отправляются через рабочий туннель. Самое удобное для меня было то, что конфигурация в итоге свелась к списку доменных имён. Появился новый корпоративный сервис — добавил его имя. Поменялся его IP — ничего делать не нужно. Появился новый ноутбук или новый сотрудник — настраивать для него отдельный туннель тоже не нужно. Именно этого я и хотел добиться изначально.
Мне кажется, подобная проблема встречается довольно часто. Просто обычно она не настолько сильно мешает, чтобы кто-то специально тратил время на её решение. Можно ведь каждый раз включать VPN вручную — и вроде бы всё работает. Меня в какой-то момент это достаточно сильно надоело, чтобы начать разбираться с маршрутизацией. А заодно это оказалось хорошим поводом разобраться, как на практике связаны DNS, nftables, маркировка пакетов и policy routing.
Дальше я уже разберу непосредственно реализацию: как настроить dnsmasq, как наполнять набор nftables, как маркировать нужный трафик и отправлять его через отдельную таблицу маршрутизации.
Реализация
Теперь перейдём к тому, как всё это было сделано. В моём случае маршрутизатор работал на Linux, поэтому почти всё необходимое уже было в системе. Для решения задачи понадобились dnsmasq, nftables, обычные средства маршрутизации Linux и WireGuard. Начать решил с самого главного — списка доменов. Я хотел, чтобы именно он оставался основной конфигурацией всей системы. То есть при появлении нового сервиса мне не нужно было вспоминать, в какую таблицу маршрутизации добавить его IP или какое правило nftables изменить. Условно список выглядел так:
gitlab.company.local
jira.company.local
grafana.company.local
ci.company.localДальше из этих имён нужно автоматически получить IP и передать их в nftables.
Наполняем nftables через dnsmasq
Для начала в nftables создаём отдельный set, в котором будут храниться адреса нужных нам ресурсов. Например:
table inet smart_route {
set corporate_hosts {
type ipv4_addr
}
}Пока он пустой. Теперь нужно сделать так, чтобы адреса появлялись там после DNS-запросов. У dnsmasq для этого есть параметр nftset. Для нужных доменов можно указать, в какой набор складывать результаты резолвинга. Например:
nftset=/gitlab.company.local/4#inet#smart_route#corporate_hosts
nftset=/jira.company.local/4#inet#smart_route#corporate_hosts
nftset=/grafana.company.local/4#inet#smart_route#corporate_hostsПосле этого при резолвинге одного из этих доменов dnsmasq не просто возвращает клиенту IP, но ещё и добавляет его в наш set. Проверить содержимое можно обычной командой:
nft list set inet smart_route corporate_hostsИ там уже будут реальные адреса, которые вернул DNS. Это как раз тот момент, ради которого всё и затевалось: в конфигурации я работаю с доменными именами, а ниже всё автоматически превращается в обычные IP, с которыми уже умеет работать маршрутизация Linux.
Что делать, если никто ещё не запросил домен
Тут есть небольшой нюанс, о котором я писал выше. Если после перезагрузки роутера никто ещё не открывал GitLab, то и DNS-запроса к нему могло не быть. Соответственно, нужного IP в corporate_hosts тоже пока нет. Можно рассчитывать только на DNS-запросы клиентов, но мне такой вариант не очень понравился. Поэтому список доменов я периодически резолвил принудительно. Самый простой вариант выглядит примерно так:
while read -r domain; do
dig "$domain" > /dev/null
done < corporate-domains.txtЗапускать это можно по таймеру. В итоге после запуска роутера не нужно ждать, пока сотрудники сами начнут открывать нужные сервисы. Домены резолвятся заранее, а dnsmasq заполняет набор актуальными адресами. Здесь есть важный момент: запрос должен пройти именно через тот dnsmasq, который настроен на заполнение nftables. Иначе обычный dig через какой-нибудь внешний DNS, естественно, ничего в наш set не добавит.
Теперь нужно пометить трафик
На этом этапе у нас уже есть динамический список IP. Дальше задача становится намного проще. Если пакет идёт на адрес из corporate_hosts, его нужно пометить. Для этого в nftables можно использовать meta mark. Например, в упрощённом виде правило выглядит так:
ip daddr @corporate_hosts meta mark set 0x64То есть если destination IP находится в нашем наборе, пакету выставляется метка 0x64. Почему именно 0x64 — никакой магии здесь нет. Можно выбрать другую метку, главное потом использовать то же значение в правилах маршрутизации. Теперь Linux может отличить обычный трафик от того, который мы хотим отправить через рабочий туннель.
Отдельная таблица маршрутизации
Следующий шаг — создать отдельную таблицу маршрутизации. Допустим, назовём её corporate. В ней default route будет смотреть не в обычный WAN-интерфейс роутера, а в WireGuard. Условно:
ip route add default dev wg0 table corporateТеперь нужно сказать ядру Linux, что пакеты с нашей меткой должны использовать именно эту таблицу:
ip rule add fwmark 0x64 table corporateПосле этого вся основная логика уже работает. Обычный пакет не получает метку и маршрутизируется как раньше. Если адрес назначения находится в наборе, который заполнил dnsmasq, nftables ставит метку. ip rule видит эту метку и отправляет пакет в отдельную таблицу маршрутизации, а там default route уже ведёт в wg0. При этом клиентский компьютер вообще ничего об этом не знает. Для него default gateway всё тот же.
Добавление нового сервиса
Вот здесь мне особенно понравился результат. Допустим, появился ещё один внутренний сервис:registry.company.local В первоначальном варианте с ручными маршрутами мне пришлось бы узнать его IP, понять, в какой сети он находится, и добавить соответствующий маршрут. Теперь мне достаточно добавить его доменное имя в список, из которого генерируется конфигурация dnsmasq. После следующего резолвинга адрес окажется в nftables, а существующее правило маркировки начнёт работать с ним автоматически. То есть сама маршрутизация вообще не меняется. Именно этого я и хотел от одного места конфигурации.
Неочевидный плюс, который обнаружился позже
Когда всё это уже работало в офисе, обнаружился ещё один сценарий, о котором изначально я вообще не думал. Иногда я работал из дома. Обычный вариант в такой ситуации — подключить ноутбук напрямую к VPN московского офиса. Но тогда я снова возвращаюсь к тому, от чего пытался избавиться: туннель и правила появляются уже на моём ноутбуке. Но ведь в томском офисе уже есть маршрутизатор, который умеет правильно разделять трафик. Поэтому из дома можно подключаться не напрямую к московскому офису, а сначала к нашему роутеру в Томске. Для ноутбука томский офис в таком случае становится обычным шлюзом. А дальше работает ровно та же логика, которая уже была настроена для сотрудников внутри офиса. Если я обращаюсь к корпоративному сервису, его адрес попадает под наши правила и трафик уходит дальше через рабочий туннель в Москву. Остальной трафик через московский туннель не идёт. Получается довольно интересная вещь: правила маршрутизации вообще перестают зависеть от того, где физически находится пользователь. Я могу сидеть непосредственно в томском офисе или подключиться к нему удалённо — после попадания в эту сеть правила для меня одинаковые. И это оказалось удобнее, чем поддерживать одну логику маршрутизации на роутере, другую на домашнем компьютере, третью на ноутбуке и ещё отдельные конфигурации для других устройств. Вся логика остаётся в одном месте. Со временем именно это я стал считать главным плюсом всей схемы. Изначально я просто хотел перестать вручную включать VPN. В итоге же получилась небольшая точка централизованной маршрутизации: клиенту достаточно попасть в сеть, а решение о том, куда отправить конкретный трафик, уже принимает маршрутизатор.