A pass branched on whether /etc/letsencrypt/live/<first-name> existed:
present, it ran `certbot renew`; absent, it handed over to the creation
script. Only the second path was ever told the names to ask for, so
after the first issuance CERTBOT_DOMAIN stopped reaching certbot
altogether -- renew takes the names off the certificate it already holds.
Editing the variable did nothing, silently, and the README documented the
manual issuance needed to work around it.
The branch is gone. Every pass now runs certonly with the names spelled
out, and certbot decides what that means:
same names, not due -> "Certificate not yet due for renewal; no
action taken", no connection opened, nothing
spent against the rate limits
name added or removed -> reissued with exactly the requested set
due for renewal -> renewed
--keep-until-expiring is what makes the first line true, and --cert-name
(already passed) is what keeps a changed list updating the existing
lineage instead of starting a second one beside it. No --expand is
needed to add a name once the lineage is named.
All three decisions were checked against the live stands: the no-op one
with a real run on testnet2, the other two as dry runs on testnet1 and
testnet2.
With the branch removed, 1-renew-cert.sh is a pass-through and the
numbering finally matches the order things run in -- 0-create-cert.sh
used to execute after 1-renew-cert.sh. So: 1-renew-cert.sh deleted,
0-create-cert.sh renamed to 1-ensure-cert.sh, which is what it now does.
Claude-Session: https://claude.ai/code/session_01BvgYcYPWd1KGABLKViVPSk
15 KiB
Общие сведения
English version: README.md
Certbot — клиент Let's Encrypt от EFF: получает и перевыпускает бесплатные TLS-сертификаты.
Certbot, работающий в docker-контейнере циклом перевыпуска, в паре с контейнером haproxy, который он снабжает сертификатом.
Репозиторий описывает только развёртывание в docker.
Использование
Контейнер — не разовая команда. Его команда по умолчанию представляет собой цикл: выполнить проход, поспать 12 часов и повторить, — поэтому сертификат он получает при первом старте и дальше поддерживает свежим.
Каждый проход делает три вещи, по скрипту на каждую:
| Скрипт | Что делает |
|---|---|
entrypoint.sh |
Сам цикл и PID 1 контейнера. Выполняет проход, спит 12 часов, повторяет; неудачный проход не обрывает цикл, а сообщается и повторяется |
1-ensure-cert.sh |
Сам проход. При первом запуске пишет самоподписанную заглушку, чтобы HAProxy смог занять 443; затем дожидается HAProxy и просит у certbot сертификат ровно на имена из CERTBOT_DOMAIN — выпуская его, перевыпуская при изменившемся списке имён либо не трогая вовсе |
2-concatenate-cert.sh |
Склеивает fullchain.pem и privkey.pem в единый site.pem, которого ждёт HAProxy |
3-update-haproxy-cert.sh |
Устанавливает site.pem в работающий HAProxy через его runtime API — без перезапуска и без разрыва соединений |
Проверка владения — HTTP-01 на порту 380. Внутри контейнера на нём слушает
собственный standalone-сервер certbot, а HAProxy передаёт туда
/.well-known/acme-challenge/ с публичного порта 80. Больше до 380 никто
доступа иметь не должен.
Let's Encrypt требует, чтобы публичные записи A/AAAA домена указывали на эту машину, а порт 80 был доступен из интернета.
docker-compose
services:
certbot:
build:
context: https://git.bitdeals.org/private/certbot.git
dockerfile: ./docker/Dockerfile
image: registry.bitdeals.org/certbot
restart: unless-stopped
environment:
- CERTBOT_DOMAIN=example.org,www.example.org
- CERTBOT_EMAIL=admin@example.org # необязательно, для уведомлений об истечении
volumes:
- certificates:/etc/certificates # общий с haproxy
- letsencrypt:/etc/letsencrypt # ключ учётной записи, сертификаты, настройки перевыпуска
- letsencrypt_work:/var/lib/letsencrypt
volumes:
certificates:
letsencrypt:
letsencrypt_work:
certificates — тот же том, который HAProxy подключает только на чтение; пишет
в него именно этот контейнер.
docker cli
docker run -d \
-e CERTBOT_DOMAIN=example.org,www.example.org \
-v certificates:/etc/certificates \
-v letsencrypt:/etc/letsencrypt \
-v letsencrypt_work:/var/lib/letsencrypt \
registry.bitdeals.org/certbot
Цикл перевыпуска — это CMD образа, а точка входа базового образа (certbot)
сброшена, поэтому всё, что указано после имени образа, заменяет цикл целиком:
разовая команда над тем же состоянием не требует --entrypoint:
docker run --rm \
-v letsencrypt:/etc/letsencrypt \
-v letsencrypt_work:/var/lib/letsencrypt \
registry.bitdeals.org/certbot certbot certificates
Сборка и публикация
Push в main собирает и публикует образ (.gitea/workflows/build.yaml) с тремя
тегами: <версия>.<sha7> — для развёртывания, <версия> — для чтения и
latest — для compose и Watchtower. Еженедельный cron пересобирает образ из тех
же исходников. Вручную, если под рукой учётные данные реестра:
docker build . --file docker/Dockerfile --tag registry.bitdeals.org/certbot
docker push registry.bitdeals.org/certbot
Контекст сборки — корень репозитория, а не docker/: Dockerfile копирует
./docker/scripts/, поэтому при контексте ./docker этот каталог не виден и
сборка падает на COPY.
Параметры
Образы контейнера настраиваются параметрами, передаваемыми при запуске.
| Параметр | Назначение |
|---|---|
| -e CERTBOT_DOMAIN | Домен, на который выпускается сертификат, либо несколько через запятую (a.org,b.org) — это собственный синтаксис -d у certbot: получается один сертификат, несущий все имена в Subject Alternative Names. По первому домену назван каталог сертификата под /etc/letsencrypt/live. По умолчанию: пусто — сертификат не запрашивается и сайт молча остаётся с самоподписанной заглушкой |
| -e CERTBOT_EMAIL | Адрес для уведомлений Let's Encrypt об истечении срока. По умолчанию: пусто, регистрация идёт с --register-unsafely-without-email, и предупреждений не будет — см. «Замечания» |
| -v /etc/certificates | Общий с HAProxy. Содержит site.pem — склеенные сертификат и приватный ключ, которые загружает HAProxy |
| -v /etc/letsencrypt | Каталог конфигурации certbot: ключ учётной записи ACME, выпущенные сертификаты и настройки перевыпуска. Его потеря означает повторную регистрацию и повторный выпуск |
| -v /var/lib/letsencrypt | Рабочий каталог certbot. Базовый образ объявляет его как VOLUME, поэтому без именованного тома при каждом создании контейнера возникает новый анонимный |
| -p 380 | Порт ACME-проверки HTTP-01. Внутренний: на него проксирует HAProxy. Публиковать его на хост не нужно и не следует |
Замечания
- Без адреса электронной почты предупреждений об истечении не будет. Пустой
CERTBOT_EMAILозначает регистрацию с--register-unsafely-without-email: если перевыпуск начнёт падать, вы узнаете об этом только когда сертификат истечёт. Следите за сертификатом внешними средствами или задайте переменную. - Первый сертификат самоподписанный, и браузеры об этом скажут. HAProxy не
стартует без
site.pem, поэтому1-ensure-cert.shпрежде всего пишет заглушку. Она заменяется, как только выпущен настоящий сертификат, — но если выпуск не удался, сайт продолжает отдавать именно заглушку, и сообщение об этом есть только в журнале контейнера. - Приватный ключ попадает к HAProxy через unix-сокет, а не по сети.
3-update-haproxy-cert.shпередаёт весьsite.pemв runtime API HAProxy — канал уровняadminбез аутентификации, — поэтому том сadmin.sockдолжен быть разделён с haproxy и больше ни с кем. TCP-порт был бы доступен любому контейнеру в общей сети, а правил по портам у docker-сетей нет. См. замечание о runtime API в README haproxy. - Отказ в установке сообщается, а не проглатывается. Runtime API сообщает об
отказе текстом ответа и всё равно закрывает соединение штатно, поэтому код
возврата socat ни о чём не говорит;
3-update-haproxy-cert.shвместо этого разбирает ответы наset ssl certиcommit ssl certи останавливается на первом, который не является подтверждением. Исправить он ничего не может:site.pemна томе в любом случае верен, поэтому отказ означает, что работающий HAProxy остаётся на прежнем сертификате до перезапуска. Об этом он и пишет. - Установка выполняется на каждом проходе, а не при перевыпуске. Скрипт
склеивает и переустанавливает сертификат независимо от того, сделал ли
certbot certonlyхоть что-нибудь, — дважды в сутки. Вреда нет, но это значит, что путь «сертификат обновлён» задействуется постоянно и настоящий перевыпуск ничем не отличается от любого другого прохода. Для этого у certbot есть--deploy-hook. site.pemвсегда заменяется атомарным переименованием — и на пути заглушки, и на пути перевыпуска. HAProxy читает этот файл при старте, а обычное перенаправление вывода в него оставляет промежуток, в котором на томе лежит обрезанный PEM, — то есть сертификат, с которым HAProxy откажется стартовать.- Остановка во время выпуска может не уложиться в отведённое docker время.
POSIX-оболочка выполняет обработчик сигнала только после возврата команды
переднего плана, поэтому SIGTERM, пришедший, пока
certbot certonlyобщается с Let's Encrypt, придерживается до конца этого вызова — дольше, чем отведённыеdocker stopпо умолчанию 10 секунд, после которых контейнер убивают посреди выпуска. Ничего при этом не портится (состояние в/etc/letsencryptпереживает, следующий проход доделает), но если чистая остановка важна — задайте сервисуstop_grace_period: 60s. Сон между проходами прерываемый и реагирует за миллисекунды. - Скрипты подключаются через
., а не запускаются отдельным процессом, поэтому текущий каталог,set -eи всякая оставленная переменная наследуются следующим скриптом. Именно поэтому они обращаются к файлам по абсолютным путям, аcdиumaskдержат внутри подоболочек; новые пишите по тому же правилу. - Контейнер работает от root, как и базовый образ, — ему нужно писать в
/etc/letsencrypt. Прав здесь никто потом не понижает. - Базовый образ не зафиксирован.
FROM certbot/certbot:latestвместе с еженедельной пересборкой по cron означает, что новый выпуск certbot попадает в реестр, а через Watchtower и в продуктив, без того чтобы кто-либо запускал сборку. Для воспроизводимых сборок фиксируйте версию тегом. CERTBOT_DOMAINперечитывается на каждом проходе, и сертификат следует за ним. Проход выполняетcertbot certonly --cert-name <первое-имя> --keep-until-expiring -d "$CERTBOT_DOMAIN", поэтому добавленное имя попадает в сертификат в течение 12 часов и выпускать его вручную не нужно. В обратную сторону это работает так же, и вот это — острый край: убранное имя приводит к перевыпуску без него, и на следующем проходе сайт под этим именем остаётся без TLS. Смена первого имени — третий случай: по нему назван каталог под/etc/letsencrypt/live, поэтому рядом со старым сертификатом заводится новый, а старый перестаёт продлеваться.- У Let's Encrypt есть ограничения частоты. Повторяющиеся неудачные попытки
выпуска на один домен в них засчитываются; проверяйте изменения на
--server https://acme-staging-v02.api.letsencrypt.org/directory, прежде чем оставлять цикл повторять их каждые 12 часов.