Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 37s
An empty key switches the pseudonym off: no X-Client-Id is sent, and a rate limit downstream falls back to one bucket shared by every visitor. That is the one state nobody chooses on purpose and the easiest to reach by forgetting a line in a .env — so the entrypoint now fills the key in with `openssl rand -base64 32` when nothing else did. Nobody picks this value, nothing outside the container needs to know it, and no two deployments need the same one, which is what makes generating it the right default rather than a convenience. A fresh key per container start costs a reset of the downstream rate-limit buckets — invisible against a one-minute window — and makes pseudonyms from before and after unlinkable, which is the property the key exists for rather than a loss. Passing one explicitly still wins, for whoever wants pseudonyms stable across restarts or identical on two proxies. base64 and not hex, deliberately: HAProxy's hmac() decodes the key as base64, and hex would be accepted and silently decoded into something else — a usable key, but it would quietly cost the guarantee that a malformed one stops the container at configuration parsing. The base image's entrypoint is the haproxy binary with no shell in between, so the wrapper is the whole chain and execs the same binary with the same arguments. CMD is restated rather than inherited. Verified by building the image and checking the config in all three states: unset (wrapper reports it generated one, config parses), set and valid (wrapper silent, config parses), set and not base64 (`[ALERT] invalid args in converter 'hmac' : failed to parse key`, container refuses to start). READMEs updated in both languages, and "address" is spelled "IP address" throughout — it was never anything else.
242 lines
22 KiB
Markdown
242 lines
22 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
|
||
```
|
||
|
||
## Сборка и публикация
|
||
|
||
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:<версия>`.
|