-- ACME dns-01 automation via event_hdl callbacks using the Cloudflare DNS API -- Requires HAProxy >= 3.5 (ACME_DEPLOY / ACME_NEWCERT lua events). -- -- Supports CNAME delegation: if a domain maps to a delegated FQDN (e.g. -- _acme-challenge.example.net CNAME .example.org), TXT records -- are written to the delegated target instead. The API token then only -- needs Zone:DNS:Edit on example.org, never on the primary zones. -- -- HAProxy Configuration: -- -- global -- lua-load /usr/local/etc/haproxy/acme-cloudflare.lua -- -- acme letsencrypt -- directory https://acme-v02.api.letsencrypt.org/directory -- contact admin@example.com -- challenge dns-01 -- challenge-ready cli,dns -- -- crt-store certs -- crt-base /usr/local/etc/haproxy/ssl -- key-base /usr/local/etc/haproxy/ssl -- load crt "example.com.pem" acme letsencrypt domains "example.com,*.example.com" -- -- HAProxy must be started with CLOUDFLARE_DNS_API_TOKEN in its environment; -- rc.subr exports it from haproxy_env="..." in /etc/rc.conf.d/haproxy. -- -- Token: https://dash.cloudflare.com/profile/api-tokens, Zone:DNS:Edit -- Not fatal when missing: the rc script runs "haproxy -c" as its precmd -- before rc.subr exports haproxy_env, so a hard error here would stop the -- service from ever starting. Automation is simply disabled instead. local CLOUDFLARE_DNS_API_TOKEN = os.getenv("CLOUDFLARE_DNS_API_TOKEN") local CF_API_URL = os.getenv("CLOUDFLARE_API_URL") or "https://api.cloudflare.com/client/v4" -- --------------------------------------------------------------------------- -- CNAME delegation map (injected by Ansible from haproxy_acme_challenge_delegate) -- -- Maps each challenged domain to the FQDN where the TXT record should -- actually be written. A CNAME from _acme-challenge. to the -- delegated FQDN must exist in DNS (set once, manually). -- --------------------------------------------------------------------------- local CHALLENGE_DELEGATE = { {% for domain, target in haproxy_acme_challenge_delegate.items() %} ["{{ domain }}"] = "{{ target }}", {% endfor %} } -- --------------------------------------------------------------------------- -- helpers -- --------------------------------------------------------------------------- local function cf_headers(with_body) local h = { ["Authorization"] = { "Bearer " .. CLOUDFLARE_DNS_API_TOKEN }, ["Accept"] = { "application/json" }, } if with_body then h["Content-Type"] = { "application/json" } end return h end -- Minimal JSON escaping for the few string values we send. local function json_str(s) return '"' .. s:gsub('[%c"\\]', function(c) return string.format("\\u%04x", c:byte()) end) .. '"' end -- Resolve the FQDN where the TXT record should be written for . -- "*.example.com" and "example.com" share the same challenge name, so -- strip a leading wildcard label before consulting the delegation map. local function challenge_fqdn(domain) local base = domain:gsub("^%*%.", "") if CHALLENGE_DELEGATE[base] then core.log(core.debug, string.format( "acme: delegation: %s -> %s", base, CHALLENGE_DELEGATE[base])) return CHALLENGE_DELEGATE[base] end return "_acme-challenge." .. base end -- zone name -> zone id, filled lazily local zone_cache = {} -- Find the Cloudflare zone id holding by probing parent labels -- longest-first via GET /zones?name=. Returns zone_name, zone_id -- or nil, nil. local function cf_find_zone(fqdn) local labels = {} for label in fqdn:gmatch("[^.]+") do labels[#labels + 1] = label end for i = 2, #labels - 1 do local zone = table.concat(labels, ".", i) if zone_cache[zone] then return zone, zone_cache[zone] end local url = string.format("%s/zones?name=%s&status=active", CF_API_URL, zone) core.log(core.debug, string.format("acme: probing zone %s", zone)) local hc = core.httpclient() local res = hc:get({ url = url, headers = cf_headers(false) }) if res and res.status == 200 and res.body and not res.body:find('"result":%s*%[%s*%]') then local id = res.body:match('"id"%s*:%s*"(%x+)"') if id and #id == 32 then zone_cache[zone] = id core.log(core.info, string.format( "acme: zone %s has id %s", zone, id)) return zone, id end elseif res and res.status ~= 200 then core.log(core.warning, string.format( "acme: Cloudflare zone lookup for %s returned status %s", zone, res.status)) end end core.log(core.alert, string.format( "acme: no Cloudflare zone found for %s", fqdn)) return nil, nil end -- Create a TXT record = . Returns zone_id, record_id -- or nil, nil. Multiple TXT records at the same name are allowed, which -- is what the apex + wildcard pair of a single cert needs. local function dns_set_txt(fqdn, txt_value) local zone, zone_id = cf_find_zone(fqdn) if not zone_id then return nil, nil end local url = string.format("%s/zones/%s/dns_records", CF_API_URL, zone_id) local body = string.format( '{"type":"TXT","name":%s,"content":%s,"ttl":60,"comment":"haproxy acme dns-01"}', json_str(fqdn), json_str(txt_value)) local hc = core.httpclient() local res = hc:post({ url = url, headers = cf_headers(true), body = body }) -- 81058: an identical record already exists, typically left behind when -- haproxy restarted mid-challenge. The CA will see it, so carry on. if res and res.status == 400 and res.body and res.body:find('"code"%s*:%s*81058') then core.log(core.notice, string.format( "acme: TXT record already present: %s in zone %s (value=%s)", fqdn, zone, txt_value)) return zone_id, "existing" end if not res or res.status ~= 200 then local status = res and res.status or "nil" core.log(core.alert, string.format( "acme: Cloudflare POST failed for %s (status=%s): %s", fqdn, status, res and res.body or "")) return nil, nil end local rid = res.body and res.body:match('"result"%s*:%s*{.-"id"%s*:%s*"(%x+)"') if not rid then core.log(core.alert, string.format( "acme: Cloudflare POST for %s succeeded but no record id in response", fqdn)) return nil, nil end core.log(core.notice, string.format( "acme: TXT record set: %s in zone %s (id=%s value=%s)", fqdn, zone, rid, txt_value)) return zone_id, rid end -- Delete every TXT record at in . Records are listed -- rather than remembered so ones orphaned by a restart are swept up too. local function dns_del_txt(zone_id, fqdn) local url = string.format("%s/zones/%s/dns_records?type=TXT&name=%s&per_page=100", CF_API_URL, zone_id, fqdn) local hc = core.httpclient() local res = hc:get({ url = url, headers = cf_headers(false) }) if not res or res.status ~= 200 or not res.body then core.log(core.alert, string.format( "acme: Cloudflare list failed for %s (status=%s)", fqdn, res and res.status or "nil")) return false end local n = 0 for rid in res.body:gmatch('"id"%s*:%s*"(%x+)"') do if #rid == 32 and rid ~= zone_id then local durl = string.format("%s/zones/%s/dns_records/%s", CF_API_URL, zone_id, rid) local dres = core.httpclient():delete({ url = durl, headers = cf_headers(false) }) if dres and dres.status == 200 then n = n + 1 else core.log(core.alert, string.format( "acme: Cloudflare DELETE failed for %s id=%s (status=%s)", fqdn, rid, dres and dres.status or "nil")) end end end core.log(core.notice, string.format("acme: %d TXT record(s) deleted: %s", n, fqdn)) return true end -- --------------------------------------------------------------------------- -- Tasks -- --------------------------------------------------------------------------- -- Track the names we wrote per cert path so they can be cleaned up. -- deployed[crt][fqdn] = zone_id local deployed = {} -- Spawn a background task per ACME_DEPLOY event to set the TXT record and -- signal challenge readiness. local function on_deploy(event, data, sub, when) local crt = data.crtname local domain = data.domain local record = data.dns_record core.register_task(function() local fqdn = challenge_fqdn(domain) local zone_id, record_id = dns_set_txt(fqdn, record) if not record_id then core.log(core.alert, string.format( "acme: aborting challenge for crt=%s domain=%s", crt, domain)) return end if not deployed[crt] then deployed[crt] = {} end deployed[crt][fqdn] = zone_id -- An apex and its wildcard share one domain string, and haproxy marks -- every matching authorization on the first call, so a later call for -- the same name reports "not found". That is expected, not an error; -- the challenge-ready delay covers the record we just wrote. local ok, ret = pcall(ACME.challenge_ready, crt, domain) if not ok then if tostring(ret):find("not found") then core.log(core.info, string.format( "acme: crt=%s domain=%s already marked ready", crt, domain)) else core.log(core.alert, string.format( "acme: challenge_ready error for crt=%s domain=%s: %s", crt, domain, ret)) end elseif ret == 0 then core.log(core.notice, string.format( "acme: all challenges ready for crt=%s, validation starting", crt)) else core.log(core.info, string.format( "acme: crt=%s domain=%s ready, %d challenge(s) still pending", crt, domain, ret)) end end) end -- ACME_NEWCERT: remove the TXT records that were set for this certificate. local function on_newcert(event, data, sub, when) local crt = data.crtname if not deployed[crt] then return end core.register_task(function() for fqdn, zone_id in pairs(deployed[crt]) do dns_del_txt(zone_id, fqdn) end deployed[crt] = nil end) end -- --------------------------------------------------------------------------- -- Subscribe. The ACME event family arrived in HAProxy 3.5; on 3.4 the -- subscription fails and dns-01 challenges must be answered by hand: -- echo "@1; acme challenge_ready domain " \ -- | nc -NU /var/run/haproxy.sock -- The TXT value to set is logged by haproxy at notice level. -- --------------------------------------------------------------------------- local ok, err if not CLOUDFLARE_DNS_API_TOKEN then ok, err = false, "CLOUDFLARE_DNS_API_TOKEN is not set in the environment" else ok, err = pcall(core.event_sub, {"ACME_DEPLOY"}, on_deploy) end if ok then core.event_sub({"ACME_NEWCERT"}, on_newcert) core.log(core.info, "acme: Cloudflare dns-01 automation registered") else core.log(core.alert, string.format( "acme: dns-01 automation disabled (%s); " .. "answer challenges manually via 'acme challenge_ready' on the master CLI", tostring(err))) end