| 1 | -- ACME dns-01 automation via event_hdl callbacks using the Cloudflare DNS API |
| 2 | -- Requires HAProxy >= 3.5 (ACME_DEPLOY / ACME_NEWCERT lua events). |
| 3 | -- |
| 4 | -- Supports CNAME delegation: if a domain maps to a delegated FQDN (e.g. |
| 5 | -- _acme-challenge.example.net CNAME <key>.example.org), TXT records |
| 6 | -- are written to the delegated target instead. The API token then only |
| 7 | -- needs Zone:DNS:Edit on example.org, never on the primary zones. |
| 8 | -- |
| 9 | -- HAProxy Configuration: |
| 10 | -- |
| 11 | -- global |
| 12 | -- lua-load /usr/local/etc/haproxy/acme-cloudflare.lua |
| 13 | -- |
| 14 | -- acme letsencrypt |
| 15 | -- directory https://acme-v02.api.letsencrypt.org/directory |
| 16 | -- contact admin@example.com |
| 17 | -- challenge dns-01 |
| 18 | -- challenge-ready cli,dns |
| 19 | -- |
| 20 | -- crt-store certs |
| 21 | -- crt-base /usr/local/etc/haproxy/ssl |
| 22 | -- key-base /usr/local/etc/haproxy/ssl |
| 23 | -- load crt "example.com.pem" acme letsencrypt domains "example.com,*.example.com" |
| 24 | -- |
| 25 | -- HAProxy must be started with CLOUDFLARE_DNS_API_TOKEN in its environment; |
| 26 | -- rc.subr exports it from haproxy_env="..." in /etc/rc.conf.d/haproxy. |
| 27 | -- |
| 28 | -- Token: https://dash.cloudflare.com/profile/api-tokens, Zone:DNS:Edit |
| 29 | -- Not fatal when missing: the rc script runs "haproxy -c" as its precmd |
| 30 | -- before rc.subr exports haproxy_env, so a hard error here would stop the |
| 31 | -- service from ever starting. Automation is simply disabled instead. |
| 32 | local CLOUDFLARE_DNS_API_TOKEN = os.getenv("CLOUDFLARE_DNS_API_TOKEN") |
| 33 | |
| 34 | local CF_API_URL = os.getenv("CLOUDFLARE_API_URL") or "https://api.cloudflare.com/client/v4" |
| 35 | |
| 36 | -- --------------------------------------------------------------------------- |
| 37 | -- CNAME delegation map (injected by Ansible from haproxy_acme_challenge_delegate) |
| 38 | -- |
| 39 | -- Maps each challenged domain to the FQDN where the TXT record should |
| 40 | -- actually be written. A CNAME from _acme-challenge.<domain> to the |
| 41 | -- delegated FQDN must exist in DNS (set once, manually). |
| 42 | -- --------------------------------------------------------------------------- |
| 43 | local CHALLENGE_DELEGATE = { |
| 44 | {% for domain, target in haproxy_acme_challenge_delegate.items() %} |
| 45 | ["{{ domain }}"] = "{{ target }}", |
| 46 | {% endfor %} |
| 47 | } |
| 48 | |
| 49 | -- --------------------------------------------------------------------------- |
| 50 | -- helpers |
| 51 | -- --------------------------------------------------------------------------- |
| 52 | |
| 53 | local function cf_headers(with_body) |
| 54 | local h = { |
| 55 | ["Authorization"] = { "Bearer " .. CLOUDFLARE_DNS_API_TOKEN }, |
| 56 | ["Accept"] = { "application/json" }, |
| 57 | } |
| 58 | if with_body then |
| 59 | h["Content-Type"] = { "application/json" } |
| 60 | end |
| 61 | return h |
| 62 | end |
| 63 | |
| 64 | -- Minimal JSON escaping for the few string values we send. |
| 65 | local function json_str(s) |
| 66 | return '"' .. s:gsub('[%c"\\]', function(c) |
| 67 | return string.format("\\u%04x", c:byte()) |
| 68 | end) .. '"' |
| 69 | end |
| 70 | |
| 71 | -- Resolve the FQDN where the TXT record should be written for <domain>. |
| 72 | -- "*.example.com" and "example.com" share the same challenge name, so |
| 73 | -- strip a leading wildcard label before consulting the delegation map. |
| 74 | local function challenge_fqdn(domain) |
| 75 | local base = domain:gsub("^%*%.", "") |
| 76 | if CHALLENGE_DELEGATE[base] then |
| 77 | core.log(core.debug, string.format( |
| 78 | "acme: delegation: %s -> %s", base, CHALLENGE_DELEGATE[base])) |
| 79 | return CHALLENGE_DELEGATE[base] |
| 80 | end |
| 81 | return "_acme-challenge." .. base |
| 82 | end |
| 83 | |
| 84 | -- zone name -> zone id, filled lazily |
| 85 | local zone_cache = {} |
| 86 | |
| 87 | -- Find the Cloudflare zone id holding <fqdn> by probing parent labels |
| 88 | -- longest-first via GET /zones?name=<zone>. Returns zone_name, zone_id |
| 89 | -- or nil, nil. |
| 90 | local function cf_find_zone(fqdn) |
| 91 | local labels = {} |
| 92 | for label in fqdn:gmatch("[^.]+") do |
| 93 | labels[#labels + 1] = label |
| 94 | end |
| 95 | |
| 96 | for i = 2, #labels - 1 do |
| 97 | local zone = table.concat(labels, ".", i) |
| 98 | if zone_cache[zone] then |
| 99 | return zone, zone_cache[zone] |
| 100 | end |
| 101 | |
| 102 | local url = string.format("%s/zones?name=%s&status=active", CF_API_URL, zone) |
| 103 | core.log(core.debug, string.format("acme: probing zone %s", zone)) |
| 104 | |
| 105 | local hc = core.httpclient() |
| 106 | local res = hc:get({ url = url, headers = cf_headers(false) }) |
| 107 | |
| 108 | if res and res.status == 200 and res.body |
| 109 | and not res.body:find('"result":%s*%[%s*%]') then |
| 110 | local id = res.body:match('"id"%s*:%s*"(%x+)"') |
| 111 | if id and #id == 32 then |
| 112 | zone_cache[zone] = id |
| 113 | core.log(core.info, string.format( |
| 114 | "acme: zone %s has id %s", zone, id)) |
| 115 | return zone, id |
| 116 | end |
| 117 | elseif res and res.status ~= 200 then |
| 118 | core.log(core.warning, string.format( |
| 119 | "acme: Cloudflare zone lookup for %s returned status %s", |
| 120 | zone, res.status)) |
| 121 | end |
| 122 | end |
| 123 | |
| 124 | core.log(core.alert, string.format( |
| 125 | "acme: no Cloudflare zone found for %s", fqdn)) |
| 126 | return nil, nil |
| 127 | end |
| 128 | |
| 129 | -- Create a TXT record <fqdn> = <txt_value>. Returns zone_id, record_id |
| 130 | -- or nil, nil. Multiple TXT records at the same name are allowed, which |
| 131 | -- is what the apex + wildcard pair of a single cert needs. |
| 132 | local function dns_set_txt(fqdn, txt_value) |
| 133 | local zone, zone_id = cf_find_zone(fqdn) |
| 134 | if not zone_id then |
| 135 | return nil, nil |
| 136 | end |
| 137 | |
| 138 | local url = string.format("%s/zones/%s/dns_records", CF_API_URL, zone_id) |
| 139 | local body = string.format( |
| 140 | '{"type":"TXT","name":%s,"content":%s,"ttl":60,"comment":"haproxy acme dns-01"}', |
| 141 | json_str(fqdn), json_str(txt_value)) |
| 142 | |
| 143 | local hc = core.httpclient() |
| 144 | local res = hc:post({ url = url, headers = cf_headers(true), body = body }) |
| 145 | |
| 146 | -- 81058: an identical record already exists, typically left behind when |
| 147 | -- haproxy restarted mid-challenge. The CA will see it, so carry on. |
| 148 | if res and res.status == 400 and res.body and res.body:find('"code"%s*:%s*81058') then |
| 149 | core.log(core.notice, string.format( |
| 150 | "acme: TXT record already present: %s in zone %s (value=%s)", fqdn, zone, txt_value)) |
| 151 | return zone_id, "existing" |
| 152 | end |
| 153 | |
| 154 | if not res or res.status ~= 200 then |
| 155 | local status = res and res.status or "nil" |
| 156 | core.log(core.alert, string.format( |
| 157 | "acme: Cloudflare POST failed for %s (status=%s): %s", |
| 158 | fqdn, status, res and res.body or "")) |
| 159 | return nil, nil |
| 160 | end |
| 161 | |
| 162 | local rid = res.body and res.body:match('"result"%s*:%s*{.-"id"%s*:%s*"(%x+)"') |
| 163 | if not rid then |
| 164 | core.log(core.alert, string.format( |
| 165 | "acme: Cloudflare POST for %s succeeded but no record id in response", fqdn)) |
| 166 | return nil, nil |
| 167 | end |
| 168 | |
| 169 | core.log(core.notice, string.format( |
| 170 | "acme: TXT record set: %s in zone %s (id=%s value=%s)", fqdn, zone, rid, txt_value)) |
| 171 | return zone_id, rid |
| 172 | end |
| 173 | |
| 174 | -- Delete every TXT record at <fqdn> in <zone_id>. Records are listed |
| 175 | -- rather than remembered so ones orphaned by a restart are swept up too. |
| 176 | local function dns_del_txt(zone_id, fqdn) |
| 177 | local url = string.format("%s/zones/%s/dns_records?type=TXT&name=%s&per_page=100", |
| 178 | CF_API_URL, zone_id, fqdn) |
| 179 | |
| 180 | local hc = core.httpclient() |
| 181 | local res = hc:get({ url = url, headers = cf_headers(false) }) |
| 182 | if not res or res.status ~= 200 or not res.body then |
| 183 | core.log(core.alert, string.format( |
| 184 | "acme: Cloudflare list failed for %s (status=%s)", fqdn, res and res.status or "nil")) |
| 185 | return false |
| 186 | end |
| 187 | |
| 188 | local n = 0 |
| 189 | for rid in res.body:gmatch('"id"%s*:%s*"(%x+)"') do |
| 190 | if #rid == 32 and rid ~= zone_id then |
| 191 | local durl = string.format("%s/zones/%s/dns_records/%s", CF_API_URL, zone_id, rid) |
| 192 | local dres = core.httpclient():delete({ url = durl, headers = cf_headers(false) }) |
| 193 | if dres and dres.status == 200 then |
| 194 | n = n + 1 |
| 195 | else |
| 196 | core.log(core.alert, string.format( |
| 197 | "acme: Cloudflare DELETE failed for %s id=%s (status=%s)", |
| 198 | fqdn, rid, dres and dres.status or "nil")) |
| 199 | end |
| 200 | end |
| 201 | end |
| 202 | |
| 203 | core.log(core.notice, string.format("acme: %d TXT record(s) deleted: %s", n, fqdn)) |
| 204 | return true |
| 205 | end |
| 206 | |
| 207 | -- --------------------------------------------------------------------------- |
| 208 | -- Tasks |
| 209 | -- --------------------------------------------------------------------------- |
| 210 | |
| 211 | -- Track the names we wrote per cert path so they can be cleaned up. |
| 212 | -- deployed[crt][fqdn] = zone_id |
| 213 | local deployed = {} |
| 214 | |
| 215 | -- Spawn a background task per ACME_DEPLOY event to set the TXT record and |
| 216 | -- signal challenge readiness. |
| 217 | local function on_deploy(event, data, sub, when) |
| 218 | local crt = data.crtname |
| 219 | local domain = data.domain |
| 220 | local record = data.dns_record |
| 221 | |
| 222 | core.register_task(function() |
| 223 | local fqdn = challenge_fqdn(domain) |
| 224 | local zone_id, record_id = dns_set_txt(fqdn, record) |
| 225 | if not record_id then |
| 226 | core.log(core.alert, string.format( |
| 227 | "acme: aborting challenge for crt=%s domain=%s", crt, domain)) |
| 228 | return |
| 229 | end |
| 230 | |
| 231 | if not deployed[crt] then deployed[crt] = {} end |
| 232 | deployed[crt][fqdn] = zone_id |
| 233 | |
| 234 | -- An apex and its wildcard share one domain string, and haproxy marks |
| 235 | -- every matching authorization on the first call, so a later call for |
| 236 | -- the same name reports "not found". That is expected, not an error; |
| 237 | -- the challenge-ready delay covers the record we just wrote. |
| 238 | local ok, ret = pcall(ACME.challenge_ready, crt, domain) |
| 239 | if not ok then |
| 240 | if tostring(ret):find("not found") then |
| 241 | core.log(core.info, string.format( |
| 242 | "acme: crt=%s domain=%s already marked ready", crt, domain)) |
| 243 | else |
| 244 | core.log(core.alert, string.format( |
| 245 | "acme: challenge_ready error for crt=%s domain=%s: %s", crt, domain, ret)) |
| 246 | end |
| 247 | elseif ret == 0 then |
| 248 | core.log(core.notice, string.format( |
| 249 | "acme: all challenges ready for crt=%s, validation starting", crt)) |
| 250 | else |
| 251 | core.log(core.info, string.format( |
| 252 | "acme: crt=%s domain=%s ready, %d challenge(s) still pending", |
| 253 | crt, domain, ret)) |
| 254 | end |
| 255 | end) |
| 256 | end |
| 257 | |
| 258 | -- ACME_NEWCERT: remove the TXT records that were set for this certificate. |
| 259 | local function on_newcert(event, data, sub, when) |
| 260 | local crt = data.crtname |
| 261 | if not deployed[crt] then return end |
| 262 | |
| 263 | core.register_task(function() |
| 264 | for fqdn, zone_id in pairs(deployed[crt]) do |
| 265 | dns_del_txt(zone_id, fqdn) |
| 266 | end |
| 267 | deployed[crt] = nil |
| 268 | end) |
| 269 | end |
| 270 | |
| 271 | -- --------------------------------------------------------------------------- |
| 272 | -- Subscribe. The ACME event family arrived in HAProxy 3.5; on 3.4 the |
| 273 | -- subscription fails and dns-01 challenges must be answered by hand: |
| 274 | -- echo "@1; acme challenge_ready <crt> domain <domain>" \ |
| 275 | -- | nc -NU /var/run/haproxy.sock |
| 276 | -- The TXT value to set is logged by haproxy at notice level. |
| 277 | -- --------------------------------------------------------------------------- |
| 278 | local ok, err |
| 279 | if not CLOUDFLARE_DNS_API_TOKEN then |
| 280 | ok, err = false, "CLOUDFLARE_DNS_API_TOKEN is not set in the environment" |
| 281 | else |
| 282 | ok, err = pcall(core.event_sub, {"ACME_DEPLOY"}, on_deploy) |
| 283 | end |
| 284 | if ok then |
| 285 | core.event_sub({"ACME_NEWCERT"}, on_newcert) |
| 286 | core.log(core.info, "acme: Cloudflare dns-01 automation registered") |
| 287 | else |
| 288 | core.log(core.alert, string.format( |
| 289 | "acme: dns-01 automation disabled (%s); " |
| 290 | .. "answer challenges manually via 'acme challenge_ready' on the master CLI", |
| 291 | tostring(err))) |
| 292 | end |
| 293 |
dch / HAProxy ACME CloudFlare integration
Last active 8 hours ago
See https://people.freebsd.org/~dch/posts/2026-10-08-haproxy-acme/ Modelled after https://github.com/haproxy/haproxy/blob/master/examples/lua/acme-gandi-livedns.lua