#!/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 determine_colored_output() { declare -gl COLORED_STDOUT COLORED_STDERR readonly GREEN='\033[0;32m' readonly RED='\033[0;31m' readonly COLOR_DEFAULT='\033[0m' if [[ -v NO_COLOR || ${TERM-} == dumb ]]; then COLORED_STDOUT=false COLORED_STDERR=false else [[ -t 1 ]] || COLORED_STDOUT=false [[ -t 2 ]] || COLORED_STDERR=false fi } exit_with_error() { local error_prefix=error:$'\t\t' [[ ${COLORED_STDERR-} != false ]] && local -r COLORED_ERROR_MSG=${RED}${error_prefix}${*}${COLOR_DEFAULT} # We will have closed file descriptor 2 unless verbosity was requested, so we # will try to use FD5 (the FD that stderr was likely redirected to), and # fallback to FD2 if FD5 wasn't opened yet. if [[ -f /dev/fd/5 ]]; then exec >&5 else exec >&2 fi printf '%b\n' "${COLORED_ERROR_MSG:-${error_prefix}${@}}" exit 1 } check_for_dependencies() { if ((BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3 || BASH_VERSINFO[0] < 4)); then exit_with_error "${0##*/} requires Bash 4.3+." fi if ! { command -v openssl >&- && [[ $(openssl version || true) =~ ^OpenSSL\ ([[:digit:]]+)\.([[:digit:]]+) ]] && ((BASH_REMATCH[1] == 1 && BASH_REMATCH[2] >= 1 || BASH_REMATCH[1] > 1)); }; then # shellcheck disable=2016 exit_with_error \ "${0##*/} requires OpenSSL 1.1.0+," \ 'but it is not available on $PATH.' fi } parse_cli_options() { local -r cli_options=" Usage: ${0} [-c/--certbot-dir DIRECTORY] [-f/--force-update] \\ [-h/--help] [-l/--no-color] [-n/--cert-name NAME[,NAME...] \\ [-u/--ocsp-responder URL]] [-o/--output-dir DIRECTORY] \\ [-q/--quiet|-v/--verbose] [-w/--no-reload-webserver] " print_option_error() { local reason=${1} option=${2} shift 2 local option_error="${option}: " case ${reason} in --conflict) local second_option=${1} shift option_error+="This option cannot be combined with the option ${second_option}." ;; --duplicate) option_error+="This option cannot be specified multiple times." ;; --unknown) option_error+="Invalid option." ;; --value) option_error+="This option requires a value." ;; *) exit 1 ;; esac exit_with_error "${option_error}" "${cli_options}" } declare -gl ERROR_ENCOUNTERED declare -gi VERBOSITY=${VERBOSITY:-1} 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 print_option_error --duplicate "${parameter}" fi if [[ ${parameter} =~ --certbot-dir=(.+) ]]; then CERTBOT_DIR=${BASH_REMATCH[1]} else if [[ -n ${2-} ]]; then CERTBOT_DIR=${2} shift else print_option_error --value "${parameter}" 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) { printf '%s\n' certbot-ocsp-fetcher printf '%s\n' "${cli_options}" local absolute_tool_path absolute_tool_path=$(realpath --no-symlinks -- "${0}") readonly absolute_tool_path cat <= 1)) && ((VERBOSITY == 1)); then # We set VERBOSITY to 0 in case of --quiet, so use the value of $DEBUG # incremented with 1 to match it with $VERBOSITY. VERBOSITY=$((DEBUG + 1)) fi # 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 # First copy file descriptor 2 to a new FD, so stderr can still be used # (unconditionally) in the exit_with_error function. exec 5>&2 if ((VERBOSITY < 1)); then exec 2>/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 # Use $CACHE_DIRECTORY if set (e.g. when run as a systemd service), # otherwise the working directory readonly OUTPUT_DIR=${CACHE_DIRECTORY:-.} fi if [[ ! -w ${OUTPUT_DIR} ]]; then exit_with_error "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() { printf >&2 '%s\n\n' "Running in stand-alone mode..." readonly CERTBOT_DIR=${CERTBOT_DIR:-/etc/letsencrypt} if [[ ! -r ${CERTBOT_DIR} || (-d ${CERTBOT_DIR}/live && ! -r ${CERTBOT_DIR}/live) ]]; then exit_with_error "can't access ${CERTBOT_DIR}/live" fi # Check specific lineage if passed on CLI, # or otherwise all lineages in Certbot's dir if [[ -n ${!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 "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() { printf >&2 '%s\n\n' "Running as a deploy hook of Certbot..." if [[ -v CERTBOT_DIR ]]; then # The directory is already inferred from the environment variable that # Certbot passes exit_with_error \ "-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 \ "-f/--force-update cannot be passed" \ "when run as Certbot hook" fi if [[ -n ${!CERT_LINEAGES[*]} ]]; then # The certificate lineage is already inferred from the environment # variable that Certbot passes exit_with_error "-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. { # The command substitutions here don't respect `set -o errexit`, but in # case any of them fail, the total command still fails unless both # substitutions print an integer. This seems very unlikely to occur, so # let's ignore this. # shellcheck disable=2312 local -ri response_lifetime_in_seconds=$(($(date +%s --date "${next_update}") - $(date +%s --date "${this_update}"))) # `set -o errexit` isn't respected here either, but we default to renewing # the OCSP response, so this is fine. # shellcheck disable=2312 (($(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} # This validation should be revisited once # https://github.com/certbot/certbot/issues/6127 is fixed. if [[ ${lineage_name} =~ ($'\n')|($'\t') ]]; then ERROR_ENCOUNTERED=true exit_with_error \ "Unsupported characters encountered in the following" \ "lineage name: ${lineage_name}$'\n\n'" \ "Lineage names with embedded tabs or newlines are not supported," \ "because Certbot (as of version 1.18.0) does not have well-defined" \ 'behavior on handling any "unconventional" lineage names.' fi case ${1} in --standalone) local -r lineage_dir=${CERTBOT_DIR}/live/${lineage_name} # `set -o errexit` is not respected here, but in case of failure we still # err on the safe side by renewing the OCSP staple file. # shellcheck disable=2310 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}"]="failed to update" 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}"]="failed to update" 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 local lineages_processed_marked_up for lineage_name in "${!lineages_processed[@]}"; do lineages_processed_marked_up+=$'\n'"${lineage_name}"$'\t' if [[ ${COLORED_STDOUT-} != false ]]; then if [[ ${lineages_processed["${lineage_name}"]} =~ ^updated ]]; then lineages_processed_marked_up+=${GREEN} elif [[ ${lineages_processed["${lineage_name}"]} =~ ^"failed to update" ]]; then lineages_processed_marked_up+=${RED} fi lineages_processed_marked_up+=${lineages_processed["${lineage_name}"]}${COLOR_DEFAULT} else lineages_processed_marked_up+=${lineages_processed["${lineage_name}"]} fi done unset lineage_name lineages_processed_marked_up=$(sort <<<"${lineages_processed_marked_up-}") readonly lineages_processed_marked_up if [[ ${RELOAD_WEBSERVER-} != false ]]; then reload_webserver fi local output=${header}${lineages_processed_marked_up-}${nginx_status-} if ((VERBOSITY >= 1)); then local output_table # shellcheck disable=2016 output_table=$(column \ --output-separator $'\t' \ --separator $'\t' \ --table \ <<<"${output}" \ 2>/dev/null) || output_table=$(column -s$'\t' -t <<<"${output}" 2>/dev/null) || local -r column_error=($'\n' 'Install the BSD utility `column` for properly formatted output.' 'If the version of `column` supports the `--output-separator` flag,' 'the output will be formatted as TSV.' $'\n' ) readonly output=${output_table:-${output}} unset output_table # Extract header to direct it to stderr printf '%s\n' "${output%%$'\n'*}" >&2 # Remove header before printing everything else to stdout [[ -n ${!lineages_processed[*]} ]] && printf '%b\n' "${output#*$'\n'}" if [[ ${COLORED_STDERR-} != false ]]; then printf %b "${RED}${column_error[*]-}${COLOR_DEFAULT}" >&2 else printf %b "${column_error[*]-}" >&2 fi fi [[ ${ERROR_ENCOUNTERED-} != true ]] } reload_webserver() { for lineage_name in "${!lineages_processed[@]}"; do if [[ ${lineages_processed["${lineage_name}"]} == updated ]]; then local nginx_status if nginx -s reload >&3 2>&1; then [[ ${COLORED_STDERR-} != false ]] && nginx_status=${GREEN} # The last line includes a leading space, to workaround the lack of the # `-n` flag in later versions of `column`. nginx_status+=$'\n\n \t'"nginx reloaded" else ERROR_ENCOUNTERED=true [[ ${COLORED_STDERR-} != false ]] && nginx_status=${RED} nginx_status=$'\n\n \t'"nginx not reloaded"$'\t'"unable to reload nginx service, try manually" fi [[ ${COLORED_STDERR-} != false ]] && readonly nginx_status+=${COLOR_DEFAULT} break fi done unset lineage_name } main() { check_for_dependencies determine_colored_output parse_cli_options "${@}" prepare_output_dir start_in_correct_mode } main "${@}"