graphene-os-server-infrastr.../certbot-ocsp-fetcher

513 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# Unofficial Bash strict mode
set \
-o errexit \
-o errtrace \
-o noglob \
-o nounset \
-o pipefail
IFS=$'\n\t'
shopt -s inherit_errexit
exit_with_error() {
echo "${@}" >&2
exit 1
}
check_for_dependencies() {
if ((BASH_VERSINFO[0] == 4 && \
BASH_VERSINFO[1] < 3 || \
BASH_VERSINFO[0] < 4)); then
exit_with_error \
error:$'\t\t'"${0##*/} requires Bash 4.3+."
fi
if ! { command -v openssl >&- &&
[[ $(openssl version) =~ ^OpenSSL\ ([[:digit:]]+)\.([[:digit:]]+) ]] &&
((BASH_REMATCH[1] == 1 && \
BASH_REMATCH[2] >= 1 || \
BASH_REMATCH[1] > 1)); }; then
# shellcheck disable=2016
exit_with_error \
error:$'\t\t'"${0##*/} requires OpenSSL 1.1.0+," \
'but it is not available on $PATH.'
fi
}
parse_cli_arguments() {
local -r usage=(
"USAGE: ${0}"
"[-c/--certbot-dir DIRECTORY]"
"[-f/--force-update]"
"[-h/--help]"
"[-n/--cert-name NAME[,NAME...] [-u/--ocsp-responder URL]]"
"[-o/--output-dir DIRECTORY]"
"[-q/--quiet]"
"[-v/--verbose]"
"[-w/--no-reload-webserver]"
)
declare -gl ERROR_ENCOUNTERED
declare -gi VERBOSITY=1
local -r verbosity_error=(
"error: -q/--quiet cannot be specified in conjunction with -v/--verbose."
)
while ((${#} > 0)); do
local parameter=${1}
case ${parameter} in
-[^-]?*)
set -- "-${parameter:1:1}" "-${parameter:2}" "${@:2}"
;;
-c | --certbot-dir | --certbot-dir=?*)
if [[ -v CERTBOT_DIR ]]; then
exit_with_error "${usage[@]}"
fi
if [[ ${parameter} =~ --certbot-dir=(.+) ]]; then
CERTBOT_DIR=${BASH_REMATCH[1]}
else
if [[ -n ${2:-} ]]; then
CERTBOT_DIR=${2}
shift
else
exit_with_error "${usage[@]}"
fi
fi
CERTBOT_DIR=$(
realpath \
--canonicalize-missing \
--relative-base . \
-- "${CERTBOT_DIR}"
echo x
)
CERTBOT_DIR=${CERTBOT_DIR%??}
shift
;;
-f | --force-update)
if [[ ! -v FORCE_UPDATE ]]; then
declare -glr FORCE_UPDATE=true
fi
shift
;;
-h | --help)
echo >&2 "${usage[@]}"
exit
;;
-n | --cert-name | --cert-name=?*)
if [[ ${parameter} =~ --cert-name=(.+) ]]; then
local cert_lineages_value=${BASH_REMATCH[1]}
shift
else
if [[ -n ${2:-} ]]; then
local cert_lineages_value=${2}
shift 2
else
exit_with_error "${usage[@]}"
fi
fi
# Loop over any lineages passed in the same value of --cert-name.
OLDIFS=${IFS}
IFS=,
declare -Ag CERT_LINEAGES
# Check if a hardcoded OCSP responder was specified for this set of
# lineages.
case ${1:-} in
-u | --ocsp-responder)
if [[ -n ${2:-} ]]; then
for lineage_name in ${cert_lineages_value}; do
CERT_LINEAGES["${lineage_name}"]=${2}
done
shift
else
exit_with_error "${usage[@]}"
fi
shift
;;
--ocsp-responder=?*)
[[ ${1} =~ --ocsp-responder=(.+) ]]
for lineage_name in ${cert_lineages_value}; do
CERT_LINEAGES["${lineage_name}"]=${BASH_REMATCH[1]}
done
shift
;;
*)
# If no OCSP responder was specified, just save the lineage
# name as the key, with an empty value.
for lineage_name in ${cert_lineages_value}; do
CERT_LINEAGES["${lineage_name}"]=
done
;;
esac
unset lineage_name cert_lineages_value
IFS=${OLDIFS}
;;
-o | --output-dir | --output-dir=?*)
if [[ -v OUTPUT_DIR ]]; then
exit_with_error "${usage[@]}"
fi
if [[ ${parameter} =~ --output-dir=(.+) ]]; then
OUTPUT_DIR=${BASH_REMATCH[1]}
else
if [[ -n ${2:-} ]]; then
OUTPUT_DIR=${2}
shift
else
exit_with_error "${usage[@]}"
fi
fi
OUTPUT_DIR=$(
realpath \
--canonicalize-missing \
--relative-base . \
-- "${OUTPUT_DIR}"
echo x
)
OUTPUT_DIR=${OUTPUT_DIR%??}
shift
;;
-q | --quiet)
if ((VERBOSITY != 1)); then
exit_with_error "${verbosity_error[@]}"
else
readonly VERBOSITY=0
shift
fi
;;
-v | --verbose)
if ((VERBOSITY == 0)); then
exit_with_error "${verbosity_error[@]}"
else
VERBOSITY+=1
shift
fi
;;
-w | --no-reload-webserver)
if [[ ! -v RELOAD_WEBSERVER ]]; then
declare -glr RELOAD_WEBSERVER=false
fi
shift
;;
*)
exit_with_error "${usage[@]}"
;;
esac
done
# When not parsed, the stdout and/or stderr output of all external commands
# we call in the script is redirected to file descriptor 3. Depending on the
# desired verbosity, we redirect this file descriptor to either stderr or to
# /dev/null.
if ((VERBOSITY >= 2)); then
exec 3>&2
else
exec 3>/dev/null
fi
}
# Set output directory if necessary and check if it's writeable
prepare_output_dir() {
if [[ -v OUTPUT_DIR ]]; then
if [[ ! -e ${OUTPUT_DIR} ]]; then
# Don't yet fail if it's not possible to create the directory, so we can
# exit with a custom error down below
mkdir \
--parents \
-- "${OUTPUT_DIR}" || true
fi
else
readonly OUTPUT_DIR=.
fi
if [[ ! -w ${OUTPUT_DIR} ]]; then
exit_with_error \
error:$'\t\t'"no write access to output directory (\"${OUTPUT_DIR}\")"
fi
}
start_in_correct_mode() {
# Create temporary directory to store OCSP staple file,
# before having checked the certificate status in the response
local temp_output_dir
temp_output_dir=$(mktemp --directory)
readonly temp_output_dir
trap "rm -r -- ""${temp_output_dir}" EXIT
declare -A lineages_processed
# These two environment variables are set if this script is invoked by Certbot
if [[ ! -v RENEWED_DOMAINS || ! -v RENEWED_LINEAGE ]]; then
run_standalone
else
run_as_deploy_hook
fi
print_and_handle_result
}
# Run in "check one or all certificate lineage(s) managed by Certbot" mode
# $1 - Path to temporary output directory
run_standalone() {
readonly CERTBOT_DIR=${CERTBOT_DIR:-/etc/letsencrypt}
if [[ ! -r ${CERTBOT_DIR} || (-d ${CERTBOT_DIR}/live && ! -r ${CERTBOT_DIR}/live) ]]; then
exit_with_error \
error:$'\t\t'"can't access ${CERTBOT_DIR}/live"
fi
# Check specific lineage if passed on CLI,
# or otherwise all lineages in Certbot's dir
if [[ -v CERT_LINEAGES[@] ]]; then
for lineage_name in "${!CERT_LINEAGES[@]}"; do
if [[ -r ${CERTBOT_DIR}/live/${lineage_name} ]]; then
fetch_ocsp_response \
"--standalone" \
"${temp_output_dir}" \
"${lineage_name}" \
"${CERT_LINEAGES["${lineage_name}"]}"
else
exit_with_error \
"error:"$'\t\t'"can't access ${CERTBOT_DIR}/live/${lineage_name}"
fi
done
else
set +f
shopt -s nullglob
for lineage_dir in "${CERTBOT_DIR}"/live/*; do
set -f
# Skip non-directories, like Certbot's README file
[[ -d ${lineage_dir} ]] || continue
fetch_ocsp_response \
"--standalone" "${temp_output_dir}" "${lineage_dir##*/}"
done
unset lineage_dir
fi
}
# Run in deploy-hook mode, only processing the passed lineage
# $1 - Path to temporary output directory
run_as_deploy_hook() {
if [[ -v CERTBOT_DIR ]]; then
# The directory is already inferred from the environment variable that
# Certbot passes
exit_with_error \
error:$'\t\t'"-c/--certbot-dir cannot be passed" \
"when run as Certbot hook"
fi
if [[ -v FORCE_UPDATE ]]; then
# When run as deploy hook the behavior of this flag is used by default.
# Therefore passing this flag would not have any effect.
exit_with_error \
error:$'\t\t'"-f/--force-update cannot be passed" \
"when run as Certbot hook"
fi
if [[ -v CERT_LINEAGES[@] ]]; then
# The certificate lineage is already inferred from the environment
# variable that Certbot passes
exit_with_error \
error:$'\t\t'"-n/--cert-name cannot be passed when run as Certbot hook"
fi
fetch_ocsp_response \
--deploy_hook "${temp_output_dir}" "${RENEWED_LINEAGE##*/}"
}
# Check if it's necessary to fetch a new OCSP response
check_for_existing_ocsp_staple_file() {
[[ -f ${OUTPUT_DIR}/${lineage_name}.der ]] || return 1
# Validate and verify the existing local OCSP staple file
local existing_ocsp_response
set +e
existing_ocsp_response=$(openssl ocsp \
-no_nonce \
-issuer "${lineage_dir}/chain.pem" \
-cert "${lineage_dir}/cert.pem" \
-verify_other "${lineage_dir}/chain.pem" \
-respin "${OUTPUT_DIR}/${lineage_name}.der" 2>&3)
local -ir existing_ocsp_response_rc=${?}
set -e
readonly existing_ocsp_response
((existing_ocsp_response_rc == 0)) || return 1
for existing_ocsp_response_line in ${existing_ocsp_response}; do
if [[ ${existing_ocsp_response_line} =~ ^[[:blank:]]*"This Update: "(.+)$ ]]; then
local -r this_update=${BASH_REMATCH[1]}
elif [[ ${existing_ocsp_response_line} =~ ^[[:blank:]]*"Next Update: "(.+)$ ]]; then
local -r next_update=${BASH_REMATCH[1]}
fi
done
[[ -n ${this_update:-} && -n ${next_update:-} ]] || return 1
# Only continue fetching OCSP response if existing response expires within
# half of its lifetime.
local -ri response_lifetime_in_seconds=$((\
$(date +%s --date "${next_update}") - $(date +%s --date "${this_update}")))
(($(date +%s) < \
$(date +%s --date "${this_update}") + response_lifetime_in_seconds / 2)) || return 1
}
# Generate file used by ssl_stapling_file in nginx config of websites
# $1 - Whether to run as a deploy hook for Certbot, or standalone
# $2 - Path to temporary output directory
# $3 - Name of certificate lineage
# $4 - OCSP endpoint (if specified on command line)
fetch_ocsp_response() {
local -r temp_output_dir=${2}
local -r lineage_name=${3}
case ${1} in
--standalone)
local -r lineage_dir=${CERTBOT_DIR}/live/${lineage_name}
if [[ ${FORCE_UPDATE:-} != true ]] &&
check_for_existing_ocsp_staple_file; then
lineages_processed["${lineage_name}"]="not updated"$'\t'"valid staple file on disk"
return
fi
;;
--deploy_hook)
local -r lineage_dir=${RENEWED_LINEAGE}
;;
*)
return 1
;;
esac
shift 3
# Verify that the leaf certificate is still valid. If the certificate is
# expired, we don't have to request a (new) OCSP response.
local cert_expiry_output
set +e
cert_expiry_output=$(openssl x509 \
-in "${lineage_dir}/cert.pem" \
-checkend 0 \
-noout 2>&3)
local -ri cert_expiry_rc=${?}
set -e
if ((cert_expiry_rc != 0)); then
ERROR_ENCOUNTERED=true
lineages_processed["${lineage_name}"]="not updated"
if [[ ${cert_expiry_output} == "Certificate will expire" ]]; then
lineages_processed["${lineage_name}"]+=$'\t'"leaf certificate expired"
fi
return
fi
local ocsp_endpoint
if [[ -n ${1-} ]]; then
ocsp_endpoint=${1}
else
ocsp_endpoint=$(openssl x509 \
-noout \
-ocsp_uri \
-in "${lineage_dir}/cert.pem" \
2>&3)
fi
# Request, verify and temporarily save the actual OCSP response,
# and check whether the certificate status is "good"
local ocsp_call_output
set +e
ocsp_call_output=$(openssl ocsp \
-no_nonce \
-url "${ocsp_endpoint}" \
-issuer "${lineage_dir}/chain.pem" \
-cert "${lineage_dir}/cert.pem" \
-verify_other "${lineage_dir}/chain.pem" \
-respout "${temp_output_dir}/${lineage_name}.der" 2>&3)
local -ir ocsp_call_rc=${?}
set -e
readonly ocsp_call_output=${ocsp_call_output#${lineage_dir}/cert.pem: }
local -r cert_status=${ocsp_call_output%%$'\n'*}
if [[ ${ocsp_call_rc} != 0 || ${cert_status} != good ]]; then
ERROR_ENCOUNTERED=true
lineages_processed["${lineage_name}"]="not updated"
if ((VERBOSITY >= 2)); then
lineages_processed["${lineage_name}"]+=$'\t'"${ocsp_call_output//[[:space:]]/ }"
else
lineages_processed["${lineage_name}"]+=$'\t'"${cert_status}"
fi
return
fi
# If arrived here status was good, so move OCSP staple file to definitive
# folder
mv "${temp_output_dir}/${lineage_name}.der" "${OUTPUT_DIR}/"
lineages_processed["${lineage_name}"]=updated
}
print_and_handle_result() {
local -r header=LINEAGE$'\t'RESULT$'\t'REASON
for lineage_name in "${!lineages_processed[@]}"; do
local lineages_processed_formatted+=$'\n'"${lineage_name}"$'\t'"${lineages_processed["${lineage_name}"]}"
done
unset lineage_name
lineages_processed_formatted=$(sort <<<"${lineages_processed_formatted:-}")
readonly lineages_processed_formatted
if [[ ${RELOAD_WEBSERVER:-} != false ]]; then
reload_webserver
fi
local -r output=${header}${lineages_processed_formatted:-}${nginx_status-}
if ((VERBOSITY >= 1)); then
if command -v column >&-; then
column -ts$'\t' <<<"${output}"
else
# shellcheck disable=2016
echo >&2 \
'Install the BSD utility `column` for properly formatted output.'$'\n'
echo "${output}"
fi
fi
[[ ${ERROR_ENCOUNTERED:-} != true ]]
}
reload_webserver() {
for lineage_name in "${!lineages_processed[@]}"; do
if [[ ${lineages_processed["${lineage_name}"]} == updated ]]; then
if nginx -s reload >&3 2>&1; then
# The last line includes a leading space, to workaround the lack of the
# `-n` flag in later versions of `column`.
local -r nginx_status=$'\n\n \t'"nginx reloaded"
else
ERROR_ENCOUNTERED=true
local -r nginx_status=$'\n\n \t'"nginx not reloaded"$'\t'"unable to reload nginx service, try manually"
fi
break
fi
done
unset lineage_name
}
main() {
check_for_dependencies
parse_cli_arguments "${@}"
prepare_output_dir
start_in_correct_mode
}
main "${@}"