Using haproxy to split letsencrypt acme challenges from regular traffic

Intro

This configuration is taken from my own live working configuration, butt it's possible that I've made a mistake extracting the parts used here, so as always, test this before relying on it. Please let me know if you find this helpful or if you find any issues with it.
This configuration will let you direct acme challenges using either http-01 or tls-sni-01 to a separate target than normal traffic. There are a lot of ways to forward the http-01 challenge, but forwarding on the tls-01 challenge is a lot harder as we have to detect the challenge and redirect the traffic before we try to terminate the tls connection, and most tools won't let you do that. The approach described here lets you have the acme client you are using to request your certificate (presumably from Let's Encrypt) not touch your web server at all, even potentially on a separate machine. I manage the certificates from a particular virtual machine that is separate from the vm's for email, haproxy, and my web server vm, letting me redirect all of the validation challenges to that vm. The basic idea is to use two haproxy configuration blocks, the first as a tcp proxy that will filter out the tls-01 challenge, and the second which will do both hostname based forwarding for http(s) and filter out the http-01 challenge.

The config

The first block:
listen letsencrypt-tls01-detect
	mode tcp
	
	option tcplog
	
	bind 192.0.2.1:443
	
	tcp-request inspect-delay 1s
	tcp-request content accept if { req.ssl_hello_type 1 }
	
	use-server letsencrypt-server if { req_ssl_sni -m end .acme.invalid }
	
	server letsencrypt-serverA 198.51.100.1:18443 weight 0
	server letsencrypt-second-haproxy 127.0.0.1:19443 weight 1 send-proxy

The second block:
frontend letsencrypt-second-haproxy-fe
	mode http
	
	option  forwardfor
	
	bind 192.0.2.1:80
	bind 127.0.0.1:19443 ssl crt /etc/ssl/YOUR_HAPROXY_STYLE_KEY_AND_CERT_HERE no-sslv3 accept-proxy
	
	redirect scheme https code 301 if !{ ssl_fc } !{ path_beg /.well-known/acme-challenge }
	
	use_backend letsencrypt-serverB if { path_beg /.well-known/acme-challenge }
	
	default_backend default-web-server

backend letsencrypt-serverB
	server letsencrypt-serverB0 198.51.100.1:18080

backend default-web-server
	server default-web-server0 198.51.100.2:80
Config notes
Ip's used are taken from the special reserved blocks for documentation and should be replaced with the ip addresses for your server(s).
192.0.2.1 is the public ip of your haproxy instance
198.51.100.1 port 18443 is the ip and port to direct tls-01 challenges to when your haproxy instance detects them
198.51.100.1 port 18080 is the ip and port to direct http-01 challenges to when your haproxy instance detects them

How it works


listen letsencrypt-tls01-detect
	mode tcp
	
	option tcplog
	
	bind 192.0.2.1:443
This create a tcp mode entry in haproxy to listen on on our tls port (443)
	tcp-request inspect-delay 1s
	tcp-request content accept if { req.ssl_hello_type 1 }
This tells haproxy to wait up to 1 second for server name indication (SNI) information to be sent and continue immediately if it see's the SNI information or after 1 second if it has not.
	use-server letsencrypt-serverA if { req_ssl_sni -m end .acme.invalid }
This tells haproxy to send the connection to the server defined as "letsencrypt-serverA" if it received SNI information which ends in .acme.invalid, which indicates an acme challenge.
	server letsencrypt-serverA 198.51.100.1:18443 weight 0
This tells haproxy where to find "letsencrypt-serverA" and the zero weight tells it to send no traffic to it unless specifically directed to.
	server letsencrypt-second-haproxy 127.0.0.1:19443 weight 1 send-proxy
This tells haproxy about another server "letsencrypt-second-haproxy" which has a non zero weight and tells haproxy to use the PROXY protocol to preserve information about the connecting ip and port, etc.
frontend letsencrypt-second-haproxy-fe
	mode http
	
	option  forwardfor
	
	bind 192.0.2.1:80
	bind 127.0.0.1:19443 ssl crt /etc/ssl/YOUR_HAPROXY_STYLE_KEY_AND_CERT_HERE no-sslv3 accept-proxy
This tells haproxy to listen on port 80 for plain http traffic and on port 19443 for ssl/tls traffic using the PROXY protocol (which is the traffic we sent from the first configuration block). It also tells haproxy to add the x-forwarded-for header to the http traffic when it sends it on to it's final destination.
	redirect scheme https code 301 if !{ ssl_fc } !{ path_beg /.well-known/acme-challenge }
This tells haproxy to redirect all http traffic other than acme challenges to ssl/tls.
	use_backend letsencrypt-serverB if { path_beg /.well-known/acme-challenge }
This tells haproxy to direct http-01 challenges to the backend we define called "letsencrypt-serverB".
	default_backend default-web-server
This tells haproxy to direct any traffic we haven't told it to do something specific with to go to the "default-web-server" backend, we could have also added more configuration to use the hostname in the traffic to route it to different servers, and I do in my live setup, but to keep this simple and focused we don't here.
backend letsencrypt-serverB
	server letsencrypt-serverB0 198.51.100.1:18080
This tells haproxy where to send the acme challenge traffic we directed to "letsencrypt-serverB"
backend default-web-server
	server default-web-server0 198.51.100.2:80
This tells haproxy where to send the normal web traffic to, presumably your web server.




crabby
crabby
crabby
crabby