diff --git a/README.md b/README.md index 9f053df..eb37542 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,15 @@ This repository covers the docker deployment only. # Usage 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 -on its first start and keeps it fresh from then on. +a pass, sleeps 12 hours, and repeats — so it obtains the certificate on its +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| |:--|:--| |`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| -|`0-create-cert.sh`|First run: writes a self-signed placeholder so HAProxy can bind 443, waits for HAProxy, then requests the real certificate| +|`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| |`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| @@ -117,7 +116,7 @@ Container images are configured using parameters passed at runtime. starts failing, nothing tells you until the certificate expires. Monitor the certificate externally, or set the variable. - **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 issued — but if issuance fails, the placeholder is what the site keeps 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 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 - and re-installs whether or not `certbot renew` actually did anything, twice a - day. Harmless, but it means the "certificate updated" path is exercised - constantly and a genuine renewal looks like every other pass. Certbot's own + and re-installs whether or not `certbot certonly` actually did anything, + twice a day. Harmless, but it means the "certificate updated" path is + exercised constantly and a genuine renewal looks like every other pass. + Certbot's own `--deploy-hook` is the mechanism built for this. - **`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 @@ -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 Watchtower, production — without anyone triggering a build. Pin a version tag for reproducible builds. -- **Adding a domain to `CERTBOT_DOMAIN` does not reissue by itself.** The loop - calls `certbot renew`, which takes the names from the certificate it already - holds and never reads `CERTBOT_DOMAIN`. On a machine that has one, issue the - new set once by hand and the loop keeps it from then on: - `certbot certonly --standalone -n --agree-tos --http-01-port=380 --cert-name --expand -d `. +- **`CERTBOT_DOMAIN` is read on every pass, and the certificate follows it.** + The pass runs `certbot certonly --cert-name --keep-until-expiring + -d "$CERTBOT_DOMAIN"`, so an added name is on the certificate within 12 hours + and no manual issuance is needed. It follows the other way too, which is the + 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 same domain counts against them; test changes against `--server https://acme-staging-v02.api.letsencrypt.org/directory` before diff --git a/README.ru-RU.md b/README.ru-RU.md index 145dd79..1568912 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -11,16 +11,15 @@ Certbot, работающий в docker-контейнере циклом пер # Использование Контейнер — не разовая команда. Его команда по умолчанию представляет собой -цикл: запустить скрипт перевыпуска, поспать 12 часов и повторить, — поэтому -сертификат он получает при первом старте и дальше поддерживает свежим. +цикл: выполнить проход, поспать 12 часов и повторить, — поэтому сертификат он +получает при первом старте и дальше поддерживает свежим. -Каждый проход делает четыре вещи, по скрипту на каждую: +Каждый проход делает три вещи, по скрипту на каждую: |Скрипт|Что делает| |:--|:--| |`entrypoint.sh`|Сам цикл и PID 1 контейнера. Выполняет проход, спит 12 часов, повторяет; неудачный проход не обрывает цикл, а сообщается и повторяется| -|`1-renew-cert.sh`|Начало прохода. Перевыпускает существующий сертификат либо передаёт управление `0-create-cert.sh`, если сертификата ещё нет| -|`0-create-cert.sh`|Первый запуск: пишет самоподписанную заглушку, чтобы HAProxy смог занять 443, дожидается HAProxy и запрашивает настоящий сертификат| +|`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 — без перезапуска и без разрыва соединений| @@ -117,7 +116,7 @@ docker push registry.bitdeals.org/certbot если перевыпуск начнёт падать, вы узнаете об этом только когда сертификат истечёт. Следите за сертификатом внешними средствами или задайте переменную. - **Первый сертификат самоподписанный, и браузеры об этом скажут.** 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 есть + `certbot certonly` хоть что-нибудь, — дважды в сутки. Вреда нет, но это + значит, что путь «сертификат обновлён» задействуется постоянно и настоящий + перевыпуск ничем не отличается от любого другого прохода. Для этого у certbot + есть `--deploy-hook`. - **`site.pem` всегда заменяется атомарным переименованием** — и на пути заглушки, и на пути перевыпуска. HAProxy читает этот файл при старте, а @@ -166,12 +166,15 @@ docker push registry.bitdeals.org/certbot еженедельной пересборкой по cron означает, что новый выпуск certbot попадает в реестр, а через Watchtower и в продуктив, без того чтобы кто-либо запускал сборку. Для воспроизводимых сборок фиксируйте версию тегом. -- **Добавленный в `CERTBOT_DOMAIN` домен сам по себе не приводит к - перевыпуску.** Цикл вызывает `certbot renew`, а тот берёт имена из уже - выданного сертификата и в `CERTBOT_DOMAIN` не заглядывает. Если сертификат на - машине уже есть, выпустите новый набор имён один раз вручную — дальше цикл - будет его поддерживать: - `certbot certonly --standalone -n --agree-tos --http-01-port=380 --cert-name <первый-домен> --expand -d <новый,список>`. +- **`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`, прежде чем diff --git a/docker/scripts/0-create-cert.sh b/docker/scripts/1-ensure-cert.sh similarity index 67% rename from docker/scripts/0-create-cert.sh rename to docker/scripts/1-ensure-cert.sh index 49986ee..9b67692 100644 --- a/docker/scripts/0-create-cert.sh +++ b/docker/scripts/1-ensure-cert.sh @@ -1,5 +1,19 @@ #!/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 if [ ! -f /etc/certificates/site.pem ]; then @@ -47,11 +61,15 @@ if [ -n "$CERTBOT_DOMAIN" ]; then set -- --register-unsafely-without-email 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 \ --non-interactive --agree-tos --http-01-port=380 \ "$@" \ --cert-name "${CERTBOT_DOMAIN%%,*}" \ + --keep-until-expiring \ -d "$CERTBOT_DOMAIN" # Concatenate certificates diff --git a/docker/scripts/1-renew-cert.sh b/docker/scripts/1-renew-cert.sh deleted file mode 100644 index ae0a1cc..0000000 --- a/docker/scripts/1-renew-cert.sh +++ /dev/null @@ -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 - diff --git a/docker/scripts/entrypoint.sh b/docker/scripts/entrypoint.sh index 27bc113..1ef3505 100644 --- a/docker/scripts/entrypoint.sh +++ b/docker/scripts/entrypoint.sh @@ -17,7 +17,7 @@ # 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. # -# 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 # Let's Encrypt can outlast docker's 10-second stop grace. Give the service a # longer `stop_grace_period` if that matters. @@ -25,7 +25,7 @@ trap 'exit 0' TERM INT while :; do - /opt/scripts/1-renew-cert.sh \ + /opt/scripts/1-ensure-cert.sh \ || echo "entrypoint: renewal pass failed, retrying in 12h" >&2 sleep 12h & wait