Рано или поздно в инфраструктуре появляется задача автоматического обновления 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: internalfirewalld_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:00Timezone=Europe/Moscow# Разводит стадо: ноды не приходят на раздачу в одну и ту же секунду.RandomizedDelaySec=900# Догоняет запуск, пропущенный из-за простоя хоста.Persistent=trueUnit=wildcard-cert-pull.service
RandomizedDelaySec размазывает обращения по пятнадцатиминутному окну, чтобы десяток нод не пришёл на раздачу одновременно. Persistent=true догоняет пропущенный запуск важно для pull-модели, где сходимость обеспечивается регулярностью опроса.
У Persistent=true есть побочный эффект, который стоит знать при первом развёртывании: на хосте, где таймер никогда не срабатывал, systemd считает прошлый запуск пропущенным и стартует сервис сразу при включении таймера то есть в момент прогона плейбука, а не в 23:00. Для первичной раскатки это выключается отдельной переменной.
Сам сервис oneshot без рестарта:
[Service]Type=oneshotExecStart=/usr/bin/python3 /usr/local/sbin/wildcard-cert-pull.pyUser=root# Провалившийся запуск уже отчитался в канал, а следующий ночной# повторит всё с нуля рестартовать здесь нечего.Restart=noTimeoutStartSec=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-доступ с машины, хранящей приватные ключи всех доменов, отсутствует как класс.
Каждая часть отказывает независимо и локально. Битый файл не устанавливается благодаря валидации, недописанный не появляется благодаря атомарной замене, а неподхваченный веб-сервером ловится тестом конфигурации до перезагрузки. Пропущенная ночь ничего не стоит: следующая попытка через сутки, и таких попыток тридцать. Поверх всего внешний мониторинг сроков, проверяющий не намерения автоматики, а фактически отдаваемый клиенту сертификат.
ссылка на оригинал статьи https://habr.com/ru/articles/1071764/