1
0
docker-letsencrypt-nginx-pr.../app/letsencrypt_service
Nicolas Duchon 8a936cc4fe
Add standalone certificate feature
Standalone certificates are generated from a static user provided
configuration file rather than from the dynamicaly generated (from
running containers environment variables) letsencrypt_service_data file.
2020-05-10 19:35:59 +02:00

366 lines
15 KiB
Bash
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
# shellcheck source=functions.sh
source /app/functions.sh
DEBUG="$(lc "$DEBUG")"
seconds_to_wait=3600
ACME_CA_URI="${ACME_CA_URI:-https://acme-v02.api.letsencrypt.org/directory}"
DEFAULT_KEY_SIZE=4096
REUSE_ACCOUNT_KEYS="$(lc "${REUSE_ACCOUNT_KEYS:-true}")"
REUSE_PRIVATE_KEYS="$(lc "${REUSE_PRIVATE_KEYS:-false}")"
MIN_VALIDITY_CAP=7603200
DEFAULT_MIN_VALIDITY=2592000
function create_link {
local -r source=${1?missing source argument}
local -r target=${2?missing target argument}
if [[ -f "$target" ]] && [[ "$(readlink "$target")" == "$source" ]]; then
set_ownership_and_permissions "$target"
[[ "$DEBUG" == true ]] && echo "$target already linked to $source"
return 1
else
ln -sf "$source" "$target" \
&& set_ownership_and_permissions "$target"
fi
}
function create_links {
local -r base_domain=${1?missing base_domain argument}
local -r domain=${2?missing base_domain argument}
if [[ ! -f "/etc/nginx/certs/$base_domain/fullchain.pem" || \
! -f "/etc/nginx/certs/$base_domain/key.pem" ]]; then
return 1
fi
local return_code=1
create_link "./$base_domain/fullchain.pem" "/etc/nginx/certs/$domain.crt"
return_code=$(( return_code & $? ))
create_link "./$base_domain/key.pem" "/etc/nginx/certs/$domain.key"
return_code=$(( return_code & $? ))
if [[ -f "/etc/nginx/certs/dhparam.pem" ]]; then
create_link ./dhparam.pem "/etc/nginx/certs/$domain.dhparam.pem"
return_code=$(( return_code & $? ))
fi
if [[ -f "/etc/nginx/certs/$base_domain/chain.pem" ]]; then
create_link "./$base_domain/chain.pem" "/etc/nginx/certs/$domain.chain.pem"
return_code=$(( return_code & $? ))
fi
return $return_code
}
function cleanup_links {
local -a LETSENCRYPT_CONTAINERS
local -a LETSENCRYPT_STANDALONE_CERTS
local -a ENABLED_DOMAINS
local -a SYMLINKED_DOMAINS
local -a DISABLED_DOMAINS
# Create an array containing domains for which a
# symlinked private key exists in /etc/nginx/certs.
for symlinked_domain in /etc/nginx/certs/*.crt; do
[[ -L "$symlinked_domain" ]] || continue
symlinked_domain="${symlinked_domain##*/}"
symlinked_domain="${symlinked_domain%*.crt}"
SYMLINKED_DOMAINS+=("$symlinked_domain")
done
[[ "$DEBUG" == true ]] && echo "Symlinked domains: ${SYMLINKED_DOMAINS[*]}"
# Create an array containing domains that are considered
# enabled (ie present on /app/letsencrypt_service_data or /app/letsencrypt_user_data).
[[ -f /app/letsencrypt_service_data ]] && source /app/letsencrypt_service_data
[[ -f /app/letsencrypt_user_data ]] && source /app/letsencrypt_user_data
LETSENCRYPT_CONTAINERS+=( "${LETSENCRYPT_STANDALONE_CERTS[@]}" )
for cid in "${LETSENCRYPT_CONTAINERS[@]}"; do
host_varname="LETSENCRYPT_${cid}_HOST"
hosts_array="${host_varname}[@]"
for domain in "${!hosts_array}"; do
# Add domain to the array storing currently enabled domains.
ENABLED_DOMAINS+=("$domain")
done
done
[[ "$DEBUG" == true ]] && echo "Enabled domains: ${ENABLED_DOMAINS[*]}"
# Create an array containing only domains for which a symlinked private key exists
# in /etc/nginx/certs but that no longer have a corresponding LETSENCRYPT_HOST set
# on an active container or on /app/letsencrypt_user_data
if [[ ${#SYMLINKED_DOMAINS[@]} -gt 0 ]]; then
mapfile -t DISABLED_DOMAINS < <(echo "${SYMLINKED_DOMAINS[@]}" \
"${ENABLED_DOMAINS[@]}" \
"${ENABLED_DOMAINS[@]}" \
| tr ' ' '\n' | sort | uniq -u)
fi
[[ "$DEBUG" == true ]] && echo "Disabled domains: ${DISABLED_DOMAINS[*]}"
# Remove disabled domains symlinks if present.
# Return 1 if nothing was removed and 0 otherwise.
if [[ ${#DISABLED_DOMAINS[@]} -gt 0 ]]; then
[[ "$DEBUG" == true ]] && echo "Some domains are disabled :"
for disabled_domain in "${DISABLED_DOMAINS[@]}"; do
[[ "$DEBUG" == true ]] && echo "Checking domain ${disabled_domain}"
cert_folder="$(readlink -f /etc/nginx/certs/"${disabled_domain}".crt)"
# If the dotfile is absent, skip domain.
if [[ ! -e "${cert_folder%/*}/.companion" ]]; then
[[ "$DEBUG" == true ]] && echo "No .companion file found in ${cert_folder}. ${disabled_domain} is not managed by letsencrypt-nginx-proxy-companion. Skipping domain."
continue
else
[[ "$DEBUG" == true ]] && echo "${disabled_domain} is managed by letsencrypt-nginx-proxy-companion. Removing unused symlinks."
fi
for extension in .crt .key .dhparam.pem .chain.pem; do
file="${disabled_domain}${extension}"
if [[ -n "${file// }" ]] && [[ -L "/etc/nginx/certs/${file}" ]]; then
[[ "$DEBUG" == true ]] && echo "Removing /etc/nginx/certs/${file}"
rm -f "/etc/nginx/certs/${file}"
fi
done
done
return 0
else
return 1
fi
}
function update_certs {
local -a LETSENCRYPT_CONTAINERS
local -a LETSENCRYPT_STANDALONE_CERTS
check_nginx_proxy_container_run || return
# Load relevant container settings
if [[ -f /app/letsencrypt_service_data ]]; then
source /app/letsencrypt_service_data
else
echo "Warning: /app/letsencrypt_service_data not found, skipping data from containers."
fi
# Load settings for standalone certs
if [[ -f /app/letsencrypt_user_data ]]; then
if source /app/letsencrypt_user_data; then
for cid in "${LETSENCRYPT_STANDALONE_CERTS[@]}"; do
host_varname="LETSENCRYPT_${cid}_HOST"
hosts_array="${host_varname}[@]"
for domain in "${!hosts_array}"; do
add_standalone_configuration "$domain"
done
done
reload_nginx
LETSENCRYPT_CONTAINERS+=( "${LETSENCRYPT_STANDALONE_CERTS[@]}" )
else
echo "Warning: could not source /app/letsencrypt_user_data, skipping user data"
fi
fi
should_reload_nginx='false'
for cid in "${LETSENCRYPT_CONTAINERS[@]}"; do
should_restart_container='false'
# Derive host and email variable names
host_varname="LETSENCRYPT_${cid}_HOST"
# Array variable indirection hack: http://stackoverflow.com/a/25880676/350221
hosts_array="${host_varname}[@]"
hosts_array_expanded=("${!hosts_array}")
# First domain will be our base domain
base_domain="${hosts_array_expanded[0]}"
params_d_arr=()
# Use container's LETSENCRYPT_EMAIL if set, fallback to DEFAULT_EMAIL
email_varname="LETSENCRYPT_${cid}_EMAIL"
email_address="${!email_varname:-"<no value>"}"
if [[ "$email_address" != "<no value>" ]]; then
params_d_arr+=(--email "$email_address")
elif [[ -n "${DEFAULT_EMAIL:-}" ]]; then
params_d_arr+=(--email "$DEFAULT_EMAIL")
fi
keysize_varname="LETSENCRYPT_${cid}_KEYSIZE"
cert_keysize="${!keysize_varname:-"<no value>"}"
if [[ "$cert_keysize" == "<no value>" ]]; then
cert_keysize=$DEFAULT_KEY_SIZE
fi
test_certificate_varname="LETSENCRYPT_${cid}_TEST"
le_staging_uri="https://acme-staging-v02.api.letsencrypt.org/directory"
if [[ $(lc "${!test_certificate_varname:-}") == true ]] || \
[[ "$ACME_CA_URI" == "$le_staging_uri" ]]; then
# Use staging Let's Encrypt ACME end point
acme_ca_uri="$le_staging_uri"
# Prefix test certificate directory with _test_
certificate_dir="/etc/nginx/certs/_test_$base_domain"
else
# Use default or user provided ACME end point
acme_ca_uri="$ACME_CA_URI"
certificate_dir="/etc/nginx/certs/$base_domain"
fi
account_varname="LETSENCRYPT_${cid}_ACCOUNT_ALIAS"
account_alias="${!account_varname:-"<no value>"}"
if [[ "$account_alias" == "<no value>" ]]; then
account_alias=default
fi
[[ "$DEBUG" == true ]] && params_d_arr+=(-v)
[[ $REUSE_PRIVATE_KEYS == true ]] && params_d_arr+=(--reuse_key)
min_validity="LETSENCRYPT_${cid}_MIN_VALIDITY"
min_validity="${!min_validity:-"<no value>"}"
if [[ "$min_validity" == "<no value>" ]]; then
min_validity=$DEFAULT_MIN_VALIDITY
fi
# Sanity Check
# Upper Bound
if [[ $min_validity -gt $MIN_VALIDITY_CAP ]]; then
min_validity=$MIN_VALIDITY_CAP
fi
# Lower Bound
if [[ $min_validity -lt $((seconds_to_wait * 2)) ]]; then
min_validity=$((seconds_to_wait * 2))
fi
if [[ "${1}" == "--force-renew" ]]; then
# Manually set to highest certificate lifetime given by LE CA
params_d_arr+=(--valid_min 7776000)
else
params_d_arr+=(--valid_min "$min_validity")
fi
# Create directory for the first domain,
# make it root readable only and make it the cwd
mkdir -p "$certificate_dir"
set_ownership_and_permissions "$certificate_dir"
pushd "$certificate_dir" || return
for domain in "${!hosts_array}"; do
# Add all the domains to certificate
params_d_arr+=(-d "$domain")
# Add location configuration for the domain
add_location_configuration "$domain" || reload_nginx
done
if [[ -e "./account_key.json" ]] && [[ ! -e "./account_reg.json" ]]; then
# If there is an account key present without account registration, this is
# a leftover from the ACME v1 version of simp_le. Remove this account key.
rm -f ./account_key.json
[[ "$DEBUG" == true ]] \
&& echo "Debug: removed ACME v1 account key $certificate_dir/account_key.json"
fi
# The ACME account key and registration full path are derived from the
# endpoint URI + the account alias (set to 'default' if no alias is provided)
account_dir="../accounts/${acme_ca_uri#*://}"
if [[ $REUSE_ACCOUNT_KEYS == true ]]; then
for type in "key" "reg"; do
file_full_path="${account_dir}/${account_alias}_${type}.json"
simp_le_file="./account_${type}.json"
if [[ -f "$file_full_path" ]]; then
# If there is no symlink to the account file, create it
if [[ ! -L "$simp_le_file" ]]; then
ln -sf "$file_full_path" "$simp_le_file" \
&& set_ownership_and_permissions "$simp_le_file"
# If the symlink target the wrong account file, replace it
elif [[ "$(readlink -f "$simp_le_file")" != "$file_full_path" ]]; then
ln -sf "$file_full_path" "$simp_le_file" \
&& set_ownership_and_permissions "$simp_le_file"
fi
fi
done
fi
echo "Creating/renewal $base_domain certificates... (${hosts_array_expanded[*]})"
/usr/bin/simp_le \
-f account_key.json -f account_reg.json \
-f key.pem -f chain.pem -f fullchain.pem -f cert.pem \
"${params_d_arr[@]}" \
--cert_key_size="$cert_keysize" \
--server="$acme_ca_uri" \
--default_root /usr/share/nginx/html/
simp_le_return=$?
if [[ $REUSE_ACCOUNT_KEYS == true ]]; then
mkdir -p "$account_dir"
for type in "key" "reg"; do
file_full_path="${account_dir}/${account_alias}_${type}.json"
simp_le_file="./account_${type}.json"
# If the account file to be reused does not exist yet, copy it
# from the CWD and replace the file in CWD with a symlink
if [[ ! -f "$file_full_path" && -f "$simp_le_file" ]]; then
cp "$simp_le_file" "$file_full_path"
ln -sf "$file_full_path" "$simp_le_file"
fi
done
fi
popd || return
if [[ $simp_le_return -ne 2 ]]; then
for domain in "${!hosts_array}"; do
if [[ "$acme_ca_uri" == "$le_staging_uri" ]]; then
create_links "_test_$base_domain" "$domain" && should_reload_nginx='true' && should_restart_container='true'
else
create_links "$base_domain" "$domain" && should_reload_nginx='true' && should_restart_container='true'
fi
done
touch "${certificate_dir}/.companion"
# Set ownership and permissions of the files inside $certificate_dir
for file in .companion cert.pem key.pem chain.pem fullchain.pem account_key.json account_reg.json; do
file_path="${certificate_dir}/${file}"
[[ -e "$file_path" ]] && set_ownership_and_permissions "$file_path"
done
account_path="/etc/nginx/certs/accounts/${acme_ca_uri#*://}"
account_key_perm_path="${account_path}/${account_alias}_key.json"
account_reg_perm_path="${account_path}/${account_alias}_reg.json"
# Account key and registration files do not necessarily exists after
# simp_le exit code 1. Check if they exist before perm check (#591).
[[ -f "$account_key_perm_path" ]] && set_ownership_and_permissions "$account_key_perm_path"
[[ -f "$account_reg_perm_path" ]] && set_ownership_and_permissions "$account_reg_perm_path"
# Set ownership and permissions of the ACME account folder and its
# parent folders (up to /etc/nginx/certs/accounts included)
until [[ "$account_path" == /etc/nginx/certs ]]; do
set_ownership_and_permissions "$account_path"
account_path="$(dirname "$account_path")"
done
# Queue nginx reload if a certificate was issued or renewed
[[ $simp_le_return -eq 0 ]] && should_reload_nginx='true' && should_restart_container='true'
fi
# Restart container if certs are updated and the respective environmental variable is set
restart_container_var="LETSENCRYPT_${cid}_RESTART_CONTAINER"
if [[ $(lc "${!restart_container_var:-}") == true ]] && [[ "$should_restart_container" == 'true' ]]; then
echo "Restarting container (${cid})..."
docker_restart "${cid}"
fi
for domain in "${!hosts_array}"; do
if [[ -f "/etc/nginx/conf.d/standalone-cert-$domain.conf" ]]; then
[[ $DEBUG == true ]] && echo "Debug: removing standalone configuration file /etc/nginx/conf.d/standalone-cert-$domain.conf"
rm -f "/etc/nginx/conf.d/standalone-cert-$domain.conf" && should_reload_nginx='true'
fi
done
done
cleanup_links && should_reload_nginx='true'
[[ "$should_reload_nginx" == 'true' ]] && reload_nginx
}
# Allow the script functions to be sourced without starting the Service Loop.
if [ "${1}" == "--source-only" ]; then
return 0
fi
pid=
# Service Loop: When this script exits, start it again.
trap '[[ $pid ]] && kill $pid; exec $0' EXIT
trap 'trap - EXIT' INT TERM
update_certs "$@"
# Wait some amount of time
echo "Sleep for ${seconds_to_wait}s"
sleep $seconds_to_wait & pid=$!
wait
pid=