Files
certbot/README.ru-RU.md
T
bitdeals e8204bbb4d 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
2026-08-24 13:13:03 +00:00

182 lines
15 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 часов.