mirror of
https://github.com/GrapheneOS/infrastructure.git
synced 2024-10-01 00:55:42 -04:00
513 lines
14 KiB
Plaintext
513 lines
14 KiB
Plaintext
|
#!/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 "${@}"
|