mirror of
https://github.com/haproxy/haproxy.git
synced 2026-06-15 12:30:16 -04:00
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.
162 lines
5.8 KiB
Lua
162 lines
5.8 KiB
Lua
-- 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)
|