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:

  1. dehydrated -c
  2. For each domain/san a challenge will be returned from ACME
  3. dehydrated will put the challenge in /var/lib/haproxy/acme
  4. ACME will request http://the-domain/.well-known/acme-challenge/<challenge-sha>
  5. The haproxy acl acl acme var(txn.txnpath) -m beg -i /.well-known/acme-challenge will be true
  6. http-request use-service lua.acme-http01 if acme will send the request to the lua service
  7. 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
  8. ACME (should) get what it's expecting and return a valid certificate
  9. 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.