]> git.ipfire.org Git - thirdparty/haproxy.git/commitdiff
EXAMPLES: lua/acme: add a dns-01 handler for Gandi LiveDNS API
authorWilliam Lallemand <wlallemand@haproxy.com>
Thu, 11 Jun 2026 17:34:15 +0000 (19:34 +0200)
committerWilliam Lallemand <wlallemand@haproxy.com>
Thu, 11 Jun 2026 17:37:49 +0000 (19:37 +0200)
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.

examples/lua/acme-gandi-livedns.lua [new file with mode: 0644]

diff --git a/examples/lua/acme-gandi-livedns.lua b/examples/lua/acme-gandi-livedns.lua
new file mode 100644 (file)
index 0000000..0d1799f
--- /dev/null
@@ -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)