diff --git a/lib/resty/acme/autossl.lua b/lib/resty/acme/autossl.lua index 348d224..092c818 100644 --- a/lib/resty/acme/autossl.lua +++ b/lib/resty/acme/autossl.lua @@ -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 --[[ @@ -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 }) @@ -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 @@ -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 @@ -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() @@ -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 diff --git a/lib/resty/acme/challenge/dns-01.lua b/lib/resty/acme/challenge/dns-01.lua new file mode 100644 index 0000000..ca9f564 --- /dev/null +++ b/lib/resty/acme/challenge/dns-01.lua @@ -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 diff --git a/lib/resty/acme/client.lua b/lib/resty/acme/client.lua index e8cb7d7..1caf4c8 100644 --- a/lib/resty/acme/client.lua +++ b/lib/resty/acme/client.lua @@ -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() @@ -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 ) @@ -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 diff --git a/lib/resty/acme/dns_provider/cloudflare.lua b/lib/resty/acme/dns_provider/cloudflare.lua new file mode 100644 index 0000000..b889d8f --- /dev/null +++ b/lib/resty/acme/dns_provider/cloudflare.lua @@ -0,0 +1,165 @@ +local http = require("resty.http") +local cjson = require("cjson") + +local _M = {} +local mt = {__index = _M} + +function _M.new(token) + if not token or token == "" then + return nil, "api token is needed" + end + + local self = setmetatable({ + endpoint = "https://api.cloudflare.com/client/v4", + httpc = nil, + token = token, + zone = nil, + headers = { + ["Content-Type"] = "application/json", + ["Authorization"] = "Bearer " .. token, + } + }, mt) + + self.httpc = http.new() + return self +end + +local function get_zone_id(self, fqdn) + local url = self.endpoint .. "/zones" + local resp, err = self.httpc:request_uri(url, + { + method = "GET", + headers = self.headers + } + ) + if err then + return nil, err + end + + if resp and resp.status == 200 then + -- { + -- "result": + -- [{ + -- "id":"12345abcde", + -- "name":"domain.com", + -- ... + -- }], + -- "result_info":{"page":1,"per_page":20,"total_pages":1,"count":1,"total_count":1}, + -- "success":true, + -- "errors":[], + -- "messages":[] + -- } + local body = cjson.decode(resp.body) + if not body then + return nil, "json decode error" + end + + for _, zone in ipairs(body.result) do + local start, _, err = fqdn:find(zone.name, 1, true) + if err then + return nil, err + end + if start then + self.zone = zone.name + return zone.id + end + end + end + + return nil, "no matched dns zone found" +end + +function _M:post_txt_record(fqdn, content) + local zone_id, err = get_zone_id(self, fqdn) + if err then + return nil, err + end + local url = self.endpoint .. "/zones/" .. zone_id .. "/dns_records" + local body = { + ["type"] = "TXT", + ["name"] = fqdn, + ["content"] = content + } + local resp, err = self.httpc:request_uri(url, + { + method = "POST", + headers = self.headers, + body = cjson.encode(body) + } + ) + if err then + return nil, err + end + + return resp.status +end + +local function get_record_id(self, zone_id, fqdn) + local url = self.endpoint .. "/zones/" .. zone_id .. "/dns_records" + local resp, err = self.httpc:request_uri(url, + { + method = "GET", + headers = self.headers + } + ) + if err then + return nil, err + end + + if resp and resp.status == 200 then + -- { + -- "result": + -- [{ + -- "id":"12345abcdefghti", + -- "zone_id":"12345abcde", + -- "zone_name":"domain.com", + -- "name":"_acme-challenge.domain.com", + -- "type":"TXT", + -- "content":"record_content", + -- ... + -- }], + -- "success":true, + -- "errors":[], + -- "messages":[], + -- "result_info":{"page":1,"per_page":100,"count":1,"total_count":1,"total_pages":1} + -- } + local body = cjson.decode(resp.body) + if not body then + return nil, "json decode error" + end + + for _, record in ipairs(body.result) do + if fqdn == record.name then + return record.id + end + end + end + + return nil, "no matched dns record found" +end + +function _M:delete_txt_record(fqdn) + local zone_id, err = get_zone_id(self, fqdn) + if err then + return nil, err + end + local record_id, err = get_record_id(self, zone_id, fqdn) + if err then + return nil, err + end + local url = self.endpoint .. "/zones/" .. zone_id .. "/dns_records/" .. record_id + local resp, err = self.httpc:request_uri(url, + { + method = "DELETE", + headers = self.headers + } + ) + if err then + return nil, err + end + + -- return 200 ok + return resp.status +end + +return _M diff --git a/lib/resty/acme/dns_provider/dynv6.lua b/lib/resty/acme/dns_provider/dynv6.lua new file mode 100644 index 0000000..9a35fc7 --- /dev/null +++ b/lib/resty/acme/dns_provider/dynv6.lua @@ -0,0 +1,160 @@ +local http = require("resty.http") +local cjson = require("cjson") + +local _M = {} +local mt = {__index = _M} + +function _M.new(token) + if not token or token == "" then + return nil, "api token is needed" + end + + local self = setmetatable({ + endpoint = "https://dynv6.com/api/v2", + httpc = nil, + token = token, + zone = nil, + headers = { + ["Content-Type"] = "application/json", + ["Authorization"] = "Bearer " .. token, + } + }, mt) + + self.httpc = http.new() + return self +end + +local function get_zone_id(self, fqdn) + local url = self.endpoint .. "/zones" + local resp, err = self.httpc:request_uri(url, + { + method = "GET", + headers = self.headers + } + ) + if err then + return nil, err + end + + if resp and resp.status == 200 then + -- [{ + -- "name":"domain.dynv6.net", + -- "ipv4address":"", + -- "ipv6prefix":"", + -- "id":1, + -- "createdAt":"2022-08-14T17:32:57+02:00", + -- "updatedAt":"2022-08-14T17:32:57+02:00" + -- }] + local body = cjson.decode(resp.body) + if not body then + return nil, "json decode error" + end + + for _, zone in ipairs(body) do + local start, _, err = fqdn:find(zone.name, 1, true) + if err then + return nil, err + end + if start then + self.zone = zone.name + return zone.id + end + end + end + + return nil, "no matched dns zone found" +end + +function _M:post_txt_record(fqdn, content) + local zone_id, err = get_zone_id(self, fqdn) + if err then + return nil, err + end + local url = self.endpoint .. "/zones/" .. zone_id .. "/records" + local body = { + ["type"] = "TXT", + ["name"] = fqdn:gsub("." .. self.zone, ""), + ["data"] = content + } + local resp, err = self.httpc:request_uri(url, + { + method = "POST", + headers = self.headers, + body = cjson.encode(body) + } + ) + if err then + return nil, err + end + + return resp.status +end + +local function get_record_id(self, zone_id, fqdn) + local url = self.endpoint .. "/zones/" .. zone_id .. "/records" + local resp, err = self.httpc:request_uri(url, + { + method = "GET", + headers = self.headers + } + ) + if err then + return nil, err + end + + if resp and resp.status == 200 then + -- [{ + -- "type":"TXT", + -- "name":"_acme-challenge", + -- "data":"record_content", + -- "priority":null, + -- "flags":null, + -- "tag":null, + -- "weight":null, + -- "port":null, + -- "id":1, + -- "zoneID":1 + -- }] + local body = cjson.decode(resp.body) + if not body then + return nil, "json decode error" + end + + for _, record in ipairs(body) do + local start, _, err = fqdn:find(record.name, 1, true) + if err then + return nil, err + end + if start then + return record.id + end + end + end + + return nil, "no matched dns record found" +end + +function _M:delete_txt_record(fqdn) + local zone_id, err = get_zone_id(self, fqdn) + if err then + return nil, err + end + local record_id, err = get_record_id(self, zone_id, fqdn) + if err then + return nil, err + end + local url = self.endpoint .. "/zones/" .. zone_id .. "/records/" .. record_id + local resp, err = self.httpc:request_uri(url, + { + method = "DELETE", + headers = self.headers + } + ) + if err then + return nil, err + end + -- return 204 not content + return resp.status +end + +return _M