feat: harden the edge and move the runtime API off TCP

The runtime API was an unauthenticated `level admin` channel on TCP 9999.
`expose:` restricts nothing and docker networks have no per-port rules, so it
was reachable by every container sharing a network — nginx included — and
reaching it means installing your own certificate and key. It is now a unix
socket on a volume shared with certbot alone, which also keeps the private key
off the network on every renewal. `expose-fd listeners` is dropped: it hands
the listening sockets themselves to a client of that socket and only serves
seamless reloads, which this image never performs.

HAProxy binds a unix socket by creating `<path>.<pid>.tmp` and renaming it, so
it needs write access to the directory; the image now creates /var/lib/haproxy
owned by uid 1001 and docker carries that onto an empty named volume. Without
it HAProxy refuses to start.

Also in this commit:

- The visitor's address no longer leaves the process. `option forwardfor` is
  gone, X-Forwarded-For is deleted unconditionally, and X-Client-Id carries an
  HMAC of the address under the optional XFF_HMAC_KEY instead — one-to-one with
  the address, so a rate limit keyed on it is as precise, but not reversible.
  An empty key sends no header rather than one derived from an empty key.
- Logging, which was absent entirely. To stdout for `docker logs`, errors-only
  via the existing dontlog-normal. Both log-format and error-log-format are
  hand-written: the built-in formats open with %ci:%cp and would have logged
  the addresses the rest of this works to avoid. The pseudonym is computed by a
  tcp-request connection rule so a refused handshake has one too.
- TLS pinned: floor TLS 1.2, ECDHE-only in ECDSA and RSA variants, no session
  tickets, ALPN offering HTTP/2.
- HTTP redirects to HTTPS, excepting the ACME challenge path, plus HSTS at one
  day — short deliberately, since the header cannot be recalled once sent.
- timeout http-request, which `timeout client` cannot stand in for: that one
  resets on every byte, so a slow-drip client held a connection indefinitely.
- Backends re-resolve through the declared `resolvers docker`, which nothing
  referenced. Names were resolved once at boot, so a container recreated on a
  new IP was never noticed. init-addr libc,none lets HAProxy start anyway.
- The commented-out /ms deny rules are deleted. They would have blocked
  /ms/info and /ms/api/v2/whoami, both deliberately public.
- compose: build context is the repository root, as the Dockerfile's COPY
  expects and as CI already did; restart policy added, since an empty
  certificate volume is a fatal start-up error; the volumes section, which was
  missing, so the file can actually come up.

Verified on testnet2 against the real binary: config parses clean with the key
set, empty, and rejects a non-base64 key at parse time; a live stack confirms
the pseudonym is stable per address, differs between addresses, survives a
forged header, and that the client address appears nowhere in the log.
This commit is contained in:
bitdeals
2026-08-07 13:38:40 +00:00
parent f9dca2deff
commit ab75b21cb4
3 changed files with 166 additions and 19 deletions
+17 -4
View File
@@ -1,14 +1,27 @@
services:
haproxy:
build:
context: ./docker
dockerfile: Dockerfile
# The repository root, not ./docker: the Dockerfile copies
# ./docker/haproxy.cfg, which a context of ./docker cannot see.
context: .
dockerfile: ./docker/Dockerfile
image: registry.bitdeals.org/haproxy
# `bind ... ssl crt` is resolved while the configuration is parsed, so an
# empty certificates volume is a fatal start-up error rather than a warning.
# certbot writes a self-signed placeholder on its own first start; until it
# has, HAProxy needs to keep retrying. Order it after certbot too — in a
# project that defines one, add: depends_on: [nginx, certbot]
restart: unless-stopped
volumes:
- certificates:/usr/local/etc/haproxy/certificates:ro
# The runtime API socket. Share this volume with certbot and with nothing
# else: reaching the socket is equivalent to holding the TLS private key.
- haproxy_admin:/var/lib/haproxy
ports:
- "80:80"
- "443:443"
expose:
- "9999"
volumes:
certificates:
haproxy_admin: