diff --git a/examples/lua/acme-gandi-livedns.lua b/examples/lua/acme-gandi-livedns.lua new file mode 100644 index 000000000..0d1799f76 --- /dev/null +++ b/examples/lua/acme-gandi-livedns.lua @@ -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 to . +-- 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 and . +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)