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:
+17
-4
@@ -1,14 +1,27 @@
|
|||||||
services:
|
services:
|
||||||
haproxy:
|
haproxy:
|
||||||
build:
|
build:
|
||||||
context: ./docker
|
# The repository root, not ./docker: the Dockerfile copies
|
||||||
dockerfile: Dockerfile
|
# ./docker/haproxy.cfg, which a context of ./docker cannot see.
|
||||||
|
context: .
|
||||||
|
dockerfile: ./docker/Dockerfile
|
||||||
image: registry.bitdeals.org/haproxy
|
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:
|
volumes:
|
||||||
- certificates:/usr/local/etc/haproxy/certificates:ro
|
- 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:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
expose:
|
|
||||||
- "9999"
|
volumes:
|
||||||
|
certificates:
|
||||||
|
haproxy_admin:
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,16 @@ FROM bitnami/haproxy
|
|||||||
# Copy config
|
# Copy config
|
||||||
COPY ./docker/haproxy.cfg /bitnami/haproxy/conf/haproxy.cfg
|
COPY ./docker/haproxy.cfg /bitnami/haproxy/conf/haproxy.cfg
|
||||||
|
|
||||||
|
# Directory for the runtime API socket. HAProxy runs as uid 1001 here and binds
|
||||||
|
# a unix socket by creating `<path>.<pid>.tmp` and renaming it over the target,
|
||||||
|
# so it needs write permission on the *directory*, not just the file — which is
|
||||||
|
# also why a stale socket left by a previous run is harmless.
|
||||||
|
#
|
||||||
|
# /var/lib is owned by root, hence the explicit USER switch. Docker copies this
|
||||||
|
# ownership onto an empty named volume when it initialises one here, so the
|
||||||
|
# volume shared with certbot comes up writable by HAProxy without a chown at
|
||||||
|
# runtime.
|
||||||
|
USER root
|
||||||
|
RUN mkdir -p /var/lib/haproxy && chown 1001:1001 /var/lib/haproxy
|
||||||
|
USER 1001
|
||||||
|
|
||||||
|
|||||||
+136
-15
@@ -1,6 +1,40 @@
|
|||||||
global
|
global
|
||||||
# Enable HAProxy runtime API
|
# The runtime API, through which certbot installs a renewed certificate.
|
||||||
stats socket :9999 level admin expose-fd listeners
|
#
|
||||||
|
# A unix socket on a volume shared with certbot alone, not a TCP port. This
|
||||||
|
# is an unauthenticated `level admin` channel — whoever reaches it can
|
||||||
|
# install their own certificate and private key, or point a backend
|
||||||
|
# somewhere else — and a TCP port is reachable by every container sharing a
|
||||||
|
# network, which here would include nginx. File permissions are the only
|
||||||
|
# access control available; a docker network has none.
|
||||||
|
#
|
||||||
|
# No expose-fd listeners: that would additionally hand a client of this
|
||||||
|
# socket the listening sockets themselves, and it is only needed for
|
||||||
|
# seamless reloads, which this image never performs.
|
||||||
|
stats socket /var/lib/haproxy/admin.sock mode 660 level admin
|
||||||
|
|
||||||
|
# To stdout, so `docker logs` collects it — a container has no syslog
|
||||||
|
# daemon, and without any `log` directive HAProxy emits nothing at all:
|
||||||
|
# no backend failures, no TLS handshake refusals, no 5xx.
|
||||||
|
log stdout format raw local0
|
||||||
|
|
||||||
|
# TLS policy for every `bind ... ssl` below. Left unset, the floor is
|
||||||
|
# whatever the image's OpenSSL happens to accept, which is not a decision
|
||||||
|
# this repository gets to make once and keep.
|
||||||
|
#
|
||||||
|
# ECDHE only, no DHE: every client that speaks TLS 1.2 has done ECDHE for
|
||||||
|
# over a decade, and admitting DHE would drag in the question of DH
|
||||||
|
# parameter size for no gain. Both -ECDSA- and -RSA- variants are listed
|
||||||
|
# because the certificate can be either — certbot issues ECDSA by default,
|
||||||
|
# while the self-signed placeholder it falls back to is RSA.
|
||||||
|
#
|
||||||
|
# no-tls-tickets: a resumption ticket is sealed with a key held for the life
|
||||||
|
# of the process, so a ticket captured today is readable by anyone who later
|
||||||
|
# obtains that key. Dropping tickets keeps forward secrecy whole, at the
|
||||||
|
# cost of a full handshake on resumption.
|
||||||
|
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
|
||||||
|
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305
|
||||||
|
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
|
||||||
|
|
||||||
defaults
|
defaults
|
||||||
mode http
|
mode http
|
||||||
@@ -9,43 +43,130 @@ defaults
|
|||||||
timeout connect 5s
|
timeout connect 5s
|
||||||
timeout client 1m
|
timeout client 1m
|
||||||
timeout server 1m
|
timeout server 1m
|
||||||
|
# An absolute cap on the header-reading phase. `timeout client` cannot
|
||||||
|
# replace it: that one is an inactivity timeout and resets on every byte, so
|
||||||
|
# a client dripping one byte at a time holds a connection open for as long
|
||||||
|
# as it likes.
|
||||||
|
timeout http-request 10s
|
||||||
|
|
||||||
|
log global
|
||||||
|
|
||||||
|
# A hand-written format, never `option httplog`: that one's default begins
|
||||||
|
# with `%ci:%cp` and would put every visitor's address into `docker logs`,
|
||||||
|
# undoing the pseudonym the frontends go to the trouble of minting. The
|
||||||
|
# pseudonym takes that first field instead, and with no key configured the
|
||||||
|
# field is a bare `-` rather than something derived from an empty key.
|
||||||
|
#
|
||||||
|
# The request line is logged as method and path only. `%{+Q}r` would carry
|
||||||
|
# the query string too, and a token that ever appears in one would be
|
||||||
|
# written down for as long as the log is kept.
|
||||||
|
# Two loggers, and the second is easy to miss: a connection that dies before
|
||||||
|
# a transaction exists — a refused TLS handshake, and TLS 1.2 is now the
|
||||||
|
# floor — is written by the error logger, whose default format *also* opens
|
||||||
|
# with %ci:%cp. Leaving it alone would log the address of exactly the
|
||||||
|
# clients this configuration turns away.
|
||||||
|
.if !streq("${XFF_HMAC_KEY}","")
|
||||||
|
log-format "%[var(sess.cid)] [%tr] %ft %b/%s %ST %B %TR/%Ta %HM %HP"
|
||||||
|
error-log-format "%[var(sess.cid)] [%tr] %ft %ac/%fc %[fc_err_str]"
|
||||||
|
.else
|
||||||
|
log-format "- [%tr] %ft %b/%s %ST %B %TR/%Ta %HM %HP"
|
||||||
|
error-log-format "- [%tr] %ft %ac/%fc %[fc_err_str]"
|
||||||
|
.endif
|
||||||
|
|
||||||
|
# Kept: with logging now on, this is what makes the log errors-only rather
|
||||||
|
# than a full access log. Remove it deliberately if every request is wanted.
|
||||||
option dontlog-normal
|
option dontlog-normal
|
||||||
option tcp-smart-accept
|
option tcp-smart-accept
|
||||||
option tcp-smart-connect
|
option tcp-smart-connect
|
||||||
#option forwardfor
|
# No `option forwardfor`, deliberately: it would write the visitor's real
|
||||||
|
# address into X-Forwarded-For, and the address must not leave this process.
|
||||||
|
# Each frontend deletes that header and mints X-Client-Id instead — see
|
||||||
|
# XFF_HMAC_KEY below.
|
||||||
option http-keep-alive
|
option http-keep-alive
|
||||||
|
|
||||||
http-reuse safe
|
http-reuse safe
|
||||||
|
|
||||||
frontend http
|
frontend http
|
||||||
bind *:80
|
bind *:80
|
||||||
#http-request redirect scheme https code 301
|
|
||||||
|
.if !streq("${XFF_HMAC_KEY}","")
|
||||||
|
# A pseudonym instead of the address: one-to-one with it, so a rate limit
|
||||||
|
# keyed on it is exactly as precise, but not reversible without the key.
|
||||||
|
# HMAC rather than a bare digest — IPv4 is 2^32 values, and an unkeyed hash
|
||||||
|
# of an address is brute-forced in seconds. Unset key means no pseudonym at
|
||||||
|
# all, never one derived from an empty key.
|
||||||
|
#
|
||||||
|
# Computed on accept rather than in the http phase: a TLS handshake that
|
||||||
|
# fails never reaches an http-request rule, and the error logger still needs
|
||||||
|
# something to write in place of the address. `sess` scope so it outlives
|
||||||
|
# the transaction. Written above the http-request rules because that is the
|
||||||
|
# order it runs in, and HAProxy warns when the two disagree.
|
||||||
|
tcp-request connection set-var(sess.cid) src,hmac(sha256,"${XFF_HMAC_KEY}"),base64
|
||||||
|
.endif
|
||||||
|
|
||||||
|
# The visitor's address stops here — X-Forwarded-For is dropped rather than
|
||||||
|
# filled in, so nothing downstream can log an address it was never given.
|
||||||
|
# Both deletes run unconditionally: a header a client sent must never be
|
||||||
|
# mistaken for one this proxy minted.
|
||||||
|
http-request del-header X-Forwarded-For
|
||||||
|
http-request del-header X-Client-Id
|
||||||
|
.if !streq("${XFF_HMAC_KEY}","")
|
||||||
|
http-request set-header X-Client-Id %[var(sess.cid)]
|
||||||
|
.endif
|
||||||
|
http-request set-header X-Forwarded-Proto http
|
||||||
|
|
||||||
# ACL
|
# ACL
|
||||||
acl certbot path_beg /.well-known/acme-challenge/
|
acl certbot path_beg /.well-known/acme-challenge/
|
||||||
use_backend certbot if certbot
|
|
||||||
|
|
||||||
# # Fool-proof: MS has no auth — never expose /ms publicly.
|
# Everything except the ACME challenge goes to HTTPS. The `unless` is
|
||||||
# http-request deny deny_status 404 if { path -i /ms } || { path -i -m beg /ms/ }
|
# load-bearing: Let's Encrypt validates over plain HTTP on port 80, so
|
||||||
|
# redirecting that path would break every renewal. Written above
|
||||||
|
# use_backend because that is the order it runs in — http-request rules are
|
||||||
|
# evaluated before backend selection whatever the order in the file, and
|
||||||
|
# HAProxy warns when the two disagree.
|
||||||
|
http-request redirect scheme https code 301 unless certbot
|
||||||
|
|
||||||
|
use_backend certbot if certbot
|
||||||
|
|
||||||
frontend https
|
frontend https
|
||||||
bind *:443 ssl crt /usr/local/etc/haproxy/certificates/site.pem
|
# alpn: without it HAProxy negotiates HTTP/1.1 only. The backend stays
|
||||||
http-request add-header X-Forwarded-Proto https
|
# HTTP/1.1 — HAProxy translates — so this is a client-side upgrade alone.
|
||||||
|
bind *:443 ssl crt /usr/local/etc/haproxy/certificates/site.pem alpn h2,http/1.1
|
||||||
|
|
||||||
|
# Same as the http frontend — see the comments there.
|
||||||
|
.if !streq("${XFF_HMAC_KEY}","")
|
||||||
|
tcp-request connection set-var(sess.cid) src,hmac(sha256,"${XFF_HMAC_KEY}"),base64
|
||||||
|
.endif
|
||||||
|
|
||||||
|
http-request del-header X-Forwarded-For
|
||||||
|
http-request del-header X-Client-Id
|
||||||
|
.if !streq("${XFF_HMAC_KEY}","")
|
||||||
|
http-request set-header X-Client-Id %[var(sess.cid)]
|
||||||
|
.endif
|
||||||
|
http-request set-header X-Forwarded-Proto https
|
||||||
|
|
||||||
|
# HSTS. max-age is deliberately one day, not the customary year: this is a
|
||||||
|
# one-way door — a browser that has seen the header refuses plain HTTP to
|
||||||
|
# this host until it expires, and no server-side change can call it back.
|
||||||
|
# One day keeps a mistake (a lapsed certificate, a host that must serve
|
||||||
|
# HTTP again) recoverable within a day. Raise it in steps once renewals have
|
||||||
|
# been seen to work for a while: 86400 -> 2592000 -> 31536000.
|
||||||
|
# No includeSubDomains and no preload: both would bind names this proxy does
|
||||||
|
# not serve, and preload is effectively permanent.
|
||||||
|
http-response set-header Strict-Transport-Security "max-age=86400"
|
||||||
|
|
||||||
# ACL
|
# ACL
|
||||||
acl certbot path_beg /.well-known/acme-challenge/
|
acl certbot path_beg /.well-known/acme-challenge/
|
||||||
use_backend certbot if certbot
|
use_backend certbot if certbot
|
||||||
|
|
||||||
# # Fool-proof: MS has no auth — never expose /ms publicly.
|
|
||||||
# http-request deny deny_status 404 if { path -i /ms } || { path -i -m beg /ms/ }
|
|
||||||
|
|
||||||
backend default-backend-http
|
backend default-backend-http
|
||||||
http-request set-header X-Forwarded-Proto https if { ssl_fc }
|
# resolvers: without it the name is resolved once at start-up and kept, so a
|
||||||
server main nginx:80 check
|
# recreated nginx container on a new IP is never noticed. init-addr libc,none
|
||||||
|
# lets HAProxy start even when nginx is not up yet.
|
||||||
|
server main nginx:80 check resolvers docker resolve-prefer ipv4 init-addr libc,none
|
||||||
|
|
||||||
backend certbot
|
backend certbot
|
||||||
server certbot certbot:380
|
server certbot certbot:380 resolvers docker resolve-prefer ipv4 init-addr libc,none
|
||||||
|
|
||||||
resolvers docker
|
resolvers docker
|
||||||
nameserver dns1 127.0.0.11:53
|
nameserver dns1 127.0.0.11:53
|
||||||
|
|||||||
Reference in New Issue
Block a user