#!/bin/bash # shellcheck source=functions.sh source /app/functions.sh CERTS_UPDATE_INTERVAL="${CERTS_UPDATE_INTERVAL:-3600}" ACME_CA_URI="${ACME_CA_URI:-"https://acme-v02.api.letsencrypt.org/directory"}" ACME_CA_TEST_URI="https://acme-staging-v02.api.letsencrypt.org/directory" DEFAULT_KEY_SIZE="${DEFAULT_KEY_SIZE:-4096}" RENEW_PRIVATE_KEYS="$(lc "${RENEW_PRIVATE_KEYS:-true}")" # Backward compatibility environment variable REUSE_PRIVATE_KEYS="$(lc "${REUSE_PRIVATE_KEYS:-false}")" function strip_wildcard { # Remove wildcard prefix if present # https://github.com/nginx-proxy/nginx-proxy/tree/main/docs#wildcard-certificates local -r domain="${1?missing domain argument}" if [[ "${domain:0:2}" == "*." ]]; then echo "${domain:2}" else echo "$domain" fi } 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" == 1 ]] && 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 domain=${2?missing base_domain argument} domain="$(strip_wildcard "$domain")" 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 certificate # exists in /etc/nginx/certs (excluding default cert). for symlinked_domain in /etc/nginx/certs/*.crt; do [[ -L "$symlinked_domain" ]] || continue symlinked_domain="${symlinked_domain##*/}" symlinked_domain="${symlinked_domain%*.crt}" [[ "$symlinked_domain" != "default" ]] || continue SYMLINKED_DOMAINS+=("$symlinked_domain") done [[ "$DEBUG" == 1 ]] && 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 local -n hosts_array="LETSENCRYPT_${cid}_HOST" for domain in "${hosts_array[@]}"; do domain="$(strip_wildcard "$domain")" # Add domain to the array storing currently enabled domains. ENABLED_DOMAINS+=("$domain") done done [[ "$DEBUG" == 1 ]] && 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" == 1 ]] && 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" == 1 ]] && echo "Some domains are disabled :" for disabled_domain in "${DISABLED_DOMAINS[@]}"; do [[ "$DEBUG" == 1 ]] && 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" == 1 ]] && echo "No .companion file found in ${cert_folder}. ${disabled_domain} is not managed by acme-companion. Skipping domain." continue else [[ "$DEBUG" == 1 ]] && echo "${disabled_domain} is managed by acme-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" == 1 ]] && echo "Removing /etc/nginx/certs/${file}" rm -f "/etc/nginx/certs/${file}" fi done done return 0 else return 1 fi } function update_cert { local cid="${1:?}" local -n hosts_array="LETSENCRYPT_${cid}_HOST" # First domain will be our base domain local base_domain="${hosts_array[0]}" local wildcard_certificate='false' if [[ "${base_domain:0:2}" == "*." ]]; then wildcard_certificate='true' fi local should_restart_container='false' # Base CLI parameters array, used for both --register-account and --issue local -a params_base_arr params_base_arr+=(--log /dev/null) params_base_arr+=(--useragent "nginx-proxy/acme-companion/$COMPANION_VERSION (acme.sh/$ACMESH_VERSION)") [[ "$DEBUG" == 1 ]] && params_base_arr+=(--debug 2) # Alternative trusted root CA path, used for test with Pebble if [[ -n "${CA_BUNDLE// }" ]]; then if [[ -f "$CA_BUNDLE" ]]; then params_base_arr+=(--ca-bundle "$CA_BUNDLE") [[ "$DEBUG" == 1 ]] && echo "Debug: acme.sh will use $CA_BUNDLE as trusted root CA." else echo "Warning: the path to the alternate CA bundle ($CA_BUNDLE) is not valid, using default Alpine trust store." fi fi # CLI parameters array used for --register-account local -a params_register_arr # CLI parameters array used for --issue local -a params_issue_arr # ACME challenge type local -n acme_challenge="ACME_${cid}_CHALLENGE" acme_challenge="${acme_challenge:-HTTP-01}" if [[ "$acme_challenge" == "HTTP-01" ]]; then # HTTP-01 challenge if [[ "$wildcard_certificate" == 'true' ]]; then echo "Error: wildcard certificates (${base_domain}) can't be obtained with HTTP-01 challenge" return 1 fi params_issue_arr+=(--webroot /usr/share/nginx/html) elif [[ "$acme_challenge" == "DNS-01" ]]; then # DNS-01 challenge local -n acmesh_dns_config="ACMESH_${cid}_DNS_API_CONFIG" local acmesh_dns_api="${acmesh_dns_config[DNS_API]}" if [[ -z "$acmesh_dns_api" ]]; then echo "Error: missing acme.sh DNS API for DNS challenge" return 1 fi params_issue_arr+=(--dns "$acmesh_dns_api") # Loop over defined variable for acme.sh DNS api config local -a dns_api_keys for key in "${!acmesh_dns_config[@]}"; do [[ "$key" == "DNS_API" ]] && continue dns_api_keys+=("$key") local value="${acmesh_dns_config[$key]}" declare -x "$key"="$value" done echo "Info: DNS challenge using $acmesh_dns_api DNS API with the following keys: ${dns_api_keys[*]}" else echo "Error: unknown ACME challenge method: $acme_challenge" return 1 fi local -n cert_keysize="LETSENCRYPT_${cid}_KEYSIZE" if [[ -z "$cert_keysize" ]] || \ [[ ! "$cert_keysize" =~ ^('2048'|'3072'|'4096'|'ec-256'|'ec-384')$ ]]; then cert_keysize=$DEFAULT_KEY_SIZE fi params_issue_arr+=(--keylength "$cert_keysize") # OCSP-Must-Staple extension local -n ocsp="ACME_${cid}_OCSP" if [[ $(lc "$ocsp") == true ]]; then params_issue_arr+=(--ocsp-must-staple) fi local -n accountemail="LETSENCRYPT_${cid}_EMAIL" local config_home # If we don't have a LETSENCRYPT_EMAIL from the proxied container # and DEFAULT_EMAIL is set to a non empty value, use the latter. if [[ -z "$accountemail" ]]; then if [[ -n "${DEFAULT_EMAIL// }" ]]; then accountemail="$DEFAULT_EMAIL" else unset accountemail fi fi if [[ -n "${accountemail// }" ]]; then # If we got an email, use it with the corresponding config home config_home="/etc/acme.sh/$accountemail" else # If we did not get any email at all, use the default (empty mail) config config_home="/etc/acme.sh/default" fi local -n acme_ca_uri="ACME_${cid}_CA_URI" if [[ -z "$acme_ca_uri" ]]; then # Use default or user provided ACME end point acme_ca_uri="$ACME_CA_URI" fi # LETSENCRYPT_TEST overrides LETSENCRYPT_ACME_CA_URI local -n test_certificate="LETSENCRYPT_${cid}_TEST" if [[ $(lc "$test_certificate") == true ]]; then # Use Let's Encrypt ACME V2 staging end point acme_ca_uri="$ACME_CA_TEST_URI" fi # Set relevant --server parameter and ca folder name params_base_arr+=(--server "$acme_ca_uri") # Reproduce acme.sh logic to determine the ca account folder path local ca_host_dir ca_host_dir="$(echo "$acme_ca_uri" | cut -d : -f 2 | tr -s / | cut -d / -f 2)" local ca_path_dir ca_path_dir="$(echo "$acme_ca_uri" | cut -d : -f 2- | tr -s / | cut -d / -f 3-)" local relative_certificate_dir if [[ "$wildcard_certificate" == 'true' ]]; then relative_certificate_dir="wildcard_${base_domain:2}" else relative_certificate_dir="$base_domain" fi # If we're going to use one of LE stating endpoints ... if [[ "$acme_ca_uri" =~ ^https://acme-staging.* ]]; then # Unset accountemail # force config dir to 'staging' unset accountemail config_home="/etc/acme.sh/staging" # Prefix test certificate directory with _test_ relative_certificate_dir="_test_${relative_certificate_dir}" fi local absolute_certificate_dir="/etc/nginx/certs/$relative_certificate_dir" params_issue_arr+=( \ --cert-file "${absolute_certificate_dir}/cert.pem" \ --key-file "${absolute_certificate_dir}/key.pem" \ --ca-file "${absolute_certificate_dir}/chain.pem" \ --fullchain-file "${absolute_certificate_dir}/fullchain.pem" \ ) [[ ! -d "$config_home" ]] && mkdir -p "$config_home" params_base_arr+=(--config-home "$config_home") local account_file="${config_home}/ca/${ca_host_dir}/${ca_path_dir}/account.json" # External Account Binding (EAB) local -n eab_kid="ACME_${cid}_EAB_KID" local -n eab_hmac_key="ACME_${cid}_EAB_HMAC_KEY" if [[ ! -f "$account_file" ]]; then if [[ -n "${eab_kid}" && -n "${eab_hmac_key}" ]]; then # Register the ACME account with the per container EAB credentials. params_register_arr+=(--eab-kid "$eab_kid" --eab-hmac-key "$eab_hmac_key") elif [[ -n "${ACME_EAB_KID// }" && -n "${ACME_EAB_HMAC_KEY// }" ]]; then # We don't have per-container EAB kid and hmac key or Zero SSL API key. # Register the ACME account with the default EAB credentials. params_register_arr+=(--eab-kid "$ACME_EAB_KID" --eab-hmac-key "$ACME_EAB_HMAC_KEY") elif [[ -n "${accountemail// }" ]]; then # We don't have per container nor default EAB credentials, register a new account. params_register_arr+=(--accountemail "$accountemail") fi fi # Zero SSL if [[ "$acme_ca_uri" == "https://acme.zerossl.com/v2/DV90" ]]; then # Test if we already have: # - an account file # - the --accountemail account registration parameter # - the --eab-kid and --eab-hmac-key account registration parameters local account_ok='false' if [[ -f "$account_file" ]]; then account_ok='true' elif in_array '--accountemail' 'params_register_arr'; then account_ok='true' elif in_array '--eab-kid' 'params_register_arr' && in_array '--eab-hmac-key' 'params_register_arr'; then account_ok='true' fi if [[ $account_ok == 'false' ]]; then local -n zerossl_api_key="ZEROSSL_${cid}_API_KEY" if [[ -z "$zerossl_api_key" ]]; then # Try using the default API key zerossl_api_key="${ZEROSSL_API_KEY:-}" fi if [[ -n "${zerossl_api_key// }" ]]; then # Generate a set of ACME EAB credentials using the ZeroSSL API. local zerossl_api_response if zerossl_api_response="$(curl -s -X POST "https://api.zerossl.com/acme/eab-credentials?access_key=${zerossl_api_key}")"; then if [[ "$(jq -r .success <<< "$zerossl_api_response")" == 'true' ]]; then eab_kid="$(jq -r .eab_kid <<< "$zerossl_api_response")" eab_hmac_key="$(jq -r .eab_hmac_key <<< "$zerossl_api_response")" params_register_arr+=(--eab-kid "$eab_kid" --eab-hmac-key "$eab_hmac_key") [[ "$DEBUG" == 1 ]] && echo "Successfull EAB credentials request against the ZeroSSL API, got the following EAB kid : ${eab_kid}" else # The JSON response body indicated an unsuccesfull API call. echo "Warning: the EAB credentials request against the ZeroSSL API was not successfull." fi else # curl failed. echo "Warning: curl failed to make an HTTP POST request to https://api.zerossl.com/acme/eab-credentials." fi else # We don't have a Zero SSL ACME account, EAB credentials, a ZeroSSL API key or an account email : # skip certificate account registration and certificate issuance. echo "Error: usage of ZeroSSL require an email bound account. No EAB credentials, ZeroSSL API key or email were provided for this certificate, creation aborted." return 1 fi fi fi # Account registration and update if required if [[ ! -f "$account_file" ]]; then params_register_arr=("${params_base_arr[@]}" "${params_register_arr[@]}") [[ "$DEBUG" == 1 ]] && echo "Calling acme.sh --register-account with the following parameters : ${params_register_arr[*]}" acme.sh --register-account "${params_register_arr[@]}" fi if [[ -n "${accountemail// }" ]] && ! grep -q "mailto:$accountemail" "$account_file"; then local -a params_update_arr=("${params_base_arr[@]}" --accountemail "$accountemail") [[ "$DEBUG" == 1 ]] && echo "Calling acme.sh --update-account with the following parameters : ${params_update_arr[*]}" acme.sh --update-account "${params_update_arr[@]}" fi # If we still don't have an account.json file by this point, we've got an issue if [[ ! -f "$account_file" ]]; then echo "Error: no ACME account was found or registered for $accountemail and $acme_ca_uri, certificate creation aborted." return 1 fi # acme.sh pre and post hooks local -n acme_pre_hook="ACME_${cid}_PRE_HOOK" if [[ -n "${acme_pre_hook}" ]]; then # Use per-container pre hook params_issue_arr+=(--pre-hook "$acme_pre_hook") elif [[ -n ${ACME_PRE_HOOK// } ]]; then # Use default pre hook params_issue_arr+=(--pre-hook "$ACME_PRE_HOOK") fi local -n acme_post_hook="ACME_${cid}_POST_HOOK" if [[ -n "${acme_post_hook}" ]]; then # Use per-container post hook params_issue_arr+=(--post-hook "$acme_post_hook") elif [[ -n ${ACME_POST_HOOK// } ]]; then # Use default post hook params_issue_arr+=(--post-hook "$ACME_POST_HOOK") fi local -n acme_preferred_chain="ACME_${cid}_PREFERRED_CHAIN" if [[ -n "${acme_preferred_chain}" ]]; then # Using amce.sh --preferred-chain to select alternate chain. params_issue_arr+=(--preferred-chain "$acme_preferred_chain") fi if [[ "$RENEW_PRIVATE_KEYS" != 'false' && "$REUSE_PRIVATE_KEYS" != 'true' ]]; then params_issue_arr+=(--always-force-new-domain-key) fi [[ "${2:-}" == "--force-renew" ]] && params_issue_arr+=(--force) # Create directory for the first domain mkdir -p "$absolute_certificate_dir" set_ownership_and_permissions "$absolute_certificate_dir" for domain in "${hosts_array[@]}"; do # Add all the domains to certificate params_issue_arr+=(--domain "$domain") # If enabled, add location configuration for the domain if [[ "$acme_challenge" == "HTTP-01" ]] && parse_true "${ACME_HTTP_CHALLENGE_LOCATION:=false}"; then add_location_configuration "$domain" || reload_nginx fi done params_issue_arr=("${params_base_arr[@]}" "${params_issue_arr[@]}") [[ "$DEBUG" == 1 ]] && echo "Calling acme.sh --issue with the following parameters : ${params_issue_arr[*]}" echo "Creating/renewal $base_domain certificates... (${hosts_array[*]})" acme.sh --issue "${params_issue_arr[@]}" local acmesh_return=$? # DNS challenge: clean environment variables if [[ "$acme_challenge" == "DNS-01" ]]; then local -n acmesh_dns_config="ACMESH_${cid}_DNS_API_CONFIG" # Loop over defined variable for acme.sh DNS api config for key in "${!acmesh_dns_config[@]}"; do [[ "$key" == "DNS_API" ]] && continue unset "$key" done fi # 0 = success, 2 = RENEW_SKIP if [[ $acmesh_return == 0 || $acmesh_return == 2 ]]; then for domain in "${hosts_array[@]}"; do create_links "$relative_certificate_dir" "$domain" \ && should_reload_nginx='true' \ && should_restart_container='true' done echo "${COMPANION_VERSION:-}" > "${absolute_certificate_dir}/.companion" set_ownership_and_permissions "${absolute_certificate_dir}/.companion" # Make private key root readable only for file in cert.pem key.pem chain.pem fullchain.pem; do local file_path="${absolute_certificate_dir}/${file}" [[ -e "$file_path" ]] && set_ownership_and_permissions "$file_path" done local acme_private_key acme_private_key="$(find /etc/acme.sh -name "*.key" -path "*${hosts_array[0]}*")" [[ -e "$acme_private_key" ]] && set_ownership_and_permissions "$acme_private_key" # Queue nginx reload if a certificate was issued or renewed [[ $acmesh_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 local -n restart_container="LETSENCRYPT_${cid}_RESTART_CONTAINER" if [[ $(lc "$restart_container") == 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" == 1 ]] && 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 } function update_certs { local -a LETSENCRYPT_CONTAINERS local -a LETSENCRYPT_STANDALONE_CERTS pushd /etc/nginx/certs > /dev/null || return 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 local -n hosts_array="LETSENCRYPT_${cid}_HOST" 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 # Pass the eventual --force-renew arg to update_cert() as second arg update_cert "$cid" "${1:-}" done cleanup_links && should_reload_nginx='true' [[ "$should_reload_nginx" == 'true' ]] && reload_nginx popd > /dev/null || return } # 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 ${CERTS_UPDATE_INTERVAL}s" sleep $CERTS_UPDATE_INTERVAL & pid=$! wait pid=