dch

dch / HAProxy ACME CloudFlare integration

Last active 9 hours ago

Like 0

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

Revision 412ca0e9d2b8da5083800a93d716116014223d95

acme-cloudflare.lua.j2 Raw
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.skunkwerks.at CNAME <hash>.indieacme.org), TXT records
6-- are written to the delegated target instead. The API token then only
7-- needs Zone:DNS:Edit on indieacme.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.
32local CLOUDFLARE_DNS_API_TOKEN = os.getenv("CLOUDFLARE_DNS_API_TOKEN")
33
34local 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-- ---------------------------------------------------------------------------
43local CHALLENGE_DELEGATE = {
44{% for domain, target in haproxy_acme_challenge_delegate.items() %}
45 ["{{ domain }}"] = "{{ target }}",
46{% endfor %}
47}
48
49-- ---------------------------------------------------------------------------
50-- helpers
51-- ---------------------------------------------------------------------------
52
53local 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
62end
63
64-- Minimal JSON escaping for the few string values we send.
65local function json_str(s)
66 return '"' .. s:gsub('[%c"\\]', function(c)
67 return string.format("\\u%04x", c:byte())
68 end) .. '"'
69end
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.
74local 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
82end
83
84-- zone name -> zone id, filled lazily
85local 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.
90local 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
127end
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.
132local 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
172end
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.
176local 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
205end
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
213local deployed = {}
214
215-- Spawn a background task per ACME_DEPLOY event to set the TXT record and
216-- signal challenge readiness.
217local 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)
256end
257
258-- ACME_NEWCERT: remove the TXT records that were set for this certificate.
259local 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)
269end
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-- ---------------------------------------------------------------------------
278local ok, err
279if not CLOUDFLARE_DNS_API_TOKEN then
280 ok, err = false, "CLOUDFLARE_DNS_API_TOKEN is not set in the environment"
281else
282 ok, err = pcall(core.event_sub, {"ACME_DEPLOY"}, on_deploy)
283end
284if ok then
285 core.event_sub({"ACME_NEWCERT"}, on_newcert)
286 core.log(core.info, "acme: Cloudflare dns-01 automation registered")
287else
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)))
292end
293
294