Бандл из ключа и цепочки — вещь, которая либо работает, либо валит рукопожатие в самый неподходящий момент. Причём молча: сервер стартует, а браузер уже потом говорит «не удалось установить защищённое соединение». Cert Unifier 3.1.1 идёт от обратного: проверка ключа сертификата здесь — предусловие записи, а не предупреждение в логе. Сначала сравниваются открытые ключи, и только при совпадении собирается <domain>.pem с режимом 0600. Ниже — как устроено, что показали два прогона (локальный на Git Bash и живой на дереве Certbot из 23 доменов) и на чём спотыкаются при первом запуске.

cert unifier cover

TL;DR: что это и первая проверка

  1. Что это. Один Bash-скрипт: обходит Certbot live/ и legacy-раскладки domain_leN, проверяет пары «ключ — сертификат».
  2. Что пишет. Один бандл на домен: приватный ключ, затем лист, затем промежуточные. Режим 0600, запись атомарная.
  3. Что отказывается делать. Не пишет бандл, если ключ не совпал, защищён паролем или отсутствует.
  4. Чего не делает вообще. Не запрашивает, не продлевает и не отзывает сертификаты, не перезагружает службы, не ходит в сеть.
  5. Проверка за минуту. --version, затем --dry-run --verbose по целевому дереву.
wget -O cert-unifier.sh https://raw.githubusercontent.com/paulmann/cert-unifier/main/cert-unifier.sh
chmod +x cert-unifier.sh
./cert-unifier.sh --version
./cert-unifier.sh --dry-run --verbose /etc/letsencrypt/live

Проблема, которую это решает

Собрать бандл руками — три команды cat. Вопрос не в сложности, а в том, что ошибка здесь не видна.

Первый сценарий: Certbot при продлении перевыпустил ключ, а старый .pem остался. Конфигурация указывает на бандл, ключ в нём от прежнего сертификата — и nginx запускается, но рукопожатие падает. В логах это выглядит как «SSL_do_handshake() failed», причину ищут в сертификате, а дело в ключе.

Второй: legacy-раскладки. У домена рядом лежат example.com_le1.crt, example.com_le2.crt и example.com_le10.crt. Сортировка «по алфавиту» уверенно ставит _le10 перед _le2, и старый сертификат перезаписывает новый. Причём _le10 в старых скриптах не ловился вообще — просто потому, что шаблон ждал одну цифру.

Третий: у части legacy-сертификатов файла .key нет вовсе — ключ лежит отдельно, с именем домена. Скрипт, который не проверяет наличие, соберёт бандл без ключа и завершится кодом 0.

Общая черта всех трёх — тихий отказ. Именно поэтому в 3.1.0 нашлось шесть блокирующих дефектов, о которых честно написано в заметках к релизу: --pfx падал на OpenSSL 3.x, --json обрезал вывод и возвращал 1 при успехе, несовпадающий ключ молча попадал в «проверенный» бандл, сертификат без .key давал бандл без ключа и код 0, поколения _le10 и выше не сопоставлялись, а ключ с паролем подвешивал запуск на приглашении OpenSSL. Версия 3.1.0 так и не была опубликована — 3.1.1 это то же дерево с исправленными блокерами.

Что такое Cert Unifier

flowchart TB
  SCAN["обход дерева: live/ Certbot и legacy _leN"] --> PAIR["пара: сертификат + ключ"]
  PAIR --> VERIFY{"открытый ключ ключа = открытый ключ сертификата?"}
  VERIFY -- нет --> REFUSE["отказ, код 1, бандл не пишется"]
  VERIFY -- да --> BUILD["сборка: ключ, лист, цепочка"]
  BUILD --> ATOMIC["запись во временный файл, chmod 0600, rename"]
  ATOMIC --> PEM["domain.pem рядом с источником или в --output-dir"]
  BUILD -. опция .-> PFX["domain.pfx для IIS"]
Свойство Значение
Тип самодостаточный Bash-скрипт, не служба
Требования Bash 4.0+, OpenSSL 1.1.1 или 3.x, flock желательно
Платформы Linux, macOS, WSL
Вход дерево каталогов с ключами и сертификатами
Выход <domain>.pem (и опционально <domain>.pfx)
Сеть нет доступа
Лицензия MIT

Скрипт занимает 931 строку и весит 32 150 байт — то есть читается целиком за один вечер, что для утилиты, работающей с приватными ключами, важнее любых обещаний.

Возможности

Проверка

Что Как работает
Сверка пары открытый ключ из сертификата против открытого ключа из ключа, а не хеш модуля
RSA и EC оба типа, а не только RSA
Отказ несовпадающий, зашифрованный, нечитаемый или отсутствующий ключ останавливает запись
Явный обход --no-key-validation — единственный способ записать непроверенное
Проверка PEM перед записью оба файла разбираются OpenSSL

Отдельного разговора стоит сама сверка. Сравнивать хеш модуля — привычный способ, но он знает только RSA: у EC-ключей модуля нет, и такая проверка либо не работает, либо даёт ложный результат. Здесь из сертификата извлекается открытый ключ, из ключа — тоже открытый, и сравниваются их SHA-256. Ошибка в любую сторону — отказ до записи.

Запись

Что Как работает
Атомарность сборка во временном файле в целевом каталоге, затем rename
Права umask 077, режим 0600 принудительно
Идемпотентность байт-идентичный бандл не перезаписывается, mtime сохраняется
Исправление прав существующий бандл с режимом 0644 получает 0600
Порядок ключ, лист, промежуточные — именно в таком порядке
PFX опционально, для IIS и Windows

Автоматизация

Что Как работает
--dry-run проверка и отчёт без записи
--json машиночитаемая сводка в stdout, логи в stderr
Коды выхода 0 — сделано, 1 — отказ, 2 — ничего не найдено, 130 — прерывание
--check-expiry предупреждение о близком истечении
flock блокировка, чтобы два прогона не трогали одно дерево

Раскладки, которые он понимает

flowchart TD
  ROOT["корень дерева"] --> CB["Certbot: privkey.pem и fullchain.pem"]
  ROOT --> LG["legacy: domain_leN.crt, .crtca, .key"]
  LG --> GEN["выбирается старшее поколение N в каталоге"]
  CB --> OUT["файл domain.pem"]
  GEN --> OUT
  OPT["--tlds, --no-tld-filter, --recursive"] --> ROOT
Раскладка Файлы Результат
Certbot privkey.pem, fullchain.pem в каталоге домена <domain>.pem рядом с ними
Legacy domain_leN.crt, domain_leN.crtca, domain_leN.key <domain>.pem
Legacy с общим ключом domain.key вместо domain_leN.key <domain>.pem
Вложенное дерево --recursive обход в глубину

Поколение выбирается по числу, а не по строке: _le10 старше _le2, и именно оно попадёт в бандл. Это ровно та деталь, на которой спотыкались старые самописные скрипты.

Замеры и цифры

Проверено 09.11.2026, двумя прогонами.

Первый — локальный, на Windows в Git Bash (bash 5.3.15, OpenSSL 3.5.9), на подготовленном наборе: корректная пара, несовпадающая пара, ключ с паролем, legacy без файла цепочки.

Проверка Результат
--version cert-unifier.sh 3.1.1, MIT, код 0
Корректная пара (--dry-run) planned: 1, запись не выполняется
Несовпадающая пара Private key does NOT match certificate … refusing to write, код 1
Ключ с паролем Private key is passphrase-protected … use --no-key-validation, запись не выполнена
Legacy без .crtca Missing chain file for legacy.example.com, код 1
flock отсутствует предупреждение flock not available — running without lock, работа продолжается
Реальная запись бандл 2 878 байт, первая строка -----BEGIN PRIVATE KEY-----
Повторный прогон Unchanged: … (content identical, mode enforced), mtime не изменился

Второй — на живом сервере (bash 5.3.9, OpenSSL 3.5.5, flock из util-linux 2.41.3): дерево /etc/letsencrypt/live, 23 каталога доменов.

Проверка Результат
--dry-run --json по всему дереву status: success, planned: 23, errors: 0, код 0
Побочный эффект dry-run новых .pem в живом дереве не появилось
Реальный прогон на двух доменах в песочницу код 0, два бандла, режим 600
Содержимое бандла 5 блоков BEGIN — ключ, лист, три промежуточных
Сверка внутри готового бандла хеш открытого ключа из сертификата равен хешу из ключа
Повторный прогон Success: 0 updated, 2 unchanged, mtime прежний
Срок действия одного из сертификатов notAfter=Jan 17 01:23:43 2027 GMT
Cert Unifier 3.1.1: отказы, запись и повторный прогон
Cert Unifier 3.1.1: где сработал отказ, а где запись

Что замеры не показывают: как ведут себя права 0600 на Windows — NTFS не хранит POSIX-режимы, и локально stat показывал 644 даже после chmod; настоящая проверка режима возможна только на Linux, и она сделана на сервере. Также не проверялись --pfx (экспорт PKCS#12) и --check-expiry: это отдельные ветки, для них нужен свой набор, а правило «два замера на статью» я соблюдаю.

Сильные стороны продукта

  • Проверка до записи, а не после. Отказ происходит раньше, чем испорчен предыдущий бандл.
  • Сравнение по открытому ключу. Работает и для RSA, и для EC — в отличие от хеша модуля, который знает только RSA.
  • Атомарная запись. Неудачный прогон оставляет прежний бандл целым.
  • Идемпотентность с сохранением mtime. Проверено на живом дереве: повтор дал 0 updated, 2 unchanged.
  • Права 0600 принудительно. Проверено: на Linux бандл получил именно 600, а не «как umask позволит».
  • Понятные коды выхода. 0, 1, 2, 130 — их можно ставить в условие скрипта, не разбирая текст логов.
  • Ноль сетевой активности. Утилита не может ничего продлить или отозвать — только собрать.
  • Читаемый размер. 931 строка: для кода, который держит приватные ключи, это аргумент в пользу доверия.

Установка

wget https://raw.githubusercontent.com/paulmann/cert-unifier/main/cert-unifier.sh
chmod +x cert-unifier.sh
./cert-unifier.sh --version

# системно
sudo install -m 755 cert-unifier.sh /usr/local/bin/cert-unifier
Требование Зачем Проверка
Bash 4.0+ массивы, shopt, подстановка процессов bash --version
OpenSSL 1.1.1 или 3.x сравнение открытых ключей, PEM, PKCS#12 openssl version
flock не даёт двум прогонам мешать друг другу command -v flock
Чтение ключей ключ читается в бандл права на каталог
Запись в целевой каталог бандл кладётся рядом с источниками ls -ld

На macOS системный Bash — 3.2; там нужен brew install bash, иначе скрипт не запустится. В минимальных контейнерах часто нет flock — утилита отработает с предупреждением, но параллельный запуск двух прогонов тогда никем не ограничен.

Настройка

flowchart LR
  D["пробный прогон"] --> V["проверка ключа и PEM"]
  V --> R["отчёт плана"]
  R --> J["сводка JSON в stdout"]
  V --> W["запись только после проверки"]
  W --> M["режим 0600 при umask 077"]
  M --> I["повторный прогон находит файл неизменным"]
Опция Что делает По умолчанию
--dry-run проверить и отчитаться, ничего не писать выключено
--verbose отладочные сообщения выключено
--tlds LIST разрешённые доменные зоны ru com
--no-tld-filter обрабатывать все домены выключено
--output-dir PATH писать бандлы в отдельный каталог рядом с источниками
--recursive обход вложенных каталогов для legacy выключено
--pfx дополнительно собрать PKCS#12 выключено
--check-expiry предупреждать о скором истечении выключено
--expiry-days N порог предупреждения 30
--json машиночитаемая сводка в stdout выключено

Переменные окружения: ALLOWED_TLDS, LOG_LEVEL, PFX_PASSWORD, CERT_UNIFIER_LOCK, NO_KEY_VALIDATION.

Рабочий порядок настройки такой: сначала --dry-run --verbose по дереву, потом разбор кодов выхода, и только затем реальная запись. Если в dry-run код 2 — значит ни один домен не прошёл фильтр зон; помочь может --no-tld-filter, но сначала стоит понять, почему домены не подошли.

Коды выхода и что они значат на практике

Код Что значит Что делать
0 всё запрошенное сделано проверить содержимое бандла
1 хотя бы одна запись отклонена читать stderr: причина указана с путём
2 не найдено ни одного сертификата проверить корень дерева и --tlds
130 прерывание INT или TERM ничего не записано, можно повторить

Отдельно про код 1: он означает не «всё сломалось», а «часть доменов отброшена». Годные домены при этом обрабатываются — отказ точечный, что и подтвердилось в локальном прогоне: корректная пара попала в planned, а несовпадающая и зашифрованная были отклонены с понятными сообщениями.

Почему порядок «ключ, лист, цепочка» важен

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

Служба Что ожидает Симптом при другом порядке
nginx ключ, затем цепочка SSL_CTX_use_PrivateKey не находит ключ
Apache отдельные директивы путаница между SSLCertificateFile и SSLCertificateKeyFile
Postfix один файл с ключом и цепочкой cannot load Private Key
HAProxy ключ и сертификат в одном файле отказ при разборе PEM

Проверка простая: первая строка готового файла — BEGIN PRIVATE KEY (или BEGIN RSA PRIVATE KEY для старых ключей), а число блоков BEGIN равно числу сертификатов в цепочке плюс один. В живом прогоне это дало пять блоков: ключ, лист и три промежуточных — и хеш открытого ключа из сертификата совпал с хешем из ключа, то есть бандл действительно собран из одной пары.

Как встроить в обслуживание

Утилита не должна жить в кроне «на всякий случай»: она не отслеживает изменения и не знает, когда Certbot перевыпустил сертификат. Разумная схема — запуск после продления или вручную по чек-листу.

Триггер Команда Зачем
После certbot renew cert-unifier --no-tld-filter /etc/letsencrypt/live обновить бандлы под новый материал
Перед сменой конфигурации --dry-run --verbose увидеть, что изменится
Еженедельно --dry-run --json найти пропавшие ключи и истекшие сроки
После миграции сервера прогон по новому корню поймать битые симлинки

Две оговорки. Первая: утилита не перезагружает службы, поэтому «собрал бандл» и «сервер начал использовать новый» — два разных шага. Вторая: --json пишет в stdout только сводку, логи идут в stderr, значит в кроне достаточно перенаправить stderr в файл, а stdout — в разбор.

Где это уместно, а где нет

Задача Подходит Почему
Собрать бандл из Certbot для nginx/Postfix да ровно этот сценарий и заявлен
Привести в порядок legacy-дерево _leN да поколения выбираются по числу
Проверить, не перепутаны ли ключи да отказ с указанием домена
Экспорт PFX для IIS да, --pfx ветка есть, в этом прогоне не проверялась
Продление сертификатов нет утилита не работает с ACME
Перезагрузка служб после замены нет намеренно не делает
Ротация ключей нет только сборка из того, что есть

Показательные случаи

Что видно при работе Что за этим стоит Что делать
rc=2 на живом дереве передан каталог домена вместо родительского указать корень live/
Часть доменов пропала из отчёта фильтр зон по умолчанию ru com --no-tld-filter, если это ваши домены
Cannot verify private key при живых файлах в Git Bash выставлена переменная MSYS_NO_PATHCONV убрать её для вызова
Отчёт пуст после копирования дерева относительные симлинки Certbot сломались копировать с cp -aL
Код 1 на одном домене из десяти локальная авария: чужой ключ или пароль разобрать один домен, остальные уже обработаны
Предупреждение про flock нет util-linux в минимальном образе поставить пакет или запускать вручную

Что изменилось в 3.1.1

По заметкам к релизу, шесть дефектов блокировали публикацию 3.1.0, и все они касались именно тихих ошибок.

Дефект 3.1.0 Что стало в 3.1.1
--pfx падал на OpenSSL 3.x из-за несуществующей опции работает на 1.1.1 и 3.x
--json обрезал вывод и давал код 1 при успехе полная сводка, статусы success, none, error
Несовпадающий ключ молча попадал в бандл проверка до записи, отказ с кодом 1
Сертификат без .key давал бандл без ключа и код 0 отказ с указанием домена
Поколения _le10 и выше не ловились любое число цифр, старшее поколение побеждает
Ключ с паролем вешал запуск на приглашении распознаётся до вызова OpenSSL, отказ без зависания

Практический вывод из этого списка простой: все шесть — про то, что ошибка раньше была либо невидимой, либо вела себя хуже ошибки. Версия 3.1.1 превращает их в явные отказы с кодом выхода, и это главное, что изменилось.

Подводные камни

  • Корень дерева, а не каталог домена. Если передать путь одного домена Certbot, скрипт ответит кодом 2 — это проверено и описано в AGENTS.md.
  • Фильтр зон по умолчанию — ru com. Домен в другой зоне не попадёт в обработку без --no-tld-filter.
  • Симлинки в /etc/letsencrypt/live. При копировании дерева cp -a сохраняет относительные ссылки, и они ломаются: прогон по такой копии даёт rc=2 и «ничего не найдено». Нужен cp -aL; я поймал это сам и потерял один прогон.
  • Windows и MSYS_NO_PATHCONV. Если выставить эту переменную в Git Bash, пути вида /c/Users/... больше не преобразуются для нативного openssl.exe, и проверка ключа начинает отказывать даже на корректной паре. Симптом обманчивый: сообщение «Cannot verify private key» при живых файлах.
  • Права 0600 на NTFS не проверить. Локально stat показывает 644 при любом chmod; судить о режиме можно только на Linux.
  • Бандл заменяет предыдущий. Запись атомарная, но не «накопительная»: сначала --dry-run, потом запись.
  • Пароль PFX уходит в командную строку. Он виден в ps на время вызова OpenSSL; надёжнее PFX_PASSWORD из окружения.
  • flock не обязателен, но нужен. Без него прогоны не блокируются, и в логе появляется предупреждение — на боевом сервере это повод поставить util-linux.
  • Утилита не перезагружает службы. После замены бандла перезапуск nginx или Postfix остаётся вашей задачей.
  • Версии 3.1.0 не существует. В заметках к 3.1.1 перечислены шесть блокеров, из-за которых 3.1.0 не опубликовали, — полезное чтение перед доверием к «проверенному» бандлу из старых скриптов.

Чек-лист

  • [ ] Проверить bash --version (4.0+), openssl version (1.1.1 или 3.x), наличие flock.
  • [ ] Скачать скрипт из релиза и сверить SHA-256 с SHA256SUMS.txt.
  • [ ] Запустить --version и --help, сверить вывод.
  • [ ] Сделать --dry-run --verbose по корню дерева, а не по каталогу домена.
  • [ ] Разобрать код выхода: 0 — писать, 1 — читать причины, 2 — проверить зоны.
  • [ ] Выполнить реальный прогон и проверить режим 0600 на Linux.
  • [ ] Открыть один бандл и убедиться, что первая строка — BEGIN PRIVATE KEY.
  • [ ] Повторить прогон и увидеть unchanged при неизменном mtime.
  • [ ] Перезапустить службу самостоятельно и проверить рукопожатие.
  • [ ] Записать версию скрипта и дату прогона в журнал обслуживания.

FAQ

Заменяет ли это Certbot?

Нет. Certbot получает и продлевает сертификаты, Cert Unifier только собирает из них бандлы. Сеть утилита не использует вообще.

Что будет, если ключ не совпадает с сертификатом?

Запись этого домена не произойдёт, в stderr будет причина с путём, код выхода — 1. Прежний бандл останется на месте.

Почему сравнение идёт по открытому ключу, а не по хешу модуля?

Хеш модуля работает только для RSA. Сравнение открытых ключей одинаково годится для RSA и EC — это и проверено на обеих ветках в тестах проекта.

Можно ли обойти проверку ключа?

Да, --no-key-validation. Это явный отказ от гарантии: в лог попадёт предупреждение, а бандл может оказаться нерабочим.

Нужен ли root?

Для чтения приватных ключей Certbot — обычно да. Сама утилита привилегий не требует и ничего, кроме бандлов и файла блокировки, не создаёт.

Что означает код 2?

Ни один сертификат не найден. Чаще всего неверный корень дерева (передан каталог домена вместо родительского) или домен вне зон ru com без --no-tld-filter.

Итог

Cert Unifier 3.1.1 закрывает узкую, но болезненную задачу: собрать бандл и не дать тихо получить нерабочий. Проверка ключа сертификата здесь не предупреждение, а условие записи — на локальном наборе это видно по трём разным отказам (несовпадение, пароль, отсутствие цепочки), а на живом дереве — по 23 запланированным доменам с нулём ошибок и последующей записью с режимом 600 и сохранением mtime при повторе. Ограничения честные: только сборка, без продления и перезапуска служб; ветки --pfx и --check-expiry в этом прогоне не проверялись; на Windows права 0600 не увидеть.

Цитата: Бандл проверяют не тогда, когда он сломался, а до того, как он заменил рабочий.

Связанные материалы

LLM Summary

  • Тема: Cert Unifier 3.1.1 — Bash-утилита, которая собирает один PEM-бандл на домен (ключ, лист, промежуточные) из Certbot live/ и legacy-раскладок _leN, с проверкой ключа сертификата до записи.
  • Главное правило: несовпадающий, зашифрованный, нечитаемый или отсутствующий ключ → отказ, код 1, бандл не пишется; обход только через --no-key-validation.
  • Запись: атомарная (временный файл в целевом каталоге и rename), режим 0600 при umask 077, идемпотентность с сохранением mtime, исправление режима 0644 на существующем бандле.
  • Замер 09.11.2026, локально (Git Bash, bash 5.3.15, OpenSSL 3.5.9): корректная пара → planned; несовпадение → Private key does NOT match certificate, код 1; ключ с паролем → отказ; legacy без .crtca → Missing chain file; бандл 2 878 байт, первая строка BEGIN PRIVATE KEY; повтор → content identical, mode enforced.
  • Замер 09.11.2026, живой сервер (bash 5.3.9, OpenSSL 3.5.5): dry-run по /etc/letsencrypt/live → status: success, planned: 23, errors: 0, код 0, новых файлов не создано; реальный прогон на двух доменах → режим 600, 5 блоков BEGIN, повтор 0 updated, 2 unchanged, mtime прежний, хеш ключа равен хешу сертификата.
  • Коды выхода: 0 — сделано, 1 — отказ хотя бы по одной записи, 2 — ничего не найдено, 130 — прерывание.
  • Требования: Bash 4.0+, OpenSSL 1.1.1 или 3.x, flock желательно; Linux, macOS, WSL; на macOS нужен Bash из Homebrew.
  • Чего не делает: не продлевает и не отзывает сертификаты, не ходит в сеть, не перезагружает службы, не удаляет исходные файлы.
  • Не проверялось в этом прогоне: ветки --pfx и --check-expiry, поведение на macOS, работа под нагрузкой из нескольких параллельных запусков с flock.
  • Ссылки: https://github.com/paulmann/cert-unifier, https://github.com/paulmann/cert-unifier/releases/tag/v3.1.1

Промт для агента (установка и настройка)

Готовый промт: скопируйте блок целиком. Шаги и предупреждения взяты из AGENTS.md репозитория (файл есть, проверено HTTP 200) и сверены с собственными прогонами.

Задача: поставить Cert Unifier 3.1.1 и собрать проверенные бандлы сертификатов из существующего дерева Certbot, не испортив рабочие файлы.

Материалы:
- репозиторий: https://github.com/paulmann/cert-unifier
- AGENTS.md: https://github.com/paulmann/cert-unifier/blob/main/AGENTS.md
- README: https://github.com/paulmann/cert-unifier/blob/main/README.md
- релиз: https://github.com/paulmann/cert-unifier/releases/tag/v3.1.1

Шаг 1. Предусловия: bash --version (4.0+), command -v openssl && openssl version (1.1.1 или 3.x), command -v flock.
Шаг 2. Скачать и установить:
wget -O cert-unifier.sh https://raw.githubusercontent.com/paulmann/cert-unifier/main/cert-unifier.sh
chmod +x cert-unifier.sh
sudo install -m 755 cert-unifier.sh /usr/local/bin/cert-unifier
Шаг 3. Сверить вывод: ./cert-unifier.sh --version  → "cert-unifier.sh 3.1.1" и "License: MIT"; затем --help, код 0.
Шаг 4. Проверка без записи: ./cert-unifier.sh --dry-run --verbose --no-tld-filter /etc/letsencrypt/live
Шаг 5. Прочитать код выхода: 0 — можно писать; 1 — читать stderr, там домен и причина; 2 — неверный корень дерева или фильтр зон.
Шаг 6. Реальная запись в отдельный каталог на первом проходе: --output-dir /root/cert-bundles
Шаг 7. Проверить результат: stat -c '%a %n' на бандлах (ожидается 600), первая строка файла — BEGIN PRIVATE KEY, число блоков BEGIN равно числу сертификатов в цепочке плюс один.
Шаг 8. Повторить прогон и убедиться, что в выводе "0 updated, N unchanged" и mtime не изменился.
Шаг 9. Перезапустить службу самостоятельно (утилита этого не делает) и проверить рукопожатие.
Шаг 10. Записать версию скрипта, дату и код выхода в журнал обслуживания.

Запрещено:
- запускать запись без предварительного --dry-run: бандл заменяет предыдущий;
- передавать каталог одного домена Certbot как корень: будет код 2;
- использовать --no-key-validation без письменного решения владельца сервиса;
- передавать пароль PFX аргументом --pfx-password: он виден в ps, использовать PFX_PASSWORD;
- ожидать продления, отзыва или перезапуска служб: утилита этого не делает;
- вставлять в отчёт содержимое приватных ключей и пароли.

Что считать успехом: --dry-run дал planned больше нуля при нуле ошибок; реальный прогон дал код 0; бандлы имеют режим 600; повторный прогон сообщил 0 updated; служба после перезапуска отдаёт корректный сертификат.