📱 Подписаться
IT и цифровая трансформация

cert-manager-webhook-freens: сертификаты Let's Encrypt на все поддомены в Kubernetes через бесплатный российский DNS

📰 Habr 👁️ 0 просмотров

Hubbitus6 часов назадОбъяснить с

cert-manager-webhook-freens: сертификаты Let's Encrypt на все поддомены в Kubernetes через бесплатный российский DNS

Уровень сложностиСреднийВремя на прочтение9 минОхват и читатели3.3KKubernetes*DNS*Привет, Хабровчане!

Казалось бы, что там может быть интересного — 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.1086400 секунд. Сутки. Параметра 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 через APITTL 86400, одна TXT на имя, нет встроенной поддержки в cert-managerЗаменилиhldns.ruБесплатно, российский, TTL 300 сНет TXT, TTL не настраивается, проблемы с сайтом и почтойОтказалисьCloudflare FreeTTL от 60 с, поддержка в cert-manager из коробкиПоддомен — только Enterprise, иначе переезд всей зоныОтказалисьYandex Cloud DNSПолноценный API, TXT, TTL, плагин для cert-managerПлатно (~43 ₽/мес + запросы), привязка к вендоруОтказалисьWebnames, партнёрский APIA, CNAME, TXT, домен не переезжаетНет TTL, пароль от всего аккаунтаОтказалисьWebnames, ключ для ACMEПрава только на TXT, есть в lego и certbotНет TTL, ключ на всю зону, A-записи остаются на DuckDNSЗапасной вариантFreeNSTTL от 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. Дальше четыре задания:

• 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 для своих тестовых сред? Делитесь в комментариях!Теги:• k8s
• kubernetes
• ssl
• letsencrypt
• dns
• certbot
• cert-managerХабы:• Kubernetes
• DNS

Получайте больше инсайтов о систематизации бизнеса

Подписывайтесь на Telegram-канал Business Operations — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности

💬 Подписаться на канал