Сервер стоит дома или в филиале, провайдер время от времени меняет внешний адрес, а домен в зоне .ru обслуживает , Selectel или Yandex Cloud. Популярные DDNS-клиенты эти DNS-хостинги почти не поддерживают, поэтому запись обновляют самописными скриптами на curl. Ломаются такие скрипты молча. Selectel в 2026 году отключил старый DNS API, и скрипты, которые в него ходили, перестали обновлять записи. Я разобрал API этих хостингов, собрал их подводные камни и написал на Go DDNS-демон dnspatch, который их учитывает.
Начну с того, как эту задачу решают сейчас: почему самый частый совет, Cloudflare, подходит российской аудитории с оговорками, что умеют готовые DDNS-клиенты и где ломаются самописные скрипты. Затем разберу API REG.RU, Selectel и Yandex Cloud и их подводные камни. В конце расскажу, как устроен dnspatch, почему он устроен именно так и как запустить его за пару минут.
Почему не Cloudflare
Для self-hosted обычно советуют перенести зону в Cloudflare и поставить favonia/cloudflare-ddns (⭐ 2,9 тыс.) или timothymiller/cloudflare-ddns (⭐ 4,5 тыс.). Оба работают только с Cloudflare. Для сервиса с российской аудиторией у этого совета появились оговорки:
в ноябре 2024 года ЦМУ ССОП (Центр мониторинга и управления сетью связи общего пользования, подведомственный Роскомнадзору) рекомендовал владельцам сайтов отключить TLS ECH или перейти на отечественные CDN [1];
с 9 июня 2025 года российские провайдеры замедляют соединения с ресурсами за Cloudflare, и пользователь получает только первые 16 КБ ответа [2];
с 1 сентября 2026 года по 569-ФЗ делегировать домен в зонах .ru, .рф и .su и менять его NS можно только после идентификации администратора через Госуслуги [3].
Замедление касается соединений с серверами Cloudflare, через которые идёт трафик сайта. Если в Cloudflare лежит только DNS, а запись указывает прямо на ваш адрес, трафик через Cloudflare не идёт. Но если зона уже обслуживается у регистратора, где куплен домен, например у REG.RU, перенос ради DDNS-клиента означает смену NS со всеми новыми формальностями. Проще взять клиент, который работает с DNS-хостингом, где зона уже лежит.
Что уже есть
Я проверил известные DDNS-клиенты на поддержку российских DNS-хостингов. Звёзды указаны на 24 сентября 2026 года.
У dddns-updater есть провайдер REG.RU, но на 24 сентября его метод обновления вызывает только service/nop, то есть проверку доступа к API, и запись не меняет [5].
Дальше идут одиночные скрипты. Я нашёл на GitHub 13 репозиториев под REG.RU, Selectel, Beget, Timeweb и Yandex Cloud, у каждого от 0 до 5 звёзд. В трекерах крупных клиентов нашёлся только один запрос на такие хостинги, про Timeweb в ddns-updater [6], и тот без реакций. Спрос есть, но он раздроблен, и каждый пишет себе свой скрипт.
Подводные камни API
REG.RU пускает в API только с разрешённых адресов
REG.API 2 отвечает только на запросы с IP-адресов, перечисленных в настройках безопасности аккаунта [7]. А DDNS нужен как раз потому, что адрес меняется. Остаётся ходить в API через небольшой VPS со статическим адресом и разрешить в REG.RU только его. Для этого у провайдеров dnspatch есть параметр proxy:
[[instance.provider]]
type = "regru"
username = "my-login"
password = "${REGRU_PASSWORD}"
zone = "example.ru"
rr_name = "home"
proxy = "${PROXY_URL}" # например, socks5://user:pass@203.0.113.5:1080
Поддерживаются схемы socks5, socks5h, http и https. Retriever’ы, которые узнают адрес, по умолчанию ходят напрямую и переменные HTTP_PROXY/HTTPS_PROXY игнорируют. Иначе сервис вернул бы адрес VPS, и в DNS попал бы он. Логин и пароль из URL прокси dnspatch не печатает ни в логах, ни в ошибках. Вот что будет, если ошибиться в схеме прокси:
$ REGRU_PASSWORD=x PROXY_URL='sock5://user:s3cret@203.0.113.5:1080' dnspatch --config dnspatch.toml --check-config
dnspatch: error: instance "home": provider "regru": proxy: scheme must be one of socks5, socks5h, http or https, or the word "direct" for no proxy
Второй камень в том, что в API нет метода «изменить запись». dnspatch читает записи зоны (zone/get_resource_records), добавляет новую (zone/add_alias для A, zone/add_aaaa для AAAA) и только потом удаляет старую (zone/remove_record). Порядок выбран так, чтобы имя ни в какой момент не осталось без адреса. Если процесс упадёт между добавлением и удалением, в зоне окажутся две записи. На следующем цикле dnspatch найдёт среди них нужную и удалит остальные. Если же записей несколько и ни одна не совпадает с текущим адресом, dnspatch ничего не трогает и пишет ошибку. Такое состояние создал не он, и угадывать, какую запись заменить, он не станет. TTL через API не задаётся, в REG.RU он один на всю зону.
Пароль уходит в теле каждого запроса к REG.API. Ответ 307 повторил бы такой запрос вместе с паролем по адресу, который назвал сервер, поэтому провайдеры dnspatch редиректам не следуют.
Selectel отключил старый API
Selectel выводил старый DNS-хостинг из работы по этапам [8]. Создавать в нём домены нельзя с 1 марта 2025 года, редактировать записи нельзя с 1 февраля 2026-го. API domains/v1 отключён 1 мая 2026-го, а NS-серверы старого хостинга выключены 1 августа 2026 года. Скрипт tonytkachenko/selectel-ddns [9] ходит как раз в api.selectel.ru/domains/v1/. Кто поставил такой скрипт в cron и забыл, с февраля остался без обновлений DNS, и cron об этом никому не сообщил.
Новый API называется domains/v2, авторизация в нём идёт через Keystone (OpenStack Identity). Для dnspatch нужен сервисный пользователь с доступом к проекту, в котором лежит зона:
[provider.selectel]
type = "selectel"
account_id = "123456" # номер аккаунта в панели Selectel
username = "dnspatch" # сервисный пользователь
password = "${SELECTEL_PASSWORD}"
project_name = "my-project"
zone = "example.ru"
rr_name = "home"
dnspatch получает токен запросом POST /identity/v3/auth/tokens (сам токен приходит в заголовке X-Subject-Token), кеширует и перевыпускает за минуту до истечения. На 401 он получает новый токен и повторяет запрос один раз. Все записи одного имени и типа в Selectel хранятся одним объектом rrset, и его содержимое заменяется одним запросом. Поэтому промежуточного состояния, когда в DNS два адреса или ни одного, здесь нет. TTL задаётся для новой записи (по умолчанию 60 секунд), существующая сохраняет свой.
Yandex Cloud и цепочка из ключа, JWT и IAM-токена
Для Cloud DNS нужен сервисный аккаунт с ролью dns.editor на каталог зоны и его авторизованный ключ в виде JSON-файла, который создаёт yc iam key create. Из ключа dnspatch собирает JWT, подписанный PS256, со сроком жизни в час (больше IAM не принимает), обменивает его на IAM-токен в iam.api.cloud.yandex.net/iam/v1/tokens и кеширует токен до истечения. SDK для этого не нужен, хватает стандартной библиотеки:
У dnspatch всего две прямые зависимости, BurntSushi/toml для конфига и miekg/dns для работы с DNS. Сам ключ удобно передать файлом через key = "${file:/run/secrets/yc_key.json}".
В самом DNS API нашлись две мелочи. TTL в ответе приходит строкой, потому что 64-битные числа API кодирует в JSON как строки, и поле типа int на таком ответе падает с ошибкой разбора. dnspatch читает TTL как json.Number, который принимает обе формы. Запись меняется методом upsertRecordSets, а он возвращает не результат, а long-running operation. Если операция сразу пришла с ошибкой, dnspatch это заметит, а операцию, которая ещё выполняется, он считает принятой.
Как устроен dnspatch
В dnspatch три сущности:
retriever узнаёт текущий публичный адрес;
provider записывает адрес в DNS;
instance связывает один или несколько retriever’ов с одним или несколькими provider’ами и опрашивает их со своим интервалом.
Instance’ы работают независимо, так что в одном процессе можно вести несколько сайтов и зоны у разных хостингов. Вот типичный конфиг. В нём два источника IPv4, из которых второй запасной, записи home и *.home в REG.RU через прокси и корень другой зоны в Yandex Cloud:
interval = "5m"
[retriever.v4]
type = "ipify"
family = "ipv4"
[retriever.v4-reserve]
type = "icanhazip"
family = "ipv4"
[provider.regru]
type = "regru"
username = "my-login"
password = "${REGRU_PASSWORD}"
zone = "example.ru"
rr_name = "home"
proxy = "${PROXY_URL}"
[provider.yandexcloud]
type = "yandexcloud"
key = "${file:./secrets/yc_key.json}"
zone_id = "dns1234567890abcdef"
rr_name = "@"
[[instance]]
name = "home"
[[instance.retriever]]
ref = "v4"
[[instance.retriever]]
ref = "v4-reserve"
[[instance.provider]]
ref = "regru"
[[instance.provider]]
ref = "regru"
rr_name = "*.home"
[[instance.provider]]
ref = "yandexcloud"
ref ссылается на определение, а параметры рядом с ref его переопределяют. Так один аккаунт REG.RU обслуживает и home, и *.home. icanhazip вызывается, только если ipify не ответил.
Два стека и запасные источники
Retriever’ы instance’а опрашиваются по порядку, пока не заполнится каждое семейство адресов. Какое семейство вернул retriever, dnspatch определяет по самому адресу, а не по настройкам. Для A и AAAA в одном instance’е хватит одного retriever’а с family = "dual" (его умеют icanhazip, identme, ifconfigco, ipify и interface) или двух, по одному на IPv4 и IPv6.
Retriever interface берёт адрес с сетевого интерфейса, без внешнего сервиса. Он нужен в первую очередь для IPv6. Когда провайдер делегирует префикс (DHCPv6-PD), адрес из него уже висит на интерфейсе, и спрашивать о нём внешний сервис незачем. Учитываются только публичные адреса, а loopback, link-local, частные сети, ULA (fc00::/7) и CGNAT (100.64.0.0/10) пропускаются. Если публичных несколько, берётся наименьший, чтобы выбор не прыгал между циклами. Сузить выбор можно префиксом:
[retriever.lan]
type = "interface"
name = "eth0" # "wan" или "br-lan" на OpenWrt, "Ethernet" на Windows
family = "ipv6"
network = "2001:db8:1234::/64" # только адреса из этого префикса
Ответ внешнего сервиса dnspatch разбирает через netip.ParseAddr и проверяет семейство. HTML-страница ошибки, пустой ответ, loopback или адрес не того семейства становятся ошибкой retriever’а и в DNS не попадают.
Строгий конфиг
Ошибки в параметрах выводятся все сразу, а к неизвестному параметру dnspatch подсказывает ближайшее имя. Возьмём конфиг из быстрого старта (он в конце статьи) и сделаем в нём две опечатки, family = "ip4" у retriever’а и pasword вместо password:
$ REGRU_PASSWORD=x dnspatch --config typo.toml --check-config; echo "exit=$?"
dnspatch: error: instance "home": retriever "ipify": family: must be "ipv4", "ipv6" or "dual", got "ip4"
instance "home": provider "regru": parameter "password" is required
unknown parameter "pasword" (did you mean "password"?)
exit=2
Незаданную переменную окружения dnspatch тоже считает ошибкой и не подставляет вместо неё пустую строку. Иначе демон с пустым паролем стартовал бы и на каждом цикле получал отказ авторизации. О такой ошибке dnspatch сообщает первой, а остальные покажет, когда переменная появится. --check-config проверяет конфиг и выходит с кодом 0 или 2, не запуская демон. Его удобно поставить в ExecStartPre у systemd или в CI. В репозитории CI так проверяет все примеры из examples/.
Секреты
${NAME} подставляет переменную окружения, а ${file:/run/secrets/...} подставляет содержимое файла без последнего перевода строки. Второй вариант нужен потому, что значения из .env становятся переменными окружения контейнера, а их покажет docker inspect любому, у кого есть доступ к Docker-демону. Secrets в Docker и Kubernetes монтируются файлами и в окружение не попадают.
Сбои
Provider’ы одного instance’а обновляются независимо, и упавший не мешает остальным. На каждый запрос адреса и каждую запись отводится 30 секунд.
Provider получает запрос, только если адрес отличается от последнего, который он принял.
Упавший provider повторяется с экспоненциальным backoff и jitter. Первая пауза длится от половины интервала до целого интервала, дальше потолок удваивается с каждой ошибкой до 30 минут (или до интервала, если он длиннее). Jitter разводит во времени instance’ы, которые стартовали вместе.
Последний записанный адрес хранится только в памяти. После перезапуска первый цикл записывает текущий адрес во все provider’ы, даже если он там уже есть. Это один лишний вызов каждого provider’а за перезапуск. Файл состояния я делать не стал. В контейнере без volume он терялся бы при каждом перезапуске, то есть как раз там, где был бы нужен, а ещё потребовал бы думать о пути, правах и устаревших данных. Запись у provider’ов идемпотентна, и если адрес уже верный, в зоне ничего не меняется.
Быстрый старт
# dnspatch.toml
[[instance]]
name = "home"
[[instance.retriever]]
type = "ipify"
[[instance.provider]]
type = "regru"
username = "my-login"
password = "${REGRU_PASSWORD}"
zone = "example.ru"
rr_name = "home"
echo 'REGRU_PASSWORD=...' > .env && chmod 600 .env
chmod 644 dnspatch.toml # контейнер работает под uid 65532 и должен прочитать файл
Для REG.RU не забудьте разрешить в настройках API адрес, с которого пойдут запросы, или добавить proxy, как выше. Проверить конфиг до запуска можно тем же образом, командой docker compose run --rm dnspatch --check-config.
Что дальше
Увеличение количества поддерживаемых провайдеров.
Уведомления о смене адреса и сбоях.
Health-check и пинги мониторинга
Рассматривается вопрос о необходимости UI, но точно буду делать его опциональным
Итоги
Узнать адрес и отправить его в API несложно. Сложнее учесть особенности конкретного API. REG.RU принимает запросы только с разрешённых адресов, Selectel выключил старый API, а Yandex Cloud требует цепочку из ключа, JWT и IAM-токена. Самописный скрипт узнаёт о таких вещах, только когда ломается, и ломается молча.
Код dnspatch открыт под лицензией MIT и лежит на GitHub: https://github.com/KrimsN/dnspatch. Если что-то работает не так или вашего хостинга нет в списке, напишите в issues. А если всё работает, буду рад звёздочке на GitHub.
Если какую-то часть стоит разобрать подробнее, напишите в комментариях, что именно интересно.