fix: the certificate follows CERTBOT_DOMAIN, on every pass
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
This commit is contained in:
@@ -11,16 +11,15 @@ This repository covers the docker deployment only.
|
|||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
The container is not a one-shot command. Its default command is a loop that runs
|
The container is not a one-shot command. Its default command is a loop that runs
|
||||||
the renewal script, sleeps 12 hours, and repeats — so it obtains the certificate
|
a pass, sleeps 12 hours, and repeats — so it obtains the certificate on its
|
||||||
on its first start and keeps it fresh from then on.
|
first start and keeps it fresh from then on.
|
||||||
|
|
||||||
Each pass does four things, one script apiece:
|
Each pass does three things, one script apiece:
|
||||||
|
|
||||||
|Script|What it does|
|
|Script|What it does|
|
||||||
|:--|:--|
|
|:--|:--|
|
||||||
|`entrypoint.sh`|The loop itself, and PID 1 of the container. Runs a pass, sleeps 12 hours, repeats; a failed pass is reported and retried rather than ending the loop|
|
|`entrypoint.sh`|The loop itself, and PID 1 of the container. Runs a pass, sleeps 12 hours, repeats; a failed pass is reported and retried rather than ending the loop|
|
||||||
|`1-renew-cert.sh`|The start of a pass. Renews an existing certificate, or hands over to `0-create-cert.sh` when there is none yet|
|
|`1-ensure-cert.sh`|The pass itself. On the first run writes a self-signed placeholder so HAProxy can bind 443; then waits for HAProxy and asks certbot for a certificate carrying exactly the names in `CERTBOT_DOMAIN` — issuing it, reissuing it when that list changed, or leaving it alone|
|
||||||
|`0-create-cert.sh`|First run: writes a self-signed placeholder so HAProxy can bind 443, waits for HAProxy, then requests the real certificate|
|
|
||||||
|`2-concatenate-cert.sh`|Joins `fullchain.pem` and `privkey.pem` into the single `site.pem` HAProxy expects|
|
|`2-concatenate-cert.sh`|Joins `fullchain.pem` and `privkey.pem` into the single `site.pem` HAProxy expects|
|
||||||
|`3-update-haproxy-cert.sh`|Installs `site.pem` into the *running* HAProxy over its runtime API — no restart, no dropped connections|
|
|`3-update-haproxy-cert.sh`|Installs `site.pem` into the *running* HAProxy over its runtime API — no restart, no dropped connections|
|
||||||
|
|
||||||
@@ -117,7 +116,7 @@ Container images are configured using parameters passed at runtime.
|
|||||||
starts failing, nothing tells you until the certificate expires. Monitor the
|
starts failing, nothing tells you until the certificate expires. Monitor the
|
||||||
certificate externally, or set the variable.
|
certificate externally, or set the variable.
|
||||||
- **The first certificate is self-signed, and browsers will say so.** HAProxy
|
- **The first certificate is self-signed, and browsers will say so.** HAProxy
|
||||||
cannot start without `site.pem`, so `0-create-cert.sh` writes a placeholder
|
cannot start without `site.pem`, so `1-ensure-cert.sh` writes a placeholder
|
||||||
before doing anything else. It is replaced as soon as the real certificate is
|
before doing anything else. It is replaced as soon as the real certificate is
|
||||||
issued — but if issuance fails, the placeholder is what the site keeps
|
issued — but if issuance fails, the placeholder is what the site keeps
|
||||||
serving, with no error anywhere but the container log.
|
serving, with no error anywhere but the container log.
|
||||||
@@ -136,9 +135,10 @@ Container images are configured using parameters passed at runtime.
|
|||||||
on the volume is correct either way, so a refusal means the *running* HAProxy
|
on the volume is correct either way, so a refusal means the *running* HAProxy
|
||||||
is still on the previous certificate until it restarts. The message says so.
|
is still on the previous certificate until it restarts. The message says so.
|
||||||
- **Renewal is pushed on every pass, not on renewal.** The script concatenates
|
- **Renewal is pushed on every pass, not on renewal.** The script concatenates
|
||||||
and re-installs whether or not `certbot renew` actually did anything, twice a
|
and re-installs whether or not `certbot certonly` actually did anything,
|
||||||
day. Harmless, but it means the "certificate updated" path is exercised
|
twice a day. Harmless, but it means the "certificate updated" path is
|
||||||
constantly and a genuine renewal looks like every other pass. Certbot's own
|
exercised constantly and a genuine renewal looks like every other pass.
|
||||||
|
Certbot's own
|
||||||
`--deploy-hook` is the mechanism built for this.
|
`--deploy-hook` is the mechanism built for this.
|
||||||
- **`site.pem` is always replaced by an atomic rename**, on both the placeholder
|
- **`site.pem` is always replaced by an atomic rename**, on both the placeholder
|
||||||
and the renewal path. HAProxy reads that file at start-up, and a plain
|
and the renewal path. HAProxy reads that file at start-up, and a plain
|
||||||
@@ -162,11 +162,14 @@ Container images are configured using parameters passed at runtime.
|
|||||||
by cron, means a new certbot release reaches the registry — and through
|
by cron, means a new certbot release reaches the registry — and through
|
||||||
Watchtower, production — without anyone triggering a build. Pin a version tag
|
Watchtower, production — without anyone triggering a build. Pin a version tag
|
||||||
for reproducible builds.
|
for reproducible builds.
|
||||||
- **Adding a domain to `CERTBOT_DOMAIN` does not reissue by itself.** The loop
|
- **`CERTBOT_DOMAIN` is read on every pass, and the certificate follows it.**
|
||||||
calls `certbot renew`, which takes the names from the certificate it already
|
The pass runs `certbot certonly --cert-name <first-name> --keep-until-expiring
|
||||||
holds and never reads `CERTBOT_DOMAIN`. On a machine that has one, issue the
|
-d "$CERTBOT_DOMAIN"`, so an added name is on the certificate within 12 hours
|
||||||
new set once by hand and the loop keeps it from then on:
|
and no manual issuance is needed. It follows the other way too, which is the
|
||||||
`certbot certonly --standalone -n --agree-tos --http-01-port=380 --cert-name <first-domain> --expand -d <the,new,list>`.
|
sharp edge: **removing** a name reissues without it, and the site loses TLS
|
||||||
|
under that name on the next pass. Changing the *first* name is a third case —
|
||||||
|
it names the lineage under `/etc/letsencrypt/live`, so a new certificate is
|
||||||
|
started beside the old one and the old one stops being renewed.
|
||||||
- **Let's Encrypt enforces rate limits.** Repeated failed issuance against the
|
- **Let's Encrypt enforces rate limits.** Repeated failed issuance against the
|
||||||
same domain counts against them; test changes against
|
same domain counts against them; test changes against
|
||||||
`--server https://acme-staging-v02.api.letsencrypt.org/directory` before
|
`--server https://acme-staging-v02.api.letsencrypt.org/directory` before
|
||||||
|
|||||||
+18
-15
@@ -11,16 +11,15 @@ Certbot, работающий в docker-контейнере циклом пер
|
|||||||
# Использование
|
# Использование
|
||||||
|
|
||||||
Контейнер — не разовая команда. Его команда по умолчанию представляет собой
|
Контейнер — не разовая команда. Его команда по умолчанию представляет собой
|
||||||
цикл: запустить скрипт перевыпуска, поспать 12 часов и повторить, — поэтому
|
цикл: выполнить проход, поспать 12 часов и повторить, — поэтому сертификат он
|
||||||
сертификат он получает при первом старте и дальше поддерживает свежим.
|
получает при первом старте и дальше поддерживает свежим.
|
||||||
|
|
||||||
Каждый проход делает четыре вещи, по скрипту на каждую:
|
Каждый проход делает три вещи, по скрипту на каждую:
|
||||||
|
|
||||||
|Скрипт|Что делает|
|
|Скрипт|Что делает|
|
||||||
|:--|:--|
|
|:--|:--|
|
||||||
|`entrypoint.sh`|Сам цикл и PID 1 контейнера. Выполняет проход, спит 12 часов, повторяет; неудачный проход не обрывает цикл, а сообщается и повторяется|
|
|`entrypoint.sh`|Сам цикл и PID 1 контейнера. Выполняет проход, спит 12 часов, повторяет; неудачный проход не обрывает цикл, а сообщается и повторяется|
|
||||||
|`1-renew-cert.sh`|Начало прохода. Перевыпускает существующий сертификат либо передаёт управление `0-create-cert.sh`, если сертификата ещё нет|
|
|`1-ensure-cert.sh`|Сам проход. При первом запуске пишет самоподписанную заглушку, чтобы HAProxy смог занять 443; затем дожидается HAProxy и просит у certbot сертификат ровно на имена из `CERTBOT_DOMAIN` — выпуская его, перевыпуская при изменившемся списке имён либо не трогая вовсе|
|
||||||
|`0-create-cert.sh`|Первый запуск: пишет самоподписанную заглушку, чтобы HAProxy смог занять 443, дожидается HAProxy и запрашивает настоящий сертификат|
|
|
||||||
|`2-concatenate-cert.sh`|Склеивает `fullchain.pem` и `privkey.pem` в единый `site.pem`, которого ждёт HAProxy|
|
|`2-concatenate-cert.sh`|Склеивает `fullchain.pem` и `privkey.pem` в единый `site.pem`, которого ждёт HAProxy|
|
||||||
|`3-update-haproxy-cert.sh`|Устанавливает `site.pem` в *работающий* HAProxy через его runtime API — без перезапуска и без разрыва соединений|
|
|`3-update-haproxy-cert.sh`|Устанавливает `site.pem` в *работающий* HAProxy через его runtime API — без перезапуска и без разрыва соединений|
|
||||||
|
|
||||||
@@ -117,7 +116,7 @@ docker push registry.bitdeals.org/certbot
|
|||||||
если перевыпуск начнёт падать, вы узнаете об этом только когда сертификат
|
если перевыпуск начнёт падать, вы узнаете об этом только когда сертификат
|
||||||
истечёт. Следите за сертификатом внешними средствами или задайте переменную.
|
истечёт. Следите за сертификатом внешними средствами или задайте переменную.
|
||||||
- **Первый сертификат самоподписанный, и браузеры об этом скажут.** HAProxy не
|
- **Первый сертификат самоподписанный, и браузеры об этом скажут.** HAProxy не
|
||||||
стартует без `site.pem`, поэтому `0-create-cert.sh` прежде всего пишет
|
стартует без `site.pem`, поэтому `1-ensure-cert.sh` прежде всего пишет
|
||||||
заглушку. Она заменяется, как только выпущен настоящий сертификат, — но если
|
заглушку. Она заменяется, как только выпущен настоящий сертификат, — но если
|
||||||
выпуск не удался, сайт продолжает отдавать именно заглушку, и сообщение об
|
выпуск не удался, сайт продолжает отдавать именно заглушку, и сообщение об
|
||||||
этом есть только в журнале контейнера.
|
этом есть только в журнале контейнера.
|
||||||
@@ -137,9 +136,10 @@ docker push registry.bitdeals.org/certbot
|
|||||||
он и пишет.
|
он и пишет.
|
||||||
- **Установка выполняется на каждом проходе, а не при перевыпуске.** Скрипт
|
- **Установка выполняется на каждом проходе, а не при перевыпуске.** Скрипт
|
||||||
склеивает и переустанавливает сертификат независимо от того, сделал ли
|
склеивает и переустанавливает сертификат независимо от того, сделал ли
|
||||||
`certbot renew` хоть что-нибудь, — дважды в сутки. Вреда нет, но это значит,
|
`certbot certonly` хоть что-нибудь, — дважды в сутки. Вреда нет, но это
|
||||||
что путь «сертификат обновлён» задействуется постоянно и настоящий перевыпуск
|
значит, что путь «сертификат обновлён» задействуется постоянно и настоящий
|
||||||
ничем не отличается от любого другого прохода. Для этого у certbot есть
|
перевыпуск ничем не отличается от любого другого прохода. Для этого у certbot
|
||||||
|
есть
|
||||||
`--deploy-hook`.
|
`--deploy-hook`.
|
||||||
- **`site.pem` всегда заменяется атомарным переименованием** — и на пути
|
- **`site.pem` всегда заменяется атомарным переименованием** — и на пути
|
||||||
заглушки, и на пути перевыпуска. HAProxy читает этот файл при старте, а
|
заглушки, и на пути перевыпуска. HAProxy читает этот файл при старте, а
|
||||||
@@ -166,12 +166,15 @@ docker push registry.bitdeals.org/certbot
|
|||||||
еженедельной пересборкой по cron означает, что новый выпуск certbot попадает в
|
еженедельной пересборкой по cron означает, что новый выпуск certbot попадает в
|
||||||
реестр, а через Watchtower и в продуктив, без того чтобы кто-либо запускал
|
реестр, а через Watchtower и в продуктив, без того чтобы кто-либо запускал
|
||||||
сборку. Для воспроизводимых сборок фиксируйте версию тегом.
|
сборку. Для воспроизводимых сборок фиксируйте версию тегом.
|
||||||
- **Добавленный в `CERTBOT_DOMAIN` домен сам по себе не приводит к
|
- **`CERTBOT_DOMAIN` перечитывается на каждом проходе, и сертификат следует за
|
||||||
перевыпуску.** Цикл вызывает `certbot renew`, а тот берёт имена из уже
|
ним.** Проход выполняет `certbot certonly --cert-name <первое-имя>
|
||||||
выданного сертификата и в `CERTBOT_DOMAIN` не заглядывает. Если сертификат на
|
--keep-until-expiring -d "$CERTBOT_DOMAIN"`, поэтому добавленное имя попадает
|
||||||
машине уже есть, выпустите новый набор имён один раз вручную — дальше цикл
|
в сертификат в течение 12 часов и выпускать его вручную не нужно. В обратную
|
||||||
будет его поддерживать:
|
сторону это работает так же, и вот это — острый край: **убранное** имя
|
||||||
`certbot certonly --standalone -n --agree-tos --http-01-port=380 --cert-name <первый-домен> --expand -d <новый,список>`.
|
приводит к перевыпуску без него, и на следующем проходе сайт под этим именем
|
||||||
|
остаётся без TLS. Смена *первого* имени — третий случай: по нему назван
|
||||||
|
каталог под `/etc/letsencrypt/live`, поэтому рядом со старым сертификатом
|
||||||
|
заводится новый, а старый перестаёт продлеваться.
|
||||||
- **У Let's Encrypt есть ограничения частоты.** Повторяющиеся неудачные попытки
|
- **У Let's Encrypt есть ограничения частоты.** Повторяющиеся неудачные попытки
|
||||||
выпуска на один домен в них засчитываются; проверяйте изменения на
|
выпуска на один домен в них засчитываются; проверяйте изменения на
|
||||||
`--server https://acme-staging-v02.api.letsencrypt.org/directory`, прежде чем
|
`--server https://acme-staging-v02.api.letsencrypt.org/directory`, прежде чем
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
|
# One pass of the loop: make the certificate on the volume the one CERTBOT_DOMAIN
|
||||||
|
# asks for, then hand it to the running HAProxy. Every pass, not just the first.
|
||||||
|
#
|
||||||
|
# `certbot certonly` rather than `certbot renew`, and that is the whole reason
|
||||||
|
# this script is the pass: renew takes the names from the certificate it already
|
||||||
|
# holds and never reads CERTBOT_DOMAIN, so an edit to the variable stayed
|
||||||
|
# invisible until somebody issued the new set by hand. certonly is told the names
|
||||||
|
# on every pass, so the certificate follows the variable in both directions.
|
||||||
|
#
|
||||||
|
# --keep-until-expiring is what makes calling it twice a day free: with the same
|
||||||
|
# names and no expiry due, certbot answers "Certificate not yet due for renewal;
|
||||||
|
# no action taken" without opening a connection, so nothing is spent against
|
||||||
|
# Let's Encrypt's rate limits.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
if [ ! -f /etc/certificates/site.pem ]; then
|
if [ ! -f /etc/certificates/site.pem ]; then
|
||||||
@@ -47,11 +61,15 @@ if [ -n "$CERTBOT_DOMAIN" ]; then
|
|||||||
set -- --register-unsafely-without-email
|
set -- --register-unsafely-without-email
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Request certificate
|
# Request certificate. --cert-name pins the lineage to the first name, so a
|
||||||
|
# changed list updates that certificate instead of starting a second one
|
||||||
|
# beside it; with the lineage named, certbot needs no --expand to accept an
|
||||||
|
# added name non-interactively.
|
||||||
certbot certonly --standalone \
|
certbot certonly --standalone \
|
||||||
--non-interactive --agree-tos --http-01-port=380 \
|
--non-interactive --agree-tos --http-01-port=380 \
|
||||||
"$@" \
|
"$@" \
|
||||||
--cert-name "${CERTBOT_DOMAIN%%,*}" \
|
--cert-name "${CERTBOT_DOMAIN%%,*}" \
|
||||||
|
--keep-until-expiring \
|
||||||
-d "$CERTBOT_DOMAIN"
|
-d "$CERTBOT_DOMAIN"
|
||||||
|
|
||||||
# Concatenate certificates
|
# Concatenate certificates
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# Certificates exist
|
|
||||||
if [ -n "$CERTBOT_DOMAIN" -a -d /etc/letsencrypt/live/"${CERTBOT_DOMAIN%%,*}" ]; then
|
|
||||||
# Check certificates and renew them
|
|
||||||
certbot renew --http-01-port=380
|
|
||||||
|
|
||||||
# Concatenate certificates
|
|
||||||
. $(dirname $0)/2-concatenate-cert.sh
|
|
||||||
|
|
||||||
# Update certificates in HAProxy
|
|
||||||
. $(dirname $0)/3-update-haproxy-cert.sh
|
|
||||||
|
|
||||||
# Certificates don't exist
|
|
||||||
else
|
|
||||||
# Execute certificate creation script
|
|
||||||
. $(dirname $0)/0-create-cert.sh
|
|
||||||
fi
|
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
# substitution runs in a subshell that reports the parent's jobs in bash but
|
# substitution runs in a subshell that reports the parent's jobs in bash but
|
||||||
# not in dash, and bare `wait` waits for every background job in either.
|
# not in dash, and bare `wait` waits for every background job in either.
|
||||||
#
|
#
|
||||||
# One thing this cannot fix: a signal arriving while 1-renew-cert.sh is in the
|
# One thing this cannot fix: a signal arriving while 1-ensure-cert.sh is in the
|
||||||
# foreground is deferred until that script returns, and an issuance talking to
|
# foreground is deferred until that script returns, and an issuance talking to
|
||||||
# Let's Encrypt can outlast docker's 10-second stop grace. Give the service a
|
# Let's Encrypt can outlast docker's 10-second stop grace. Give the service a
|
||||||
# longer `stop_grace_period` if that matters.
|
# longer `stop_grace_period` if that matters.
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
trap 'exit 0' TERM INT
|
trap 'exit 0' TERM INT
|
||||||
|
|
||||||
while :; do
|
while :; do
|
||||||
/opt/scripts/1-renew-cert.sh \
|
/opt/scripts/1-ensure-cert.sh \
|
||||||
|| echo "entrypoint: renewal pass failed, retrying in 12h" >&2
|
|| echo "entrypoint: renewal pass failed, retrying in 12h" >&2
|
||||||
sleep 12h &
|
sleep 12h &
|
||||||
wait
|
wait
|
||||||
|
|||||||
Reference in New Issue
Block a user