]> git.street.me.uk Git - andy/dehydrated.git/blame - dehydrated
Merge branch 'simondeziel-fix-example-hook'
[andy/dehydrated.git] / dehydrated
CommitLineData
2e8454b4 1#!/usr/bin/env bash
a1a9c8a4 2
ec49a443 3# dehydrated by lukas2511
64e35463 4# Source: https://github.com/lukas2511/dehydrated
0fa381ff
LS
5#
6# This script is licensed under The MIT License (see LICENSE for more information).
a1a9c8a4 7
69f3e78b
LS
8set -e
9set -u
10set -o pipefail
da2eeda9 11[[ -n "${ZSH_VERSION:-}" ]] && set -o SH_WORD_SPLIT && set +o FUNCTION_ARGZERO
81882a64 12umask 077 # paranoid umask, we're creating private keys
61f0b7ed 13
85a25b56
LS
14# Find directory in which this script is stored by traversing all symbolic links
15SOURCE="${0}"
16while [ -h "$SOURCE" ]; do # resolve $SOURCE until the file is no longer a symlink
17 DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
18 SOURCE="$(readlink "$SOURCE")"
19 [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" # if $SOURCE was a relative symlink, we need to resolve it relative to the path where the symlink file was located
20done
21SCRIPTDIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
22
0e92aba2
MG
23BASEDIR="${SCRIPTDIR}"
24
1f6a80a0
LS
25# Create (identifiable) temporary files
26_mktemp() {
75985c6a 27 # shellcheck disable=SC2068
ec49a443 28 mktemp ${@:-} "${TMPDIR:-/tmp}/dehydrated-XXXXXX"
1f6a80a0
LS
29}
30
bc580335 31# Check for script dependencies
9f66bfdb 32check_dependencies() {
0af7f388 33 # just execute some dummy and/or version commands to see if required tools exist and are actually usable
115041cd 34 openssl version > /dev/null 2>&1 || _exiterr "This script requires an openssl binary."
f7c7d8c5 35 _sed "" < /dev/null > /dev/null 2>&1 || _exiterr "This script requires sed with support for extended (modern) regular expressions."
4b8883b4 36 command -v grep > /dev/null 2>&1 || _exiterr "This script requires grep."
e2e2c362 37 command -v mktemp > /dev/null 2>&1 || _exiterr "This script requires mktemp."
cfc00c42 38 command -v diff > /dev/null 2>&1 || _exiterr "This script requires diff."
0af7f388
LS
39
40 # curl returns with an error code in some ancient versions so we have to catch that
41 set +e
42 curl -V > /dev/null 2>&1
0af7f388 43 retcode="$?"
36a03146 44 set -e
0af7f388
LS
45 if [[ ! "${retcode}" = "0" ]] && [[ ! "${retcode}" = "2" ]]; then
46 _exiterr "This script requires curl."
47 fi
9f66bfdb
LS
48}
49
ec489069
LS
50store_configvars() {
51 __KEY_ALGO="${KEY_ALGO}"
52 __OCSP_MUST_STAPLE="${OCSP_MUST_STAPLE}"
53 __PRIVATE_KEY_RENEW="${PRIVATE_KEY_RENEW}"
54 __KEYSIZE="${KEYSIZE}"
55 __CHALLENGETYPE="${CHALLENGETYPE}"
56 __HOOK="${HOOK}"
57 __WELLKNOWN="${WELLKNOWN}"
58 __HOOK_CHAIN="${HOOK_CHAIN}"
59 __OPENSSL_CNF="${OPENSSL_CNF}"
60 __RENEW_DAYS="${RENEW_DAYS}"
364bcccf 61 __IP_VERSION="${IP_VERSION}"
ec489069
LS
62}
63
64reset_configvars() {
65 KEY_ALGO="${__KEY_ALGO}"
66 OCSP_MUST_STAPLE="${__OCSP_MUST_STAPLE}"
67 PRIVATE_KEY_RENEW="${__PRIVATE_KEY_RENEW}"
68 KEYSIZE="${__KEYSIZE}"
69 CHALLENGETYPE="${__CHALLENGETYPE}"
70 HOOK="${__HOOK}"
71 WELLKNOWN="${__WELLKNOWN}"
72 HOOK_CHAIN="${__HOOK_CHAIN}"
73 OPENSSL_CNF="${__OPENSSL_CNF}"
74 RENEW_DAYS="${__RENEW_DAYS}"
364bcccf 75 IP_VERSION="${__IP_VERSION}"
ec489069
LS
76}
77
78# verify configuration values
79verify_config() {
80 [[ "${CHALLENGETYPE}" =~ (http-01|dns-01) ]] || _exiterr "Unknown challenge type ${CHALLENGETYPE}... can not continue."
81 if [[ "${CHALLENGETYPE}" = "dns-01" ]] && [[ -z "${HOOK}" ]]; then
82 _exiterr "Challenge type dns-01 needs a hook script for deployment... can not continue."
83 fi
84 if [[ "${CHALLENGETYPE}" = "http-01" && ! -d "${WELLKNOWN}" ]]; then
85 _exiterr "WELLKNOWN directory doesn't exist, please create ${WELLKNOWN} and set appropriate permissions."
86 fi
87 [[ "${KEY_ALGO}" =~ ^(rsa|prime256v1|secp384r1)$ ]] || _exiterr "Unknown public key algorithm ${KEY_ALGO}... can not continue."
364bcccf 88 if [[ -n "${IP_VERSION}" ]]; then
89 [[ "${IP_VERSION}" = "4" || "${IP_VERSION}" = "6" ]] || _exiterr "Unknown IP version ${IP_VERSION}... can not continue."
90 fi
ec489069
LS
91}
92
ff116396
LS
93# Setup default config values, search for and load configuration files
94load_config() {
00810795
LS
95 # Check for config in various locations
96 if [[ -z "${CONFIG:-}" ]]; then
ec49a443 97 for check_config in "/etc/dehydrated" "/usr/local/etc/dehydrated" "${PWD}" "${SCRIPTDIR}"; do
0d8b9289 98 if [[ -f "${check_config}/config" ]]; then
00810795 99 BASEDIR="${check_config}"
d5b28586 100 CONFIG="${check_config}/config"
00810795
LS
101 break
102 fi
103 done
104 fi
105
ff116396
LS
106 # Default values
107 CA="https://acme-v01.api.letsencrypt.org/directory"
6a32f20e
LS
108 CA_TERMS="https://acme-v01.api.letsencrypt.org/terms"
109 LICENSE=
785ffa55 110 CERTDIR=
034ec30c 111 ACCOUNTDIR=
de173892 112 CHALLENGETYPE="http-01"
a1cb7ccc 113 CONFIG_D=
44aca90c 114 DOMAINS_D=
a3e5ed36 115 DOMAINS_TXT=
ff116396 116 HOOK=
6e048f7f 117 HOOK_CHAIN="no"
30ad9584 118 RENEW_DAYS="30"
ff116396 119 KEYSIZE="4096"
9baf3532 120 WELLKNOWN=
e608dc2b 121 PRIVATE_KEY_RENEW="yes"
a13e4103 122 PRIVATE_KEY_ROLLOVER="no"
c71ca3a8 123 KEY_ALGO=rsa
f0323faf 124 OPENSSL_CNF="$(openssl version -d | cut -d\" -f2)/openssl.cnf"
ff116396 125 CONTACT_EMAIL=
9baf3532 126 LOCKFILE=
8e77ba5e 127 OCSP_MUST_STAPLE="no"
364bcccf 128 IP_VERSION=
1e33cfe5 129
81882a64 130 if [[ -z "${CONFIG:-}" ]]; then
ff116396 131 echo "#" >&2
a1cb7ccc 132 echo "# !! WARNING !! No main config file found, using default config!" >&2
ff116396 133 echo "#" >&2
0d8b9289 134 elif [[ -f "${CONFIG}" ]]; then
a1cb7ccc 135 echo "# INFO: Using main config file ${CONFIG}"
81882a64
LS
136 BASEDIR="$(dirname "${CONFIG}")"
137 # shellcheck disable=SC1090
138 . "${CONFIG}"
139 else
f06f764f 140 _exiterr "Specified config file doesn't exist."
81882a64 141 fi
61f0b7ed 142
a1cb7ccc
DB
143 if [[ -n "${CONFIG_D}" ]]; then
144 if [[ ! -d "${CONFIG_D}" ]]; then
145 _exiterr "The path ${CONFIG_D} specified for CONFIG_D does not point to a directory." >&2
146 fi
147
e2d8bfa4 148 for check_config_d in "${CONFIG_D}"/*.sh; do
a1cb7ccc
DB
149 if [[ ! -e "${check_config_d}" ]]; then
150 echo "# !! WARNING !! Extra configuration directory ${CONFIG_D} exists, but no configuration found in it." >&2
151 break
152 elif [[ -f "${check_config_d}" ]] && [[ -r "${check_config_d}" ]]; then
153 echo "# INFO: Using additional config file ${check_config_d}"
5c68c221 154 # shellcheck disable=SC1090
e2d8bfa4 155 . "${check_config_d}"
a1cb7ccc
DB
156 else
157 _exiterr "Specified additional config ${check_config_d} is not readable or not a file at all." >&2
158 fi
159 done
160 fi
161
81882a64
LS
162 # Remove slash from end of BASEDIR. Mostly for cleaner outputs, doesn't change functionality.
163 BASEDIR="${BASEDIR%%/}"
401f5f75 164
1e33cfe5 165 # Check BASEDIR and set default variables
f06f764f 166 [[ -d "${BASEDIR}" ]] || _exiterr "BASEDIR does not exist: ${BASEDIR}"
ed27e013 167
034ec30c
LS
168 CAHASH="$(echo "${CA}" | urlbase64)"
169 [[ -z "${ACCOUNTDIR}" ]] && ACCOUNTDIR="${BASEDIR}/accounts"
170 mkdir -p "${ACCOUNTDIR}/${CAHASH}"
171 [[ -f "${ACCOUNTDIR}/${CAHASH}/config" ]] && . "${ACCOUNTDIR}/${CAHASH}/config"
172 ACCOUNT_KEY="${ACCOUNTDIR}/${CAHASH}/account_key.pem"
173 ACCOUNT_KEY_JSON="${ACCOUNTDIR}/${CAHASH}/registration_info.json"
174
175 if [[ -f "${BASEDIR}/private_key.pem" ]] && [[ ! -f "${ACCOUNT_KEY}" ]]; then
176 echo "! Moving private_key.pem to ${ACCOUNT_KEY}"
177 mv "${BASEDIR}/private_key.pem" "${ACCOUNT_KEY}"
178 fi
179 if [[ -f "${BASEDIR}/private_key.json" ]] && [[ ! -f "${ACCOUNT_KEY_JSON}" ]]; then
180 echo "! Moving private_key.json to ${ACCOUNT_KEY_JSON}"
181 mv "${BASEDIR}/private_key.json" "${ACCOUNT_KEY_JSON}"
182 fi
183
785ffa55 184 [[ -z "${CERTDIR}" ]] && CERTDIR="${BASEDIR}/certs"
a3e5ed36 185 [[ -z "${DOMAINS_TXT}" ]] && DOMAINS_TXT="${BASEDIR}/domains.txt"
64e35463 186 [[ -z "${WELLKNOWN}" ]] && WELLKNOWN="/var/www/dehydrated"
9baf3532 187 [[ -z "${LOCKFILE}" ]] && LOCKFILE="${BASEDIR}/lock"
8456855e 188 [[ -n "${PARAM_LOCKFILE_SUFFIX:-}" ]] && LOCKFILE="${LOCKFILE}-${PARAM_LOCKFILE_SUFFIX}"
bd9cc5b0 189 [[ -n "${PARAM_NO_LOCK:-}" ]] && LOCKFILE=""
9baf3532 190
de173892 191 [[ -n "${PARAM_HOOK:-}" ]] && HOOK="${PARAM_HOOK}"
785ffa55 192 [[ -n "${PARAM_CERTDIR:-}" ]] && CERTDIR="${PARAM_CERTDIR}"
de173892 193 [[ -n "${PARAM_CHALLENGETYPE:-}" ]] && CHALLENGETYPE="${PARAM_CHALLENGETYPE}"
c71ca3a8 194 [[ -n "${PARAM_KEY_ALGO:-}" ]] && KEY_ALGO="${PARAM_KEY_ALGO}"
8e77ba5e 195 [[ -n "${PARAM_OCSP_MUST_STAPLE:-}" ]] && OCSP_MUST_STAPLE="${PARAM_OCSP_MUST_STAPLE}"
364bcccf 196 [[ -n "${PARAM_IP_VERSION:-}" ]] && IP_VERSION="${PARAM_IP_VERSION}"
e925b293 197
ec489069
LS
198 verify_config
199 store_configvars
ff116396
LS
200}
201
93cd114f 202# Initialize system
ff116396
LS
203init_system() {
204 load_config
81882a64 205
1e33cfe5 206 # Lockfile handling (prevents concurrent access)
bd9cc5b0
LS
207 if [[ -n "${LOCKFILE}" ]]; then
208 LOCKDIR="$(dirname "${LOCKFILE}")"
209 [[ -w "${LOCKDIR}" ]] || _exiterr "Directory ${LOCKDIR} for LOCKFILE ${LOCKFILE} is not writable, aborting."
210 ( set -C; date > "${LOCKFILE}" ) 2>/dev/null || _exiterr "Lock file '${LOCKFILE}' present, aborting."
211 remove_lock() { rm -f "${LOCKFILE}"; }
212 trap 'remove_lock' EXIT
213 fi
81882a64 214
81882a64 215 # Get CA URLs
3a9e97f9 216 CA_DIRECTORY="$(http_request get "${CA}")"
81882a64
LS
217 CA_NEW_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-cert)" &&
218 CA_NEW_AUTHZ="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-authz)" &&
219 CA_NEW_REG="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-reg)" &&
10d9f342 220 # shellcheck disable=SC2015
81882a64 221 CA_REVOKE_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value revoke-cert)" ||
93cd114f 222 _exiterr "Problem retrieving ACME/CA-URLs, check if your configured CA points to the directory entrypoint."
81882a64 223
93cd114f 224 # Export some environment variables to be used in hook script
298a7e9a 225 export WELLKNOWN BASEDIR CERTDIR CONFIG COMMAND
0e92aba2 226
93cd114f
LS
227 # Checking for private key ...
228 register_new_key="no"
8aa1a05b 229 if [[ -n "${PARAM_ACCOUNT_KEY:-}" ]]; then
0e92aba2 230 # a private key was specified from the command line so use it for this run
8aa1a05b
LS
231 echo "Using private key ${PARAM_ACCOUNT_KEY} instead of account key"
232 ACCOUNT_KEY="${PARAM_ACCOUNT_KEY}"
233 ACCOUNT_KEY_JSON="${PARAM_ACCOUNT_KEY}.json"
0e92aba2
MG
234 else
235 # Check if private account key exists, if it doesn't exist yet generate a new one (rsa key)
8aa1a05b 236 if [[ ! -e "${ACCOUNT_KEY}" ]]; then
6a32f20e
LS
237 REAL_LICENSE="$(http_request head "${CA_TERMS}" | (grep Location: || true) | awk -F ': ' '{print $2}' | tr -d '\n\r')"
238 if [[ -z "${REAL_LICENSE}" ]]; then
239 printf '\n'
240 printf 'Error retrieving terms of service from certificate authority.\n'
241 printf 'Please set LICENSE in config manually.\n'
242 exit 1
243 fi
244 if [[ ! "${LICENSE}" = "${REAL_LICENSE}" ]]; then
245 if [[ "${PARAM_ACCEPT_TERMS:-}" = "yes" ]]; then
246 LICENSE="${REAL_LICENSE}"
247 else
248 printf '\n'
249 printf 'To use dehydrated with this certificate authority you have to agree to their terms of service which you can find here: %s\n\n' "${REAL_LICENSE}"
250 printf 'To accept these terms of service run `%s --register --accept-terms`.\n' "${0}"
251 exit 1
252 fi
253 fi
254
81882a64 255 echo "+ Generating account key..."
8aa1a05b 256 _openssl genrsa -out "${ACCOUNT_KEY}" "${KEYSIZE}"
93cd114f 257 register_new_key="yes"
81882a64 258 fi
81882a64 259 fi
8aa1a05b 260 openssl rsa -in "${ACCOUNT_KEY}" -check 2>/dev/null > /dev/null || _exiterr "Account key is not valid, can not continue."
1ab6a436 261
81882a64 262 # Get public components from private key and calculate thumbprint
8aa1a05b
LS
263 pubExponent64="$(printf '%x' "$(openssl rsa -in "${ACCOUNT_KEY}" -noout -text | awk '/publicExponent/ {print $2}')" | hex2bin | urlbase64)"
264 pubMod64="$(openssl rsa -in "${ACCOUNT_KEY}" -noout -modulus | cut -d'=' -f2 | hex2bin | urlbase64)"
81882a64 265
21c18dd3 266 thumbprint="$(printf '{"e":"%s","kty":"RSA","n":"%s"}' "${pubExponent64}" "${pubMod64}" | openssl dgst -sha256 -binary | urlbase64)"
81882a64
LS
267
268 # If we generated a new private key in the step above we have to register it with the acme-server
93cd114f 269 if [[ "${register_new_key}" = "yes" ]]; then
64e35463 270 echo "+ Registering account key with ACME server..."
93cd114f
LS
271 [[ ! -z "${CA_NEW_REG}" ]] || _exiterr "Certificate authority doesn't allow registrations."
272 # If an email for the contact has been provided then adding it to the registration request
034ec30c 273 FAILED=false
81882a64 274 if [[ -n "${CONTACT_EMAIL}" ]]; then
034ec30c 275 (signed_request "${CA_NEW_REG}" '{"resource": "new-reg", "contact":["mailto:'"${CONTACT_EMAIL}"'"], "agreement": "'"$LICENSE"'"}' > "${ACCOUNT_KEY_JSON}") || FAILED=true
81882a64 276 else
034ec30c
LS
277 (signed_request "${CA_NEW_REG}" '{"resource": "new-reg", "agreement": "'"$LICENSE"'"}' > "${ACCOUNT_KEY_JSON}") || FAILED=true
278 fi
279 if [[ "${FAILED}" = "true" ]]; then
280 echo
281 echo
282 echo "Error registering account key. See message above for more information."
283 rm "${ACCOUNT_KEY}" "${ACCOUNT_KEY_JSON}"
284 exit 1
81882a64
LS
285 fi
286 fi
181dd0ff 287
81882a64 288}
c24843c6 289
f7c7d8c5
LS
290# Different sed version for different os types...
291_sed() {
c3c9ff4c 292 if [[ "${OSTYPE}" = "Linux" ]]; then
f7c7d8c5
LS
293 sed -r "${@}"
294 else
295 sed -E "${@}"
296 fi
297}
298
9f66bfdb
LS
299# Print error message and exit with error
300_exiterr() {
301 echo "ERROR: ${1}" >&2
302 exit 1
303}
304
561f0626
LS
305# Remove newlines and whitespace from json
306clean_json() {
307 tr -d '\r\n' | _sed -e 's/ +/ /g' -e 's/\{ /{/g' -e 's/ \}/}/g' -e 's/\[ /[/g' -e 's/ \]/]/g'
308}
309
994803bf 310# Encode data as url-safe formatted base64
61f0b7ed 311urlbase64() {
c6e60302 312 # urlbase64: base64 encoded string with '+' replaced with '-' and '/' replaced with '_'
a316a094 313 openssl base64 -e | tr -d '\n\r' | _sed -e 's:=*$::g' -e 'y:+/:-_:'
61f0b7ed 314}
91ce50af 315
16bef17e 316# Convert hex string to binary data
9fe313d8 317hex2bin() {
16bef17e 318 # Remove spaces, add leading zero, escape as hex string and parse with printf
f7c7d8c5 319 printf -- "$(cat | _sed -e 's/[[:space:]]//g' -e 's/^(.(.{2})*)$/0\1/' -e 's/(.{2})/\\x\1/g')"
9fe313d8 320}
61f0b7ed 321
bc580335 322# Get string value from json dictionary
09729186 323get_json_string_value() {
5c68c221
LS
324 local filter
325 filter=$(printf 's/.*"%s": *"\([^"]*\)".*/\\1/p' "$1")
326 sed -n "${filter}"
09729186
LS
327}
328
9729751d 329rm_json_arrays() {
330 local filter
331 filter='s/\[[^][]*\]/null/g'
332 # remove three levels of nested arrays
333 sed -e "${filter}" -e "${filter}" -e "${filter}"
334}
335
cc605a22
LS
336# OpenSSL writes to stderr/stdout even when there are no errors. So just
337# display the output if the exit code was != 0 to simplify debugging.
338_openssl() {
339 set +e
340 out="$(openssl "${@}" 2>&1)"
341 res=$?
342 set -e
39c01fd7
LS
343 if [[ ${res} -ne 0 ]]; then
344 echo " + ERROR: failed to run $* (Exitcode: ${res})" >&2
cc605a22
LS
345 echo >&2
346 echo "Details:" >&2
39c01fd7 347 echo "${out}" >&2
676d15c5 348 echo >&2
39c01fd7 349 exit ${res}
cc605a22
LS
350 fi
351}
352
59f16407 353# Send http(s) request with specified method
3a9e97f9 354http_request() {
1f6a80a0 355 tempcont="$(_mktemp)"
3cb292cb 356
364bcccf 357 if [[ -n "${IP_VERSION:-}" ]]; then
358 ip_version="-${IP_VERSION}"
359 fi
360
1233dc95 361 set +e
dd5f36e5 362 if [[ "${1}" = "head" ]]; then
197ca8e8 363 statuscode="$(curl ${ip_version:-} -s -w "%{http_code}" -o "${tempcont}" "${2}" -I)"
1233dc95 364 curlret="${?}"
dd5f36e5 365 elif [[ "${1}" = "get" ]]; then
197ca8e8 366 statuscode="$(curl ${ip_version:-} -s -w "%{http_code}" -o "${tempcont}" "${2}")"
1233dc95 367 curlret="${?}"
dd5f36e5 368 elif [[ "${1}" = "post" ]]; then
197ca8e8 369 statuscode="$(curl ${ip_version:-} -s -w "%{http_code}" -o "${tempcont}" "${2}" -d "${3}")"
1233dc95 370 curlret="${?}"
59f16407 371 else
1233dc95 372 set -e
59f16407 373 _exiterr "Unknown request method: ${1}"
91ce50af 374 fi
1233dc95
LS
375 set -e
376
377 if [[ ! "${curlret}" = "0" ]]; then
df292dec 378 _exiterr "Problem connecting to server (${1} for ${2}; curl returned with ${curlret})"
1233dc95 379 fi
dd5f36e5 380
3cb292cb 381 if [[ ! "${statuscode:0:1}" = "2" ]]; then
6a32f20e
LS
382 if [[ ! "${2}" = "${CA_TERMS}" ]] || [[ ! "${statuscode:0:1}" = "3" ]]; then
383 echo " + ERROR: An error occurred while sending ${1}-request to ${2} (Status ${statuscode})" >&2
384 echo >&2
385 echo "Details:" >&2
386 cat "${tempcont}" >&2
387 echo >&2
388 echo >&2
404dc3fe 389
6a32f20e
LS
390 # An exclusive hook for the {1}-request error might be useful (e.g., for sending an e-mail to admins)
391 if [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]]; then
392 errtxt=`cat ${tempcont}`
393 "${HOOK}" "request_failure" "${statuscode}" "${errtxt}" "${1}"
394 fi
404dc3fe 395
6a32f20e 396 rm -f "${tempcont}"
c24843c6 397
6a32f20e
LS
398 # Wait for hook script to clean the challenge if used
399 if [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && [[ -n "${challenge_token:+set}" ]]; then
400 "${HOOK}" "clean_challenge" '' "${challenge_token}" "${keyauth}"
401 fi
c24843c6 402
6a32f20e
LS
403 # remove temporary domains.txt file if used
404 [[ -n "${PARAM_DOMAIN:-}" && -n "${DOMAINS_TXT:-}" ]] && rm "${DOMAINS_TXT}"
405 exit 1
406 fi
130ea6ab 407 fi
dd5f36e5 408
31111265 409 cat "${tempcont}"
3cb292cb 410 rm -f "${tempcont}"
91ce50af 411}
81882a64 412
1446fd88 413# Send signed request
61f0b7ed 414signed_request() {
c6e60302 415 # Encode payload as urlbase64
4aa48d33 416 payload64="$(printf '%s' "${2}" | urlbase64)"
61f0b7ed 417
c6e60302 418 # Retrieve nonce from acme-server
994803bf 419 nonce="$(http_request head "${CA}" | grep Replay-Nonce: | awk -F ': ' '{print $2}' | tr -d '\n\r')"
61f0b7ed 420
c6e60302 421 # Build header with just our public key and algorithm information
61f0b7ed
LS
422 header='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}}'
423
c6e60302 424 # Build another header which also contains the previously received nonce and encode it as urlbase64
61f0b7ed 425 protected='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}, "nonce": "'"${nonce}"'"}'
4aa48d33 426 protected64="$(printf '%s' "${protected}" | urlbase64)"
61f0b7ed 427
c6e60302 428 # Sign header with nonce and our payload with our private key and encode signature as urlbase64
8aa1a05b 429 signed64="$(printf '%s' "${protected64}.${payload64}" | openssl dgst -sha256 -sign "${ACCOUNT_KEY}" | urlbase64)"
61f0b7ed 430
c6e60302 431 # Send header + extended header + payload + signature to the acme-server
61f0b7ed
LS
432 data='{"header": '"${header}"', "protected": "'"${protected64}"'", "payload": "'"${payload64}"'", "signature": "'"${signed64}"'"}'
433
3a9e97f9 434 http_request post "${1}" "${data}"
61f0b7ed
LS
435}
436
a62968c9
NL
437# Extracts all subject names from a CSR
438# Outputs either the CN, or the SANs, one per line
439extract_altnames() {
440 csr="${1}" # the CSR itself (not a file)
81882a64 441
a62968c9
NL
442 if ! <<<"${csr}" openssl req -verify -noout 2>/dev/null; then
443 _exiterr "Certificate signing request isn't valid"
09729186 444 fi
3cc587c2 445
a62968c9 446 reqtext="$( <<<"${csr}" openssl req -noout -text )"
39c01fd7 447 if <<<"${reqtext}" grep -q '^[[:space:]]*X509v3 Subject Alternative Name:[[:space:]]*$'; then
a62968c9 448 # SANs used, extract these
03f0dc18 449 altnames="$( <<<"${reqtext}" awk '/^[[:space:]]*X509v3 Subject Alternative Name:[[:space:]]*$/{print;getline;print;}' | tail -n1 )"
a62968c9 450 # split to one per line:
5c68c221 451 # shellcheck disable=SC1003
34f94322 452 altnames="$( <<<"${altnames}" _sed -e 's/^[[:space:]]*//; s/, /\'$'\n''/g' )"
a62968c9 453 # we can only get DNS: ones signed
5c68c221 454 if grep -qv '^DNS:' <<<"${altnames}"; then
a62968c9
NL
455 _exiterr "Certificate signing request contains non-DNS Subject Alternative Names"
456 fi
457 # strip away the DNS: prefix
458 altnames="$( <<<"${altnames}" _sed -e 's/^DNS://' )"
39c01fd7 459 echo "${altnames}"
a62968c9
NL
460
461 else
462 # No SANs, extract CN
463 altnames="$( <<<"${reqtext}" grep '^[[:space:]]*Subject:' | _sed -e 's/.* CN=([^ /,]*).*/\1/' )"
39c01fd7 464 echo "${altnames}"
3dbbb461 465 fi
a62968c9 466}
3dbbb461 467
50e7a072
NL
468# Create certificate for domain(s) and outputs it FD 3
469sign_csr() {
470 csr="${1}" # the CSR itself (not a file)
81882a64 471
50e7a072
NL
472 if { true >&3; } 2>/dev/null; then
473 : # fd 3 looks OK
474 else
475 _exiterr "sign_csr: FD 3 not open"
61f0b7ed
LS
476 fi
477
50e7a072
NL
478 shift 1 || true
479 altnames="${*:-}"
39c01fd7
LS
480 if [ -z "${altnames}" ]; then
481 altnames="$( extract_altnames "${csr}" )"
a62968c9 482 fi
3dbbb461 483
50e7a072
NL
484 if [[ -z "${CA_NEW_AUTHZ}" ]] || [[ -z "${CA_NEW_CERT}" ]]; then
485 _exiterr "Certificate authority doesn't allow certificate signing"
61f0b7ed 486 fi
c6e60302 487
6e048f7f 488 local idx=0
da2eeda9 489 if [[ -n "${ZSH_VERSION:-}" ]]; then
9729751d 490 local -A challenge_altnames challenge_uris challenge_tokens keyauths deploy_args
da2eeda9 491 else
9729751d 492 local -a challenge_altnames challenge_uris challenge_tokens keyauths deploy_args
da2eeda9 493 fi
39c01fd7 494
6e048f7f 495 # Request challenges
1446fd88 496 for altname in ${altnames}; do
c6e60302 497 # Ask the acme-server for new challenge token and extract them from the resulting json block
579e2316 498 echo " + Requesting challenge for ${altname}..."
561f0626 499 response="$(signed_request "${CA_NEW_AUTHZ}" '{"resource": "new-authz", "identifier": {"type": "dns", "value": "'"${altname}"'"}}' | clean_json)"
61f0b7ed 500
9729751d 501 challenge_status="$(printf '%s' "${response}" | rm_json_arrays | get_json_string_value status)"
502 if [ "${challenge_status}" = "valid" ]; then
3c1d2673 503 echo " + Already validated!"
9729751d 504 continue
505 fi
506
4b8883b4 507 challenges="$(printf '%s\n' "${response}" | sed -n 's/.*\("challenges":[^\[]*\[[^]]*]\).*/\1/p')"
526843d6 508 repl=$'\n''{' # fix syntax highlighting in Vim
e925b293 509 challenge="$(printf "%s" "${challenges//\{/${repl}}" | grep \""${CHALLENGETYPE}"\")"
f7c7d8c5 510 challenge_token="$(printf '%s' "${challenge}" | get_json_string_value token | _sed 's/[^A-Za-z0-9_\-]/_/g')"
09729186 511 challenge_uri="$(printf '%s' "${challenge}" | get_json_string_value uri)"
61f0b7ed 512
dd5f36e5 513 if [[ -z "${challenge_token}" ]] || [[ -z "${challenge_uri}" ]]; then
1446fd88 514 _exiterr "Can't retrieve challenges (${response})"
abb95693
LS
515 fi
516
c6e60302 517 # Challenge response consists of the challenge token and the thumbprint of our public certificate
61f0b7ed
LS
518 keyauth="${challenge_token}.${thumbprint}"
519
de173892
LS
520 case "${CHALLENGETYPE}" in
521 "http-01")
522 # Store challenge response in well-known location and make world-readable (so that a webserver can access it)
523 printf '%s' "${keyauth}" > "${WELLKNOWN}/${challenge_token}"
524 chmod a+r "${WELLKNOWN}/${challenge_token}"
525 keyauth_hook="${keyauth}"
526 ;;
527 "dns-01")
528 # Generate DNS entry content for dns-01 validation
21c18dd3 529 keyauth_hook="$(printf '%s' "${keyauth}" | openssl dgst -sha256 -binary | urlbase64)"
de173892
LS
530 ;;
531 esac
61f0b7ed 532
9729751d 533 challenge_altnames[${idx}]="${altname}"
39c01fd7
LS
534 challenge_uris[${idx}]="${challenge_uri}"
535 keyauths[${idx}]="${keyauth}"
536 challenge_tokens[${idx}]="${challenge_token}"
6e048f7f 537 # Note: assumes args will never have spaces!
39c01fd7 538 deploy_args[${idx}]="${altname} ${challenge_token} ${keyauth_hook}"
6e048f7f
GD
539 idx=$((idx+1))
540 done
3c1d2673 541 challenge_count="${idx}"
6e048f7f
GD
542
543 # Wait for hook script to deploy the challenges if used
3c1d2673 544 if [[ ${challenge_count} -ne 0 ]]; then
9729751d 545 # shellcheck disable=SC2068
546 [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" = "yes" ]] && "${HOOK}" "deploy_challenge" ${deploy_args[@]}
547 fi
6e048f7f
GD
548
549 # Respond to challenges
9729751d 550 reqstatus="valid"
6e048f7f 551 idx=0
3c1d2673 552 if [ ${challenge_count} -ne 0 ]; then
636fa1a5
AR
553 for altname in "${challenge_altnames[@]:0}"; do
554 challenge_token="${challenge_tokens[${idx}]}"
555 keyauth="${keyauths[${idx}]}"
69eea952 556
636fa1a5
AR
557 # Wait for hook script to deploy the challenge if used
558 # shellcheck disable=SC2086
559 [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && "${HOOK}" "deploy_challenge" ${deploy_args[${idx}]}
69eea952 560
636fa1a5
AR
561 # Ask the acme-server to verify our challenge and wait until it is no longer pending
562 echo " + Responding to challenge for ${altname}..."
563 result="$(signed_request "${challenge_uris[${idx}]}" '{"resource": "challenge", "keyAuthorization": "'"${keyauth}"'"}' | clean_json)"
69eea952 564
da2eeda9 565 reqstatus="$(printf '%s\n' "${result}" | get_json_string_value status)"
69eea952 566
636fa1a5
AR
567 while [[ "${reqstatus}" = "pending" ]]; do
568 sleep 1
569 result="$(http_request get "${challenge_uris[${idx}]}")"
570 reqstatus="$(printf '%s\n' "${result}" | get_json_string_value status)"
571 done
69eea952 572
636fa1a5 573 [[ "${CHALLENGETYPE}" = "http-01" ]] && rm -f "${WELLKNOWN}/${challenge_token}"
69eea952 574
636fa1a5
AR
575 # Wait for hook script to clean the challenge if used
576 if [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && [[ -n "${challenge_token}" ]]; then
577 # shellcheck disable=SC2086
578 "${HOOK}" "clean_challenge" ${deploy_args[${idx}]}
579 fi
580 idx=$((idx+1))
69eea952 581
636fa1a5
AR
582 if [[ "${reqstatus}" = "valid" ]]; then
583 echo " + Challenge is valid!"
584 else
585 [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && "${HOOK}" "invalid_challenge" "${altname}" "${result}"
586 fi
61f0b7ed 587 done
636fa1a5 588 fi
61f0b7ed 589
6e048f7f 590 # Wait for hook script to clean the challenges if used
5c68c221 591 # shellcheck disable=SC2068
3c1d2673
LS
592 if [[ ${challenge_count} -ne 0 ]]; then
593 [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" = "yes" ]] && "${HOOK}" "clean_challenge" ${deploy_args[@]}
594 fi
6e048f7f 595
da2eeda9 596 if [[ "${reqstatus}" != "valid" ]]; then
6e048f7f 597 # Clean up any remaining challenge_tokens if we stopped early
3c1d2673 598 if [[ "${CHALLENGETYPE}" = "http-01" ]] && [[ ${challenge_count} -ne 0 ]]; then
39c01fd7
LS
599 while [ ${idx} -lt ${#challenge_tokens[@]} ]; do
600 rm -f "${WELLKNOWN}/${challenge_tokens[${idx}]}"
6e048f7f
GD
601 idx=$((idx+1))
602 done
603 fi
604
da2eeda9 605 _exiterr "Challenge is invalid! (returned: ${reqstatus}) (result: ${result})"
6e048f7f
GD
606 fi
607
b7439a83 608 # Finally request certificate from the acme-server and store it in cert-${timestamp}.pem and link from cert.pem
579e2316 609 echo " + Requesting certificate..."
50e7a072 610 csr64="$( <<<"${csr}" openssl req -outform DER | urlbase64)"
09729186 611 crt64="$(signed_request "${CA_NEW_CERT}" '{"resource": "new-cert", "csr": "'"${csr64}"'"}' | openssl base64 -e)"
50e7a072 612 crt="$( printf -- '-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n' "${crt64}" )"
1446fd88
LS
613
614 # Try to load the certificate to detect corruption
a4e7c43a 615 echo " + Checking certificate..."
50e7a072
NL
616 _openssl x509 -text <<<"${crt}"
617
618 echo "${crt}" >&3
619
620 unset challenge_token
621 echo " + Done!"
622}
623
ee65261e
LS
624# grep issuer cert uri from certificate
625get_issuer_cert_uri() {
626 certificate="${1}"
627 openssl x509 -in "${certificate}" -noout -text | (grep 'CA Issuers - URI:' | cut -d':' -f2-) || true
628}
629
630# walk certificate chain, retrieving all intermediate certificates
6a32f20e 631walk_chain() {
ee65261e 632 local certificate
6a32f20e
LS
633 certificate="${1}"
634
6a32f20e 635 local issuer_cert_uri
ee65261e
LS
636 issuer_cert_uri="${2:-}"
637 if [[ -z "${issuer_cert_uri}" ]]; then issuer_cert_uri="$(get_issuer_cert_uri "${certificate}")"; fi
6a32f20e
LS
638 if [[ -n "${issuer_cert_uri}" ]]; then
639 # create temporary files
640 local tmpcert
641 local tmpcert_raw
642 tmpcert_raw="$(_mktemp)"
643 tmpcert="$(_mktemp)"
644
645 # download certificate
646 http_request get "${issuer_cert_uri}" > "${tmpcert_raw}"
647
648 # PEM
649 if grep -q "BEGIN CERTIFICATE" "${tmpcert_raw}"; then mv "${tmpcert_raw}" "${tmpcert}"
650 # DER
651 elif openssl x509 -in "${tmpcert_raw}" -inform DER -out "${tmpcert}" -outform PEM 2> /dev/null > /dev/null; then :
652 # PKCS7
653 elif openssl pkcs7 -in "${tmpcert_raw}" -inform DER -out "${tmpcert}" -outform PEM -print_certs 2> /dev/null > /dev/null; then :
654 # Unknown certificate type
655 else _exiterr "Unknown certificate type in chain"
656 fi
657
ee65261e
LS
658 local next_issuer_cert_uri
659 next_issuer_cert_uri="$(get_issuer_cert_uri "${tmpcert}")"
660 if [[ -n "${next_issuer_cert_uri}" ]]; then
661 printf "\n%s\n" "${issuer_cert_uri}"
662 cat "${tmpcert}"
663 walk_chain "${tmpcert}" "${next_issuer_cert_uri}"
664 fi
6a32f20e
LS
665 rm -f "${tmpcert}" "${tmpcert_raw}"
666 fi
667}
668
50e7a072
NL
669# Create certificate for domain(s)
670sign_domain() {
671 domain="${1}"
672 altnames="${*}"
673 timestamp="$(date +%s)"
674
675 echo " + Signing domains..."
676 if [[ -z "${CA_NEW_AUTHZ}" ]] || [[ -z "${CA_NEW_CERT}" ]]; then
677 _exiterr "Certificate authority doesn't allow certificate signing"
678 fi
679
680 # If there is no existing certificate directory => make it
785ffa55
AM
681 if [[ ! -e "${CERTDIR}/${domain}" ]]; then
682 echo " + Creating new directory ${CERTDIR}/${domain} ..."
683 mkdir -p "${CERTDIR}/${domain}" || _exiterr "Unable to create directory ${CERTDIR}/${domain}"
50e7a072
NL
684 fi
685
af2bc7a9
LS
686 privkey="privkey.pem"
687 # generate a new private key if we need or want one
785ffa55 688 if [[ ! -r "${CERTDIR}/${domain}/privkey.pem" ]] || [[ "${PRIVATE_KEY_RENEW}" = "yes" ]]; then
af2bc7a9
LS
689 echo " + Generating private key..."
690 privkey="privkey-${timestamp}.pem"
691 case "${KEY_ALGO}" in
785ffa55
AM
692 rsa) _openssl genrsa -out "${CERTDIR}/${domain}/privkey-${timestamp}.pem" "${KEYSIZE}";;
693 prime256v1|secp384r1) _openssl ecparam -genkey -name "${KEY_ALGO}" -out "${CERTDIR}/${domain}/privkey-${timestamp}.pem";;
af2bc7a9
LS
694 esac
695 fi
a13e4103 696 # move rolloverkey into position (if any)
697 if [[ -r "${CERTDIR}/${domain}/privkey.pem" && -r "${CERTDIR}/${domain}/privkey.roll.pem" && "${PRIVATE_KEY_RENEW}" = "yes" && "${PRIVATE_KEY_ROLLOVER}" = "yes" ]]; then
698 echo " + Moving Rolloverkey into position.... "
699 mv "${CERTDIR}/${domain}/privkey.roll.pem" "${CERTDIR}/${domain}/privkey-tmp.pem"
700 mv "${CERTDIR}/${domain}/privkey-${timestamp}.pem" "${CERTDIR}/${domain}/privkey.roll.pem"
701 mv "${CERTDIR}/${domain}/privkey-tmp.pem" "${CERTDIR}/${domain}/privkey-${timestamp}.pem"
702 fi
703 # generate a new private rollover key if we need or want one
704 if [[ ! -r "${CERTDIR}/${domain}/privkey.roll.pem" && "${PRIVATE_KEY_ROLLOVER}" = "yes" && "${PRIVATE_KEY_RENEW}" = "yes" ]]; then
705 echo " + Generating private rollover key..."
706 case "${KEY_ALGO}" in
707 rsa) _openssl genrsa -out "${CERTDIR}/${domain}/privkey.roll.pem" "${KEYSIZE}";;
708 prime256v1|secp384r1) _openssl ecparam -genkey -name "${KEY_ALGO}" -out "${CERTDIR}/${domain}/privkey.roll.pem";;
709 esac
710 fi
711 # delete rolloverkeys if disabled
712 if [[ -r "${CERTDIR}/${domain}/privkey.roll.pem" && ! "${PRIVATE_KEY_ROLLOVER}" = "yes" ]]; then
713 echo " + Removing Rolloverkey (feature disabled)..."
714 rm -f "${CERTDIR}/${domain}/privkey.roll.pem"
715 fi
50e7a072
NL
716
717 # Generate signing request config and the actual signing request
718 echo " + Generating signing request..."
719 SAN=""
720 for altname in ${altnames}; do
721 SAN+="DNS:${altname}, "
722 done
723 SAN="${SAN%%, }"
724 local tmp_openssl_cnf
1f6a80a0 725 tmp_openssl_cnf="$(_mktemp)"
50e7a072
NL
726 cat "${OPENSSL_CNF}" > "${tmp_openssl_cnf}"
727 printf "[SAN]\nsubjectAltName=%s" "${SAN}" >> "${tmp_openssl_cnf}"
8e77ba5e
LS
728 if [ "${OCSP_MUST_STAPLE}" = "yes" ]; then
729 printf "\n1.3.6.1.5.5.7.1.24=DER:30:03:02:01:05" >> "${tmp_openssl_cnf}"
730 fi
785ffa55 731 openssl req -new -sha256 -key "${CERTDIR}/${domain}/${privkey}" -out "${CERTDIR}/${domain}/cert-${timestamp}.csr" -subj "/CN=${domain}/" -reqexts SAN -config "${tmp_openssl_cnf}"
50e7a072
NL
732 rm -f "${tmp_openssl_cnf}"
733
785ffa55 734 crt_path="${CERTDIR}/${domain}/cert-${timestamp}.pem"
5c68c221 735 # shellcheck disable=SC2086
785ffa55 736 sign_csr "$(< "${CERTDIR}/${domain}/cert-${timestamp}.csr" )" ${altnames} 3>"${crt_path}"
329acb58
LS
737
738 # Create fullchain.pem
1eb6f6d2 739 echo " + Creating fullchain.pem..."
785ffa55 740 cat "${crt_path}" > "${CERTDIR}/${domain}/fullchain-${timestamp}.pem"
6a32f20e 741 walk_chain "${crt_path}" > "${CERTDIR}/${domain}/chain-${timestamp}.pem"
785ffa55 742 cat "${CERTDIR}/${domain}/chain-${timestamp}.pem" >> "${CERTDIR}/${domain}/fullchain-${timestamp}.pem"
329acb58 743
1446fd88 744 # Update symlinks
785ffa55 745 [[ "${privkey}" = "privkey.pem" ]] || ln -sf "privkey-${timestamp}.pem" "${CERTDIR}/${domain}/privkey.pem"
f343dc11 746
785ffa55
AM
747 ln -sf "chain-${timestamp}.pem" "${CERTDIR}/${domain}/chain.pem"
748 ln -sf "fullchain-${timestamp}.pem" "${CERTDIR}/${domain}/fullchain.pem"
749 ln -sf "cert-${timestamp}.csr" "${CERTDIR}/${domain}/cert.csr"
750 ln -sf "cert-${timestamp}.pem" "${CERTDIR}/${domain}/cert.pem"
f343dc11 751
c24843c6 752 # Wait for hook script to clean the challenge and to deploy cert if used
785ffa55 753 [[ -n "${HOOK}" ]] && "${HOOK}" "deploy_cert" "${domain}" "${CERTDIR}/${domain}/privkey.pem" "${CERTDIR}/${domain}/cert.pem" "${CERTDIR}/${domain}/fullchain.pem" "${CERTDIR}/${domain}/chain.pem" "${timestamp}"
c24843c6 754
755 unset challenge_token
579e2316 756 echo " + Done!"
61f0b7ed
LS
757}
758
6a32f20e
LS
759# Usage: --register
760# Description: Register account key
761command_register() {
762 init_system
763 exit 0
764}
765
0a859a19 766# Usage: --cron (-c)
083c6736 767# Description: Sign/renew non-existant/changed/expiring certificates.
8f6c2328 768command_sign_domains() {
9f66bfdb
LS
769 init_system
770
8f6c2328 771 if [[ -n "${PARAM_DOMAIN:-}" ]]; then
1f6a80a0 772 DOMAINS_TXT="$(_mktemp)"
93cd114f 773 printf -- "${PARAM_DOMAIN}" > "${DOMAINS_TXT}"
a3e5ed36
DB
774 elif [[ -e "${DOMAINS_TXT}" ]]; then
775 if [[ ! -r "${DOMAINS_TXT}" ]]; then
776 _exiterr "domains.txt found but not readable"
777 fi
93cd114f
LS
778 else
779 _exiterr "domains.txt not found and --domain not given"
8f6c2328 780 fi
93cd114f 781
81882a64 782 # Generate certificates for all domains found in domains.txt. Check if existing certificate are about to expire
2099c77f
LS
783 ORIGIFS="${IFS}"
784 IFS=$'\n'
5d92c3b3 785 for line in $(<"${DOMAINS_TXT}" tr -d '\r' | awk '{print tolower($0)}' | _sed -e 's/^[[:space:]]*//g' -e 's/[[:space:]]*$//g' -e 's/[[:space:]]+/ /g' | (grep -vE '^(#|$)' || true)); do
ec489069 786 reset_configvars
2099c77f 787 IFS="${ORIGIFS}"
81882a64 788 domain="$(printf '%s\n' "${line}" | cut -d' ' -f1)"
8f6c2328 789 morenames="$(printf '%s\n' "${line}" | cut -s -d' ' -f2-)"
785ffa55 790 cert="${CERTDIR}/${domain}/cert.pem"
f9126627 791
2d097c92
MG
792 force_renew="${PARAM_FORCE:-no}"
793
8f6c2328
MG
794 if [[ -z "${morenames}" ]];then
795 echo "Processing ${domain}"
796 else
93cd114f 797 echo "Processing ${domain} with alternative names: ${morenames}"
8f6c2328
MG
798 fi
799
ec489069
LS
800 # read cert config
801 # for now this loads the certificate specific config in a subshell and parses a diff of set variables.
802 # we could just source the config file but i decided to go this way to protect people from accidentally overriding
803 # variables used internally by this script itself.
44aca90c
MS
804 if [[ -n "${DOMAINS_D}" ]]; then
805 certconfig="${DOMAINS_D}/${domain}"
806 else
807 certconfig="${CERTDIR}/${domain}/config"
808 fi
809
810 if [ -f "${certconfig}" ]; then
ec489069
LS
811 echo " + Using certificate specific config file!"
812 ORIGIFS="${IFS}"
813 IFS=$'\n'
814 for cfgline in $(
815 beforevars="$(_mktemp)"
816 aftervars="$(_mktemp)"
817 set > "${beforevars}"
818 # shellcheck disable=SC1090
44aca90c 819 . "${certconfig}"
ec489069
LS
820 set > "${aftervars}"
821 diff -u "${beforevars}" "${aftervars}" | grep -E '^\+[^+]'
822 rm "${beforevars}"
823 rm "${aftervars}"
824 ); do
825 config_var="$(echo "${cfgline:1}" | cut -d'=' -f1)"
826 config_value="$(echo "${cfgline:1}" | cut -d'=' -f2-)"
827 case "${config_var}" in
a13e4103 828 KEY_ALGO|OCSP_MUST_STAPLE|PRIVATE_KEY_RENEW|PRIVATE_KEY_ROLLOVER|KEYSIZE|CHALLENGETYPE|HOOK|WELLKNOWN|HOOK_CHAIN|OPENSSL_CNF|RENEW_DAYS)
ec489069
LS
829 echo " + ${config_var} = ${config_value}"
830 declare -- "${config_var}=${config_value}"
831 ;;
832 _) ;;
833 *) echo " ! Setting ${config_var} on a per-certificate base is not (yet) supported"
834 esac
835 done
836 IFS="${ORIGIFS}"
837 fi
838 verify_config
57197306 839 export WELLKNOWN CHALLENGETYPE KEY_ALGO PRIVATE_KEY_ROLLOVER
ec489069 840
81882a64 841 if [[ -e "${cert}" ]]; then
93cd114f 842 printf " + Checking domain name(s) of existing cert..."
2d097c92 843
f7c7d8c5
LS
844 certnames="$(openssl x509 -in "${cert}" -text -noout | grep DNS: | _sed 's/DNS://g' | tr -d ' ' | tr ',' '\n' | sort -u | tr '\n' ' ' | _sed 's/ $//')"
845 givennames="$(echo "${domain}" "${morenames}"| tr ' ' '\n' | sort -u | tr '\n' ' ' | _sed 's/ $//' | _sed 's/^ //')"
2d097c92
MG
846
847 if [[ "${certnames}" = "${givennames}" ]]; then
848 echo " unchanged."
849 else
850 echo " changed!"
851 echo " + Domain name(s) are not matching!"
852 echo " + Names in old certificate: ${certnames}"
853 echo " + Configured names: ${givennames}"
854 echo " + Forcing renew."
855 force_renew="yes"
856 fi
857 fi
858
859 if [[ -e "${cert}" ]]; then
860 echo " + Checking expire date of existing cert..."
81882a64 861 valid="$(openssl x509 -enddate -noout -in "${cert}" | cut -d= -f2- )"
8221727a 862
93cd114f 863 printf " + Valid till %s " "${valid}"
81882a64 864 if openssl x509 -checkend $((RENEW_DAYS * 86400)) -noout -in "${cert}"; then
93cd114f 865 printf "(Longer than %d days). " "${RENEW_DAYS}"
2d097c92
MG
866 if [[ "${force_renew}" = "yes" ]]; then
867 echo "Ignoring because renew was forced!"
8f6c2328 868 else
705fb54e 869 # Certificate-Names unchanged and cert is still valid
dd33de59 870 echo "Skipping renew!"
785ffa55 871 [[ -n "${HOOK}" ]] && "${HOOK}" "unchanged_cert" "${domain}" "${CERTDIR}/${domain}/privkey.pem" "${CERTDIR}/${domain}/cert.pem" "${CERTDIR}/${domain}/fullchain.pem" "${CERTDIR}/${domain}/chain.pem"
8f6c2328
MG
872 continue
873 fi
874 else
875 echo "(Less than ${RENEW_DAYS} days). Renewing!"
81882a64 876 fi
81882a64 877 fi
8221727a 878
81882a64 879 # shellcheck disable=SC2086
34565c19
B
880 if [[ "${PARAM_KEEP_GOING:-}" = "yes" ]]; then
881 sign_domain ${line} &
882 wait $! || true
883 else
884 sign_domain ${line}
885 fi
a7934fe7 886 done
f13eaa7f 887
8f6c2328 888 # remove temporary domains.txt file if used
93cd114f
LS
889 [[ -n "${PARAM_DOMAIN:-}" ]] && rm -f "${DOMAINS_TXT}"
890
298a7e9a 891 [[ -n "${HOOK}" ]] && "${HOOK}" "exit_hook"
93cd114f 892 exit 0
81882a64 893}
3390080c 894
429ec400
NL
895# Usage: --signcsr (-s) path/to/csr.pem
896# Description: Sign a given CSR, output CRT on stdout (advanced usage)
897command_sign_csr() {
898 # redirect stdout to stderr
899 # leave stdout over at fd 3 to output the cert
900 exec 3>&1 1>&2
901
902 init_system
903
904 csrfile="${1}"
905 if [ ! -r "${csrfile}" ]; then
906 _exiterr "Could not read certificate signing request ${csrfile}"
907 fi
908
620c7eb2
LS
909 # gen cert
910 certfile="$(_mktemp)"
911 sign_csr "$(< "${csrfile}" )" 3> "${certfile}"
912
d81eb585 913 # print cert
620c7eb2
LS
914 echo "# CERT #" >&3
915 cat "${certfile}" >&3
916 echo >&3
d81eb585
LS
917
918 # print chain
919 if [ -n "${PARAM_FULL_CHAIN:-}" ]; then
920 # get and convert ca cert
921 chainfile="$(_mktemp)"
7eca8aec
LS
922 tmpchain="$(_mktemp)"
923 http_request get "$(openssl x509 -in "${certfile}" -noout -text | grep 'CA Issuers - URI:' | cut -d':' -f2-)" > "${tmpchain}"
924 if grep -q "BEGIN CERTIFICATE" "${tmpchain}"; then
925 mv "${tmpchain}" "${chainfile}"
926 else
927 openssl x509 -in "${tmpchain}" -inform DER -out "${chainfile}" -outform PEM
928 rm "${tmpchain}"
d81eb585
LS
929 fi
930
931 echo "# CHAIN #" >&3
932 cat "${chainfile}" >&3
933
934 rm "${chainfile}"
935 fi
620c7eb2
LS
936
937 # cleanup
938 rm "${certfile}"
429ec400
NL
939
940 exit 0
941}
942
0a859a19
LS
943# Usage: --revoke (-r) path/to/cert.pem
944# Description: Revoke specified certificate
81882a64 945command_revoke() {
9f66bfdb
LS
946 init_system
947
3dcfa8b4
LS
948 [[ -n "${CA_REVOKE_CERT}" ]] || _exiterr "Certificate authority doesn't allow certificate revocation."
949
81882a64 950 cert="${1}"
c7018036
MG
951 if [[ -L "${cert}" ]]; then
952 # follow symlink and use real certificate name (so we move the real file and not the symlink at the end)
3bc1cf91
LS
953 local link_target
954 link_target="$(readlink -n "${cert}")"
955 if [[ "${link_target}" =~ ^/ ]]; then
c7018036
MG
956 cert="${link_target}"
957 else
958 cert="$(dirname "${cert}")/${link_target}"
959 fi
960 fi
3dcfa8b4
LS
961 [[ -f "${cert}" ]] || _exiterr "Could not find certificate ${cert}"
962
81882a64 963 echo "Revoking ${cert}"
3dcfa8b4 964
81882a64 965 cert64="$(openssl x509 -in "${cert}" -inform PEM -outform DER | urlbase64)"
561f0626 966 response="$(signed_request "${CA_REVOKE_CERT}" '{"resource": "revoke-cert", "certificate": "'"${cert64}"'"}' | clean_json)"
3dcfa8b4 967 # if there is a problem with our revoke request _request (via signed_request) will report this and "exit 1" out
81882a64 968 # so if we are here, it is safe to assume the request was successful
3dcfa8b4
LS
969 echo " + Done."
970 echo " + Renaming certificate to ${cert}-revoked"
81882a64
LS
971 mv -f "${cert}" "${cert}-revoked"
972}
c24843c6 973
e60682c0
LS
974# Usage: --cleanup (-gc)
975# Description: Move unused certificate files to archive directory
976command_cleanup() {
dec95fff
LS
977 load_config
978
e60682c0
LS
979 # Create global archive directory if not existant
980 if [[ ! -e "${BASEDIR}/archive" ]]; then
981 mkdir "${BASEDIR}/archive"
982 fi
983
984 # Loop over all certificate directories
785ffa55 985 for certdir in "${CERTDIR}/"*; do
f9430025
JB
986 # Skip if entry is not a folder
987 [[ -d "${certdir}" ]] || continue
988
e60682c0
LS
989 # Get certificate name
990 certname="$(basename "${certdir}")"
991
992 # Create certitifaces archive directory if not existant
993 archivedir="${BASEDIR}/archive/${certname}"
994 if [[ ! -e "${archivedir}" ]]; then
995 mkdir "${archivedir}"
996 fi
997
998 # Loop over file-types (certificates, keys, signing-requests, ...)
999 for filetype in cert.csr cert.pem chain.pem fullchain.pem privkey.pem; do
1000 # Skip if symlink is broken
1001 [[ -r "${certdir}/${filetype}" ]] || continue
1002
1003 # Look up current file in use
5c68c221 1004 current="$(basename "$(readlink "${certdir}/${filetype}")")"
e60682c0
LS
1005
1006 # Split filetype into name and extension
1007 filebase="$(echo "${filetype}" | cut -d. -f1)"
1008 fileext="$(echo "${filetype}" | cut -d. -f2)"
1009
1010 # Loop over all files of this type
1011 for file in "${certdir}/${filebase}-"*".${fileext}"; do
ac2d8303
JB
1012 # Handle case where no files match the wildcard
1013 [[ -f "${file}" ]] || break
1014
e60682c0
LS
1015 # Check if current file is in use, if unused move to archive directory
1016 filename="$(basename "${file}")"
1017 if [[ ! "${filename}" = "${current}" ]]; then
5c68c221 1018 echo "Moving unused file to archive directory: ${certname}/${filename}"
e60682c0
LS
1019 mv "${certdir}/${filename}" "${archivedir}/${filename}"
1020 fi
1021 done
1022 done
1023 done
1024
1025 exit 0
1026}
1027
0a859a19
LS
1028# Usage: --help (-h)
1029# Description: Show help text
81882a64 1030command_help() {
7727f5ea
LS
1031 printf "Usage: %s [-h] [command [argument]] [parameter [argument]] [parameter [argument]] ...\n\n" "${0}"
1032 printf "Default command: help\n\n"
0a859a19 1033 echo "Commands:"
760b6894 1034 grep -e '^[[:space:]]*# Usage:' -e '^[[:space:]]*# Description:' -e '^command_.*()[[:space:]]*{' "${0}" | while read -r usage; read -r description; read -r command; do
31111265 1035 if [[ ! "${usage}" =~ Usage ]] || [[ ! "${description}" =~ Description ]] || [[ ! "${command}" =~ ^command_ ]]; then
7727f5ea 1036 _exiterr "Error generating help text."
0a859a19 1037 fi
7727f5ea 1038 printf " %-32s %s\n" "${usage##"# Usage: "}" "${description##"# Description: "}"
0a859a19 1039 done
7727f5ea 1040 printf -- "\nParameters:\n"
760b6894 1041 grep -E -e '^[[:space:]]*# PARAM_Usage:' -e '^[[:space:]]*# PARAM_Description:' "${0}" | while read -r usage; read -r description; do
31111265 1042 if [[ ! "${usage}" =~ Usage ]] || [[ ! "${description}" =~ Description ]]; then
7727f5ea 1043 _exiterr "Error generating help text."
0a859a19 1044 fi
7727f5ea 1045 printf " %-32s %s\n" "${usage##"# PARAM_Usage: "}" "${description##"# PARAM_Description: "}"
0a859a19 1046 done
81882a64 1047}
063d28a6 1048
1ab6a436
LS
1049# Usage: --env (-e)
1050# Description: Output configuration variables for use in other scripts
1051command_env() {
ec49a443 1052 echo "# dehydrated configuration"
9f66bfdb 1053 load_config
44aca90c 1054 typeset -p CA LICENSE CERTDIR CHALLENGETYPE DOMAINS_D DOMAINS_TXT HOOK HOOK_CHAIN RENEW_DAYS ACCOUNT_KEY ACCOUNT_KEY_JSON KEYSIZE WELLKNOWN PRIVATE_KEY_RENEW OPENSSL_CNF CONTACT_EMAIL LOCKFILE
1ab6a436
LS
1055}
1056
bc580335 1057# Main method (parses script arguments and calls command_* methods)
9f66bfdb
LS
1058main() {
1059 COMMAND=""
1060 set_command() {
1061 [[ -z "${COMMAND}" ]] || _exiterr "Only one command can be executed at a time. See help (-h) for more information."
1062 COMMAND="${1}"
1063 }
1064
1065 check_parameters() {
1066 if [[ -z "${1:-}" ]]; then
1067 echo "The specified command requires additional parameters. See help:" >&2
31111265
LS
1068 echo >&2
1069 command_help >&2
81882a64 1070 exit 1
9f66bfdb
LS
1071 elif [[ "${1:0:1}" = "-" ]]; then
1072 _exiterr "Invalid argument: ${1}"
1073 fi
1074 }
579e2316 1075
2a7b4882 1076 [[ -z "${@}" ]] && eval set -- "--help"
fb0242a4 1077
da2eeda9 1078 while (( ${#} )); do
9f66bfdb
LS
1079 case "${1}" in
1080 --help|-h)
1081 command_help
1082 exit 0
1083 ;;
579e2316 1084
9f66bfdb
LS
1085 --env|-e)
1086 set_command env
1087 ;;
579e2316 1088
9f66bfdb
LS
1089 --cron|-c)
1090 set_command sign_domains
1091 ;;
1092
6a32f20e
LS
1093 --register)
1094 set_command register
1095 ;;
1096
1097 # PARAM_Usage: --accept-terms
1098 # PARAM_Description: Accept CAs terms of service
1099 --accept-terms)
1100 PARAM_ACCEPT_TERMS="yes"
1101 ;;
1102
429ec400
NL
1103 --signcsr|-s)
1104 shift 1
1105 set_command sign_csr
1106 check_parameters "${1:-}"
1107 PARAM_CSR="${1}"
1108 ;;
1109
9f66bfdb
LS
1110 --revoke|-r)
1111 shift 1
1112 set_command revoke
1113 check_parameters "${1:-}"
1114 PARAM_REVOKECERT="${1}"
1115 ;;
5060dea0 1116
e60682c0
LS
1117 --cleanup|-gc)
1118 set_command cleanup
1119 ;;
1120
d81eb585
LS
1121 # PARAM_Usage: --full-chain (-fc)
1122 # PARAM_Description: Print full chain when using --signcsr
1123 --full-chain|-fc)
1124 PARAM_FULL_CHAIN="1"
1125 ;;
1126
364bcccf 1127 # PARAM_Usage: --ipv4 (-4)
1128 # PARAM_Description: Resolve names to IPv4 addresses only
1129 --ipv4|-4)
1130 PARAM_IP_VERSION="4"
1131 ;;
1132
1133 # PARAM_Usage: --ipv6 (-6)
1134 # PARAM_Description: Resolve names to IPv6 addresses only
1135 --ipv6|-6)
1136 PARAM_IP_VERSION="6"
1137 ;;
1138
8f6c2328 1139 # PARAM_Usage: --domain (-d) domain.tld
9f66bfdb
LS
1140 # PARAM_Description: Use specified domain name(s) instead of domains.txt entry (one certificate!)
1141 --domain|-d)
1142 shift 1
1143 check_parameters "${1:-}"
1144 if [[ -z "${PARAM_DOMAIN:-}" ]]; then
1145 PARAM_DOMAIN="${1}"
1146 else
1147 PARAM_DOMAIN="${PARAM_DOMAIN} ${1}"
1148 fi
1149 ;;
1150
34565c19
B
1151 # PARAM_Usage: --keep-going (-g)
1152 # PARAM_Description: Keep going after encountering an error while creating/renewing multiple certificates in cron mode
1153 --keep-going|-g)
1154 PARAM_KEEP_GOING="yes"
1155 ;;
1156
8f6c2328 1157 # PARAM_Usage: --force (-x)
9f66bfdb
LS
1158 # PARAM_Description: Force renew of certificate even if it is longer valid than value in RENEW_DAYS
1159 --force|-x)
1160 PARAM_FORCE="yes"
1161 ;;
1162
bd9cc5b0
LS
1163 # PARAM_Usage: --no-lock (-n)
1164 # PARAM_Description: Don't use lockfile (potentially dangerous!)
1165 --no-lock|-n)
1166 PARAM_NO_LOCK="yes"
1167 ;;
1168
8456855e
E
1169 # PARAM_Usage: --lock-suffix example.com
1170 # PARAM_Description: Suffix lockfile name with a string (useful for with -d)
1171 --lock-suffix)
1172 shift 1
1173 check_parameters "${1:-}"
1174 PARAM_LOCKFILE_SUFFIX="${1}"
1175 ;;
1176
8e77ba5e
LS
1177 # PARAM_Usage: --ocsp
1178 # PARAM_Description: Sets option in CSR indicating OCSP stapling to be mandatory
1179 --ocsp)
1180 PARAM_OCSP_MUST_STAPLE="yes"
1181 ;;
1182
0a859a19
LS
1183 # PARAM_Usage: --privkey (-p) path/to/key.pem
1184 # PARAM_Description: Use specified private key instead of account key (useful for revocation)
9f66bfdb
LS
1185 --privkey|-p)
1186 shift 1
1187 check_parameters "${1:-}"
8aa1a05b 1188 PARAM_ACCOUNT_KEY="${1}"
9f66bfdb
LS
1189 ;;
1190
d5b28586 1191 # PARAM_Usage: --config (-f) path/to/config
9f66bfdb
LS
1192 # PARAM_Description: Use specified config file
1193 --config|-f)
1194 shift 1
1195 check_parameters "${1:-}"
1196 CONFIG="${1}"
1197 ;;
1198
ed27e013
MG
1199 # PARAM_Usage: --hook (-k) path/to/hook.sh
1200 # PARAM_Description: Use specified script for hooks
1201 --hook|-k)
1202 shift 1
1203 check_parameters "${1:-}"
1204 PARAM_HOOK="${1}"
1205 ;;
1206
785ffa55
AM
1207 # PARAM_Usage: --out (-o) certs/directory
1208 # PARAM_Description: Output certificates into the specified directory
1209 --out|-o)
1210 shift 1
1211 check_parameters "${1:-}"
1212 PARAM_CERTDIR="${1}"
1213 ;;
1214
e925b293
MG
1215 # PARAM_Usage: --challenge (-t) http-01|dns-01
1216 # PARAM_Description: Which challenge should be used? Currently http-01 and dns-01 are supported
1217 --challenge|-t)
1218 shift 1
1219 check_parameters "${1:-}"
1220 PARAM_CHALLENGETYPE="${1}"
1221 ;;
1222
c71ca3a8
MG
1223 # PARAM_Usage: --algo (-a) rsa|prime256v1|secp384r1
1224 # PARAM_Description: Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1
1225 --algo|-a)
1226 shift 1
1227 check_parameters "${1:-}"
1228 PARAM_KEY_ALGO="${1}"
1229 ;;
1230
9f66bfdb
LS
1231 *)
1232 echo "Unknown parameter detected: ${1}" >&2
1233 echo >&2
1234 command_help >&2
1235 exit 1
1236 ;;
1237 esac
1238
1239 shift 1
1240 done
1241
1242 case "${COMMAND}" in
1243 env) command_env;;
1244 sign_domains) command_sign_domains;;
6a32f20e 1245 register) command_register;;
429ec400 1246 sign_csr) command_sign_csr "${PARAM_CSR}";;
9f66bfdb 1247 revoke) command_revoke "${PARAM_REVOKECERT}";;
e60682c0 1248 cleanup) command_cleanup;;
7191ed25 1249 *) command_help; exit 1;;
81882a64 1250 esac
9f66bfdb 1251}
81882a64 1252
c3c9ff4c
LS
1253# Determine OS type
1254OSTYPE="$(uname)"
1255
9f66bfdb
LS
1256# Check for missing dependencies
1257check_dependencies
1258
1259# Run script
1260main "${@:-}"