#!/usr/bin/env bash source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" source "$PLUGIN_CORE_AVAILABLE_PATH/certs/functions" source "$PLUGIN_CORE_AVAILABLE_PATH/domains/functions" source "$PLUGIN_CORE_AVAILABLE_PATH/nginx-vhosts/functions" if [[ -f "$PLUGIN_CORE_AVAILABLE_PATH/nginx-vhosts/internal-functions" ]]; then source "$PLUGIN_CORE_AVAILABLE_PATH/nginx-vhosts/internal-functions" fi source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/config" set -eo pipefail [[ $DOKKU_TRACE ]] && set -x fn-letsencrypt-acme-execute-challenge() { declare desc="perform actual ACME validation procedure" declare APP="$1" local FAKE_NGINX_CONF=false local challenge_mode config_dir if [[ ! -f "$DOKKU_ROOT/$APP/nginx.conf" ]]; then FAKE_NGINX_CONF=true fi fn-letsencrypt-create-root "$APP" challenge_mode="HTTP-01" dns_provider="$(fn-letsencrypt-computed-dns-provider "$APP")" if [[ -n "$dns_provider" ]]; then challenge_mode="DNS-01" fi dokku_log_info1 "Getting letsencrypt certificate for ${APP} via ${challenge_mode}" # read arguments from appropriate config file into the config array config_dirs="$(fn-letsencrypt-configure-and-get-dir "$APP")" host_config_dir="$(echo "$config_dirs" | cut -d: -f1)" container_config_dir="$(echo "$config_dirs" | cut -d: -f2)" read -r -a config <"$container_config_dir/config" # run letsencrypt as a docker container using "certonly" mode # port 80 of the standalone webserver will be forwarded by the proxy set +e export DOKKU_UID=$(id -u) export DOKKU_GID=$(id -g) mkdir -p "$DOKKU_LIB_ROOT/data/letsencrypt/$APP" docker run --rm \ --env-file "$host_config_dir/docker.env" \ --user $DOKKU_UID:$DOKKU_GID \ -v "$host_config_dir:/certs" \ -v "$DOKKU_LIB_ROOT/data/letsencrypt/$APP:/webroot" \ "${PLUGIN_IMAGE}:${PLUGIN_IMAGE_VERSION}" \ "${config[@]}" run | sed "s/^/ /" local exit_code=$? set -e if [[ "$FAKE_NGINX_CONF" == "true" ]]; then rm "$DOKKU_ROOT/$APP/nginx.conf" fi if [[ $exit_code != 0 ]]; then dokku_log_info1 "Certificate retrieval failed!" return $exit_code fi # got certificate dokku_log_info1 "Certificate retrieved successfully." fn-letsencrypt-symlink-certs "$APP" "$container_config_dir" plugn trigger proxy-build-config "$APP" } fn-letsencrypt-acme-revoke() { declare desc="perform actual certificate revocation" local APP="$1" fn-letsencrypt-create-root "$APP" challenge_mode="HTTP-01" dns_provider="$(fn-letsencrypt-computed-dns-provider "$APP")" if [[ -n "$dns_provider" ]]; then challenge_mode="DNS-01" fi dokku_log_info1 "Revoking letsencrypt certificate for ${APP} via ${challenge_mode}" local acme_port="$(plugn trigger ports-get-available)" if [[ -z "$acme_port" ]]; then acme_port="$(get_available_port)" fi # read arguments from appropriate config file into the config array config_dirs="$(fn-letsencrypt-configure-and-get-dir "$APP")" host_config_dir="$(echo "$config_dirs" | cut -d: -f1)" container_config_dir="$(echo "$config_dirs" | cut -d: -f2)" read -r -a config <"$container_config_dir/config" # run letsencrypt as a docker container using "certonly" mode # port 80 of the standalone webserver will be forwarded by the proxy set +e export DOKKU_UID=$(id -u) export DOKKU_GID=$(id -g) mkdir -p "$DOKKU_LIB_ROOT/data/letsencrypt/$APP" docker run --rm \ --env-file "$host_config_dir/docker.env" \ --user $DOKKU_UID:$DOKKU_GID \ -p "$acme_port:$acme_port" \ -v "$host_config_dir:/certs" \ -v "$DOKKU_LIB_ROOT/data/letsencrypt/$APP:/webroot" \ "${PLUGIN_IMAGE}:${PLUGIN_IMAGE_VERSION}" \ "${config[@]}" revoke | sed "s/^/ /" local exit_code=$? set -e # handle return codes if [[ $exit_code == 0 ]]; then # certificate revoked dokku_log_info1 "Certificate revoked successfully." else # error - don't try to link certificates dokku_log_info1 "Certificate revocation failed (code $simple_result)!" return fi local domain="$(get_app_domains "$APP" | xargs | awk '{print $1}')" # removing the certificate will automatically reconfigure nginx if [[ -z $DOKKU_APP_NAME ]]; then dokku certs:remove "$APP" else dokku certs:remove fi } fn-letsencrypt-acme-proxy-disable() { declare desc="disable ACME proxy for an app" local APP="$1" local app_root="$DOKKU_ROOT/$APP" local app_config_dir="$app_root/nginx.conf.d" dokku_log_info1 "Disabling ACME proxy for $APP..." [[ -f "$app_config_dir/letsencrypt.conf" ]] && rm "$app_config_dir/letsencrypt.conf" restart_nginx | sed "s/^/ /" } fn-letsencrypt-acme-proxy-enable() { declare desc="enable ACME proxy for an app" local APP="$1" local app_root="$DOKKU_ROOT/$APP" local app_config_dir="$app_root/nginx.conf.d" dokku_log_info1 "Enabling ACME proxy for ${APP}..." # ensure the nginx.conf.d directory exists [[ -d "$app_config_dir" ]] || mkdir "$app_config_dir" # generate letsencrypt config sigil -f "$PLUGIN_AVAILABLE_PATH/letsencrypt/templates/letsencrypt.conf.sigil" \ APP="$APP" \ DOKKU_LIB_ROOT="$DOKKU_LIB_ROOT" \ >"$app_config_dir/letsencrypt.conf" restart_nginx | sed "s/^/ /" } fn-letsencrypt-computed-dns-provider() { declare desc="get configured dns provider" declare APP="$1" value="$(fn-letsencrypt-dns-provider "$APP")" if [[ -z "$value" ]]; then value="$(fn-letsencrypt-global-dns-provider)" fi echo "$value" } fn-letsencrypt-global-dns-provider() { declare desc="get configured dns provider" fn-plugin-property-get-default "letsencrypt" "--global" "dns-provider" "" } fn-letsencrypt-dns-provider() { declare desc="get configured dns provider" declare APP="$1" fn-plugin-property-get-default "letsencrypt" "$APP" "dns-provider" "" } fn-letsencrypt-computed-lego-docker-args() { declare desc="get configured lego docker args" declare APP="$1" value="$(fn-letsencrypt-lego-docker-args "$APP")" if [[ -z "$value" ]]; then value="$(fn-letsencrypt-global-lego-docker-args)" fi echo "$value" } fn-letsencrypt-global-lego-docker-args() { declare desc="get configured lego docker args" fn-plugin-property-get-default "letsencrypt" "--global" "lego-docker-args" "" } fn-letsencrypt-lego-docker-args() { declare desc="get configured lego docker args" declare APP="$1" fn-plugin-property-get-default "letsencrypt" "$APP" "lego-docker-args" "" } fn-letsencrypt-check-email() { declare desc="Check if an e-mail address is provided globally or for the app" declare APP="$1" # check we have a valid e-mail address if [[ -z "$(fn-letsencrypt-computed-email "$APP")" ]]; then dokku_log_warn "ERROR: Cannot request a certificate without an e-mail address!" dokku_log_warn " please provide your e-mail address using" dokku_log_warn " dokku letsencrypt:set $APP email " return 1 fi } fn-letsencrypt-configure-and-get-dir() { declare desc="assemble lego command line arguments and create a config hash directory for them" declare APP="$1" local config config_dir config_hash dns_provider domain_args domains email extra_args cert_timeout key server value local app_root="$DOKKU_ROOT/$APP" local le_root="$app_root/letsencrypt" mkdir -p "$DOKKU_ROOT/$APP/letsencrypt/account" # build up a string of all certificate-controlling configuration settings. # this will be used to determine the folder name for the account key and certificates # get the selected ACME server server="$(fn-letsencrypt-computed-server "$APP")" # construct domain arguments domains="$(get_app_domains "$APP")" domain_args='' for domain in $domains; do dokku_log_verbose " - Domain '$domain'" >&2 domain_args="$domain_args --domains $domain" done # lego --cert.timeout is in seconds and defaults to 30 cert_timeout=30 email="$(fn-letsencrypt-computed-email "$APP")" extra_args="$(fn-letsencrypt-computed-lego-docker-args "$APP")" config="--pem --accept-tos --cert.timeout $cert_timeout --path /certs --server $server --email $email $extra_args $domain_args" dns_provider="$(fn-letsencrypt-computed-dns-provider "$APP")" if [[ -n "$dns_provider" ]]; then config="--dns $dns_provider $config" else config="--http --http.webroot /webroot $config" fi config_hash=$(echo "$config" | sha1sum | awk '{print $1}') config_dir="$le_root/certs/$config_hash" mkdir -p "$config_dir" rm -f "$config_dir/docker.env" touch "$config_dir/docker.env" if [[ -n "$dns_provider" ]]; then fn-plugin-property-get-all "letsencrypt" "--global" | while read -r line; do [[ -n "$line" ]] || continue key="$(cut -d" " -f1 <<<"$line")" if [[ "$key" == dns-provider-* ]]; then value="$(cut -d" " -f2 <<<"$line")" echo "${key#"dns-provider-"}=$value" >>"$config_dir/docker.env" fi done fn-plugin-property-get-all "letsencrypt" "$APP" | while read -r line; do [[ -n "$line" ]] || continue key="$(cut -d" " -f1 <<<"$line")" if [[ "$key" == dns-provider-* ]]; then value="$(cut -d" " -f2 <<<"$line")" echo "${key#"dns-provider-"}=$value" >>"$config_dir/docker.env" fi done fi # ensure the permissions are set correctly on anything that may expose api keys chmod 0755 "$config_dir/docker.env" # store config settings echo "$config" >"$config_dir/config" # send both host and container path # to respect mapped DOKKU_ROOT when running in a container echo "$DOKKU_HOST_ROOT/$APP/letsencrypt/certs/$config_hash:$config_dir" } fn-letsencrypt-cron-job-enabled() { declare desc="Check if the cron plugin is available" if [[ ! -f "$PLUGIN_AVAILABLE_PATH/cron/cron-write" ]]; then return 1 fi } fn-letsencrypt-cron-job-add() { declare desc="Add auto-renew cronjob to dokku user's crontab" touch "${DOKKU_LIB_ROOT}/data/letsencrypt/autorenew" if fn-letsencrypt-cron-job-enabled; then plugn trigger cron-write else crons="$(crontab -l || true)" crons="$(grep -v -F "$LETSENCRYPT_CRON_CMD" <<<"$crons")" printf "%s\n%s\n" "$crons" "$LETSENCRYPT_CRON_JOB" | crontab - fi dokku_log_info1 "Added cron job to dokku's crontab." } fn-letsencrypt-cron-job-remove() { declare desc="Remove auto-renew cronjob from dokku user's crontab" rm -f "${DOKKU_LIB_ROOT}/data/letsencrypt/autorenew" if fn-letsencrypt-cron-job-enabled; then plugn trigger cron-write else crons="$(crontab -l || true)" crons="$(grep -v -F "$LETSENCRYPT_CRON_CMD" <<<"$crons")" printf "%s" "$crons" | crontab - fi dokku_log_info1 "Removed cron job from dokku's crontab." } fn-letsencrypt-create-root() { declare desc="Ensure the let's encrypt root directory exists" declare APP="$1" local NGINX_ACCESS_LOG_FORMAT NGINX_ACCESS_LOG_PATH NGINX_ERROR_LOG_PATH local app_root="$DOKKU_ROOT/$APP" local le_root="$app_root/letsencrypt" mkdir -p "$le_root" if [[ ! -f "$DOKKU_ROOT/$APP/nginx.conf" ]]; then dokku_log_info1 "Setting temporary site" NGINX_ACCESS_LOG_FORMAT="$(fn-nginx-access-log-format "$APP")" NGINX_ACCESS_LOG_PATH="$(fn-nginx-access-log-path "$APP")" NGINX_ERROR_LOG_PATH="$(fn-nginx-error-log-path "$APP")" if [[ -z "$NGINX_ACCESS_LOG_FORMAT" ]]; then NGINX_ACCESS_LOG_FORMAT="$(fn-nginx-computed-access-log-format "$APP")" fi if [[ -z "$NGINX_ACCESS_LOG_PATH" ]]; then NGINX_ACCESS_LOG_PATH="$(fn-nginx-computed-access-log-path "$APP")" fi if [[ -z "$NGINX_ERROR_LOG_PATH" ]]; then NGINX_ERROR_LOG_PATH="$(fn-nginx-computed-error-log-path "$APP")" fi sigil -f "$PLUGIN_AVAILABLE_PATH/letsencrypt/templates/default-nginx.conf.sigil" \ DOMAINS="$(get_app_domains "$APP" | xargs)" DOKKU_ROOT="$DOKKU_ROOT" APP="$APP" \ NGINX_ACCESS_LOG_FORMAT="$NGINX_ACCESS_LOG_FORMAT" NGINX_ACCESS_LOG_PATH="$NGINX_ACCESS_LOG_PATH" \ NGINX_ERROR_LOG_PATH="$NGINX_ERROR_LOG_PATH" \ >"$DOKKU_ROOT/$APP/nginx.conf" restart_nginx | sed "s/^/ /" fi } fn-letsencrypt-is-autorenew-enabled() { declare desc="check if autorenew is enabled" local enabled=false if [[ -f "${DOKKU_LIB_ROOT}/data/letsencrypt/autorenew" ]]; then enabled=true fi echo "$enabled" } fn-letsencrypt-computed-email() { declare desc="get configured email address" declare APP="$1" value="$(fn-letsencrypt-email "$APP")" if [[ -z "$value" ]]; then value="$(fn-letsencrypt-global-email)" fi echo "$value" } fn-letsencrypt-global-email() { declare desc="get configured email address" fn-plugin-property-get-default "letsencrypt" "--global" "email" "" } fn-letsencrypt-email() { declare desc="get configured email address" declare APP="$1" fn-plugin-property-get-default "letsencrypt" "$APP" "email" "" } fn-letsencrypt-enable() { declare APP="$1" local EXIT_CODE=0 local domain verify_app_name "$APP" domain="$(get_app_domains "$APP" | xargs | awk '{print $1}')" if [[ -z "$domain" ]]; then dokku_log_warn "No domains detected for $APP" return 1 fi dokku_log_info2 "Enabling letsencrypt for $APP" fn-letsencrypt-check-email "$APP" fn-letsencrypt-acme-proxy-enable "$APP" fn-letsencrypt-acme-execute-challenge "$APP" || EXIT_CODE=$? # remove ACME proxy even if this fails fn-letsencrypt-acme-proxy-disable "$APP" if [[ "$EXIT_CODE" == 0 ]]; then dokku_log_info1 "Done" return fi dokku_log_warn "Failed to setup letsencrypt" DOKKU_FAIL_EXIT_CODE="$EXIT_CODE" dokku_log_fail "Check log output for further information on failure" } fn-letsencrypt-expiration() { declare desc="prints expiration time" declare APP="$1" if [[ -f "$DOKKU_ROOT/$APP/tls/server.letsencrypt.crt" ]]; then date -u -d "$(openssl x509 -in "$DOKKU_ROOT/$APP/tls/server.letsencrypt.crt" -enddate -noout | sed -e "s/^notAfter=//")" "+%s" else date -u -d "$(openssl x509 -in "$DOKKU_ROOT/$APP/tls/server.crt" -enddate -noout | sed -e "s/^notAfter=//")" "+%s" fi } fn-letsencrypt-computed-graceperiod() { declare desc="get configured graceperiod" declare APP="$1" local value value="$(fn-letsencrypt-graceperiod "$APP")" if [[ -z "$value" ]]; then value="$(fn-letsencrypt-global-graceperiod)" fi if [[ -z "$value" ]]; then value="$((60 * 60 * 24 * 30))" fi echo "$value" } fn-letsencrypt-global-graceperiod() { declare desc="get configured graceperiod" fn-plugin-property-get-default "letsencrypt" "--global" "graceperiod" "" } fn-letsencrypt-graceperiod() { declare desc="get configured graceperiod" declare APP="$1" fn-plugin-property-get-default "letsencrypt" "$APP" "graceperiod" "" } fn-letsencrypt-format-timediff() { declare desc="format a time difference in seconds into a human-readable string" local td="$1" local negative_td=0 if [ "$td" -lt 0 ]; then negative_td=1 td=$((-td)) fi local days=$((td / (24 * 60 * 60))) td=$((td % (24 * 60 * 60))) local hours=$((td / (60 * 60))) td=$((td % (60 * 60))) local minutes=$((td / 60)) local secs=$((td % 60)) local res="" if [ $days -gt 0 ]; then res="${days}d, " fi if [ $hours -gt 0 ]; then res="${res}${hours}h, " fi if [ $minutes -gt 0 ]; then res="${res}${minutes}m, " fi if [ $secs -gt 0 ]; then res="${res}${secs}s, " fi # remove trailing comma res="$(echo "$res" | sed -re 's/, ?$//g')" if [[ $negative_td == 1 ]]; then res="${res} ago" fi echo "$res" } fn-letsencrypt-is-active() { declare desc="outputs true if active, false otherwise" declare APP=$1 local domain # check if SSL is enabled on per-app level if ! is_ssl_enabled "$APP"; then echo "false" return fi domain="$(get_app_domains "$APP" | xargs | awk '{print $1}')" # check if certificate is identical to the current let's encrypt certificate by comparing SHA1 hashes local cert_sha1 if [[ -f "$DOKKU_ROOT/$APP/tls/server.letsencrypt.crt" ]]; then cert_sha1=$( (cat "$DOKKU_ROOT/$APP/tls/server.letsencrypt.crt" 2>/dev/null) | sha1sum || echo "not_found") else cert_sha1=$( (cat "$DOKKU_ROOT/$APP/tls/server.crt" 2>/dev/null) | sha1sum || echo "not_found") fi local le_sha1="not_found" local fileSafeDomain fileSafeDomain="${domain/\*/_}" # wildcards are using *.example.com which have certificates named _.example.com if [[ -f "$DOKKU_ROOT/$APP/letsencrypt/certs/current/certificates/$fileSafeDomain.pem" ]]; then le_sha1=$( (cat "$DOKKU_ROOT/$APP/letsencrypt/certs/current/certificates/$fileSafeDomain.crt" 2>/dev/null) | sha1sum || echo "not_found") elif [[ -f "$DOKKU_ROOT/$APP/letsencrypt/certs/current/fullchain.pem" ]]; then le_sha1=$( (cat "$DOKKU_ROOT/$APP/letsencrypt/certs/current/fullchain.pem" 2>/dev/null) | sha1sum || echo "not_found") fi if [[ "$cert_sha1" != "$le_sha1" ]]; then echo "false" return fi echo "true" } fn-letsencrypt-list-apps-with-expiry() { declare desc="list all letsencrypt-secured apps together with their expiry date" # prints a tab-separated list of # * app name # * expiry dates as UNIX timestamp (seconds since epoch) # * selected renewal grace period (in seconds) # * time left on certificate (in seconds) # * time until renewal (in seconds) for APP in $(dokku_apps); do if [[ "$APP" == "=====>" ]] || [[ "$APP" == "My" ]] || [[ "$APP" == "Apps" ]]; then continue; fi if [[ "$(fn-letsencrypt-is-active "$APP")" == "true" ]]; then local expiry=$(fn-letsencrypt-expiration "$APP") local grace_period="$(fn-letsencrypt-computed-graceperiod "$APP")" local time_to_expiry=$((expiry - $(date +%s))) local time_to_renewal=$((expiry - grace_period - $(date +%s))) echo -e "$APP\t$expiry\t$grace_period\t$time_to_expiry\t$time_to_renewal" fi done } fn-letsencrypt-computed-server() { declare desc="get configured server" declare APP="$1" value="$(fn-letsencrypt-server "$APP")" if [[ -z "$value" ]]; then value="$(fn-letsencrypt-global-server)" fi if [[ -z "$value" ]] || [[ "$value" == "default" ]]; then value="https://acme-v02.api.letsencrypt.org/directory" elif [[ "$value" == "staging" ]]; then value="https://acme-staging-v02.api.letsencrypt.org/directory" fi echo "$value" } fn-letsencrypt-global-server() { declare desc="get configured server" fn-plugin-property-get-default "letsencrypt" "--global" "server" "" } fn-letsencrypt-server() { declare desc="get configured server" declare APP="$1" fn-plugin-property-get-default "letsencrypt" "$APP" "server" "" } fn-letsencrypt-symlink-certs() { declare desc="symlink let's encrypt certificates so they can be found by dokku" declare APP="$1" config_dir="$2" local app_root="$DOKKU_ROOT/$APP" local le_root="$app_root/letsencrypt" local domain dokku_log_info1 "Installing let's encrypt certificates" # link the current config directory to 'current' ln -nsf "$config_dir" "$le_root/certs/current" # install the let's encrypt certificate for the app unset DOKKU_APP_NAME domain="$(get_app_domains "$APP" | xargs | awk '{print $1}')" local fileSafeDomain fileSafeDomain="${domain/\*/_}" # wildcards are using *.example.com which have certificates named _.example.com dokku certs:add "$APP" "$config_dir/certificates/$fileSafeDomain.pem" "$config_dir/certificates/$fileSafeDomain.key" rm -f "$app_root/tls/server.letsencrypt.crt" "$app_root/tls/server.crt" cp "$config_dir/certificates/$fileSafeDomain.crt" "$app_root/tls/server.letsencrypt.crt" cp "$config_dir/certificates/$fileSafeDomain.crt" "$app_root/tls/server.crt" }