]>
Commit | Line | Data |
---|---|---|
1 | #!/usr/bin/env bash | |
2 | set -e | |
3 | set -u | |
4 | set -o pipefail | |
5 | umask 077 # paranoid umask, we're creating private keys | |
6 | ||
7 | # Get the directory in which this script is stored | |
8 | SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" | |
9 | BASEDIR="${SCRIPTDIR}" | |
10 | ||
11 | check_dependencies() { | |
12 | curl -V > /dev/null 2>&1 || _exiterr "This script requires curl." | |
13 | openssl version > /dev/null 2>&1 || _exiterr "This script requres an openssl binary." | |
14 | sed "" < /dev/null > /dev/null 2>&1 || _exiterr "This script requres sed." | |
15 | grep -V > /dev/null 2>&1 || _exiterr "This script requres grep." | |
16 | } | |
17 | ||
18 | # Setup default config values, search for and load configuration files | |
19 | load_config() { | |
20 | # Default values | |
21 | CA="https://acme-v01.api.letsencrypt.org/directory" | |
22 | LICENSE="https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf" | |
23 | HOOK= | |
24 | RENEW_DAYS="14" | |
25 | PRIVATE_KEY="${BASEDIR}/private_key.pem" | |
26 | KEYSIZE="4096" | |
27 | WELLKNOWN="${BASEDIR}/.acme-challenges" | |
28 | PRIVATE_KEY_RENEW="no" | |
29 | OPENSSL_CNF="$(openssl version -d | cut -d'"' -f2)/openssl.cnf" | |
30 | CONTACT_EMAIL= | |
31 | LOCKFILE="${BASEDIR}/lock" | |
32 | ||
33 | # Check for config in various locations | |
34 | if [[ -z "${CONFIG:-}" ]]; then | |
35 | for check_config in "/etc/letsencrypt.sh" "/usr/local/etc/letsencrypt.sh" "${PWD}" "${SCRIPTDIR}"; do | |
36 | if [[ -e "${check_config}/config.sh" ]]; then | |
37 | BASEDIR="${check_config}" | |
38 | CONFIG="${check_config}/config.sh" | |
39 | break | |
40 | fi | |
41 | done | |
42 | fi | |
43 | ||
44 | if [[ -z "${CONFIG:-}" ]]; then | |
45 | echo "#" >&2 | |
46 | echo "# !! WARNING !! No config file found, using default config!" >&2 | |
47 | echo "#" >&2 | |
48 | elif [[ -e "${CONFIG}" ]]; then | |
49 | echo "# INFO: Using config file ${CONFIG}" | |
50 | BASEDIR="$(dirname "${CONFIG}")" | |
51 | # shellcheck disable=SC1090 | |
52 | . "${CONFIG}" | |
53 | else | |
54 | echo "Specified config file doesn't exist." >&2 | |
55 | exit 1 | |
56 | fi | |
57 | ||
58 | # Remove slash from end of BASEDIR. Mostly for cleaner outputs, doesn't change functionality. | |
59 | BASEDIR="${BASEDIR%%/}" | |
60 | ||
61 | # Check BASEDIR and set default variables | |
62 | if [[ ! -d "${BASEDIR}" ]]; then | |
63 | echo "BASEDIR does not exist: ${BASEDIR}" >&2 | |
64 | exit 1 | |
65 | fi | |
66 | } | |
67 | ||
68 | init_system() { | |
69 | load_config | |
70 | ||
71 | if [[ "${COMMAND}" = "env" ]]; then | |
72 | return | |
73 | fi | |
74 | ||
75 | # Lockfile handling (prevents concurrent access) | |
76 | set -o noclobber | |
77 | if ! { date > "${LOCKFILE}"; } 2>/dev/null; then | |
78 | echo " + ERROR: Lock file '${LOCKFILE}' present, aborting." >&2 | |
79 | LOCKFILE="" # so remove_lock doesn't remove it | |
80 | exit 1 | |
81 | fi | |
82 | set +o noclobber | |
83 | ||
84 | remove_lock() { | |
85 | if [[ -n "${LOCKFILE}" ]]; then | |
86 | rm -f "${LOCKFILE}" | |
87 | fi | |
88 | } | |
89 | trap 'remove_lock' EXIT | |
90 | ||
91 | # Export some environment variables to be used in hook script | |
92 | export WELLKNOWN | |
93 | export BASEDIR | |
94 | export CONFIG | |
95 | ||
96 | # Get CA URLs | |
97 | CA_DIRECTORY="$(http_request get "${CA}")" | |
98 | CA_NEW_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-cert)" && | |
99 | CA_NEW_AUTHZ="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-authz)" && | |
100 | CA_NEW_REG="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-reg)" && | |
101 | # shellcheck disable=SC2015 | |
102 | CA_REVOKE_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value revoke-cert)" || | |
103 | (echo "Error retrieving ACME/CA-URLs, check if your configured CA points to the directory entrypoint." >&2; exit 1) | |
104 | ||
105 | ||
106 | # check private key ... | |
107 | register="0" | |
108 | if [[ -n "${PARAM_PRIVATE_KEY:-}" ]]; then | |
109 | # a private key was specified from the command line so use it for this run | |
110 | echo "Using private key ${PARAM_PRIVATE_KEY} instead of account key" | |
111 | PRIVATE_KEY="${PARAM_PRIVATE_KEY}" | |
112 | if ! openssl rsa -in "${PRIVATE_KEY}" -check 2>/dev/null > /dev/null; then | |
113 | echo " + ERROR: private key is not valid, can not continue" >&2 | |
114 | exit 1 | |
115 | fi | |
116 | else | |
117 | # Check if private account key exists, if it doesn't exist yet generate a new one (rsa key) | |
118 | if [[ ! -e "${PRIVATE_KEY}" ]]; then | |
119 | echo "+ Generating account key..." | |
120 | _openssl genrsa -out "${PRIVATE_KEY}" "${KEYSIZE}" | |
121 | register="1" | |
122 | fi | |
123 | fi | |
124 | ||
125 | # Get public components from private key and calculate thumbprint | |
126 | pubExponent64="$(openssl rsa -in "${PRIVATE_KEY}" -noout -text | grep publicExponent | grep -oE "0x[a-f0-9]+" | cut -d'x' -f2 | hex2bin | urlbase64)" | |
127 | pubMod64="$(openssl rsa -in "${PRIVATE_KEY}" -noout -modulus | cut -d'=' -f2 | hex2bin | urlbase64)" | |
128 | ||
129 | thumbprint="$(printf '{"e":"%s","kty":"RSA","n":"%s"}' "${pubExponent64}" "${pubMod64}" | openssl sha -sha256 -binary | urlbase64)" | |
130 | ||
131 | # If we generated a new private key in the step above we have to register it with the acme-server | |
132 | if [[ "${register}" = "1" ]]; then | |
133 | echo "+ Registering account key with letsencrypt..." | |
134 | if [ -z "${CA_NEW_REG}" ]; then | |
135 | echo " + ERROR: Certificate authority doesn't allow registrations." >&2 | |
136 | exit 1 | |
137 | fi | |
138 | # if an email for the contact has been provided then adding it to the registration request | |
139 | if [[ -n "${CONTACT_EMAIL}" ]]; then | |
140 | signed_request "${CA_NEW_REG}" '{"resource": "new-reg", "contact":["mailto:'"${CONTACT_EMAIL}"'"], "agreement": "'"$LICENSE"'"}' > /dev/null | |
141 | else | |
142 | signed_request "${CA_NEW_REG}" '{"resource": "new-reg", "agreement": "'"$LICENSE"'"}' > /dev/null | |
143 | fi | |
144 | fi | |
145 | ||
146 | if [[ -e "${BASEDIR}/domains.txt" ]]; then | |
147 | DOMAINS_TXT="${BASEDIR}/domains.txt" | |
148 | else | |
149 | echo " + ERROR: domains.txt not found" >&2 | |
150 | exit 1 | |
151 | fi | |
152 | ||
153 | if [[ ! -e "${WELLKNOWN}" ]]; then | |
154 | echo " + ERROR: WELLKNOWN directory doesn't exist, please create ${WELLKNOWN} and set appropriate permissions." >&2 | |
155 | exit 1 | |
156 | fi | |
157 | } | |
158 | ||
159 | # Print error message and exit with error | |
160 | _exiterr() { | |
161 | echo "ERROR: ${1}" >&2 | |
162 | exit 1 | |
163 | } | |
164 | ||
165 | anti_newline() { | |
166 | tr -d '\n\r' | |
167 | } | |
168 | ||
169 | urlbase64() { | |
170 | # urlbase64: base64 encoded string with '+' replaced with '-' and '/' replaced with '_' | |
171 | openssl base64 -e | anti_newline | sed 's/=*$//g' | tr '+/' '-_' | |
172 | } | |
173 | ||
174 | # Convert hex string to binary data | |
175 | hex2bin() { | |
176 | # Remove spaces, add leading zero, escape as hex string and parse with printf | |
177 | printf -- "$(cat | sed -E -e 's/[[:space:]]//g' -e 's/^(.(.{2})*)$/0\1/' -e 's/(.{2})/\\x\1/g')" | |
178 | } | |
179 | ||
180 | get_json_string_value() { | |
181 | grep -Eo '"'"${1}"'":[[:space:]]*"[^"]*"' | cut -d'"' -f4 | |
182 | } | |
183 | ||
184 | get_json_array() { | |
185 | grep -Eo '"'"${1}"'":[^\[]*\[[^]]*]' | |
186 | } | |
187 | ||
188 | http_request() { | |
189 | tempcont="$(mktemp)" | |
190 | ||
191 | if [[ "${1}" = "head" ]]; then | |
192 | statuscode="$(curl -s -w "%{http_code}" -o "${tempcont}" "${2}" -I)" | |
193 | elif [[ "${1}" = "get" ]]; then | |
194 | statuscode="$(curl -s -w "%{http_code}" -o "${tempcont}" "${2}")" | |
195 | elif [[ "${1}" = "post" ]]; then | |
196 | statuscode="$(curl -s -w "%{http_code}" -o "${tempcont}" "${2}" -d "${3}")" | |
197 | fi | |
198 | ||
199 | if [[ ! "${statuscode:0:1}" = "2" ]]; then | |
200 | echo " + ERROR: An error occurred while sending ${1}-request to ${2} (Status ${statuscode})" >&2 | |
201 | echo >&2 | |
202 | echo "Details:" >&2 | |
203 | cat "${tempcont}" >&2 | |
204 | rm -f "${tempcont}" | |
205 | ||
206 | # Wait for hook script to clean the challenge if used | |
207 | if [[ -n "${HOOK}" ]] && [[ -n "${challenge_token:+set}" ]]; then | |
208 | ${HOOK} "clean_challenge" '' "${challenge_token}" "${keyauth}" | |
209 | fi | |
210 | ||
211 | # remove temporary domains.txt file if used | |
212 | if [[ -n "${PARAM_DOMAIN:-}" ]]; then | |
213 | rm "${DOMAINS_TXT}" | |
214 | fi | |
215 | ||
216 | exit 1 | |
217 | fi | |
218 | ||
219 | cat "${tempcont}" | |
220 | rm -f "${tempcont}" | |
221 | } | |
222 | ||
223 | # OpenSSL writes to stderr/stdout even when there are no errors. So just | |
224 | # display the output if the exit code was != 0 to simplify debugging. | |
225 | _openssl() { | |
226 | set +e | |
227 | out="$(openssl "${@}" 2>&1)" | |
228 | res=$? | |
229 | set -e | |
230 | if [[ $res -ne 0 ]]; then | |
231 | echo " + ERROR: failed to run $* (Exitcode: $res)" >&2 | |
232 | echo >&2 | |
233 | echo "Details:" >&2 | |
234 | echo "$out" >&2 | |
235 | exit $res | |
236 | fi | |
237 | } | |
238 | ||
239 | signed_request() { | |
240 | # Encode payload as urlbase64 | |
241 | payload64="$(printf '%s' "${2}" | urlbase64)" | |
242 | ||
243 | # Retrieve nonce from acme-server | |
244 | nonce="$(http_request head "${CA}" | grep Replay-Nonce: | awk -F ': ' '{print $2}' | anti_newline)" | |
245 | ||
246 | # Build header with just our public key and algorithm information | |
247 | header='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}}' | |
248 | ||
249 | # Build another header which also contains the previously received nonce and encode it as urlbase64 | |
250 | protected='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}, "nonce": "'"${nonce}"'"}' | |
251 | protected64="$(printf '%s' "${protected}" | urlbase64)" | |
252 | ||
253 | # Sign header with nonce and our payload with our private key and encode signature as urlbase64 | |
254 | signed64="$(printf '%s' "${protected64}.${payload64}" | openssl dgst -sha256 -sign "${PRIVATE_KEY}" | urlbase64)" | |
255 | ||
256 | # Send header + extended header + payload + signature to the acme-server | |
257 | data='{"header": '"${header}"', "protected": "'"${protected64}"'", "payload": "'"${payload64}"'", "signature": "'"${signed64}"'"}' | |
258 | ||
259 | http_request post "${1}" "${data}" | |
260 | } | |
261 | ||
262 | sign_domain() { | |
263 | domain="${1}" | |
264 | altnames="${*}" | |
265 | ||
266 | echo " + Signing domains..." | |
267 | if [[ -z "${CA_NEW_AUTHZ}" ]] || [[ -z "${CA_NEW_CERT}" ]]; then | |
268 | echo " + ERROR: Certificate authority doesn't allow certificate signing" >&2 | |
269 | exit 1 | |
270 | fi | |
271 | timestamp="$(date +%s)" | |
272 | ||
273 | # If there is no existing certificate directory => make it | |
274 | if [[ ! -e "${BASEDIR}/certs/${domain}" ]]; then | |
275 | echo " + make directory ${BASEDIR}/certs/${domain} ..." | |
276 | mkdir -p "${BASEDIR}/certs/${domain}" | |
277 | fi | |
278 | ||
279 | privkey="privkey.pem" | |
280 | # generate a new private key if we need or want one | |
281 | if [[ ! -f "${BASEDIR}/certs/${domain}/privkey.pem" ]] || [[ "${PRIVATE_KEY_RENEW}" = "yes" ]]; then | |
282 | echo " + Generating private key..." | |
283 | privkey="privkey-${timestamp}.pem" | |
284 | _openssl genrsa -out "${BASEDIR}/certs/${domain}/privkey-${timestamp}.pem" "${KEYSIZE}" | |
285 | fi | |
286 | ||
287 | # Generate signing request config and the actual signing request | |
288 | SAN="" | |
289 | for altname in $altnames; do | |
290 | SAN+="DNS:${altname}, " | |
291 | done | |
292 | SAN="${SAN%%, }" | |
293 | echo " + Generating signing request..." | |
294 | local tmp_openssl_cnf | |
295 | tmp_openssl_cnf="$(mktemp)" | |
296 | cat "${OPENSSL_CNF}" > "${tmp_openssl_cnf}" | |
297 | printf "[SAN]\nsubjectAltName=%s" "${SAN}" >> "${tmp_openssl_cnf}" | |
298 | openssl req -new -sha256 -key "${BASEDIR}/certs/${domain}/${privkey}" -out "${BASEDIR}/certs/${domain}/cert-${timestamp}.csr" -subj "/CN=${domain}/" -reqexts SAN -config "${tmp_openssl_cnf}" | |
299 | rm -f "${tmp_openssl_cnf}" | |
300 | ||
301 | # Request and respond to challenges | |
302 | for altname in $altnames; do | |
303 | # Ask the acme-server for new challenge token and extract them from the resulting json block | |
304 | echo " + Requesting challenge for ${altname}..." | |
305 | response="$(signed_request "${CA_NEW_AUTHZ}" '{"resource": "new-authz", "identifier": {"type": "dns", "value": "'"${altname}"'"}}')" | |
306 | ||
307 | challenges="$(printf '%s\n' "${response}" | get_json_array challenges)" | |
308 | repl=$'\n''{' # fix syntax highlighting in Vim | |
309 | challenge="$(printf "%s" "${challenges//\{/${repl}}" | grep 'http-01')" | |
310 | challenge_token="$(printf '%s' "${challenge}" | get_json_string_value token | sed 's/[^A-Za-z0-9_\-]/_/g')" | |
311 | challenge_uri="$(printf '%s' "${challenge}" | get_json_string_value uri)" | |
312 | ||
313 | if [[ -z "${challenge_token}" ]] || [[ -z "${challenge_uri}" ]]; then | |
314 | echo " + Error: Can't retrieve challenges (${response})" >&2 | |
315 | exit 1 | |
316 | fi | |
317 | ||
318 | # Challenge response consists of the challenge token and the thumbprint of our public certificate | |
319 | keyauth="${challenge_token}.${thumbprint}" | |
320 | ||
321 | # Store challenge response in well-known location and make world-readable (so that a webserver can access it) | |
322 | printf '%s' "${keyauth}" > "${WELLKNOWN}/${challenge_token}" | |
323 | chmod a+r "${WELLKNOWN}/${challenge_token}" | |
324 | ||
325 | # Wait for hook script to deploy the challenge if used | |
326 | if [[ -n "${HOOK}" ]]; then | |
327 | ${HOOK} "deploy_challenge" "${altname}" "${challenge_token}" "${keyauth}" | |
328 | fi | |
329 | ||
330 | # Ask the acme-server to verify our challenge and wait until it becomes valid | |
331 | echo " + Responding to challenge for ${altname}..." | |
332 | result="$(signed_request "${challenge_uri}" '{"resource": "challenge", "keyAuthorization": "'"${keyauth}"'"}')" | |
333 | ||
334 | status="$(printf '%s\n' "${result}" | get_json_string_value status)" | |
335 | ||
336 | # get status until a result is reached => not pending anymore | |
337 | while [[ "${status}" = "pending" ]]; do | |
338 | sleep 1 | |
339 | status="$(http_request get "${challenge_uri}" | get_json_string_value status)" | |
340 | done | |
341 | ||
342 | rm -f "${WELLKNOWN}/${challenge_token}" | |
343 | ||
344 | # Wait for hook script to clean the challenge if used | |
345 | if [[ -n "${HOOK}" ]] && [[ -n "${challenge_token}" ]]; then | |
346 | ${HOOK} "clean_challenge" "${altname}" "${challenge_token}" "${keyauth}" | |
347 | fi | |
348 | ||
349 | if [[ "${status}" = "valid" ]]; then | |
350 | echo " + Challenge is valid!" | |
351 | else | |
352 | echo " + Challenge is invalid! (returned: ${status})" >&2 | |
353 | exit 1 | |
354 | fi | |
355 | ||
356 | done | |
357 | ||
358 | # Finally request certificate from the acme-server and store it in cert-${timestamp}.pem and link from cert.pem | |
359 | echo " + Requesting certificate..." | |
360 | csr64="$(openssl req -in "${BASEDIR}/certs/${domain}/cert-${timestamp}.csr" -outform DER | urlbase64)" | |
361 | crt64="$(signed_request "${CA_NEW_CERT}" '{"resource": "new-cert", "csr": "'"${csr64}"'"}' | openssl base64 -e)" | |
362 | crt_path="${BASEDIR}/certs/${domain}/cert-${timestamp}.pem" | |
363 | printf -- '-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n' "${crt64}" > "${crt_path}" | |
364 | # try to load the certificate to detect corruption | |
365 | echo " + Checking certificate..." | |
366 | _openssl x509 -text < "${crt_path}" | |
367 | ||
368 | # Create fullchain.pem | |
369 | echo " + Creating fullchain.pem..." | |
370 | cat "${crt_path}" > "${BASEDIR}/certs/${domain}/fullchain-${timestamp}.pem" | |
371 | http_request get "$(openssl x509 -in "${BASEDIR}/certs/${domain}/cert-${timestamp}.pem" -noout -text | grep 'CA Issuers - URI:' | cut -d':' -f2-)" > "${BASEDIR}/certs/${domain}/chain-${timestamp}.pem" | |
372 | if ! grep "BEGIN CERTIFICATE" "${BASEDIR}/certs/${domain}/chain-${timestamp}.pem" > /dev/null 2>&1; then | |
373 | openssl x509 -in "${BASEDIR}/certs/${domain}/chain-${timestamp}.pem" -inform DER -out "${BASEDIR}/certs/${domain}/chain-${timestamp}.pem" -outform PEM | |
374 | fi | |
375 | ln -sf "chain-${timestamp}.pem" "${BASEDIR}/certs/${domain}/chain.pem" | |
376 | cat "${BASEDIR}/certs/${domain}/chain-${timestamp}.pem" >> "${BASEDIR}/certs/${domain}/fullchain-${timestamp}.pem" | |
377 | ln -sf "fullchain-${timestamp}.pem" "${BASEDIR}/certs/${domain}/fullchain.pem" | |
378 | ||
379 | # Update remaining symlinks | |
380 | if [ ! "${privkey}" = "privkey.pem" ]; then | |
381 | ln -sf "privkey-${timestamp}.pem" "${BASEDIR}/certs/${domain}/privkey.pem" | |
382 | fi | |
383 | ||
384 | ln -sf "cert-${timestamp}.csr" "${BASEDIR}/certs/${domain}/cert.csr" | |
385 | ln -sf "cert-${timestamp}.pem" "${BASEDIR}/certs/${domain}/cert.pem" | |
386 | ||
387 | # Wait for hook script to clean the challenge and to deploy cert if used | |
388 | if [[ -n "${HOOK}" ]]; then | |
389 | ${HOOK} "deploy_cert" "${domain}" "${BASEDIR}/certs/${domain}/privkey.pem" "${BASEDIR}/certs/${domain}/cert.pem" "${BASEDIR}/certs/${domain}/fullchain.pem" | |
390 | fi | |
391 | ||
392 | unset challenge_token | |
393 | echo " + Done!" | |
394 | } | |
395 | ||
396 | ||
397 | # Usage: --cron (-c) | |
398 | # Description: Sign/renew non-existant/changed/expiring certificates. | |
399 | command_sign_domains() { | |
400 | init_system | |
401 | ||
402 | if [[ -n "${PARAM_DOMAIN:-}" ]]; then | |
403 | # we are using a temporary domains.txt file so we don't need to duplicate any code | |
404 | DOMAINS_TXT="$(mktemp)" | |
405 | echo "${PARAM_DOMAIN}" > "${DOMAINS_TXT}" | |
406 | fi | |
407 | # Generate certificates for all domains found in domains.txt. Check if existing certificate are about to expire | |
408 | <"${DOMAINS_TXT}" sed 's/^[[:space:]]*//g;s/[[:space:]]*$//g' | grep -vE '^(#|$)' | while read -r line; do | |
409 | domain="$(printf '%s\n' "${line}" | cut -d' ' -f1)" | |
410 | morenames="$(printf '%s\n' "${line}" | cut -s -d' ' -f2-)" | |
411 | cert="${BASEDIR}/certs/${domain}/cert.pem" | |
412 | ||
413 | force_renew="${PARAM_FORCE:-no}" | |
414 | ||
415 | if [[ -z "${morenames}" ]];then | |
416 | echo "Processing ${domain}" | |
417 | else | |
418 | echo "Processing ${domain} with SAN: ${morenames}" | |
419 | fi | |
420 | ||
421 | if [[ -e "${cert}" ]]; then | |
422 | echo -n " + Checking domain name(s) of existing cert..." | |
423 | ||
424 | certnames="$(openssl x509 -in "${cert}" -text -noout | grep DNS: | sed 's/DNS://g' | tr -d ' ' | tr ',' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')" | |
425 | givennames="$(echo "${domain}" "${morenames}"| tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//' | sed 's/^ //')" | |
426 | ||
427 | if [[ "${certnames}" = "${givennames}" ]]; then | |
428 | echo " unchanged." | |
429 | else | |
430 | echo " changed!" | |
431 | echo " + Domain name(s) are not matching!" | |
432 | echo " + Names in old certificate: ${certnames}" | |
433 | echo " + Configured names: ${givennames}" | |
434 | echo " + Forcing renew." | |
435 | force_renew="yes" | |
436 | fi | |
437 | fi | |
438 | ||
439 | if [[ -e "${cert}" ]]; then | |
440 | echo " + Checking expire date of existing cert..." | |
441 | ||
442 | valid="$(openssl x509 -enddate -noout -in "${cert}" | cut -d= -f2- )" | |
443 | ||
444 | echo -n " + Valid till ${valid} " | |
445 | if openssl x509 -checkend $((RENEW_DAYS * 86400)) -noout -in "${cert}"; then | |
446 | echo -n "(Longer than ${RENEW_DAYS} days). " | |
447 | if [[ "${force_renew}" = "yes" ]]; then | |
448 | echo "Ignoring because renew was forced!" | |
449 | else | |
450 | echo "Skipping!" | |
451 | continue | |
452 | fi | |
453 | else | |
454 | echo "(Less than ${RENEW_DAYS} days). Renewing!" | |
455 | fi | |
456 | fi | |
457 | ||
458 | # shellcheck disable=SC2086 | |
459 | sign_domain $line | |
460 | done || true | |
461 | ||
462 | # remove temporary domains.txt file if used | |
463 | if [[ -n "${PARAM_DOMAIN:-}" ]]; then | |
464 | rm -f "${DOMAINS_TXT}" | |
465 | fi | |
466 | } | |
467 | ||
468 | # Usage: --revoke (-r) path/to/cert.pem | |
469 | # Description: Revoke specified certificate | |
470 | command_revoke() { | |
471 | init_system | |
472 | ||
473 | cert="${1}" | |
474 | if [[ -L "${cert}" ]]; then | |
475 | # follow symlink and use real certificate name (so we move the real file and not the symlink at the end) | |
476 | local link_target | |
477 | link_target="$(readlink -n "${cert}")" | |
478 | if [[ "${link_target}" =~ ^/ ]]; then | |
479 | cert="${link_target}" | |
480 | else | |
481 | cert="$(dirname "${cert}")/${link_target}" | |
482 | fi | |
483 | fi | |
484 | if [[ ! -f "${cert}" ]]; then | |
485 | echo "ERROR: Could not find certificate ${cert}" | |
486 | exit 1 | |
487 | fi | |
488 | echo "Revoking ${cert}" | |
489 | if [[ -z "${CA_REVOKE_CERT}" ]]; then | |
490 | echo " + ERROR: Certificate authority doesn't allow certificate revocation." >&2 | |
491 | exit 1 | |
492 | fi | |
493 | cert64="$(openssl x509 -in "${cert}" -inform PEM -outform DER | urlbase64)" | |
494 | response="$(signed_request "${CA_REVOKE_CERT}" '{"resource": "revoke-cert", "certificate": "'"${cert64}"'"}')" | |
495 | # if there is a problem with our revoke request http_request (via signed_request) will report this and "exit 1" out | |
496 | # so if we are here, it is safe to assume the request was successful | |
497 | echo " + SUCCESS" | |
498 | echo " + renaming certificate to ${cert}-revoked" | |
499 | mv -f "${cert}" "${cert}-revoked" | |
500 | } | |
501 | ||
502 | # Usage: --help (-h) | |
503 | # Description: Show help text | |
504 | command_help() { | |
505 | echo "Usage: ${0} [-h] [command [argument]] [parameter [argument]] [parameter [argument]] ..." | |
506 | echo | |
507 | echo "Default command: help" | |
508 | echo | |
509 | ( | |
510 | echo "Commands:" | |
511 | grep -e '^[[:space:]]*# Usage:' -e '^[[:space:]]*# Description:' -e '^command_.*()[[:space:]]*{' "${0}" | while read -r usage; read -r description; read -r command; do | |
512 | if [[ ! "${usage}" =~ Usage ]] || [[ ! "${description}" =~ Description ]] || [[ ! "${command}" =~ ^command_ ]]; then | |
513 | echo "Error generating help text." >&2 | |
514 | exit 1 | |
515 | fi | |
516 | printf " %s\t%s\n" "${usage##"# Usage: "}" "${description##"# Description: "}" | |
517 | done | |
518 | echo "---" | |
519 | echo "Parameters:" | |
520 | grep -E -e '^[[:space:]]*# PARAM_Usage:' -e '^[[:space:]]*# PARAM_Description:' "${0}" | while read -r usage; read -r description; do | |
521 | if [[ ! "${usage}" =~ Usage ]] || [[ ! "${description}" =~ Description ]]; then | |
522 | echo "Error generating help text." >&2 | |
523 | exit 1 | |
524 | fi | |
525 | printf " %s\t%s\n" "${usage##"# PARAM_Usage: "}" "${description##"# PARAM_Description: "}" | |
526 | done | |
527 | ) | column -t -s $'\t' | sed 's/^---$//g' | |
528 | } | |
529 | ||
530 | # Usage: --env (-e) | |
531 | # Description: Output configuration variables for use in other scripts | |
532 | command_env() { | |
533 | echo "# letsencrypt.sh configuration" | |
534 | load_config | |
535 | typeset -p CA LICENSE HOOK RENEW_DAYS PRIVATE_KEY KEYSIZE WELLKNOWN PRIVATE_KEY_RENEW OPENSSL_CNF CONTACT_EMAIL LOCKFILE | |
536 | } | |
537 | ||
538 | main() { | |
539 | COMMAND="" | |
540 | set_command() { | |
541 | [[ -z "${COMMAND}" ]] || _exiterr "Only one command can be executed at a time. See help (-h) for more information." | |
542 | COMMAND="${1}" | |
543 | } | |
544 | ||
545 | check_parameters() { | |
546 | if [[ -z "${1:-}" ]]; then | |
547 | echo "The specified command requires additional parameters. See help:" >&2 | |
548 | echo >&2 | |
549 | command_help >&2 | |
550 | exit 1 | |
551 | elif [[ "${1:0:1}" = "-" ]]; then | |
552 | _exiterr "Invalid argument: ${1}" | |
553 | fi | |
554 | } | |
555 | ||
556 | while (( "${#}" )); do | |
557 | case "${1}" in | |
558 | --help|-h) | |
559 | command_help | |
560 | exit 0 | |
561 | ;; | |
562 | ||
563 | --env|-e) | |
564 | set_command env | |
565 | ;; | |
566 | ||
567 | --cron|-c) | |
568 | set_command sign_domains | |
569 | ;; | |
570 | ||
571 | --revoke|-r) | |
572 | shift 1 | |
573 | set_command revoke | |
574 | check_parameters "${1:-}" | |
575 | PARAM_REVOKECERT="${1}" | |
576 | ;; | |
577 | ||
578 | # PARAM_Usage: --domain (-d) domain.tld | |
579 | # PARAM_Description: Use specified domain name(s) instead of domains.txt entry (one certificate!) | |
580 | --domain|-d) | |
581 | shift 1 | |
582 | check_parameters "${1:-}" | |
583 | if [[ -z "${PARAM_DOMAIN:-}" ]]; then | |
584 | PARAM_DOMAIN="${1}" | |
585 | else | |
586 | PARAM_DOMAIN="${PARAM_DOMAIN} ${1}" | |
587 | fi | |
588 | ;; | |
589 | ||
590 | ||
591 | # PARAM_Usage: --force (-x) | |
592 | # PARAM_Description: Force renew of certificate even if it is longer valid than value in RENEW_DAYS | |
593 | --force|-x) | |
594 | PARAM_FORCE="yes" | |
595 | ;; | |
596 | ||
597 | # PARAM_Usage: --privkey (-p) path/to/key.pem | |
598 | # PARAM_Description: Use specified private key instead of account key (useful for revocation) | |
599 | --privkey|-p) | |
600 | shift 1 | |
601 | check_parameters "${1:-}" | |
602 | PARAM_PRIVATE_KEY="${1}" | |
603 | ;; | |
604 | ||
605 | # PARAM_Usage: --config (-f) path/to/config.sh | |
606 | # PARAM_Description: Use specified config file | |
607 | --config|-f) | |
608 | shift 1 | |
609 | check_parameters "${1:-}" | |
610 | CONFIG="${1}" | |
611 | ;; | |
612 | ||
613 | *) | |
614 | echo "Unknown parameter detected: ${1}" >&2 | |
615 | echo >&2 | |
616 | command_help >&2 | |
617 | exit 1 | |
618 | ;; | |
619 | esac | |
620 | ||
621 | shift 1 | |
622 | done | |
623 | ||
624 | case "${COMMAND}" in | |
625 | env) command_env;; | |
626 | sign_domains) command_sign_domains;; | |
627 | revoke) command_revoke "${PARAM_REVOKECERT}";; | |
628 | *) command_help; exit1;; | |
629 | esac | |
630 | } | |
631 | ||
632 | # Check for missing dependencies | |
633 | check_dependencies | |
634 | ||
635 | # Run script | |
636 | main "${@:-}" |