EXAMPLES: lua/acme: add a dns-01 handler for Gandi LiveDNS API

This Lua script automates dns-01 ACME challenges using the Gandi LiveDNS
API v5. It subscribes to the ACME_DEPLOY event to set the required
_acme-challenge TXT record via the Gandi REST API, signals HAProxy that
the challenge is ready using ACME.challenge_ready(), then cleans up the
TXT record once the certificate is issued on ACME_NEWCERT.

The API key is read from the GANDI_API_KEY environment variable at
startup. Zone discovery is automatic: the script probes parent zones
from longest to shortest until Gandi accepts the record, which handles
both apex and wildcard certificates transparently.
This commit is contained in:
William Lallemand 2026-06-11 19:34:15 +02:00
parent 4bb21dae2f
commit d2c9bf70e5

View file

@ -0,0 +1,162 @@
-- ACME dns-01 automation via event_hdl callbacks using the Gandi LiveDNS API v5
--
-- HAProxy Configuration:
--
-- global
-- expose-experimental-directives
-- tune.lua.bool-sample-conversion normal
-- lua-load examples/lua/acme-gandi-livedns.lua
-- log stderr local0
--
-- acme LE
-- directory https://acme-staging-v02.api.letsencrypt.org/directory
-- contact foobar@example.com
-- challenge dns-01
-- challenge-ready cli,dns
--
-- crt-store
-- load crt foobar.pem acme letsencrypt LE *.foobar.example.com
--
-- Start HAProxy with the GANDI_API_KEY variable:
--
-- GANDI_API_KEY=fer89wf498w4f98we74f98wwiw787f8we4f8 ./haproxy -W -f haproxy.cfg
--
-- Gandi Personal Access Token (https://account.gandi.net -> Security -> Personal Access Tokens).
-- Set the GANDI_API_KEY environment variable before starting HAProxy.
local GANDI_API_KEY = os.getenv("GANDI_API_KEY") or error("GANDI_API_KEY environment variable is not set")
-- Gandi LiveDNS API base URL.
local GANDI_API_URL = "https://api.gandi.net/v5/livedns"
-- ---------------------------------------------------------------------------
-- Gandi LiveDNS helpers
-- ---------------------------------------------------------------------------
-- Try to set the _acme-challenge TXT record for <domain> to <txt_value>.
-- Probes each possible parent zone (longest first) until Gandi accepts one.
-- Returns the zone and record name on success, or nil on failure.
local function dns_set_txt(domain, txt_value)
local labels = {}
for label in domain:gmatch("[^.]+") do
labels[#labels + 1] = label
end
for i = 1, #labels - 1 do
local zone = table.concat(labels, ".", i + 1)
local name = "_acme-challenge." .. table.concat(labels, ".", 1, i)
local url = string.format("%s/domains/%s/records/%s/TXT", GANDI_API_URL, zone, name)
local body = string.format('{"rrset_values":["%s"],"rrset_ttl":300}', txt_value)
core.log(core.debug, string.format("acme: trying PUT %s", url))
-- Remove any stale TXT record first so the new value propagates cleanly.
local hc_del = core.httpclient()
hc_del:delete({
url = url,
headers = { ["Authorization"] = { "Bearer " .. GANDI_API_KEY } },
})
local hc = core.httpclient()
local res = hc:put({
url = url,
headers = {
["Authorization"] = { "Bearer " .. GANDI_API_KEY },
["Content-Type"] = { "application/json" },
},
body = body,
})
if res and (res.status == 200 or res.status == 201) then
core.log(core.notice, string.format(
"acme: TXT record set: %s in zone %s", name, zone))
return zone, name
end
end
core.log(core.alert, string.format(
"acme: failed to set TXT record for _acme-challenge.%s: no valid zone found", domain))
return nil, nil
end
-- Deletes the TXT record identified by <zone> and <name>.
local function dns_del_txt(zone, name)
local url = string.format("%s/domains/%s/records/%s/TXT", GANDI_API_URL, zone, name)
core.log(core.notice, string.format("acme: DELETE %s", url))
local hc = core.httpclient()
local res = hc:delete({
url = url,
headers = {
["Authorization"] = { "Bearer " .. GANDI_API_KEY },
},
})
if not res or res.status ~= 204 then
local status = res and res.status or "nil"
core.log(core.alert, string.format(
"acme: Gandi DELETE failed for %s/%s (status=%s)", zone, name, status))
return false
end
core.log(core.notice, string.format(
"acme: TXT record deleted: %s in zone %s", name, zone))
return true
end
-- ---------------------------------------------------------------------------
-- Tasks
-- ---------------------------------------------------------------------------
-- Track deployed TXT records per cert path so they can be cleaned up.
-- deployed[crt][domain] = { zone = ..., name = ... }
local deployed = {}
-- Spawn a background task per ACME_DEPLOY event to set the TXT record and
-- signal challenge readiness. Using register_task keeps HTTP calls in a
-- plain task context.
core.event_sub({"ACME_DEPLOY"}, function(event, data, sub, when)
local crt = data.crtname
local domain = data.domain
local record = data.dns_record
core.register_task(function()
local zone, name = dns_set_txt(domain, record)
if not zone then
core.log(core.alert, string.format(
"acme: aborting challenge for crt=%s domain=%s", crt, domain))
return
end
-- Remember this record for cleanup on ACME_NEWCERT.
if not deployed[crt] then deployed[crt] = {} end
deployed[crt][domain] = { zone = zone, name = name }
-- Signal HAProxy that the dns-01 challenge for this domain is ready.
local ok, ret = pcall(ACME.challenge_ready, crt, domain)
if not ok then
core.log(core.alert, string.format(
"acme: challenge_ready error for crt=%s domain=%s: %s", crt, domain, ret))
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.
core.event_sub({"ACME_NEWCERT"}, function(event, data, sub, when)
local crt = data.crtname
if not deployed[crt] then return end
core.register_task(function()
for _, rec in pairs(deployed[crt]) do
dns_del_txt(rec.zone, rec.name)
end
deployed[crt] = nil
end)
end)