-- 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)