feat: generate XFF_HMAC_KEY when none is passed
Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 37s
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.
This commit is contained in:
@@ -19,9 +19,10 @@ 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.
|
||||
There is one environment variable, `XFF_HMAC_KEY`, and it needs no setting: the
|
||||
entrypoint generates one when it is absent. 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
|
||||
@@ -101,7 +102,7 @@ Container images are configured using parameters passed at runtime.
|
||||
|-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|
|
||||
|-e XFF_HMAC_KEY|Optional, base64. The visitor's IP is replaced by an HMAC of it in `X-Client-Id` and never passed on. Leave it unset and the entrypoint generates one per container start; pass one only to keep pseudonyms stable across restarts or identical on two proxies — see Notes|
|
||||
|
||||
Routing, timeouts and TLS settings are not parameters: they live in
|
||||
`docker/haproxy.cfg` and ship inside the image.
|
||||
@@ -166,10 +167,10 @@ Routing, timeouts and TLS settings are not parameters: they live in
|
||||
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.**
|
||||
- **There are two loggers, and forgetting the second one leaks IP 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
|
||||
would put every visitor's IP 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
|
||||
@@ -179,32 +180,40 @@ Routing, timeouts and TLS settings are not parameters: they live in
|
||||
- **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`:
|
||||
- **The visitor's IP 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.
|
||||
behind this proxy can log an IP it was never given. `X-Client-Id` carries a
|
||||
pseudonym instead — HMAC-SHA256 of the IP address under `XFF_HMAC_KEY`. Being
|
||||
one-to-one with the IP 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 IP address is brute-forced in seconds.
|
||||
- **`XFF_HMAC_KEY` is generated when absent, not left empty.** An empty key
|
||||
disables the feature — no `X-Client-Id` at all, and a rate limit downstream
|
||||
falls back to one bucket shared by every visitor, which is the one state
|
||||
nobody chooses on purpose and the easiest to reach by forgetting a line in a
|
||||
`.env`. So the entrypoint fills it 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.
|
||||
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. Pass a value explicitly only to keep pseudonyms stable across
|
||||
restarts, or identical on two proxies.
|
||||
The config still handles an empty key, because `haproxy.cfg` can be run
|
||||
outside this image. And a value that is not valid base64 still stops the
|
||||
container at configuration parsing — it cannot degrade quietly, which is also
|
||||
why the generated one is base64 and not hex: hex would be accepted here and
|
||||
silently decoded as base64 into something else.
|
||||
- **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
|
||||
on the socket IP address — nginx's `$binary_remote_addr`, ДС's
|
||||
`request.client.host` — sees this proxy's IP 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 IP; `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
|
||||
|
||||
Reference in New Issue
Block a user