From 4082a5f002b6fcf4ef03201a0cc0ea0a01575031 Mon Sep 17 00:00:00 2001 From: kiler129 Date: Sat, 20 Mar 2021 00:34:52 -0500 Subject: [PATCH] [POC] Add OpenSSL provider support --- bin/openssl_hooks | 50 ++++++++++ bin/openssl_manager | 94 +++++++++++++++++++ lib/resty/auto-ssl.lua | 8 ++ lib/resty/auto-ssl/jobs/renewal.lua | 31 ++---- lib/resty/auto-ssl/ssl_certificate.lua | 18 ++-- .../auto-ssl/ssl_providers/lets_encrypt.lua | 29 ++++++ lib/resty/auto-ssl/ssl_providers/openssl.lua | 84 +++++++++++++++++ 7 files changed, 282 insertions(+), 32 deletions(-) create mode 100755 bin/openssl_hooks create mode 100755 bin/openssl_manager create mode 100644 lib/resty/auto-ssl/ssl_providers/openssl.lua diff --git a/bin/openssl_hooks b/bin/openssl_hooks new file mode 100755 index 0000000..fba3a15 --- /dev/null +++ b/bin/openssl_hooks @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +# OpenSSL, by definition, is run locally. To pass back its execution state +# it will call this hooks script. +# For more details regarding internals see letsencrypt_hooks. + +set -Eeuo pipefail + +deploy_cert() { + local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" + local EXPIRY + if ! EXPIRY=$(openssl x509 -enddate -noout -in "$CERTFILE"); then + echo "failed to get the expiry date" + fi + + curl --silent --show-error --fail -XPOST \ + --header "X-Hook-Secret: $HOOK_SECRET" \ + --data-urlencode "domain=$DOMAIN" \ + --data-urlencode "privkey@$KEYFILE" \ + --data-urlencode "cert@$CERTFILE" \ + --data-urlencode "fullchain@$FULLCHAINFILE" \ + --data-urlencode "expiry=$EXPIRY" \ + "http://127.0.0.1:$HOOK_SERVER_PORT/deploy-cert" || { echo "hook request (deploy_cert) failed" 1>&2; exit 1; } +} + +unchanged_cert() { + local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" +} + +request_failure() { + local STATUSCODE="${1}" REASON="${2}" + echo "Failure: STATUSCODE=${STATUSCODE} REASON=${REASON} REQTYPE=${REQTYPE}" + exit 1 +} + +startup_hook() { + : +} + +exit_hook() { + : +} + +HANDLER=$1; shift; + +if ! command -v "$HANDLER"; then + exit 0 +fi + +$HANDLER "$@" diff --git a/bin/openssl_manager b/bin/openssl_manager new file mode 100755 index 0000000..c8e31f2 --- /dev/null +++ b/bin/openssl_manager @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +# This script is responsible for the actual certificates generation/renewal +# It's like dehydrated but for OpenSSL. As of now it makes the following assumptions: +# - First parameter is always the config file; leave empty to use defaults [below] +# - Second parameter is always the hook file; cannot be empty (pass "true" if you REALLY want) + +set -Eeuo pipefail + +# Default configuration +BASEDIR="/etc/resty-auto-ssl/openssl" +CERTSDIR="${BASEDIR}/certs" +CACRT="${CERTSDIR}/_ca.crt" +INTCACRT="${CERTSDIR}/_int.crt" +SIGNKEY="${CERTSDIR}/_sign.key" +KEYSPEC="rsa:2048" +CRTEXPDAYS="14" +SUBJECT="/C=WW/ST=/L=/O=/CN=" +OSLCFG="keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +subjectAltName = @alt_names +[alt_names] +DNS.1 = " + +if [[ ! -z "${MANAGER_CFG:-}" ]] && [[ -f "${MANAGER_CFG}" ]] ; then + echo "[*] Loading custom config ${MANAGER_CFG}" + #shellcheck source=/dev/null + . "${CFGFILE}" +fi + +if [[ -z "${HOOK_BIN:-}" ]] ; then + echo "[?] No HOOK_BIN set" + HOOK_BIN="echo" +fi + +mkdir -p "${BASEDIR}" +mkdir -p "${CERTSDIR}" + +issue_cert() { + local DOMAIN="${1}" + local DOMAINDIR=${CERTSDIR}/${DOMAIN}; + mkdir -p "${DOMAINDIR}" + + [[ -z "${INTCACRT}" ]] && local SIGNCRT="${CACRT}" || local SIGNCRT="${INTCACRT}" # use intermediate cert if available + local OSLCFGFILE="${CERTSDIR}/openssl.cfg" # OpenSSL signing config (deleted after) + local KEYFILE="${DOMAINDIR}/privkey.pem" # certificate private key + local CSRFILE="${DOMAINDIR}/request.csr" # signing request (deleted after) + local CERFILE="${DOMAINDIR}/cert.pem" # contains just new cert file + local CHAINFILE="${DOMAINDIR}/fullchain.pem" # contains intermediate + cert + + echo "[*] Generating CSR for ${DOMAIN} to ${CSRFILE}" + openssl req -subj "${SUBJECT///$DOMAIN}" -newkey "${KEYSPEC}" -nodes -keyout "${KEYFILE}" -out "${CSRFILE}" \ + || _fail $? "OpenSSL failed failed to make CSR+KEY" + + echo "[*] Signing CSR for ${DOMAIN}" + echo "${OSLCFG///$DOMAIN}" > "${OSLCFGFILE}" + openssl x509 -req \ + -in "${CSRFILE}" \ + -CA "${SIGNCRT}" -CAkey "${SIGNKEY}" -CAcreateserial \ + -out "${CERFILE}" \ + -days "${CRTEXPDAYS}" -sha256 -extfile "${OSLCFGFILE}" \ + || _fail $? "[-] OpenSSL failed failed to sign CSR" + + echo "[*] Cleaning up" + rm "${OSLCFGFILE}" "${CSRFILE}" + + echo "[*] Generating chain files" + cat "${CERFILE}" > "${CHAINFILE}" + if [[ -f "${INTCACRT}" ]] ; then + # shellcheck source=/dev/null + cat "${INTCACRT}" >> "${CHAINFILE}" + fi + + echo "[+] Certificate for ${DOMAIN} issued successfully (${CERFILE}), calling hook" + "${HOOK_BIN}" deploy_cert "${DOMAIN}" "${KEYFILE}" "${CERFILE}" "${CHAINFILE}" +} + +_fail() { # Args: code; reason text + echo "[-] ${2}" 1>&2 + echo "[*] Calling request_failure hook" + "${HOOK_BIN}" request_failure "${1}" "${2}" + exit 1 +} + +HANDLER=$1; shift; + +if ! command -v "$HANDLER"; then + exit 0 +fi + +"${HOOK_BIN}" startup_hook +$HANDLER "$@" +"${HOOK_BIN}" exit_hook diff --git a/lib/resty/auto-ssl.lua b/lib/resty/auto-ssl.lua index e4fe75a..ef42194 100644 --- a/lib/resty/auto-ssl.lua +++ b/lib/resty/auto-ssl.lua @@ -45,6 +45,10 @@ function _M.new(options) options["json_adapter"] = "resty.auto-ssl.json_adapters.cjson" end + if not options["ssl_provider"] then + options["ssl_provider"] = "resty.auto-ssl.ssl_providers.lets_encrypt" + end + if not options["ocsp_stapling_error_level"] then options["ocsp_stapling_error_level"] = ngx.ERR end @@ -57,6 +61,10 @@ function _M.new(options) options["hook_server_port"] = 8999 end + if not options["openssl_config"] then + options["openssl_config"] = "" + end + return setmetatable({ options = options }, { __index = _M }) end diff --git a/lib/resty/auto-ssl/jobs/renewal.lua b/lib/resty/auto-ssl/jobs/renewal.lua index 5f4c16a..1fd027b 100644 --- a/lib/resty/auto-ssl/jobs/renewal.lua +++ b/lib/resty/auto-ssl/jobs/renewal.lua @@ -2,7 +2,6 @@ local lock = require "resty.lock" local parse_openssl_time = require "resty.auto-ssl.utils.parse_openssl_time" local shell_blocking = require "shell-games" local shuffle_table = require "resty.auto-ssl.utils.shuffle_table" -local ssl_provider = require "resty.auto-ssl.ssl_providers.lets_encrypt" local _M = {} @@ -160,32 +159,14 @@ local function renew_check_cert(auto_ssl_instance, storage, domain) cert["cert_pem"] = cert["fullchain_pem"] end - -- Write out the cert.pem value to the location dehydrated expects it for - -- checking. - local dir = auto_ssl_instance:get("dir") .. "/letsencrypt/certs/" .. domain - local _, mkdir_err = shell_blocking.capture_combined({ "mkdir", "-p", dir }, { umask = "0022" }) - if mkdir_err then - ngx.log(ngx.ERR, "auto-ssl: failed to create letsencrypt/certs dir: ", mkdir_err) - renew_check_cert_unlock(domain, storage, local_lock, distributed_lock_value) - return false, mkdir_err - end - local cert_pem_path = dir .. "/cert.pem" - local file, err = io.open(cert_pem_path, "w") - if err then - ngx.log(ngx.ERR, "auto-ssl: write cert.pem for " .. domain .. " failed: ", err) + -- Attempt renewal using provider logic + local ok, err = require(auto_ssl_instance:get("ssl_provider")).renew_cert(auto_ssl_instance, domain, cert["cert_pem"]) + if not ok then + ngx.log(ngx.ERR, "auto-ssl: cert renewal for " .. domain .. " failed: ", err) renew_check_cert_unlock(domain, storage, local_lock, distributed_lock_value) - return false, err - end - file:write(cert["cert_pem"]) - file:close() - - -- Trigger a normal certificate issuance attempt, which dehydrated will - -- skip if the certificate already exists or renew if it's within the - -- configured time for renewals. - local _, issue_err = ssl_provider.issue_cert(auto_ssl_instance, domain) - if issue_err then - ngx.log(ngx.ERR, "auto-ssl: issuing renewal certificate failed: ", issue_err) delete_cert_if_expired(domain, storage, cert) + + return false, err end renew_check_cert_unlock(domain, storage, local_lock, distributed_lock_value) diff --git a/lib/resty/auto-ssl/ssl_certificate.lua b/lib/resty/auto-ssl/ssl_certificate.lua index ab71cfd..a864e53 100644 --- a/lib/resty/auto-ssl/ssl_certificate.lua +++ b/lib/resty/auto-ssl/ssl_certificate.lua @@ -2,7 +2,6 @@ local http = require "resty.http" local lock = require "resty.lock" local ocsp = require "ngx.ocsp" local ssl = require "ngx.ssl" -local ssl_provider = require "resty.auto-ssl.ssl_providers.lets_encrypt" local function convert_to_der_and_cache(domain, cert) -- Convert certificate from PEM to DER format. @@ -91,8 +90,9 @@ local function issue_cert(auto_ssl_instance, storage, domain) return cert end - ngx.log(ngx.NOTICE, "auto-ssl: issuing new certificate for ", domain) - cert, err = ssl_provider.issue_cert(auto_ssl_instance, domain) + local ssl_provider_name = auto_ssl_instance:get("ssl_provider") + ngx.log(ngx.NOTICE, "auto-ssl: issuing new certificate for ", domain, " using ", ssl_provider_name) + cert, err = require(ssl_provider_name).issue_cert(auto_ssl_instance, domain) if err then ngx.log(ngx.ERR, "auto-ssl: issuing new certificate failed: ", err) end @@ -157,10 +157,10 @@ local function get_cert_der(auto_ssl_instance, domain, ssl_options) end local function get_ocsp_response(fullchain_der, auto_ssl_instance) - -- Pull the OCSP URL to hit out of the certificate chain. + -- Pull the OCSP URL (if available) to hit out of the certificate chain. local ocsp_url, ocsp_responder_err = ocsp.get_ocsp_responder_from_der_chain(fullchain_der) if not ocsp_url then - return nil, "failed to get OCSP responder: " .. (ocsp_responder_err or "") + return nil, nil -- lack of OCSP URL is *not* an error end -- Generate the OCSP request body. @@ -221,7 +221,9 @@ local function set_ocsp_stapling(domain, cert_der, auto_ssl_instance) local ocsp_response_err ocsp_resp, ocsp_response_err = get_ocsp_response(cert_der["fullchain_der"], auto_ssl_instance) - if ocsp_response_err then + if ocsp_resp == nil and ocsp_response_err == nil then + return nil, nil + elseif ocsp_response_err then return false, "failed to get ocsp response: " .. (ocsp_response_err or "") end @@ -256,7 +258,9 @@ local function set_response_cert(auto_ssl_instance, domain, cert_der) -- Set OCSP stapling. ok, err = set_ocsp_stapling(domain, cert_der, auto_ssl_instance) - if not ok then + if ok == nil and err == nil then + ngx.log(ngx.NOTICE, "auto-ssl: ocsp stapling skipped for ", domain, " - no OCSP responder available") + elseif not ok then ngx.log(auto_ssl_instance:get("ocsp_stapling_error_level"), "auto-ssl: failed to set ocsp stapling for ", domain, " - continuing anyway - ", err) end diff --git a/lib/resty/auto-ssl/ssl_providers/lets_encrypt.lua b/lib/resty/auto-ssl/ssl_providers/lets_encrypt.lua index 570432f..bbec036 100644 --- a/lib/resty/auto-ssl/ssl_providers/lets_encrypt.lua +++ b/lib/resty/auto-ssl/ssl_providers/lets_encrypt.lua @@ -1,6 +1,7 @@ local _M = {} local shell_execute = require "resty.auto-ssl.utils.shell_execute" +local shell_blocking = require "shell-games" function _M.issue_cert(auto_ssl_instance, domain) assert(type(domain) == "string", "domain must be a string") @@ -66,6 +67,34 @@ function _M.issue_cert(auto_ssl_instance, domain) return cert end +function _M.renew_cert(auto_ssl_instance, domain, current_cert_pem) + -- Write out the cert.pem value to the location dehydrated expects it for + -- checking. + local dir = auto_ssl_instance:get("dir") .. "/letsencrypt/certs/" .. domain + local _, mkdir_err = shell_blocking.capture_combined({ "mkdir", "-p", dir }, { umask = "0022" }) + if mkdir_err then + return false, "failed to create letsencrypt/certs dir: " .. mkdir_err + end + + local cert_pem_path = dir .. "/cert.pem" + local file, err = io.open(cert_pem_path, "w") + if err then + return false, "write cert.pem for " .. domain .. " failed: " .. err + end + file:write(current_cert_pem) + file:close() + + -- Trigger a normal certificate issuance attempt, which dehydrated will + -- skip if the certificate already exists or renew if it's within the + -- configured time for renewals. + local _, issue_err = _M.issue_cert(auto_ssl_instance, domain) + if issue_err then + return false, "issuing renewal certificate failed: " .. issue_err + end + + return true, nil +end + function _M.cleanup(auto_ssl_instance, domain) assert(string.find(domain, "/") == nil) assert(string.find(domain, "%.%.") == nil) diff --git a/lib/resty/auto-ssl/ssl_providers/openssl.lua b/lib/resty/auto-ssl/ssl_providers/openssl.lua new file mode 100644 index 0000000..166cd90 --- /dev/null +++ b/lib/resty/auto-ssl/ssl_providers/openssl.lua @@ -0,0 +1,84 @@ +local _M = {} + +local shell_execute = require "resty.auto-ssl.utils.shell_execute" + +function _M.issue_cert(auto_ssl_instance, domain) + assert(type(domain) == "string", "domain must be a string") + + local lua_root = auto_ssl_instance.lua_root + assert(type(lua_root) == "string", "lua_root must be a string") + + local base_dir = auto_ssl_instance:get("dir") + assert(type(base_dir) == "string", "dir must be a string") + + local hook_port = auto_ssl_instance:get("hook_server_port") + assert(type(hook_port) == "number", "hook_port must be a number") + assert(hook_port <= 65535, "hook_port must be below 65536") + + local hook_secret = ngx.shared.auto_ssl_settings:get("hook_server:secret") + assert(type(hook_secret) == "string", "hook_server:secret must be a string") + + local manager_config = auto_ssl_instance:get("openssl_config") + + local result, err = shell_execute({ + "env", + "HOOK_BIN=" .. lua_root .. "/bin/resty-auto-ssl/openssl_hooks", + "HOOK_SECRET=" .. hook_secret, + "HOOK_SERVER_PORT=" .. hook_port, + "MANAGER_CFG=" .. manager_config, + lua_root .. "/bin/resty-auto-ssl/openssl_manager", + "issue_cert", + domain, + }) + + -- Cleanup OpenSSL manager files after running to prevent temp files from piling + -- up. This always runs, regardless of whether or not dehydrated succeeds (in + -- which case the certs should be installed in storage) or manager fails + -- (in which case these files aren't of much additional use). + _M.cleanup(auto_ssl_instance, domain) + + if result["status"] ~= 0 then + ngx.log(ngx.ERR, "auto-ssl: openssl_manager failed: ", result["command"], " status: ", result["status"], " out: ", result["output"], " err: ", err) + return nil, "openssl_manager failure" + end + + ngx.log(ngx.DEBUG, "auto-ssl: openssl_manager output: " .. result["output"]) + + -- The result of running that command should result in the certs being + -- populated in our storage (due to the deploy_cert hook triggering). + local storage = auto_ssl_instance.storage + local cert, get_cert_err = storage:get_cert(domain) + if get_cert_err then + ngx.log(ngx.ERR, "auto-ssl: error fetching certificate from storage for ", domain, ": ", get_cert_err) + end + + -- Return error if things are still unexpectedly missing. + if not cert or not cert["fullchain_pem"] or not cert["privkey_pem"] then + return nil, "openssl_manager succeeded, but no certs present" + end + + return cert +end + +function _M.renew_cert(auto_ssl_instance, domain, current_cert_pem) + -- Basically just renew it any time it's asked (there are no limits with local OpenSSL after all) + local _, issue_err = _M.issue_cert(auto_ssl_instance, domain) + if issue_err then + return false, "issuing renewal certificate failed: " .. issue_err + end + + return true, nil +end + +function _M.cleanup(auto_ssl_instance, domain) + assert(string.find(domain, "/") == nil) + assert(string.find(domain, "%.%.") == nil) + + local dir = auto_ssl_instance:get("dir") .. "/openssl/certs/" .. domain + local _, rm_err = shell_execute({ "rm", "-rf", dir }) + if rm_err then + ngx.log(ngx.ERR, "auto-ssl: failed to cleanup certs: ", rm_err) + end +end + +return _M