docs: add README in English and Russian

Same structure as the bitmessage and bitcoind repositories: intro, usage with
compose and cli examples, a parameter table, and notes carrying the traps —
why the certificate must exist before start-up, why the runtime API is a unix
socket, why there are two loggers, and why HSTS is one day rather than a year.
This commit is contained in:
bitdeals
2026-08-07 13:38:54 +00:00
parent ab75b21cb4
commit a66944077d
2 changed files with 459 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
# Общие сведения
> English version: [README.md](README.md)
[HAProxy](https://www.haproxy.org/) — балансировщик и обратный прокси для TCP и HTTP. Здесь он служит публичным краем сайта BitDeals: терминирует TLS на 443, передаёт всё остальное веб-контейнеру и направляет ACME-проверки в certbot.
HAProxy, работающий в docker-контейнере с конфигурацией, вшитой в образ.
Репозиторий описывает только развёртывание в docker. Образ — `bitnami/haproxy`, в который скопирован один файл.
# Использование
У контейнера два порта, **80** и **443**, и оба — публичный сайт.
Третий канал портом не является: runtime API HAProxy слушает unix-сокет
`/var/lib/haproxy/admin.sock` на томе, разделяемом с контейнером
[certbot](https://git.bitdeals.org/private/certbot), — тот через него
устанавливает обновлённый сертификат в работающий процесс без перезапуска. API
имеет уровень `admin` и не защищён аутентификацией, поэтому кто может его
открыть, определяют права на файл, — см. «Замечания».
Переменная окружения одна — `XFF_HMAC_KEY`, и она необязательна. Всё остальное
задано в `docker/haproxy.cfg`, который копируется в образ при сборке, поэтому
изменение маршрутизации означает пересборку и повторное развёртывание.
Сертификат читается из `/usr/local/etc/haproxy/certificates/site.pem`, том
подключён **только на чтение** и разделяется с certbot. Файл обязан
существовать до старта контейнера — см. «Замечания».
## docker-compose
```yaml
services:
haproxy:
build:
context: https://git.bitdeals.org/private/haproxy.git
dockerfile: ./docker/Dockerfile
image: registry.bitdeals.org/haproxy
restart: unless-stopped
depends_on:
- nginx
- certbot
volumes:
- certificates:/usr/local/etc/haproxy/certificates:ro
- haproxy_admin:/var/lib/haproxy # сокет runtime API — только для certbot
ports:
- "80:80"
- "443:443"
volumes:
certificates:
haproxy_admin:
```
Оба бэкенда названы по сервисам, к которым обращаются: `nginx:80` — сайт,
`certbot:380` — ACME-проверки. Эти имена должны разрешаться внутри
compose-проекта, то есть названные сервисы обязаны быть с этим в одной сети.
## docker cli
```sh
docker run -d \
-p 80:80 \
-p 443:443 \
-v certificates:/usr/local/etc/haproxy/certificates:ro \
registry.bitdeals.org/haproxy
```
Всё, что указано после имени образа, заменяет собственные аргументы демона,
поэтому проверка подключённого файла конфигурации не требует нового образа:
```sh
docker run --rm -v "$PWD/docker/haproxy.cfg:/tmp/haproxy.cfg:ro" \
registry.bitdeals.org/haproxy -c -f /tmp/haproxy.cfg
```
## Сборка и публикация
Push в `main` собирает и публикует образ (`.gitea/workflows/build.yaml`) с тремя
тегами: `<версия>.<sha7>` — для развёртывания, `<версия>` — для чтения и
`latest` — для compose и Watchtower. Ночной cron пересобирает образ из тех же
исходников. Вручную, если под рукой учётные данные реестра:
```sh
docker build . --file docker/Dockerfile --tag registry.bitdeals.org/haproxy
docker push registry.bitdeals.org/haproxy
```
**Контекст сборки — корень репозитория**, а не `docker/`: Dockerfile копирует
`./docker/haproxy.cfg`, поэтому при контексте `./docker` этот файл не виден и
сборка падает на `COPY`.
# Параметры
Образы контейнера настраиваются параметрами, передаваемыми при запуске.
|Параметр|Назначение|
|:--------|:-------|
|-p 80|Обычный HTTP. Перенаправляет на HTTPS кодом 301, кроме пути ACME-проверки: он обязан оставаться доступным здесь, иначе перевыпуск не проходит|
|-p 443|HTTPS. Требует наличия `site.pem` в томе сертификатов до старта контейнера|
|-v /usr/local/etc/haproxy/certificates|Каталог сертификатов, только на чтение. Читается лишь `site.pem` и лишь при связывании портов. certbot пишет его через тот же том, подключённый на запись как `/etc/certificates`|
|-v /var/lib/haproxy|Сокет runtime API (`admin.sock`, уровень `admin`, **без аутентификации**). Подключайте этот том к certbot и больше никуда — см. «Замечания»|
|-e XFF_HMAC_KEY|Необязательная, base64. Задана — адрес посетителя заменяется на HMAC от него в `X-Client-Id` и дальше не передаётся; пуста — такой заголовок не отправляется. Создать: `openssl rand -base64 32`, см. «Замечания»|
Маршрутизация, тайм-ауты и настройки TLS параметрами не являются: они находятся
в `docker/haproxy.cfg` и поставляются внутри образа.
# Замечания
- **`site.pem` обязан существовать до старта контейнера.** `bind ... ssl crt`
разбирается при чтении конфигурации, поэтому пустой том — это фатальная
ошибка запуска, а не предупреждение: HAProxy завершается и без политики
перезапуска больше не поднимается. certbot при первом старте создаёт
самоподписанную заглушку именно чтобы разорвать этот круг, — поэтому сервис
поставляется с `restart: unless-stopped`; в проекте, где certbot определён,
добавьте ещё и `depends_on`.
- **Сертификат, установленный через runtime API, живёт только в памяти.** Именно
поэтому том здесь подключён только на чтение: `set ssl cert` и
`commit ssl cert` на диск ничего не пишут. Файл на томе — копия certbot, и
именно её HAProxy перечитывает после перезапуска, так что оба пути согласуются
без права записи у HAProxy.
- **Runtime API — полноценный административный канал без пароля.** Любой, кто
способен его открыть, может установить другой сертификат с другим приватным
ключом, перенаправить бэкенд на иной адрес или вывести серверы из
обслуживания — то есть незаметно устроить сайту «человека посередине».
Считайте доступ к `admin.sock` равноценным владению приватным ключом TLS и
подключайте этот том к certbot и больше никуда. `expose-fd listeners`, которая
вдобавок отдала бы клиенту сокета сами слушающие сокеты, намеренно **не**
задана: она нужна для бесшовной перезагрузки, которой этот образ не выполняет.
- **Unix-сокет — потому что порт ограничить нельзя.** `expose:` наружу ничего не
публикует, но и не ограничивает, а правил по портам у docker-сетей нет: значит
runtime API на TCP открыт любому контейнеру в общей сети, включая nginx, — ведь
HAProxy обязан дозваниваться до *него*. Сокет на томе доступен только тем
контейнерам, которые этот том подключают, и это исчерпывающее описание
разграничения доступа. Заодно приватный ключ, идущий по этому каналу при
каждом перевыпуске, больше не покидает пределы тома.
- **HAProxy нужны права на запись в каталог сокета, а не только на сам файл.**
Он связывается с сокетом, создавая `<путь>.<pid>.tmp` и переименовывая его
поверх целевого, — поэтому образ создаёт `/var/lib/haproxy` с владельцем 1001,
а docker переносит это владение на пустой именованный том, подключённый сюда.
Переименование заодно объясняет, почему оставшийся от прошлого запуска сокет
безвреден. certbot подключается от root, и `mode 660` его не касается.
- **У редиректа на HTTPS есть одно исключение, и оно несущее.** Порт 80
отвечает 301 на всё, кроме `/.well-known/acme-challenge/`, — этот путь
Let's Encrypt проверяет по обычному HTTP, и его перенаправление останавливает
любой перевыпуск. Правило записано выше `use_backend`, потому что в этом
порядке оно и выполняется: правила `http-request` вычисляются до выбора
бэкенда независимо от порядка в файле, и HAProxy предупреждает, когда одно
расходится с другим.
- **HSTS выставлен на сутки, а не на привычный год.** Это дверь в одну сторону:
браузер, увидевший заголовок, отказывается ходить по обычному HTTP на этот
хост до истечения срока, и отменить это с сервера нельзя. Сутки оставляют
просроченный сертификат исправимым. Поднимать ступенями — 86400, 2592000,
31536000, — когда перевыпуск устоится. `includeSubDomains` и `preload`
намеренно отсутствуют: первый связывает имена, которых этот прокси не
обслуживает, второй практически необратим.
- **Адреса бэкендов перечитываются, и по умолчанию это не так.** Обе строки
`server` несут `resolvers docker`, поэтому имена `nginx` и `certbot`
разрешаются заново по ходу работы. Без этого имя разрешается один раз при
загрузке и держится всё время жизни процесса, а пересозданный с новым IP
контейнер — то, что Watchtower делает при каждом развёртывании, — остаётся
незамеченным. `init-addr libc,none` — вторая половина: она позволяет HAProxy
стартовать, когда бэкенд ещё не поднят, вместо отказа разобрать неразрешимое
имя.
- **Журнал идёт в stdout, а `option dontlog-normal` оставляет в нём только
ошибки.** `log stdout format raw local0` не требует syslog-демона — вывод
забирает `docker logs`. Успешный запрос не пишется ничем; 503, бэкенд без
сервера, отклонённое рукопожатие — пишутся. Убирайте `dontlog-normal`
осознанно, если нужен полный журнал обращений: он же удерживает объём.
- **Логгеров два, и забытый второй сдаёт адреса.** `option httplog` здесь не
используется: его формат по умолчанию начинается с `%ci:%cp`, то есть адреса
посетителей попали бы в `docker logs` и свели бы на нет псевдоним, который
выставляют фронтенды. Собственный `log-format` ставит в это первое поле
псевдоним. Ловушка — `error-log-format`: он покрывает то, что происходит *до*
появления транзакции (отклонённое TLS-рукопожатие, а нижняя граница теперь
TLS 1.2), и его умолчание начинается так же. Здесь заданы оба. Поэтому
псевдоним вычисляется правилом `tcp-request connection` на приёме соединения и
в области `sess`: правило http-фазы к моменту провала рукопожатия ещё не
выполнялось бы.
- **В журнал идут только метод и путь, никогда не строка запроса.** `%{+Q}r`
унёс бы и её, и токен, однажды оказавшийся в URL, был бы записан на всё время
хранения журнала.
- **Адрес посетителя дальше не идёт.** `option forwardfor` не задан:
`X-Forwarded-For` в обоих фронтендах удаляется и никогда не заполняется,
поэтому ничто за этим прокси не может записать в журнал адрес, которого ему не
давали. Вместо адреса передаётся псевдоним в `X-Client-Id` — HMAC-SHA256 от
адреса на ключе `XFF_HMAC_KEY`. Он взаимно однозначен с адресом, то есть как
ключ ограничения частоты ничем не хуже, и без ключа необратим. Именно HMAC, а
не просто хеш: IPv4 — это 2³² значений, и хеш адреса без ключа перебирается за
секунды.
- **`XFF_HMAC_KEY` необязателен, и пустой ключ выключает функцию, а не
ослабляет её.** Не задан — `X-Client-Id` не отправляется вовсе, и ограничение
частоты ниже по цепочке вырождается в одну корзину на всех посетителей;
задан — у каждого посетителя своя. Чего не происходит никогда, так это
псевдонима, выведенного на пустом ключе. Значение, не являющееся корректным
base64, останавливает контейнер при разборе конфигурации — тихо испортиться
оно не может. Ротация ключа сбрасывает корзины (пользователь этого не видит) и
меняет все псевдонимы, поэтому активность до и после ротации связать нельзя.
- **Оба удаления безусловны.** `X-Forwarded-For` и `X-Client-Id` удаляются
независимо от того, задан ключ или нет, — чтобы присланный клиентом заголовок
ниже по цепочке нельзя было принять за выставленный этим прокси. То же с
`X-Forwarded-Proto`: каждый фронтенд выставляет собственную схему, а не
передаёт дальше клиентское утверждение.
- **Потребителя всё равно нужно научить этим пользоваться.** Ограничение
частоты, построенное на адресе сокета — `$binary_remote_addr` у nginx,
`request.client.host` у ДС, — видит этот прокси на каждом запросе и
вырождается в общую корзину. Ключом должен быть `X-Client-Id`, и доверять ему
следует только с адреса этого прокси; готовый пример —
`frontend/docker/rate-limit.conf` в репозитории bitdeals-ng.
- **TLS задан в `global`, а не отдан на усмотрение OpenSSL.** Нижняя граница —
TLS 1.2, список шифров только ECDHE и в вариантах ECDSA и RSA (certbot
выпускает ECDSA, а самоподписанная заглушка — RSA), билеты сессий выключены,
чтобы совершенная прямая секретность не сводилась на нет долгоживущим ключом
билета. `alpn h2,http/1.1` в строке `bind` предлагает браузерам HTTP/2;
бэкенд остаётся на HTTP/1.1, преобразованием занимается HAProxy. Заголовок
**HSTS не отправляется**, и это намеренно: он был бы преждевременным, пока
порт 80 отдаёт сайт вместо перенаправления, а отменить его после того, как
браузеры запомнили политику, трудно.
- **Фазу чтения заголовков ограничивает `timeout http-request 10s`**, и
`timeout client` его не заменяет: тот является таймаутом *бездействия* и
сбрасывается на каждом полученном байте, поэтому клиент, шлющий по байту,
держит соединение открытым сколько угодно. Этот — абсолютный.
- **Процесс работает под uid 1001 и всё же занимает порты 80 и 443.** Это
работает потому, что docker по умолчанию выставляет в контейнерах
`net.ipv4.ip_unprivileged_port_start=0`; хост или среда выполнения, вернувшие
традиционное значение, приведут к тому, что контейнер не сможет занять порты.
- **Базовый образ не зафиксирован.** `FROM bitnami/haproxy` означает `:latest`,
а ночной cron пересборки берёт то, на что этот тег указывает сейчас, — минорная
версия HAProxy может смениться в сборке, которую никто не запускал, после чего
Watchtower выкатит её. Для воспроизводимых сборок фиксируйте
`FROM bitnami/haproxy:<версия>`.