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:
+231
@@ -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:<версия>`.
|
||||
Reference in New Issue
Block a user