Почему DNS-запросы обходят VPN и как работают route-only-домены
VPN поднялся, внутренний DNS виден, прямой запрос к нему отвечает, а `crm.corp.example` всё равно получает NXDOMAIN от публичного резолвера. Я, Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш», разбирал эту картину не раз. Причина обычно не в WireGuard и не в самом DNS: администратор указал адрес сервера, но не объяснил `systemd-resolved`, какие имена надо отправлять на этот сервер. Покажу логику выбора DNS-канала, разницу между `~corp.example` и обычным поисковым суффиксом, постоянную настройку через NetworkManager и проверку результата на практическом стенде.
Адрес DNS-сервера ещё не задаёт маршрут запросов
Главная ошибка — воспринимать список DNS как /etc/resolv.conf старой школы: первый сервер основной, второй резервный, третий опрашивается после тайм-аута. У systemd-resolved другая модель. DNS-серверы, домены и признак DefaultRoute привязаны к сетевым интерфейсам. Ethernet может получить DNS от DHCP, а VPN — корпоративный DNS. Затем resolved для каждого запрашиваемого имени выбирает подходящий DNS-канал. Поэтому наличие 10.77.0.53 в выводе resolvectl status ещё ничего не гарантирует.
Сначала resolved ищет среди routing- и search-доменов всех каналов и глобальной конфигурации наиболее подходящий: совпадающий с именем и содержащий больше всего меток. Для api.corp.example домен corp.example специфичнее, чем ~.. Если одинаковый лучший домен настроен на нескольких каналах, запрос уходит на их серверы параллельно, и возвращается первый успешный ответ. Если совпадений нет, запрос получают DNS-серверы каналов с признаком DefaultRoute и глобальные серверы из DNS= в resolved.conf. Вот почему внутреннее имя уходит на публичный DNS: VPN-сервер объявлен, но домена для него нет, а сам VPN помечен как не используемый для остальных DNS-запросов.
Не путайте DNS-маршрутизацию с таблицей маршрутов ядра. DefaultRoute=no в выводе resolvectl относится к выбору DNS-канала. ipv4.never-default yes в NetworkManager запрещает обычный IP-маршрут по умолчанию через соединение. Это связанные в типичной конфигурации, но разные механизмы. Даже идеально выбранный DNS не ответит, если до 10.77.0.53 нет IP-маршрута через туннель.
Отдельно запомните правило неявного значения. Если DefaultRoute не задан явно ни через resolvectl default-route, ни через DNSDefaultRoute= в .network-файле, resolved вычисляет его сам: при наличии на канале route-only-домена, отличного от ~., признак считается no, иначе yes. Поэтому VPN-канал с DNS-серверами, но без доменов, по умолчанию может начать получать вообще все запросы наравне с Ethernet, а канал с ~corp.example автоматически перестаёт быть маршрутом по умолчанию. NetworkManager, в свою очередь, сам добавляет ~. соединению, у которого есть IP-маршрут по умолчанию.
- DNS server отвечает на вопрос «кому можно отправить запрос».
- Routing domain отвечает на вопрос «какие имена отправлять этому серверу».
- Search domain дополнительно отвечает на вопрос «чем дополнять короткие имена».
- IP route определяет, как пакет физически дойдёт до DNS-сервера.
Что означает тильда и почему я выбираю route-only
Запись ~corp.example — route-only-домен. Тильда не является частью DNS-имени и никуда не передаётся. Она говорит resolved: запросы к самому corp.example и любым его поддоменам направляй на DNS-серверы этого канала, но не используй суффикс для дополнения коротких имён. Запрос crm.corp.example попадёт в VPN. Запрос crm не превратится автоматически в crm.corp.example.
Запись corp.example без тильды — поисковый домен. Для уже полного имени она выполняет ту же маршрутизирующую роль. Разница проявляется на однокомпонентных именах: приложение запросило crm, а resolved попробовал имя с поисковым суффиксом. Это удобно для старых сценариев, но создаёт неявное поведение. Пользователь думает, что обращается к локальному printer, а после подключения VPN получает корпоративный printer.corp.example. Риск не надо раздувать до катастрофы, однако путаница, лишние запросы и трудно воспроизводимые ошибки вполне реальны.
Я выбираю ~corp.example и требую от приложений FQDN. Обычный search-домен оставляю только там, где короткие имена являются документированным требованием старого ПО. Одновременно указывать corp.example и ~corp.example бессмысленно: обычный search-домен уже участвует в маршрутизации. Специальный домен ~. означает DNS-корень и совпадает с любым именем. Это правильная настройка для VPN, который должен забирать весь DNS-трафик, но плохой выбор для корпоративного split tunnel.
- `~corp.example`: маршрутизирует `host.corp.example`, короткое `host` не дополняет.
- `corp.example`: маршрутизирует полные имена и может дополнять однокомпонентное `host`.
- `~.`: перехватывает все имена, кроме тех, для которых существует более специфичный доменный маршрут.
- `~77.10.in-addr.arpa`: направляет через VPN обратные PTR-запросы для сети `10.77.0.0/16`.
Как увидеть реальную картину до внесения изменений
Сначала убеждаюсь, что работает именно systemd-resolved, а приложения обращаются к его stub-резолверу. На распространённой схеме /etc/resolv.conf ссылается на /run/systemd/resolve/stub-resolv.conf, где указан 127.0.0.53. Сам по себе этот адрес нормален: список реальных upstream-серверов хранится по каналам и показывается через resolvectl. Если /etc/resolv.conf является обычным статическим файлом, split DNS resolved может вообще не участвовать в запросах приложения.
Минимальный снимок состояния снимаю одной серией команд. Название интерфейса смотрю в общем статусе, а не угадываю по имени профиля NetworkManager.
systemctl is-active systemd-resolved
readlink -f /etc/resolv.conf
resolvectl status
resolvectl dns
resolvectl domain
nmcli connection show --active
ip route get 10.77.0.53В нужном блоке должны одновременно присутствовать корпоративные DNS-серверы, ~corp.example и Default Route: no (в разных версиях строка называется DefaultRoute setting). Последняя команда должна показать VPN-интерфейс, например wg-polis. Если пакет до DNS идёт через обычный шлюз, исправлять домены пока рано — сначала добавьте DNS-адрес в AllowedIPs или обычные маршруты VPN.
Проверка dig @10.77.0.53 api.corp.example полезна, но доказывает только работоспособность конкретного сервера. Она намеренно обходит алгоритм выбора resolved. Для проверки пользовательского пути нужен resolvectl query api.corp.example: в ответе он показывает использованные сервер, протокол и link. После неудачных опытов я очищаю кэш, иначе отрицательный ответ NXDOMAIN может создать впечатление, что новая настройка не сработала.
sudo resolvectl flush-caches
resolvectl query api.corp.example
dig @10.77.0.53 api.corp.exampleНаконец, браузер с принудительным DNS over HTTPS, локальный контейнерный резолвер или приложение с собственным DNS-клиентом способны обойти системную схему. Это уже отдельный тракт, и перезапуск resolved его не исправит.
- `readlink -f /etc/resolv.conf` должен вести на `/run/systemd/resolve/stub-resolv.conf` (или `resolv.conf` того же каталога), а не на статический файл.
- В `resolvectl status <интерфейс>` у VPN-канала должны быть одновременно `Current Scopes: DNS`, корпоративные серверы и нужные домены.
- `ip route get <адрес DNS>` должен показывать туннельный интерфейс, иначе сначала чините маршруты VPN.
- `resolvectl query <FQDN>` в строке `-- link:` показывает фактически выбранный канал — это главный доказательный тест.
- `dig @сервер` проверяет только сам сервер и не участвует в выборе канала resolved.
Кейс «Полиса и партнёров»: DNS был указан, но не использовался
Разберу проект страхового брокера «Полис и партнёры»: 29 рабочих мест, один офис, половина сотрудников регулярно работает из дома и на встречах с клиентами. Технический сценарий собран из моей практики. Девять ноутбуков менеджеров по урегулированию убытков и аналитиков работали на Ubuntu 24.04 LTS с systemd 255.4 и NetworkManager 1.46.0, остальные станции — Windows. Удалённые сотрудники подключались к офису через WireGuard. Внутренние сервисы — CRM по договорам, портал заявок и файловый архив полисов — жили в зоне corp.example, DNS — 10.77.0.53 и 10.77.0.54, прикладной сегмент — 10.77.0.0/16. Профиль VPN назывался polis-vpn, интерфейс — wg-polis.
Симптом выглядел убедительно: VPN подключён, ping 10.77.12.20 проходит, dig @10.77.0.53 api.corp.example возвращает внутренний адрес, но обычный getent hosts api.corp.example периодически даёт NXDOMAIN. В профиле действительно были оба корпоративных DNS. Однако ipv4.dns-search оставался пустым, а split-tunnel-профиль имел ipv4.never-default yes. В состоянии resolved это выражалось так:
Link 7 (wg-polis)
Current Scopes: DNS
DefaultRoute setting: no
DNS Servers: 10.77.0.53 10.77.0.54Серверы были зарегистрированы и DNS-scope существовал, но у канала не было ни подходящего домена, ни права обслуживать запросы по умолчанию. Запрос уходил через Wi-Fi-канал на DNS, полученный от домашнего роутера, а тот пересылал его публичному резолверу провайдера.
Дополнительную неразбериху создали две ручные попытки ремонта. На двух ноутбуках добавили search corp.example прямо в /etc/resolv.conf; настройка исчезла после переподключения. Ещё на одном в конфигурацию WireGuard вписали DNS = 10.77.0.53, и внутренние имена заработали, зато весь публичный DNS начал ходить через офисный канал: wg-quick регистрирует такие серверы через resolvconf -x, а совместимый режим resolvectl превращает -x в домен ~.. Утечкой это не назовёшь, но задержка внешних ответов выросла с 10–20 до 40–70 мс, а на время обслуживания офисного DNS сотрудник терял разрешение интернет-имён. Я вернул модель к простой политике: корпоративная зона — через VPN, всё остальное — через текущий канал сотрудника.
- 29 рабочих мест, один офис, удалённая работа примерно половины сотрудников.
- 9 ноутбуков Ubuntu 24.04 LTS с NetworkManager.
- WireGuard split tunnel без маршрута `0.0.0.0/0`.
- Два внутренних DNS: `10.77.0.53` и `10.77.0.54`.
- Прямая проверка DNS проходила, системное разрешение имени — нет.
Постоянная настройка через NetworkManager
На рабочих станциях я храню DNS-политику в профиле NetworkManager. Так она применяется вместе с VPN и удаляется при его отключении. Ручной resolvectl оставляю для теста или для VPN-клиента, который сам не интегрируется с resolved. На Ubuntu связка обычно уже настроена, но это надо проверить. Если NetworkManager не передаёт per-link DNS в resolved, задаётся его DNS-плагин:
# /etc/NetworkManager/conf.d/10-dns-resolved.conf
[main]
dns=systemd-resolvedПосле изменения конфигурации NetworkManager нужно перечитать настройки. Сам файл создавайте только после проверки существующих drop-in-файлов: глобальная настройка DNS влияет на все профили.
sudo systemctl reload NetworkManagerДля polis-vpn я задал два прямых доменных маршрута: внутреннюю прямую зону и reverse-зону. Автоматический DNS от самого VPN нам не требовался, а обычный маршрут по умолчанию должен был оставаться у основного канала сотрудника.
sudo nmcli connection modify "polis-vpn" ipv4.dns "10.77.0.53,10.77.0.54" ipv4.dns-search "~corp.example,~77.10.in-addr.arpa" ipv4.ignore-auto-dns yes ipv4.never-default yes
sudo nmcli connection down "polis-vpn"
sudo nmcli connection up "polis-vpn"ipv4.ignore-auto-dns yes нужен, если профиль способен получить лишние DNS или домены автоматически. Он не запрещает два адреса, заданных вручную. Отрицательный dns-priority я здесь не использую: он может исключить DNS других активных соединений целиком, тогда как нам достаточно более специфичного доменного маршрута.
После переподключения resolvectl status wg-polis должен показать DNS Servers: 10.77.0.53 10.77.0.54, DNS Domain: ~corp.example ~77.10.in-addr.arpa и выключенный DefaultRoute. Если короткие имена действительно обязательны, замените ~corp.example на corp.example. Именно замените, а не добавляйте второй вариант. Я сначала выясняю, какое старое приложение требует crm вместо FQDN, и только затем разрешаю search-поведение.
Для интерфейса, которым NetworkManager не управляет, ту же политику можно проверить командами resolved. Но она живёт только до удаления интерфейса, перезапуска сетевого менеджера или перезагрузки:
sudo resolvectl domain wg-polis '~corp.example' '~77.10.in-addr.arpa'
sudo resolvectl default-route wg-polis no
sudo resolvectl dns wg-polis 10.77.0.53 10.77.0.54VPN-клиент должен применять эти параметры при поднятии канала и вызывать resolvectl revert wg-polis при отключении. Иначе после аварийного завершения останется устаревшее per-link-состояние.
- `ipv4.dns` — корпоративные серверы именно этого VPN-профиля.
- `ipv4.dns-search "~corp.example,~77.10.in-addr.arpa"` — прямая и обратная зоны без дополнения коротких имён.
- `ipv4.never-default yes` — IP-маршрут по умолчанию остаётся у основного канала, NetworkManager не добавит VPN домен `~.`.
- `ipv4.ignore-auto-dns yes` — отбрасывает DNS и домены, пришедшие автоматически.
- Для IPv6-сегмента те же параметры задаются в `ipv6.*`, иначе AAAA и PTR по IPv6 пойдут по общим правилам.
WireGuard, OpenVPN и systemd-networkd: кто на самом деле пишет настройки
Половина проблем со split DNS возникает не в resolved, а в том, кто и как передаёт ему параметры. У wg-quick директива DNS = в секции [Interface] принимает и адреса, и имена: всё, что похоже на IP, становится сервером, остальное — поисковым доменом. Регистрирует их wg-quick командой resolvconf -a <интерфейс> -m 0 -x. Если в системе resolvconf — это ссылка на resolvectl, ключ -m молча игнорируется, а -x превращается в домен ~.. Итог: любой туннель wg-quick с DNS = забирает весь DNS-трафик, кроме имён, для которых на других каналах есть более специфичный домен. Для split DNS я убираю DNS = и задаю политику в хуках:
[Interface]
PostUp = resolvectl dns %i 10.77.0.53 10.77.0.54; resolvectl domain %i '~corp.example' '~77.10.in-addr.arpa'; resolvectl default-route %i no
PreDown = resolvectl revert %iЕсли WireGuard импортирован в NetworkManager (nmcli connection import type wireguard file wg-polis.conf), хуки не нужны: DNS и домены задаются свойствами профиля, как в предыдущем разделе.
У OpenVPN штатной интеграции с resolved нет, для этого используют скрипт update-systemd-resolved. Он читает переданные сервером опции dhcp-option и вызывает resolved через D-Bus. Важная деталь: для route-only-доменов у него отдельный ключ DOMAIN-ROUTE, а DOMAIN и DOMAIN-SEARCH создают обычные поисковые домены. Клиентский конфиг по README проекта выглядит так (путь зависит от способа установки, в пакетах дистрибутивов он другой):
script-security 2
up /usr/local/libexec/openvpn/update-systemd-resolved
up-restart
down /usr/local/libexec/openvpn/update-systemd-resolved
down-preНа стороне сервера отдаю push "dhcp-option DNS 10.77.0.53" и push "dhcp-option DOMAIN-ROUTE corp.example". dhcp-option DOMAIN-ROUTE . означает ~. — ровно то, чего в split tunnel надо избегать. Без up-restart скрипт не отработает при переподключении с сохранённым tun-устройством, и настройки канала могут потеряться.
На серверах и станциях без NetworkManager туннель описываю в systemd-networkd. В .network-файле интерфейса DNS= задаёт серверы, Domains= — домены (с тильдой — route-only), а DNSDefaultRoute= — участие канала в запросах, не совпавших ни с одним доменом. Без явного значения действует автоматический режим: канал с route-only-доменами маршрутом по умолчанию не считается.
# /etc/systemd/network/50-wg-polis.network
[Match]
Name=wg-polis
[Network]
DNS=10.77.0.53 10.77.0.54
Domains=~corp.example ~77.10.in-addr.arpa
DNSDefaultRoute=falseИ последний источник сюрпризов — /etc/systemd/resolved.conf. Глобальные DNS= участвуют в каждом запросе, не совпавшем с доменами, параллельно с каналами DefaultRoute, а Domains= там действуют на всю систему. Прописанный туда корпоративный сервер или публичный резолвер «на всякий случай» ломает всю модель per-link-маршрутизации.
- wg-quick с `DNS =` через совместимый `resolvconf -x` ставит `~.` — это полный DNS-туннель, а не split.
- OpenVPN + `update-systemd-resolved`: для route-only-зон используйте `DOMAIN-ROUTE`, а не `DOMAIN`.
- systemd-networkd: `Domains=~зона` плюс явный `DNSDefaultRoute=false` для предсказуемости.
- NetworkManager: `ipv4.dns-search` с тильдой и `ipv4.never-default yes` в VPN-профиле.
- В `resolved.conf` не держите корпоративные или публичные `DNS=` без чёткой причины.
Приёмка, результат и ошибки, которые стоит ловить
Приёмку я провожу минимум в трёх состояниях: VPN выключен, VPN включён через офисный Wi-Fi и VPN включён через домашний роутер или мобильную точку доступа. После очистки кэша проверяю полное внутреннее имя, внешний домен, короткое имя и PTR. Короткое имя в выбранной мною политике не обязано разрешаться — это ожидаемый результат, а не дефект.
sudo resolvectl flush-caches
resolvectl query api.corp.example
resolvectl query kernel.org
resolvectl query crm
resolvectl query 10.77.12.20Для внутреннего FQDN и PTR вывод должен ссылаться на wg-polis и корпоративный DNS. kernel.org должен обслуживаться обычным каналом. crm не должен незаметно превращаться в crm.corp.example.
Если результат спорный, смотрю пакеты. На VPN-интерфейсе должны появляться только запросы корпоративной зоны и нужных reverse-зон. Параллельно полезно проверить обычный интерфейс, чтобы убедиться, что внутренний FQDN туда не попадает.
sudo tcpdump -ni wg-polis '(host 10.77.0.53 or host 10.77.0.54) and port 53'Не забывайте, что tcpdump -i any покажет ещё локальный обмен со stub 127.0.0.53 и может запутать картину. А dig @8.8.8.8 всегда обходит системную политику — это не подходящий тест отсутствия DNS-утечки.
В «Полисе и партнёрах» профиль развернули на девяти Ubuntu-ноутбуках. Провели 80 циклов подключения через домашние сети сотрудников, мобильный интернет и гостевой Wi-Fi офиса. Внутренний FQDN во всех циклах выбирал wg-polis; публичные запросы оставались на текущем канале. Задержка внутренних ответов укладывалась в 20–35 мс, внешняя вернулась к привычным 10–20 мс. За следующие 30 дней не было ни одного обращения «CRM не открывается после VPN»; до изменения таких обращений набиралось в среднем три-четыре в неделю.
Из повторяющихся ошибок я бы в первую очередь ловил ~., забытый reverse-домен, отсутствие IP-маршрута до DNS и браузерный DoH. На .local для корпоративной unicast-зоны лучше вообще не рассчитывать: этот суффикс зарезервирован для MulticastDNS и требует особого обращения. Приоритеты dns-priority NetworkManager трогайте только при конфликте одинаковых доменов на разных соединениях. Самое важное — явно описать зоны и проверить фактический link каждого контрольного запроса.
- Не считайте успешный `dig @сервер` доказательством правильного split DNS.
- Не используйте `~.` для корпоративного split tunnel без осознанной причины.
- Добавляйте reverse routing domain, если администраторы и мониторинг используют PTR.
- Проверяйте маршруты до каждого корпоративного DNS-сервера.
- Отдельно учитывайте приложения и контейнеры, обходящие системный resolver.
Частые вопросы
Почему запрос уходит на публичный DNS, хотя DNS VPN виден в `resolvectl status`?
Потому что адрес сервера не определяет обслуживаемые зоны. Если у VPN нет подходящего routing/search-домена и `DefaultRoute` выключен, resolved отправит запрос серверам каналов с `DefaultRoute` и глобальным `DNS=` из `resolved.conf`.
Чем `~corp.example` отличается от `corp.example`?
Оба варианта маршрутизируют полные имена зоны через связанный канал. Вариант без тильды дополнительно является поисковым суффиксом и может превратить короткое `crm` в `crm.corp.example`; route-only-вариант с тильдой этого не делает.
Нужно ли одновременно указывать `corp.example` и `~corp.example`?
Нет. Search-домен без тильды уже участвует в маршрутизации. Выберите его, если нужны короткие имена, либо route-only-домен, если приложения используют FQDN.
Что делает `~.`?
Это маршрут для DNS-корня, то есть подходящий маршрут практически для любого имени. Его используют, когда VPN должен принимать весь DNS-трафик. Более специфичные доменные маршруты всё равно имеют приоритет.
Почему после `DNS =` в конфиге WireGuard весь DNS пошёл через офис?
wg-quick регистрирует серверы командой `resolvconf -x`, а совместимый режим `resolvectl` превращает `-x` в домен `~.`. Для split DNS уберите `DNS =` и задайте домены через `PostUp` с `resolvectl` или через профиль NetworkManager.
Почему после правильной настройки имя всё ещё не открывается?
Очистите отрицательный кэш через `resolvectl flush-caches`, проверьте `ip route get` до DNS-сервера и убедитесь, что приложение не использует собственный DoH или контейнерный resolver.
Нужно ли править `/etc/resolv.conf`?
Обычно нет. При работе через systemd-resolved он должен вести к управляемому stub-файлу. Постоянные per-link-настройки задаются в NetworkManager, systemd-networkd или VPN-клиенте.
Источники
- systemd — systemd-resolved.service — Официальная man page, раздел «Protocols and Routing», актуальная ветка проекта: https://www.freedesktop.org/software/systemd/man/latest/systemd-resolved.html
- systemd — systemd-resolved and VPNs — Официальное руководство проекта: search domains, routing domains, `~.`, `default-route`, reverse-зоны и порядок применения настроек: https://github.com/systemd/systemd/blob/main/docs/RESOLVED-VPNS.md
- systemd — resolvectl — Официальная man page команд `status`, `dns`, `domain`, `default-route`, `revert` и `flush-caches`: https://www.freedesktop.org/software/systemd/man/latest/resolvectl.html
- NetworkManager — nm-settings-nmcli — Официальный справочник, свойства `ipv4.dns`, `ipv4.dns-search`, `ipv4.ignore-auto-dns`, `ipv4.never-default` и `ipv4.dns-priority`: https://www.networkmanager.dev/docs/api/latest/nm-settings-nmcli.html
- NetworkManager — NetworkManager.conf — Официальный справочник, раздел `[main]`, параметр `dns=systemd-resolved`: https://www.networkmanager.dev/docs/api/latest/NetworkManager.conf.html
- Ubuntu — пакеты Noble 24.04 LTS — Официальные карточки пакетов, использованные для проверки версий стенда: systemd 255.4 — https://launchpad.net/ubuntu/noble/%2Bpackage/systemd ; NetworkManager 1.46.0 — https://launchpad.net/ubuntu/noble/%2Bpackage/network-manager
- systemd — systemd.network — Man page, секция [Network]: `DNS=`, `Domains=` (routing-only-домены с `~`), `DNSDefaultRoute=`: https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html
- systemd — resolved.conf — Man page: глобальные `DNS=`, `FallbackDNS=`, `Domains=`: https://www.freedesktop.org/software/systemd/man/latest/resolved.conf.html
- WireGuard — wg-quick — Исходный код wg-quick для Linux, функция `set_dns` (`resolvconf -a … -m 0 -x`): https://git.zx2c4.com/wireguard-tools/tree/src/wg-quick/linux.bash
- update-systemd-resolved — README проекта: опции `dhcp-option DNS/DOMAIN/DOMAIN-SEARCH/DOMAIN-ROUTE`, пример клиентского конфига OpenVPN: https://github.com/jonathanio/update-systemd-resolved
