Skip to content

feat: support dns-01 challenge #114

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 80 additions & 5 deletions lib/resty/acme/autossl.lua
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,28 @@ local default_config = {
-- if true, the request to nginx waits until the cert has been generated and it is used right away
blocking = false,
enabled_delete_not_whitelisted_domain = false,
-- the maps of domain and dns provider, dns provider could be reused in different domains
-- like: domain_dns_provider_mapping = { ["*.domain.com"] = "cloudflare_token_default", ["www.domain.com"] = "dynv6_token_default" }
domain_dns_provider_mapping = {},
-- one provider could have different key content defined by different names like 'cloudflare_token_1' and 'cloudflare_token_2'
dns_provider_keys = {
cloudflare_token_default = {
-- the module name in 'lib/resty/acme/dns_provider'
provider = "cloudflare",
-- the api token
content = ""
},
dynv6_token_default = {
provider = "dynv6",
content = ""
}
}
}

local domain_pkeys = {}

local domain_key_types, domain_key_types_count
local domain_whitelist, domain_whitelist_callback
local domain_whitelist, domain_whitelist_callback, wildcard_domain_matcher
local failure_cooloff_callback

--[[
Expand Down Expand Up @@ -369,6 +385,37 @@ function AUTOSSL.check_renew(premature)
end
end

local function build_wildcard_domain_matcher(domains)
local domains_wildcard = {}
local domains_wildcard_count = 0

if domains == nil or domains == ngx.null then
return false
end

for _, d in ipairs(domains) do
if string.sub(d, 1, 1) == "*" then
d = string.gsub(string.sub(d, 2), "%.", "\\.")
table.insert(domains_wildcard, d)
domains_wildcard_count = domains_wildcard_count + 1
end
end

local domains_pattern
if domains_wildcard_count > 0 then
domains_pattern = "(" .. table.concat(domains_wildcard, "|") .. ")$"
end

return setmetatable({}, {
__index = function(_, k)
if not domains_pattern then
return false
end
return ngx.re.match(k, domains_pattern, "jo")
end
})
end

function AUTOSSL.init(autossl_config, acme_config)
autossl_config = setmetatable(autossl_config or {}, { __index = default_config })

Expand Down Expand Up @@ -414,6 +461,13 @@ function AUTOSSL.init(autossl_config, acme_config)
acme_config.account_email = autossl_config.account_email
acme_config.enabled_challenge_handlers = autossl_config.enabled_challenge_handlers

acme_config.dns_provider_keys_mapping = acme_config.dns_provider_keys_mapping or {}
for domain, key_name in pairs(autossl_config.domain_dns_provider_mapping) do
if autossl_config.dns_provider_keys[key_name].content ~= "" then
acme_config.dns_provider_keys_mapping[domain] = autossl_config.dns_provider_keys[key_name]
end
end

acme_config.challenge_start_callback = function()
ngx.sleep(autossl_config.challenge_start_delay)
return true
Expand All @@ -428,6 +482,8 @@ function AUTOSSL.init(autossl_config, acme_config)
for _, w in ipairs(domain_whitelist) do
domain_whitelist[w] = true
end
-- build regex for wildcard domain
wildcard_domain_matcher = build_wildcard_domain_matcher(domain_whitelist)
end
domain_whitelist_callback = autossl_config.domain_whitelist_callback
if domain_whitelist_callback and type(domain_whitelist_callback) ~= "function" then
Expand Down Expand Up @@ -512,12 +568,20 @@ end

function AUTOSSL.is_domain_whitelisted(domain, is_new_cert_needed)
if domain_whitelist_callback then
-- domain_whitelist_callback always first
return domain_whitelist_callback(domain, is_new_cert_needed)
elseif domain_whitelist then
return domain_whitelist[domain]
end
if domain_whitelist[domain] then
-- exact match
return domain
else
return true
-- wildcard match
local result = wildcard_domain_matcher[domain]
if result then
return "*" .. result[1]
end
end
return false
end

function AUTOSSL.ssl_certificate()
Expand All @@ -530,7 +594,18 @@ function AUTOSSL.ssl_certificate()

domain = string.lower(domain)

if not AUTOSSL.is_domain_whitelisted(domain, false) then
local result = AUTOSSL.is_domain_whitelisted(domain, false)
if type(result) == "string" and result == domain then
-- exact match
log(ngx_DEBUG, "exact match in domain_whitelist: ", result)
elseif type(result) == "string" and string.sub(result, 1, 1) == "*" then
-- wildcard match
log(ngx_DEBUG, "wildcard match in domain_whitelist: ", result)
domain = result
elseif result then
-- domain_whitelist_callback
log(ngx_DEBUG, "match by domain_whitelist_callback: ", domain)
else
log(ngx_INFO, "domain ", domain, " not in whitelist, skipping")
return
end
Expand Down
151 changes: 151 additions & 0 deletions lib/resty/acme/challenge/dns-01.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
local util = require("resty.acme.util")
local digest = require("resty.openssl.digest")
local resolver = require("resty.dns.resolver")
local base64 = require("ngx.base64")
local cjson = require("cjson")
local log = util.log
local encode_base64url = base64.encode_base64url

local _M = {}
local mt = {__index = _M}

function _M.new(storage)
local self = setmetatable({
storage = storage,
-- dns_provider_keys_mapping = {
-- ["*.domain.com"] = {
-- provider = "cloudflare",
-- content = "token"
-- },
-- ["www.domain.com"] = {
-- provider = "dynv6",
-- content = "token"
-- }
-- }
dns_provider_keys_mapping = {}
}, mt)
return self
end

local function calculate_txt_record(keyauthorization)
local dgst = assert(digest.new("sha256"):final(keyauthorization))
local txt_record = encode_base64url(dgst)
log(ngx.DEBUG, "calculate txt record: ", txt_record)
return txt_record
end

local function ch_key(challenge)
return challenge .. "#dns-01"
end

local function choose_dns_provider(self, domain)
if not self.dns_provider_keys_mapping[domain] then
return nil, "not dns provider key for domain"
end
local provider = self.dns_provider_keys_mapping[domain].provider
if not provider then
return nil, "dns provider not support"
end
log(ngx.INFO, "using dns provider: ", provider, " for domain: ", domain)
local content = self.dns_provider_keys_mapping[domain].content
if not content or content == "" then
return nil, "dns provider key content is empty"
end
local ok, module = pcall(require, "resty.acme.dns_provider." .. provider)
if ok then
local handler, err = module.new(content)
if not err then
return handler
end
end
return nil, "require dns provider error: " .. provider
end

local function verify_txt_record(record_name, expected_record_content)
local r, err = resolver:new{
nameservers = {"8.8.8.8", "8.8.4.4"},
retrans = 5,
timeout = 2000,
no_random = true,
}
if not r then
return false, "failed to instantiate the resolver: " .. err
end
local answers, err, _ = r:query(record_name, { qtype = r.TYPE_TXT }, {})
if not answers then
return false, "failed to query the DNS server: " .. err
end
if answers.errcode then
return false, "server returned error code: " .. answers.errcode .. ": " .. (answers.errstr or "nil")
end
for _, ans in ipairs(answers) do
if ans.txt == expected_record_content then
log(ngx.DEBUG, "verify txt record ok: ", ans.name, ", content: ", ans.txt)
return true
end
end
return false, "txt record mismatch"
end

function _M:update_dns_provider_info(dns_provider_keys_mapping)
log(ngx.INFO, "update_dns_provider_info: " .. cjson.encode(dns_provider_keys_mapping))
self.dns_provider_keys_mapping = dns_provider_keys_mapping
end

function _M:register_challenge(_, response, domains)
local dnsapi, err
for _, domain in ipairs(domains) do
err = self.storage:set(ch_key(domain), response, 3600)
if err then
return err
end
dnsapi, err = choose_dns_provider(self, domain)
if err then
return err
end
local txt_record_name = "_acme-challenge." .. domain:gsub("*.", "")
local txt_record_content = calculate_txt_record(response)
local result, err = dnsapi:post_txt_record(txt_record_name, txt_record_content)
if err then
return err
end
log(ngx.INFO,
"dns provider post_txt_record returns: ", result,
", now waiting for dns record propagation")
local wait_verify_counts = 0
while true do
local ok, err = verify_txt_record(txt_record_name, txt_record_content)
if ok then
break
end
log(ngx.DEBUG, "unable to verify txt record, last error was: ", err, ", retrying in 5 seconds")
ngx.sleep(5)
wait_verify_counts = wait_verify_counts + 1
if wait_verify_counts >= 60 then
return "timeout (5m) exceeded to verify txt record, latest error was: " .. (err or "nil")
end
end
end
end

function _M:cleanup_challenge(_--[[challenge]], domains)
local dnsapi, err
for _, domain in ipairs(domains) do
err = self.storage:delete(ch_key(domain))
if err then
return err
end
dnsapi, err = choose_dns_provider(self, domain)
if err then
return err
end
local trim_domain = domain:gsub("*.", "")
local result, err = dnsapi:delete_txt_record("_acme-challenge." .. trim_domain)
if err then
return err
end
log(ngx.DEBUG, "dns provider delete_txt_record returns: ", result)
end
end

return _M
18 changes: 17 additions & 1 deletion lib/resty/acme/client.lua
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ local default_config = {
preferred_chain = nil,
-- callback function that allows to wait before signaling ACME server to validate
challenge_start_callback = nil,
-- dns provider keys mapping for dns-01 challenge, contains provider and keys detail
-- like: dns_provider_keys_mapping = {
-- ["*.domain.com"] = {
-- provider = "cloudflare",
-- content = "token"
-- },
-- ["www.domain.com"] = {
-- provider = "dynv6",
-- content = "token"
-- }
-- }
dns_provider_keys_mapping = {}
}

local function new_httpc()
Expand Down Expand Up @@ -90,7 +102,8 @@ function _M.new(conf)
eab_handler = conf.eab_handler,
eab_kid = conf.eab_kid,
eab_hmac_key = decode_base64url(conf.eab_hmac_key),
challenge_handlers = {}
challenge_handlers = {},
dns_provider_keys_mapping = conf.dns_provider_keys_mapping,
}, mt
)

Expand All @@ -114,6 +127,9 @@ function _M.new(conf)
for _, c in ipairs(conf.enabled_challenge_handlers) do
local handler = require("resty.acme.challenge." .. c)
self.challenge_handlers[c] = handler.new(self.storage)
if c == "dns-01" then
self.challenge_handlers[c]:update_dns_provider_info(self.dns_provider_keys_mapping)
end
end

if conf.account_key then
Expand Down
Loading
Loading