Skip to content

[POC/RFC] Add OpenSSL provider support #255

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

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
50 changes: 50 additions & 0 deletions bin/openssl_hooks
Original file line number Diff line number Diff line change
@@ -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 "$@"
94 changes: 94 additions & 0 deletions bin/openssl_manager
Original file line number Diff line number Diff line change
@@ -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=<domain>"
OSLCFG="keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
subjectAltName = @alt_names
[alt_names]
DNS.1 = <domain>"

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>/$DOMAIN}" -newkey "${KEYSPEC}" -nodes -keyout "${KEYFILE}" -out "${CSRFILE}" \
|| _fail $? "OpenSSL failed failed to make CSR+KEY"

echo "[*] Signing CSR for ${DOMAIN}"
echo "${OSLCFG//<domain>/$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
8 changes: 8 additions & 0 deletions lib/resty/auto-ssl.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
31 changes: 6 additions & 25 deletions lib/resty/auto-ssl/jobs/renewal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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)
Expand Down
18 changes: 11 additions & 7 deletions lib/resty/auto-ssl/ssl_certificate.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions lib/resty/auto-ssl/ssl_providers/lets_encrypt.lua
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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)
Expand Down
84 changes: 84 additions & 0 deletions lib/resty/auto-ssl/ssl_providers/openssl.lua
Original file line number Diff line number Diff line change
@@ -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