Files
certbot/README.ru-RU.md
T
bitdeals 0801b0d376
Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 1m4s
feat: put the log in the volume, so the container can be read-only
/var/log/letsencrypt was the only thing this image wrote outside its volumes,
and it is what stopped `read_only: true` from working. --logs-dir moves it to
/etc/letsencrypt/logs, beside the account key and the renewal config it
already keeps there.

The second gain is the one that matters more here. This is the service whose
breakage does not announce itself: a renewal that stops working is an expired
certificate sixty days later, and the log is what says why. On the root
filesystem it died with every container the registry replaced; in the volume
it outlives them.

Nothing else about the container needs to change to be confined. It never
changes user, the files it touches are its own, the ACME challenge is served
on 380 rather than a privileged port, and the HAProxy runtime socket it writes
to is group-owned by root -- so it reaches it by permission rather than by
CAP_DAC_OVERRIDE, and `cap_drop: ALL` takes nothing away. Measured on the live
relay: no file under /etc/letsencrypt or /etc/certificates is owned by anyone
but root, and the socket is srw-rw---- 1001:0.

Both READMEs say what a caller should set now, stop_grace_period included --
the entrypoint has asked for that one since it was written.
2026-09-10 11:49:04 +00:00

192 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Общие сведения
> 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-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
```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,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
```sh
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`:
```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`) с тремя
тегами: `<версия>.<sha7>` — для развёртывания, `<версия>` — для чтения и
`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|Домен, на который выпускается сертификат, либо несколько через запятую (`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](https://git.bitdeals.org/private/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 часов.
- **Ни одна capability здесь не нужна, а после `--logs-dir` не остаётся и
записи за пределы томов.** Контейнер не меняет пользователя, трогает только
свои файлы, а ACME-челлендж отдаёт на порту 380, а не на привилегированном,
поэтому `cap_drop: ALL` ему ничего не стоит. Исключением был журнал:
`/var/log/letsencrypt` на корневой файловой системе. Теперь он пишется в том
`letsencrypt`, рядом с остальным состоянием, и именно это делает возможным
`read_only: true` и сохраняет журнал неудавшегося продления после замены
контейнера. Задайте сервису и `stop_grace_period`: сигнал, пришедший во время
выпуска, откладывается до конца прохода, а выпуск умеет тянуться дольше
десяти секунд, которые даёт docker.