#!/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 "${@}"