Jan Broer пре 10 година
комит
503cc445cd
4 измењених фајлова са 374 додато и 0 уклоњено
  1. 140 0
      README.md
  2. 98 0
      acme-http01-webroot.lua
  3. 109 0
      cert-renewal-haproxy.sh
  4. 27 0
      haproxy.cfg.example

+ 140 - 0
README.md

@@ -0,0 +1,140 @@
+## `HAProxy` ACME domain validation plugin
+
+HAProxy plugin implementing zero-downtime [ACME http-01](https://github.com/letsencrypt/acme-spec) validation for domains served by HAProxy instances. The plugin leverages HAProxy's Lua API to allow HAProxy to answer validation challenges using token/key-auth files provisioned by an ACME client to a designated directory.
+
+## Compatible ACME clients
+
+The plugin is compatible with ACME clients supporting webroot authentication for http-01 challenges.
+
+- [Official Let's Encrypt client](https://github.com/letsencrypt/letsencrypt)
+- [lego ACME client](https://github.com/xenolf/lego) (coming soon!)
+
+## Features
+
+### Zero-Downtime
+
+No need to take HAProxy offline to issue or reissue certificates.
+### Self-Contained & Lean
+
+No need to leverage a backend webserver for the trivial task of serving a key authorization from a file.
+
+## Installation instructions
+
+### Prerequesites
+
+You need to be rolling HAProxy version 1.6 or later with Lua support enabled.
+To check if your HAProxy binary was compiled with Lua support run the following command:
+
+	haproxy -vv
+
+If there is a line similar to this you are good to go:
+
+	Built with Lua support
+
+If Lua support is not enabled you may follow [these instructions](http://www.arpalert.org/haproxy-lua.html#h103) for compiling a HAProxy binary with Lua support (hint: it's quick and painless).
+
+### HAProxy configuration
+
+Copy `acme-http01-webroot.lua` to a location accessible by HAProxy.
+
+Only minimal changes to your existing `haproxy.cfg` are necessary. In fact you just need to add **three lines**:
+
+In the `global` section insert
+
+	lua-load /etc/haproxy/acme-http01-webroot.lua
+
+to invoke the Lua plugin.
+
+In the `frontend` section serving the domain(s) for which you want to create/renew certificates insert
+
+	acl url_acme_http01 path_beg /.well-known/acme-challenge/
+    http-request use-service lua.acme-http01 if METH_GET url_acme_http01
+
+to pass ACME http-01 validation requests to the Lua plugin.
+
+*Note:* ACME protocol stipulates validation on port 80. If your HTTP frontend listens on a non-standard port, make sure to add a port 80 bind directive.
+
+Finally, soft-restart HAProxy (see below for instructions) to apply the updated configuration.
+
+## Workflow
+
+A complete workflow for issuing certificates using the Let's Encrypt CA for domains served by HAProxy.
+
+An example minimal `haproxy.cfg` for this workflow is available [here](haproxy.cfg.example).
+
+### 1. Prepare HAProxy
+
+First, enable the `acme-http01-webroot.lua` plugin in your haproxy.cfg as described above.
+
+Letsencrypt stores the certificate, chain and private key in `/etc/letsencrypt/live/domain.tld/`. HAProxy requires a `PEM` file that includes the certificate and corresponding private key. We need to set the `crt` directive in the `haproxy.cfg` to point to the `PEM` file which we will create later in the process.
+
+```
+...
+frontend https
+    bind *:443 ssl crt /etc/letsencrypt/live/www.example.com/haproxy.pem
+...
+```
+
+### 2. Install `letsencrypt` client
+
+Follow the [official guide](https://letsencrypt.readthedocs.org/en/latest/using.html#getting-the-code) to install the client.
+
+### 3. Issue certificate
+
+We are ready to create our certificate. Let's roll! 
+
+What happens here is, we invoke the `letsencrypt` client with the [webroot method](https://letsencrypt.readthedocs.org/en/latest/using.html#webroot) and pass our email address and the `WEBROOT` path configured in the Lua plugin. The domain validation is then be performed against the running HAProxy instance.
+
+	$ sudo ./letsencrypt-auto certonly --text --webroot --webroot-path \
+	  /var/temp -d www.example.com --renew-by-default --agree-tos \
+	  --email your@email.com
+
+Next, concat the certificate chain and private key to a `PEM` file suitable for HAProxy:
+
+	$ sudo cat /etc/letsencrypt/live/www.example.com/privkey.pem \
+	  /etc/letsencrypt/live/www.example.com/fullchain.pem \
+	  | sudo tee /etc/letsencrypt/live/www.example.com/haproxy.pem >/dev/null
+
+Whohaaa! Done.
+
+### 4. Soft-restart HAProxy
+
+We want HAProxy to reload the certificate without interrupting existing connections or introducing any sort of down-time.
+
+Depending on your environment this can be accomplished in several ways:
+
+#### Ubuntu/Debian command
+
+	$ sudo service haproxy reload
+
+#### Generic command
+
+	$ haproxy -f /etc/haproxy/haproxy.cfg -p /var/run/haproxy.pid \
+	  -sf $(cat /var/run/haproxy.pid)
+
+or if you are up for some bash-ism:
+
+	$ eval $(xargs -0 < /proc/$(pidof haproxy)/cmdline | \
+	  awk -F '-sf' '{print $1}') -sf $(pidof haproxy)
+
+## Certificate renewal
+
+To renew a certificate manually just repeat steps No. 3 and 4.
+
+### Automatic renewal
+
+To automate renewal of certificates you can use this handy bash script: [cert-renewal-haproxy.sh](cert-renewal-haproxy.sh).
+
+The script automates the following steps:
+
+- Check the expiry of all the certificates under /etc/letsencrypt/live
+- Renew certificates that expire in less than 4 weeks
+- Create the haproxy.pem files
+- Soft-restart HAProxy.
+
+Use it in a cron job like this for weekly runs:
+
+	$ sudo crontab -e
+
+	5 8 * * 6 /usr/bin/cert-renewal-haproxy.sh
+

+ 98 - 0
acme-http01-webroot.lua

@@ -0,0 +1,98 @@
+-- ACME http-01 domain validation plugin for Haproxy 1.6+
+-- copyright (C) 2015 Jan Broer
+--
+-- usage:
+--
+-- 1) copy acme-webroot.lua in your haproxy config dir
+-- 
+-- 2) Invoke the plugin by adding in the 'global' section of haproxy.cfg:
+-- 
+--    lua-load /etc/haproxy/acme-webroot.lua
+-- 
+-- 3) insert these two lines in every http frontend that is
+--    serving domains for which you want to create certificates:
+-- 
+--    acl url_acme_http01 path_beg /.well-known/acme-challenge/
+--    http-request use-service lua.acme-http01 if METH_GET url_acme_http01
+--
+-- 4) reload haproxy
+--
+-- 5) create a certificate:
+--
+-- ./letsencrypt-auto certonly --text --webroot --webroot-path /var/tmp -d blah.example.com --renew-by-default --agree-tos --email my@email.com
+--
+
+--
+-- Configuration begin
+--
+
+-- Path passed to letsencrypt via the '--webroot-path' parameter must match this
+WEBROOT = "/var/tmp"
+
+--
+-- Configuration end
+--
+
+VERSION = "0.1.0"
+
+core.Info("[acme] http-01 plugin v." .. VERSION .. " loaded");
+
+--
+-- ACME http-01 validation endpoint
+--
+core.register_service("acme-http01", "http", function(applet)
+	local response = ""
+	local reqPath = applet.sf: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\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 f = io.open(WEBROOT .. "/.well-known/acme-challenge/" .. token, "rb")
+	if f ~= nil then
+		keyAuth = f:read("*all")
+		f:close()
+	end
+	return keyAuth
+end

+ 109 - 0
cert-renewal-haproxy.sh

@@ -0,0 +1,109 @@
+#!/bin/bash
+
+# automation of certificate renewal for let's encrypt and haproxy
+# - checks all certificates under /etc/letsencrypt/live and renews
+#   those about about to expire in less than 4 weeks
+# - creates haproxy.pem files in /etc/letsencrypt/live/domain.tld/
+# - soft-restarts haproxy to apply new certificates
+# usage:
+# sudo ./cert-renewal-haproxy.sh
+
+###################
+## configuration ##
+###################
+
+EMAIL="your_le_account@email.com"
+
+LE_CLIENT="/path/to/letsencrypt-auto"
+
+HAPROXY_RELOAD_CMD="service haproxy reload"
+
+WEBROOT="/var/tmp"
+
+# Enable to redirect output to logfile (for silent cron jobs)
+# LOGFILE="/var/log/certrenewal.log"
+
+######################
+## utility function ##
+######################
+
+function issueCert {
+  $LE_CLIENT certonly --text --webroot --webroot-path ${WEBROOT} --renew-by-default --agree-tos --email ${EMAIL} $1 &>/dev/null
+  return $?
+}
+
+function logger_error {
+  if [ -n "${LOGFILE}" ]
+  then
+    echo "[error] ${1}\n" >> ${LOG_TO_FILE}
+  fi
+  >&2 echo "[error] ${1}"
+}
+
+function logger_info {
+  if [ -n "${LOG_TO_FILE}" ]
+  then
+    echo "[info] ${1}\n" >> ${LOG_TO_FILE}
+  else
+    echo "[info] ${1}"
+  fi
+}
+
+##################
+## main routine ##
+##################
+
+le_cert_root="/etc/letsencrypt/live"
+
+if [ ! -d ${le_cert_root} ]; then
+  logger_error "${le_cert_root} does not exist!"
+  exit 1
+fi
+
+# check certificate expiration and run certificate issue requests
+# for those that expire in under 4 weeks
+renewed_certs=()
+exitcode=0
+while IFS= read -r -d '' cert; do
+  if ! openssl x509 -noout -checkend $((4*7*86400)) -in "${cert}"; then
+    subject="$(openssl x509 -noout -subject -in "${cert}" | grep -o -E 'CN=[^ ,]+' | tr -d 'CN=')"
+    subjectaltnames="$(openssl x509 -noout -text -in "${cert}" | sed -n '/X509v3 Subject Alternative Name/{n;p}' | sed 's/\s//g' | tr -d 'DNS:' | sed 's/,/ /g')"
+    domains="-d ${subject}"
+    for name in ${subjectaltnames}; do
+      if [ "${name}" != "${subject}" ]; then
+        domains="${domains} -d ${name}"
+      fi
+    done
+    issueCert "${domains}"
+    if [ $? -ne 0 ]
+    then
+      logger_error "failed to renew certificate! check /var/log/letsencrypt/letsencrypt.log!"
+      exitcode=1
+    else
+      renewed_certs+=("$subject")
+      logger_info "renewed certificate for ${subject}"
+    fi
+  else
+    logger_info "none of the certificates requires renewal"
+  fi
+done < <(find /etc/letsencrypt/live -name cert.pem -print0)
+
+# create haproxy.pem file(s)
+for domain in ${renewed_certs[@]}; do
+  cat ${le_cert_root}/${domain}/privkey.pem ${le_cert_root}/${domain}/fullchain.pem | sudo tee ${le_cert_root}/${domain}/haproxy.pem >/dev/null
+  if [ $? -ne 0 ]; then
+    logger_error "failed to create haproxy.pem file!"
+    exit 1
+  fi
+done
+
+# soft-restart haproxy
+if [ "${#renewed_certs[@]}" -gt 0 ]; then
+  $HAPROXY_RELOAD_CMD
+  if [ $? -ne 0 ]; then
+    logger_error "failed to reload haproxy!"
+    exit 1
+  fi
+fi
+
+exit ${exitcode}

+ 27 - 0
haproxy.cfg.example

@@ -0,0 +1,27 @@
+global
+    #daemon
+    #log /dev/log    local0
+    crt-base /etc/letsencrypt/live
+    lua-load /etc/haproxy/acme-http01-webroot.lua
+    
+defaults
+    mode    http
+    option  httplog
+    timeout connect 5000
+    timeout client  50000
+    timeout server  50000
+    
+frontend http
+    bind *:80
+    mode http
+    acl url_acme_http01 path_beg /.well-known/acme-challenge/
+    http-request use-service lua.acme-http01 if METH_GET url_acme_http01
+    default_backend default
+
+frontend https
+    bind *:443 ssl crt www.example.com/haproxy.pem
+    mode http
+    default_backend default
+    
+backend default
+    server server1 127.0.0.1:8002