Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 24s
The Notes explain at length that no visitor's IP address reaches the log and why both loggers are hand-written to keep it that way. Nothing showed it. A reader had to take the claim on faith or read haproxy.cfg. Add a "the log" section to Usage in both READMEs: a real `docker compose logs haproxy` excerpt from a running node, the two log formats broken down field by field, and the point the sample exists to make — the first field is the X-Client-Id pseudonym, and it is all the log knows about who connected.
277 lines
24 KiB
Markdown
277 lines
24 KiB
Markdown
# Общие сведения
|
||
|
||
> 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
|
||
```
|
||
|
||
## Журнал
|
||
|
||
`docker compose logs haproxy` на работающем узле:
|
||
|
||
```text
|
||
haproxy-1 | XFF_HMAC_KEY was not set — generated one for this container.
|
||
haproxy-1 | [NOTICE] (1) : Automatically setting global.maxconn to 32745.
|
||
haproxy-1 | rlj13BvDLpJLzzHbutTiC5fjK0OR4YRlPrYnJIHpO7Y= [18/Aug/2026:10:25:25.196] http 1/1 Success
|
||
haproxy-1 | wgM6JB1uN2AGFKkl67+O7SCx+DHrGttufQw0HDQMSIU= [18/Aug/2026:10:45:10.700] https~ 1/1 SSL handshake failure
|
||
haproxy-1 | 5056er7Wogz59zFJfaNNelKpEnxzEISztzbgn/omnHg= [18/Aug/2026:10:54:38.337] https~ 1/1 Connection closed during SSL handshake
|
||
haproxy-1 | 6TPbf9FXhRxBoy+SSpFrOuTvYjZF3RjTQmdCteZWH3s= [19/Aug/2026:04:13:17.521] https~ default-backend-http/main 400 289 0/497 SSTP_DUPLEX_POST /sra_{BA195980-CD49-458b-9E23-C84EE0ADCD75}/
|
||
```
|
||
|
||
**Ни в одной строке нет IP-адреса, и появиться ему неоткуда.** Первое поле —
|
||
псевдоним из `X-Client-Id`, HMAC-SHA256 от адреса посетителя на ключе
|
||
`XFF_HMAC_KEY` в base64, — и это всё, что журнал знает о том, кто подключился.
|
||
Оба логгера выписаны вручную именно поэтому: их умолчания начинаются с
|
||
`%ci:%cp`, то есть с адреса и порта. См. «Замечания».
|
||
|
||
Четыре строки обращений — это два формата:
|
||
|
||
- уровень соединения, `%[var(sess.cid)] [%tr] %ft %ac/%fc %[fc_err_str]`:
|
||
псевдоним, время, фронтенд, счётчики соединений, чем соединение кончилось.
|
||
Сюда попадает всё, что умирает до появления запроса: клиент, не предлагающий
|
||
ничего выше TLS 1.1, сканер, обрывающий рукопожатие на середине, проба,
|
||
которая открыла порт и ушла.
|
||
- уровень транзакции, `… %ft %b/%s %ST %B %TR/%Ta %HM %HP`: псевдоним, время,
|
||
фронтенд, бэкенд/сервер, статус, байты, таймеры, метод, путь. Последняя строка
|
||
— 400 в ответ на случайную SSTP-пробу. Путь заканчивается там, где начинается
|
||
строка запроса: её в журнале нет никогда.
|
||
|
||
Успешные запросы не пишутся вовсе (`option dontlog-normal`), поэтому настолько
|
||
тихий журнал — нормальное состояние работающего узла, а не признак того, что
|
||
логгеры настроены неверно.
|
||
|
||
## Сборка и публикация
|
||
|
||
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. IP посетителя заменяется на HMAC от него в `X-Client-Id` и дальше не передаётся. Не задавай — точка входа сгенерирует ключ на каждый запуск контейнера; задавай, только если псевдонимы должны пережить перезапуск или совпадать на двух прокси, см. «Замечания»|
|
||
|
||
Маршрутизация, тайм-ауты и настройки 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`
|
||
осознанно, если нужен полный журнал обращений: он же удерживает объём.
|
||
- **Логгеров два, и забытый второй сдаёт IP-адреса.** `option httplog` здесь не
|
||
используется: его формат по умолчанию начинается с `%ci:%cp`, то есть
|
||
IP-адреса посетителей попали бы в `docker logs` и свели бы на нет псевдоним,
|
||
который выставляют фронтенды. Собственный `log-format` ставит в это первое поле
|
||
псевдоним. Ловушка — `error-log-format`: он покрывает то, что происходит *до*
|
||
появления транзакции (отклонённое TLS-рукопожатие, а нижняя граница теперь
|
||
TLS 1.2), и его умолчание начинается так же. Здесь заданы оба. Поэтому
|
||
псевдоним вычисляется правилом `tcp-request connection` на приёме соединения и
|
||
в области `sess`: правило http-фазы к моменту провала рукопожатия ещё не
|
||
выполнялось бы.
|
||
- **В журнал идут только метод и путь, никогда не строка запроса.** `%{+Q}r`
|
||
унёс бы и её, и токен, однажды оказавшийся в URL, был бы записан на всё время
|
||
хранения журнала.
|
||
- **IP-адрес посетителя дальше не идёт.** `option forwardfor` не задан:
|
||
`X-Forwarded-For` в обоих фронтендах удаляется и никогда не заполняется,
|
||
поэтому ничто за этим прокси не может записать в журнал IP, которого ему не
|
||
давали. Вместо IP-адреса передаётся псевдоним в `X-Client-Id` — HMAC-SHA256 от
|
||
IP-адреса на ключе `XFF_HMAC_KEY`. Он взаимно однозначен с IP, то есть как
|
||
ключ ограничения частоты ничем не хуже, и без ключа необратим. Именно HMAC, а
|
||
не просто хеш: IPv4 — это 2³² значений, и хеш IP-адреса без ключа перебирается
|
||
за секунды.
|
||
- **`XFF_HMAC_KEY` не оставляется пустым, а генерируется.** Пустой ключ
|
||
выключает функцию: `X-Client-Id` не отправляется вовсе, и ограничение частоты
|
||
ниже по цепочке вырождается в одну корзину на всех посетителей — состояние,
|
||
которого никто не выбирает нарочно и в которое проще всего попасть, забыв
|
||
строку в `.env`. Поэтому точка входа подставляет `openssl rand -base64 32`,
|
||
если ключа нет. Его значение никто не выбирает, снаружи контейнера оно никому
|
||
не нужно, и двум развёртываниям не требуется одинаковое.
|
||
Новый ключ на каждый запуск контейнера стоит сброса корзин ниже по цепочке —
|
||
на минутном окне это незаметно — и делает несвязываемыми псевдонимы до и
|
||
после, а это свойство, ради которого ключ и существует, а не потеря. Задавать
|
||
значение явно стоит лишь тогда, когда псевдонимы должны пережить перезапуск
|
||
или совпадать на двух прокси.
|
||
Конфигурация по-прежнему умеет работать с пустым ключом: `haproxy.cfg` можно
|
||
запустить и вне этого образа. Значение, не являющееся корректным base64,
|
||
по-прежнему останавливает контейнер при разборе — тихо испортиться оно не
|
||
может, и поэтому же генерируется base64, а не hex: hex здесь был бы принят и
|
||
молча раскодирован как base64 во что-то другое.
|
||
- **Оба удаления безусловны.** `X-Forwarded-For` и `X-Client-Id` удаляются
|
||
независимо от того, задан ключ или нет, — чтобы присланный клиентом заголовок
|
||
ниже по цепочке нельзя было принять за выставленный этим прокси. То же с
|
||
`X-Forwarded-Proto`: каждый фронтенд выставляет собственную схему, а не
|
||
передаёт дальше клиентское утверждение.
|
||
- **Потребителя всё равно нужно научить этим пользоваться.** Ограничение
|
||
частоты, построенное на IP-адресе сокета — `$binary_remote_addr` у nginx,
|
||
`request.client.host` у ДС, — видит IP этого прокси на каждом запросе и
|
||
вырождается в общую корзину. Ключом должен быть `X-Client-Id`, и доверять ему
|
||
следует только с IP этого прокси; готовый пример —
|
||
`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:<версия>`.
|