haproxy and ACME certificates
Here is one way of doing Let's Encrypt certificates using haproxy, lua, and dehydrated. Using the configuration from the prior haproxy article, uncomment the lua line from global and the http-request line that has use-service.
We'll use dehydrated
LUA application
-- ACME http-01 domain validation plugin for Haproxy 1.6+ -- copyright (C) 2015 Jan Broer -- acme = {} acme.version = "0.1.1" -- -- Configuration -- -- When HAProxy is *not* configured with the 'chroot' option you must set an absolute path here and pass -- that as 'webroot-path' to the letsencrypt client acme.conf = { ["non_chroot_webroot"] = "" } -- -- Startup -- acme.startup = function() core.Info("[acme] start http-01 plugin v" .. acme.version); end -- -- ACME http-01 validation endpoint -- acme.http01 = function(applet) local response = "" local reqPath = applet.path local src = applet.sf:src() local token = reqPath:match( ".+/(.*)$" ) if token then token = sanitizeToken(token) end if (token == nil or token == '') then response = "bad request\n" applet:set_status(400) core.Warning("[acme] malformed request (client-ip: " .. tostring(src) .. ")") else auth = getKeyAuth(token) if (auth:len() >= 1) then response = auth .. "\n" applet:set_status(200) core.Info("[acme] served http-01 token: " .. token .. " (client-ip: " .. tostring(src) .. ")") else response = "resource not found: " .. token .. "\n" applet:set_status(404) core.Warning("[acme] http-01 token not found: " .. token .. " (client-ip: " .. tostring(src) .. ")") end end applet:add_header("Server", "haproxy/acme-http01-authenticator") applet:add_header("Content-Length", string.len(response)) applet:add_header("Content-Type", "text/plain") applet:start_response() applet:send(response) end -- -- strip chars that are not in the URL-safe Base64 alphabet -- see https://github.com/letsencrypt/acme-spec/blob/master/draft-barnes-acme.md -- function sanitizeToken(token) _strip="[^%a%d%+%-%_=]" token = token:gsub(_strip,'') return token end -- -- get key auth from token file -- function getKeyAuth(token) local keyAuth = "" local path = acme.conf.non_chroot_webroot .. "/acme/" .. token core.Info("[acme] http-01:getKeyAuth:" .. path ) local f = io.open(path, "rb") if f ~= nil then keyAuth = f:read("*all") f:close() end return keyAuth end core.register_init(acme.startup) core.register_service("acme-http01", "http", acme.http01)
This file needs to be saved as /etc/haproxy/luascript_acme-webroot.lua as per the haproxy.cfg from before references it as that.
You may not already have an haproxy user and group, so to get those:
groupadd -g 188 haproxy
useradd -g haproxy -u 188 -r -d /var/lib/haproxy -m -s /bin/nologin
There are also a few steps required to get things ready for dehydrated to use this, primary one of permissions. In an effort to reduce the number of things the run as root, I use a non-root user to run dehydrated. For this to work we need a directory inside the chroot of haproxy that the non-root user can read and write:
sudo mkdir /var/lib/haproxy/acme
sudo chown acme:haproxy /var/lib/haproxy/acme
sudo chmod 750 /var/lib/haproxy/acme
How it works
The haproxy.cfg has the acl that test for /.well-known/acme-challenge request paths on http requests. This is the Let's Encrypt http01 path where challenge urls are based. With a single config line for dehydrated
WELLKNOWN="/var/lib/haproxy/acme"
the entire process loop is closed. You'll still need to follow the basic setup of dehydrated and create your domains.txt. This is how it goes:
- dehydrated -c
- For each domain/san a challenge will be returned from ACME
- dehydrated will put the challenge in /var/lib/haproxy/acme
- ACME will request http://the-domain/.well-known/acme-challenge/<challenge-sha>
- The haproxy acl
acl acme var(txn.txnpath) -m beg -i /.well-known/acme-challenge
will be true http-request use-service lua.acme-http01 if acme
will send the request to the lua service- The lua service will look for the file in /var/lib/haproxy/acme named for the last path section of the request and return the contents
- ACME (should) get what it's expecting and return a valid certificate
- The sub dir certs/<primary-domain>/{fullchain.pem,privkey.pem} will now exist
The first time you do this, you'll need to run dehydrated --register --accept-terms
and you'll also need to create a file /etc/haproxy/ssl/fe-https.crt_list as per the haproxy.cfg. This is a super simple file:
/etc/haproxy/ssl/certs.pem
To create that file or update it:
cat certs/<primary-domain>/{fullchain.pem,privkey.pem} > /etc/haproxy/ssl/certs.pem
Then systemctl restart haproxy
, though you might want to haproxy -f /etc/haproxy.cfg -c
first to make sure it's configured correctly.
In the end
These posts are a product of doing this exact process today. I've probably forgotten to include a few details and some mistakes, but it pulls a lot of things together in what I hope is a form that will help you to do this same thing. There's definitely a script that can encapsulate the regeneration of certificates and it wouldn't be more that a few lines. Hopefully that will come soon to a new post.
Thanks for reading.