diff --git a/docker-compose.yml b/docker-compose.yml index be8c52b..ca7e782 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docker/Dockerfile b/docker/Dockerfile index 9fca6a3..24b01d9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -3,3 +3,16 @@ FROM bitnami/haproxy # Copy config 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 `..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 + diff --git a/docker/haproxy.cfg b/docker/haproxy.cfg index 22590a3..0bc4a03 100644 --- a/docker/haproxy.cfg +++ b/docker/haproxy.cfg @@ -1,6 +1,40 @@ global - # Enable HAProxy runtime API - stats socket :9999 level admin expose-fd listeners + # The runtime API, through which certbot installs a renewed certificate. + # + # 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 mode http @@ -9,43 +43,130 @@ defaults timeout connect 5s timeout client 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 tcp-smart-accept 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 http-reuse safe frontend http 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 certbot path_beg /.well-known/acme-challenge/ - 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/ } + # Everything except the ACME challenge goes to HTTPS. The `unless` is + # 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 - bind *:443 ssl crt /usr/local/etc/haproxy/certificates/site.pem - http-request add-header X-Forwarded-Proto https + # alpn: without it HAProxy negotiates HTTP/1.1 only. The backend stays + # 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 certbot path_beg /.well-known/acme-challenge/ 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 - http-request set-header X-Forwarded-Proto https if { ssl_fc } - server main nginx:80 check + # resolvers: without it the name is resolved once at start-up and kept, so a + # 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 - server certbot certbot:380 + server certbot certbot:380 resolvers docker resolve-prefer ipv4 init-addr libc,none resolvers docker nameserver dns1 127.0.0.11:53