Files
2026-07-13 12:32:21 +08:00

539 lines
15 KiB
Lua

local acme = require "resty.acme.client"
local util = require "resty.acme.util"
local x509 = require "resty.openssl.x509"
local reserved_words = require "kong.plugins.acme.reserved_words"
local config_adapters = require "kong.plugins.acme.storage.config_adapters"
local cjson = require "cjson"
local ngx_ssl = require "ngx.ssl"
local ipairs = ipairs
local tonumber = tonumber
local math_max = math.max
local string_sub = string.sub
local string_format = string.format
local cjson_encode = cjson.encode
local cjson_decode = cjson.decode
local ngx_sleep = ngx.sleep
local ngx_time = ngx.time
local ngx_localtime = ngx.localtime
local ngx_re_match = ngx.re.match
local dbless = kong.configuration.database == "off"
local hybrid_mode = kong.configuration.role == "control_plane" or
kong.configuration.role == "data_plane"
local RENEW_KEY_PREFIX = reserved_words.RENEW_KEY_PREFIX
local RENEW_LAST_RUN_KEY = reserved_words.RENEW_LAST_RUN_KEY
local CERTKEY_KEY_PREFIX = reserved_words.CERTKEY_KEY_PREFIX
local DAY_SECONDS = 86400 -- one day in seconds
local LOCK_TIMEOUT = 30 -- in seconds
local CACHE_TTL = 3600 -- in seconds
local CACHE_NEG_TTL = 5
local function account_name(conf)
return "kong_acme:account:" .. conf.api_uri .. ":" ..
ngx.encode_base64(conf.account_email)
end
local function deserialize_account(j)
j = cjson_decode(j)
if not j.key then
return nil, "key found in account"
end
return j
end
local function deserialize_certkey(j)
local certkey = cjson_decode(j)
if not certkey.key or not certkey.cert then
return nil, "key or cert found in storage"
end
local cert, err = ngx_ssl.cert_pem_to_der(certkey.cert)
if err then
return nil, err
end
local key, err = ngx_ssl.priv_key_pem_to_der(certkey.key)
if err then
return nil, err
end
return {
key = key,
cert = cert,
}
end
local function cached_get(storage, key, deserializer, ttl, neg_ttl)
local cache_key = kong.db.acme_storage:cache_key(key)
return kong.cache:get(cache_key, {
l1_serializer = deserializer,
-- in dbless mode, kong.cache has mlcache set to 0 as ttl
-- we override the default setting here so that cert can be invalidated
-- with renewal.
ttl = math_max(ttl or CACHE_TTL, 0),
neg_ttl = math_max(neg_ttl or CACHE_NEG_TTL, 0),
}, storage.get, storage, key)
end
local function new_storage_adapter(conf)
local storage = conf.storage
if not storage then
return nil, nil, "storage is nil"
end
local storage_config = config_adapters.adapt_config(conf.storage, conf.storage_config)
if not storage_config then
return nil, nil, storage .. " is not defined in plugin storage config"
end
if storage == "kong" then
storage = "kong.plugins.acme.storage.kong"
else
storage = "resty.acme.storage." .. storage
end
local lib = require(storage)
local st, err = lib.new(storage_config)
return storage, st, err
end
local function new(conf)
local storage_full_path, st, err = new_storage_adapter(conf)
if err then
return nil, err
end
local storage_config = config_adapters.adapt_config(conf.storage, conf.storage_config)
local account_name = account_name(conf)
local account, err = cached_get(st, account_name, deserialize_account)
if err then
return nil, err
elseif not account then
-- TODO: populate new account?
return nil, "account ".. conf.account_email .. " not found in storage"
end
-- backward compat
local url = conf.api_uri
if not ngx_re_match(url, "/directory$", "jo") then
if not ngx_re_match(url, "/$", "jo") then
url = url .. "/"
end
url = url .. "directory"
end
-- TODO: let acme accept initlizaed storage table alternatively
return acme.new({
account_email = conf.account_email,
account_key = account.key,
api_uri = url,
storage_adapter = storage_full_path,
storage_config = storage_config,
eab_kid = conf.eab_kid,
eab_hmac_key = conf.eab_hmac_key,
challenge_start_callback = hybrid_mode and function()
-- The delayed-push mechanism in hybrid mode may result in up to
-- 2 times of db_update_frequency (the time push delayed) duration
local wait = kong.configuration.db_update_frequency * 2
kong.log.info("Kong is running in Hybrid mode, wait for ", wait,
" seconds for ACME challenges to propogate")
ngx_sleep(wait)
return true
end or nil,
preferred_chain = conf.preferred_chain,
})
end
local function order(acme_client, host, key, cert_type, rsa_key_size)
local err = acme_client:init()
if err then
return nil, nil, err
end
local _, err = acme_client:new_account()
if err then
return nil, nil, err
end
if not key then
-- FIXME: this might block worker for several seconds in some virtualization env
if cert_type == "rsa" then
key = util.create_pkey(rsa_key_size, 'RSA')
else
key = util.create_pkey(nil, 'EC', 'prime256v1')
end
end
local cert, err = acme_client:order_certificate(key, host)
if err then
local concatErr = "could not create certificate for host: " .. host .. " err: " .. err
return nil, nil, concatErr
end
return cert, key, nil
end
-- idempotent routine for updating sni and certificate in Kong database
local function save_dao(host, key, cert)
local cert_entity, err = kong.db.certificates:insert({
cert = cert,
key = key,
tags = { "managed-by-acme" },
})
if err then
return "could not insert cert: " .. err
end
local old_sni_entity, err = kong.db.snis:select_by_name(host)
if err then
kong.log.warn("error finding sni entity: ", err)
end
local _, err = kong.db.snis:upsert_by_name(host, {
certificate = cert_entity,
tags = { "managed-by-acme" },
})
if err then
local ok, err_2 = kong.db.certificates:delete(cert_entity)
if not ok then
kong.log.warn("error cleaning up certificate entity ", cert_entity.id, ": ", err_2)
end
return "could not upsert sni: " .. err
end
if old_sni_entity and old_sni_entity.certificate then
local ok, err = kong.db.certificates:delete(old_sni_entity.certificate)
if not ok then
kong.log.warn("error deleting expired certificate entity ", old_sni_entity.certificate.id, ": ", err)
end
end
end
local function store_renew_config(conf, host)
local _, st, err = new_storage_adapter(conf)
if err then
return err
end
-- Note: we don't distinguish api uri because host is unique in Kong SNIs
err = st:set(RENEW_KEY_PREFIX .. host, cjson_encode({
host = host,
expire_at = ngx_time() + DAY_SECONDS * 90,
}))
return err
end
local function get_account_key(conf)
local kid = conf.key_id
local lookup = { kid = kid }
if conf.key_set then
local key_set, key_set_err = kong.db.key_sets:select_by_name(conf.key_set)
if key_set_err then
return nil, "could not load keyset: " .. key_set_err
end
lookup.set = { id = key_set.id }
end
local cache_key = kong.db.keys:cache_key(lookup)
local key, key_err = kong.db.keys:select_by_cache_key(cache_key)
if key_err then
return nil, "could not load keys: " .. key_err
end
return kong.db.keys:get_privkey(key)
end
local function create_account(conf)
local _, st, err = new_storage_adapter(conf)
if err then
return err
end
local account_name = account_name(conf)
local account, err = st:get(account_name)
if err then
return err
elseif account then
return
end
local pkey
if conf.account_key then
local account_key, err = get_account_key(conf.account_key)
if err then
return err
end
pkey = account_key
else
-- no account yet, create one now
pkey = util.create_pkey(4096, "RSA")
end
local err = st:set(account_name, cjson_encode({
key = pkey,
}))
if err then
return err
end
conf.account_key = nil
return
end
local function update_certificate(conf, host, key)
local _, st, err = new_storage_adapter(conf)
if err then
return false, "can't create storage adapter: " .. err
end
local backoff_key = "kong_acme:fail_backoff:" .. host
local backoff_until, err = st:get(backoff_key)
if err then
kong.log.warn("failed to read backoff status for ", host, " : ", err)
end
if backoff_until and tonumber(backoff_until) then
local wait = tonumber(backoff_until) - ngx_time()
return false, "please try again in " .. wait .. " seconds for host " ..
host .. " because of previous failure; this is configurable " ..
"with config.fail_backoff_minutes"
end
local lock_key = "kong_acme:update_lock:" .. host
-- TODO: wait longer?
-- This goes to the backend storage and may bring pressure, add a first pass shm cache?
local err = st:add(lock_key, "placeholder", LOCK_TIMEOUT)
if err then
kong.log.info("update_certificate for ", host, " is already running: ", err)
return false
end
local acme_client, cert, err
err = create_account(conf)
if err then
goto update_certificate_error
end
acme_client, err = new(conf)
if err then
goto update_certificate_error
end
cert, key, err = order(acme_client, host, key, conf.cert_type, conf.rsa_key_size)
if not err then
if dbless or hybrid_mode then
-- in dbless mode, we don't actively release lock
-- since we don't implement an IPC to purge potentially negatively
-- cached cert/key in other node, we set the cache to be same as
-- lock timeout, so that multiple node will not try to update certificate
-- at the same time because they are all seeing default cert is served
local err = st:set(CERTKEY_KEY_PREFIX .. host, cjson_encode({
key = key,
cert = cert,
}))
return true, err
else
err = save_dao(host, key, cert)
end
end
::update_certificate_error::
local wait_seconds = conf.fail_backoff_minutes * 60
local err_set = st:set(backoff_key, string_format("%d", ngx_time() + wait_seconds), wait_seconds)
if err_set then
kong.log.warn("failed to set fallback key for ", host, ": ", err_set)
end
local err_del = st:delete(lock_key)
if err_del then
kong.log.warn("failed to delete update_certificate lock for ", host, ": ", err_del)
end
return true, err
end
local function check_expire(cert, threshold)
local crt, err = x509.new(cert)
if err then
kong.log.info("can't parse cert stored in storage: ", err)
elseif crt:get_not_after() - threshold > ngx_time() then
return false
end
return true
end
-- loads existing cert and key for host from storage or Kong database
local function load_certkey(conf, host)
if dbless or hybrid_mode then
local _, st, err = new_storage_adapter(conf)
if err then
return nil, err
end
local certkey, err = st:get(CERTKEY_KEY_PREFIX .. host)
if err then
return nil, err
elseif not certkey then
return nil
end
return cjson_decode(certkey)
end
local sni_entity, err = kong.db.snis:select_by_name(host)
if err then
return nil, "can't read SNI entity"
elseif not sni_entity then
kong.log.info("SNI ", host, " is not found in Kong database")
return
end
if not sni_entity or not sni_entity.certificate then
return nil, "DAO returns empty SNI entity or Certificte entity"
end
local cert_entity, err = kong.db.certificates:select(sni_entity.certificate)
if err then
kong.log.info("can't read certificate ", sni_entity.certificate.id, " from db",
", deleting renew config")
return nil, nil
elseif not cert_entity then
kong.log.warn("certificate for SNI ", host, " is not found in Kong database")
return nil, nil
end
return {
cert = cert_entity.cert,
key = cert_entity.key,
}
end
local function load_certkey_cached(conf, host)
local _, st, err = new_storage_adapter(conf)
if err then
return nil, err
end
local key = CERTKEY_KEY_PREFIX .. host
return cached_get(st, key, deserialize_certkey)
end
local function renew_certificate_storage(conf)
local _, st, err = new_storage_adapter(conf)
if err then
kong.log.err("can't create storage adapter: ", err)
return
end
local renew_conf_keys, err = st:list(RENEW_KEY_PREFIX)
if err then
kong.log.err("can't list renew hosts: ", err)
return
end
err = st:set(RENEW_LAST_RUN_KEY, ngx_localtime())
if err then
kong.log.warn("can't set renew_last_run: ", err)
end
for _, renew_conf_key in ipairs(renew_conf_keys) do
local renew_conf, err = st:get(renew_conf_key)
if err then
kong.log.err("can't read renew conf: ", err)
goto renew_continue
end
if not renew_conf then
kong.log.err("renew config key ",renew_conf_key, " is empty")
goto renew_continue
end
renew_conf = cjson_decode(renew_conf)
local host = renew_conf.host
local expire_threshold = DAY_SECONDS * conf.renew_threshold_days
if renew_conf.expire_at - expire_threshold > ngx_time() then
kong.log.info("certificate for host ", host, " is not due for renewal")
goto renew_continue
end
local certkey, err = load_certkey(conf, host)
if err then
kong.log.err("error loading existing certkey for host: ", host, ": ", err)
goto renew_continue
end
if not certkey then
kong.log.warn("deleting renewal config for host: ", host)
err = st:delete(renew_conf_key)
if err then
kong.log.warn("error deleting unneeded renew config key \"", renew_conf_key, "\"")
end
goto renew_continue
end
local renew, err = check_expire(certkey.cert, expire_threshold)
if err then
kong.log.err("error checking expiry for certificate of host: ", host, ": ", err)
goto renew_continue
end
if not renew then
kong.log.info("certificate for ", host, " is not due for renewal")
goto renew_continue
end
if not certkey.key then
kong.log.info("previous key is not defined, creating new key")
end
kong.log.info("renew certificate for host ", host)
local _, err = update_certificate(conf, host, certkey.key)
if err then
kong.log.err("failed to renew certificate: ", err)
end
::renew_continue::
end
end
local function renew_certificate(config)
kong.log.info("renew storage configured in acme plugin: ", config.__plugin_id)
renew_certificate_storage(config)
end
local function load_renew_hosts(conf)
local _, st, err = new_storage_adapter(conf)
if err then
return nil, err
end
local hosts, err = st:list(RENEW_KEY_PREFIX)
if err then
return nil, err
end
local data = {}
for i, host in ipairs(hosts) do
data[i] = string_sub(host, #RENEW_KEY_PREFIX + 1)
end
return data
end
return {
new = new,
create_account = create_account,
update_certificate = update_certificate,
renew_certificate = renew_certificate,
store_renew_config = store_renew_config,
load_renew_hosts = load_renew_hosts,
load_certkey = load_certkey,
load_certkey_cached = load_certkey_cached,
-- for test only
_save_dao = save_dao,
_order = order,
_account_name = account_name,
_renew_key_prefix = RENEW_KEY_PREFIX,
_certkey_key_prefix = CERTKEY_KEY_PREFIX,
_renew_certificate_storage = renew_certificate_storage,
_check_expire = check_expire,
_set_is_dbless = function(d) dbless = d end,
_create_account = create_account,
}