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