Привет, Хабровчане!

Казалось бы, что там может быть интересного — A-запись прописал и забыл. Ан нет: стоило захотеть один сертификат сразу на все тестовые поддомены и переключать тестовую среду между двумя кластерами Kubernetes, да ещё автоматически поднять сертификаты Let’s Encrypt с помощью cert-manager… Выяснилось, что всё просто, да не так просто, как хотелось бы.

Бесплатный DynDNS, скажем, DuckDNS.org, отдаёт записи с TTL в сутки (переключился — а люди ещё долго ходят на старый кластер), а wildcard сертификат Let’s Encrypt выдаёт только через проверку DNS-записи.

Ищем альтернативу и учим cert-manager с ней работать без костылей. В итоге родился маленький открытый проект: cert-manager-webhook-freens — плагин для cert-manager, который умеет получать такие сертификаты через бесплатный DNS-хостинг FreeNS.

Сразу о том, для кого это. Если вы не используете Kubernetes и cert-manager, статья вам будет малополезна, разве что раздел про сравнение бесплатных DNS-хостингов (и он не претендует на полный обзор рынка).

Расскажу, зачем проект понадобился, почему не подошли очевидные варианты, как им пользоваться и что у него внутри.

С чего всё началось: DuckDNS и TTL в сутки

У нас есть проект с тестовой средой в Kubernetes. Имена вида argo.dev.example.com у регистратора указывали CNAME-записями на example-dev.duckdns.org, а A-запись в DuckDNS обновлялась скриптом при подъёме кластера. Дёшево, сердито, работает.

Пока не посмотришь на TTL. Спрашиваем авторитетный сервер DuckDNS:

example-dev.duckdns.org. 86400 IN A 203.0.113.10

86400 секунд. Сутки. Параметра TTL в API DuckDNS нет: принимаются ровно domains, token, ip, ipv6, txt, verbose, clear. В веб-панели — тоже нет. То есть сменил IP — и кэширующие DNS-серверы по всему миру имеют полное право до суток отдавать старый адрес.

Сначала мы это обошли: закрепили внешний IP входного балансировщика (ingress), чтобы он не менялся при пересоздании кластера. Боль ушла, задачу отложили. Честно — «нет боли, нет спешки».

А потом понадобилась проверка через DNS

Тестовая среда разрослась до двух кластеров (один в Yandex Cloud, второй у другого провайдера). Решили так: каждый кластер обслуживает свои имена *.<cluster>.dev.example.com, а активный кластер — ещё и общий псевдоним *.dev.example.com.

И вот тут сразу две проблемы:

  1. Сертификат на псевдоним. При проверке через HTTP (HTTP-01) Let’s Encrypt выдаст сертификат только тому кластеру, на который сейчас указывает DNS. Второй кластер заранее свой сертификат на псевдоним не получит. К тому же сертификаты на все поддомены сразу (*.) так вообще не выдаются — только при проверке через DNS (DNS-01).

  2. Переключение псевдонима. Переключать *.dev.example.com между кластерами через DuckDNS с TTL в сутки — ну, вы поняли, боль.

Значит, нужен DNS-провайдер, у которого есть:

  • API для TXT-записей (для проверки через DNS);

  • короткий TTL (минута, а не сутки);

  • возможность делегировать только поддомен dev.example.com, не трогая боевую зону;

  • и желательно бесплатно — среда всё-таки тестовая (точнее, даже для разработки).

Что перебрали, прежде чем нашли FreeNS

DuckDNS.org: оставить как есть

Бесплатный, стабильный, до пяти имён на аккаунт, и даже TXT-запись через API ставит (параметр txt). Но TXT-запись на имя ровно одна: если оба кластера одновременно пойдут продлевать сертификат, они перезапишут проверочные значения друг друга. Плюс тот самый TTL в сутки, который не меняется. Встроенной поддержки в cert-manager тоже нет — всё равно нужен сторонний плагин, и токен DuckDNS пришлось бы раздать обоим кластерам. А делегировать сам поддомен dev. на DuckDNS — значит отдать NS тестовой зоны сервису, где их не настроишь.

hldns.ru: российский аналог DuckDNS

Выглядит как хорошая отечественная замена (и это неоспоримый плюс), но по описанию и проверке оказался даже слабее:

  • через API обновляется только A-запись, про TXT — ни слова;

  • TTL фиксированный: в документации он не указан, задать его ни в API, ни в настройках нельзя. Спросили NS-сервер напрямую — динамические имена отдаются с TTL 300 секунд. Это гораздо лучше суток у DuckDNS, но для проверки через DNS не помогает;

  • одно имя на один email;

  • у самого сайта HTTPS-сертификат не проходит проверку;

  • на главной висит объявление о проблемах с отправкой писем, в том числе при регистрации;

  • аккаунт без обновлений удаляют через полгода.

Итог — чистый динамический DNS, проверку через DNS он не закрывает.

Cloudflare Free

Напрашивается, конечно, Cloudflare: бесплатно, TTL от 60 секунд, cert-manager поддерживает его сразу. Но делегирование поддомена у них доступно только на тарифе Enterprise: ни Free, ни Pro, ни Business его не дают. Значит, Cloudflare — это перенос NS всей зоны example.com, вместе с боевой средой. Трогать продакшн ради тестовой среды — так себе идея. Думаю, для многих это станет непреодолимым препятствием. Отказались.

API регистратора

Домен у нас зарегистрирован в Webnames, там же и DNS-зона. Логично посмотреть, что умеет регистратор. Нашлась пара вариантов.

Партнёрский API. Умеет A, CNAME и TXT, но:

  • TTL не задаётся;

  • авторизация — паролем от всего аккаунта. С этим паролем можно перенести домен или сменить NS, класть его в автоматику тестовой среды нельзя (притом что я отнюдь не ИБэшник).

Отдельный ключ для ACME-проверок. Это уже сильно аккуратнее: ключ умеет только добавлять и удалять TXT, домен остаётся у регистратора, поддержка есть в lego и в плагине для certbot от самого регистратора. Но:

  • для cert-manager нашёлся лишь один сторонний плагин, cert-manager-webhook-webnames, без звёзд и релизов;

  • ключ действует на всю зону, то есть при утечке позволяет выпустить сертификат и на боевые имена;

  • TTL не задаётся (фактически 2 часа);

  • A-записи всё равно остались бы, скажем, на DuckDNS с суточным TTL.

Этот вариант далеко не самый плохой, оставили запасным на случай, если лучше не найдётся.

Yandex Cloud DNS

Первый кластер у нас и так живёт в Yandex Cloud, так что Cloud DNS — очевидный кандидат: полноценный API, TXT-записи, настраиваемый TTL, для cert-manager есть плагин. Его рассматривали ещё раньше, в связке с external-dns, и смотрели цены: около 43 ₽ в месяц за зону плюс оплата за запросы. Деньги небольшие, но отказались по другой причине — привязка к вендору. Второй кластер работает у другого провайдера, и делать DNS тестовой среды зависимым от одного облака мы не хотели. Тем более что вся задача — переключаться между провайдерами, а так переключение всё равно оставалось бы зависимым от одного из них.

Кого не проверяли

Hetzner DNS, deSEC, Bunny DNS и AWS Route53 были в списке кандидатов, но до них не дошли: FreeNS закрыл все требования раньше. Зарубежные сервисы (кроме Cloudflare, разобранного выше) рассматривались на всякий случай: по нашей специфике сейчас, сами понимаете, это риск.

FreeNS

И тут нашёлся FreeNS — бесплатный DNS-хостинг с нормальным REST API: ключ в заголовке X-API-Key, TTL от 60 до 86400 секунд, создание, чтение, изменение и удаление записей. Один API закрывает и проверку через DNS, и A-записи с коротким TTL — то есть DuckDNS и прослойка CNAME-записей у регистратора уходят целиком.

Прежде чем что-то писать, проверили руками:

  • поддомен dev.example.com принимается как самостоятельная зона и отдаётся с a.freens.ru и b.freens.ru (авторитетно) ещё до делегирования — удобно проверять;

  • TXT с TTL 60 видна на обоих NS меньше чем через минуту после создания;

  • A-запись * срабатывает и на один, и на несколько уровней поддоменов, а явная TXT рядом с ней не перекрывается;

  • после удаления оба NS честно отвечают NXDOMAIN.

После этого у регистратора осталось прописать NS-записи для dev — и вся тестовая зона живёт на FreeNS, боевая не тронута. Красота, да и только.

Не без ложки дёгтя, конечно.

Риски — честно

Не буду делать вид, что это решение корпоративного уровня:

  • сервис весьма маленький, молодой - сервису около полугода, ведёт его частное лицо;

  • пользовательского соглашения и SLA нет;

  • оба NS у одного хостера;

  • API-ключ один на весь аккаунт, ограничить его одной зоной нельзя;

  • отрицательный ответ (запись не найдена) кэшируется на 1 час.

Для тестовой среды это приемлемо, и это зафиксировано в архитектурном решении (ADR) вместе с запасным вариантом. Для продакшна я бы несколько раз подумал.

Ну и ещё одно огорчение: для Webnames нашёлся хотя бы сторонний cert-manager-webhook-webnames, а тут придётся писать самому… Но, забегая вперёд, кажется, получилось неплохо.

Сводная таблица сравнения DNS-сервисов

Сервис

Хорошо

Плохо

Итог

DuckDNS

Бесплатно, стабильно, есть TXT через API

TTL 86400, одна TXT на имя, нет встроенной поддержки в cert-manager

Заменили

hldns.ru

Бесплатно, российский, TTL 300 с

Нет TXT, TTL не настраивается, проблемы с сайтом и почтой

Отказались

Cloudflare Free

TTL от 60 с, поддержка в cert-manager из коробки

Поддомен — только Enterprise, иначе переезд всей зоны

Отказались

Yandex Cloud DNS

Полноценный API, TXT, TTL, плагин для cert-manager

Платно (~43 ₽/мес + запросы), привязка к вендору

Отказались

Webnames, партнёрский API

A, CNAME, TXT, домен не переезжает

Нет TTL, пароль от всего аккаунта

Отказались

Webnames, ключ для ACME

Права только на TXT, есть в lego и certbot

Нет TTL, ключ на всю зону, A-записи остаются на DuckDNS

Запасной вариант

FreeNS

TTL от 60 с, полноценный API, зона для поддомена

Маленький частный сервис, ключ на весь аккаунт

Выбрали

Не хватает только плагина

Как уже упоминал, cert-manager сам умеет работать с Cloudflare, Route53, Google Cloud DNS, Azure DNS и ещё несколькими крупными провайдерами. Для всех остальных есть механизм внешних плагинов (webhook): отдельный сервис, который cert-manager вызывает через расширение Kubernetes API (API aggregation), чтобы создать и удалить TXT-запись _acme-challenge.

Для FreeNS такого не было. Значит, пишем.

cert-manager-webhook-freens

Репозиторий: github.com/Hubbitus/cert-manager-webhook-freens, лицензия Apache-2.0. Написан на Go, за основу взят официальный шаблон cert-manager/webhook-example.

Установка

Образ docker.io/hubbitus/cert-manager-webhook-freens и Helm-чарт публикуются на каждый тег вида vX.Y.Z. Ставим в пространство имён cert-manager, фиксируя версию чарта и контрольную сумму (digest) образа:

helm install cert-manager-webhook-freens oci://registry-1.docker.io/hubbitus/cert-manager-webhook-freens-chart \
  --version <X.Y.Z> --namespace cert-manager --set image.digest=sha256:<digest>

Ключ FreeNS кладём в секрет в том же пространстве имён. Чарт даёт плагину право читать только этот секрет (имя задаётся apiKeySecret.name, по умолчанию freens-api-key), а не все секреты подряд:

kubectl -n cert-manager create secret generic freens-api-key --from-literal=api-key=<key>

ClusterIssuer

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: you@example.com
    privateKeySecretRef:
      name: letsencrypt-account
    solvers:
      - dns01:
          webhook:
            groupName: acme.freens.ru
            solverName: freens
            config:
              apiKeySecretRef:
                name: freens-api-key
                key: api-key
              # Необязательно: домен в FreeNS, если он отличается от зоны, определённой по SOA.
              zone: dev.example.com

Дальше — обычный Certificate на *.dev.example.com, и cert-manager всё сделает сам.

Что внутри

Плагин без состояния

Интерфейс плагина у cert-manager простой: Present (создай TXT) и CleanUp (удали её). Напрашивается очевидное решение: запомнить идентификатор созданной записи в памяти между этими вызовами. Но если под перезапустится посередине, запись так и останется висеть в зоне.

Здесь плагин вообще не хранит состояния:

  • Present читает список записей зоны и создаёт TXT _acme-challenge с TTL 60, только если записи с таким же именем и значением ещё нет (повторный вызов ничего не сломает);

  • CleanUp снова читает зону и удаляет все TXT, у которых совпадают и имя, и значение проверки.

// CleanUp удаляет все TXT-записи с именем и значением проверки.
func (s *freensSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error {
	...
	for _, r := range matching(records, t.name, ch.Key) {
		if err := t.api.DeleteRecord(ctx, t.domainID, r.ID); err != nil {
			errs = append(errs, err)
			continue
		}
		...
	}
	return errors.Join(errs...)
}

Бонус: сертификат на dev.example.com + *.dev.example.com порождает две проверки с одинаковым именем _acme-challenge.dev.example.com, но разными значениями. Поскольку записи сопоставляются по паре (имя, значение), проверки не удаляют записи друг друга. Классические грабли, на которые здесь наступить невозможно.

Безопасность

Отдельно позаботился о том, чтобы плагин не стал дырой в кластере:

  • минимум прав (RBAC) — доступ только к одному секрету с ключом;

  • API-ключ не уйдёт на чужой хост — apiUrl принимается только с https, и ключ отправляется только на заданный в настройках хост (на это есть отдельный тест);

  • рабочий образ — distroless/static:nonroot, все базовые образы и GitHub Actions зафиксированы по контрольной сумме;

  • по умолчанию заданы запросы ресурсов и ограничение памяти;

  • образ и чарт подписываются через cosign (без ключей, keyless) по контрольной сумме, сборка под linux/amd64 и linux/arm64.

Тесты

Можно ли сегодня доверять ПО без автотестов? Вопрос риторический — здесь они, конечно, есть.

Отмечу несколько моментов:

  • код писался через разработку от тестов (TDD) — в истории коммиты идут парами test: ... (red) → feat/fix: ...;

  • make check требует 100% покрытия каждой функции, плюс go vet, golangci-lint, helm lint и govulncheck;

  • тесты чарта сверяют права RBAC и их привязки точно, а не по принципу «что-то там есть»;

  • и главное — официальный набор тестов на соответствие (conformance) от cert-manager прогоняется против живого FreeNS, в реальной зоне, с проверкой через авторитетный a.freens.ru. При каждой отправке в main и обязательно перед публикацией релиза.

Без ключа FREENS_API_KEY тесты на соответствие печатают SKIP и завершаются с кодом 0, так что локальной разработке не мешают:

mise install
mise exec -- make all                                   # проверки + сборка
FREENS_API_KEY=... mise exec -- make test-conformance   # против живого FreeNS

Выпуск релиза

Релиз — это тег vX.Y.Z на коммите из main. Дальше четыре задания:

  1. verify — без доступа к секретам: отклоняет тег не из main или не совпадающий с версией чарта, запускает make check и пробно стартует контейнер;

  2. conformance — тесты на соответствие против живого FreeNS;

  3. publish — сборка образа под обе архитектуры и OCI-чарта;

  4. sign — единственное задание с правом id-token: write: подписывает образ и чарт через cosign.

Секреты разнесены по окружениям GitHub (environments), на уровне репозитория их нет вовсе. Версии всех инструментов зафиксированы в mise.toml с контрольными суммами в mise.lock.

Для проекта примерно в 1,3 тыс. строк Go вместе с тестами это может показаться перебором. Но плагин работает внутри cert-manager, имеет доступ к секрету и меняет DNS — мне хотелось, чтобы ему можно было доверять.

Итог

Что получили:

  • тестовая зона dev.example.com делегирована на FreeNS, боевая зона у регистратора не тронута;

  • TTL — минута вместо суток, псевдоним *.dev.example.com быстро переключается между кластерами;

  • сертификаты Let’s Encrypt на все поддомены через проверку DNS выпускаются на обоих кластерах, независимо от того, куда сейчас указывает DNS;

  • DuckDNS и прослойка CNAME-записей у регистратора больше не нужны;

  • и небольшой, но аккуратный открытый плагин, которым может воспользоваться любой, кто держит зону на FreeNS.

Если вам нужен бесплатный DNS с API и коротким TTL для тестовых и домашних проектов, а Cloudflare не подходит из-за поддоменов — попробуйте. Буду рад звёздочкам ⭐, сообщениям об ошибках и запросам на слияние на GitHub.

А как вы решаете проверку через DNS для своих тестовых сред? Делитесь в комментариях!

Комментарии (2)


  1. spions
    02.10.2026 06:47

    Воспользуйтесь бесплатным (по крайней мере сейчас) DNS от selectel. Все "плохо" в статье там учтены. Не амбассадор селека, просто в свое время опенсорсил его в nginx proxy manager.


    1. Hubbitus Автор
      02.10.2026 06:47

      У Селектела смотрел тоже, бесплатным он будет (не проверял, так написано) только если используются другие услуги. Просто зайти и начать использовать, не создав платного аккаунта без баланса - нельзя, к сожалению.