DDNS для российских DNS-хостингов: почему не подошли готовые клиенты и что я написал взамен

Сервер стоит дома или в филиале, провайдер время от времени меняет внешний адрес, а домен в зоне .ru обслуживает REG.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 года.
Клиент | Язык | ⭐ | Российские DNS-хостинги |
|---|---|---|---|
Go | 17 366 | нет; около 30 провайдеров, в основном китайские | |
Perl | 3 553 | устаревший API Яндекс PDD без AAAA [4]; пример для NIC.RU есть только в ветке main, в релизе 4.0.0 его нет | |
Go | 3 218 | нет среди 59 провайдеров | |
Go | 1 779 | нет | |
C | 1 152 | Яндекс PDD; репозиторий в архиве с октября 2025 года | |
Go | 370 | Selectel, Timeweb и NIC.RU через libdns, но только как модуль Caddy | |
shell | — | Beget | |
C# | 2 | Beget и Timeweb, только A |
У 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 для этого не нужен, хватает стандартной библиотеки:
signing := enc.EncodeToString(header) + "." + enc.EncodeToString(claims)
sum := sha256.Sum256([]byte(signing))
sig, err := rsa.SignPSS(rand.Reader, p.key, crypto.SHA256, sum[:],
&rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash})
if err != nil {
return "", fmt.Errorf("signing JWT: %w", err)
}
return signing + "." + enc.EncodeToString(sig), nil
У 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"
$ REGRU_PASSWORD=x PROXY_URL='socks5://user:pass@203.0.113.5:1080' dnspatch --config dnspatch.toml --check-config
dnspatch: config OK: dnspatch.toml (1 instance(s))
home: interval=5m0s retrievers=[v4(ipv4), v4-reserve(ipv4)] providers=[regru(rr_name=home), regru(rr_name=*.home), 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 и должен прочитать файл
# compose.yml
services:
dnspatch:
image: krimsn/dnspatch:0.3.0
restart: unless-stopped
env_file: .env
volumes:
- ./dnspatch.toml:/etc/dnspatch/config.toml:ro
logging:
driver: json-file
options: {max-size: "10m", max-file: "3"}
docker compose up -d
docker compose logs -f
Для 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.
Если какую-то часть стоит разобрать подробнее, напишите в комментариях, что именно интересно.
Источники
CNews, 7 ноября 2024 года. Роскомнадзор рекомендует заменить инструмент шифрования компании Cloudflare на российские аналоги
Блог Cloudflare, 26 июня 2025 года. Russian Internet users are unable to access the open Internet
vc.ru. Обязательная идентификация доменов через «Госуслуги»: что изменится с 1 сентября
ddclient, issue #954. Migrate ‘yandex’ protocol from LegacyProtocol to Protocol (add AAAA support)
sfadeev/dddns-updater, код провайдера REG.RU. RegRuDnsProvider.cs
qdm12/ddns-updater, issue #1153. Feature request: Add support for Timeweb DNS provider
Справка REG.RU. Какие ограничения есть при работе с Рег.API
Документация Selectel. General information about DNS hosting
tonytkachenko/selectel-ddns, адрес API в примере конфига. .env.sample
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.