Obtains certificates from Let's Encrypt, using Lexicon to answer DNS-based challenges
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

letsencrypt.sh 32KB

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