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, }