Рано или поздно в инфраструктуре появляется задача автоматического обновления TLS-сертификатов. В простом случае она решается установкой certbot: один домен, один сервер, cron-задание и дальше можно не вспоминать об этом годами.
Сложности начинаются, когда инфраструктура вырастает. Появляются wildcard-сертификаты, которые нельзя получить через HTTP-01 challenge. Появляется несколько доменов, у части из них по два сертификата с разными типами ключей. Появляется десяток машин, терминирующих TLS, и каждой нужны свежие файлы на диске. Схема «certbot на каждом хосте» здесь перестаёт работать: она упирается в rate limits, требует доступа к DNS с каждой машины и разъезжается по состоянию между хостами.
Дальше начинается более сложная часть. Обычно для неё приходится держать несколько компонентов: DNS-сервер для challenge, acme-клиент, связующий их хук и механизм доставки готовых сертификатов на остальные машины.
Ниже варианты этой схемы и то, как мы в итоге свели её к одному компоненту.
TL;DR
Wildcard требует DNS-01 challenge. Это не обсуждается: HTTP-01 для wildcard не работает в принципе. Значит, автоматике нужен доступ к DNS и вся сложность растёт отсюда.
Вместо токена DNS-провайдера делегирование
_acme-challengeна свой NS. Боевая зона после этого не меняется никогда, а полномочия автоматики сведены к одному техническому поддомену.Свой NS поднимать не пришлось: он уже встроен в Angie. ACME-клиент и DNS-сервер для challenge это две директивы в конфиге веб-сервера. Четыре компонента схлопнулись в один, сертификаты подхватываются без reload.
Доставка на другие машины pull, а не push. Машины сами забирают свои сертификаты по HTTP из внутренней сети по расписанию. Хосту, который хранит приватные ключи всех доменов, не нужен SSH-доступ никуда.
Раздача закрыта gateway’ем с TLS, ограничением по подсетям и разграничением по путям. Скрипт валидирует скачанное (соответствие ключа, сроки, SAN) и пишет файлы атомарно, поэтому рабочий сертификат невозможно испортить.
Сроки сходятся с запасом: перевыпуск за 30 дней до истечения, опрос раз в сутки около тридцати попыток забрать новый файл. Поверх внешний мониторинг истечения SSL, который проверяет то, что реально отдаётся клиенту.
Содержание
Задача, которая есть у всех
Ситуация довольно обычная для инфраструктуры, где больше одного домена и одной машины. Задача формулируется в одну строку: нужны wildcard-сертификаты на несколько доменов, автоматически продлеваемые, разложенные по нескольким хостам.
Конкретно у нас это несколько доменов вида *.example.com и *.example.net, плюс вложенные зоны *.orders.example.com и *.api.example.com. Часть доменов дополнительно требует второго сертификата с другим типом ключа (RSA в дополнение к ECDSA) старые клиенты не умеют в современную криптографию. Цифры у всех свои, суть одна.
Проблема здесь довольно типовая. Wildcard-сертификаты Let’s Encrypt выдаются только через DNS-01 challenge. HTTP-01 для них недоступен принципиально: подтверждение владения *.example.com нельзя свести к выкладыванию файла на один конкретный хост, потому что wildcard покрывает произвольное количество имён. Значит, автоматика должна уметь создавать и удалять TXT-записи в DNS и именно отсюда растёт вся сложность.
На практике у нас было три варианта:
Подход | Что требуется | Чем плох |
|---|---|---|
Руками править зону | Ничего | Регулярная ручная операция раз в ~60 дней с жёстким окном. Это не автоматизация |
API DNS-провайдера | Токен провайдера на хосте + acme-клиент с плагином | Привязка к провайдеру, ломается при смене регистратора. Права шире задачи: нужна одна TXT-запись, а токен даёт всю зону |
Делегировать | Свой DNS-сервер | Ещё один сервис в эксплуатации: BIND/PowerDNS, его конфиг, его обновления, его мониторинг |
Третий вариант архитектурно лучший: боевая зона не меняется никогда, а полномочия автоматики сведены к одному техническому поддомену, который ни в чём, кроме ACME, не участвует.
_acme-challenge.example.com. NS ns-acme.internal.example.com.
Один раз прописали делегирование дальше все запросы на _acme-challenge уходят на наш сервер, и он отвечает на них динамически.
Смущал только ценник: «поднять свой NS» звучит как отдельный полноценный сервис со своей эксплуатационной нагрузкой. Плюс над ним всё равно нужен acme-клиент, хук, который дёргает NS при выпуске, и что-то, что перезагрузит веб-сервер после получения файлов. Четыре компонента вместо одного ради одной TXT-записи, которая живёт две минуты.
Поэтому мы не стали проектировать сложное решение под известную задачу, а пошли подбирать под неё готовое. Нашли Angie.
Почему Angie: сложный NS оказался не нужен
Главное здесь в Angie уже есть DNS-сервер для ACME-challenge. Не интеграция с чужим DNS, не хук наружу, а собственный DNS-резолвер внутри веб-сервера, включаемый одной директивой. Вместе с ACME-клиентом, тоже встроенным.
В результате четыре компонента превращаются в один:
Классический стек | С Angie |
|---|---|
BIND/PowerDNS + зона + конфиг | директива |
certbot / lego / acme.sh | директива |
хук, правящий DNS при выпуске | не нужен это один процесс |
reload веб-сервера после выпуска | не нужен |
Мы получили архитектурно правильный вариант (делегирование поддомена на свой NS) по цене нескольких строк конфига вместо отдельного сервиса в эксплуатации. Ровно то, ради чего вообще имеет смысл подбирать инструмент под задачу, а не наоборот.
Минимальная рабочая конфигурация целиком:
http { acme_dns_port 53; acme_client_path /var/lib/angie/acme; acme_client example_com https://acme-v02.api.letsencrypt.org/directory challenge=dns; server { listen 443 ssl; server_name *.example.com example.com; acme example_com; ssl_certificate $acme_cert_example_com; ssl_certificate_key $acme_cert_key_example_com; } }
Что здесь происходит без единой внешней зависимости:
Angie слушает порт 53 и отвечает на challenge-запросы Let’s Encrypt самостоятельно отдельный BIND/PowerDNS не нужен;
сам ходит в ACME-директорию, заказывает сертификат, публикует TXT-запись, дожидается валидации;
складывает выданные файлы в
acme_client_path;подставляет их в
ssl_certificateчерез переменные$acme_cert_*, перечитывая без перезагрузки обычный nginx так не умеет, там путь к сертификату статичен и требует reload;следит за сроком и перевыпускает заранее (порядка 30 дней до истечения), без крона и таймеров.
Для эксплуатации это заметно упрощает схему. Нет хука, который молча упадёт при смене API провайдера. Нет крона, который перестанет срабатывать. Нет рассинхрона между «сертификат перевыпущен» и «сервис его подхватил». Отказ возможен, но он один и виден целиком.
Есть и ещё один полезный момент: можно использовать несколько типов ключей для одного домена. Два acme_client с разным key_type привязываются к одному server-блоку, и обе пары директив ssl_certificate/ssl_certificate_key перечисляются подряд Angie выберет подходящую по возможностям клиента:
acme_client example_com https://acme-v02.api.letsencrypt.org/directory challenge=dns; acme_client example_com_rsa https://acme-v02.api.letsencrypt.org/directory challenge=dns key_type=rsa; server { listen 443 ssl; server_name *.example.com example.com; acme example_com; acme example_com_rsa; ssl_certificate $acme_cert_example_com; ssl_certificate_key $acme_cert_key_example_com; ssl_certificate $acme_cert_example_com_rsa; ssl_certificate_key $acme_cert_key_example_com_rsa; }
Выделенный сервер
ACME-хост вынесен на отдельную машину, которая не обслуживает пользовательский трафик. Причины:
Порт 53 наружу. Машина должна принимать DNS-запросы из интернета их шлют валидационные серверы CA. Совмещать это с боевым фронтендом означает расширять его сетевую поверхность.
Приватные ключи всех доменов в одном месте. Компрометация этой машины стоит дорого, поэтому на ней не должно быть ничего лишнего ни приложений, ни пользовательских workload’ов.
Независимость жизненного цикла. Перезагрузка или обновление фронтенда не должны влиять на перевыпуск сертификатов, и наоборот.
Файрвол на хосте открывает ровно два порта 53/udp и 53/tcp во внутренней зоне; всё остальное закрыто:
firewalld_zone: internal firewalld_ports: - { port: 53, proto: udp } - { port: 53, proto: tcp }
Ansible-шаблоны: описание сертификатов данными
Конфигурация Angie генерируется из списка сайтов, а не пишется руками. Добавление домена это добавление записи в список:
angie_sites: - name: example_com domain_name: "*.example.com example.com" - name: example_com_rsa domain_name: "*.example.com example.com" key_type: rsa - name: example_net domain_name: "*.example.net example.net" - name: orders_example_com domain_name: "*.orders.example.com orders.example.com"
Шаблон разворачивает это в конфиг, решая две неочевидные проблемы.
Дедупликация server-блоков. Две записи с одинаковым domain_name (обычный и RSA-сертификат) должны попасть в один server, а не в два конфликтующих. Шаблон генерирует блок только для первого вхождения каждого набора доменов, а остальные подшивает к нему:
{% for site in angie_sites %} {% if angie_sites[:loop.index0] | selectattr('domain_name', 'equalto', site.domain_name) | list | length == 0 %} server { listen 443 ssl; server_name {{ site.domain_name }}; {% for peer in angie_sites | selectattr('domain_name', 'equalto', site.domain_name) %} acme {{ peer.name }}; {% endfor %} {% for peer in angie_sites | selectattr('domain_name', 'equalto', site.domain_name) %} ssl_certificate $acme_cert_{{ peer.name }}; ssl_certificate_key $acme_cert_key_{{ peer.name }}; {% endfor %} } {% endif %} {% endfor %}
Именование каталогов хранения. Angie кладёт файлы в подкаталог по имени клиента, и два клиента на один домен коллизировали бы. Ключ маппинга поэтому включает тип ключа: *.example.com для дефолтного, *.example.com_rsa для RSA.
Итог: новый wildcard-сертификат это четыре строки YAML и один прогон плейбука. Конфиг не редактируется, а значит и не расходится между хостами.
Доставка сертификатов: push или pull
Выпущенный сертификат нужен на машинах, которые терминируют TLS. Рассматривались два подхода.
Push с ACME-хоста
Централизованный крон на ACME-хосте: после перевыпуска пройтись по списку машин и разложить файлы по SSH, затем перезагрузить веб-сервер.
Здесь появляются несколько проблем:
Направление доверия. ACME-хосту потребовался бы SSH-ключ с правом писать в /etc/ssl и перезапускать сервисы на каждой машине парка. Машина, где лежат приватные ключи всех доменов, дополнительно становится точкой, компрометация которой даёт root везде. Два серьёзных актива складываются в один.
Реестр хостов. Нужно поддерживать список машин и соответствие «машина → сертификаты». Новый хост, не внесённый в список, молча не получает обновлений отказ тихий и обнаруживается по истечении сертификата.
Событийность. Push происходит один раз. Машина, недоступная в этот момент (перезагрузка, обслуживание, сетевой сбой), остаётся со старым сертификатом. Нужна отдельная логика ретраев и отслеживания, кому доехало.
Знание о чужих сервисах. Как именно перезагружать веб-сервер знает только сама машина. При push эту логику пришлось бы описывать централизованно для каждого хоста.
Радиус поражения. Ошибка в push раскатывает битый сертификат на весь парк за один заход.
Pull на стороне клиентов
Мы выбрали pull. ACME-хост раздаёт сертификаты по HTTP внутри контура и не знает о клиентах ничего. Каждая машина сама ходит за своими файлами по расписанию.
У этого подхода есть несколько плюсов:
SSH-доступ с ACME-хоста не нужен вообще. Направление инициации развёрнуто: соединение идёт от клиента к раздаче, а не наоборот.
Реестра нет. Новая машина ставит скрипт и начинает забирать своё. Регистрация на стороне ACME-хоста не требуется, расходиться нечему.
Идемпотентность вместо событийности. Опрос по расписанию сходится сам: машина была выключена заберёт на следующем тике. Ретраи не нужны как отдельная сущность.
Решение о перезагрузке локально. Машина знает свой веб-сервер и свою команду проверки конфига.
Локализация отказа. Каждый клиент валидирует скачанное сам и не трогает рабочий сертификат, если проверка не прошла. Проблема остаётся на одной машине.
Минус задержка распространения равна интервалу опроса. Для сертификатов со сроком в 90 дней и перевыпуском за 30 дней до истечения это несущественно: суточный опрос даёт около тридцати попыток забрать новый файл.
Раздача сертификатов по HTTP
На ACME-хосте поднят отдельный server-блок на порту 80, отдающий выпущенные файлы по предсказуемым путям.
map $req_domain $req_site { default ""; *.example.com example_com; *.example.com_rsa example_com_rsa; *.example.net example_net; } map $req_kind $req_file { default ""; cert certificate.pem; key private.key; } server { listen 80 default_server; server_name _; location ~ ^/acme/(?<req_domain>[^/]+)/(?<req_kind>cert|key)$ { if ($req_site = "") { return 404; } alias /var/lib/angie/acme/$req_site/$req_file; } }
С точки зрения безопасности здесь важна одна деталь: пути не собираются из пользовательского ввода напрямую. Запрошенное имя домена прогоняется через map, который является явным белым списком. Домен, которого нет в списке, даёт пустое значение и 404. Обход каталога через ../ невозможен в alias подставляется не то, что пришло в запросе, а заранее известное значение из карты. Оба map генерируются Ansible из того же angie_sites, так что раздача и выпуск не могут разойтись.
Модель доступа
Раздача идёт по plain HTTP, но не является публичной и не полагается на HTTP как на границу безопасности. Перед ней стоит внутренний API-gateway, который:
терминирует TLS наружу от gateway трафик идёт шифрованным, plain HTTP остаётся только на участке gateway → ACME-хост внутри контура;
ограничивает источники по подсетям доступ разрешён только инфраструктурным сегментам организации;
разграничивает по путям конкретные группы машин имеют доступ только к своим сертификатам, а не ко всей раздаче;
управляет кешированием ответы не кешируются, иначе клиент мог бы получать протухший файл уже после перевыпуска.
Последние два пункта существенны. Фильтрация по подсети сама по себе грубый allow-list: без разграничения по путям любая машина разрешённого сегмента могла бы выкачать приватные ключи всех доменов. Разграничение на gateway сводит доступ каждой группы к её собственным сертификатам.
Кеширование специфическая для pull-модели проблема. Промежуточный кеш на пути раздачи означает, что клиент честно ходит за обновлением, честно получает 200 OK и честно ставит себе старый сертификат, не имея никакой возможности это заметить.
Забор сертификатов на машинах
На каждой машине, терминирующей TLS, разворачивается Python-скрипт и systemd-таймер. Скрипт написан на стандартной библиотеке из зависимостей только python3 и openssl, которые и так есть.
Что забирается
Соответствие «машина → сертификаты» описывается одной картой в inventory:
cert_pull_map: web_node_1: - name: orders_example_com cert_path: "*.orders.example.com/cert" key_path: "*.orders.example.com/key" dst_cert: /etc/nginx/ssl/wildcard_orders.example.com domains: ["*.orders.example.com", "orders.example.com"]
Роль выбирает из карты записи для текущего хоста и падает на этапе прогона, если запись неполная:
- name: Select certificate mappings for this host ansible.builtin.set_fact: cert_pull_sites: >- {{ cert_pull_map | default({}) | dict2items | selectattr('key', 'equalto', inventory_hostname) | map(attribute='value') | flatten }} - name: Fail on incomplete mappings ansible.builtin.fail: msg: >- entry '{{ item.name | default('<unnamed>') }}' is incomplete: needs name, cert_path, key_path and at least one destination. loop: "{{ cert_pull_sites }}" when: >- item.name is not defined or item.cert_path is not defined or item.key_path is not defined or (item.dst_cert is not defined and not (item.dst_crt is defined and item.dst_key is defined))
Поддерживаются три формы назначения объединённый PEM (dst_cert, ключ и цепочка в одном файле, права 0600), отдельный сертификат (dst_crt, 0644) и отдельный ключ (dst_key, 0600). Разные веб-серверы ожидают разного, и выбор остаётся за записью в карте.
Развёртывание
Скрипт раскладывается шаблоном с проверкой синтаксиса до установки сломанный шаблон не доедет до машины в виде нерабочего файла:
- name: Deploy certificate pull script ansible.builtin.template: src: cert_pull.py.j2 dest: "{{ cert_pull_script }}" mode: "0755" validate: "/usr/bin/python3 -m py_compile %s"
Каталоги назначения создаются заранее, из тех же путей, что указаны в карте список выводится, а не дублируется руками:
- name: Ensure destination directories exist ansible.builtin.file: path: "{{ item }}" state: directory mode: "0755" loop: >- {{ (cert_pull_sites | selectattr('dst_cert', 'defined') | map(attribute='dst_cert') | list + cert_pull_sites | selectattr('dst_crt', 'defined') | map(attribute='dst_crt') | list + cert_pull_sites | selectattr('dst_key', 'defined') | map(attribute='dst_key') | list) | map('dirname') | unique }}
Учётные данные для уведомлений держатся отдельным файлом от .env ACME-хоста. Это осознанное разделение: на ACME-хосте лежат токены каналов уведомлений о выпуске, и им нечего делать на веб-нодах, которые пишут только в свой канал.
Планировщик
Systemd-таймер вместо крона ради Persistent и RandomizedDelaySec:
[Timer] OnCalendar=*-*-* 23:00:00 Timezone=Europe/Moscow # Разводит стадо: ноды не приходят на раздачу в одну и ту же секунду. RandomizedDelaySec=900 # Догоняет запуск, пропущенный из-за простоя хоста. Persistent=true Unit=wildcard-cert-pull.service
RandomizedDelaySec размазывает обращения по пятнадцатиминутному окну, чтобы десяток нод не пришёл на раздачу одновременно. Persistent=true догоняет пропущенный запуск важно для pull-модели, где сходимость обеспечивается регулярностью опроса.
У Persistent=true есть побочный эффект, который стоит знать при первом развёртывании: на хосте, где таймер никогда не срабатывал, systemd считает прошлый запуск пропущенным и стартует сервис сразу при включении таймера то есть в момент прогона плейбука, а не в 23:00. Для первичной раскатки это выключается отдельной переменной.
Сам сервис oneshot без рестарта:
[Service] Type=oneshot ExecStart=/usr/bin/python3 /usr/local/sbin/wildcard-cert-pull.py User=root # Провалившийся запуск уже отчитался в канал, а следующий ночной # повторит всё с нуля рестартовать здесь нечего. Restart=no TimeoutStartSec=600
Как именно забирается сертификат
Порядок действий в скрипте выстроен так, чтобы рабочий сертификат нельзя было испортить ни на одном шаге.
Шаг 1. Проверка необходимости
Перед любым сетевым обращением скрипт смотрит на срок уже установленного сертификата:
days_left = local_days_left(site) if not FORCE and days_left is not None and days_left > RENEW_BEFORE_DAYS: return False, {"skipped": True, "days_left": days_left}
Порог 30 дней, совпадает с моментом перевыпуска на ACME-хосте. Пока запас больше, ходить некуда: нового файла на раздаче ещё нет. Ночной запуск в этом случае вообще не трогает сеть, а пишет в журнал строку вида «осталось N дней, делать нечего». Тихая ночь остаётся объяснимой.
Отсутствующий, нечитаемый или неразбираемый локальный файл даёт None и всегда приводит к скачиванию. Это важный частный случай: свежеразвёрнутая машина забирает сертификат немедленно, а не ждёт истечения того, чего у неё нет.
Шаг 2. Скачивание с ретраями
def http_get(url): last_error = None for attempt in range(1, HTTP_RETRIES + 1): try: request = urllib.request.Request(url, headers={"User-Agent": "wildcard-cert-pull/1"}) with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT) as response: if response.status != 200: raise urllib.error.HTTPError(...) return response.read() except (urllib.error.URLError, ssl.SSLError, OSError) as e: last_error = e if attempt < HTTP_RETRIES: time.sleep(2 ** attempt) raise RuntimeError(f"GET {url} failed after {HTTP_RETRIES} attempts: {last_error}")
Три попытки с экспоненциальной паузой. Данные целиком остаются в памяти на диск ничего не попадает до прохождения всех проверок, поэтому оборванная загрузка физически не может оставить обрезанный файл.
Отказ по одному сертификату не прерывает обработку остальных: исключение ловится в цикле, запись уходит в список провалов, работа продолжается.
Шаг 3. Валидация
Перед установкой выполняются четыре проверки:
def validate(site, cert_pem, key_pem): leaf = parse_leaf(cert_pem) if b"-----BEGIN" not in key_pem: raise ValueError("private key is not PEM") # Ключ соответствует сертификату сверка публичных частей. if cert_public_key(leaf).strip() != key_public_key(key_pem).strip(): raise ValueError("private key does not match certificate") # Сроки: не истёк и уже вступил в силу. not_before, not_after = cert_dates(leaf) now = datetime.now(timezone.utc) if now >= not_after: raise ValueError(f"certificate expired at {not_after}") if now < not_before: raise ValueError(f"certificate not valid until {not_before}") # SAN покрывает ожидаемые домены. expected = set(site.get("domains") or []) if expected: missing = expected - cert_domains(leaf) if missing: raise ValueError(f"certificate does not cover {' '.join(sorted(missing))}") return not_before, not_after
Проверка доменов защита от подмены не злоумышленником, а собственной ошибкой в конфигурации: если раздача по какой-то причине отдаст на запрос *.orders.example.com сертификат *.example.com, это будет поймано до установки, а не браузерами пользователей после.
Шаг 4. Сравнение с установленным
Если скачанное побайтово совпадает с тем, что уже лежит, установка пропускается и веб-сервер не трогается:
if all(read_if_exists(path) == data for path, data, _ in targets): return False, {"not_before": not_before, "not_after": not_after}
Шаг 5. Атомарная запись
def write_atomic(path, data, mode): directory = os.path.dirname(path) fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".cert-pull-") try: with os.fdopen(fd, "wb") as f: f.write(data) f.flush() os.fsync(f.fileno()) os.chmod(tmp_path, mode) os.replace(tmp_path, path) except Exception: # При любой ошибке по ходу записи существующий файл остаётся на месте. if os.path.exists(tmp_path): os.unlink(tmp_path) raise
Временный файл создаётся в том же каталоге, что и целевой иначе os.replace через границу файловой системы перестанет быть атомарным. fsync перед переименованием гарантирует, что данные дошли до диска, а не остались в буфере. С точки зрения любого читателя файл в каждый момент времени либо старый целиком, либо новый целиком.
Шаг 6. Проверка конфига, затем перезагрузка
Перезагрузка выполняется один раз после установки всех сертификатов и только если что-то изменилось. Ей предшествует тест конфигурации:
test = subprocess.run(CONFIG_TEST_COMMAND, capture_output=True) if test.returncode != 0: raise RuntimeError("web server config test failed after certificate update: " + ...) reload_result = subprocess.run(RELOAD_COMMAND, capture_output=True) if reload_result.returncode != 0: raise RuntimeError("web server reload failed: " + ...)
Провал перезагрузки аннулирует отчёт об успехе записи убираются из списка обновлённых и переходят в провалы. Сообщение «сертификат обновлён» при неподхваченном сертификате было бы хуже, чем отсутствие сообщения.
Тайминги не врут: почему схема сходится
Соединим числа.
Параметр | Значение |
|---|---|
Срок сертификата Let’s Encrypt | 90 дней |
Перевыпуск на ACME-хосте | ~30 дней до истечения |
Порог начала опроса на клиентах | 30 дней до истечения |
Период опроса | сутки |
Разброс времени запуска | до 15 минут |
Попыток забрать новый сертификат | ~30 |
Пороги выпуска и загрузки совпадают, а клиенты проверяют раз в сутки. В первую ночь после пересечения порога новый сертификат на раздаче может ещё не появиться Angie перевыпускает по собственному расписанию, не синхронизированному с таймерами клиентов. Ничего страшного не происходит: клиент честно скачивает то, что есть, видит совпадение с локальным файлом и уходит без изменений. На следующую ночь повторяет.
Окно в 30 дней при суточном опросе даёт около тридцати независимых попыток. Промах в отдельную ночь не значит ничего; чтобы сертификат дошёл до истечения, отказ должен быть устойчивым и длиться месяц.
Вырожденный случай тоже закрыт: если локального сертификата нет вовсе или он не читается, порог не применяется и скачивание происходит при первом же запуске.
Уведомления
Скрипт пишет в корпоративный мессенджер по факту события:
успех отдельное сообщение на каждый обновлённый сертификат, с SAN, путями установленных файлов и датой окончания. Одно сообщение на сертификат, а не сводка: два сертификата в одном пузыре читаются как стена текста, и домены второго принимают за домены первого;
ошибка сообщение с текстом причины и явным указанием, что файлы не изменены;
без изменений по умолчанию молчание, опционально включается.
Сообщения помечены префиксом, отличающим «нода забрала сертификат» от «сертификат выпущен» оба типа приходят в один канал.
Отдельная деталь реализации: экранирование MarkdownV2. Каждое wildcard-имя содержит *, и неэкранированный символ приводит к отклонению всего сообщения на стороне API то есть уведомление об ошибке само молча не доходит. Экранируются все зарезервированные символы, а пути и списки доменов выводятся блоками кода, иначе мессенджер превращает доменное имя в гиперссылку.
Внешний контроль
Уведомления из скрипта не единственный и не основной контур наблюдения, потому что они зависят от работоспособности самого скрипта и сетевой доступности с той же машины. Основной контур внешний мониторинг истечения SSL, который проверяет то, что реально отдаётся клиенту по TLS-рукопожатию.
Такой контроль надёжнее. Она не зависит ни от скрипта, ни от таймера, ни от доступности раздачи, и ловит любую причину, по которой сертификат не обновился включая те, о которых скрипт не знает: файл обновлён, но веб-сервер его не перечитал; сертификат подложен не в тот vhost; сервис слушает со старым конфигом. Скрипт может сколько угодно рапортовать об успешной установке файла на диск валидна только проверка со стороны клиента.
На ACME-хосте дополнительно работает страница статуса и endpoint extended-status.json, обновляемый по расписанию: он отдаёт машиночитаемое состояние всех сертификатов с порогами предупреждения и ошибки по остатку дней. Это точка съёма для системы мониторинга, чтобы не парсить вывод openssl на каждом хосте.
Итог
Схема состоит из двух слабо связанных частей.
Выпуск. Отдельная машина с Angie, делегированным ACME-поддоменом и встроенным DNS-сервером. Никаких внешних acme-клиентов, хуков и API-токенов провайдера. Добавление домена четыре строки в inventory. Права на правку боевой DNS-зоны не нужны никому и никогда.
Доставка. Pull вместо push. ACME-хост не знает о клиентах и не имеет к ним доступа; клиенты сами забирают своё по расписанию через gateway, ограничивающий доступ по подсетям и путям. SSH-доступ с машины, хранящей приватные ключи всех доменов, отсутствует как класс.
Каждая часть отказывает независимо и локально. Битый файл не устанавливается благодаря валидации, недописанный не появляется благодаря атомарной замене, а неподхваченный веб-сервером ловится тестом конфигурации до перезагрузки. Пропущенная ночь ничего не стоит: следующая попытка через сутки, и таких попыток тридцать. Поверх всего внешний мониторинг сроков, проверяющий не намерения автоматики, а фактически отдаваемый клиенту сертификат.

