global # 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 default_backend default-backend-http 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 # 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 .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/ # 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 # 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 backend default-backend-http # 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 resolvers docker resolve-prefer ipv4 init-addr libc,none resolvers docker nameserver dns1 127.0.0.11:53