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:
@@ -0,0 +1,228 @@
|
|||||||
|
# Intro
|
||||||
|
|
||||||
|
> Русская версия: [README.ru-RU.md](README.ru-RU.md)
|
||||||
|
|
||||||
|
[HAProxy](https://www.haproxy.org/) is a TCP/HTTP load balancer and reverse proxy. Here it is the public edge of a BitDeals site: it terminates TLS on 443, forwards everything to the web container, and routes ACME challenges to certbot.
|
||||||
|
|
||||||
|
HAProxy running in a docker container with a baked-in configuration.
|
||||||
|
|
||||||
|
This repository covers the docker deployment only. The image is `bitnami/haproxy` with one file copied into it.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
The container has two ports, **80** and **443**, and both are the public site.
|
||||||
|
|
||||||
|
The third channel is not a port: the HAProxy runtime API listens on a unix
|
||||||
|
socket at `/var/lib/haproxy/admin.sock`, on a volume shared with the
|
||||||
|
[certbot](https://git.bitdeals.org/private/certbot) container, which uses it to
|
||||||
|
install a renewed certificate into the running process without a restart. The
|
||||||
|
API is `level admin` and has no authentication, so who can open it is decided by
|
||||||
|
file permissions — see Notes.
|
||||||
|
|
||||||
|
There is one environment variable, `XFF_HMAC_KEY`, and it is optional.
|
||||||
|
Everything else is in `docker/haproxy.cfg`, which is copied into the image at
|
||||||
|
build time, so changing the routing means rebuilding and redeploying.
|
||||||
|
|
||||||
|
The certificate is read from `/usr/local/etc/haproxy/certificates/site.pem`,
|
||||||
|
mounted **read-only** from a volume shared with certbot. It must exist before
|
||||||
|
the container starts — see Notes.
|
||||||
|
|
||||||
|
## 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 socket — certbot only
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
certificates:
|
||||||
|
haproxy_admin:
|
||||||
|
```
|
||||||
|
|
||||||
|
The two backends are named after the services they reach: `nginx:80` for the
|
||||||
|
site and `certbot:380` for ACME challenges. Both names have to resolve inside
|
||||||
|
the compose project, so those services must share a network with this one.
|
||||||
|
|
||||||
|
## docker cli
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d \
|
||||||
|
-p 80:80 \
|
||||||
|
-p 443:443 \
|
||||||
|
-v certificates:/usr/local/etc/haproxy/certificates:ro \
|
||||||
|
registry.bitdeals.org/haproxy
|
||||||
|
```
|
||||||
|
|
||||||
|
Anything after the image name replaces the daemon's own arguments, so a config
|
||||||
|
check against a mounted file needs no new image:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --rm -v "$PWD/docker/haproxy.cfg:/tmp/haproxy.cfg:ro" \
|
||||||
|
registry.bitdeals.org/haproxy -c -f /tmp/haproxy.cfg
|
||||||
|
```
|
||||||
|
|
||||||
|
## build and publish
|
||||||
|
|
||||||
|
A push to `main` builds and publishes the image
|
||||||
|
(`.gitea/workflows/build.yaml`), tagging it three ways: `<version>.<sha7>` to
|
||||||
|
deploy by, `<version>` to read, and `latest` for compose and Watchtower. A
|
||||||
|
nightly cron rebuilds from the same sources. By hand, when the registry
|
||||||
|
credentials are at hand:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build . --file docker/Dockerfile --tag registry.bitdeals.org/haproxy
|
||||||
|
docker push registry.bitdeals.org/haproxy
|
||||||
|
```
|
||||||
|
|
||||||
|
**The build context is the repository root**, not `docker/`: the Dockerfile
|
||||||
|
copies `./docker/haproxy.cfg`, so a context of `./docker` cannot see it and the
|
||||||
|
build fails on the `COPY`.
|
||||||
|
|
||||||
|
# Parameters
|
||||||
|
|
||||||
|
Container images are configured using parameters passed at runtime.
|
||||||
|
|
||||||
|
|Parameter|Function|
|
||||||
|
|:--------|:-------|
|
||||||
|
|-p 80|Plain HTTP. Redirects to HTTPS with a 301, except the ACME challenge path, which must stay reachable here for renewals to work|
|
||||||
|
|-p 443|HTTPS. Needs `site.pem` in the certificates volume before the container starts|
|
||||||
|
|-v /usr/local/etc/haproxy/certificates|Certificate directory, read-only. Only `site.pem` is read, at bind time. certbot writes it through the same volume mounted read-write at `/etc/certificates`|
|
||||||
|
|-v /var/lib/haproxy|Runtime API socket (`admin.sock`, `level admin`, **no authentication**). Mount it into certbot and nothing else — see Notes|
|
||||||
|
|-e XFF_HMAC_KEY|Optional, base64. Set, the visitor's address is replaced by an HMAC of it in `X-Client-Id` and never passed on; empty, no such header is sent. Generate with `openssl rand -base64 32` — see Notes|
|
||||||
|
|
||||||
|
Routing, timeouts and TLS settings are not parameters: they live in
|
||||||
|
`docker/haproxy.cfg` and ship inside the image.
|
||||||
|
|
||||||
|
# Notes
|
||||||
|
|
||||||
|
- **`site.pem` must exist before the container starts.** `bind ... ssl crt` is
|
||||||
|
resolved while the configuration is parsed, so an empty volume is a fatal
|
||||||
|
start-up error, not a warning — HAProxy exits, and without a restart policy it
|
||||||
|
stays down. certbot writes a self-signed placeholder on its own first start
|
||||||
|
precisely to break this circle, which is why the service ships with
|
||||||
|
`restart: unless-stopped`; order it after certbot with `depends_on` in a
|
||||||
|
project that defines one.
|
||||||
|
- **A certificate installed over the runtime API lives in memory only.** That is
|
||||||
|
why the volume is mounted read-only here: `set ssl cert` + `commit ssl cert`
|
||||||
|
never write to disk. The file on the volume is certbot's copy, and it is what
|
||||||
|
HAProxy re-reads after a restart — so the two paths agree without HAProxy
|
||||||
|
needing write access.
|
||||||
|
- **The runtime API is a full administrative channel with no password.** Anyone
|
||||||
|
who can open it can install a different certificate and private key, redirect
|
||||||
|
a backend to another address, or take servers out of rotation — that is,
|
||||||
|
silently man-in-the-middle the site. Treat access to `admin.sock` as
|
||||||
|
equivalent to holding the TLS private key, and mount that volume into certbot
|
||||||
|
and nothing else. `expose-fd listeners`, which would additionally hand a
|
||||||
|
client of the socket the listening sockets themselves, is deliberately **not**
|
||||||
|
set: it exists for seamless reloads, which this image never performs.
|
||||||
|
- **A unix socket, because a port cannot be restricted.** `expose:` publishes
|
||||||
|
nothing to the host but restricts nothing either, and docker networks have no
|
||||||
|
per-port rules — so a TCP runtime API is open to every container sharing a
|
||||||
|
network, which here includes nginx, since HAProxy must be able to call *it*.
|
||||||
|
A socket on a volume is reachable only by containers that mount the volume,
|
||||||
|
and that is the whole access-control story. It also keeps the private key,
|
||||||
|
which crosses this channel on every renewal, off the network.
|
||||||
|
- **HAProxy needs write access to the socket's directory, not just the file.**
|
||||||
|
It binds by creating `<path>.<pid>.tmp` and renaming it over the target — so
|
||||||
|
the image creates `/var/lib/haproxy` owned by uid 1001, and docker carries
|
||||||
|
that ownership onto an empty named volume mounted there. The rename is also
|
||||||
|
why a stale socket left by a previous run is harmless. certbot connects as
|
||||||
|
root and is unaffected by the `mode 660`.
|
||||||
|
- **The redirect to HTTPS carries one exception, and it is load-bearing.**
|
||||||
|
Port 80 answers 301 for everything except `/.well-known/acme-challenge/`,
|
||||||
|
which Let's Encrypt validates over plain HTTP — redirect that and every
|
||||||
|
renewal stops. The rule is written above `use_backend` because that is the
|
||||||
|
order it runs in: `http-request` rules are evaluated before backend selection
|
||||||
|
whatever the file says, and HAProxy warns when the two disagree.
|
||||||
|
- **HSTS is one day, not the customary year.** It is a one-way door: a browser
|
||||||
|
that has seen the header refuses plain HTTP to this host until it expires, and
|
||||||
|
nothing server-side can call that back. A day keeps a lapsed certificate
|
||||||
|
recoverable. Raise it in steps — 86400, 2592000, 31536000 — once renewals have
|
||||||
|
been seen to work. `includeSubDomains` and `preload` are deliberately absent:
|
||||||
|
the first binds names this proxy does not serve, the second is effectively
|
||||||
|
permanent.
|
||||||
|
- **Backend addresses are re-resolved, and that is not the default.** Both
|
||||||
|
`server` lines carry `resolvers docker`, so the `nginx` and `certbot` names
|
||||||
|
are looked up again while HAProxy runs. Without it a name is resolved once at
|
||||||
|
boot and kept for the life of the process, and a container recreated on a new
|
||||||
|
IP — which is what Watchtower does on every deploy — is never noticed.
|
||||||
|
`init-addr libc,none` is the other half: it lets HAProxy start when a backend
|
||||||
|
is not up yet, instead of refusing to parse a name it cannot resolve.
|
||||||
|
- **Logging goes to stdout, and `option dontlog-normal` makes it errors-only.**
|
||||||
|
`log stdout format raw local0` needs no syslog daemon — `docker logs` collects
|
||||||
|
it. A successful request writes nothing; a 503, a backend with no server, a
|
||||||
|
refused handshake do. Drop `dontlog-normal` deliberately if a full access log
|
||||||
|
is wanted, and understand that it is also what keeps the volume down.
|
||||||
|
- **There are two loggers, and forgetting the second one leaks addresses.**
|
||||||
|
`option httplog` is never used: its default format opens with `%ci:%cp`, which
|
||||||
|
would put every visitor's address into `docker logs` and undo the pseudonym the
|
||||||
|
frontends mint. A hand-written `log-format` puts the pseudonym in that first
|
||||||
|
field instead. The trap is `error-log-format`, which covers what happens
|
||||||
|
*before* a transaction exists — a refused TLS handshake, and TLS 1.2 is now
|
||||||
|
the floor — and whose default opens the same way. Both are set here. The
|
||||||
|
pseudonym is therefore computed by a `tcp-request connection` rule on accept,
|
||||||
|
in `sess` scope, because an http-phase rule would not have run yet when a
|
||||||
|
handshake fails.
|
||||||
|
- **Only the method and path are logged, never the query string.** `%{+Q}r`
|
||||||
|
would carry it, and a token that ever appeared in a URL would be written down
|
||||||
|
for as long as the log is kept.
|
||||||
|
- **The visitor's address stops here.** There is no `option forwardfor`:
|
||||||
|
`X-Forwarded-For` is deleted in both frontends and never filled in, so nothing
|
||||||
|
behind this proxy can log an address it was never given. `X-Client-Id` carries
|
||||||
|
a pseudonym instead — HMAC-SHA256 of the address under `XFF_HMAC_KEY`. Being
|
||||||
|
one-to-one with the address it is exactly as good a rate-limiting key, and
|
||||||
|
without the key it is not reversible. HMAC rather than a bare digest because
|
||||||
|
IPv4 is 2^32 values and an unkeyed hash of an address is brute-forced in
|
||||||
|
seconds.
|
||||||
|
- **`XFF_HMAC_KEY` is optional, and an empty one disables the feature rather
|
||||||
|
than weakening it.** Unset, no `X-Client-Id` is sent at all and a rate limit
|
||||||
|
downstream falls back to one bucket shared by every visitor; set, each visitor
|
||||||
|
gets their own. What never happens is a pseudonym derived from an empty key.
|
||||||
|
A value that is not valid base64 stops the container at configuration
|
||||||
|
parsing — it cannot degrade quietly. Rotating the key resets rate-limit
|
||||||
|
buckets (invisible to users) and changes every pseudonym, so activity either
|
||||||
|
side of a rotation cannot be linked.
|
||||||
|
- **Both deletes are unconditional.** `X-Forwarded-For` and `X-Client-Id` are
|
||||||
|
dropped whether or not a key is configured, so a header a client sent can
|
||||||
|
never be mistaken downstream for one this proxy minted. Same for
|
||||||
|
`X-Forwarded-Proto`, which each frontend sets to its own scheme rather than
|
||||||
|
passing on the client's claim.
|
||||||
|
- **The consumer must still be told to use it.** A downstream rate limit keyed
|
||||||
|
on the socket address — nginx's `$binary_remote_addr`, ДС's
|
||||||
|
`request.client.host` — sees this proxy for every request and degenerates to
|
||||||
|
one shared bucket. It has to key on `X-Client-Id`, and trust that header only
|
||||||
|
from this proxy's address; `frontend/docker/rate-limit.conf` in the bitdeals-ng
|
||||||
|
repository is the worked example.
|
||||||
|
- **TLS is pinned in `global`, not left to OpenSSL.** TLS 1.2 is the floor,
|
||||||
|
the cipher list is ECDHE-only in both ECDSA and RSA variants — certbot issues
|
||||||
|
ECDSA, the self-signed placeholder is RSA — and session tickets are off so
|
||||||
|
forward secrecy is not undone by a long-lived ticket key. `alpn h2,http/1.1`
|
||||||
|
on the bind offers HTTP/2 to browsers; the backend stays HTTP/1.1 and HAProxy
|
||||||
|
translates. **No HSTS header is sent**, deliberately: it would be premature
|
||||||
|
while port 80 still serves the site rather than redirecting, and it is hard to
|
||||||
|
take back once browsers have cached the policy.
|
||||||
|
- **`timeout http-request 10s` is what bounds the header phase**, and
|
||||||
|
`timeout client` cannot stand in for it: that one is an *inactivity* timeout
|
||||||
|
and resets on every byte received, so a client dripping a byte at a time holds
|
||||||
|
a connection open indefinitely. This one is absolute.
|
||||||
|
- **The process runs as uid 1001 and still binds 80 and 443.** That works
|
||||||
|
because Docker sets `net.ipv4.ip_unprivileged_port_start=0` in containers by
|
||||||
|
default; a host or runtime that restores the traditional value will make the
|
||||||
|
container fail to bind.
|
||||||
|
- **The base image is unpinned.** `FROM bitnami/haproxy` means `:latest`, and
|
||||||
|
the nightly rebuild cron picks up whatever that tag points at — a HAProxy
|
||||||
|
minor version can change under a build nobody triggered, and Watchtower then
|
||||||
|
rolls it out. Pin `FROM bitnami/haproxy:<version>` for reproducible builds.
|
||||||
+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