diff --git a/README.md b/README.md index 8dd0358b..5b119c24 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ Please note that [letsencrypt-nginx-proxy-companion does not work with ACME v2 e ### Features: * Automatic creation/renewal of Let's Encrypt certificates using original nginx-proxy container. * Support creation of Multi-Domain ([SAN](https://www.digicert.com/subject-alternative-name.htm)) Certificates. -* Automatically creation of a Strong Diffie-Hellman Group (for having an A+ Rate on the [Qualsys SSL Server Test](https://www.ssllabs.com/ssltest/)). +* Automatic creation of a Strong Diffie-Hellman Group (for having an A+ Rate on the [Qualsys SSL Server Test](https://www.ssllabs.com/ssltest/)). +* Automatic creation of a self-signed [default certificate](https://github.com/jwilder/nginx-proxy#how-ssl-support-works) if a user-provided one can't be found. * Work with all versions of docker. ![schema](./schema.png) diff --git a/app/entrypoint.sh b/app/entrypoint.sh index e8763683..0db8c535 100755 --- a/app/entrypoint.sh +++ b/app/entrypoint.sh @@ -91,6 +91,40 @@ is being created." ) &disown } +function check_default_cert_key { + local cn='letsencrypt-nginx-proxy-companion' + + if [[ -e /etc/nginx/certs/default.crt && -e /etc/nginx/certs/default.key ]]; then + default_cert_cn="$(openssl x509 -noout -subject -in /etc/nginx/certs/default.crt)" + # Check if the existing default certificate is still valid for more + # than 3 months / 7776000 seconds (60 x 60 x 24 x 30 x 3). + check_cert_min_validity /etc/nginx/certs/default.crt 7776000 + cert_validity=$? + [[ $DEBUG == true ]] && echo "Debug: a default certificate with $default_cert_cn is present." + fi + + # Create a default cert and private key if: + # - either default.crt or default.key are absent + # OR + # - the existing default cert/key were generated by the container + # and the cert validity is less than three months + if [[ ! -e /etc/nginx/certs/default.crt || ! -e /etc/nginx/certs/default.key ]] || [[ "${default_cert_cn:-}" =~ $cn && "${cert_validity:-}" -ne 0 ]]; then + openssl req -x509 \ + -newkey rsa:4096 -sha256 -nodes -days 365 \ + -subj "/CN=$cn" \ + -keyout /etc/nginx/certs/default.key.new \ + -out /etc/nginx/certs/default.crt.new \ + && mv /etc/nginx/certs/default.key.new /etc/nginx/certs/default.key \ + && mv /etc/nginx/certs/default.crt.new /etc/nginx/certs/default.crt \ + && reload_nginx + echo "Info: a default key and certificate have been created at /etc/nginx/certs/default.key and /etc/nginx/certs/default.crt." + elif [[ $DEBUG == true && "${default_cert_cn:-}" =~ $cn ]]; then + echo "Debug: the self generated default certificate is still valid for more than three months. Skipping default certificate creation." + elif [[ $DEBUG == true ]]; then + echo "Debug: the default certificate is user provided. Skipping default certificate creation." + fi +} + source /app/functions.sh if [[ "$*" == "/bin/bash /app/start.sh" ]]; then @@ -125,6 +159,7 @@ if [[ "$*" == "/bin/bash /app/start.sh" ]]; then check_writable_directory '/etc/nginx/vhost.d' check_writable_directory '/usr/share/nginx/html' check_deprecated_env_var + check_default_cert_key check_dh_group fi diff --git a/app/functions.sh b/app/functions.sh index dc615490..92cc1552 100644 --- a/app/functions.sh +++ b/app/functions.sh @@ -46,6 +46,19 @@ function remove_all_location_configurations { eval "$old_shopt_options" # Restore shopt options } +function check_cert_min_validity { + # Check if a certificate ($1) is still valid for a given amount of time in seconds ($2). + # Returns 0 if the certificate is still valid for this amount of time, 1 otherwise. + local cert_path="$1" + local min_validity="$(( $(date "+%s") + $2 ))" + + local cert_expiration + cert_expiration="$(openssl x509 -noout -enddate -in "$cert_path" | cut -d "=" -f 2)" + cert_expiration="$(date --utc --date "${cert_expiration% GMT}" "+%s")" + + [[ $cert_expiration -gt $min_validity ]] || return 1 +} + function get_self_cid { DOCKER_PROVIDER=${DOCKER_PROVIDER:-docker} diff --git a/test/config.sh b/test/config.sh index 9733858c..e90a312b 100755 --- a/test/config.sh +++ b/test/config.sh @@ -8,6 +8,7 @@ testAlias+=( imageTests+=( [le-companion]=' docker_api + default_cert certs_single certs_san force_renew diff --git a/test/tests/certs_san/run.sh b/test/tests/certs_san/run.sh index 309bf5e1..a3982089 100755 --- a/test/tests/certs_san/run.sh +++ b/test/tests/certs_san/run.sh @@ -55,7 +55,7 @@ for hosts in "${letsencrypt_hosts[@]}"; do fi # Wait for a connection to https://domain then grab the served certificate in text form. - wait_for_conn "$domain" + wait_for_conn --domain "$domain" served_cert_fingerprint="$(echo \ | openssl s_client -showcerts -servername $domain -connect $domain:443 2>/dev/null \ | openssl x509 -fingerprint -noout)" diff --git a/test/tests/certs_single/run.sh b/test/tests/certs_single/run.sh index a2ac506f..b65dbae3 100755 --- a/test/tests/certs_single/run.sh +++ b/test/tests/certs_single/run.sh @@ -41,7 +41,7 @@ for domain in "${domains[@]}"; do fi # Wait for a connection to https://domain then grab the served certificate fingerprint. - wait_for_conn "$domain" + wait_for_conn --domain "$domain" served_cert_fingerprint="$(echo \ | openssl s_client -showcerts -servername "$domain" -connect "$domain:443" 2>/dev/null \ | openssl x509 -fingerprint -noout)" diff --git a/test/tests/default_cert/expected-std-out.txt b/test/tests/default_cert/expected-std-out.txt new file mode 100644 index 00000000..1889fa82 --- /dev/null +++ b/test/tests/default_cert/expected-std-out.txt @@ -0,0 +1,7 @@ +Started letsencrypt container for test default_cert +Connection to le1.wtf using https was successful. +Connection to le2.wtf using https was successful. +Connection to le3.wtf using https was successful. +Connection to le1.wtf using https was successful. +Connection to le2.wtf using https was successful. +Connection to le3.wtf using https was successful. diff --git a/test/tests/default_cert/run.sh b/test/tests/default_cert/run.sh new file mode 100755 index 00000000..cf290307 --- /dev/null +++ b/test/tests/default_cert/run.sh @@ -0,0 +1,96 @@ +#!/bin/bash + +## Test for single domain certificates. + +if [[ -z $TRAVIS_CI ]]; then + le_container_name="$(basename ${0%/*})_$(date "+%Y-%m-%d_%H.%M.%S")" +else + le_container_name="$(basename ${0%/*})" +fi +run_le_container ${1:?} "$le_container_name" + +# Create the $domains array from comma separated domains in TEST_DOMAINS. +IFS=',' read -r -a domains <<< "$TEST_DOMAINS" + +# Cleanup function with EXIT trap +function cleanup { + # Cleanup the files created by this run of the test to avoid foiling following test(s). + docker exec "$le_container_name" sh -c 'rm -rf /etc/nginx/certs/default.*' + docker stop "$le_container_name" > /dev/null +} +trap cleanup EXIT + +function default_cert_fingerprint { + docker exec "$le_container_name" openssl x509 -in "/etc/nginx/certs/default.crt" -fingerprint -noout +} + +function default_cert_subject { + docker exec "$le_container_name" openssl x509 -in "/etc/nginx/certs/default.crt" -subject -noout +} + +user_cn="user-provided" + +i=0 +until docker exec "$le_container_name" [[ -f /etc/nginx/certs/default.crt ]]; do + if [ $i -gt 60 ]; then + echo "Default cert wasn't created under one minute at container first launch." + fi + i=$((i + 2)) + sleep 2 +done + +# Connection test to unconfigured domains +for domain in "${domains[@]}"; do + wait_for_conn --domain "$domain" --default-cert +done + +# Test if the default certificate get re-created when +# the certificate or private key file are deleted +for file in 'default.key' 'default.crt'; do + old_default_cert_fingerprint="$(default_cert_fingerprint)" + docker exec "$le_container_name" rm -f /etc/nginx/certs/$file + docker restart "$le_container_name" > /dev/null && sleep 5 + i=0 + while [[ "$(default_cert_fingerprint)" == "$old_default_cert_fingerprint" ]]; do + if [ $i -gt 55 ]; then + echo "Default cert wasn't re-created under one minute after $file deletion." + break + fi + i=$((i + 2)) + sleep 2 + done +done + +# Test if the default certificate get re-created when +# the certificate expire in less than three months +docker exec "$le_container_name" sh -c 'rm -rf /etc/nginx/certs/default.*' +docker exec "$le_container_name" openssl req -x509 \ + -newkey rsa:4096 -sha256 -nodes -days 60 \ + -subj "/CN=letsencrypt-nginx-proxy-companion" \ + -keyout /etc/nginx/certs/default.key \ + -out /etc/nginx/certs/default.crt > /dev/null 2>&1 +old_default_cert_fingerprint="$(default_cert_fingerprint)" +docker restart "$le_container_name" > /dev/null && sleep 5 +i=0 +while [[ "$(default_cert_fingerprint)" == "$old_default_cert_fingerprint" ]]; do + if [ $i -gt 55 ]; then + echo "Default cert wasn't re-created under one minute when the certificate expire in less than three months." + break + fi + i=$((i + 2)) + sleep 2 +done + +# Test that a user provided default certificate isn't overwrited +docker exec "$le_container_name" sh -c 'rm -rf /etc/nginx/certs/default.*' +docker exec "$le_container_name" openssl req -x509 \ + -newkey rsa:4096 -sha256 -nodes -days 60 \ + -subj "/CN=$user_cn" \ + -keyout /etc/nginx/certs/default.key \ + -out /etc/nginx/certs/default.crt > /dev/null 2>&1 +docker restart "$le_container_name" > /dev/null + +# Connection test to unconfigured domains +for domain in "${domains[@]}"; do + wait_for_conn --domain "$domain" --subject-match "$user_cn" +done diff --git a/test/tests/test-functions.sh b/test/tests/test-functions.sh index 2e9c73cd..44b19b3a 100755 --- a/test/tests/test-functions.sh +++ b/test/tests/test-functions.sh @@ -8,6 +8,7 @@ function get_base_domain { } export -f get_base_domain + # Run a letsencrypt-nginx-proxy-companion container function run_le_container { local image="${1:?}" @@ -30,6 +31,7 @@ function run_le_container { } export -f run_le_container + # Wait for the /etc/nginx/certs/$1.crt symlink to exist inside container $2 function wait_for_symlink { local domain="${1:?}" @@ -50,6 +52,7 @@ function wait_for_symlink { } export -f wait_for_symlink + # Wait for the /etc/nginx/certs/$1.crt file to be removed inside container $2 function wait_for_symlink_rm { local domain="${1:?}" @@ -67,11 +70,104 @@ function wait_for_symlink_rm { } export -f wait_for_symlink_rm -# Wait for a successful https connection to domain $1 + +# Attempt to grab the certificate from domain passed with -d/--domain +# then check if the subject either match or doesn't match the pattern +# passed with either -m/--match or -nm/--no-match +# If domain can't be reached return 1 +function check_cert_subj { + while [[ $# -gt 0 ]]; do + local flag="$1" + + case $flag in + -d|--domain) + local domain="${2:?}" + shift + shift + ;; + + -m|--match) + local re="${2:?}" + local match_rc=0 + local no_match_rc=1 + shift + shift + ;; + + -n|--no-match) + local re="${2:?}" + local match_rc=1 + local no_match_rc=0 + shift + shift + ;; + + *) #Unknown option + shift + ;; + esac + done + + if curl -k https://"$domain" > /dev/null 2>&1; then + local cert_subject + cert_subject="$(echo \ + | openssl s_client -showcerts -servername "$domain" -connect "$domain:443" 2>/dev/null \ + | openssl x509 -subject -noout)" + else + return 1 + fi + + if [[ "$cert_subject" =~ $re ]]; then + return $match_rc + else + return $no_match_rc + fi +} +export -f check_cert_subj + + +# Wait for a successful https connection to domain passed with -d/--domain then wait +# - until the served certificate isn't the default one (default behavior) +# - until the served certificate is the default one (--default-cert) +# - until the served certificate subject match a string (--subject-match) function wait_for_conn { - local domain="${1:?}" + local action + local domain + local string + + while [[ $# -gt 0 ]]; do + local flag="$1" + + case $flag in + -d|--domain) + domain="${2:?}" + shift + shift + ;; + + --default-cert) + action='--match' + shift + ;; + + --subject-match) + action='--match' + string="$2" + shift + shift + ;; + + *) #Unknown option + shift + ;; + esac + done + local i=0 - until curl -k https://"$domain" > /dev/null 2>&1; do + action="${action:---no-match}" + string="${string:-letsencrypt-nginx-proxy-companion}" + + until check_cert_subj --domain "$domain" "$action" "$string"; do if [ $i -gt 120 ]; then echo "Could not connect to $domain using https under two minutes, timing out." return 1 @@ -83,6 +179,7 @@ function wait_for_conn { } export -f wait_for_conn + # Get the expiration date in unix epoch of domain $1 inside container $2 function get_cert_expiration_epoch { local domain="${1:?}"