Синхронизация DNS static между устройствами RouterOS 7
94
Утилита на Python, которая через REST API RouterOS 7 синхронизирует
записи /ip/dns/static типа FWD с заполненным address-list между
несколькими устройствами MikroTik. Соответствует актуальному REST API
RouterOS 7.x (GET = print, PUT = add, PATCH = set, DELETE = remove).
Состав проекта:
| Файл | Назначение |
|---|---|
main.py | логика синхронизации (сбор → слияние → применение) и CLI |
models.py | pydantic-модели: конфигурация, записи, статистика |
ros_api.py | REST-клиент RouterOS (RouterOsRest, DeviceError) |
config.py | загрузка и валидация config.json5 |
helpers.py | маркер удаления, сравнение записей, локальный forward-to |
service.py | периодический запуск с корректной обработкой сигналов |
tests/ | офлайн-тесты (unittest, без сети и роутеров) |
Работа идёт в два прохода, как и задумано:
/ip/dns/static, отбираются только записи type=FWD, у которых
address-list равен имени, настроенному для этого устройства в конфиге.
Одноимённые FWD-записи вне списка запоминаются отдельно (кандидаты на
перехват). Все остальные записи (A, CNAME, FWD без списка, динамические
и т.д.) игнорируются.del) хотя бы на одном устройстве, удаляется на всех устройствах;forward-to, ttl и comment — локальные поля устройства и не
синхронизируются: при обновлении существующих записей они не
перезаписываются. При создании новой записи forward-to берётся из
преобладающего значения других записей этого устройства (на каждом
роутере оно может быть своим, например свой локальный DNS-форвардер),
а ttl и comment не задаются вовсе — остаются значениями по
умолчанию RouterOS. Если на устройстве ещё нет записей — используется
default_forward_to из конфига, а при его отсутствии — значение
forward-to с устройства-источника. Комментарий при этом читается:
маркер удаления (delete_comment) работает как раньше;match-subdomain побеждает значение с первой включённой
записи без маркера удаления, в лог пишется предупреждение о конфликте.PUT), исправляются отличающиеся (PATCH — только изменившиеся
поля) и удаляются помеченные (DELETE). Имя address-list подставляется
из настроек конкретного устройства, поэтому на разных устройствах
список может называться по-разному. Ключ --device ограничивает только
применение: сбор всегда идёт со всех включённых устройств, поэтому
выбранное устройство синхронизируется относительно всей сети, а не само
с собой.Ключ записи — её DNS-имя (без учёта регистра). Регистр имени как на устройстве не меняется, при создании используется регистр с устройства-источника.
--force — тогда недоступное устройство просто исключается
из применения. Исключение: устройство, явно указанное в --device, обязано
быть собрано — без знания его состояния применять изменения нельзя даже
с --force (код возврата 2).address-list (в т.ч. одноимённые FWD с другим
списком или без списка) не удаляются никогда.
Если одноимённая FWD-запись существует вне списка, она по умолчанию
перехватывается (adopt_foreign: true): ей проставляется нужный
address-list и синхронизируются поля — это защищает от дублей при
переименовании списка. При adopt_foreign: false перехват отключён:
такие записи просто игнорируются — не перехватываются, одноимённая
запись в списке не создаётся (чтобы не плодить дубли); в обычном логе
тишина, упоминание только при -v.delete_comment (по умолчанию del), хотя бы на одном устройстве — при
следующем запуске она будет удалена везде. Прежняя опция prune_stale
(авточистка «отсутствующих в целевом состоянии» записей) удалена:
целевое состояние строится из самих устройств, поэтому «запись, которая
есть только на одном устройстве» неотличима от новой записи, добавленной
на этом устройстве, — авточистка удаляла бы новые записи. Если в старом
конфиге остался ключ prune_stale, скрипт предупредит в логе и подскажет,
чем его заменить.pip install -r requirements.txt (requests, pydantic, json5, python-dotenv)www-ssl с сертификатом (рекомендуется);www и /ip/service/webserver/set rest-plain=yes
(только для защищённой сети/лаборатории).read,write,api,rest-api.Подготовка роутера (один раз):
/certificate/add name=rest-ca common-name=rest-ca key-usage=key-cert-sign,crl-sign days-valid=3650
/certificate/sign rest-ca
:delay 3s
/certificate/add name=rest-https common-name=192.168.88.1 subject-alt-name=IP:192.168.88.1 days-valid=365
/certificate/sign rest-https ca=rest-ca
:delay 3s
/certificate/export-certificate rest-ca file-name=rest-ca
/ip/service/set www-ssl certificate=rest-https disabled=no
/user/group/add name=rest-dns policy=read,write,api,rest-api
/user/add name=dns-sync group=rest-dns password="длинный-случайный-пароль"
rest-ca.crt скопируйте на машину со скриптом и укажите в конфиге
(verify_ssl: true + ca_cert: rest-ca.crt), либо ставьте verify_ssl: false
(проверка сертификата отключена — только для доверенных сетей).
Скопируйте config.example.json5 в config.json5 и отредактируйте.
Конфигурация — JSON5: можно оставлять комментарии, висячие запятые и
одинарные кавычки (обычный JSON тоже читается без изменений).
Глобальные параметры:
| Параметр | По умолчанию | Описание |
|---|---|---|
delete_comment | del | маркер удаления: комментарий, равный этому значению (без учёта регистра и пробелов), означает «удалить везде» |
adopt_foreign | true | перехватывать одноимённые FWD-записи вне настроенного списка; false — просто игнорировать их |
timeout | 15 | таймаут HTTP-запросов, сек |
max_workers | 4 | параллельность опроса устройств |
default_forward_to | "" | forward-to для новых записей на устройстве, где своих записей ещё нет; если не задан — берётся значение с устройства-источника |
Параметры устройства:
| Параметр | Обязателен | Описание |
|---|---|---|
name | да | имя устройства (для логов и --device) |
host | да | адрес роутера |
username | да | пользователь REST API |
password | да* | пароль (или password_env — имя переменной окружения) |
address_list | да | имя address-list на этом устройстве: по нему фильтруется сбор и в него пишутся записи |
scheme | нет | https (по умолчанию) или http — другие значения отклоняются |
port | нет | 443 для https, 80 для http (по умолчанию); допустимо 1–65535 |
verify_ssl | нет | проверка сертификата (false по умолчанию) |
ca_cert | нет | путь к CA-сертификату роутера (с verify_ssl: true) |
enabled | нет | false — временно исключить устройство |
exclude_domains | нет | список доменов, которые не синхронизируруются на/с этого устройства |
address_list — это и фильтр при сборе, и имя списка назначения при
применении. Поэтому на каждом устройстве указывается своё значение.
exclude_domains — исключения для конкретного устройства (точное совпадение
имени записи, без учёта регистра): перечисленные домены не собираются с него
(не влияют на целевое состояние других устройств) и не создаются,
не изменяются и не удаляются на нём — даже по маркеру удаления. Можно указать
одну строку вместо списка.
Ошибки значений (timeout не число, port вне диапазона 1–65535, scheme
не http/https, пустые обязательные поля, дубликаты имён) — это ошибка
конфигурации: запуск завершается с кодом 3. Неизвестные параметры не ломают
запуск, но вызывают предупреждение в логе — это ловит опечатки
(например, adopt_foregin) и остатки удалённого prune_stale.
pip install -r requirements.txt
# сначала посмотреть план без изменений:
python main.py -c config.json5 --dry-run
# применить:
python main.py -c config.json5
# синхронизировать только одно устройство (сбор — по-прежнему со всех):
python main.py -c config.json5 --device gw-branch --dry-run
# продолжить, даже если часть устройств недоступна при сборе:
python main.py -c config.json5 --force
# подробный лог:
python main.py -c config.json5 -v
Коды возврата: 0 — успех, 1 — были ошибки применения, 2 — сбор не удался,
3 — ошибка конфигурации/окружения. Удобно для мониторинга.
Файл .env в каталоге скрипта подгружается автоматически (python-dotenv):
переменные для password_env и SYNC_INTERVAL_MINUTES можно держать в нём.
Уже установленные переменные окружения приоритетнее .env, поэтому
локально dry-run запускается просто:
python main.py --dry-run
python service.py -c config.json5 -i 5 # каждые 5 минут
python service.py -c config.json5 --interval 5 --device gw-branch -v
-i/--interval — минуты между запусками (или переменная окружения
SYNC_INTERVAL_MINUTES, можно держать в .env); --device, --force,
-v передаются в main.py.
Остановка по SIGINT/SIGTERM (Ctrl+C, docker stop): сигнал во время
ожидания завершает сервис сразу; во время синхронизации — текущий запуск
доезжает до конца (роутеры не остаются в полуприменённом состоянии) и
сервис выходит; повторный сигнал — немедленный выход. Код возврата —
последний код синхронизации; ошибка конфигурации (код 3) останавливает
сервис сразу, чтобы оркестратор перезапустил его с задержкой
(restart: on-failure / unless-stopped).
*/5 * * * * /usr/bin/python3 /opt/dns-sync/main.py -c /opt/dns-sync/config.json5 >> /var/log/dns-sync.log 2>&1
Учтите: для запуска нужен весь каталог проекта (main.py импортирует
models, ros_api, config, helpers), а не один main.py.
cp config.example.json5 config.json5 # и отредактируйте
cp docker-compose.example.yaml docker-compose.yaml
# при необходимости создайте .env с переменными для password_env, например:
# MIKROTIK_GW_BRANCH_PASS="пароль"
docker compose run --rm dry-run # посмотреть план
docker compose run --rm sync # применить изменения
docker compose up -d service # постоянная синхронизация (каждые 5 мин)
config.json5, CA-сертификаты и переменные password_env монтируются/
передаются через compose (значения берутся из .env). Остановка сервиса:
docker compose stop service — текущий запуск доедет до конца
(stop_grace_period: 90s). Для доступа к роутерам в локальной сети на
Linux удобен network_mode: host (в примере закомментирован; в Docker
Desktop для Windows/macOS не работает).
Готовый образ из Docker Hub (сборка локально не нужна):
docker run --rm -v ./config.json5:/app/config.json5:ro \
--env-file .env mark99i/mikrotik-dns-sync:latest --dry-run
Офлайн-тесты без сети и роутеров — REST-слой подменяется заглушками, конфиги пишутся во временные файлы:
python -m unittest discover -s tests -t . -v
Временные файлы создаются в tests/tmp/ и удаляются по завершении тестов.
pytest не обязателен (используется unittest из стандартной библиотеки),
но тесты запускаются и через него, если он установлен. В CI (GitHub Actions,
.github/workflows/tests.yml) тесты прогоняются на каждый push в master
и на pull request. Образ в Docker Hub собирается и публикуется workflow
.github/workflows/docker.yml — тоже после зелёных тестов, по тегам
версий вида 26.10.19 (CalVer).
На gw-main (список blocked-dns, записи с forward-to=1.1.1.1) и
gw-branch (список dns-block-branch, записи с forward-to=9.9.9.9):
gw-main: foo.com FWD forward-to=1.1.1.1 enabled
bar.com FWD forward-to=1.1.1.1 comment="del" ← удалить везде
baz.com FWD forward-to=1.1.1.1 enabled
gw-branch: foo.com FWD forward-to=9.9.9.9 disabled ← включена на gw-main
После запуска на gw-branch: foo.com будет включена, её forward-to
останется 9.9.9.9 (на каждом устройстве он свой); bar.com удалена и
там и там; baz.com создана в списке dns-block-branch с
forward-to=9.9.9.9 — как у остальных записей gw-branch.
type Payload = ...), встроенные дженерики dict[...], объединения
X | None, модели данных — pydantic v2 (models.py: BaseModel с
frozen=True для конфигурации, записей и целевого состояния — защита от случайной мутации;
валидация конфигурации — декларативная: Literal для scheme, границы
port/timeout, непустые обязательные поля, уникальность имён устройств,
а все ошибки собираются одним списком с указанием места в конфиге);
REST-сессия переиспользуется между проходами, чтобы не поднимать
TLS-соединение дважды. Прочие новшества 3.15 (t-строки, frozendict)
для этого скрипта выигрыша не дают.config.json5 лежит открытым текстом: файл добавлен в
.gitignore, ограничьте права (chmod 600) или используйте password_env
(значение удобно держать в .env — он тоже в .gitignore).Content type
Image
Digest
sha256:e27d63bb9…
Size
47.1 MB
Last updated
3 days ago
docker pull mark99i/mikrotik-dns-sync