Привет, Хабровчане!
Казалось бы, что там может быть интересного — 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.
И вот тут сразу две проблемы:
-
Сертификат на псевдоним. При проверке через HTTP (
HTTP-01) Let’s Encrypt выдаст сертификат только тому кластеру, на который сейчас указывает DNS. Второй кластер заранее свой сертификат на псевдоним не получит. К тому же сертификаты на все поддомены сразу (*.) так вообще не выдаются — только при проверке через DNS (DNS-01). -
Переключение псевдонима. Переключать
*.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/v1kind: ClusterIssuermetadata: name: letsencryptspec: 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 installmise exec -- make all # проверки + сборкаFREENS_API_KEY=... mise exec -- make test-conformance # против живого FreeNS
Выпуск релиза
Релиз — это тег vX.Y.Z на коммите из main. Дальше четыре задания:
-
verify— без доступа к секретам: отклоняет тег не изmainили не совпадающий с версией чарта, запускаетmake checkи пробно стартует контейнер; -
conformance— тесты на соответствие против живого FreeNS; -
publish— сборка образа под обе архитектуры и OCI-чарта; -
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 для своих тестовых сред? Делитесь в комментариях!
ссылка на оригинал статьи https://habr.com/ru/articles/1089388/