
render_topology_2d.py из того же config.json, который используется для создания конфигураций роутеров и серверов.Репозиторий проекта: https://github.com/karen07/openwrt-mesh-builder
openwrt-mesh-builder почти полностью автоматизирует построение routed-сети из OpenWrt-роутеров и Ubuntu/VPS-серверов: с leaf/spine-топологией, несколькими exit-узлами, динамической маршрутизацией Babel, point-to-point WG/AWG-линками, IPIP data-plane, policy routing, access-подсетями, split DNS и автоматическим переключением при отказах.
Вся сеть описывается в одном config.json. На его основе проект рассчитывает топологию и адресацию, генерирует конфигурации роутеров и серверов, ключи, access-клиентов, firewall-правила, настройки DNS, DHCP и Wi-Fi, собирает готовые образы OpenWrt, устанавливает их на роутеры и развёртывает exit-серверы.
То есть почти весь путь — от описания сети до её развёртывания и последующих обновлений — автоматизирован.
Как всё началось
В середине 2024 года у меня было две локации. В каждой стоял свой OpenWrt-роутер, каждая сеть жила сама по себе, и связности между ними не было.
В одной локации находился Samba-сервер с фильмами, в другой — телевизор, на котором эти фильмы хотелось смотреть. Какое-то время я переносил файлы на SSD: скачать в одном месте, скопировать на внешний диск, отвезти в другое место и подключить к телевизору.
Схема рабочая, но не особенно удобная.
Хотелось, чтобы телевизор в одной сети мог открыть Samba-сервер в другой без переносных дисков и ручного копирования.
Мне повезло: в одной из локаций на WAN роутера был белый IP-адрес. Поэтому два OpenWrt-роутера удалось связать напрямую. На каждом я создал туннельный интерфейс, добавил маршрут до удалённой LAN и разрешил нужный доступ в firewall.
После этого телевизор увидел Samba-сервер, фильмы начали открываться по сети, а SSD перестал быть обязательным промежуточным звеном.

От двух туннелей к routed-сети
Пока роутеров было два, одного прямого линка и пары статических маршрутов было достаточно.
Но постепенно локаций стало больше. Появился третий роутер, затем потребовалось связать несколько LAN и обеспечить для них общий выход через exit-серверы.
Физически exit мог быть один, но каждый роутер имел собственный point-to-point-линк до него:
Роутер A ── линк A ──┐ Роутер B ── линк B ──┼── Exit Роутер C ── линк C ──┘
В нормальной ситуации каждый роутер использует прямой путь:
Роутер A → Exit
Но если линк A → Exit пропал, а связь между роутерами сохранилась, тот же exit может оставаться достижимым обходным маршрутом:
Роутер A → Роутер B → Exit
При более сложном отказе путь может стать длиннее:
Роутер A → Роутер B → Роутер C → Exit
С ростом сети появились две связанные задачи:
распространять маршруты до LAN всех локаций;
находить рабочий путь до exit, даже если прямой линк конкретного роутера недоступен.
Решать это набором статических маршрутов неудобно. При каждом изменении топологии пришлось бы вручную определять, через какой узел доступна каждая сеть, куда переключать трафик при отказе, как возвращать его на прямой путь и как не создать маршрутизационную петлю.
Поэтому отдельные туннели начали складываться в общую routed-сеть. Каждое соединение стало одним из возможных путей, а маршрут до удалённой LAN или exit выбирается по текущей связности.
Каждому point-to-point-линку выделяется отдельная /31-подсеть:
Роутер A ── 10.255.0.0/31 ── Роутер B Роутер B ── 10.255.0.2/31 ── Роутер C Роутер A ── 10.255.0.4/31 ── Exit Роутер B ── 10.255.0.6/31 ── Exit Роутер C ── 10.255.0.8/31 ── Exit
В такой сети всего два адреса — по одному на каждую сторону. Не нужны broadcast-адрес, отдельный gateway и общая транзитная подсеть на несколько узлов.
Поверх линков работает Babel. Роутеры анонсируют свои LAN и access-подсети, а exit — служебные префиксы, по которым определяется его достижимость. Если линк исчезает, маршрут через него пропадает, и Babel может выбрать другой путь.
Именно здесь появилась аналогия с сетью ЦОД: point-to-point-линки, динамический обмен префиксами, несколько путей и автоматическое перестроение после отказа.
Масштаб другой, а физический underlay проходит через обычный интернет, но базовая логика похожа.

Spine и leaf, но дома
В классической spine-leaf-сети leaf-коммутаторы подключают конечные устройства или стойки, а spine обеспечивают связность между leaf.
Я использовал похожую идею, но адаптировал её под домашние OpenWrt-роутеры, NAT, публичные адреса и соединения через интернет.
Роутеры, доступные извне, выступают в роли spine. Они принимают входящие соединения и становятся точками агрегации.
Роутеры за NAT работают как leaf. Публичный адрес им не нужен: они сами устанавливают исходящие соединения со всеми spine.
Internet | +------------+------------+ | | | Spine01 Spine02 Spine03 / | \ / | \ / | \ / | \ / | \ / | \ Leaf01 Leaf02 Leaf03 Leaf04 другие узлы
Это не буквальная копия ЦОДовой топологии. Spine проекта дополнительно соединены кольцом, а exit-серверы являются отдельным типом узлов.
Но главная идея сохраняется: публично доступные узлы становятся точками агрегации, а leaf за NAT подключаются к ним исходящими соединениями. Полносвязная сетка «каждый с каждым» не требуется.

Как генератор строит линки
В текущей модели соединения создаются по следующим правилам:
spine ↔ spine кольцо между публичными spine leaf → spine каждый leaf подключается ко всем spine router → public exit отдельный линк каждого роутера до exit exit → spine reverse exit подключается к spine exit ↔ exit кольцо между публичными exit
Кольцо spine ↔ spine обеспечивает связность между опорными узлами. Каждый leaf самостоятельно подключается ко всем spine. Для публичного exit каждый роутер получает отдельный прямой линк.
Если exit не имеет публичного адреса, он работает как reverse exit: сам подключается к spine и после этого становится достижимым через внутреннюю routed-сеть.
Публичные exit дополнительно соединяются между собой кольцом.
Генератору достаточно знать роли узлов. Нужные point-to-point-соединения он создаёт сам.
Из каких слоёв состоит сеть
Чтобы не смешивать транспорт, маршрутизацию, подключение клиентов, выбор выхода и разрешение имён в одной сущности, сеть разделена на несколько слоёв:
WAN underlay обычная связность через интернет, NAT и провайдеров encrypted overlay point-to-point AWG/WG-линки между узлами routing plane Babel поверх линковых интерфейсов access layer WG/AWG/OpenVPN-клиенты и их подсети exit data-plane IPIP от роутера до выбранного exit policy plane fwmark и отдельная routing table DNS plane split DNS и failover резолверов
WAN underlay
Это обычная сеть провайдера. В ней могут быть публичные и серые адреса, CGNAT и домашние подключения.
Проект не управляет этим уровнем, а использует существующую IP-связность для построения внутренних линков.
Encrypted overlay
Поверх интернета строятся point-to-point AmneziaWG/WireGuard-соединения. Каждый линк получает отдельную /31-подсеть.
Эти интерфейсы служат транспортом для внутренней маршрутизации между узлами. Они не являются одним общим VPN-интерфейсом, через который безусловно отправляется весь пользовательский трафик.
Routing plane и Babel
Поверх линковых интерфейсов работает Babel. Каждый роутер анонсирует свою LAN, поэтому остальные узлы динамически узнают путь до неё.
Например, маршрут от Leaf01 до Leaf04 может выглядеть так:
Leaf01 → Spine01 → Leaf04
Если Spine01 становится недоступен, путь перестраивается:
Leaf01 → Spine02 → Leaf04
IP-адреса конечных устройств не меняются — меняется только внутренний путь пакета.
Babel не требует центрального контроллера. На каждом узле работает routing daemon, соседи обмениваются маршрутами через point-to-point-интерфейсы, а ядро Linux получает готовые записи в таблице маршрутизации.
Генератору достаточно создать линки, назначить /31-подсети, определить анонсируемые префиксы и запустить Babel на нужных интерфейсах. Единственный маршрут между каждой парой узлов заранее вычислять не нужно.
Babel распространяет не только LAN-префиксы. Каждый exit получает два служебных префикса:
node prefix— адрес назначения для IPIP и служебного доступа;marker prefix— признак того, что exit достижим.
Пока marker prefix присутствует как маршрут Babel, exit считается доступным.
Если прямой линк до него исчез, но marker prefix остаётся достижим через другой узел, можно продолжать использовать тот же exit по обходному пути.
Access layer
К фабрике могут подключаться не только LAN, расположенные за OpenWrt-роутерами. Сами роутеры могут принимать подключения отдельных устройств: ноутбуков, телефонов, рабочих станций и других клиентов.
Для этого на выбранном роутере создаются access-интерфейсы на базе WireGuard, AmneziaWG или OpenVPN.
Каждая access-группа получает собственную подсеть:
Ноутбук ─┐ Телефон ─┼─ WireGuard/AWG/OpenVPN ─→ OpenWrt-роутер │ access subnet └───────────────────────────────┐ ↓ routed fabric
Подключённое устройство получает адрес из access-подсети, а эта подсеть анонсируется через Babel остальным узлам.
Таким образом, фабрика знает маршруты не только до обычных LAN:
LAN Spine01 → 10.101.1.0/24 LAN Leaf01 → 10.101.11.0/24
но и до сетей удалённых клиентов:
AdminWG → 10.201.1.0/24 GuestWG → 10.201.2.0/24 MobileAWG → 10.201.21.0/24
Access-клиент становится ещё одной конечной точкой routed fabric. Он может обращаться к разрешённым LAN и другим узлам через те же маршруты Babel:
Ноутбук ↓ access-интерфейс ↓ OpenWrt-роутер ↓ Babel routed fabric ↓ удалённая LAN
Обратный маршрут также распространяется через Babel, поэтому удалённые узлы знают, через какой роутер находится access-подсеть.
Доступ при этом контролируется firewall-правилами. Для разных групп клиентов можно разрешить административный доступ к роутерам и LAN, оставить только доступ к отдельным сетям или предоставить лишь выход в интернет через выбранный exit.
Получается, что LAN роутеров и access-подсети — это два вида edge-сетей, подключённых к одной общей routed fabric.
Несколько выходов в интернет
У каждого роутера может быть собственный список exit в порядке предпочтения:
"exit_order": [ "EGR01", "EGR02", "PUB01", "REV01" ]
Роутер выбирает первый exit из списка, marker prefix которого сейчас доступен через Babel.
Порядок может отличаться от площадки к площадке: один роутер предпочитает ближайший сервер, другой — exit в другом регионе.
За выбор отвечает exit-route.sh. По умолчанию он раз в пять секунд проверяет таблицу маршрутизации. Если предпочтительный exit исчез, скрипт выбирает следующий доступный и меняет маршрут в отдельной таблице:
fwmark 0x25 → table 200
Основной default route в main при этом не изменяется. Через таблицу 200 проходит только заранее помеченный трафик.
Благодаря этому независимо обрабатываются:
трафик, который должен идти напрямую через провайдера;
трафик, который должен проходить через exit;
служебный трафик самого роутера;
доступ к локальным и удалённым LAN и access-подсетям.
Если предпочтительный exit снова появляется, роутер может вернуться к нему без изменения основной таблицы маршрутизации.
Зачем нужен отдельный IPIP data-plane
На первый взгляд пользовательский трафик можно было бы отправлять прямо по линковому интерфейсу, через который Babel видит exit.
Но тогда выбор exit оказался бы связан с выбором конкретного внутреннего пути до него.
В проекте эти задачи разделены:
Babel решает, как добраться до выбранного exit внутри routed-сети;
пользовательский пакет инкапсулируется в IPIP до node-адреса этого exit.
пользовательский пакет ↓ IPIP до выбранного exit ↓ маршрут Babel до node prefix ↓ один или несколько AWG/WG-линков ↓ exit-сервер
IPIP сам по себе не шифрует трафик, но его пакеты идут внутри защищённого overlay.
Главное преимущество — внутренний маршрут может измениться, а адрес назначения IPIP останется прежним:
Leaf01 → Spine01 → EGR01
После отказа путь превращается в:
Leaf01 → Spine02 → EGR01
Exit и внешний IP при этом не меняются, поэтому обычно теряется лишь несколько пакетов.
Если недоступен уже сам EGR01 и роутер переключается на EGR02, внешний адрес меняется. Установленные TCP-соединения могут оборваться, но новые создадутся через работающий exit.
Если недоступен ни один exit из списка, маршрут в таблице 200 удаляется, и помеченный трафик снова начинает использовать обычный маршрут из таблицы main.
Сейчас это fail-open-поведение: интернет не пропадает полностью, но трафик начинает выходить напрямую через провайдера.
Exit без публичного адреса
Exit-серверу необязательно иметь доступный входящий адрес.
Если сервер находится за NAT, он работает как reverse exit и сам устанавливает исходящие соединения со spine.
После поднятия линков он анонсирует через Babel node prefix и marker prefix.
Остальные узлы узнают маршруты до этих префиксов и могут построить IPIP data-plane до reverse exit через внутреннюю routed-сеть. В качестве exit можно использовать обычный Ubuntu-сервер за NAT.
Есть одна особенность: первый bootstrap выполняется вручную. Пока внутренняя связность ещё не поднята, подключиться к node-адресу невозможно.
После первого развёртывания дальнейшее управление может идти через сгенерированный SSH alias:
server_rev01_node
Direct traffic
Не весь пользовательский трафик отправляется через exit.
Для части направлений используется обычный маршрут через провайдера. В текущей конфигурации напрямую могут идти, например, выбранные национальные сети, сети отдельных автономных систем, инфраструктура определённых сервисов, локальные подсети и служебные адреса самой топологии.
Роутер сначала проверяет адрес назначения. Если он входит в direct-набор, пакет не получает метку 0x25 и продолжает маршрутизироваться через таблицу main.
Остальной пользовательский трафик получает метку и попадает в отдельную таблицу маршрутизации:
┌─ direct destinations ─→ main пользовательский ───┤ трафик └─ остальные адреса ────→ table 200 → exit
Состав direct-набора не зашит в логику проекта намертво. Списки стран, ASN и дополнительных CIDR задаются в tools/default.py, поэтому политику легко адаптировать под собственную сеть.
На exit-сервере действует дополнительная защитная проверка. В нормальной ситуации direct traffic исключается ещё на роутере. Но если такой пакет из-за ошибки конфигурации всё же пришёл через managed exit subnet, сервер не позволяет выпустить его наружу.
Получается две проверки:
router → не помечать direct traffic и отправлять его через main exit → отбросить direct traffic, если он всё-таки пришёл
Split DNS и failover резолверов
Разделение выполняется не только на уровне IP-маршрутизации. Для DNS в проекте есть собственные split-правила и автоматическое переключение между upstream-резолверами.
Клиенты локальной сети обращаются к dnsmasq на роутере. Дальше запрос может быть направлен либо на DNS-серверы провайдера, либо на один из локальных endpoints https-dns-proxy.
клиенты LAN и access ↓ dnsmasq ├─ выбранные доменные зоны → DNS провайдера └─ остальные запросы → DoH
Например, отдельные национальные доменные зоны можно разрешать через DNS провайдера, а остальные запросы отправлять через DNS over HTTPS.
Список зон настраивается в tools/default.py, поэтому DNS split не привязан намертво к конкретным доменам.
Для DoH можно указать несколько резолверов. Скрипт check-doh.sh периодически проверяет их доступность и выбирает первый работающий.
Если основной DoH-сервер перестаёт отвечать, dnsmasq переключается на следующий:
DoH 1 ↓ отказ DoH 2 ↓ отказ DoH 3
После переключения доменные split-правила сохраняются: выбранные зоны по-прежнему разрешаются через DNS провайдера, а остальные запросы уходят на новый активный DoH endpoint.
Если не отвечает ни один DoH endpoint, в качестве резерва могут использоваться DNS-серверы, полученные от провайдера:
DoH 1 → DoH 2 → DoH 3 → DNS провайдера
Таким образом, DNS и IP-маршрутизация управляются независимо:
IP policy: direct-направления → напрямую через провайдера остальной трафик → выбранный exit DNS policy: выбранные зоны → DNS провайдера остальные зоны → DoH с failover
При этом трафик самого https-dns-proxy можно направлять через policy routing и выбранный exit.
В результате отказ одного DNS-сервера не должен оставлять клиентов без разрешения имён, а разные доменные зоны могут использовать разные способы резолвинга.
Один config.json вместо ручного IPAM
Назначать адреса каждому point-to-point-линку вручную неудобно.
При добавлении узла пришлось бы:
искать свободную
/31-подсеть;выбирать адреса обеих сторон;
назначать порт;
придумывать имя интерфейса;
проверять пересечения;
синхронно обновлять несколько конфигураций.
В openwrt-mesh-builder служебная адресация вычисляется детерминированно из имён узлов и ключей линков.
Используются заранее выделенные диапазоны:
infra p2p links /31 из 10.255.0.0/16 exit marker prefixes /31 из 10.254.0.0/24 exit node prefixes /31 из 10.254.1.0/24
Детерминированно выбираются также порты линков, имена интерфейсов, SSH aliases и адреса сторон point-to-point-соединений.
Пользователь описывает желаемую модель сети, а не каждый низкоуровневый интерфейс:
{ "routers": [ { "name": "Spine01", "device_profile": "asus_rt-ax59u", "subnet": "10.101.1.0/24" }, { "name": "Leaf01", "device_profile": "asus_rt-ax53u", "subnet": "10.101.11.0/24", "allow_to_lan": [ "Spine01" ] } ], "mesh_hubs": [ { "name": "Spine01", "listen_ip": "203.0.113.11" } ], "exit_hubs": [ { "name": "EGR01", "listen_ip": "198.51.100.21" }, { "name": "REV01" } ], "exit_order": [ "EGR01", "REV01" ] }
Из этого описания генератор создаёт:
point-to-point-адреса;
ключи линков;
Babel-конфигурацию;
access-интерфейсы и клиентские конфиги;
IPIP-интерфейсы;
firewall-зоны и правила;
policy routing;
DNS, DHCP и Wi-Fi-конфигурацию;
SSH aliases.
Перед записью файлов конфигурация валидируется.
Проверяются пересечения LAN и служебных подсетей, повторяющиеся адреса и порты, ссылки на несуществующие узлы, допустимость имён и наличие обязательных параметров.
config.json становится источником истины, а низкоуровневая конфигурация воспроизводимо генерируется из него.
Что генерирует проект
Главная команда проекта:
./generate_configs.py
Она читает config.json и создаёт отдельное дерево для каждого узла:
конфиги OpenWrt-роутеров;
конфиги Ubuntu exit-серверов;
ключи внутренних AWG/WG-линков;
access-клиентов;
Babel-конфигурацию;
IPIP-интерфейсы;
firewall-зоны и правила доступа;
policy routing и direct ipsets;
настройки DNS, DHCP и Wi-Fi;
SSH-ключи и aliases.
Для локальной проверки структуры без загрузки пакетов и внешних списков можно использовать:
./generate_configs.py \ --skip-awg-download \ --skip-package-sync \ --skip-direct-downloads
Такой режим удобен для проверки config.json, просмотра сгенерированных файлов и diff перед развёртыванием.
Для VPS генератор также создаёт серверную конфигурацию и скрипты развёртывания. Сейчас серверная часть рассчитана только на Ubuntu.
Exit-серверы разворачиваются командой:
./deploy_servers.py
Скрипт копирует подготовленное дерево на сервер и запускает сгенерированный deploy.sh.
Сборка прошивок
Проект умеет создавать готовые образы OpenWrt через ImageBuilder:
./build_router_images.py
Для каждого роутера выбираются:
device profile;
архитектура;
набор пакетов;
собственное дерево
files/;конфигурация именно этого узла.
Имена образов выглядят примерно так:
images/spine01_25.12.5_a1b2c3d_asus_rt-ax59u_sysupgrade.bin images/leaf01_25.12.5_a1b2c3d_asus_rt-ax53u_sysupgrade.bin
В имя добавляется git commit, поэтому видно, из какой версии проекта и конфигурации собран образ.
Обновление запускается командой:
./upgrade_routers.py
Сначала обновляются leaf, затем остальные mesh hubs и только в конце main_router.
Это не гарантирует отсутствие потери связности, но снижает вероятность первой же перезагрузкой отключить узел, через который доступны остальные устройства.
Проверить состояние и версии можно командами:
./run_routers.py ./run_servers.py
Они выполняют указанную команду на роутерах или серверах через сгенерированные SSH aliases.
Секреты в репозитории
Генератор работает с приватными ключами внутренних линков, паролями Wi-Fi, ключами access-клиентов, сертификатами и серверными SSH-ключами.
Хранить всё это открытым текстом рядом с config.json было бы плохой идеей.
Поэтому секреты и key material сохраняются в зашифрованных контейнерах:
OWMB_ENC_SECRET_V1{...} OWMB_ENC_MATERIAL_V1{...}
Master keys лежат отдельно от репозитория.
Расшифровка выполняется во временной директории во время сборки образа или подготовки файлов для развёртывания, после чего временные данные удаляются.
Это не полноценный secrets manager, но такой подход позволяет хранить конфигурацию и историю изменений в Git без публикации приватных ключей и паролей.
При этом в публичном репозитории нет моих рабочих конфигураций, ключей, паролей и сгенерированных файлов реальной сети. В нём находятся сами скрипты, тестовый конфиг и примеры, на основе которых можно создать собственную конфигурацию.
Визуализация и проверка скорости
Когда связей стало много, читать топологию только по config.json и сгенерированным интерфейсам стало неудобно.
Проект умеет строить SVG-схему и интерактивную 3D-топологию:
./render_topology_2d.py ./render_topology_3d.py
SVG сохраняется в:
topology/topology_2d_topology.svg
На схеме отображаются узлы, их роли и созданные между ними линки.
Кроме логической топологии проект может измерять фактическую скорость направленных соединений через iperf3:
./collect_link_speeds.py \ --progress \ --json-out link-speeds.json
Результаты сохраняются в JSON и используются при рендеринге.
На схеме становится видна не только связность, но и реальная пропускная способность каждого направления.
На этом этапе особенно заметно, насколько далеко проект ушёл от первоначальной задачи просто посмотреть фильм.
Типовой рабочий процесс
Обычный цикл выглядит так:
vim config.json ./generate_configs.py ./deploy_servers.py ./build_router_images.py ./upgrade_routers.py ./run_routers.py ./run_servers.py
Сначала меняется желаемое состояние сети в config.json.
Затем из него заново генерируются конфигурации, ключи, адреса, firewall-правила, таблицы маршрутизации, DNS, DHCP, Wi-Fi и образы прошивок.
После изменения топологии не нужно вручную заходить на каждый роутер и вспоминать, где был прописан очередной маршрут или firewall rule.
config.json остаётся источником истины, а остальные файлы воспроизводимо создаются из него.
Для специфичной настройки отдельного роутера остаётся функция customization() внутри 99-firstboot-custom.
Туда можно добавить команды, которые нужны конкретному устройству, но не относятся к общей модели генератора.
Что осталось за рамками
В статье описаны основные идеи и архитектура проекта, но не все его возможности.
Для каждого роутера можно отдельно задавать firewall-правила, параметры DHCP, конфигурацию Wi-Fi, доступ между сетями и дополнительные настройки устройства. Также можно выбрать пакеты из репозиториев OpenWrt и добавить собственные пакеты: они устанавливаются не после прошивки роутера, а заранее встраиваются в собираемый образ.
Проект также генерирует access-клиентов и конфигурацию Ubuntu-серверов, собирает образы OpenWrt, управляет ключами и секретами, развёртывает exit-серверы, обновляет роутеры, визуализирует топологию и проверяет скорость соединений.
В публичном репозитории нет файлов моей рабочей сети: там находятся только сами скрипты, тестовая конфигурация и примеры.
Подробное описание параметров config.json, доступных возможностей и примеров использования находится в README проекта:
https://github.com/karen07/openwrt-mesh-builder
Возможностей у проекта получилось много — все их в рамках одной статьи всё равно не упомнишь.
