/
letsencrypt_service
executable file
·335 lines (293 loc) · 13.8 KB
/
letsencrypt_service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
#!/bin/bash
# shellcheck disable=SC2120
source /app/functions.sh
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"
[[ "$(lc $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 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
[[ "$(lc $DEBUG)" == true ]] && echo "Symlinked domains: ${SYMLINKED_DOMAINS[*]}"
# Create an array containing domains that are considered
# enabled (ie present on /app/letsencrypt_service_data).
# shellcheck source=/dev/null
source /app/letsencrypt_service_data
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
[[ "$(lc $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.
if [[ ${#SYMLINKED_DOMAINS[@]} -gt 0 ]]; then
mapfile -t DISABLED_DOMAINS < <(echo "${SYMLINKED_DOMAINS[@]}" \
"${ENABLED_DOMAINS[@]}" \
"${ENABLED_DOMAINS[@]}" \
| tr ' ' '\n' | sort | uniq -u)
fi
[[ "$(lc $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
[[ "$(lc $DEBUG)" == true ]] && echo "Some domains are disabled :"
for disabled_domain in "${DISABLED_DOMAINS[@]}"; do
[[ "$(lc $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
[[ "$(lc $DEBUG)" == true ]] && echo "No .companion file found in ${cert_folder}. ${disabled_domain} is not managed by letsencrypt-nginx-proxy-companion. Skipping domain."
continue
else
[[ "$(lc $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
[[ "$(lc $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 {
check_nginx_proxy_container_run || return
[[ -f /app/letsencrypt_service_data ]] || return
# Load relevant container settings
unset LETSENCRYPT_CONTAINERS
# shellcheck source=/dev/null
source /app/letsencrypt_service_data
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_str=""
# Use container's LETSENCRYPT_EMAIL if set, fallback to DEFAULT_EMAIL
email_varname="LETSENCRYPT_${cid}_EMAIL"
email_address="${!email_varname}"
if [[ "$email_address" != "<no value>" ]]; then
params_d_str+=" --email $email_address"
elif [[ -n "${DEFAULT_EMAIL:-}" ]]; then
params_d_str+=" --email $DEFAULT_EMAIL"
fi
keysize_varname="LETSENCRYPT_${cid}_KEYSIZE"
cert_keysize="${!keysize_varname}"
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}"
if [[ "$account_alias" == "<no value>" ]]; then
account_alias=default
fi
[[ "$(lc $DEBUG)" == true ]] && params_d_str+=" -v"
[[ $REUSE_PRIVATE_KEYS == true ]] && params_d_str+=" --reuse_key"
min_validity="LETSENCRYPT_${cid}_MIN_VALIDITY"
min_validity="${!min_validity}"
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_str+=" --valid_min 7776000"
else
params_d_str+=" --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_str+=" -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
[[ "$(lc $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_str \
--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
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=