Split routing на домашнем сервере
Домашний роутер обычно настраивают один раз через веб-интерфейс и потом стараются к нему не возвращаться. Пока конфигурация простая, это работает. Но когда появляются WireGuard, разные маршруты для разных сетей, DNS, kill-switch, статические DHCP-адреса и необходимость быстро вернуть систему в рабочее состояние, ручная настройка превращается в плохо документированное состояние на конкретной машине.
В моём случае роутер atlas управляется Ansible. Это не попытка превратить
домашнюю сеть в Kubernetes-кластер, а применение нескольких зрелых DevOps
практик там, где они действительно полезны: декларативность, идемпотентность,
разделение секретов, проверка изменений до применения, явный аварийный путь и
наблюдаемость.
Что именно делает роутер
atlas — Ubuntu Server с двумя физическими интерфейсами и WireGuard:
LAN 192.168.50.0/24
|
v
atlas / enp3s0
|
+-- enp1s0 --> провайдер, прямой выход
|
+-- wg0 ----> WireGuard VPN
Для клиентов действует простое правило: обычный интернет идёт через VPN, а выбранные назначения — напрямую через провайдера. Сейчас к прямому выходу относятся российские IPv4-префиксы и отдельный список сетей Steam/Valve. Локальные и приватные сети также не отправляются в VPN.
Это не два отдельных роутера и не набор команд, которые выполняются в определённом порядке вручную. Это единая конфигурация, где DHCP, DNS, firewall, WireGuard и маршрутизация согласованы между собой.
Ansible как источник истины
Репозиторий устроен как небольшой, но полноценный инфраструктурный проект:
site.yml точка входа
inventory/hosts.yml адрес и параметры подключения к atlas
group_vars/atlas.yml несекретная конфигурация
group_vars/atlas_vault.yml секреты WireGuard и сервисов
roles/router_base/ основная роль роутера
roles/router_base/templates/ systemd, nftables, dnsmasq, WireGuard
roles/router_base/files/ списки IP-сетей
scripts/ обновление списков и аварийные операции
В site.yml описан один хост — atlas, а вся логика собрана в роли
router_base. Переменные вроде wan_if, lan_if, wg_if, адреса LAN,
шлюз провайдера, таблицы маршрутизации и DNS находятся отдельно от шаблонов.
Поэтому изменение конкретного окружения не требует переписывать shell-скрипты
или копировать конфигурацию по нескольким местам.
Роль последовательно приводит сервер к целевому состоянию: устанавливает
пакеты, настраивает systemd-networkd, dnsmasq, nftables, WireGuard,
policy routing, SSH hardening, fail2ban и, при необходимости, Grafana Alloy.
Повторный запуск не означает «выполнить всё заново» — Ansible меняет только
то, что действительно отличается от описанного состояния.
Как работает split routing
Основная логика разделена на два уровня.
Сначала nftables смотрит на адрес назначения пакета из LAN. Для адресов из
наборов ru_direct и steam_direct он ставит прямую метку 0x2. Для любого
остального внешнего трафика ставится VPN-метка 0x1. Приватные диапазоны
обходят эту логику и остаются в основной таблице маршрутов.
Затем Linux применяет policy routing:
fwmark 0x1 -> table 200 -> default через wg0
fwmark 0x2 -> table 201 -> default через enp1s0 и шлюз провайдера
WireGuard настроен с Table = off. Это важная деталь: wg-quick не должен
самовольно подменять default route и конфликтовать с системой маршрутизации.
Маршруты и правила создаёт отдельный скрипт atlas-policy-routing, который
запускается systemd-юнитом atlas-policy-routing.service.
Защита от утечки построена не только на наличии маршрута. В обычном режиме
nftables разрешает VPN-маркированному трафику выходить только через wg0,
а direct-маркированному — только через WAN. Если policy rules исчезнут,
пакет не «упадёт» незаметно в обычный WAN, а будет отброшен. Для домашней
сети это более предсказуемое поведение, чем случайный VPN bypass.
DNS и DHCP — часть маршрутизации
dnsmasq обслуживает только LAN-интерфейс. Он выдаёт клиентам адрес роутера
как gateway и DNS, раздаёт DHCP-диапазон и поддерживает статические lease для
известных устройств. Конфигурация генерируется из переменных Ansible и
проверяется через dnsmasq --test до установки.
Upstream DNS задан явно, а dnsmasq не читает произвольный системный
resolv.conf. Для самого роутера сохраняется ссылка на stub-resolver
systemd-resolved, потому что DNS, доступный с WAN, и DNS, который должен
использоваться клиентами, — это разные эксплуатационные контексты.
В рабочем режиме запросы dnsmasq к upstream DNS маркируются и отправляются
через VPN. Это не гарантирует «магическую анонимность» всего DNS-стека, но
убирает распространённый класс ошибок, когда пользовательский трафик идёт
через туннель, а DNS незаметно уходит напрямую через провайдера.
Надёжность: восстановление состояния, а не бесконечный restart
Policy routing — динамическое состояние ядра. Его могут затронуть перезапуск сети, WireGuard или другой системный сервис. Поэтому в проекте есть два уровня защиты.
Во-первых, atlas-policy-routing.service связан с wg-quick@wg0 и
systemd-networkd: при изменении жизненного цикла этих сервисов маршруты
пересобираются в согласованном порядке. Сам скрипт использует flock, чтобы
запуск при загрузке, handler Ansible и health-check не выполняли конкурентные
операции delete/add над одними и теми же правилами.
Во-вторых, systemd timer запускает проверку каждые 30 секунд. Health-check
проверяет интерфейс wg0, обе policy rules, default routes в таблицах 200 и
201, а также фактический результат ip route get для обеих меток. Если
WireGuard поднят, но часть маршрутизации потеряна, состояние восстанавливается.
Если сам wg0 недоступен, система не пытается вслепую «починить» VPN и не
включает автоматический VPN-off — она оставляет предупреждение в journal.
Отдельно контролируется свежесть WireGuard handshake. Старый или отсутствующий handshake — это диагностический сигнал, а не повод автоматически менять маршруты и тем самым усложнять разбор аварии.
Fail-closed и аварийный путь
Надёжность — это не только автоматическое восстановление, но и понятный путь
выйти из неисправного состояния. Для этого есть отдельный
emergency_vpn_off.yml, запускаемый через scripts/vpn_off.sh.
Аварийный playbook делает ограниченный набор действий: переводит dnsmasq на
обычный upstream DNS, добавляет LAN в специальный nftables-набор
vpn_off_lan, останавливает health-check, policy routing и WireGuard, удаляет
правила для меток и очищает таблицы 200/201. После этого клиенты используют
основную WAN-маршрутизацию.
Важно, что это отдельный режим, а не скрытая логика внутри обычного playbook.
Основной firewall не переписывается, а аварийное исключение явно видно в
ruleset. После восстановления VPN обычный site.yml очищает этот набор и
возвращает managed state.
Такой дизайн даёт два свойства одновременно: в штатной работе отсутствует тихий обход VPN, а при серьёзной аварии есть короткий и проверяемый путь вернуть интернет в дом.
Безопасность домашнего периметра
Firewall использует deny-by-default для входящего трафика и forwarding. DHCP, DNS и SSH разрешены с LAN; входящий forward с WAN отсутствует. SSH дополнительно настроен на ключевую аутентификацию без root login и паролей, а fail2ban работает через nftables backend как дополнительный слой защиты.
Sysctl-параметры хранятся в отдельном позднем файле
/etc/sysctl.d/99-atlas-router.conf. Там включён IP forwarding, задан loose
reverse-path filtering для совместимости с policy routing, отключены source
route и ICMP redirects, включены syncookies и базовые сетевые hardening-настройки.
Это не заменяет обновление Ubuntu и резервное копирование, но делает безопасность повторяемой. После пересоздания машины или миграции на другой диск настройки не нужно вспоминать по истории shell.
Изменения и эксплуатация
Обычный рабочий цикл выглядит как review инфраструктурного изменения:
- Изменение появляется в репозитории, а секреты остаются в Ansible Vault.
- Запускается
--syntax-check, затем--check --diff. - Шаблоны дополнительно валидируют свои конфиги:
nft -c,dnsmasq --test,sshd -t, а для Alloy —alloy validate. - После review выполняется применение с
--diffи нужными tags. - Handlers перезапускают только связанные сервисы: изменение WireGuard не требует вручную вспоминать про policy routing, а изменение nftables корректно обновляет fail2ban.
Для переключения на резервный WireGuard-профиль используется отдельный vault и тот же управляемый путь применения. Для аварийного отключения VPN не нужен vault: это сознательно снижает число зависимостей в момент, когда основная связь может быть неисправна.
Обновление IP-списков без слепого доверия к источнику
Списки российских сетей и Steam/Valve хранятся как versioned input в репозитории, а не скачиваются роутером во время каждого запуска. Скрипты обновления получают новые данные, проверяют каждый CIDR как IPv4-сеть, сравнивают добавленные и удалённые префиксы и по умолчанию отказываются применять удаление сетей.
Это небольшое, но важное правило: изменение внешнего списка не должно автоматически становиться изменением firewall без возможности его увидеть в diff. Для RU-списка используется агрегированный источник ipverse, для Steam — анонсируемые IPv4-префиксы Valve AS32590 через RIPEstat. После обновления скрипты могут сами запустить syntax-check и dry-run Ansible.
Наблюдаемость
Если включить метрики, на atlas ставится Grafana Alloy. Он собирает системные
и сетевые метрики, включая счётчики интерфейсов, а также blackbox-проверки
TCP-доступности DNS Cloudflare и Quad9 и HTTP-доступности внешнего endpoint.
Данные отправляются через Prometheus remote write с очередью и WAL.
Это полезнее, чем просто знать, что systemd считает сервис «active». Можно отдельно увидеть, что интерфейс поднят, handshake свежий, DNS отвечает, а внешний HTTP-маршрут действительно работает.
Почему это удобно дома
Удобство здесь не в том, что команд стало меньше любой ценой. Удобство в том, что система стала объяснимой:
- целевое состояние описано в одном репозитории;
- изменения видны в diff до применения;
- секреты отделены от обычных переменных;
- повторный запуск безопасен и идемпотентен;
- штатный и аварийный режимы разделены;
- отдельные списки маршрутизации не смешаны;
- восстановление policy routing проверяется автоматически;
- диагностика остаётся в systemd journal и метриках.
При этом проект не скрывает свою сложность. Ansible не делает сетевые
изменения безопасными сам по себе: неверный шлюз, интерфейс или nftables rule
всё ещё могут отрезать SSH. Поэтому у atlas есть явный dry-run, ограничения
на работу с секретами, отдельный emergency playbook и привычка сначала
проверять новую SSH-сессию, а уже потом закрывать старую.
Для домашней инфраструктуры это хороший баланс. Система остаётся небольшой и понятной одному человеку, но её можно восстановить, проверить и изменить как инфраструктуру, а не как уникальный сервер с набором забытых ручных правок.
Результат в повседневном использовании
В итоге split routing остаётся незаметным для домашних устройств и приложений. Не нужно вручную включать VPN на телефоне, выбирать отдельный прокси для браузера или помнить, какой сервис должен идти через какой маршрут.
В домашнем интернете нормально и прозрачно работают:
- российские сервисы и госуслуги;
- Telegram;
- Яндекс и устройства экосистемы дома;
- Instagram и другие сервисы, которым нужен внешний маршрут;
- торренты;
- AI-сервисы;
- обычные сайты, обновления, магазины и всё остальное, что нужно в быту.
Российские и локальные назначения получают прямой путь через провайдера, а остальной трафик автоматически уходит через VPN. Поэтому сервисы не требуют ручных исключений на каждом клиенте, а разные устройства — от ноутбука до умной колонки — используют одну и ту же понятную политику.
Для пользователя это выглядит просто: интернет либо работает, либо система понятно сообщает, какой слой неисправен. В штатном режиме нет ощущения, что маршрутизацией нужно заниматься отдельно — она становится частью домашней инфраструктуры и просто выполняет свою работу.
