From a184a566520e578d05c25776accfe9554340c03d Mon Sep 17 00:00:00 2001 From: Wangchong Zhou Date: Wed, 22 May 2024 16:24:30 +0800 Subject: [PATCH 1/4] fix(*) cleanup API for dns-01 challenge --- README.md | 83 +++++++++++++++------ lib/resty/acme/autossl.lua | 65 +++++++++-------- lib/resty/acme/challenge/dns-01.lua | 84 +++++++++++++++------- lib/resty/acme/client.lua | 16 ++--- lib/resty/acme/dns_provider/cloudflare.lua | 53 +++++++++----- lib/resty/acme/dns_provider/dynv6.lua | 50 ++++++++----- 6 files changed, 230 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 7fa318f..3a50f40 100644 --- a/README.md +++ b/README.md @@ -139,14 +139,31 @@ with the fallback certificate. Note that `domain_whitelist` or `domain_whitelist_callback` must be set to include your domain that you wish to server autossl, to prevent potential abuse using fake SNI in SSL handshake. -`domain_whitelist` defines a table that includes all domains should be included, and -`domain_whitelist_callback` defines a function that accepts domain as parameter and return -boolean to indicate if it should be included. + +`domain_whitelist` defines a table that includes all domains should be included and the CN to be +used to create cert for. Only a single `*` is allowed as a wildcard. + ```lua -domain_whitelist = { "domain1.com", "domain2.com", "domain3.com" }, +domain_whitelist = { "domain1.com", "domain2.com", "domain3.com", "*.domain4.com" }, ``` -To match a pattern in your domain name, for example all subdomains under `example.com`, use: +## Wildcard certificates + +To enable this library to create wildcard certificate, the following requirements must be met: + +- The wildcard domain appear exactly as `*.somedomain.com` in `domain_whitelist`. +- `dns-01` challenge is enabled and a dns provider that has `domains` matching the domain is configured. + +Otherwise a non-wildcard certificate will be created as fallback. + +## Advanced Usage + +### Use a function to include domains + +`domain_whitelist_callback` defines a function that accepts domain as parameter and return +boolean to indicate if it should be included. + +To match a pattern in your domain name, for example **all** subdomains under `example.com`, use: ```lua domain_whitelist_callback = function(domain, is_new_cert_needed) @@ -177,6 +194,8 @@ end}), `domain_whitelist_callback` function is provided with a second argument, which indicates whether the certificate is about to be served on incoming HTTP request (false) or new certificate is about to be requested (true). This allows to use cached values on hot path (serving requests) while fetching fresh data from storage for new certificates. One may also implement different logic, e.g. do extra checks before requesting new cert. +### Define failure cooloff period + In case of certificate request failure one may want to prevent ACME client to request another certificate immediatelly. By default, the cooloff period it is set to 300 seconds (5 minutes). It may be customized with `failure_cooloff` or with `failure_cooloff_callback` function, e.g. to implement exponential backoff. ```lua @@ -348,6 +367,8 @@ DNS-01 challenge is supported on lua-resty-acme > 0.13.0. Currently, following D - Cloudflare - Dynv6 +To read to how to extend a new DNS provider to work with `dns-01` challenge, see [DNS provider](#dns-providers). + An example config to use `dns-01` challenge would be: ```lua @@ -365,19 +386,18 @@ require("resty.acme.autossl").init({ account_email = "youemail@youdomain.com", domain_whitelist = { "example.com", "subdomain.anotherdomain.com" }, - domain_dns_provider_mapping = { - ["example.com"] = "cloudflare_account_1", - ["*.anotherdomain.com"] = "dynv6_account_1", - }, - - dns_provider_keys = { - cloudflare_account_1 = { + dns_provider_accounts = { + { + name = "cloudflare_prod", provider = "cloudflare", - content = "apikey of cloudflare", + secret = "apikey of cloudflare", + domains = { "example.com" }, }, - dynv6_account_1 = { + { + name = "dynv6_staging", provider = "dynv6", - content = "apikey of dynv6", + secret = "apikey of dynv6", + domains = { "*.anotherdomain.com" }, }, }, }) @@ -523,14 +543,8 @@ default_config = { preferred_chain = nil, -- callback function that allows to wait before signaling ACME server to validate challenge_start_callback = nil, - -- the maps of domain name to the dns_provider name defined in the dns_provider_keys table - domain_dns_provider_mapping = {}, -- the dict of dns providers, each provider should have following struct: - -- { - -- provider = "provider_name", -- "cloudflare" or "dynv6" - -- content = "the api key or token", - -- } - dns_provider_keys = {}, + dns_provider_accounts = {}, } ``` @@ -763,6 +777,31 @@ Etcd storage requires [lua-resty-etcd](https://github.com/api7/lua-resty-etcd) l It can be manually installed with `opm install api7/lua-resty-etcd` or `luarocks install lua-resty-etcd`. +## DNS providers + +TO create a custom DNS provider, follow these steps: + +- Create a file like `route53.lua` under `lib/resty/acme/dns_provider` +- Implement following function signature + +```lua +function _M.new(token) + -- ... + return self +end + +function _M:post_txt_record(fqdn, content) + return ok, err +end + +function _M:delete_txt_record(fqdn) + return ok, err +end +``` + +Where `token` is the apikey, `fqdn` is the DNS record name to set record, and `content` is the value of the record. + + TODO ==== - autossl: ocsp staping diff --git a/lib/resty/acme/autossl.lua b/lib/resty/acme/autossl.lua index af9f822..efe321a 100644 --- a/lib/resty/acme/autossl.lua +++ b/lib/resty/acme/autossl.lua @@ -60,20 +60,20 @@ local default_config = { blocking = false, -- if true, the certificate for domain not in whitelist will be deleted from storage enabled_delete_not_whitelisted_domain = false, - -- the maps of domain name to the dns_provider name defined in the dns_provider_keys table; domain name supports wildcard - domain_dns_provider_mapping = {}, -- the dict of dns providers, each provider should have following struct: -- { + -- name = "prod_account", -- provider = "provider_name", -- "cloudflare" or "dynv6" - -- content = "the api key or token", + -- secret = "the api key or token", + -- domains = { "example.com", "*.example.com" }, -- the list of domains that can be used with this provider -- } - dns_provider_keys = {}, + dns_provider_accounts = {}, } local domain_pkeys = {} local domain_key_types, domain_key_types_count -local domain_whitelist, domain_whitelist_callback, wildcard_domain_matcher +local domain_whitelist, domain_whitelist_callback, domain_wildcard_matcher local failure_cooloff_callback --[[ @@ -212,6 +212,7 @@ local function update_cert_handler(data) ngx.update_time() log(ngx_INFO, ngx.now() - t, "s spent in creating new ", typ, " private key") end + local cert, err = AUTOSSL.client:order_certificate(pkey, domain) if err then log(ngx_ERR, "error updating cert for ", domain, " err: ", err) @@ -378,7 +379,7 @@ function AUTOSSL.check_renew(premature) end end -local function build_wildcard_domain_matcher(domains) +local function build_domain_wildcard_matcher(domains) local domains_wildcard = {} local domains_wildcard_count = 0 @@ -452,9 +453,16 @@ function AUTOSSL.init(autossl_config, acme_config) acme_config.api_uri = "https://acme-staging-v02.api.letsencrypt.org/directory" end acme_config.account_email = autossl_config.account_email - acme_config.enabled_challenge_handlers = autossl_config.enabled_challenge_handlers - acme_config.domain_dns_provider_mapping = autossl_config.domain_dns_provider_mapping - acme_config.dns_provider_keys = autossl_config.dns_provider_keys + acme_config.dns_provider_accounts = autossl_config.dns_provider_accounts + + local ech = autossl_config.enabled_challenge_handlers + for _, ch in ipairs(ech) do + if ch == "dns-01" then + AUTOSSL.dns_01_enabled = true + break + end + end + acme_config.enabled_challenge_handlers = ech acme_config.challenge_start_callback = function() ngx.sleep(autossl_config.challenge_start_delay) @@ -471,7 +479,7 @@ function AUTOSSL.init(autossl_config, acme_config) domain_whitelist[w] = true end -- build regex for wildcard domain - wildcard_domain_matcher = build_wildcard_domain_matcher(domain_whitelist) + domain_wildcard_matcher = build_domain_wildcard_matcher(domain_whitelist) end domain_whitelist_callback = autossl_config.domain_whitelist_callback if domain_whitelist_callback and type(domain_whitelist_callback) ~= "function" then @@ -557,14 +565,14 @@ 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) + return domain_whitelist_callback(domain, is_new_cert_needed) and domain or false end - if domain_whitelist[domain] then + if domain_whitelist and domain_whitelist[domain] then -- exact match return domain - else + elseif domain_wildcard_matcher then -- wildcard match - local result = wildcard_domain_matcher[domain] + local result = domain_wildcard_matcher[domain] if result then return "*" .. result[1] end @@ -580,24 +588,25 @@ function AUTOSSL.ssl_certificate() return end - domain = string.lower(domain) + local orig_domain = string.lower(domain) + domain = AUTOSSL.is_domain_whitelisted(orig_domain, false) - 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") + if not domain then + log(ngx_INFO, "domain ", orig_domain, " not in whitelist, skipping") return end + if domain ~= orig_domain then + if AUTOSSL.dns_01_enabled then + log(ngx_INFO, "domain ", orig_domain, " is matched to ", domain, " in certificate") + else + log(ngx_INFO, orig_domain, " matched to wildcard ", domain, " but dns-01 is not enabled, ", + "non-wildcard cert will be created") + domain = orig_domain + end + + end + local chains_set_count = 0 local chains_set = {} diff --git a/lib/resty/acme/challenge/dns-01.lua b/lib/resty/acme/challenge/dns-01.lua index ca9f564..edbf9c2 100644 --- a/lib/resty/acme/challenge/dns-01.lua +++ b/lib/resty/acme/challenge/dns-01.lua @@ -2,7 +2,6 @@ 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 @@ -12,17 +11,18 @@ local mt = {__index = _M} function _M.new(storage) local self = setmetatable({ storage = storage, - -- dns_provider_keys_mapping = { + -- dns_provider_accounts_mapping = { -- ["*.domain.com"] = { -- provider = "cloudflare", - -- content = "token" + -- secret = "token" -- }, -- ["www.domain.com"] = { -- provider = "dynv6", - -- content = "token" + -- secret = "token" -- } -- } - dns_provider_keys_mapping = {} + dns_provider_accounts_mapping = {}, + dns_provider_modules = {}, }, mt) return self end @@ -30,7 +30,6 @@ 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 @@ -39,26 +38,25 @@ local function ch_key(challenge) 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" + local prov = self.dns_provider_accounts_mapping[domain] + if not prov then + return nil, "no dns provider key configured for domain " .. domain end - local provider = self.dns_provider_keys_mapping[domain].provider - if not provider then - return nil, "dns provider not support" + + if not prov.provider or not prov.secret then + return nil, "provider config malformed for domain " .. domain 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" + + local module = self.dns_provider_modules[prov.provider] + if not module then + return nil, "provider " .. prov.provider .. " is not loaded for domain " .. domain 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 + + local handler, err = module.new(prov.secret) + if not err then + return handler end - return nil, "require dns provider error: " .. provider + return nil, "dns provider init error: " .. err .. " for domain " .. domain end local function verify_txt_record(record_name, expected_record_content) @@ -87,9 +85,36 @@ local function verify_txt_record(record_name, expected_record_content) 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 +function _M:update_dns_provider_info(dns_provider_accounts) + self.dns_provider_accounts_mapping = {} + self.dns_provider_modules = {} + + for i, account in ipairs(dns_provider_accounts) do + if not account.name then + return nil, "#" .. i .. " element in dns_provider_accounts doesn't have a name" + end + if not account.secret then + return nil, "dns provider account " .. account.name .." doesn't have a secret" + end + if not account.provider then + return nil, "dns provider account " .. account.name .." doesn't have a provider" + end + + if not self.dns_provider_modules[account.provider] then + local ok, perr = pcall(require, "resty.acme.dns_provider." .. account.provider) + if not ok then + return nil, "dns provider " .. account.provider .. " failed to load: " .. perr + end + + self.dns_provider_modules[account.provider] = perr + end + + for _, domain in ipairs(account.domains) do + self.dns_provider_accounts_mapping[domain] = account + end + end + + return true end function _M:register_challenge(_, response, domains) @@ -103,15 +128,20 @@ function _M:register_challenge(_, response, domains) 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) + log(ngx.DEBUG, "calculated txt record: ", txt_record_content, " for domain: ", domain) + + local _, 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) @@ -125,6 +155,8 @@ function _M:register_challenge(_, response, domains) return "timeout (5m) exceeded to verify txt record, latest error was: " .. (err or "nil") end end + + log(ngx.INFO, "txt record for ", txt_record_name, " verified, continue to next domain") end end diff --git a/lib/resty/acme/client.lua b/lib/resty/acme/client.lua index 25dba0b..1173be9 100644 --- a/lib/resty/acme/client.lua +++ b/lib/resty/acme/client.lua @@ -54,10 +54,8 @@ local default_config = { preferred_chain = nil, -- callback function that allows to wait before signaling ACME server to validate challenge_start_callback = nil, - -- the maps of domain name to the dns_provider name defined in the dns_provider_keys table; domain name supports wildcard - domain_dns_provider_mapping = {}, -- the dict of dns providers, each provider should have following struct: - dns_provider_keys = {}, + dns_provider_accounts = {}, } local function new_httpc() @@ -95,7 +93,6 @@ function _M.new(conf) eab_kid = conf.eab_kid, eab_hmac_key = decode_base64url(conf.eab_hmac_key), challenge_handlers = {}, - dns_provider_keys_mapping = {}, }, mt ) @@ -115,18 +112,15 @@ function _M.new(conf) return nil, "at least one challenge handler is needed" end - for domain, key_name in pairs(conf.domain_dns_provider_mapping) do - if conf.dns_provider_keys[key_name].content ~= "" then - self.dns_provider_keys_mapping[domain] = conf.dns_provider_keys[key_name] - end - end - -- TODO: catch error and return gracefully 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) + local ok, err = self.challenge_handlers[c]:update_dns_provider_info(self.conf.dns_provider_accounts) + if not ok then + return nil, err + end end end diff --git a/lib/resty/acme/dns_provider/cloudflare.lua b/lib/resty/acme/dns_provider/cloudflare.lua index b889d8f..53a3d04 100644 --- a/lib/resty/acme/dns_provider/cloudflare.lua +++ b/lib/resty/acme/dns_provider/cloudflare.lua @@ -54,6 +54,8 @@ local function get_zone_id(self, fqdn) return nil, "json decode error" end + ngx.log(ngx.DEBUG, "[cloudflare] find zone ", fqdn, " in ", resp.body) + for _, zone in ipairs(body.result) do local start, _, err = fqdn:find(zone.name, 1, true) if err then @@ -61,9 +63,12 @@ local function get_zone_id(self, fqdn) end if start then self.zone = zone.name + ngx.log(ngx.DEBUG, "[cloudflare] zone id is ", zone.id, " for domain ", fqdn) return zone.id end end + else + return nil, "get_zone_id: cloudflare returned non 200 status: " .. resp.status .. " body: " .. resp.body end return nil, "no matched dns zone found" @@ -72,7 +77,7 @@ end function _M:post_txt_record(fqdn, content) local zone_id, err = get_zone_id(self, fqdn) if err then - return nil, err + return nil, "post_txt_record: " .. err end local url = self.endpoint .. "/zones/" .. zone_id .. "/dns_records" local body = { @@ -91,10 +96,17 @@ function _M:post_txt_record(fqdn, content) return nil, err end - return resp.status + if resp.status == 400 then + ngx.log(ngx.INFO, "[cloudflare] ignoring possibly fine error: ", resp.body) + return true + elseif resp.status ~= 200 then + return false, "post_txt_record: cloudflare returned non 200 status: " .. resp.status .. " body: " .. resp.body + end + + return true end -local function get_record_id(self, zone_id, fqdn) +local function get_record_ids(self, zone_id, fqdn) local url = self.endpoint .. "/zones/" .. zone_id .. "/dns_records" local resp, err = self.httpc:request_uri(url, { @@ -128,11 +140,13 @@ local function get_record_id(self, zone_id, fqdn) return nil, "json decode error" end + local ids = {} for _, record in ipairs(body.result) do if fqdn == record.name then - return record.id + ids[#ids+1] = record.id end end + return ids end return nil, "no matched dns record found" @@ -143,23 +157,30 @@ function _M:delete_txt_record(fqdn) if err then return nil, err end - local record_id, err = get_record_id(self, zone_id, fqdn) + + local record_ids, err = get_record_ids(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 + + for _, record_id in ipairs(record_ids) do + 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 + + if resp.status ~= 200 then + return nil, "delete_txt_record: cloudflare returned non 200 status: " .. resp.status .. " body: " .. resp.body + end end - -- return 200 ok - return resp.status + return true end return _M diff --git a/lib/resty/acme/dns_provider/dynv6.lua b/lib/resty/acme/dns_provider/dynv6.lua index 9a35fc7..08a6850 100644 --- a/lib/resty/acme/dns_provider/dynv6.lua +++ b/lib/resty/acme/dns_provider/dynv6.lua @@ -68,7 +68,7 @@ end function _M:post_txt_record(fqdn, content) local zone_id, err = get_zone_id(self, fqdn) if err then - return nil, err + return nil, "post_txt_record: " .. err end local url = self.endpoint .. "/zones/" .. zone_id .. "/records" local body = { @@ -87,10 +87,14 @@ function _M:post_txt_record(fqdn, content) return nil, err end - return resp.status + if resp.status ~= 200 then + return nil, "post_txt_record: dynv6 returned non 200 status: " .. resp.status .. " body: " .. resp.body + end + + return true end -local function get_record_id(self, zone_id, fqdn) +local function get_record_ids(self, zone_id, fqdn) local url = self.endpoint .. "/zones/" .. zone_id .. "/records" local resp, err = self.httpc:request_uri(url, { @@ -120,15 +124,17 @@ local function get_record_id(self, zone_id, fqdn) return nil, "json decode error" end + local ids = {} 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 + ids[#ids+1] = record.id end end + return ids end return nil, "no matched dns record found" @@ -137,24 +143,32 @@ end function _M:delete_txt_record(fqdn) local zone_id, err = get_zone_id(self, fqdn) if err then - return nil, err + return nil, "delete_txt_record: " .. err end - local record_id, err = get_record_id(self, zone_id, fqdn) + + local record_ids, err = get_record_ids(self, zone_id, fqdn) if err then - return nil, err + return nil, "delete_txt_record: " .. 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 + + for _, record_id in ipairs(record_ids) do + 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 + + if resp.status ~= 200 then + return nil, "delete_txt_record: dynv6 returned non 200 status: " .. resp.status .. " body: " .. resp.body + end end - -- return 204 not content - return resp.status + + return true end return _M From 4de1886712db2d9babde41902e7a71de1327a6ed Mon Sep 17 00:00:00 2001 From: fffonion Date: Tue, 28 May 2024 21:19:58 +0800 Subject: [PATCH 2/4] feat(autossl) support create wildcard cert in SAN --- README.md | 14 ++++++++++---- lib/resty/acme/autossl.lua | 12 +++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3a50f40..e5dd980 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,8 @@ To enable this library to create wildcard certificate, the following requirement Otherwise a non-wildcard certificate will be created as fallback. +By default, the wildcard domain `*.example.com` will appear in Common Name. When `wildcard_domain_in_san` is set to `true` however, a cert with Common Name `example.com` and Subject Alternate Name `*.example.com` will be created. Note both `*.example.com` and `example.com` should appear in `dns_provider_accounts`. + ## Advanced Usage ### Use a function to include domains @@ -400,6 +402,8 @@ require("resty.acme.autossl").init({ domains = { "*.anotherdomain.com" }, }, }, + -- uncomment following to create anotherdomain.com in CN and *.anotherdomain.com in SAN + -- wildcard_domain_in_san = true, }) ``` @@ -454,14 +458,16 @@ default_config = { blocking = false, -- if true, the certificate for domain not in whitelist will be deleted from storage enabled_delete_not_whitelisted_domain = false, - -- the maps of domain name to the dns_provider name defined in the dns_provider_keys table; domain name supports wildcard - domain_dns_provider_mapping = {}, -- the dict of dns providers, each provider should have following struct: -- { + -- name = "prod_account", -- provider = "provider_name", -- "cloudflare" or "dynv6" - -- content = "the api key or token", + -- secret = "the api key or token", + -- domains = { "example.com", "*.example.com" }, -- the list of domains that can be used with this provider -- } - dns_provider_keys = {}, + dns_provider_accounts = {}, + -- if enabled, wildcard domains like *.example.com will be created as SAN and CN will be example.com + wildcard_domain_in_san = false, } ``` diff --git a/lib/resty/acme/autossl.lua b/lib/resty/acme/autossl.lua index efe321a..0dde2f9 100644 --- a/lib/resty/acme/autossl.lua +++ b/lib/resty/acme/autossl.lua @@ -68,6 +68,8 @@ local default_config = { -- domains = { "example.com", "*.example.com" }, -- the list of domains that can be used with this provider -- } dns_provider_accounts = {}, + -- if enabled, wildcard domains like *.example.com will be created as SAN and CN will be example.com + wildcard_domain_in_san = false, } local domain_pkeys = {} @@ -213,7 +215,15 @@ local function update_cert_handler(data) log(ngx_INFO, ngx.now() - t, "s spent in creating new ", typ, " private key") end - local cert, err = AUTOSSL.client:order_certificate(pkey, domain) + -- create example.com automatically if we are creating *.example.com + local alt_domain + if domain:sub(1, 1) == "*" and AUTOSSL.config.wildcard_domain_in_san then + alt_domain = domain + domain = domain:sub(3) + log(ngx_INFO, "creating wildcard domain certificate using SAN") + end + + local cert, err = AUTOSSL.client:order_certificate(pkey, domain, alt_domain) if err then log(ngx_ERR, "error updating cert for ", domain, " err: ", err) return err From 7833acd8d1a27df496757ddb48da13f9e238b529 Mon Sep 17 00:00:00 2001 From: fffonion Date: Tue, 28 May 2024 21:20:26 +0800 Subject: [PATCH 3/4] feat(dns-01) add dnspod-intl provider --- README.md | 5 +- lib/resty/acme/dns_provider/dnspod-intl.lua | 143 ++++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 lib/resty/acme/dns_provider/dnspod-intl.lua diff --git a/README.md b/README.md index e5dd980..2bdfd59 100644 --- a/README.md +++ b/README.md @@ -366,8 +366,9 @@ than `shm`. If you must use `shm`, you will need to apply DNS-01 challenge is supported on lua-resty-acme > 0.13.0. Currently, following DNS providers are supported: -- Cloudflare -- Dynv6 +- `cloudflare`: Cloudflare +- `dynv6`: Dynv6 +- `dnspod-intl`: Dnspod International (only Dnspod token is supported and use `id,token` in secret field) To read to how to extend a new DNS provider to work with `dns-01` challenge, see [DNS provider](#dns-providers). diff --git a/lib/resty/acme/dns_provider/dnspod-intl.lua b/lib/resty/acme/dns_provider/dnspod-intl.lua new file mode 100644 index 0000000..a996329 --- /dev/null +++ b/lib/resty/acme/dns_provider/dnspod-intl.lua @@ -0,0 +1,143 @@ +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.dnspod.com/", + httpc = nil, + token = token, + ttl = 600, + headers = { + ["Content-Type"] = "application/json", + ["User-Agent"] = "lua-resty-acme/0.0.0 (noreply@github.com)", + } + }, mt) + + self.httpc = http.new() + return self +end + +local function request(self, uri, body) + body = body or {} + body.login_token = self.token + body.lang = "en" + body.error_on_empty = "no" + + local url = self.endpoint .. "/" .. uri + + 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 +end + +local function get_base_domain(domain) + local parts = {} + for part in domain:gmatch("([^.]+)") do + table.insert(parts, part) + end + + local num_parts = #parts + if num_parts <= 2 then + return "@", domain + else + local base_domain = parts[num_parts-1] .. "." .. parts[num_parts] + table.remove(parts, num_parts) + table.remove(parts, num_parts - 1) + local subdomain = table.concat(parts, ".") + return subdomain, base_domain + end +end + +function _M:post_txt_record(fqdn, content) + local sub, base = get_base_domain(fqdn) + + ngx.log(ngx.DEBUG, "[dnspod-intl] base domain is ", base, " subdomain is ", sub) + + local resp, err = request(self, "Record.Create", { + domain = base, + sub_domain = sub, + record_type = "TXT", + record_line = "default", + value = content, + ttl = self.ttl + }) + + if err then + return nil, "post_txt_record: " .. err + end + + if resp.status ~= 200 then + return nil, "post_txt_record: dnspod returned non 200 status: " .. resp.status .. " body: " .. resp.body + end + + return true +end + +local function get_record_id(self, fqdn) + local sub, base = get_base_domain(fqdn) + + ngx.log(ngx.DEBUG, "[dnspod-intl] base domain is ", base, " subdomain is ", sub) + + local resp, err = request(self, "Record.List", { + domain = base, + sub_domain = sub, + }) + + if err then + return nil, "get_record_id: " .. err + end + + local body = cjson.decode(resp.body) + + local records = {} + + for _, record in ipairs(body.records) do + if record.type == "TXT" then + records[#records+1] = record.id + end + end + + return records +end + +function _M:delete_txt_record(fqdn) + local record_ids, err = get_record_id(self, fqdn) + if err then + return nil, "get_record_id: " .. err + end + local _, base = get_base_domain(fqdn) + for _, rec in ipairs(record_ids) do + local resp, err = request(self, "Record.Remove", { + domain = base, + record_id = rec, + }) + + if err then + return nil, err + end + + if resp.status ~= 200 then + return nil, "delete_txt_record: dnspod returned non 200 status: " .. resp.status .. " body: " .. resp.body + end + end + + return true +end + +return _M From 79daa32fa4b59eea78658c0c92a7e923334419c3 Mon Sep 17 00:00:00 2001 From: fffonion Date: Tue, 28 May 2024 22:14:03 +0800 Subject: [PATCH 4/4] fix(*) provide better robustness when waiting for DNS propagation --- README.md | 2 ++ lib/resty/acme/autossl.lua | 1 + lib/resty/acme/challenge/dns-01.lua | 6 ++---- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2bdfd59..24c3ae9 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,8 @@ require("resty.acme.autossl").init({ }) ``` +By default, this library tries up to 5 minutes for DNS propagation. If the default TTL for dns provider is longer than that, user may want to tune up `challenge_start_delay` manually to wait longer. + ## resty.acme.autossl A config table can be passed to `resty.acme.autossl.init()`, the default values are: diff --git a/lib/resty/acme/autossl.lua b/lib/resty/acme/autossl.lua index 0dde2f9..2671d6c 100644 --- a/lib/resty/acme/autossl.lua +++ b/lib/resty/acme/autossl.lua @@ -475,6 +475,7 @@ function AUTOSSL.init(autossl_config, acme_config) acme_config.enabled_challenge_handlers = ech acme_config.challenge_start_callback = function() + ngx.log(ngx.INFO, "wait for ", autossl_config.challenge_start_delay, " seconds to continue") ngx.sleep(autossl_config.challenge_start_delay) return true end diff --git a/lib/resty/acme/challenge/dns-01.lua b/lib/resty/acme/challenge/dns-01.lua index edbf9c2..73abb5f 100644 --- a/lib/resty/acme/challenge/dns-01.lua +++ b/lib/resty/acme/challenge/dns-01.lua @@ -69,7 +69,7 @@ local function verify_txt_record(record_name, expected_record_content) if not r then return false, "failed to instantiate the resolver: " .. err end - local answers, err, _ = r:query(record_name, { qtype = r.TYPE_TXT }, {}) + local answers, err, _ = r:tcp_query(record_name, { qtype = r.TYPE_TXT }, {}) if not answers then return false, "failed to query the DNS server: " .. err end @@ -138,9 +138,7 @@ function _M:register_challenge(_, response, domains) return err end - log(ngx.INFO, - "dns provider post_txt_record returns: ", result, - ", now waiting for dns record propagation") + log(ngx.INFO, "waiting up to 5 minutes for dns record propagation on ", txt_record_name) local wait_verify_counts = 0 while true do