Sign inSign up

mark99i/mikrotik-dns-sync

By mark99i

•Updated 3 days ago

Синхронизация DNS static между устройствами RouterOS 7

Image
Networking
0

94

mark99i/mikrotik-dns-sync repository overview

⁠mikrotik_dns_sync — синхронизация DNS static (FWD + address-list) между устройствами RouterOS 7

Утилита на 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.pypydantic-модели: конфигурация, записи, статистика
ros_api.pyREST-клиент RouterOS (RouterOsRest, DeviceError)
config.pyзагрузка и валидация config.json5
helpers.pyмаркер удаления, сравнение записей, локальный forward-to
service.pyпериодический запуск с корректной обработкой сигналов
tests/офлайн-тесты (unittest, без сети и роутеров)

⁠Что делает

Работа идёт в два прохода, как и задумано:

  1. Проход 1 (сбор) — со всех включённых устройств параллельно читается /ip/dns/static, отбираются только записи type=FWD, у которых address-list равен имени, настроенному для этого устройства в конфиге. Одноимённые FWD-записи вне списка запоминаются отдельно (кандидаты на перехват). Все остальные записи (A, CNAME, FWD без списка, динамические и т.д.) игнорируются.
  2. Слияние — по собранным данным строится целевое состояние:
    • запись, у которой комментарий равен маркеру удаления (по умолчанию del) хотя бы на одном устройстве, удаляется на всех устройствах;
    • если запись включена хотя бы где-то — она станет включённой везде; если отключена везде — переносится на остальные устройства также отключённой;
    • forward-to, ttl и comment — локальные поля устройства и не синхронизируются: при обновлении существующих записей они не перезаписываются. При создании новой записи forward-to берётся из преобладающего значения других записей этого устройства (на каждом роутере оно может быть своим, например свой локальный DNS-форвардер), а ttl и comment не задаются вовсе — остаются значениями по умолчанию RouterOS. Если на устройстве ещё нет записей — используется default_forward_to из конфига, а при его отсутствии — значение forward-to с устройства-источника. Комментарий при этом читается: маркер удаления (delete_comment) работает как раньше;
    • при конфликте match-subdomain побеждает значение с первой включённой записи без маркера удаления, в лог пишется предупреждение о конфликте.
  3. Проход 2 (применение diff) — на каждом устройстве создаются недостающие записи (PUT), исправляются отличающиеся (PATCH — только изменившиеся поля) и удаляются помеченные (DELETE). Имя address-list подставляется из настроек конкретного устройства, поэтому на разных устройствах список может называться по-разному. Ключ --device ограничивает только применение: сбор всегда идёт со всех включённых устройств, поэтому выбранное устройство синхронизируется относительно всей сети, а не само с собой.

Ключ записи — её DNS-имя (без учёта регистра). Регистр имени как на устройстве не меняется, при создании используется регистр с устройства-источника.

⁠Правила безопасности

  • Если любое устройство недоступно на проходе 1, применение отменяется (fail-safe: неизвестно, что было на недоступном устройстве). Продолжить без него можно ключом --force — тогда недоступное устройство просто исключается из применения. Исключение: устройство, явно указанное в --device, обязано быть собрано — без знания его состояния применять изменения нельзя даже с --force (код возврата 2).
  • Записи вне настроенного address-list (в т.ч. одноимённые FWD с другим списком или без списка) не удаляются никогда. Если одноимённая FWD-запись существует вне списка, она по умолчанию перехватывается (adopt_foreign: true): ей проставляется нужный address-list и синхронизируются поля — это защищает от дублей при переименовании списка. При adopt_foreign: false перехват отключён: такие записи просто игнорируются — не перехватываются, одноимённая запись в списке не создаётся (чтобы не плодить дубли); в обычном логе тишина, упоминание только при -v.
  • Удаление — только маркером: поставьте записи комментарий, равный delete_comment (по умолчанию del), хотя бы на одном устройстве — при следующем запуске она будет удалена везде. Прежняя опция prune_stale (авточистка «отсутствующих в целевом состоянии» записей) удалена: целевое состояние строится из самих устройств, поэтому «запись, которая есть только на одном устройстве» неотличима от новой записи, добавленной на этом устройстве, — авточистка удаляла бы новые записи. Если в старом конфиге остался ключ prune_stale, скрипт предупредит в логе и подскажет, чем его заменить.

⁠Требования

  • Python 3.15+
  • pip install -r requirements.txt (requests, pydantic, json5, python-dotenv)
  • RouterOS 7.x с включённым веб-сервисом:
    • HTTPS: сервис www-ssl с сертификатом (рекомендуется);
    • или HTTP: сервис 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_commentdelмаркер удаления: комментарий, равный этому значению (без учёта регистра и пробелов), означает «удалить везде»
adopt_foreigntrueперехватывать одноимённые FWD-записи вне настроенного списка; false — просто игнорировать их
timeout15таймаут HTTP-запросов, сек
max_workers4параллельность опроса устройств
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

⁠Запуск по расписанию

⁠Встроенный сервис (service.py)
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).

⁠cron
*/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.

⁠Docker

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.

⁠Замечания

  • Код рассчитан на Python 3.15 и написан в современном стиле: type-псевдонимы PEP 695 (type Payload = ...), встроенные дженерики dict[...], объединения X | None, модели данных — pydantic v2 (models.py: BaseModel с frozen=True для конфигурации, записей и целевого состояния — защита от случайной мутации; валидация конфигурации — декларативная: Literal для scheme, границы port/timeout, непустые обязательные поля, уникальность имён устройств, а все ошибки собираются одним списком с указанием места в конфиге); REST-сессия переиспользуется между проходами, чтобы не поднимать TLS-соединение дважды. Прочие новшества 3.15 (t-строки, frozendict) для этого скрипта выигрыша не дают.
  • Динамические DNS-записи (от DHCP и т.п.) пропускаются.
  • Дубликаты одного имени внутри одного списка — предупреждение, берётся последняя запись.
  • REST API RouterOS отвечает на запрос максимум за 60 секунд — таймаут в конфиге держите меньше.
  • Пароль в config.json5 лежит открытым текстом: файл добавлен в .gitignore, ограничьте права (chmod 600) или используйте password_env (значение удобно держать в .env — он тоже в .gitignore).

Tag summary

Content type

Image

Digest

sha256:e27d63bb9…

Size

47.1 MB

Last updated

3 days ago

docker pull mark99i/mikrotik-dns-sync