diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff7c410 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# Intro + +> Русская версия: [README.ru-RU.md](README.ru-RU.md) + +[Certbot](https://certbot.eff.org/) is the EFF client for [Let's Encrypt](https://letsencrypt.org/): it obtains and renews free TLS certificates. + +Certbot running in a docker container as a renewal loop, paired with the [haproxy](https://git.bitdeals.org/private/haproxy) container it keeps supplied with a certificate. + +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. + +Each pass does four 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| +|`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| + +Validation is **HTTP-01 on port 380**. Certbot's own standalone server listens +there inside the container, and HAProxy forwards `/.well-known/acme-challenge/` +to it from the public port 80. Nothing else may reach 380. + +Let's Encrypt requires the domain's public A/AAAA DNS records to point at this +machine, and port 80 to be reachable from the internet. + +## docker-compose + +```yaml +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 + - CERTBOT_EMAIL=admin@example.org # optional, for expiry notices + volumes: + - certificates:/etc/certificates # shared with haproxy + - letsencrypt:/etc/letsencrypt # account key, certificates, renewal config + - letsencrypt_work:/var/lib/letsencrypt + +volumes: + certificates: + letsencrypt: + letsencrypt_work: +``` + +`certificates` is the same volume HAProxy mounts read-only; this container is +the one that writes it. + +## docker cli + +```sh +docker run -d \ + -e CERTBOT_DOMAIN=example.org \ + -v certificates:/etc/certificates \ + -v letsencrypt:/etc/letsencrypt \ + -v letsencrypt_work:/var/lib/letsencrypt \ + registry.bitdeals.org/certbot +``` + +The renewal loop is the image's `CMD`, and the base image's `certbot` entrypoint +is reset, so anything after the image name replaces the loop outright — a one-off +command against the same state needs no `--entrypoint`: + +```sh +docker run --rm \ + -v letsencrypt:/etc/letsencrypt \ + -v letsencrypt_work:/var/lib/letsencrypt \ + registry.bitdeals.org/certbot certbot certificates +``` + +## build and publish + +A push to `main` builds and publishes the image +(`.gitea/workflows/build.yaml`), tagging it three ways: `.` to +deploy by, `` to read, and `latest` for compose and Watchtower. A +weekly cron rebuilds from the same sources. By hand, when the registry +credentials are at hand: + +```sh +docker build . --file docker/Dockerfile --tag registry.bitdeals.org/certbot +docker push registry.bitdeals.org/certbot +``` + +**The build context is the repository root**, not `docker/`: the Dockerfile +copies `./docker/scripts/`, so a context of `./docker` cannot see it and the +build fails on the `COPY`. + +# Parameters + +Container images are configured using parameters passed at runtime. + +|Parameter|Function| +|:--------|:-------| +|-e CERTBOT_DOMAIN|The domain to certify. Default: empty — no certificate is requested and the site keeps the self-signed placeholder, silently. One domain only; the scripts pass a single `-d`| +|-e CERTBOT_EMAIL|Address for Let's Encrypt expiry notices. Default: empty, which registers with `--register-unsafely-without-email` and leaves you without warnings — see Notes| +|-v /etc/certificates|Shared with HAProxy. Holds `site.pem`: the concatenated certificate and private key HAProxy binds to| +|-v /etc/letsencrypt|Certbot's config directory: the ACME account key, the issued certificates and the renewal configuration. Losing it means re-registering and re-issuing| +|-v /var/lib/letsencrypt|Certbot's work directory. The base image declares it a `VOLUME`, so leaving it unnamed creates a fresh anonymous volume on every container creation| +|-p 380|ACME HTTP-01 challenge port. Internal: HAProxy proxies to it. Publishing it to the host is not needed and not wanted| + +# Notes + +- **Without an email address there are no expiry warnings.** An empty + `CERTBOT_EMAIL` registers with `--register-unsafely-without-email`: if renewal + 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 + 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. +- **The private key reaches HAProxy over a unix socket, not the network.** + `3-update-haproxy-cert.sh` pipes the whole of `site.pem` into HAProxy's + runtime API — an unauthenticated `level admin` channel — so the volume holding + `admin.sock` must be shared with haproxy and with nothing else. A TCP port + would have been reachable by every container on a shared network, and docker + networks have no per-port rules. See the runtime-API note in the + [haproxy README](https://git.bitdeals.org/private/haproxy). +- **A refused installation is reported, not silently passed over.** The runtime + API answers a refusal in the reply text and still closes cleanly, so socat's + exit status says nothing; `3-update-haproxy-cert.sh` matches the replies to + `set ssl cert` and `commit ssl cert` instead, and stops at the first one that + is not an acknowledgement. What it cannot do is repair anything — `site.pem` + 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 + `--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 + redirect into it leaves a window in which the file on the volume is a + truncated PEM — which is a certificate HAProxy refuses to start with. +- **A stop during issuance can outlast docker's grace period.** A POSIX shell + runs a trap only once the foreground command returns, so a SIGTERM arriving + while `certbot certonly` is talking to Let's Encrypt is held until that call + finishes — past the 10 seconds `docker stop` allows by default, after which + the container is killed mid-issuance. Nothing is corrupted (the state under + `/etc/letsencrypt` survives and the next pass finishes the job), but set + `stop_grace_period: 60s` on the service if a clean stop matters. The sleep + between passes is interruptible and reacts in milliseconds. +- **The scripts are sourced, not executed** (`.` rather than a subprocess), so + the working directory, `set -e` and any variable one of them leaves behind is + inherited by the next. That is why they address files absolutely and keep + `cd` and `umask` inside subshells; keep new ones to the same rule. +- **The container runs as root**, as the base image does — it needs to write + `/etc/letsencrypt`. Nothing here drops privileges afterwards. +- **The base image is unpinned.** `FROM certbot/certbot:latest`, rebuilt weekly + 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. +- **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 + letting a loop retry every 12 hours. diff --git a/README.ru-RU.md b/README.ru-RU.md new file mode 100644 index 0000000..0f49f7e --- /dev/null +++ b/README.ru-RU.md @@ -0,0 +1,172 @@ +# Общие сведения + +> English version: [README.md](README.md) + +[Certbot](https://certbot.eff.org/) — клиент [Let's Encrypt](https://letsencrypt.org/) от EFF: получает и перевыпускает бесплатные TLS-сертификаты. + +Certbot, работающий в docker-контейнере циклом перевыпуска, в паре с контейнером [haproxy](https://git.bitdeals.org/private/haproxy), который он снабжает сертификатом. + +Репозиторий описывает только развёртывание в docker. + +# Использование + +Контейнер — не разовая команда. Его команда по умолчанию представляет собой +цикл: запустить скрипт перевыпуска, поспать 12 часов и повторить, — поэтому +сертификат он получает при первом старте и дальше поддерживает свежим. + +Каждый проход делает четыре вещи, по скрипту на каждую: + +|Скрипт|Что делает| +|:--|:--| +|`entrypoint.sh`|Сам цикл и PID 1 контейнера. Выполняет проход, спит 12 часов, повторяет; неудачный проход не обрывает цикл, а сообщается и повторяется| +|`1-renew-cert.sh`|Начало прохода. Перевыпускает существующий сертификат либо передаёт управление `0-create-cert.sh`, если сертификата ещё нет| +|`0-create-cert.sh`|Первый запуск: пишет самоподписанную заглушку, чтобы HAProxy смог занять 443, дожидается HAProxy и запрашивает настоящий сертификат| +|`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 + +```yaml +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 + - 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 + +```sh +docker run -d \ + -e CERTBOT_DOMAIN=example.org \ + -v certificates:/etc/certificates \ + -v letsencrypt:/etc/letsencrypt \ + -v letsencrypt_work:/var/lib/letsencrypt \ + registry.bitdeals.org/certbot +``` + +Цикл перевыпуска — это `CMD` образа, а точка входа базового образа (`certbot`) +сброшена, поэтому всё, что указано после имени образа, заменяет цикл целиком: +разовая команда над тем же состоянием не требует `--entrypoint`: + +```sh +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`) с тремя +тегами: `<версия>.` — для развёртывания, `<версия>` — для чтения и +`latest` — для compose и Watchtower. Еженедельный cron пересобирает образ из тех +же исходников. Вручную, если под рукой учётные данные реестра: + +```sh +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|Домен, на который выпускается сертификат. По умолчанию: пусто — сертификат не запрашивается и сайт молча остаётся с самоподписанной заглушкой. Домен только один: скрипты передают единственный `-d`| +|-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`, поэтому `0-create-cert.sh` прежде всего пишет + заглушку. Она заменяется, как только выпущен настоящий сертификат, — но если + выпуск не удался, сайт продолжает отдавать именно заглушку, и сообщение об + этом есть только в журнале контейнера. +- **Приватный ключ попадает к HAProxy через unix-сокет, а не по сети.** + `3-update-haproxy-cert.sh` передаёт весь `site.pem` в runtime API HAProxy — + канал уровня `admin` без аутентификации, — поэтому том с `admin.sock` должен + быть разделён с haproxy и больше ни с кем. TCP-порт был бы доступен любому + контейнеру в общей сети, а правил по портам у docker-сетей нет. См. замечание + о runtime API в [README haproxy](https://git.bitdeals.org/private/haproxy). +- **Отказ в установке сообщается, а не проглатывается.** Runtime API сообщает об + отказе текстом ответа и всё равно закрывает соединение штатно, поэтому код + возврата socat ни о чём не говорит; `3-update-haproxy-cert.sh` вместо этого + разбирает ответы на `set ssl cert` и `commit ssl cert` и останавливается на + первом, который не является подтверждением. Исправить он ничего не может: + `site.pem` на томе в любом случае верен, поэтому отказ означает, что + *работающий* HAProxy остаётся на прежнем сертификате до перезапуска. Об этом + он и пишет. +- **Установка выполняется на каждом проходе, а не при перевыпуске.** Скрипт + склеивает и переустанавливает сертификат независимо от того, сделал ли + `certbot renew` хоть что-нибудь, — дважды в сутки. Вреда нет, но это значит, + что путь «сертификат обновлён» задействуется постоянно и настоящий перевыпуск + ничем не отличается от любого другого прохода. Для этого у 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 и в продуктив, без того чтобы кто-либо запускал + сборку. Для воспроизводимых сборок фиксируйте версию тегом. +- **У Let's Encrypt есть ограничения частоты.** Повторяющиеся неудачные попытки + выпуска на один домен в них засчитываются; проверяйте изменения на + `--server https://acme-staging-v02.api.letsencrypt.org/directory`, прежде чем + оставлять цикл повторять их каждые 12 часов.