Files
haproxy/README.ru-RU.md
T
bitdeals a66944077d 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.
2026-08-07 13:38:54 +00:00

232 lines
20 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)
[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:<версия>`.