|
|
@@ -0,0 +1,138 @@
|
|
|
+-- ACME http-01 domain validation and token provisioning plugin for Haproxy 1.6+
|
|
|
+-- copyright (C) 2015 Jan Broer
|
|
|
+-- serves http-01 validation resources from a map-store
|
|
|
+-- allows provisioning new validation resources via POST request
|
|
|
+
|
|
|
+-- usage:
|
|
|
+--
|
|
|
+-- challenge endpoint example haproxy.cfg:
|
|
|
+--
|
|
|
+-- frontend public-http
|
|
|
+-- ...
|
|
|
+-- acl url_acme_http01 path_beg /.well-known/acme-challenge/
|
|
|
+-- http-request use-service lua.acme-http01 if url_acme_http01
|
|
|
+--
|
|
|
+-- provision endpoint example haproxy.cfg:
|
|
|
+--
|
|
|
+-- frontend internal-http
|
|
|
+-- bind bind *:8081
|
|
|
+-- ...
|
|
|
+-- acl url_acme_provision path /.well-known/provision-token
|
|
|
+-- http-request lua.acme-provision if METH_POST url_acme_provision
|
|
|
+--
|
|
|
+-- Provision new http01 token/keyauth resource:
|
|
|
+--
|
|
|
+-- curl -X POST --header 'X-Auth-Secret: VERYSECRET' -d "token=123&authkey=ABC123" \
|
|
|
+-- http://haproxy-ip:8081/.well-known/provision-token
|
|
|
+--
|
|
|
+
|
|
|
+-- Begin configuration
|
|
|
+
|
|
|
+-- Client must send AUTH_SECRET in the 'X-Auth-Secret' header to provision new token
|
|
|
+AUTH_SECRET = "042Fa8_bf0/9fb0"
|
|
|
+
|
|
|
+-- Path to map file that stores key authorizations (dummy file must exist)
|
|
|
+AUTH_STORE = "/etc/haproxy/acme-store.map"
|
|
|
+
|
|
|
+-- End configuration
|
|
|
+
|
|
|
+acme = Map.new(AUTH_STORE, Map.str);
|
|
|
+
|
|
|
+-- http-01 token provisioning endpoint
|
|
|
+core.register_action("acme-provision", { "tcp-req", "http-req" }, function(txn)
|
|
|
+ local msg = ""
|
|
|
+ local response = ""
|
|
|
+ local src = txn.sf:src()
|
|
|
+ local contentType = txn.sf:hdr("Content-Type")
|
|
|
+
|
|
|
+ if contentType == "application/x-www-form-urlencoded" then
|
|
|
+ local auth = txn.sf:hdr("X-Auth-Secret")
|
|
|
+ if auth == AUTH_SECRET then
|
|
|
+ local token = txn.sf:req_body_param("token")
|
|
|
+ local authkey = txn.sf:req_body_param("authkey")
|
|
|
+ if (token ~= nil and token ~= '' and authkey ~= nil and authkey ~= '') then
|
|
|
+ token = sanitizeToken(token)
|
|
|
+ core.Info("[acme] http-01 token provisioned: " .. token .. " (client-ip: " .. tostring(src) .. ")")
|
|
|
+ core.set_map(AUTH_STORE, token, authkey)
|
|
|
+ msg = "OK"
|
|
|
+ response = response .. "HTTP/1.0 200 OK\r\n"
|
|
|
+ else
|
|
|
+ core.Warning("[acme] failed to provision http-01 token: invalid request (client-ip: " .. tostring(src) .. ")")
|
|
|
+ msg = "invalid request"
|
|
|
+ response = response .. "HTTP/1.0 400 Bad Request\r\n"
|
|
|
+ end
|
|
|
+ else
|
|
|
+ core.Warning("[acme] failed to provision http-01 token: invalid authentication (client-ip: " .. tostring(src) .. ")")
|
|
|
+ msg = "invalid authentication"
|
|
|
+ response = response .. "HTTP/1.0 401 Unauthorized\r\n"
|
|
|
+ end
|
|
|
+ else
|
|
|
+ core.Warning("[acme] failed to provision http-01 token: invalid content-type: " .. contentType .." (client-ip: " .. tostring(src) .. ")")
|
|
|
+ msg = "invalid content-type: " .. contentType
|
|
|
+ response = response .. "HTTP/1.0 400 Bad Request\r\n"
|
|
|
+ end
|
|
|
+
|
|
|
+ response = response .. "Server: haproxy/acme-http01-authenticator\r\n"
|
|
|
+ response = response .. "Content-Type: text/plain\r\n"
|
|
|
+ response = response .. "Content-Length: " .. msg:len() .. "\r\n"
|
|
|
+ response = response .. "Connection: close\r\n"
|
|
|
+ response = response .. "\r\n"
|
|
|
+ response = response .. msg
|
|
|
+
|
|
|
+ txn.res:send(response)
|
|
|
+ txn.done(txn)
|
|
|
+end)
|
|
|
+
|
|
|
+-- 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) .. ")")
|
|
|
+ core.del_map(AUTH_STORE, token)
|
|
|
+ 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 map store
|
|
|
+function getKeyAuth(token)
|
|
|
+ local keyAuth = ""
|
|
|
+ local k = acme:lookup(token)
|
|
|
+ if k ~= nil then
|
|
|
+ keyAuth = k
|
|
|
+ end
|
|
|
+ return keyAuth
|
|
|
+end
|