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 14 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
49 changes: 47 additions & 2 deletions lib/resty/acme/autossl.lua
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ 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 dnsapi keys
-- like: domain_used_dnsapi_key = { ["*.domain.com"] = "cloudflare_token_default", ["www.domain.com"] = "dynv6_token_default" }
domain_used_dnsapi_key = {},
dnsapi_keys = {
cloudflare_token_default = {
-- the module name in 'lib/resty/acme/dnsapi'
provider = "cloudflare",
-- the api token
content = ""
},
dynv6_token_default = {
provider = "dynv6",
content = ""
}
}
}

local domain_pkeys = {}
Expand Down Expand Up @@ -414,6 +429,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.domain_used_dnsapi_key_detail = acme_config.domain_used_dnsapi_key_detail or {}
for domain, key_name in pairs(autossl_config.domain_used_dnsapi_key) do
if autossl_config.dnsapi_keys[key_name].content ~= "" then
acme_config.domain_used_dnsapi_key_detail[domain] = autossl_config.dnsapi_keys[key_name]
end
end

acme_config.challenge_start_callback = function()
ngx.sleep(autossl_config.challenge_start_delay)
return true
Expand Down Expand Up @@ -520,6 +542,25 @@ function AUTOSSL.is_domain_whitelisted(domain, is_new_cert_needed)
end
end

local function find_wildcard_domain(domain)
if not domain_whitelist then
return nil
end

for _, w in ipairs(domain_whitelist) do
local is_wildcard, _, _ = w:find("*.", 1, true)
if is_wildcard then
local trim_w = w:gsub("*.", "")
local is_matched, _, _ = domain:find(trim_w, 1, true)
if is_matched and domain ~= trim_w then
return w
end
end
end

return nil
end

function AUTOSSL.ssl_certificate()
local domain, err = ssl.server_name()

Expand All @@ -531,8 +572,12 @@ function AUTOSSL.ssl_certificate()
domain = string.lower(domain)

if not AUTOSSL.is_domain_whitelisted(domain, false) then
log(ngx_INFO, "domain ", domain, " not in whitelist, skipping")
return
local matched_wildcard_domain = find_wildcard_domain(domain)
if not matched_wildcard_domain then
log(ngx_INFO, "domain ", domain, " not in whitelist, skipping")
return
end
domain = matched_wildcard_domain
end

local chains_set_count = 0
Expand Down
109 changes: 109 additions & 0 deletions lib/resty/acme/challenge/dns-01.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
local util = require("resty.acme.util")
local digest = require("resty.openssl.digest")
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,
-- domain_used_dnsapi_key_detail = {
-- ["*.domain.com"] = {
-- provider = "cloudflare",
-- content = "token"
-- },
-- ["www.domain.com"] = {
-- provider = "dynv6",
-- content = "token"
-- }
-- }
domain_used_dnsapi_key_detail = {}
}, 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_dnsapi(self, domain)
if not self.domain_used_dnsapi_key_detail[domain] then
return nil, "not dnsapi key for domain"
end
local provider = self.domain_used_dnsapi_key_detail[domain].provider
if not provider then
return nil, "dnsapi provider not support"
end
log(ngx.DEBUG, "use dnsapi provider: ", provider)
local content = self.domain_used_dnsapi_key_detail[domain].content
if not content or content == "" then
return nil, "dnsapi key content is empty"
end
local ok, module = pcall(require, "resty.acme.dnsapi." .. provider)
if ok then
local handler, err = module.new(content)
if not err then
return handler
end
end
return nil, "require dnsapi error: " .. provider
end

function _M:update_dnsapi_info(domain_used_dnsapi_key_detail)
log(ngx.INFO, "update_dnsapi_info: " .. cjson.encode(domain_used_dnsapi_key_detail))
self.domain_used_dnsapi_key_detail = domain_used_dnsapi_key_detail
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_dnsapi(self, domain)
if err then
return err
end
local trim_domain = domain:gsub("*.", "")
local txt_record = calculate_txt_record(response)
local result, err = dnsapi:post_txt_record("_acme-challenge." .. trim_domain, txt_record)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we might want to wait the dns record to propogate after we set the record. use openresty/lua-resty-dns to query the record would be good to avoid the acme server try to validate it prematurely.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we can handle it in challenge_start_callback? I will check it.

if err then
return err
end
log(ngx.INFO, "dnsapi post_txt_record returns: ", result)
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_dnsapi(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.INFO, "dnsapi 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,
-- the domain used dnsapi key detail for dns-01 challenge
-- like: domain_used_dnsapi_key_detail = {
-- ["*.domain.com"] = {
-- provider = "cloudflare",
-- content = "token"
-- },
-- ["www.domain.com"] = {
-- provider = "dynv6",
-- content = "token"
-- }
-- }
domain_used_dnsapi_key_detail = {}
}

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 = {},
domain_used_dnsapi_key_detail = conf.domain_used_dnsapi_key_detail
}, 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_dnsapi_info(self.domain_used_dnsapi_key_detail)
end
end

if conf.account_key then
Expand Down
165 changes: 165 additions & 0 deletions lib/resty/acme/dnsapi/cloudflare.lua
Original file line number Diff line number Diff line change
@@ -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

function _M:get_zone_id(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 = self:get_zone_id(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

function _M:get_record_id(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 = self:get_zone_id(fqdn)
if err then
return nil, err
end
local record_id, err = self:get_record_id(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
Loading