Skip to content

Commit eaec604

Browse files
authored
Merge pull request #423 from buchdag/default-cert-key
Automatic creation of default cert and private key
2 parents 1a294ac + a0afb09 commit eaec604

File tree

9 files changed

+256
-6
lines changed

9 files changed

+256
-6
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ Please note that [letsencrypt-nginx-proxy-companion does not work with ACME v2 e
1111
### Features:
1212
* Automatic creation/renewal of Let's Encrypt certificates using original nginx-proxy container.
1313
* Support creation of Multi-Domain ([SAN](https://www.digicert.com/subject-alternative-name.htm)) Certificates.
14-
* Automatically creation of a Strong Diffie-Hellman Group (for having an A+ Rate on the [Qualsys SSL Server Test](https://www.ssllabs.com/ssltest/)).
14+
* Automatic creation of a Strong Diffie-Hellman Group (for having an A+ Rate on the [Qualsys SSL Server Test](https://www.ssllabs.com/ssltest/)).
15+
* 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.
1516
* Work with all versions of docker.
1617

1718
![schema](./schema.png)

app/entrypoint.sh

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,40 @@ is being created."
9191
) &disown
9292
}
9393

94+
function check_default_cert_key {
95+
local cn='letsencrypt-nginx-proxy-companion'
96+
97+
if [[ -e /etc/nginx/certs/default.crt && -e /etc/nginx/certs/default.key ]]; then
98+
default_cert_cn="$(openssl x509 -noout -subject -in /etc/nginx/certs/default.crt)"
99+
# Check if the existing default certificate is still valid for more
100+
# than 3 months / 7776000 seconds (60 x 60 x 24 x 30 x 3).
101+
check_cert_min_validity /etc/nginx/certs/default.crt 7776000
102+
cert_validity=$?
103+
[[ $DEBUG == true ]] && echo "Debug: a default certificate with $default_cert_cn is present."
104+
fi
105+
106+
# Create a default cert and private key if:
107+
# - either default.crt or default.key are absent
108+
# OR
109+
# - the existing default cert/key were generated by the container
110+
# and the cert validity is less than three months
111+
if [[ ! -e /etc/nginx/certs/default.crt || ! -e /etc/nginx/certs/default.key ]] || [[ "${default_cert_cn:-}" =~ $cn && "${cert_validity:-}" -ne 0 ]]; then
112+
openssl req -x509 \
113+
-newkey rsa:4096 -sha256 -nodes -days 365 \
114+
-subj "/CN=$cn" \
115+
-keyout /etc/nginx/certs/default.key.new \
116+
-out /etc/nginx/certs/default.crt.new \
117+
&& mv /etc/nginx/certs/default.key.new /etc/nginx/certs/default.key \
118+
&& mv /etc/nginx/certs/default.crt.new /etc/nginx/certs/default.crt \
119+
&& reload_nginx
120+
echo "Info: a default key and certificate have been created at /etc/nginx/certs/default.key and /etc/nginx/certs/default.crt."
121+
elif [[ $DEBUG == true && "${default_cert_cn:-}" =~ $cn ]]; then
122+
echo "Debug: the self generated default certificate is still valid for more than three months. Skipping default certificate creation."
123+
elif [[ $DEBUG == true ]]; then
124+
echo "Debug: the default certificate is user provided. Skipping default certificate creation."
125+
fi
126+
}
127+
94128
source /app/functions.sh
95129

96130
if [[ "$*" == "/bin/bash /app/start.sh" ]]; then
@@ -125,6 +159,7 @@ if [[ "$*" == "/bin/bash /app/start.sh" ]]; then
125159
check_writable_directory '/etc/nginx/vhost.d'
126160
check_writable_directory '/usr/share/nginx/html'
127161
check_deprecated_env_var
162+
check_default_cert_key
128163
check_dh_group
129164
fi
130165

app/functions.sh

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ function remove_all_location_configurations {
4646
eval "$old_shopt_options" # Restore shopt options
4747
}
4848

49+
function check_cert_min_validity {
50+
# Check if a certificate ($1) is still valid for a given amount of time in seconds ($2).
51+
# Returns 0 if the certificate is still valid for this amount of time, 1 otherwise.
52+
local cert_path="$1"
53+
local min_validity="$(( $(date "+%s") + $2 ))"
54+
55+
local cert_expiration
56+
cert_expiration="$(openssl x509 -noout -enddate -in "$cert_path" | cut -d "=" -f 2)"
57+
cert_expiration="$(date --utc --date "${cert_expiration% GMT}" "+%s")"
58+
59+
[[ $cert_expiration -gt $min_validity ]] || return 1
60+
}
61+
4962
function get_self_cid {
5063
DOCKER_PROVIDER=${DOCKER_PROVIDER:-docker}
5164

test/config.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ testAlias+=(
88
imageTests+=(
99
[le-companion]='
1010
docker_api
11+
default_cert
1112
certs_single
1213
certs_san
1314
force_renew

test/tests/certs_san/run.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ for hosts in "${letsencrypt_hosts[@]}"; do
5555
fi
5656

5757
# Wait for a connection to https://domain then grab the served certificate in text form.
58-
wait_for_conn "$domain"
58+
wait_for_conn --domain "$domain"
5959
served_cert_fingerprint="$(echo \
6060
| openssl s_client -showcerts -servername $domain -connect $domain:443 2>/dev/null \
6161
| openssl x509 -fingerprint -noout)"

test/tests/certs_single/run.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ for domain in "${domains[@]}"; do
4141
fi
4242

4343
# Wait for a connection to https://domain then grab the served certificate fingerprint.
44-
wait_for_conn "$domain"
44+
wait_for_conn --domain "$domain"
4545
served_cert_fingerprint="$(echo \
4646
| openssl s_client -showcerts -servername "$domain" -connect "$domain:443" 2>/dev/null \
4747
| openssl x509 -fingerprint -noout)"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Started letsencrypt container for test default_cert
2+
Connection to le1.wtf using https was successful.
3+
Connection to le2.wtf using https was successful.
4+
Connection to le3.wtf using https was successful.
5+
Connection to le1.wtf using https was successful.
6+
Connection to le2.wtf using https was successful.
7+
Connection to le3.wtf using https was successful.

test/tests/default_cert/run.sh

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#!/bin/bash
2+
3+
## Test for single domain certificates.
4+
5+
if [[ -z $TRAVIS_CI ]]; then
6+
le_container_name="$(basename ${0%/*})_$(date "+%Y-%m-%d_%H.%M.%S")"
7+
else
8+
le_container_name="$(basename ${0%/*})"
9+
fi
10+
run_le_container ${1:?} "$le_container_name"
11+
12+
# Create the $domains array from comma separated domains in TEST_DOMAINS.
13+
IFS=',' read -r -a domains <<< "$TEST_DOMAINS"
14+
15+
# Cleanup function with EXIT trap
16+
function cleanup {
17+
# Cleanup the files created by this run of the test to avoid foiling following test(s).
18+
docker exec "$le_container_name" sh -c 'rm -rf /etc/nginx/certs/default.*'
19+
docker stop "$le_container_name" > /dev/null
20+
}
21+
trap cleanup EXIT
22+
23+
function default_cert_fingerprint {
24+
docker exec "$le_container_name" openssl x509 -in "/etc/nginx/certs/default.crt" -fingerprint -noout
25+
}
26+
27+
function default_cert_subject {
28+
docker exec "$le_container_name" openssl x509 -in "/etc/nginx/certs/default.crt" -subject -noout
29+
}
30+
31+
user_cn="user-provided"
32+
33+
i=0
34+
until docker exec "$le_container_name" [[ -f /etc/nginx/certs/default.crt ]]; do
35+
if [ $i -gt 60 ]; then
36+
echo "Default cert wasn't created under one minute at container first launch."
37+
fi
38+
i=$((i + 2))
39+
sleep 2
40+
done
41+
42+
# Connection test to unconfigured domains
43+
for domain in "${domains[@]}"; do
44+
wait_for_conn --domain "$domain" --default-cert
45+
done
46+
47+
# Test if the default certificate get re-created when
48+
# the certificate or private key file are deleted
49+
for file in 'default.key' 'default.crt'; do
50+
old_default_cert_fingerprint="$(default_cert_fingerprint)"
51+
docker exec "$le_container_name" rm -f /etc/nginx/certs/$file
52+
docker restart "$le_container_name" > /dev/null && sleep 5
53+
i=0
54+
while [[ "$(default_cert_fingerprint)" == "$old_default_cert_fingerprint" ]]; do
55+
if [ $i -gt 55 ]; then
56+
echo "Default cert wasn't re-created under one minute after $file deletion."
57+
break
58+
fi
59+
i=$((i + 2))
60+
sleep 2
61+
done
62+
done
63+
64+
# Test if the default certificate get re-created when
65+
# the certificate expire in less than three months
66+
docker exec "$le_container_name" sh -c 'rm -rf /etc/nginx/certs/default.*'
67+
docker exec "$le_container_name" openssl req -x509 \
68+
-newkey rsa:4096 -sha256 -nodes -days 60 \
69+
-subj "/CN=letsencrypt-nginx-proxy-companion" \
70+
-keyout /etc/nginx/certs/default.key \
71+
-out /etc/nginx/certs/default.crt > /dev/null 2>&1
72+
old_default_cert_fingerprint="$(default_cert_fingerprint)"
73+
docker restart "$le_container_name" > /dev/null && sleep 5
74+
i=0
75+
while [[ "$(default_cert_fingerprint)" == "$old_default_cert_fingerprint" ]]; do
76+
if [ $i -gt 55 ]; then
77+
echo "Default cert wasn't re-created under one minute when the certificate expire in less than three months."
78+
break
79+
fi
80+
i=$((i + 2))
81+
sleep 2
82+
done
83+
84+
# Test that a user provided default certificate isn't overwrited
85+
docker exec "$le_container_name" sh -c 'rm -rf /etc/nginx/certs/default.*'
86+
docker exec "$le_container_name" openssl req -x509 \
87+
-newkey rsa:4096 -sha256 -nodes -days 60 \
88+
-subj "/CN=$user_cn" \
89+
-keyout /etc/nginx/certs/default.key \
90+
-out /etc/nginx/certs/default.crt > /dev/null 2>&1
91+
docker restart "$le_container_name" > /dev/null
92+
93+
# Connection test to unconfigured domains
94+
for domain in "${domains[@]}"; do
95+
wait_for_conn --domain "$domain" --subject-match "$user_cn"
96+
done

test/tests/test-functions.sh

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ function get_base_domain {
88
}
99
export -f get_base_domain
1010

11+
1112
# Run a letsencrypt-nginx-proxy-companion container
1213
function run_le_container {
1314
local image="${1:?}"
@@ -30,6 +31,7 @@ function run_le_container {
3031
}
3132
export -f run_le_container
3233

34+
3335
# Wait for the /etc/nginx/certs/$1.crt symlink to exist inside container $2
3436
function wait_for_symlink {
3537
local domain="${1:?}"
@@ -50,6 +52,7 @@ function wait_for_symlink {
5052
}
5153
export -f wait_for_symlink
5254

55+
5356
# Wait for the /etc/nginx/certs/$1.crt file to be removed inside container $2
5457
function wait_for_symlink_rm {
5558
local domain="${1:?}"
@@ -67,11 +70,104 @@ function wait_for_symlink_rm {
6770
}
6871
export -f wait_for_symlink_rm
6972

70-
# Wait for a successful https connection to domain $1
73+
74+
# Attempt to grab the certificate from domain passed with -d/--domain
75+
# then check if the subject either match or doesn't match the pattern
76+
# passed with either -m/--match or -nm/--no-match
77+
# If domain can't be reached return 1
78+
function check_cert_subj {
79+
while [[ $# -gt 0 ]]; do
80+
local flag="$1"
81+
82+
case $flag in
83+
-d|--domain)
84+
local domain="${2:?}"
85+
shift
86+
shift
87+
;;
88+
89+
-m|--match)
90+
local re="${2:?}"
91+
local match_rc=0
92+
local no_match_rc=1
93+
shift
94+
shift
95+
;;
96+
97+
-n|--no-match)
98+
local re="${2:?}"
99+
local match_rc=1
100+
local no_match_rc=0
101+
shift
102+
shift
103+
;;
104+
105+
*) #Unknown option
106+
shift
107+
;;
108+
esac
109+
done
110+
111+
if curl -k https://"$domain" > /dev/null 2>&1; then
112+
local cert_subject
113+
cert_subject="$(echo \
114+
| openssl s_client -showcerts -servername "$domain" -connect "$domain:443" 2>/dev/null \
115+
| openssl x509 -subject -noout)"
116+
else
117+
return 1
118+
fi
119+
120+
if [[ "$cert_subject" =~ $re ]]; then
121+
return $match_rc
122+
else
123+
return $no_match_rc
124+
fi
125+
}
126+
export -f check_cert_subj
127+
128+
129+
# Wait for a successful https connection to domain passed with -d/--domain then wait
130+
# - until the served certificate isn't the default one (default behavior)
131+
# - until the served certificate is the default one (--default-cert)
132+
# - until the served certificate subject match a string (--subject-match)
71133
function wait_for_conn {
72-
local domain="${1:?}"
134+
local action
135+
local domain
136+
local string
137+
138+
while [[ $# -gt 0 ]]; do
139+
local flag="$1"
140+
141+
case $flag in
142+
-d|--domain)
143+
domain="${2:?}"
144+
shift
145+
shift
146+
;;
147+
148+
--default-cert)
149+
action='--match'
150+
shift
151+
;;
152+
153+
--subject-match)
154+
action='--match'
155+
string="$2"
156+
shift
157+
shift
158+
;;
159+
160+
*) #Unknown option
161+
shift
162+
;;
163+
esac
164+
done
165+
73166
local i=0
74-
until curl -k https://"$domain" > /dev/null 2>&1; do
167+
action="${action:---no-match}"
168+
string="${string:-letsencrypt-nginx-proxy-companion}"
169+
170+
until check_cert_subj --domain "$domain" "$action" "$string"; do
75171
if [ $i -gt 120 ]; then
76172
echo "Could not connect to $domain using https under two minutes, timing out."
77173
return 1
@@ -83,6 +179,7 @@ function wait_for_conn {
83179
}
84180
export -f wait_for_conn
85181

182+
86183
# Get the expiration date in unix epoch of domain $1 inside container $2
87184
function get_cert_expiration_epoch {
88185
local domain="${1:?}"

0 commit comments

Comments
 (0)