# Общие сведения > 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`) с тремя тегами: `<версия>.` — для развёртывания, `<версия>` — для чтения и `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 нужны права на запись в каталог сокета, а не только на сам файл.** Он связывается с сокетом, создавая `<путь>..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:<версия>`.