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