diff options
Diffstat (limited to '')
-rw-r--r-- | .github/FUNDING.yml | 2 | ||||
-rw-r--r-- | .gitignore | 9 | ||||
-rw-r--r-- | CHANGELOG | 166 | ||||
-rw-r--r-- | LICENSE | 21 | ||||
-rw-r--r-- | README.md | 88 | ||||
-rwxr-xr-x | dehydrated | 2404 | ||||
-rw-r--r-- | docs/acme-v1.md | 19 | ||||
-rw-r--r-- | docs/dns-verification.md | 31 | ||||
-rw-r--r-- | docs/domains_txt.md | 107 | ||||
-rw-r--r-- | docs/ecc.md | 4 | ||||
-rw-r--r-- | docs/examples/config | 132 | ||||
-rw-r--r-- | docs/examples/domains.txt | 39 | ||||
-rwxr-xr-x | docs/examples/hook.sh | 220 | ||||
-rw-r--r-- | docs/hook_chain.md | 62 | ||||
-rw-r--r-- | docs/logo.png | bin | 0 -> 76482 bytes | |||
-rw-r--r-- | docs/man/dehydrated.1 | 155 | ||||
-rw-r--r-- | docs/per-certificate-config.md | 29 | ||||
-rw-r--r-- | docs/staging.md | 14 | ||||
-rw-r--r-- | docs/tls-alpn.md | 124 | ||||
-rw-r--r-- | docs/troubleshooting.md | 52 | ||||
-rw-r--r-- | docs/wellknown.md | 78 |
21 files changed, 3756 insertions, 0 deletions
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..4e52c7e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: lukas2511 +custom: ["https://paypal.me/lukas2511", "http://www.amazon.de/registry/wishlist/1TUCFJK35IO4Q"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfb5a93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +private_key.pem +private_key.json +domains.txt +config +hook.sh +certs/* +archive/* +accounts/* +chains/* diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..42d5761 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,166 @@ +# Change Log +This file contains a log of major changes in dehydrated + +## [0.7.1] - 2022-10-31 +## Changed +- `--force` no longer forces domain name revalidation by default, a new argument `--force-validation` has been added for that +- Added support for EC secp521r1 algorithm (works with e.g. zerossl) +- `EC PARAMETERS` are no longer written to privkey.pem (didn't seem necessary and was causing issues with various software) + +## Fixed +- Requests resulting in `badNonce` errors are now automatically retried (fixes operation with LE staging servers) +- Deprecated `egrep` usage has been removed + +## Added +- Implemented EC for account keys +- Domain list now also read from domains.txt.d subdirectory (behaviour might change, see docs) +- Implemented RFC 8738 (validating/signing certificates for IP addresses instead of domain names) support (this will not work with most public CAs, if any!) + +## [0.7.0] - 2020-12-10 +## Added +- Support for external account bindings +- Special support for ZeroSSL +- Support presets for some CAs instead of requiring URLs +- Allow requesting preferred chain (`--preferred-chain`) +- Added method to show CAs current terms of service (`--display-terms`) +- Allow setting path to domains.txt using cli arguments (`--domains-txt`) +- Added new cli command `--cleanupdelete` which deletes old files instead of archiving them + +## Fixed +- No more silent failures on broken hook-scripts +- Better error-handling with KEEP_GOING enabled +- Check actual order status instead of assuming it's valid +- Don't include keyAuthorization in challenge validation (RFC compliance) + +## Changed +- Using EC secp384r1 as default certificate type +- Use JSON.sh to parse JSON +- Use account URL instead of account ID (RFC compliance) +- Dehydrated now has a new home: https://github.com/dehydrated-io/dehydrated +- Added `OCSP_FETCH` and `OCSP_DAYS` to per-certificate configurable options +- Cleanup now also removes dangling symlinks + +## [0.6.5] - 2019-06-26 +## Fixed +- Fixed broken APIv1 compatibility from last update + +## [0.6.4] - 2019-06-25 +## Changed +- Fetch account ID from Location header instead of account json + +## [0.6.3] - 2019-06-25 +## Changed +- OCSP refresh interval is now configurable +- Implemented POST-as-GET +- Call exit_hook on errors (with error-message as first parameter) + +## Added +- Initial support for tls-alpn-01 validation +- New hook: sync_cert (for syncing certificate files to disk, see example hook description) + +## Fixes +- Fetch account information after registration to avoid missing account id + +## [0.6.2] - 2018-04-25 +## Added +- New deploy_ocsp hook +- Allow account registration with custom key + +## Changed +- Don't walk certificate chain for ACMEv2 (certificate contains chain by default) +- Improved documentation on wildcards + +## Fixes +- Added workaround for compatibility with filesystem ACLs +- Close unwanted external file-descriptors +- Fixed JSON parsing on force-renewal +- Fixed cleanup of challenge files/dns-entries on validation errors +- A few more minor fixes + +## [0.6.1] - 2018-03-13 +## Changed +- Use new ACME v2 endpoint by default + +## [0.6.0] - 2018-03-11 +## Changed +- Challenge validation loop has been modified to loop over authorization identifiers instead of altnames (ACMEv2 + wildcard support) +- Removed LICENSE parameter from config (terms of service is now acquired directly from the CA directory) + +## Added +- Support for ACME v02 (including wildcard certificates!) +- New hook: generate_csr (see example hook script for more information) +- Calling random hook on startup to make it clear to hook script authors that unknown hooks should just be ignored... + +## [0.5.0] - 2018-01-13 +## Changed +- Certificate chain is now cached (CHAINCACHE) +- OpenSSL binary path is now configurable (OPENSSL) +- Cleanup now also moves revoked certificates + +## Added +- New feature for updating contact information (--account) +- Allow automatic cleanup on exit (AUTO_CLEANUP) +- Initial support for fetching OCSP status to be used for OCSP stapling (OCSP_FETCH) +- Certificates can now have aliases to create multiple certificates with identical set of domains (see --alias and domains.txt documentation) +- Allow dehydrated to run as specified user (/group) + +## [0.4.0] - 2017-02-05 +## Changed +- dehydrated now asks you to read and accept the CAs terms of service before creating an account +- Skip challenges for already validated domains +- Removed need for some special commands (BusyBox compatibility) +- Exported a few more variables for use in hook-scripts +- fullchain.pem now actually contains the full chain instead of just the certificate with an intermediate cert + +## Added +- Added private-key rollover functionality +- Added `--lock-suffix` option for allowing parallel execution +- Added `invalid_challenge` hook +- Added `request_failure` hook +- Added `exit_hook` hook +- Added standalone `register` command + +## [0.3.1] - 2016-09-13 +## Changed +- Renamed project to `dehydrated`. +- Default WELLKNOWN location is now `/var/www/dehydrated` +- Config location is renamed to `dehydrated` (e.g. `/etc/dehydrated`) + +## [0.3.0] - 2016-09-07 +## Changed +- Config is now named `config` instead of `config.sh`! +- Location of domains.txt is now configurable via DOMAINS_TXT config variable +- Location of certs directory is now configurable via CERTDIR config variable +- signcsr command now also outputs chain certificate if --full-chain/-fc is set +- Location of account-key(s) changed +- Default WELLKNOWN location is now `/var/www/letsencrypt` +- New version of Let's Encrypt Subscriber Agreement + +## Added +- Added option to add CSR-flag indicating OCSP stapling to be mandatory +- Initial support for configuration on per-certificate base +- Support for per-CA account keys and custom config for output cert directory, license, etc. +- Added option to select IP version of name to address resolution +- Added option to run letsencrypt.sh without locks + +## Fixed +- letsencrypt.sh no longer stores account keys from invalid registrations + +## [0.2.0] - 2016-05-22 +### Changed +- PRIVATE_KEY config parameter has been renamed to ACCOUNT_KEY to avoid confusion with certificate keys +- deploy_cert hook now also has the certificates timestamp as standalone parameter +- Temporary files are now identifiable (template: letsencrypt.sh-XXXXXX) +- Private keys are now regenerated by default + +### Added +- Added documentation to repository + +### Fixed +- Fixed bug with uppercase names in domains.txt (script now converts everything to lowercase) +- mktemp no longer uses the deprecated `-t` parameter. +- Compatibility with "pretty" json + +## [0.1.0] - 2016-03-25 +### Changed +- This is the first numbered version of letsencrypt.sh @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-2021 Lukas Schauer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c03f147 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# dehydrated [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=23P9DSJBTY7C8) + +![](docs/logo.png) + +Dehydrated is a client for signing certificates with an ACME-server (e.g. Let's Encrypt) implemented as a relatively simple (zsh-compatible) bash-script. +This client supports both ACME v1 and the new ACME v2 including support for wildcard certificates! + +It uses the `openssl` utility for everything related to actually handling keys and certificates, so you need to have that installed. + +Other dependencies are: cURL, sed, grep, awk, mktemp (all found pre-installed on almost any system, cURL being the only exception). + +Current features: +- Signing of a list of domains (including wildcard domains!) +- Signing of a custom CSR (either standalone or completely automated using hooks!) +- Renewal if a certificate is about to expire or defined set of domains changed +- Certificate revocation +- and lots more.. + +Please keep in mind that this software, the ACME-protocol and all supported CA servers out there are relatively young and there might be a few issues. Feel free to report any issues you find with this script or contribute by submitting a pull request, +but please check for duplicates first (feel free to comment on those to get things rolling). + +## Getting started + +For getting started I recommend taking a look at [docs/domains_txt.md](docs/domains_txt.md), [docs/wellknown.md](docs/wellknown.md) and the [Usage](#usage) section on this page (you'll probably only need the `-c` option). + +Generally you want to set up your WELLKNOWN path first, and then fill in domains.txt. + +**Please note that you should use the staging URL when experimenting with this script to not hit Let's Encrypt's rate limits.** See [docs/staging.md](docs/staging.md). + +If you have any problems take a look at our [Troubleshooting](docs/troubleshooting.md) guide. + +## Config + +dehydrated is looking for a config file in a few different places, it will use the first one it can find in this order: + +- `/etc/dehydrated/config` +- `/usr/local/etc/dehydrated/config` +- The current working directory of your shell +- The directory from which dehydrated was run + +Have a look at [docs/examples/config](docs/examples/config) to get started, copy it to e.g. `/etc/dehydrated/config` +and edit it to fit your needs. + +## Usage: + +```text +Usage: ./dehydrated [-h] [command [argument]] [parameter [argument]] [parameter [argument]] ... + +Default command: help + +Commands: + --version (-v) Print version information + --display-terms Display current terms of service + --register Register account key + --account Update account contact information + --cron (-c) Sign/renew non-existent/changed/expiring certificates. + --signcsr (-s) path/to/csr.pem Sign a given CSR, output CRT on stdout (advanced usage) + --revoke (-r) path/to/cert.pem Revoke specified certificate + --deactivate Deactivate account + --cleanup (-gc) Move unused certificate files to archive directory + --cleanup-delete (-gcd) Deletes (!) unused certificate files + --help (-h) Show help text + --env (-e) Output configuration variables for use in other scripts + +Parameters: + --accept-terms Accept CAs terms of service + --full-chain (-fc) Print full chain when using --signcsr + --ipv4 (-4) Resolve names to IPv4 addresses only + --ipv6 (-6) Resolve names to IPv6 addresses only + --domain (-d) domain.tld Use specified domain name(s) instead of domains.txt entry (one certificate!) + --ca url/preset Use specified CA URL or preset + --alias certalias Use specified name for certificate directory (and per-certificate config) instead of the primary domain (only used if --domain is specified) + --keep-going (-g) Keep going after encountering an error while creating/renewing multiple certificates in cron mode + --force (-x) Force renew of certificate even if it is longer valid than value in RENEW_DAYS + --force-validation Force revalidation of domain names (used in combination with --force) + --no-lock (-n) Don't use lockfile (potentially dangerous!) + --lock-suffix example.com Suffix lockfile name with a string (useful for with -d) + --ocsp Sets option in CSR indicating OCSP stapling to be mandatory + --privkey (-p) path/to/key.pem Use specified private key instead of account key (useful for revocation) + --domains-txt path/to/domains.txt Use specified domains.txt instead of default/configured one + --config (-f) path/to/config Use specified config file + --hook (-k) path/to/hook.sh Use specified script for hooks + --preferred-chain issuer-cn Use alternative certificate chain identified by issuer CN + --out (-o) certs/directory Output certificates into the specified directory + --alpn alpn-certs/directory Output alpn verification certificates into the specified directory + --challenge (-t) http-01|dns-01|tls-alpn-01 Which challenge should be used? Currently http-01, dns-01, and tls-alpn-01 are supported + --algo (-a) rsa|prime256v1|secp384r1 Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1 +``` diff --git a/dehydrated b/dehydrated new file mode 100755 index 0000000..a170666 --- /dev/null +++ b/dehydrated @@ -0,0 +1,2404 @@ +#!/usr/bin/env bash + +# dehydrated by lukas2511 +# Source: https://dehydrated.io +# +# This script is licensed under The MIT License (see LICENSE for more information). + +set -e +set -u +set -o pipefail +[[ -n "${ZSH_VERSION:-}" ]] && set -o SH_WORD_SPLIT && set +o FUNCTION_ARGZERO && set -o NULL_GLOB && set -o noglob +[[ -z "${ZSH_VERSION:-}" ]] && shopt -s nullglob && set -f + +umask 077 # paranoid umask, we're creating private keys + +# Close weird external file descriptors +exec 3>&- +exec 4>&- + +VERSION="0.7.1" + +# Find directory in which this script is stored by traversing all symbolic links +SOURCE="${0}" +while [ -h "$SOURCE" ]; do # resolve $SOURCE until the file is no longer a symlink + DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" + SOURCE="$(readlink "$SOURCE")" + [[ $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 +done +SCRIPTDIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" + +BASEDIR="${SCRIPTDIR}" +ORIGARGS=("${@}") + +noglob_set() { + if [[ -n "${ZSH_VERSION:-}" ]]; then + set +o noglob + else + set +f + fi +} + +noglob_clear() { + if [[ -n "${ZSH_VERSION:-}" ]]; then + set -o noglob + else + set -f + fi +} + +# Generate json.sh path matching string +json_path() { + if [ ! "${1}" = "-p" ]; then + printf '"%s"' "${1}" + else + printf '%s' "${2}" + fi +} + +# Get string value from json dictionary +get_json_string_value() { + local filter + filter="$(printf 's/.*\[%s\][[:space:]]*"\([^"]*\)"/\\1/p' "$(json_path "${1:-}" "${2:-}")")" + sed -n "${filter}" +} + +# Get array values from json dictionary +get_json_array_values() { + grep -E '^\['"$(json_path "${1:-}" "${2:-}")"',[0-9]*\]' | sed -e 's/\[[^\]*\][[:space:]]*//g' -e 's/^"//' -e 's/"$//' +} + +# Get sub-dictionary from json +get_json_dict_value() { + local filter + filter="$(printf 's/.*\[%s\][[:space:]]*\(.*\)/\\1/p' "$(json_path "${1:-}" "${2:-}")")" + sed -n "${filter}" | jsonsh +} + +# Get integer value from json +get_json_int_value() { + local filter + filter="$(printf 's/.*\[%s\][[:space:]]*\([^"]*\)/\\1/p' "$(json_path "${1:-}" "${2:-}")")" + sed -n "${filter}" +} + +# Get boolean value from json +get_json_bool_value() { + local filter + filter="$(printf 's/.*\[%s\][[:space:]]*\([^"]*\)/\\1/p' "$(json_path "${1:-}" "${2:-}")")" + sed -n "${filter}" +} + +# JSON.sh JSON-parser +# Modified from https://github.com/dominictarr/JSON.sh +# Original Copyright (c) 2011 Dominic Tarr +# Licensed under The MIT License +jsonsh() { + + throw() { + echo "$*" >&2 + exit 1 + } + + awk_egrep () { + local pattern_string=$1 + + awk '{ + while ($0) { + start=match($0, pattern); + token=substr($0, start, RLENGTH); + print token; + $0=substr($0, start+RLENGTH); + } + }' pattern="$pattern_string" + } + + tokenize () { + local GREP + local ESCAPE + local CHAR + + if echo "test string" | grep -Eao --color=never "test" >/dev/null 2>&1 + then + GREP='grep -Eao --color=never' + else + GREP='grep -Eao' + fi + + # shellcheck disable=SC2196 + if echo "test string" | grep -Eao "test" >/dev/null 2>&1 + then + ESCAPE='(\\[^u[:cntrl:]]|\\u[0-9a-fA-F]{4})' + CHAR='[^[:cntrl:]"\\]' + else + GREP=awk_egrep + ESCAPE='(\\\\[^u[:cntrl:]]|\\u[0-9a-fA-F]{4})' + CHAR='[^[:cntrl:]"\\\\]' + fi + + local STRING="\"$CHAR*($ESCAPE$CHAR*)*\"" + local NUMBER='-?(0|[1-9][0-9]*)([.][0-9]*)?([eE][+-]?[0-9]*)?' + local KEYWORD='null|false|true' + local SPACE='[[:space:]]+' + + # Force zsh to expand $A into multiple words + local is_wordsplit_disabled + is_wordsplit_disabled="$(unsetopt 2>/dev/null | grep -c '^shwordsplit$')" + if [ "${is_wordsplit_disabled}" != "0" ]; then setopt shwordsplit; fi + $GREP "$STRING|$NUMBER|$KEYWORD|$SPACE|." | grep -Ev "^$SPACE$" + if [ "${is_wordsplit_disabled}" != "0" ]; then unsetopt shwordsplit; fi + } + + parse_array () { + local index=0 + local ary='' + read -r token + case "$token" in + ']') ;; + *) + while : + do + parse_value "$1" "$index" + index=$((index+1)) + ary="$ary""$value" + read -r token + case "$token" in + ']') break ;; + ',') ary="$ary," ;; + *) throw "EXPECTED , or ] GOT ${token:-EOF}" ;; + esac + read -r token + done + ;; + esac + value=$(printf '[%s]' "$ary") || value= + : + } + + parse_object () { + local key + local obj='' + read -r token + case "$token" in + '}') ;; + *) + while : + do + case "$token" in + '"'*'"') key=$token ;; + *) throw "EXPECTED string GOT ${token:-EOF}" ;; + esac + read -r token + case "$token" in + ':') ;; + *) throw "EXPECTED : GOT ${token:-EOF}" ;; + esac + read -r token + parse_value "$1" "$key" + obj="$obj$key:$value" + read -r token + case "$token" in + '}') break ;; + ',') obj="$obj," ;; + *) throw "EXPECTED , or } GOT ${token:-EOF}" ;; + esac + read -r token + done + ;; + esac + value=$(printf '{%s}' "$obj") || value= + : + } + + parse_value () { + local jpath="${1:+$1,}${2:-}" + case "$token" in + '{') parse_object "$jpath" ;; + '[') parse_array "$jpath" ;; + # At this point, the only valid single-character tokens are digits. + ''|[!0-9]) throw "EXPECTED value GOT ${token:-EOF}" ;; + *) value="${token/\\\///}" + # replace solidus ("\/") in json strings with normalized value: "/" + ;; + esac + [ "$value" = '' ] && return + [ -z "$jpath" ] && return # do not print head + + printf "[%s]\t%s\n" "$jpath" "$value" + : + } + + parse () { + read -r token + parse_value + read -r token || true + case "$token" in + '') ;; + *) throw "EXPECTED EOF GOT $token" ;; + esac + } + + tokenize | parse +} + +# Convert IP addresses to their reverse dns variants. +# Used for ALPN certs as validation for IPs uses this in SNI since IPs aren't allowed there. +ip_to_ptr() { + ip="$(cat)" + if [[ "${ip}" =~ : ]]; then + printf "%sip6.arpa" "$(printf "%s" "${ip}" | awk -F: 'BEGIN {OFS=""; }{addCount = 9 - NF; for(i=1; i<=NF;i++){if(length($i) == 0){ for(j=1;j<=addCount;j++){$i = ($i "0000");} } else { $i = substr(("0000" $i), length($i)+5-4);}}; print}' | rev | sed -e "s/./&./g")" + else + printf "%s.in-addr.arpa" "$(printf "%s" "${ip}" | awk -F. '{print $4"."$3"." $2"."$1}')" + fi +} + +# Create (identifiable) temporary files +_mktemp() { + mktemp "${TMPDIR:-/tmp}/dehydrated-XXXXXX" +} + +# Check for script dependencies +check_dependencies() { + # look for required binaries + for binary in grep mktemp diff sed awk curl cut head tail hexdump; do + bin_path="$(command -v "${binary}" 2>/dev/null)" || _exiterr "This script requires ${binary}." + [[ -x "${bin_path}" ]] || _exiterr "${binary} found in PATH but it's not executable" + done + + # just execute some dummy and/or version commands to see if required tools are actually usable + "${OPENSSL}" version > /dev/null 2>&1 || _exiterr "This script requires an openssl binary." + _sed "" < /dev/null > /dev/null 2>&1 || _exiterr "This script requires sed with support for extended (modern) regular expressions." + + # curl returns with an error code in some ancient versions so we have to catch that + set +e + CURL_VERSION="$(curl -V 2>&1 | head -n1 | awk '{print $2}')" + set -e +} + +store_configvars() { + __KEY_ALGO="${KEY_ALGO}" + __OCSP_MUST_STAPLE="${OCSP_MUST_STAPLE}" + __OCSP_FETCH="${OCSP_FETCH}" + __OCSP_DAYS="${OCSP_DAYS}" + __PRIVATE_KEY_RENEW="${PRIVATE_KEY_RENEW}" + __PRIVATE_KEY_ROLLOVER="${PRIVATE_KEY_ROLLOVER}" + __KEYSIZE="${KEYSIZE}" + __CHALLENGETYPE="${CHALLENGETYPE}" + __HOOK="${HOOK}" + __PREFERRED_CHAIN="${PREFERRED_CHAIN}" + __WELLKNOWN="${WELLKNOWN}" + __HOOK_CHAIN="${HOOK_CHAIN}" + __OPENSSL_CNF="${OPENSSL_CNF}" + __RENEW_DAYS="${RENEW_DAYS}" + __IP_VERSION="${IP_VERSION}" +} + +reset_configvars() { + KEY_ALGO="${__KEY_ALGO}" + OCSP_MUST_STAPLE="${__OCSP_MUST_STAPLE}" + OCSP_FETCH="${__OCSP_FETCH}" + OCSP_DAYS="${__OCSP_DAYS}" + PRIVATE_KEY_RENEW="${__PRIVATE_KEY_RENEW}" + PRIVATE_KEY_ROLLOVER="${__PRIVATE_KEY_ROLLOVER}" + KEYSIZE="${__KEYSIZE}" + CHALLENGETYPE="${__CHALLENGETYPE}" + HOOK="${__HOOK}" + PREFERRED_CHAIN="${__PREFERRED_CHAIN}" + WELLKNOWN="${__WELLKNOWN}" + HOOK_CHAIN="${__HOOK_CHAIN}" + OPENSSL_CNF="${__OPENSSL_CNF}" + RENEW_DAYS="${__RENEW_DAYS}" + IP_VERSION="${__IP_VERSION}" +} + +hookscript_bricker_hook() { + # Hook scripts should ignore any hooks they don't know. + # Calling a random hook to make this clear to the hook script authors... + if [[ -n "${HOOK}" ]]; then + "${HOOK}" "this_hookscript_is_broken__dehydrated_is_working_fine__please_ignore_unknown_hooks_in_your_script" || _exiterr "Please check your hook script, it should exit cleanly without doing anything on unknown/new hooks." + fi +} + +# verify configuration values +verify_config() { + [[ "${CHALLENGETYPE}" == "http-01" || "${CHALLENGETYPE}" == "dns-01" || "${CHALLENGETYPE}" == "tls-alpn-01" ]] || _exiterr "Unknown challenge type ${CHALLENGETYPE}... cannot continue." + if [[ "${CHALLENGETYPE}" = "dns-01" ]] && [[ -z "${HOOK}" ]]; then + _exiterr "Challenge type dns-01 needs a hook script for deployment... cannot continue." + fi + if [[ "${CHALLENGETYPE}" = "http-01" && ! -d "${WELLKNOWN}" && ! "${COMMAND:-}" = "register" ]]; then + _exiterr "WELLKNOWN directory doesn't exist, please create ${WELLKNOWN} and set appropriate permissions." + fi + [[ "${KEY_ALGO}" == "rsa" || "${KEY_ALGO}" == "prime256v1" || "${KEY_ALGO}" == "secp384r1" || "${KEY_ALGO}" == "secp521r1" ]] || _exiterr "Unknown public key algorithm ${KEY_ALGO}... cannot continue." + if [[ -n "${IP_VERSION}" ]]; then + [[ "${IP_VERSION}" = "4" || "${IP_VERSION}" = "6" ]] || _exiterr "Unknown IP version ${IP_VERSION}... cannot continue." + fi + [[ "${API}" == "auto" || "${API}" == "1" || "${API}" == "2" ]] || _exiterr "Unsupported API version defined in config: ${API}" + [[ "${OCSP_DAYS}" =~ ^[0-9]+$ ]] || _exiterr "OCSP_DAYS must be a number" +} + +# Setup default config values, search for and load configuration files +load_config() { + # Check for config in various locations + if [[ -z "${CONFIG:-}" ]]; then + for check_config in "/etc/dehydrated" "/usr/local/etc/dehydrated" "${PWD}" "${SCRIPTDIR}"; do + if [[ -f "${check_config}/config" ]]; then + BASEDIR="${check_config}" + CONFIG="${check_config}/config" + break + fi + done + fi + + # Preset + CA_ZEROSSL="https://acme.zerossl.com/v2/DV90" + CA_LETSENCRYPT="https://acme-v02.api.letsencrypt.org/directory" + CA_LETSENCRYPT_TEST="https://acme-staging-v02.api.letsencrypt.org/directory" + CA_BUYPASS="https://api.buypass.com/acme/directory" + CA_BUYPASS_TEST="https://api.test4.buypass.no/acme/directory" + + # Default values + CA="letsencrypt" + OLDCA= + CERTDIR= + ALPNCERTDIR= + ACCOUNTDIR= + ACCOUNT_KEYSIZE="4096" + ACCOUNT_KEY_ALGO=rsa + CHALLENGETYPE="http-01" + CONFIG_D= + CURL_OPTS= + DOMAINS_D= + DOMAINS_TXT= + HOOK= + PREFERRED_CHAIN= + HOOK_CHAIN="no" + RENEW_DAYS="30" + KEYSIZE="4096" + WELLKNOWN= + PRIVATE_KEY_RENEW="yes" + PRIVATE_KEY_ROLLOVER="no" + KEY_ALGO=secp384r1 + OPENSSL=openssl + OPENSSL_CNF= + CONTACT_EMAIL= + LOCKFILE= + OCSP_MUST_STAPLE="no" + OCSP_FETCH="no" + OCSP_DAYS=5 + IP_VERSION= + CHAINCACHE= + AUTO_CLEANUP="no" + DEHYDRATED_USER= + DEHYDRATED_GROUP= + API="auto" + + if [[ -z "${CONFIG:-}" ]]; then + echo "#" >&2 + echo "# !! WARNING !! No main config file found, using default config!" >&2 + echo "#" >&2 + elif [[ -f "${CONFIG}" ]]; then + echo "# INFO: Using main config file ${CONFIG}" + BASEDIR="$(dirname "${CONFIG}")" + # shellcheck disable=SC1090 + . "${CONFIG}" + else + _exiterr "Specified config file doesn't exist." + fi + + if [[ -n "${CONFIG_D}" ]]; then + if [[ ! -d "${CONFIG_D}" ]]; then + _exiterr "The path ${CONFIG_D} specified for CONFIG_D does not point to a directory." + fi + + # Allow globbing + noglob_set + + for check_config_d in "${CONFIG_D}"/*.sh; do + if [[ -f "${check_config_d}" ]] && [[ -r "${check_config_d}" ]]; then + echo "# INFO: Using additional config file ${check_config_d}" + # shellcheck disable=SC1090 + . "${check_config_d}" + else + _exiterr "Specified additional config ${check_config_d} is not readable or not a file at all." + fi + done + + # Disable globbing + noglob_clear + fi + + # Check for missing dependencies + check_dependencies + + has_sudo() { + command -v sudo > /dev/null 2>&1 || _exiterr "DEHYDRATED_USER set but sudo not available. Please install sudo." + } + + # Check if we are running & are allowed to run as root + if [[ -n "$DEHYDRATED_USER" ]]; then + command -v getent > /dev/null 2>&1 || _exiterr "DEHYDRATED_USER set but getent not available. Please install getent." + + TARGET_UID="$(getent passwd "${DEHYDRATED_USER}" | cut -d':' -f3)" || _exiterr "DEHYDRATED_USER ${DEHYDRATED_USER} is invalid" + if [[ -z "${DEHYDRATED_GROUP}" ]]; then + if [[ "${EUID}" != "${TARGET_UID}" ]]; then + echo "# INFO: Running $0 as ${DEHYDRATED_USER}" + has_sudo && exec sudo -u "${DEHYDRATED_USER}" "${0}" "${ORIGARGS[@]}" + fi + else + TARGET_GID="$(getent group "${DEHYDRATED_GROUP}" | cut -d':' -f3)" || _exiterr "DEHYDRATED_GROUP ${DEHYDRATED_GROUP} is invalid" + if [[ -z "${EGID:-}" ]]; then + command -v id > /dev/null 2>&1 || _exiterr "DEHYDRATED_GROUP set, don't know current gid and 'id' not available... Please provide 'id' binary." + EGID="$(id -g)" + fi + if [[ "${EUID}" != "${TARGET_UID}" ]] || [[ "${EGID}" != "${TARGET_GID}" ]]; then + echo "# INFO: Running $0 as ${DEHYDRATED_USER}/${DEHYDRATED_GROUP}" + has_sudo && exec sudo -u "${DEHYDRATED_USER}" -g "${DEHYDRATED_GROUP}" "${0}" "${ORIGARGS[@]}" + fi + fi + elif [[ -n "${DEHYDRATED_GROUP}" ]]; then + _exiterr "DEHYDRATED_GROUP can only be used in combination with DEHYDRATED_USER." + fi + + # Remove slash from end of BASEDIR. Mostly for cleaner outputs, doesn't change functionality. + [[ "$BASEDIR" != "/" ]] && BASEDIR="${BASEDIR%%/}" + + # Check BASEDIR and set default variables + [[ -d "${BASEDIR}" ]] || _exiterr "BASEDIR does not exist: ${BASEDIR}" + + # Check for ca cli parameter + if [ -n "${PARAM_CA:-}" ]; then + CA="${PARAM_CA}" + fi + + # Preset CAs + if [ "${CA}" = "letsencrypt" ]; then + CA="${CA_LETSENCRYPT}" + elif [ "${CA}" = "letsencrypt-test" ]; then + CA="${CA_LETSENCRYPT_TEST}" + elif [ "${CA}" = "zerossl" ]; then + CA="${CA_ZEROSSL}" + elif [ "${CA}" = "buypass" ]; then + CA="${CA_BUYPASS}" + elif [ "${CA}" = "buypass-test" ]; then + CA="${CA_BUYPASS_TEST}" + fi + + if [[ -z "${OLDCA}" ]] && [[ "${CA}" = "https://acme-v02.api.letsencrypt.org/directory" ]]; then + OLDCA="https://acme-v01.api.letsencrypt.org/directory" + fi + + # Create new account directory or symlink to account directory from old CA + # dev note: keep in mind that because of the use of 'echo' instead of 'printf' or + # similar there is a newline encoded in the directory name. not going to fix this + # since it's a non-issue and trying to fix existing installations would be too much + # trouble + CAHASH="$(echo "${CA}" | urlbase64)" + [[ -z "${ACCOUNTDIR}" ]] && ACCOUNTDIR="${BASEDIR}/accounts" + if [[ ! -e "${ACCOUNTDIR}/${CAHASH}" ]]; then + OLDCAHASH="$(echo "${OLDCA}" | urlbase64)" + mkdir -p "${ACCOUNTDIR}" + if [[ -n "${OLDCA}" ]] && [[ -e "${ACCOUNTDIR}/${OLDCAHASH}" ]]; then + echo "! Reusing account from ${OLDCA}" + ln -s "${OLDCAHASH}" "${ACCOUNTDIR}/${CAHASH}" + else + mkdir "${ACCOUNTDIR}/${CAHASH}" + fi + fi + + # shellcheck disable=SC1090 + [[ -f "${ACCOUNTDIR}/${CAHASH}/config" ]] && . "${ACCOUNTDIR}/${CAHASH}/config" + ACCOUNT_KEY="${ACCOUNTDIR}/${CAHASH}/account_key.pem" + ACCOUNT_KEY_JSON="${ACCOUNTDIR}/${CAHASH}/registration_info.json" + ACCOUNT_ID_JSON="${ACCOUNTDIR}/${CAHASH}/account_id.json" + ACCOUNT_DEACTIVATED="${ACCOUNTDIR}/${CAHASH}/deactivated" + + if [[ -f "${ACCOUNT_DEACTIVATED}" ]]; then + _exiterr "Account has been deactivated. Remove account and create a new one using --register." + fi + + if [[ -f "${BASEDIR}/private_key.pem" ]] && [[ ! -f "${ACCOUNT_KEY}" ]]; then + echo "! Moving private_key.pem to ${ACCOUNT_KEY}" + mv "${BASEDIR}/private_key.pem" "${ACCOUNT_KEY}" + fi + if [[ -f "${BASEDIR}/private_key.json" ]] && [[ ! -f "${ACCOUNT_KEY_JSON}" ]]; then + echo "! Moving private_key.json to ${ACCOUNT_KEY_JSON}" + mv "${BASEDIR}/private_key.json" "${ACCOUNT_KEY_JSON}" + fi + + [[ -z "${CERTDIR}" ]] && CERTDIR="${BASEDIR}/certs" + [[ -z "${ALPNCERTDIR}" ]] && ALPNCERTDIR="${BASEDIR}/alpn-certs" + [[ -z "${CHAINCACHE}" ]] && CHAINCACHE="${BASEDIR}/chains" + [[ -z "${DOMAINS_TXT}" ]] && DOMAINS_TXT="${BASEDIR}/domains.txt" + [[ -z "${WELLKNOWN}" ]] && WELLKNOWN="/var/www/dehydrated" + [[ -z "${LOCKFILE}" ]] && LOCKFILE="${BASEDIR}/lock" + [[ -z "${OPENSSL_CNF}" ]] && OPENSSL_CNF="$("${OPENSSL}" version -d | cut -d\" -f2)/openssl.cnf" + [[ -n "${PARAM_LOCKFILE_SUFFIX:-}" ]] && LOCKFILE="${LOCKFILE}-${PARAM_LOCKFILE_SUFFIX}" + [[ -n "${PARAM_NO_LOCK:-}" ]] && LOCKFILE="" + + [[ -n "${PARAM_HOOK:-}" ]] && HOOK="${PARAM_HOOK}" + [[ -n "${PARAM_DOMAINS_TXT:-}" ]] && DOMAINS_TXT="${PARAM_DOMAINS_TXT}" + [[ -n "${PARAM_PREFERRED_CHAIN:-}" ]] && PREFERRED_CHAIN="${PARAM_PREFERRED_CHAIN}" + [[ -n "${PARAM_CERTDIR:-}" ]] && CERTDIR="${PARAM_CERTDIR}" + [[ -n "${PARAM_ALPNCERTDIR:-}" ]] && ALPNCERTDIR="${PARAM_ALPNCERTDIR}" + [[ -n "${PARAM_CHALLENGETYPE:-}" ]] && CHALLENGETYPE="${PARAM_CHALLENGETYPE}" + [[ -n "${PARAM_KEY_ALGO:-}" ]] && KEY_ALGO="${PARAM_KEY_ALGO}" + [[ -n "${PARAM_OCSP_MUST_STAPLE:-}" ]] && OCSP_MUST_STAPLE="${PARAM_OCSP_MUST_STAPLE}" + [[ -n "${PARAM_IP_VERSION:-}" ]] && IP_VERSION="${PARAM_IP_VERSION}" + + if [ "${PARAM_FORCE_VALIDATION:-no}" = "yes" ] && [ "${PARAM_FORCE:-no}" = "no" ]; then + _exiterr "Argument --force-validation can only be used in combination with --force (-x)" + fi + + if [ ! "${1:-}" = "noverify" ]; then + verify_config + fi + store_configvars +} + +# Initialize system +init_system() { + load_config + + # Lockfile handling (prevents concurrent access) + if [[ -n "${LOCKFILE}" ]]; then + LOCKDIR="$(dirname "${LOCKFILE}")" + [[ -w "${LOCKDIR}" ]] || _exiterr "Directory ${LOCKDIR} for LOCKFILE ${LOCKFILE} is not writable, aborting." + ( set -C; date > "${LOCKFILE}" ) 2>/dev/null || _exiterr "Lock file '${LOCKFILE}' present, aborting." + remove_lock() { rm -f "${LOCKFILE}"; } + trap 'remove_lock' EXIT + fi + + # Get CA URLs + CA_DIRECTORY="$(http_request get "${CA}" | jsonsh)" + + # Automatic discovery of API version + if [[ "${API}" = "auto" ]]; then + grep -q newOrder <<< "${CA_DIRECTORY}" && API=2 || API=1 + fi + + # shellcheck disable=SC2015 + if [[ "${API}" = "1" ]]; then + CA_NEW_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-cert)" && + CA_NEW_AUTHZ="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-authz)" && + CA_NEW_REG="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value new-reg)" && + CA_TERMS="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value terms-of-service)" && + CA_REQUIRES_EAB="false" && + CA_REVOKE_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value revoke-cert)" || + _exiterr "Problem retrieving ACME/CA-URLs, check if your configured CA points to the directory entrypoint." + # Since reg URI is missing from directory we will assume it is the same as CA_NEW_REG without the new part + CA_REG=${CA_NEW_REG/new-reg/reg} + else + CA_NEW_ORDER="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value newOrder)" && + CA_NEW_NONCE="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value newNonce)" && + CA_NEW_ACCOUNT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value newAccount)" && + CA_TERMS="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value -p '"meta","termsOfService"')" && + CA_REQUIRES_EAB="$(printf "%s" "${CA_DIRECTORY}" | get_json_bool_value -p '"meta","externalAccountRequired"' || echo false)" && + CA_REVOKE_CERT="$(printf "%s" "${CA_DIRECTORY}" | get_json_string_value revokeCert)" || + _exiterr "Problem retrieving ACME/CA-URLs, check if your configured CA points to the directory entrypoint." + fi + + # Export some environment variables to be used in hook script + export WELLKNOWN BASEDIR CERTDIR ALPNCERTDIR CONFIG COMMAND + + # Checking for private key ... + register_new_key="no" + generated="false" + if [[ -n "${PARAM_ACCOUNT_KEY:-}" ]]; then + # a private key was specified from the command line so use it for this run + echo "Using private key ${PARAM_ACCOUNT_KEY} instead of account key" + ACCOUNT_KEY="${PARAM_ACCOUNT_KEY}" + ACCOUNT_KEY_JSON="${PARAM_ACCOUNT_KEY}.json" + ACCOUNT_ID_JSON="${PARAM_ACCOUNT_KEY}_id.json" + [ "${COMMAND:-}" = "register" ] && register_new_key="yes" + else + # Check if private account key exists, if it doesn't exist yet generate a new one (rsa key) + if [[ ! -e "${ACCOUNT_KEY}" ]]; then + if [[ ! "${PARAM_ACCEPT_TERMS:-}" = "yes" ]]; then + printf '\n' >&2 + printf 'To use dehydrated with this certificate authority you have to agree to their terms of service which you can find here: %s\n\n' "${CA_TERMS}" >&2 + printf 'To accept these terms of service run "%s --register --accept-terms".\n' "${0}" >&2 + exit 1 + fi + + echo "+ Generating account key..." + generated="true" + local tmp_account_key + tmp_account_key="$(_mktemp)" + if [[ ${API} -eq 1 && ! "${ACCOUNT_KEY_ALGO}" = "rsa" ]]; then + _exiterr "ACME API version 1 does not support EC account keys" + fi + case "${ACCOUNT_KEY_ALGO}" in + rsa) _openssl genrsa -out "${tmp_account_key}" "${ACCOUNT_KEYSIZE}";; + prime256v1|secp384r1|secp521r1) _openssl ecparam -genkey -name "${ACCOUNT_KEY_ALGO}" -out "${tmp_account_key}" -noout;; + esac + cat "${tmp_account_key}" > "${ACCOUNT_KEY}" + rm "${tmp_account_key}" + register_new_key="yes" + fi + fi + + if ("${OPENSSL}" rsa -in "${ACCOUNT_KEY}" -check 2>/dev/null > /dev/null); then + # Get public components from private key and calculate thumbprint + pubExponent64="$(printf '%x' "$("${OPENSSL}" rsa -in "${ACCOUNT_KEY}" -noout -text | awk '/publicExponent/ {print $2}')" | hex2bin | urlbase64)" + pubMod64="$("${OPENSSL}" rsa -in "${ACCOUNT_KEY}" -noout -modulus | cut -d'=' -f2 | hex2bin | urlbase64)" + + account_key_info="$(printf '{"e":"%s","kty":"RSA","n":"%s"}' "${pubExponent64}" "${pubMod64}")" + account_key_sigalgo=RS256 + elif ("${OPENSSL}" ec -in "${ACCOUNT_KEY}" -check 2>/dev/null > /dev/null); then + curve="$("${OPENSSL}" ec -in "${ACCOUNT_KEY}" -noout -text 2>/dev/null | grep 'NIST CURVE' | cut -d':' -f2 | tr -d ' ')" + pubkey="$("${OPENSSL}" ec -in "${ACCOUNT_KEY}" -noout -text 2>/dev/null | tr -d '\n ' | grep -Eo 'pub:.*ASN1' | _sed -e 's/^pub://' -e 's/ASN1$//' | tr -d ':')" + + if [ "${curve}" = "P-256" ]; then + account_key_sigalgo="ES256" + elif [ "${curve}" = "P-384" ]; then + account_key_sigalgo="ES384" + elif [ "${curve}" = "P-521" ]; then + account_key_sigalgo="ES512" + else + _exiterr "Unknown account key curve: ${curve}" + fi + + ec_x_offset=2 + ec_x_len=$((${#pubkey}/2 - 1)) + ec_x="${pubkey:$ec_x_offset:$ec_x_len}" + ec_x64="$(printf "%s" "${ec_x}" | hex2bin | urlbase64)" + + ec_y_offset=$((ec_x_offset+ec_x_len)) + ec_y_len=$((${#pubkey}-ec_y_offset)) + ec_y="${pubkey:$ec_y_offset:$ec_y_len}" + ec_y64="$(printf "%s" "${ec_y}" | hex2bin | urlbase64)" + + account_key_info="$(printf '{"crv":"%s","kty":"EC","x":"%s","y":"%s"}' "${curve}" "${ec_x64}" "${ec_y64}")" + else + _exiterr "Account key is not valid, cannot continue." + fi + thumbprint="$(printf '%s' "${account_key_info}" | "${OPENSSL}" dgst -sha256 -binary | urlbase64)" + + # If we generated a new private key in the step above we have to register it with the acme-server + if [[ "${register_new_key}" = "yes" ]]; then + echo "+ Registering account key with ACME server..." + FAILED=false + + if [[ ${API} -eq 1 && -z "${CA_NEW_REG}" ]] || [[ ${API} -eq 2 && -z "${CA_NEW_ACCOUNT}" ]]; then + echo "Certificate authority doesn't allow registrations." + FAILED=true + fi + + # ZeroSSL special sauce + if [[ "${CA}" = "${CA_ZEROSSL}" ]]; then + if [[ -z "${EAB_KID:-}" ]] || [[ -z "${EAB_HMAC_KEY:-}" ]]; then + if [[ -z "${CONTACT_EMAIL}" ]]; then + echo "ZeroSSL requires contact email to be set or EAB_KID/EAB_HMAC_KEY to be manually configured" + FAILED=true + else + zeroapi="$(curl -s "https://api.zerossl.com/acme/eab-credentials-email" -d "email=${CONTACT_EMAIL}" | jsonsh)" + EAB_KID="$(printf "%s" "${zeroapi}" | get_json_string_value eab_kid)" + EAB_HMAC_KEY="$(printf "%s" "${zeroapi}" | get_json_string_value eab_hmac_key)" + if [[ -z "${EAB_KID:-}" ]] || [[ -z "${EAB_HMAC_KEY:-}" ]]; then + echo "Unknown error retrieving ZeroSSL API credentials" + echo "${zeroapi}" + FAILED=true + fi + fi + fi + fi + + # Check if external account is required + if [[ "${FAILED}" = "false" ]]; then + if [[ "${CA_REQUIRES_EAB}" = "true" ]]; then + if [[ -z "${EAB_KID:-}" ]] || [[ -z "${EAB_HMAC_KEY:-}" ]]; then + FAILED=true + echo "This CA requires an external account but no EAB_KID/EAB_HMAC_KEY has been configured" + fi + fi + fi + + # If an email for the contact has been provided then adding it to the registration request + if [[ "${FAILED}" = "false" ]]; then + if [[ ${API} -eq 1 ]]; then + if [[ -n "${CONTACT_EMAIL}" ]]; then + (signed_request "${CA_NEW_REG}" '{"resource": "new-reg", "contact":["mailto:'"${CONTACT_EMAIL}"'"], "agreement": "'"${CA_TERMS}"'"}' > "${ACCOUNT_KEY_JSON}") || FAILED=true + else + (signed_request "${CA_NEW_REG}" '{"resource": "new-reg", "agreement": "'"${CA_TERMS}"'"}' > "${ACCOUNT_KEY_JSON}") || FAILED=true + fi + else + if [[ -n "${EAB_KID:-}" ]] && [[ -n "${EAB_HMAC_KEY:-}" ]]; then + eab_url="${CA_NEW_ACCOUNT}" + eab_protected64="$(printf '{"alg":"HS256","kid":"%s","url":"%s"}' "${EAB_KID}" "${eab_url}" | urlbase64)" + eab_payload64="$(printf "%s" "${account_key_info}" | urlbase64)" + eab_key="$(printf "%s" "${EAB_HMAC_KEY}" | deurlbase64 | bin2hex)" + eab_signed64="$(printf '%s' "${eab_protected64}.${eab_payload64}" | "${OPENSSL}" dgst -binary -sha256 -mac HMAC -macopt "hexkey:${eab_key}" | urlbase64)" + + if [[ -n "${CONTACT_EMAIL}" ]]; then + regjson='{"contact":["mailto:'"${CONTACT_EMAIL}"'"], "termsOfServiceAgreed": true, "externalAccountBinding": {"protected": "'"${eab_protected64}"'", "payload": "'"${eab_payload64}"'", "signature": "'"${eab_signed64}"'"}}' + else + regjson='{"termsOfServiceAgreed": true, "externalAccountBinding": {"protected": "'"${eab_protected64}"'", "payload": "'"${eab_payload64}"'", "signature": "'"${eab_signed64}"'"}}' + fi + else + if [[ -n "${CONTACT_EMAIL}" ]]; then + regjson='{"contact":["mailto:'"${CONTACT_EMAIL}"'"], "termsOfServiceAgreed": true}' + else + regjson='{"termsOfServiceAgreed": true}' + fi + fi + (signed_request "${CA_NEW_ACCOUNT}" "${regjson}" > "${ACCOUNT_KEY_JSON}") || FAILED=true + fi + fi + + if [[ "${FAILED}" = "true" ]]; then + echo >&2 + echo >&2 + echo "Error registering account key. See message above for more information." >&2 + if [[ "${generated}" = "true" ]]; then + rm "${ACCOUNT_KEY}" + fi + rm -f "${ACCOUNT_KEY_JSON}" + exit 1 + fi + elif [[ "${COMMAND:-}" = "register" ]]; then + echo "+ Account already registered!" + exit 0 + fi + + # Read account information or request from CA if missing + if [[ -e "${ACCOUNT_KEY_JSON}" ]]; then + if [[ ${API} -eq 1 ]]; then + ACCOUNT_ID="$(jsonsh < "${ACCOUNT_KEY_JSON}" | get_json_int_value id)" + ACCOUNT_URL="${CA_REG}/${ACCOUNT_ID}" + else + if [[ -e "${ACCOUNT_ID_JSON}" ]]; then + ACCOUNT_URL="$(jsonsh < "${ACCOUNT_ID_JSON}" | get_json_string_value url)" + fi + # if account URL is not storred, fetch it from the CA + if [[ -z "${ACCOUNT_URL:-}" ]]; then + echo "+ Fetching account URL..." + ACCOUNT_URL="$(signed_request "${CA_NEW_ACCOUNT}" '{"onlyReturnExisting": true}' 4>&1 | grep -i ^Location: | cut -d':' -f2- | tr -d ' \t\r\n')" + if [[ -z "${ACCOUNT_URL}" ]]; then + _exiterr "Unknown error on fetching account information" + fi + echo '{"url":"'"${ACCOUNT_URL}"'"}' > "${ACCOUNT_ID_JSON}" # store the URL for next time + fi + fi + else + echo "Fetching missing account information from CA..." + if [[ ${API} -eq 1 ]]; then + _exiterr "This is not implemented for ACMEv1! Consider switching to ACMEv2 :)" + else + ACCOUNT_URL="$(signed_request "${CA_NEW_ACCOUNT}" '{"onlyReturnExisting": true}' 4>&1 | grep -i ^Location: | cut -d':' -f2- | tr -d ' \t\r\n')" + ACCOUNT_INFO="$(signed_request "${ACCOUNT_URL}" '{}')" + fi + echo "${ACCOUNT_INFO}" > "${ACCOUNT_KEY_JSON}" + fi +} + +# Different sed version for different os types... +_sed() { + if [[ "${OSTYPE}" = "Linux" || "${OSTYPE:0:5}" = "MINGW" ]]; then + sed -r "${@}" + else + sed -E "${@}" + fi +} + +# Print error message and exit with error +_exiterr() { + if [ -n "${1:-}" ]; then + echo "ERROR: ${1}" >&2 + fi + [[ "${skip_exit_hook:-no}" = "no" ]] && [[ -n "${HOOK:-}" ]] && ("${HOOK}" "exit_hook" "${1:-}" || echo 'exit_hook returned with non-zero exit code!' >&2) + exit 1 +} + +# Remove newlines and whitespace from json +clean_json() { + tr -d '\r\n' | _sed -e 's/ +/ /g' -e 's/\{ /{/g' -e 's/ \}/}/g' -e 's/\[ /[/g' -e 's/ \]/]/g' +} + +# Encode data as url-safe formatted base64 +urlbase64() { + # urlbase64: base64 encoded string with '+' replaced with '-' and '/' replaced with '_' + "${OPENSSL}" base64 -e | tr -d '\n\r' | _sed -e 's:=*$::g' -e 'y:+/:-_:' +} + +# Decode data from url-safe formatted base64 +deurlbase64() { + data="$(cat | tr -d ' \n\r')" + modlen=$((${#data} % 4)) + padding="" + if [[ "${modlen}" = "2" ]]; then padding="=="; + elif [[ "${modlen}" = "3" ]]; then padding="="; fi + printf "%s%s" "${data}" "${padding}" | tr -d '\n\r' | _sed -e 'y:-_:+/:' | "${OPENSSL}" base64 -d -A +} + +# Convert hex string to binary data +hex2bin() { + # Remove spaces, add leading zero, escape as hex string and parse with printf + # shellcheck disable=SC2059 + printf "%b" "$(cat | _sed -e 's/[[:space:]]//g' -e 's/^(.(.{2})*)$/0\1/' -e 's/(.{2})/\\x\1/g')" +} + +# Convert binary data to hex string +bin2hex() { + hexdump -v -e '/1 "%02x"' +} + +# OpenSSL writes to stderr/stdout even when there are no errors. So just +# display the output if the exit code was != 0 to simplify debugging. +_openssl() { + set +e + out="$("${OPENSSL}" "${@}" 2>&1)" + res=$? + set -e + if [[ ${res} -ne 0 ]]; then + echo " + ERROR: failed to run $* (Exitcode: ${res})" >&2 + echo >&2 + echo "Details:" >&2 + echo "${out}" >&2 + echo >&2 + exit "${res}" + fi +} + +# Send http(s) request with specified method +http_request() { + tempcont="$(_mktemp)" + tempheaders="$(_mktemp)" + + if [[ -n "${IP_VERSION:-}" ]]; then + ip_version="-${IP_VERSION}" + fi + + set +e + # shellcheck disable=SC2086 + if [[ "${1}" = "head" ]]; then + statuscode="$(curl ${ip_version:-} ${CURL_OPTS} -A "dehydrated/${VERSION} curl/${CURL_VERSION}" -s -w "%{http_code}" -o "${tempcont}" "${2}" -I)" + curlret="${?}" + touch "${tempheaders}" + elif [[ "${1}" = "get" ]]; then + statuscode="$(curl ${ip_version:-} ${CURL_OPTS} -A "dehydrated/${VERSION} curl/${CURL_VERSION}" -L -s -w "%{http_code}" -o "${tempcont}" -D "${tempheaders}" "${2}")" + curlret="${?}" + elif [[ "${1}" = "post" ]]; then + statuscode="$(curl ${ip_version:-} ${CURL_OPTS} -A "dehydrated/${VERSION} curl/${CURL_VERSION}" -s -w "%{http_code}" -o "${tempcont}" "${2}" -D "${tempheaders}" -H 'Content-Type: application/jose+json' -d "${3}")" + curlret="${?}" + else + set -e + _exiterr "Unknown request method: ${1}" + fi + set -e + + if [[ ! "${curlret}" = "0" ]]; then + _exiterr "Problem connecting to server (${1} for ${2}; curl returned with ${curlret})" + fi + + if [[ ! "${statuscode:0:1}" = "2" ]]; then + # check for existing registration warning + if [[ "${API}" = "1" ]] && [[ -n "${CA_NEW_REG:-}" ]] && [[ "${2}" = "${CA_NEW_REG:-}" ]] && [[ "${statuscode}" = "409" ]] && grep -q "Registration key is already in use" "${tempcont}"; then + # do nothing + : + # check for already-revoked warning + elif [[ -n "${CA_REVOKE_CERT:-}" ]] && [[ "${2}" = "${CA_REVOKE_CERT:-}" ]] && [[ "${statuscode}" = "409" ]]; then + grep -q "Certificate already revoked" "${tempcont}" && return + else + if grep -q "urn:ietf:params:acme:error:badNonce" "${tempcont}"; then + printf "badnonce %s" "$(grep -Eoi "^replay-nonce:.*$" "${tempheaders}" | sed 's/ //' | cut -d: -f2)" + return 0 + fi + echo " + ERROR: An error occurred while sending ${1}-request to ${2} (Status ${statuscode})" >&2 + echo >&2 + echo "Details:" >&2 + cat "${tempheaders}" >&2 + cat "${tempcont}" >&2 + echo >&2 + echo >&2 + + # An exclusive hook for the {1}-request error might be useful (e.g., for sending an e-mail to admins) + if [[ -n "${HOOK}" ]]; then + errtxt="$(cat "${tempcont}")" + errheaders="$(cat "${tempheaders}")" + "${HOOK}" "request_failure" "${statuscode}" "${errtxt}" "${1}" "${errheaders}" || _exiterr 'request_failure hook returned with non-zero exit code' + fi + + rm -f "${tempcont}" + rm -f "${tempheaders}" + + # remove temporary domains.txt file if used + [[ "${COMMAND:-}" = "sign_domains" && -n "${PARAM_DOMAIN:-}" && -n "${DOMAINS_TXT:-}" ]] && rm "${DOMAINS_TXT}" + _exiterr + fi + fi + + if { true >&4; } 2>/dev/null; then + cat "${tempheaders}" >&4 + fi + cat "${tempcont}" + rm -f "${tempcont}" + rm -f "${tempheaders}" +} + +# Send signed request +signed_request() { + # Encode payload as urlbase64 + payload64="$(printf '%s' "${2}" | urlbase64)" + + if [ -n "${3:-}" ]; then + nonce="$(printf "%s" "${3}" | tr -d ' \t\n\r')" + else + # Retrieve nonce from acme-server + if [[ ${API} -eq 1 ]]; then + nonce="$(http_request head "${CA}" | grep -i ^Replay-Nonce: | cut -d':' -f2- | tr -d ' \t\n\r')" + else + nonce="$(http_request head "${CA_NEW_NONCE}" | grep -i ^Replay-Nonce: | cut -d':' -f2- | tr -d ' \t\n\r')" + fi + fi + + if [[ ${API} -eq 1 ]]; then + # Build another header which also contains the previously received nonce and encode it as urlbase64 + protected='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}, "nonce": "'"${nonce}"'"}' + protected64="$(printf '%s' "${protected}" | urlbase64)" + else + # Build another header which also contains the previously received nonce and url and encode it as urlbase64 + if [[ -n "${ACCOUNT_URL:-}" ]]; then + protected='{"alg": "'"${account_key_sigalgo}"'", "kid": "'"${ACCOUNT_URL}"'", "url": "'"${1}"'", "nonce": "'"${nonce}"'"}' + else + protected='{"alg": "'"${account_key_sigalgo}"'", "jwk": '"${account_key_info}"', "url": "'"${1}"'", "nonce": "'"${nonce}"'"}' + fi + protected64="$(printf '%s' "${protected}" | urlbase64)" + fi + + # Sign header with nonce and our payload with our private key and encode signature as urlbase64 + if [[ "${account_key_sigalgo}" = "RS256" ]]; then + signed64="$(printf '%s' "${protected64}.${payload64}" | "${OPENSSL}" dgst -sha256 -sign "${ACCOUNT_KEY}" | urlbase64)" + else + dgstparams="$(printf '%s' "${protected64}.${payload64}" | "${OPENSSL}" dgst -sha${account_key_sigalgo:2} -sign "${ACCOUNT_KEY}" | "${OPENSSL}" asn1parse -inform DER)" + dgst_parm_1="$(echo "$dgstparams" | head -n 2 | tail -n 1 | cut -d':' -f4)" + dgst_parm_2="$(echo "$dgstparams" | head -n 3 | tail -n 1 | cut -d':' -f4)" + + # zero-padding (doesn't seem to be necessary, but other clients are doing this as well... + case "${account_key_sigalgo}" in + "ES256") siglen=64;; + "ES384") siglen=96;; + "ES512") siglen=132;; + esac + while [[ ${#dgst_parm_1} -lt $siglen ]]; do dgst_parm_1="0${dgst_parm_1}"; done + while [[ ${#dgst_parm_2} -lt $siglen ]]; do dgst_parm_2="0${dgst_parm_2}"; done + + signed64="$(printf "%s%s" "${dgst_parm_1}" "${dgst_parm_2}" | hex2bin | urlbase64)" + fi + + if [[ ${API} -eq 1 ]]; then + # Build header with just our public key and algorithm information + header='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}}' + + # Send header + extended header + payload + signature to the acme-server + data='{"header": '"${header}"', "protected": "'"${protected64}"'", "payload": "'"${payload64}"'", "signature": "'"${signed64}"'"}' + else + # Send extended header + payload + signature to the acme-server + data='{"protected": "'"${protected64}"'", "payload": "'"${payload64}"'", "signature": "'"${signed64}"'"}' + fi + + output="$(http_request post "${1}" "${data}")" + + if grep -qE "^badnonce " <<< "${output}"; then + echo " ! Request failed (badNonce), retrying request..." >&2 + signed_request "${1:-}" "${2:-}" "$(printf "%s" "${output}" | cut -d' ' -f2)" + else + printf "%s" "${output}" + fi +} + +# Extracts all subject names from a CSR +# Outputs either the CN, or the SANs, one per line +extract_altnames() { + csr="${1}" # the CSR itself (not a file) + + if ! <<<"${csr}" "${OPENSSL}" req -verify -noout 2>/dev/null; then + _exiterr "Certificate signing request isn't valid" + fi + + reqtext="$( <<<"${csr}" "${OPENSSL}" req -noout -text )" + if <<<"${reqtext}" grep -q '^[[:space:]]*X509v3 Subject Alternative Name:[[:space:]]*$'; then + # SANs used, extract these + altnames="$( <<<"${reqtext}" awk '/X509v3 Subject Alternative Name:/{print;getline;print;}' | tail -n1 )" + # split to one per line: + # shellcheck disable=SC1003 + altnames="$( <<<"${altnames}" _sed -e 's/^[[:space:]]*//; s/, /\'$'\n''/g' )" + # we can only get DNS/IP: ones signed + if grep -qEv '^(DNS|IP( Address)*|othername):' <<<"${altnames}"; then + _exiterr "Certificate signing request contains non-DNS/IP Subject Alternative Names" + fi + # strip away the DNS/IP: prefix + altnames="$( <<<"${altnames}" _sed -e 's/^(DNS:|IP( Address)*:|othername:<unsupported>)//' )" + printf "%s" "${altnames}" | tr '\n' ' ' + else + # No SANs, extract CN + altnames="$( <<<"${reqtext}" grep '^[[:space:]]*Subject:' | _sed -e 's/.*[ /]CN ?= ?([^ /,]*).*/\1/' )" + printf "%s" "${altnames}" + fi +} + +# Get last issuer CN in certificate chain +get_last_cn() { + <<<"${1}" _sed 'H;/-----BEGIN CERTIFICATE-----/h;$!d;x' | "${OPENSSL}" x509 -noout -issuer | head -n1 | _sed -e 's/.*[ /]CN ?= ?([^/,]*).*/\1/' +} + +# Create certificate for domain(s) and outputs it FD 3 +sign_csr() { + csr="${1}" # the CSR itself (not a file) + + if { true >&3; } 2>/dev/null; then + : # fd 3 looks OK + else + _exiterr "sign_csr: FD 3 not open" + fi + + shift 1 || true + export altnames="${*}" + + if [[ ${API} -eq 1 ]]; then + if [[ -z "${CA_NEW_AUTHZ}" ]] || [[ -z "${CA_NEW_CERT}" ]]; then + _exiterr "Certificate authority doesn't allow certificate signing" + fi + elif [[ ${API} -eq 2 ]] && [[ -z "${CA_NEW_ORDER}" ]]; then + _exiterr "Certificate authority doesn't allow certificate signing" + fi + + if [[ -n "${ZSH_VERSION:-}" ]]; then + local -A challenge_names challenge_uris challenge_tokens authorizations keyauths deploy_args + else + local -a challenge_names challenge_uris challenge_tokens authorizations keyauths deploy_args + fi + + # Initial step: Find which authorizations we're dealing with + if [[ ${API} -eq 2 ]]; then + # Request new order and store authorization URIs + local challenge_identifiers="" + for altname in ${altnames}; do + if [[ "${altname}" =~ ^ip: ]]; then + challenge_identifiers+="$(printf '{"type": "ip", "value": "%s"}, ' "${altname:3}")" + else + challenge_identifiers+="$(printf '{"type": "dns", "value": "%s"}, ' "${altname}")" + fi + done + challenge_identifiers="[${challenge_identifiers%, }]" + + echo " + Requesting new certificate order from CA..." + order_location="$(signed_request "${CA_NEW_ORDER}" '{"identifiers": '"${challenge_identifiers}"'}' 4>&1 | grep -i ^Location: | cut -d':' -f2- | tr -d ' \t\r\n')" + result="$(signed_request "${order_location}" "" | jsonsh)" + + order_authorizations="$(echo "${result}" | get_json_array_values authorizations)" + finalize="$(echo "${result}" | get_json_string_value finalize)" + + local idx=0 + for uri in ${order_authorizations}; do + authorizations[${idx}]="${uri}" + idx=$((idx+1)) + done + echo " + Received ${idx} authorizations URLs from the CA" + else + # Copy $altnames to $authorizations (just doing this to reduce duplicate code later on) + local idx=0 + for altname in ${altnames}; do + authorizations[${idx}]="${altname}" + idx=$((idx+1)) + done + fi + + # Check if authorizations are valid and gather challenge information for pending authorizations + local idx=0 + for authorization in ${authorizations[*]}; do + if [[ "${API}" -eq 2 ]]; then + # Receive authorization ($authorization is authz uri) + response="$(signed_request "$(echo "${authorization}" | _sed -e 's/\"(.*)".*/\1/')" "" | jsonsh)" + identifier="$(echo "${response}" | get_json_string_value -p '"identifier","value"')" + identifier_type="$(echo "${response}" | get_json_string_value -p '"identifier","type"')" + echo " + Handling authorization for ${identifier}" + else + # Request new authorization ($authorization is altname) + identifier="${authorization}" + echo " + Requesting authorization for ${identifier}..." + response="$(signed_request "${CA_NEW_AUTHZ}" '{"resource": "new-authz", "identifier": {"type": "dns", "value": "'"${identifier}"'"}}' | jsonsh)" + fi + + # Check if authorization has already been validated + if [ "$(echo "${response}" | get_json_string_value status)" = "valid" ]; then + if [ "${PARAM_FORCE_VALIDATION:-no}" = "yes" ]; then + echo " + A valid authorization has been found but will be ignored" + else + echo " + Found valid authorization for ${identifier}" + continue + fi + fi + + # Find challenge in authorization + challengeindex="$(echo "${response}" | grep -E '^\["challenges",[0-9]+,"type"\][[:space:]]+"'"${CHALLENGETYPE}"'"' | cut -d',' -f2 || true)" + + if [ -z "${challengeindex}" ]; then + allowed_validations="$(echo "${response}" | grep -E '^\["challenges",[0-9]+,"type"\]' | sed -e 's/\[[^\]*\][[:space:]]*//g' -e 's/^"//' -e 's/"$//' | tr '\n' ' ')" + _exiterr "Validating this certificate is not possible using ${CHALLENGETYPE}. Possible validation methods are: ${allowed_validations}" + fi + challenge="$(echo "${response}" | get_json_dict_value -p '"challenges",'"${challengeindex}")" + + # Gather challenge information + if [ "${identifier_type:-}" = "ip" ] && [ "${CHALLENGETYPE}" = "tls-alpn-01" ] ; then + challenge_names[${idx}]="$(echo "${identifier}" | ip_to_ptr)" + else + challenge_names[${idx}]="${identifier}" + fi + challenge_tokens[${idx}]="$(echo "${challenge}" | get_json_string_value token)" + + if [[ ${API} -eq 2 ]]; then + challenge_uris[${idx}]="$(echo "${challenge}" | get_json_string_value url)" + else + if [[ "$(echo "${challenge}" | get_json_string_value type)" = "urn:acme:error:unauthorized" ]]; then + _exiterr "Challenge unauthorized: $(echo "${challenge}" | get_json_string_value detail)" + fi + challenge_uris[${idx}]="$(echo "${challenge}" | get_json_dict_value validationRecord | get_json_string_value uri)" + fi + + # Prepare challenge tokens and deployment parameters + keyauth="${challenge_tokens[${idx}]}.${thumbprint}" + + case "${CHALLENGETYPE}" in + "http-01") + # Store challenge response in well-known location and make world-readable (so that a webserver can access it) + printf '%s' "${keyauth}" > "${WELLKNOWN}/${challenge_tokens[${idx}]}" + chmod a+r "${WELLKNOWN}/${challenge_tokens[${idx}]}" + keyauth_hook="${keyauth}" + ;; + "dns-01") + # Generate DNS entry content for dns-01 validation + keyauth_hook="$(printf '%s' "${keyauth}" | "${OPENSSL}" dgst -sha256 -binary | urlbase64)" + ;; + "tls-alpn-01") + keyauth_hook="$(printf '%s' "${keyauth}" | "${OPENSSL}" dgst -sha256 -c -hex | awk '{print $NF}')" + generate_alpn_certificate "${identifier}" "${identifier_type}" "${keyauth_hook}" + ;; + esac + + keyauths[${idx}]="${keyauth}" + if [ "${identifier_type:-}" = "ip" ] && [ "${CHALLENGETYPE}" = "tls-alpn-01" ]; then + deploy_args[${idx}]="$(echo "${identifier}" | ip_to_ptr) ${challenge_tokens[${idx}]} ${keyauth_hook}" + else + deploy_args[${idx}]="${identifier} ${challenge_tokens[${idx}]} ${keyauth_hook}" + fi + + idx=$((idx+1)) + done + local num_pending_challenges=${idx} + echo " + ${num_pending_challenges} pending challenge(s)" + + # Deploy challenge tokens + if [[ ${num_pending_challenges} -ne 0 ]]; then + echo " + Deploying challenge tokens..." + if [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" = "yes" ]]; then + # shellcheck disable=SC2068 + "${HOOK}" "deploy_challenge" ${deploy_args[@]} || _exiterr 'deploy_challenge hook returned with non-zero exit code' + elif [[ -n "${HOOK}" ]]; then + # Run hook script to deploy the challenge token + local idx=0 + while [ ${idx} -lt ${num_pending_challenges} ]; do + # shellcheck disable=SC2086 + "${HOOK}" "deploy_challenge" ${deploy_args[${idx}]} || _exiterr 'deploy_challenge hook returned with non-zero exit code' + idx=$((idx+1)) + done + fi + fi + + # Validate pending challenges + local idx=0 + while [ ${idx} -lt ${num_pending_challenges} ]; do + echo " + Responding to challenge for ${challenge_names[${idx}]} authorization..." + + # Ask the acme-server to verify our challenge and wait until it is no longer pending + if [[ ${API} -eq 1 ]]; then + result="$(signed_request "${challenge_uris[${idx}]}" '{"resource": "challenge", "keyAuthorization": "'"${keyauths[${idx}]}"'"}' | jsonsh)" + else + result="$(signed_request "${challenge_uris[${idx}]}" '{}' | jsonsh)" + fi + + reqstatus="$(echo "${result}" | get_json_string_value status)" + + while [[ "${reqstatus}" = "pending" ]] || [[ "${reqstatus}" = "processing" ]]; do + sleep 1 + if [[ "${API}" -eq 2 ]]; then + result="$(signed_request "${challenge_uris[${idx}]}" "" | jsonsh)" + else + result="$(http_request get "${challenge_uris[${idx}]}" | jsonsh)" + fi + reqstatus="$(echo "${result}" | get_json_string_value status)" + done + + [[ "${CHALLENGETYPE}" = "http-01" ]] && rm -f "${WELLKNOWN}/${challenge_tokens[${idx}]}" + [[ "${CHALLENGETYPE}" = "tls-alpn-01" ]] && rm -f "${ALPNCERTDIR}/${challenge_names[${idx}]}.crt.pem" "${ALPNCERTDIR}/${challenge_names[${idx}]}.key.pem" + + if [[ "${reqstatus}" = "valid" ]]; then + echo " + Challenge is valid!" + else + [[ -n "${HOOK}" ]] && ("${HOOK}" "invalid_challenge" "${altname}" "${result}" || _exiterr 'invalid_challenge hook returned with non-zero exit code') + break + fi + idx=$((idx+1)) + done + + if [[ ${num_pending_challenges} -ne 0 ]]; then + echo " + Cleaning challenge tokens..." + + # Clean challenge tokens using chained hook + # shellcheck disable=SC2068 + [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" = "yes" ]] && ("${HOOK}" "clean_challenge" ${deploy_args[@]} || _exiterr 'clean_challenge hook returned with non-zero exit code') + + # Clean remaining challenge tokens if validation has failed + local idx=0 + while [ ${idx} -lt ${num_pending_challenges} ]; do + # Delete challenge file + [[ "${CHALLENGETYPE}" = "http-01" ]] && rm -f "${WELLKNOWN}/${challenge_tokens[${idx}]}" + # Delete alpn verification certificates + [[ "${CHALLENGETYPE}" = "tls-alpn-01" ]] && rm -f "${ALPNCERTDIR}/${challenge_names[${idx}]}.crt.pem" "${ALPNCERTDIR}/${challenge_names[${idx}]}.key.pem" + # Clean challenge token using non-chained hook + # shellcheck disable=SC2086 + [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && ("${HOOK}" "clean_challenge" ${deploy_args[${idx}]} || _exiterr 'clean_challenge hook returned with non-zero exit code') + idx=$((idx+1)) + done + + if [[ "${reqstatus}" != "valid" ]]; then + echo " + Challenge validation has failed :(" + _exiterr "Challenge is invalid! (returned: ${reqstatus}) (result: ${result})" + fi + fi + + # Finally request certificate from the acme-server and store it in cert-${timestamp}.pem and link from cert.pem + echo " + Requesting certificate..." + csr64="$( <<<"${csr}" "${OPENSSL}" req -config "${OPENSSL_CNF}" -outform DER | urlbase64)" + if [[ ${API} -eq 1 ]]; then + crt64="$(signed_request "${CA_NEW_CERT}" '{"resource": "new-cert", "csr": "'"${csr64}"'"}' | "${OPENSSL}" base64 -e)" + crt="$( printf -- '-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n' "${crt64}" )" + else + result="$(signed_request "${finalize}" '{"csr": "'"${csr64}"'"}' | jsonsh)" + while :; do + orderstatus="$(echo "${result}" | get_json_string_value status)" + case "${orderstatus}" + in + "processing" | "pending") + echo " + Order is ${orderstatus}..." + sleep 2; + ;; + "valid") + break; + ;; + *) + _exiterr "Order in status ${orderstatus}" + ;; + esac + result="$(signed_request "${order_location}" "" | jsonsh)" + done + + resheaders="$(_mktemp)" + certificate="$(echo "${result}" | get_json_string_value certificate)" + crt="$(signed_request "${certificate}" "" 4>"${resheaders}")" + + if [ -n "${PREFERRED_CHAIN:-}" ]; then + foundaltchain=0 + altcn="$(get_last_cn "${crt}")" + altoptions="${altcn}" + if [ "${altcn}" = "${PREFERRED_CHAIN}" ]; then + foundaltchain=1 + fi + if [ "${foundaltchain}" = "0" ] && (grep -Ei '^link:' "${resheaders}" | grep -q -Ei 'rel="alternate"'); then + while read -r altcrturl; do + if [ "${foundaltchain}" = "0" ]; then + altcrt="$(signed_request "${altcrturl}" "")" + altcn="$(get_last_cn "${altcrt}")" + altoptions="${altoptions}, ${altcn}" + if [ "${altcn}" = "${PREFERRED_CHAIN}" ]; then + foundaltchain=1 + crt="${altcrt}" + fi + fi + done <<< "$(grep -Ei '^link:' "${resheaders}" | grep -Ei 'rel="alternate"' | cut -d'<' -f2 | cut -d'>' -f1)" + fi + if [ "${foundaltchain}" = "0" ]; then + _exiterr "Alternative chain with CN = ${PREFERRED_CHAIN} not found, available options: ${altoptions}" + fi + echo " + Using preferred chain with CN = ${altcn}" + fi + rm -f "${resheaders}" + fi + + # Try to load the certificate to detect corruption + echo " + Checking certificate..." + _openssl x509 -text <<<"${crt}" + + echo "${crt}" >&3 + + unset challenge_token + echo " + Done!" +} + +# grep issuer cert uri from certificate +get_issuer_cert_uri() { + certificate="${1}" + "${OPENSSL}" x509 -in "${certificate}" -noout -text | (grep 'CA Issuers - URI:' | cut -d':' -f2-) || true +} + +get_issuer_hash() { + certificate="${1}" + "${OPENSSL}" x509 -in "${certificate}" -noout -issuer_hash +} + +get_ocsp_url() { + certificate="${1}" + "${OPENSSL}" x509 -in "${certificate}" -noout -ocsp_uri +} + +# walk certificate chain, retrieving all intermediate certificates +walk_chain() { + local certificate + certificate="${1}" + + local issuer_cert_uri + issuer_cert_uri="${2:-}" + if [[ -z "${issuer_cert_uri}" ]]; then issuer_cert_uri="$(get_issuer_cert_uri "${certificate}")"; fi + if [[ -n "${issuer_cert_uri}" ]]; then + # create temporary files + local tmpcert + local tmpcert_raw + tmpcert_raw="$(_mktemp)" + tmpcert="$(_mktemp)" + + # download certificate + http_request get "${issuer_cert_uri}" > "${tmpcert_raw}" + + # PEM + if grep -q "BEGIN CERTIFICATE" "${tmpcert_raw}"; then mv "${tmpcert_raw}" "${tmpcert}" + # DER + elif "${OPENSSL}" x509 -in "${tmpcert_raw}" -inform DER -out "${tmpcert}" -outform PEM 2> /dev/null > /dev/null; then : + # PKCS7 + elif "${OPENSSL}" pkcs7 -in "${tmpcert_raw}" -inform DER -out "${tmpcert}" -outform PEM -print_certs 2> /dev/null > /dev/null; then : + # Unknown certificate type + else _exiterr "Unknown certificate type in chain" + fi + + local next_issuer_cert_uri + next_issuer_cert_uri="$(get_issuer_cert_uri "${tmpcert}")" + if [[ -n "${next_issuer_cert_uri}" ]]; then + printf "\n%s\n" "${issuer_cert_uri}" + cat "${tmpcert}" + walk_chain "${tmpcert}" "${next_issuer_cert_uri}" + fi + rm -f "${tmpcert}" "${tmpcert_raw}" + fi +} + +# Generate ALPN verification certificate +generate_alpn_certificate() { + local altname="${1}" + local identifier_type="${2}" + local acmevalidation="${3}" + + local alpncertdir="${ALPNCERTDIR}" + if [[ ! -e "${alpncertdir}" ]]; then + echo " + Creating new directory ${alpncertdir} ..." + mkdir -p "${alpncertdir}" || _exiterr "Unable to create directory ${alpncertdir}" + fi + + echo " + Generating ALPN certificate and key for ${1}..." + tmp_openssl_cnf="$(_mktemp)" + cat "${OPENSSL_CNF}" > "${tmp_openssl_cnf}" + if [[ "${identifier_type}" = "ip" ]]; then + printf "\n[SAN]\nsubjectAltName=IP:%s\n" "${altname}" >> "${tmp_openssl_cnf}" + else + printf "\n[SAN]\nsubjectAltName=DNS:%s\n" "${altname}" >> "${tmp_openssl_cnf}" + fi + printf "1.3.6.1.5.5.7.1.31=critical,DER:04:20:%s\n" "${acmevalidation}" >> "${tmp_openssl_cnf}" + SUBJ="/CN=${altname}/" + [[ "${OSTYPE:0:5}" = "MINGW" ]] && SUBJ="/${SUBJ}" + if [[ "${identifier_type}" = "ip" ]]; then + altname="$(echo "${altname}" | ip_to_ptr)" + fi + _openssl req -x509 -new -sha256 -nodes -newkey rsa:2048 -keyout "${alpncertdir}/${altname}.key.pem" -out "${alpncertdir}/${altname}.crt.pem" -subj "${SUBJ}" -extensions SAN -config "${tmp_openssl_cnf}" + chmod g+r "${alpncertdir}/${altname}.key.pem" "${alpncertdir}/${altname}.crt.pem" + rm -f "${tmp_openssl_cnf}" +} + +# Create certificate for domain(s) +sign_domain() { + local certdir="${1}" + shift + timestamp="${1}" + shift + domain="${1}" + altnames="${*}" + + export altnames + + echo " + Signing domains..." + if [[ ${API} -eq 1 ]]; then + if [[ -z "${CA_NEW_AUTHZ}" ]] || [[ -z "${CA_NEW_CERT}" ]]; then + _exiterr "Certificate authority doesn't allow certificate signing" + fi + elif [[ ${API} -eq 2 ]] && [[ -z "${CA_NEW_ORDER}" ]]; then + _exiterr "Certificate authority doesn't allow certificate signing" + fi + + local privkey="privkey.pem" + if [[ ! -e "${certdir}/cert-${timestamp}.csr" ]]; then + # generate a new private key if we need or want one + if [[ ! -r "${certdir}/privkey.pem" ]] || [[ "${PRIVATE_KEY_RENEW}" = "yes" ]]; then + echo " + Generating private key..." + privkey="privkey-${timestamp}.pem" + local tmp_privkey + tmp_privkey="$(_mktemp)" + case "${KEY_ALGO}" in + rsa) _openssl genrsa -out "${tmp_privkey}" "${KEYSIZE}";; + prime256v1|secp384r1) _openssl ecparam -genkey -name "${KEY_ALGO}" -out "${tmp_privkey}" -noout;; + esac + cat "${tmp_privkey}" > "${certdir}/privkey-${timestamp}.pem" + rm "${tmp_privkey}" + fi + # move rolloverkey into position (if any) + if [[ -r "${certdir}/privkey.pem" && -r "${certdir}/privkey.roll.pem" && "${PRIVATE_KEY_RENEW}" = "yes" && "${PRIVATE_KEY_ROLLOVER}" = "yes" ]]; then + echo " + Moving Rolloverkey into position.... " + mv "${certdir}/privkey.roll.pem" "${certdir}/privkey-tmp.pem" + mv "${certdir}/privkey-${timestamp}.pem" "${certdir}/privkey.roll.pem" + mv "${certdir}/privkey-tmp.pem" "${certdir}/privkey-${timestamp}.pem" + fi + # generate a new private rollover key if we need or want one + if [[ ! -r "${certdir}/privkey.roll.pem" && "${PRIVATE_KEY_ROLLOVER}" = "yes" && "${PRIVATE_KEY_RENEW}" = "yes" ]]; then + echo " + Generating private rollover key..." + case "${KEY_ALGO}" in + rsa) _openssl genrsa -out "${certdir}/privkey.roll.pem" "${KEYSIZE}";; + prime256v1|secp384r1) _openssl ecparam -genkey -name "${KEY_ALGO}" -out "${certdir}/privkey.roll.pem" -noout;; + esac + fi + # delete rolloverkeys if disabled + if [[ -r "${certdir}/privkey.roll.pem" && ! "${PRIVATE_KEY_ROLLOVER}" = "yes" ]]; then + echo " + Removing Rolloverkey (feature disabled)..." + rm -f "${certdir}/privkey.roll.pem" + fi + + # Generate signing request config and the actual signing request + echo " + Generating signing request..." + SAN="" + for altname in ${altnames}; do + if [[ "${altname}" =~ ^ip: ]]; then + SAN="${SAN}IP:${altname:3}, " + else + SAN="${SAN}DNS:${altname}, " + fi + done + if [[ "${domain}" =~ ^ip: ]]; then + SUBJ="/CN=${domain:3}/" + else + SUBJ="/CN=${domain}/" + fi + SAN="${SAN%%, }" + local tmp_openssl_cnf + tmp_openssl_cnf="$(_mktemp)" + cat "${OPENSSL_CNF}" > "${tmp_openssl_cnf}" + printf "\n[SAN]\nsubjectAltName=%s" "${SAN}" >> "${tmp_openssl_cnf}" + if [ "${OCSP_MUST_STAPLE}" = "yes" ]; then + printf "\n1.3.6.1.5.5.7.1.24=DER:30:03:02:01:05" >> "${tmp_openssl_cnf}" + fi + if [[ "${OSTYPE:0:5}" = "MINGW" ]]; then + # The subject starts with a /, so MSYS will assume it's a path and convert + # it unless we escape it with another one: + SUBJ="/${SUBJ}" + fi + "${OPENSSL}" req -new -sha256 -key "${certdir}/${privkey}" -out "${certdir}/cert-${timestamp}.csr" -subj "${SUBJ}" -reqexts SAN -config "${tmp_openssl_cnf}" + rm -f "${tmp_openssl_cnf}" + fi + + crt_path="${certdir}/cert-${timestamp}.pem" + # shellcheck disable=SC2086 + sign_csr "$(< "${certdir}/cert-${timestamp}.csr")" ${altnames} 3>"${crt_path}" + + # Create fullchain.pem + echo " + Creating fullchain.pem..." + if [[ ${API} -eq 1 ]]; then + cat "${crt_path}" > "${certdir}/fullchain-${timestamp}.pem" + local issuer_hash + issuer_hash="$(get_issuer_hash "${crt_path}")" + if [ -e "${CHAINCACHE}/${issuer_hash}.chain" ]; then + echo " + Using cached chain!" + cat "${CHAINCACHE}/${issuer_hash}.chain" > "${certdir}/chain-${timestamp}.pem" + else + echo " + Walking chain..." + local issuer_cert_uri + issuer_cert_uri="$(get_issuer_cert_uri "${crt_path}" || echo "unknown")" + (walk_chain "${crt_path}" > "${certdir}/chain-${timestamp}.pem") || _exiterr "Walking chain has failed, your certificate has been created and can be found at ${crt_path}, the corresponding private key at ${privkey}. If you want you can manually continue on creating and linking all necessary files. If this error occurs again you should manually generate the certificate chain and place it under ${CHAINCACHE}/${issuer_hash}.chain (see ${issuer_cert_uri})" + cat "${certdir}/chain-${timestamp}.pem" > "${CHAINCACHE}/${issuer_hash}.chain" + fi + cat "${certdir}/chain-${timestamp}.pem" >> "${certdir}/fullchain-${timestamp}.pem" + else + tmpcert="$(_mktemp)" + tmpchain="$(_mktemp)" + awk '{print >out}; /----END CERTIFICATE-----/{out=tmpchain}' out="${tmpcert}" tmpchain="${tmpchain}" "${certdir}/cert-${timestamp}.pem" + mv "${certdir}/cert-${timestamp}.pem" "${certdir}/fullchain-${timestamp}.pem" + cat "${tmpcert}" > "${certdir}/cert-${timestamp}.pem" + cat "${tmpchain}" > "${certdir}/chain-${timestamp}.pem" + rm "${tmpcert}" "${tmpchain}" + fi + + # Wait for hook script to sync the files before creating the symlinks + [[ -n "${HOOK}" ]] && ("${HOOK}" "sync_cert" "${certdir}/privkey-${timestamp}.pem" "${certdir}/cert-${timestamp}.pem" "${certdir}/fullchain-${timestamp}.pem" "${certdir}/chain-${timestamp}.pem" "${certdir}/cert-${timestamp}.csr" || _exiterr 'sync_cert hook returned with non-zero exit code') + + # Update symlinks + [[ "${privkey}" = "privkey.pem" ]] || ln -sf "privkey-${timestamp}.pem" "${certdir}/privkey.pem" + + ln -sf "chain-${timestamp}.pem" "${certdir}/chain.pem" + ln -sf "fullchain-${timestamp}.pem" "${certdir}/fullchain.pem" + ln -sf "cert-${timestamp}.csr" "${certdir}/cert.csr" + ln -sf "cert-${timestamp}.pem" "${certdir}/cert.pem" + + # Wait for hook script to clean the challenge and to deploy cert if used + [[ -n "${HOOK}" ]] && ("${HOOK}" "deploy_cert" "${domain}" "${certdir}/privkey.pem" "${certdir}/cert.pem" "${certdir}/fullchain.pem" "${certdir}/chain.pem" "${timestamp}" || _exiterr 'deploy_cert hook returned with non-zero exit code') + + unset challenge_token + echo " + Done!" +} + +# Usage: --version (-v) +# Description: Print version information +command_version() { + load_config noverify + + echo "Dehydrated by Lukas Schauer" + echo "https://dehydrated.io" + echo "" + echo "Dehydrated version: ${VERSION}" + revision="$(cd "${SCRIPTDIR}"; git rev-parse HEAD 2>/dev/null || echo "unknown")" + echo "GIT-Revision: ${revision}" + echo "" + # shellcheck disable=SC1091 + if [[ "${OSTYPE}" =~ (BSD|Darwin) ]]; then + echo "OS: $(uname -sr)" + elif [[ -e /etc/os-release ]]; then + ( . /etc/os-release && echo "OS: $PRETTY_NAME" ) + elif [[ -e /usr/lib/os-release ]]; then + ( . /usr/lib/os-release && echo "OS: $PRETTY_NAME" ) + else + echo "OS: $(grep -v '^$' /etc/issue | head -n1 | _sed 's/\\(r|n|l) .*//g')" + fi + echo "Used software:" + [[ -n "${BASH_VERSION:-}" ]] && echo " bash: ${BASH_VERSION}" + [[ -n "${ZSH_VERSION:-}" ]] && echo " zsh: ${ZSH_VERSION}" + echo " curl: ${CURL_VERSION}" + if [[ "${OSTYPE}" =~ (BSD|Darwin) ]]; then + echo " awk, sed, mktemp, grep, diff: BSD base system versions" + else + echo " awk: $(awk -W version 2>&1 | head -n1)" + echo " sed: $(sed --version 2>&1 | head -n1)" + echo " mktemp: $(mktemp --version 2>&1 | head -n1)" + echo " grep: $(grep --version 2>&1 | head -n1)" + echo " diff: $(diff --version 2>&1 | head -n1)" + fi + echo " openssl: $("${OPENSSL}" version 2>&1)" + + exit 0 +} + +# Usage: --display-terms +# Description: Display current terms of service +command_terms() { + init_system + echo "The current terms of service: $CA_TERMS" + echo "+ Done!" + exit 0 +} + +# Usage: --register +# Description: Register account key +command_register() { + init_system + echo "+ Done!" + exit 0 +} + +# Usage: --account +# Description: Update account contact information +command_account() { + init_system + FAILED=false + + NEW_ACCOUNT_KEY_JSON="$(_mktemp)" + + # Check if we have the registration url + if [[ -z "${ACCOUNT_URL}" ]]; then + _exiterr "Error retrieving registration url." + fi + + echo "+ Updating registration url: ${ACCOUNT_URL} contact information..." + if [[ ${API} -eq 1 ]]; then + # If an email for the contact has been provided then adding it to the registered account + if [[ -n "${CONTACT_EMAIL}" ]]; then + (signed_request "${ACCOUNT_URL}" '{"resource": "reg", "contact":["mailto:'"${CONTACT_EMAIL}"'"]}' > "${NEW_ACCOUNT_KEY_JSON}") || FAILED=true + else + (signed_request "${ACCOUNT_URL}" '{"resource": "reg", "contact":[]}' > "${NEW_ACCOUNT_KEY_JSON}") || FAILED=true + fi + else + # If an email for the contact has been provided then adding it to the registered account + if [[ -n "${CONTACT_EMAIL}" ]]; then + (signed_request "${ACCOUNT_URL}" '{"contact":["mailto:'"${CONTACT_EMAIL}"'"]}' > "${NEW_ACCOUNT_KEY_JSON}") || FAILED=true + else + (signed_request "${ACCOUNT_URL}" '{"contact":[]}' > "${NEW_ACCOUNT_KEY_JSON}") || FAILED=true + fi + fi + + if [[ "${FAILED}" = "true" ]]; then + rm "${NEW_ACCOUNT_KEY_JSON}" + _exiterr "Error updating account information. See message above for more information." + fi + if diff -q "${NEW_ACCOUNT_KEY_JSON}" "${ACCOUNT_KEY_JSON}" > /dev/null; then + echo "+ Account information was the same after the update" + rm "${NEW_ACCOUNT_KEY_JSON}" + else + ACCOUNT_KEY_JSON_BACKUP="${ACCOUNT_KEY_JSON%.*}-$(date +%s).json" + echo "+ Backup ${ACCOUNT_KEY_JSON} as ${ACCOUNT_KEY_JSON_BACKUP}" + cp -p "${ACCOUNT_KEY_JSON}" "${ACCOUNT_KEY_JSON_BACKUP}" + echo "+ Populate ${ACCOUNT_KEY_JSON}" + mv "${NEW_ACCOUNT_KEY_JSON}" "${ACCOUNT_KEY_JSON}" + fi + echo "+ Done!" + exit 0 +} + +# Parse contents of domains.txt and domains.txt.d +parse_domains_txt() { + # Allow globbing temporarily + noglob_set + local inputs=("${DOMAINS_TXT}" "${DOMAINS_TXT}.d"/*.txt) + noglob_clear + + cat "${inputs[@]}" | + tr -d '\r' | + awk '{print tolower($0)}' | + _sed -e 's/^[[:space:]]*//g' -e 's/[[:space:]]*$//g' -e 's/[[:space:]]+/ /g' -e 's/([^ ])>/\1 >/g' -e 's/> />/g' | + (grep -vE '^(#|$)' || true) +} + +# Usage: --cron (-c) +# Description: Sign/renew non-existent/changed/expiring certificates. +command_sign_domains() { + init_system + hookscript_bricker_hook + + # Call startup hook + [[ -n "${HOOK}" ]] && ("${HOOK}" "startup_hook" || _exiterr 'startup_hook hook returned with non-zero exit code') + + if [ ! -d "${CHAINCACHE}" ]; then + echo " + Creating chain cache directory ${CHAINCACHE}" + mkdir "${CHAINCACHE}" + fi + + if [[ -n "${PARAM_DOMAIN:-}" ]]; then + DOMAINS_TXT="$(_mktemp)" + if [[ -n "${PARAM_ALIAS:-}" ]]; then + printf "%s > %s" "${PARAM_DOMAIN}" "${PARAM_ALIAS}" > "${DOMAINS_TXT}" + else + printf "%s" "${PARAM_DOMAIN}" > "${DOMAINS_TXT}" + fi + elif [[ -e "${DOMAINS_TXT}" ]]; then + if [[ ! -r "${DOMAINS_TXT}" ]]; then + _exiterr "domains.txt found but not readable" + fi + else + _exiterr "domains.txt not found and --domain not given" + fi + + # Generate certificates for all domains found in domains.txt. Check if existing certificate are about to expire + ORIGIFS="${IFS}" + IFS=$'\n' + for line in $(parse_domains_txt); do + reset_configvars + IFS="${ORIGIFS}" + alias="$(grep -Eo '>[^ ]+' <<< "${line}" || true)" + line="$(_sed -e 's/>[^ ]+[ ]*//g' <<< "${line}")" + aliascount="$(grep -Eo '>' <<< "${alias}" | awk 'END {print NR}' || true )" + [ "${aliascount}" -gt 1 ] && _exiterr "Only one alias per line is allowed in domains.txt!" + + domain="$(printf '%s\n' "${line}" | cut -d' ' -f1)" + morenames="$(printf '%s\n' "${line}" | cut -s -d' ' -f2-)" + [ "${aliascount}" -lt 1 ] && alias="${domain}" || alias="${alias#>}" + export alias + + if [[ -z "${morenames}" ]];then + echo "Processing ${domain}" + else + echo "Processing ${domain} with alternative names: ${morenames}" + fi + + if [ "${alias:0:2}" = "*." ]; then + _exiterr "Please define a valid alias for your ${domain} wildcard-certificate. See domains.txt-documentation for more details." + fi + + local certdir="${CERTDIR}/${alias}" + cert="${certdir}/cert.pem" + chain="${certdir}/chain.pem" + + force_renew="${PARAM_FORCE:-no}" + + timestamp="$(date +%s)" + + # If there is no existing certificate directory => make it + if [[ ! -e "${certdir}" ]]; then + echo " + Creating new directory ${certdir} ..." + mkdir -p "${certdir}" || _exiterr "Unable to create directory ${certdir}" + fi + + # read cert config + # for now this loads the certificate specific config in a subshell and parses a diff of set variables. + # we could just source the config file but i decided to go this way to protect people from accidentally overriding + # variables used internally by this script itself. + if [[ -n "${DOMAINS_D}" ]]; then + certconfig="${DOMAINS_D}/${alias}" + else + certconfig="${certdir}/config" + fi + + if [ -f "${certconfig}" ]; then + echo " + Using certificate specific config file!" + ORIGIFS="${IFS}" + IFS=$'\n' + for cfgline in $( + beforevars="$(_mktemp)" + aftervars="$(_mktemp)" + set > "${beforevars}" + # shellcheck disable=SC1090 + . "${certconfig}" + set > "${aftervars}" + diff -u "${beforevars}" "${aftervars}" | grep -E '^\+[^+]' + rm "${beforevars}" + rm "${aftervars}" + ); do + config_var="$(echo "${cfgline:1}" | cut -d'=' -f1)" + config_value="$(echo "${cfgline:1}" | cut -d'=' -f2- | tr -d "'")" + # All settings that are allowed here should also be stored and + # restored in store_configvars() and reset_configvars() + case "${config_var}" in + KEY_ALGO|OCSP_MUST_STAPLE|OCSP_FETCH|OCSP_DAYS|PRIVATE_KEY_RENEW|PRIVATE_KEY_ROLLOVER|KEYSIZE|CHALLENGETYPE|HOOK|PREFERRED_CHAIN|WELLKNOWN|HOOK_CHAIN|OPENSSL_CNF|RENEW_DAYS) + echo " + ${config_var} = ${config_value}" + declare -- "${config_var}=${config_value}" + ;; + _) ;; + *) echo " ! Setting ${config_var} on a per-certificate base is not (yet) supported" >&2 + esac + done + IFS="${ORIGIFS}" + fi + verify_config + hookscript_bricker_hook + export WELLKNOWN CHALLENGETYPE KEY_ALGO PRIVATE_KEY_ROLLOVER + + skip="no" + + # Allow for external CSR generation + local csr="" + if [[ -n "${HOOK}" ]]; then + csr="$("${HOOK}" "generate_csr" "${domain}" "${certdir}" "${domain} ${morenames}")" || _exiterr 'generate_csr hook returned with non-zero exit code' + if grep -qE "\-----BEGIN (NEW )?CERTIFICATE REQUEST-----" <<< "${csr}"; then + altnames="$(extract_altnames "${csr}")" + domain="$(cut -d' ' -f1 <<< "${altnames}")" + morenames="$(cut -s -d' ' -f2- <<< "${altnames}")" + echo " + Using CSR from hook script (real names: ${altnames})" + else + csr="" + fi + fi + + # Check domain names of existing certificate + if [[ -e "${cert}" && "${force_renew}" = "no" ]]; then + printf " + Checking domain name(s) of existing cert..." + + certnames="$("${OPENSSL}" x509 -in "${cert}" -text -noout | grep -E '(DNS|IP( Address*)):' | _sed 's/(DNS|IP( Address)*)://g' | tr -d ' ' | tr ',' '\n' | sort -u | tr '\n' ' ' | _sed 's/ $//')" + givennames="$(echo "${domain}" "${morenames}"| tr ' ' '\n' | sort -u | tr '\n' ' ' | _sed 's/ip://g' | _sed 's/ $//' | _sed 's/^ //')" + + if [[ "${certnames}" = "${givennames}" ]]; then + echo " unchanged." + else + echo " changed!" + echo " + Domain name(s) are not matching!" + echo " + Names in old certificate: ${certnames}" + echo " + Configured names: ${givennames}" + echo " + Forcing renew." + force_renew="yes" + fi + fi + + # Check expire date of existing certificate + if [[ -e "${cert}" ]]; then + echo " + Checking expire date of existing cert..." + valid="$("${OPENSSL}" x509 -enddate -noout -in "${cert}" | cut -d= -f2- )" + + printf " + Valid till %s " "${valid}" + if ("${OPENSSL}" x509 -checkend $((RENEW_DAYS * 86400)) -noout -in "${cert}" > /dev/null 2>&1); then + printf "(Longer than %d days). " "${RENEW_DAYS}" + if [[ "${force_renew}" = "yes" ]]; then + echo "Ignoring because renew was forced!" + else + # Certificate-Names unchanged and cert is still valid + echo "Skipping renew!" + [[ -n "${HOOK}" ]] && ("${HOOK}" "unchanged_cert" "${domain}" "${certdir}/privkey.pem" "${certdir}/cert.pem" "${certdir}/fullchain.pem" "${certdir}/chain.pem" || _exiterr 'unchanged_cert hook returned with non-zero exit code') + skip="yes" + fi + else + echo "(Less than ${RENEW_DAYS} days). Renewing!" + fi + fi + + local update_ocsp + update_ocsp="no" + + # Sign certificate for this domain + if [[ ! "${skip}" = "yes" ]]; then + update_ocsp="yes" + [[ -z "${csr}" ]] || printf "%s" "${csr}" > "${certdir}/cert-${timestamp}.csr" + # shellcheck disable=SC2086 + if [[ "${PARAM_KEEP_GOING:-}" = "yes" ]]; then + skip_exit_hook=yes + sign_domain "${certdir}" "${timestamp}" "${domain}" ${morenames} & + wait $! || exit_with_errorcode=1 + skip_exit_hook=no + else + sign_domain "${certdir}" "${timestamp}" "${domain}" ${morenames} + fi + fi + + if [[ "${OCSP_FETCH}" = "yes" ]]; then + local ocsp_url + ocsp_url="$(get_ocsp_url "${cert}")" + + if [[ ! -e "${certdir}/ocsp.der" ]]; then + update_ocsp="yes" + elif ! ("${OPENSSL}" ocsp -no_nonce -issuer "${chain}" -verify_other "${chain}" -cert "${cert}" -respin "${certdir}/ocsp.der" -status_age $((OCSP_DAYS*24*3600)) 2>&1 | grep -q "${cert}: good"); then + update_ocsp="yes" + fi + + if [[ "${update_ocsp}" = "yes" ]]; then + echo " + Updating OCSP stapling file" + ocsp_timestamp="$(date +%s)" + if grep -qE "^(openssl (0|(1\.0))\.)|(libressl (1|2|3)\.)" <<< "$(${OPENSSL} version | awk '{print tolower($0)}')"; then + ocsp_log="$("${OPENSSL}" ocsp -no_nonce -issuer "${chain}" -verify_other "${chain}" -cert "${cert}" -respout "${certdir}/ocsp-${ocsp_timestamp}.der" -url "${ocsp_url}" -header "HOST" "$(echo "${ocsp_url}" | _sed -e 's/^http(s?):\/\///' -e 's/\/.*$//g')" 2>&1)" || _exiterr "Error while fetching OCSP information: ${ocsp_log}" + else + ocsp_log="$("${OPENSSL}" ocsp -no_nonce -issuer "${chain}" -verify_other "${chain}" -cert "${cert}" -respout "${certdir}/ocsp-${ocsp_timestamp}.der" -url "${ocsp_url}" 2>&1)" || _exiterr "Error while fetching OCSP information: ${ocsp_log}" + fi + ln -sf "ocsp-${ocsp_timestamp}.der" "${certdir}/ocsp.der" + [[ -n "${HOOK}" ]] && (altnames="${domain} ${morenames}" "${HOOK}" "deploy_ocsp" "${domain}" "${certdir}/ocsp.der" "${ocsp_timestamp}" || _exiterr 'deploy_ocsp hook returned with non-zero exit code') + else + echo " + OCSP stapling file is still valid (skipping update)" + fi + fi + done + reset_configvars + + # remove temporary domains.txt file if used + [[ -n "${PARAM_DOMAIN:-}" ]] && rm -f "${DOMAINS_TXT}" + + [[ -n "${HOOK}" ]] && ("${HOOK}" "exit_hook" || echo 'exit_hook returned with non-zero exit code!' >&2) + if [[ "${AUTO_CLEANUP}" == "yes" ]]; then + echo "+ Running automatic cleanup" + command_cleanup noinit + fi + + exit "${exit_with_errorcode}" +} + +# Usage: --signcsr (-s) path/to/csr.pem +# Description: Sign a given CSR, output CRT on stdout (advanced usage) +command_sign_csr() { + init_system + + # redirect stdout to stderr + # leave stdout over at fd 3 to output the cert + exec 3>&1 1>&2 + + # load csr + csrfile="${1}" + if [ ! -r "${csrfile}" ]; then + _exiterr "Could not read certificate signing request ${csrfile}" + fi + csr="$(cat "${csrfile}")" + + # extract names + altnames="$(extract_altnames "${csr}")" + + # gen cert + certfile="$(_mktemp)" + # shellcheck disable=SC2086 + sign_csr "${csr}" ${altnames} 3> "${certfile}" + + # print cert + echo "# CERT #" >&3 + cat "${certfile}" >&3 + echo >&3 + + # print chain + if [ -n "${PARAM_FULL_CHAIN:-}" ]; then + # get and convert ca cert + chainfile="$(_mktemp)" + tmpchain="$(_mktemp)" + http_request get "$("${OPENSSL}" x509 -in "${certfile}" -noout -text | grep 'CA Issuers - URI:' | cut -d':' -f2-)" > "${tmpchain}" + if grep -q "BEGIN CERTIFICATE" "${tmpchain}"; then + mv "${tmpchain}" "${chainfile}" + else + "${OPENSSL}" x509 -in "${tmpchain}" -inform DER -out "${chainfile}" -outform PEM + rm "${tmpchain}" + fi + + echo "# CHAIN #" >&3 + cat "${chainfile}" >&3 + + rm "${chainfile}" + fi + + # cleanup + rm "${certfile}" + + exit 0 +} + +# Usage: --revoke (-r) path/to/cert.pem +# Description: Revoke specified certificate +command_revoke() { + init_system + + [[ -n "${CA_REVOKE_CERT}" ]] || _exiterr "Certificate authority doesn't allow certificate revocation." + + cert="${1}" + if [[ -L "${cert}" ]]; then + # follow symlink and use real certificate name (so we move the real file and not the symlink at the end) + local link_target + link_target="$(readlink -n "${cert}")" + if [[ "${link_target}" =~ ^/ ]]; then + cert="${link_target}" + else + cert="$(dirname "${cert}")/${link_target}" + fi + fi + [[ -f "${cert}" ]] || _exiterr "Could not find certificate ${cert}" + + echo "Revoking ${cert}" + + cert64="$("${OPENSSL}" x509 -in "${cert}" -inform PEM -outform DER | urlbase64)" + if [[ ${API} -eq 1 ]]; then + response="$(signed_request "${CA_REVOKE_CERT}" '{"resource": "revoke-cert", "certificate": "'"${cert64}"'"}' | clean_json)" + else + response="$(signed_request "${CA_REVOKE_CERT}" '{"certificate": "'"${cert64}"'"}' | clean_json)" + fi + # if there is a problem with our revoke request _request (via signed_request) will report this and "exit 1" out + # so if we are here, it is safe to assume the request was successful + echo " + Done." + echo " + Renaming certificate to ${cert}-revoked" + mv -f "${cert}" "${cert}-revoked" +} + +# Usage: --deactivate +# Description: Deactivate account +command_deactivate() { + init_system + + echo "Deactivating account ${ACCOUNT_URL}" + + if [[ ${API} -eq 1 ]]; then + echo "Deactivation for ACMEv1 is not implemented" + else + response="$(signed_request "${ACCOUNT_URL}" '{"status": "deactivated"}' | clean_json)" + deactstatus=$(echo "$response" | jsonsh | get_json_string_value "status") + if [[ "${deactstatus}" = "deactivated" ]]; then + touch "${ACCOUNT_DEACTIVATED}" + else + _exiterr "Account deactivation failed!" + fi + fi + + echo " + Done." +} + +# Usage: --cleanup (-gc) +# Description: Move unused certificate files to archive directory +command_cleanup() { + if [ ! "${1:-}" = "noinit" ]; then + load_config + fi + + if [[ ! "${PARAM_CLEANUPDELETE:-}" = "yes" ]]; then + # Create global archive directory if not existent + if [[ ! -e "${BASEDIR}/archive" ]]; then + mkdir "${BASEDIR}/archive" + fi + fi + + # Allow globbing + noglob_set + + # Loop over all certificate directories + for certdir in "${CERTDIR}/"*; do + # Skip if entry is not a folder + [[ -d "${certdir}" ]] || continue + + # Get certificate name + certname="$(basename "${certdir}")" + + # Create certificates archive directory if not existent + if [[ ! "${PARAM_CLEANUPDELETE:-}" = "yes" ]]; then + archivedir="${BASEDIR}/archive/${certname}" + if [[ ! -e "${archivedir}" ]]; then + mkdir "${archivedir}" + fi + fi + + # Loop over file-types (certificates, keys, signing-requests, ...) + for filetype in cert.csr cert.pem chain.pem fullchain.pem privkey.pem ocsp.der; do + # Delete all if symlink is broken + if [[ -r "${certdir}/${filetype}" ]]; then + # Look up current file in use + current="$(basename "$(readlink "${certdir}/${filetype}")")" + else + if [[ -h "${certdir}/${filetype}" ]]; then + echo "Removing broken symlink: ${certdir}/${filetype}" + rm -f "${certdir}/${filetype}" + fi + current="" + fi + + # Split filetype into name and extension + filebase="$(echo "${filetype}" | cut -d. -f1)" + fileext="$(echo "${filetype}" | cut -d. -f2)" + + # Loop over all files of this type + for file in "${certdir}/${filebase}-"*".${fileext}" "${certdir}/${filebase}-"*".${fileext}-revoked"; do + # Check if current file is in use, if unused move to archive directory + filename="$(basename "${file}")" + if [[ ! "${filename}" = "${current}" ]] && [[ -f "${certdir}/${filename}" ]]; then + if [[ "${PARAM_CLEANUPDELETE:-}" = "yes" ]]; then + echo "Deleting unused file: ${certname}/${filename}" + rm "${certdir}/${filename}" + else + echo "Moving unused file to archive directory: ${certname}/${filename}" + mv "${certdir}/${filename}" "${archivedir}/${filename}" + fi + fi + done + done + done + + exit "${exit_with_errorcode}" +} + +# Usage: --cleanup-delete (-gcd) +# Description: Deletes (!) unused certificate files +command_cleanupdelete() { + command_cleanup +} + + +# Usage: --help (-h) +# Description: Show help text +command_help() { + printf "Usage: %s [-h] [command [argument]] [parameter [argument]] [parameter [argument]] ...\n\n" "${0}" + printf "Default command: help\n\n" + echo "Commands:" + grep -e '^[[:space:]]*# Usage:' -e '^[[:space:]]*# Description:' -e '^command_.*()[[:space:]]*{' "${0}" | while read -r usage; read -r description; read -r command; do + if [[ ! "${usage}" =~ Usage ]] || [[ ! "${description}" =~ Description ]] || [[ ! "${command}" =~ ^command_ ]]; then + _exiterr "Error generating help text." + fi + printf " %-32s %s\n" "${usage##"# Usage: "}" "${description##"# Description: "}" + done + printf -- "\nParameters:\n" + grep -E -e '^[[:space:]]*# PARAM_Usage:' -e '^[[:space:]]*# PARAM_Description:' "${0}" | while read -r usage; read -r description; do + if [[ ! "${usage}" =~ Usage ]] || [[ ! "${description}" =~ Description ]]; then + _exiterr "Error generating help text." + fi + printf " %-32s %s\n" "${usage##"# PARAM_Usage: "}" "${description##"# PARAM_Description: "}" + done +} + +# Usage: --env (-e) +# Description: Output configuration variables for use in other scripts +command_env() { + echo "# dehydrated configuration" + load_config + typeset -p CA CERTDIR ALPNCERTDIR CHALLENGETYPE DOMAINS_D DOMAINS_TXT HOOK HOOK_CHAIN RENEW_DAYS ACCOUNT_KEY ACCOUNT_KEY_JSON ACCOUNT_ID_JSON KEYSIZE WELLKNOWN PRIVATE_KEY_RENEW OPENSSL_CNF CONTACT_EMAIL LOCKFILE +} + +# Main method (parses script arguments and calls command_* methods) +main() { + exit_with_errorcode=0 + skip_exit_hook=no + COMMAND="" + set_command() { + [[ -z "${COMMAND}" ]] || _exiterr "Only one command can be executed at a time. See help (-h) for more information." + COMMAND="${1}" + } + + check_parameters() { + if [[ -z "${1:-}" ]]; then + echo "The specified command requires additional parameters. See help:" >&2 + echo >&2 + command_help >&2 + exit 1 + elif [[ "${1:0:1}" = "-" ]]; then + _exiterr "Invalid argument: ${1}" + fi + } + + [[ -z "${*}" ]] && eval set -- "--help" + + while (( ${#} )); do + case "${1}" in + --help|-h) + command_help + exit 0 + ;; + + --env|-e) + set_command env + ;; + + --cron|-c) + set_command sign_domains + ;; + + --register) + set_command register + ;; + + --account) + set_command account + ;; + + # PARAM_Usage: --accept-terms + # PARAM_Description: Accept CAs terms of service + --accept-terms) + PARAM_ACCEPT_TERMS="yes" + ;; + + --display-terms) + set_command terms + ;; + + --signcsr|-s) + shift 1 + set_command sign_csr + check_parameters "${1:-}" + PARAM_CSR="${1}" + ;; + + --revoke|-r) + shift 1 + set_command revoke + check_parameters "${1:-}" + PARAM_REVOKECERT="${1}" + ;; + + --deactivate) + set_command deactivate + ;; + + --version|-v) + set_command version + ;; + + --cleanup|-gc) + set_command cleanup + ;; + + --cleanup-delete|-gcd) + set_command cleanupdelete + PARAM_CLEANUPDELETE="yes" + ;; + + # PARAM_Usage: --full-chain (-fc) + # PARAM_Description: Print full chain when using --signcsr + --full-chain|-fc) + PARAM_FULL_CHAIN="1" + ;; + + # PARAM_Usage: --ipv4 (-4) + # PARAM_Description: Resolve names to IPv4 addresses only + --ipv4|-4) + PARAM_IP_VERSION="4" + ;; + + # PARAM_Usage: --ipv6 (-6) + # PARAM_Description: Resolve names to IPv6 addresses only + --ipv6|-6) + PARAM_IP_VERSION="6" + ;; + + # PARAM_Usage: --domain (-d) domain.tld + # PARAM_Description: Use specified domain name(s) instead of domains.txt entry (one certificate!) + --domain|-d) + shift 1 + check_parameters "${1:-}" + if [[ -z "${PARAM_DOMAIN:-}" ]]; then + PARAM_DOMAIN="${1}" + else + PARAM_DOMAIN="${PARAM_DOMAIN} ${1}" + fi + ;; + + # PARAM_Usage: --ca url/preset + # PARAM_Description: Use specified CA URL or preset + --ca) + shift 1 + check_parameters "${1:-}" + [[ -n "${PARAM_CA:-}" ]] && _exiterr "CA can only be specified once!" + PARAM_CA="${1}" + ;; + + # PARAM_Usage: --alias certalias + # PARAM_Description: Use specified name for certificate directory (and per-certificate config) instead of the primary domain (only used if --domain is specified) + --alias) + shift 1 + check_parameters "${1:-}" + [[ -n "${PARAM_ALIAS:-}" ]] && _exiterr "Alias can only be specified once!" + PARAM_ALIAS="${1}" + ;; + + # PARAM_Usage: --keep-going (-g) + # PARAM_Description: Keep going after encountering an error while creating/renewing multiple certificates in cron mode + --keep-going|-g) + PARAM_KEEP_GOING="yes" + ;; + + # PARAM_Usage: --force (-x) + # PARAM_Description: Force renew of certificate even if it is longer valid than value in RENEW_DAYS + --force|-x) + PARAM_FORCE="yes" + ;; + + # PARAM_Usage: --force-validation + # PARAM_Description: Force revalidation of domain names (used in combination with --force) + --force-validation) + PARAM_FORCE_VALIDATION="yes" + ;; + + # PARAM_Usage: --no-lock (-n) + # PARAM_Description: Don't use lockfile (potentially dangerous!) + --no-lock|-n) + PARAM_NO_LOCK="yes" + ;; + + # PARAM_Usage: --lock-suffix example.com + # PARAM_Description: Suffix lockfile name with a string (useful for with -d) + --lock-suffix) + shift 1 + check_parameters "${1:-}" + PARAM_LOCKFILE_SUFFIX="${1}" + ;; + + # PARAM_Usage: --ocsp + # PARAM_Description: Sets option in CSR indicating OCSP stapling to be mandatory + --ocsp) + PARAM_OCSP_MUST_STAPLE="yes" + ;; + + # PARAM_Usage: --privkey (-p) path/to/key.pem + # PARAM_Description: Use specified private key instead of account key (useful for revocation) + --privkey|-p) + shift 1 + check_parameters "${1:-}" + PARAM_ACCOUNT_KEY="${1}" + ;; + + # PARAM_Usage: --domains-txt path/to/domains.txt + # PARAM_Description: Use specified domains.txt instead of default/configured one + --domains-txt) + shift 1 + check_parameters "${1:-}" + PARAM_DOMAINS_TXT="${1}" + ;; + + # PARAM_Usage: --config (-f) path/to/config + # PARAM_Description: Use specified config file + --config|-f) + shift 1 + check_parameters "${1:-}" + CONFIG="${1}" + ;; + + # PARAM_Usage: --hook (-k) path/to/hook.sh + # PARAM_Description: Use specified script for hooks + --hook|-k) + shift 1 + check_parameters "${1:-}" + PARAM_HOOK="${1}" + ;; + + # PARAM_Usage: --preferred-chain issuer-cn + # PARAM_Description: Use alternative certificate chain identified by issuer CN + --preferred-chain) + shift 1 + check_parameters "${1:-}" + PARAM_PREFERRED_CHAIN="${1}" + ;; + + # PARAM_Usage: --out (-o) certs/directory + # PARAM_Description: Output certificates into the specified directory + --out|-o) + shift 1 + check_parameters "${1:-}" + PARAM_CERTDIR="${1}" + ;; + + # PARAM_Usage: --alpn alpn-certs/directory + # PARAM_Description: Output alpn verification certificates into the specified directory + --alpn) + shift 1 + check_parameters "${1:-}" + PARAM_ALPNCERTDIR="${1}" + ;; + + # PARAM_Usage: --challenge (-t) http-01|dns-01|tls-alpn-01 + # PARAM_Description: Which challenge should be used? Currently http-01, dns-01, and tls-alpn-01 are supported + --challenge|-t) + shift 1 + check_parameters "${1:-}" + PARAM_CHALLENGETYPE="${1}" + ;; + + # PARAM_Usage: --algo (-a) rsa|prime256v1|secp384r1 + # PARAM_Description: Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1 + --algo|-a) + shift 1 + check_parameters "${1:-}" + PARAM_KEY_ALGO="${1}" + ;; + *) + echo "Unknown parameter detected: ${1}" >&2 + echo >&2 + command_help >&2 + exit 1 + ;; + esac + + shift 1 + done + + case "${COMMAND}" in + env) command_env;; + sign_domains) command_sign_domains;; + register) command_register;; + account) command_account;; + sign_csr) command_sign_csr "${PARAM_CSR}";; + revoke) command_revoke "${PARAM_REVOKECERT}";; + deactivate) command_deactivate;; + cleanup) command_cleanup;; + terms) command_terms;; + cleanupdelete) command_cleanupdelete;; + version) command_version;; + *) command_help; exit 1;; + esac + + exit "${exit_with_errorcode}" +} + +# Determine OS type +OSTYPE="$(uname)" + +if [[ ! "${DEHYDRATED_NOOP:-}" = "NOOP" ]]; then + # Run script + main "${@:-}" +fi + +# vi: expandtab sw=2 ts=2 diff --git a/docs/acme-v1.md b/docs/acme-v1.md new file mode 100644 index 0000000..c0f38c8 --- /dev/null +++ b/docs/acme-v1.md @@ -0,0 +1,19 @@ +## (Future) Removal of API version 1 + +The ACME API version 1 was never really standardized and was only supported by Let's Encrypt. Even though the protocol specification was public, +it wasn't really friendly to be integrated into existing CA systems so initial adoption was basically non-existant. + +ACME version 2 is being designed to overcome these issues by becoming an official IETF standard and supporting a more traditional approach of account +and order management in the backend, making it friendlier to integrate into existing systems centered around those. It has since become a semi-stable IETF +standard draft which only ever got two breaking changes, Content-Type enforcement and `POST-as-GET`, the latter being announced in October 2018 to be enforced +by November 2019. See https://datatracker.ietf.org/wg/acme/documents/ for a better insight into the draft and its changes. + +Next to backend changes that many users won't really care about ACME v2 has all of the features ACME v1 had, but also some additional new features like +e.g. support for [wildcard certificates](domains_txt.md#wildcards). + +Since ACME v2 is basically to be considered stable and ACME v1 has no real benefits over v2, there doesn't seem to be much of a reason to keep the old +protocol around, but since there actually are a few Certificate Authorities and resellers that implemented the v1 protocol and didn't yet make the change +to v2, so dehydrated still supports the old protocol for now. + +Please keep in mind that support for the old ACME protocol version 1 might get removed at any point of bigger inconvenience, e.g. on code changes that +would require a lot of work or ugly workarounds to keep both versions supported. diff --git a/docs/dns-verification.md b/docs/dns-verification.md new file mode 100644 index 0000000..3b6ddb9 --- /dev/null +++ b/docs/dns-verification.md @@ -0,0 +1,31 @@ +### dns-01 challenge + +This script also supports the new `dns-01`-type verification. This type of verification requires you to be able to create a specific `TXT` DNS record for each hostname included in the certificate. + +You need a hook script that deploys the challenge to your DNS server. + +The hook script (indicated in the config file or the `--hook/-k` command line argument) gets four arguments: + +$1 an operation name (`clean_challenge`, `deploy_challenge`, `deploy_cert`, `invalid_challenge` or `request_failure`) and some operands for that. +For `deploy_challenge` + +$2 is the domain name for which the certificate is required, + +$3 is a "challenge token" (which is not needed for dns-01), and + +$4 is a token which needs to be inserted in a TXT record for the domain. + +Typically, you will need to split the subdomain name in two, the subdomain name and the domain name separately. For example, for "my.example.com", you'll need "my" and "example.com" separately. You then have to prefix "_acme-challenge." before the subdomain name, as in "_acme-challenge.my" and set a TXT record for that on the domain (e.g. "example.com") which has the value supplied in $4 + +``` +_acme-challenge IN TXT $4 +_acme-challenge.my IN TXT $4 +``` + +That could be done manually (as most providers don't have a DNS API), by having your hook script echo $1, $2 and $4 and then wait (`read -s -r -e < /dev/tty`) - give it a little time to get into their DNS system. Usually providers give you a boxes to put "_acme-challenge.my" and the token value in, and a dropdown to choose the record type, TXT. + +Or when you do have a DNS API, pass the details accordingly to achieve the same thing. + +You can delete the TXT record when called with operation `clean_challenge`, when $2 is also the domain name. + +Here are some examples: [Examples for DNS-01 hooks](https://github.com/dehydrated-io/dehydrated/wiki) diff --git a/docs/domains_txt.md b/docs/domains_txt.md new file mode 100644 index 0000000..dcdbe41 --- /dev/null +++ b/docs/domains_txt.md @@ -0,0 +1,107 @@ +## domains.txt + +dehydrated uses the file `domains.txt` as configuration for which certificates +should be requested. + +The file should have the following format: + +```text +example.org +example.com www.example.com +example.net www.example.net wiki.example.net +``` + +This states that there are the following certificates: + * `example.org` without any *alternative names* + * `example.com` with an *alternative name* of `www.example.com` + * `example.net` with the *alternative names*: `www.example.net` and + `wiki.example.net` + +### Aliases + +You can define an *alias* for your certificate which will (instead of the +primary domain) be used as the directory name under your `CERTDIR` and for a +per-certificate lookup. This is done using the `>` character. This allows +multiple certificates with identical sets of domains but different +configuration to exist. + +Here is an example of using an *alias* called `certalias` for creating the +certificate for `example.net` with *alternative names* `www.example.net` and +`wiki.example.net`. The certificate will be stored in the directory `certalias` +under your `CERTDIR`. + +```text +example.net www.example.net wiki.example.net > certalias +``` + +This allows to set per certificates options. The options you can change are +explained in [Per Certificate Config](per-certificate-config.md). + +If you want to create different certificate types for the same domain +you can use: + +```text +*.service.example.org service.example.org > star_service_example_org_rsa +*.service.example.org service.example.org > star_service_example_org_ecdsa +``` + +Then add a config file `certs/star_service_example_org_rsa/config` with +the value + +``` +KEY_ALGO="rsa" +``` + +or respectively + +``` +KEY_ALGO="ecdsa" +``` + +### Wildcards + +Support for wildcards was added by the ACME v2 protocol. + +Certificates with a wildcard domain as the first (or only) name require an +*alias* to be set. *Aliases* can't start with `*.`. + +For example to create the wildcard for `*.service.example.com` your +`domains.txt` could use the *alias* method like this: + +```text +*.service.example.com > star_service_example_com +``` + +This creates a wildcard certificate for only `*.service.example.com` and will +store it in the directory `star_service_example_com` under your `CERTDIR`. As a +note this certificate will **NOT** be valid for `service.example.com` but only +for `*.service.example.com`. So it would, for example, be valid for +`foo.service.example.com`. + + +Another way to create it is using *alternative names*. For example your +`domains.txt` could do this: + +```text +service.example.com *.service.example.com +eggs.example.com *.ham.example.com +``` + +This creates two certificates one for `service.example.com` with an +*alternative name* of `*.service.example.com` and a second certificate for +`eggs.example.com` with an *alternative name* of `*.ham.example.com`. + +**Note:** The first certificate is valid for both `service.example.com` and for +`*.service.example.com` which can be a useful way to create wildcard +certificates. + +### Drop-in directory + +If a directory named `domains.txt.d` exists in the same location as +`domains.txt`, the contents of `*.txt` files in that directory are appended to +the list of domains, in alphabetical order of the filenames. This is useful for +automation, as it doesn't require editing an existing file to add new domains. + +Warning: Behaviour of this might change as the naming between `domains.txt.d` +and the `DOMAINS_D` config variable (which is used for per-certificate +configuration) is a bit confusing. diff --git a/docs/ecc.md b/docs/ecc.md new file mode 100644 index 0000000..87d52ba --- /dev/null +++ b/docs/ecc.md @@ -0,0 +1,4 @@ +### Elliptic Curve Cryptography (ECC) + +This script also supports certificates with Elliptic Curve public keys! +Simply set the `KEY_ALGO` variable in one of the config files. diff --git a/docs/examples/config b/docs/examples/config new file mode 100644 index 0000000..51e38de --- /dev/null +++ b/docs/examples/config @@ -0,0 +1,132 @@ +######################################################## +# This is the main config file for dehydrated # +# # +# This file is looked for in the following locations: # +# $SCRIPTDIR/config (next to this script) # +# /usr/local/etc/dehydrated/config # +# /etc/dehydrated/config # +# ${PWD}/config (in current working-directory) # +# # +# Default values of this config are in comments # +######################################################## + +# Which user should dehydrated run as? This will be implicitly enforced when running as root +#DEHYDRATED_USER= + +# Which group should dehydrated run as? This will be implicitly enforced when running as root +#DEHYDRATED_GROUP= + +# Resolve names to addresses of IP version only. (curl) +# supported values: 4, 6 +# default: <unset> +#IP_VERSION= + +# URL to certificate authority or internal preset +# Presets: letsencrypt, letsencrypt-test, zerossl, buypass, buypass-test +# default: letsencrypt +#CA="letsencrypt" + +# Path to old certificate authority +# Set this value to your old CA value when upgrading from ACMEv1 to ACMEv2 under a different endpoint. +# If dehydrated detects an account-key for the old CA it will automatically reuse that key +# instead of registering a new one. +# default: https://acme-v01.api.letsencrypt.org/directory +#OLDCA="https://acme-v01.api.letsencrypt.org/directory" + +# Which challenge should be used? Currently http-01, dns-01 and tls-alpn-01 are supported +#CHALLENGETYPE="http-01" + +# Path to a directory containing additional config files, allowing to override +# the defaults found in the main configuration file. Additional config files +# in this directory needs to be named with a '.sh' ending. +# default: <unset> +#CONFIG_D= + +# Directory for per-domain configuration files. +# If not set, per-domain configurations are sourced from each certificates output directory. +# default: <unset> +#DOMAINS_D= + +# Base directory for account key, generated certificates and list of domains (default: $SCRIPTDIR -- uses config directory if undefined) +#BASEDIR=$SCRIPTDIR + +# File containing the list of domains to request certificates for (default: $BASEDIR/domains.txt) +#DOMAINS_TXT="${BASEDIR}/domains.txt" + +# Output directory for generated certificates +#CERTDIR="${BASEDIR}/certs" + +# Output directory for alpn verification certificates +#ALPNCERTDIR="${BASEDIR}/alpn-certs" + +# Directory for account keys and registration information +#ACCOUNTDIR="${BASEDIR}/accounts" + +# Output directory for challenge-tokens to be served by webserver or deployed in HOOK (default: /var/www/dehydrated) +#WELLKNOWN="/var/www/dehydrated" + +# Default keysize for private keys (default: 4096) +#KEYSIZE="4096" + +# Path to openssl config file (default: <unset> - tries to figure out system default) +#OPENSSL_CNF= + +# Path to OpenSSL binary (default: "openssl") +#OPENSSL="openssl" + +# Extra options passed to the curl binary (default: <unset>) +#CURL_OPTS= + +# Program or function called in certain situations +# +# After generating the challenge-response, or after failed challenge (in this case altname is empty) +# Given arguments: clean_challenge|deploy_challenge altname token-filename token-content +# +# After successfully signing certificate +# Given arguments: deploy_cert domain path/to/privkey.pem path/to/cert.pem path/to/fullchain.pem +# +# BASEDIR and WELLKNOWN variables are exported and can be used in an external program +# default: <unset> +#HOOK= + +# Chain clean_challenge|deploy_challenge arguments together into one hook call per certificate (default: no) +#HOOK_CHAIN="no" + +# Minimum days before expiration to automatically renew certificate (default: 30) +#RENEW_DAYS="30" + +# Regenerate private keys instead of just signing new certificates on renewal (default: yes) +#PRIVATE_KEY_RENEW="yes" + +# Create an extra private key for rollover (default: no) +#PRIVATE_KEY_ROLLOVER="no" + +# Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1 +#KEY_ALGO=secp384r1 + +# E-mail to use during the registration (default: <unset>) +#CONTACT_EMAIL= + +# Lockfile location, to prevent concurrent access (default: $BASEDIR/lock) +#LOCKFILE="${BASEDIR}/lock" + +# Option to add CSR-flag indicating OCSP stapling to be mandatory (default: no) +#OCSP_MUST_STAPLE="no" + +# Fetch OCSP responses (default: no) +#OCSP_FETCH="no" + +# OCSP refresh interval (default: 5 days) +#OCSP_DAYS=5 + +# Issuer chain cache directory (default: $BASEDIR/chains) +#CHAINCACHE="${BASEDIR}/chains" + +# Automatic cleanup (default: no) +#AUTO_CLEANUP="no" + +# ACME API version (default: auto) +#API=auto + +# Preferred issuer chain (default: <unset> -> uses default chain) +#PREFERRED_CHAIN= diff --git a/docs/examples/domains.txt b/docs/examples/domains.txt new file mode 100644 index 0000000..2079346 --- /dev/null +++ b/docs/examples/domains.txt @@ -0,0 +1,39 @@ +# Create certificate for 'example.org' with an alternative name of +# 'www.example.org'. It will be stored in the directory ${CERT_DIR}/example.org +example.org www.example.org + +# Create certificate for 'example.com' with alternative names of +# 'www.example.com' & 'wiki.example.com'. It will be stored in the directory +# ${CERT_DIR}/example.com +example.com www.example.com wiki.example.com + +# Using the alias 'certalias' create certificate for 'example.net' with +# alternate name 'www.example.net' and store it in the directory +# ${CERTDIR}/certalias +example.net www.example.net > certalias + +# Using the alias 'service_example_com' create a wildcard certificate for +# '*.service.example.com' and store it in the directory +# ${CERTDIR}/service_example_com +# NOTE: It is NOT a certificate for 'service.example.com' +*.service.example.com > service_example_com + +# Using the alias 'star_service_example_org' create a wildcard certificate for +# '*.service.example.org' with an alternative name of `service.example.org' +# and store it in the directory ${CERTDIR}/star_service_example_org +# NOTE: It is a certificate for 'service.example.org' +*.service.example.org service.example.org > star_service_example_org + +# Optionally you can also append the certificate algorithm here to create +# multiple certificate types for the same domain. +# +# This allows to set per certificates options. How to do this is +# explained in [domains.txt documentation](domains_txt.md). +# +*.service.example.org service.example.org > star_service_example_org_rsa +*.service.example.org service.example.org > star_service_example_org_ecdsa + +# Create a certificate for 'service.example.net' with an alternative name of +# '*.service.example.net' (which is a wildcard domain) and store it in the +# directory ${CERTDIR}/service.example.net +service.example.net *.service.example.net diff --git a/docs/examples/hook.sh b/docs/examples/hook.sh new file mode 100755 index 0000000..4fc9015 --- /dev/null +++ b/docs/examples/hook.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash + +deploy_challenge() { + local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" + + # This hook is called once for every domain that needs to be + # validated, including any alternative names you may have listed. + # + # Parameters: + # - DOMAIN + # The domain name (CN or subject alternative name) being + # validated. + # - TOKEN_FILENAME + # The name of the file containing the token to be served for HTTP + # validation. Should be served by your web server as + # /.well-known/acme-challenge/${TOKEN_FILENAME}. + # - TOKEN_VALUE + # The token value that needs to be served for validation. For DNS + # validation, this is what you want to put in the _acme-challenge + # TXT record. For HTTP validation it is the value that is expected + # be found in the $TOKEN_FILENAME file. + + # Simple example: Use nsupdate with local named + # printf 'server 127.0.0.1\nupdate add _acme-challenge.%s 300 IN TXT "%s"\nsend\n' "${DOMAIN}" "${TOKEN_VALUE}" | nsupdate -k /var/run/named/session.key +} + +clean_challenge() { + local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" + + # This hook is called after attempting to validate each domain, + # whether or not validation was successful. Here you can delete + # files or DNS records that are no longer needed. + # + # The parameters are the same as for deploy_challenge. + + # Simple example: Use nsupdate with local named + # printf 'server 127.0.0.1\nupdate delete _acme-challenge.%s TXT "%s"\nsend\n' "${DOMAIN}" "${TOKEN_VALUE}" | nsupdate -k /var/run/named/session.key +} + +sync_cert() { + local KEYFILE="${1}" CERTFILE="${2}" FULLCHAINFILE="${3}" CHAINFILE="${4}" REQUESTFILE="${5}" + + # This hook is called after the certificates have been created but before + # they are symlinked. This allows you to sync the files to disk to prevent + # creating a symlink to empty files on unexpected system crashes. + # + # This hook is not intended to be used for further processing of certificate + # files, see deploy_cert for that. + # + # Parameters: + # - KEYFILE + # The path of the file containing the private key. + # - CERTFILE + # The path of the file containing the signed certificate. + # - FULLCHAINFILE + # The path of the file containing the full certificate chain. + # - CHAINFILE + # The path of the file containing the intermediate certificate(s). + # - REQUESTFILE + # The path of the file containing the certificate signing request. + + # Simple example: sync the files before symlinking them + # sync "${KEYFILE}" "${CERTFILE}" "${FULLCHAINFILE}" "${CHAINFILE}" "${REQUESTFILE}" +} + +deploy_cert() { + local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}" + + # This hook is called once for each certificate that has been + # produced. Here you might, for instance, copy your new certificates + # to service-specific locations and reload the service. + # + # Parameters: + # - DOMAIN + # The primary domain name, i.e. the certificate common + # name (CN). + # - KEYFILE + # The path of the file containing the private key. + # - CERTFILE + # The path of the file containing the signed certificate. + # - FULLCHAINFILE + # The path of the file containing the full certificate chain. + # - CHAINFILE + # The path of the file containing the intermediate certificate(s). + # - TIMESTAMP + # Timestamp when the specified certificate was created. + + # Simple example: Copy file to nginx config + # cp "${KEYFILE}" "${FULLCHAINFILE}" /etc/nginx/ssl/; chown -R nginx: /etc/nginx/ssl + # systemctl reload nginx +} + +deploy_ocsp() { + local DOMAIN="${1}" OCSPFILE="${2}" TIMESTAMP="${3}" + + # This hook is called once for each updated ocsp stapling file that has + # been produced. Here you might, for instance, copy your new ocsp stapling + # files to service-specific locations and reload the service. + # + # Parameters: + # - DOMAIN + # The primary domain name, i.e. the certificate common + # name (CN). + # - OCSPFILE + # The path of the ocsp stapling file + # - TIMESTAMP + # Timestamp when the specified ocsp stapling file was created. + + # Simple example: Copy file to nginx config + # cp "${OCSPFILE}" /etc/nginx/ssl/; chown -R nginx: /etc/nginx/ssl + # systemctl reload nginx +} + + +unchanged_cert() { + local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" + + # This hook is called once for each certificate that is still + # valid and therefore wasn't reissued. + # + # Parameters: + # - DOMAIN + # The primary domain name, i.e. the certificate common + # name (CN). + # - KEYFILE + # The path of the file containing the private key. + # - CERTFILE + # The path of the file containing the signed certificate. + # - FULLCHAINFILE + # The path of the file containing the full certificate chain. + # - CHAINFILE + # The path of the file containing the intermediate certificate(s). +} + +invalid_challenge() { + local DOMAIN="${1}" RESPONSE="${2}" + + # This hook is called if the challenge response has failed, so domain + # owners can be aware and act accordingly. + # + # Parameters: + # - DOMAIN + # The primary domain name, i.e. the certificate common + # name (CN). + # - RESPONSE + # The response that the verification server returned + + # Simple example: Send mail to root + # printf "Subject: Validation of ${DOMAIN} failed!\n\nOh noez!" | sendmail root +} + +request_failure() { + local STATUSCODE="${1}" REASON="${2}" REQTYPE="${3}" HEADERS="${4}" + + # This hook is called when an HTTP request fails (e.g., when the ACME + # server is busy, returns an error, etc). It will be called upon any + # response code that does not start with '2'. Useful to alert admins + # about problems with requests. + # + # Parameters: + # - STATUSCODE + # The HTML status code that originated the error. + # - REASON + # The specified reason for the error. + # - REQTYPE + # The kind of request that was made (GET, POST...) + # - HEADERS + # HTTP headers returned by the CA + + # Simple example: Send mail to root + # printf "Subject: HTTP request failed failed!\n\nA http request failed with status ${STATUSCODE}!" | sendmail root +} + +generate_csr() { + local DOMAIN="${1}" CERTDIR="${2}" ALTNAMES="${3}" + + # This hook is called before any certificate signing operation takes place. + # It can be used to generate or fetch a certificate signing request with external + # tools. + # The output should be just the certificate signing request formatted as PEM. + # + # Parameters: + # - DOMAIN + # The primary domain as specified in domains.txt. This does not need to + # match with the domains in the CSR, it's basically just the directory name. + # - CERTDIR + # Certificate output directory for this particular certificate. Can be used + # for storing additional files. + # - ALTNAMES + # All domain names for the current certificate as specified in domains.txt. + # Again, this doesn't need to match with the CSR, it's just there for convenience. + + # Simple example: Look for pre-generated CSRs + # if [ -e "${CERTDIR}/pre-generated.csr" ]; then + # cat "${CERTDIR}/pre-generated.csr" + # fi +} + +startup_hook() { + # This hook is called before the cron command to do some initial tasks + # (e.g. starting a webserver). + + : +} + +exit_hook() { + local ERROR="${1:-}" + + # This hook is called at the end of the cron command and can be used to + # do some final (cleanup or other) tasks. + # + # Parameters: + # - ERROR + # Contains error message if dehydrated exits with error +} + +HANDLER="$1"; shift +if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge|sync_cert|deploy_cert|deploy_ocsp|unchanged_cert|invalid_challenge|request_failure|generate_csr|startup_hook|exit_hook)$ ]]; then + "$HANDLER" "$@" +fi diff --git a/docs/hook_chain.md b/docs/hook_chain.md new file mode 100644 index 0000000..a4f7a5b --- /dev/null +++ b/docs/hook_chain.md @@ -0,0 +1,62 @@ +# HOOK_CHAIN + +If you want to deploy (and clean) all challenges for a single certificate in one hook call you can use `HOOK_CHAIN=yes` in your config. + +Calls to your hook script change in a way that instead of having only X parameters on deploy_challenge and clean_challenge it will have Y*X parameters, +where Y is the number of domains in your cert, and you'll have to iterate over a set of X parameters at a time in your hook script. + +See below for an example on how the calls change: + +### HOOK_CHAIN="no" (default behaviour) +``` +# INFO: Using main config file /etc/dehydrated/config +Processing lukas.im with alternative names: www.lukas.im + + Checking domain name(s) of existing cert... unchanged. + + Checking expire date of existing cert... + + Valid till Jul 7 20:54:00 2016 GMT (Longer than 30 days). Ignoring because renew was forced! + + Signing domains... + + Generating private key... + + Generating signing request... + + Requesting challenge for lukas.im... + + Requesting challenge for www.lukas.im... +HOOK: deploy_challenge lukas.im blablabla blablabla.supersecure + + Responding to challenge for lukas.im... +HOOK: clean_challenge lukas.im blablabla blablabla.supersecure + + Challenge is valid! +HOOK: deploy_challenge www.lukas.im blublublu blublublu.supersecure + + Responding to challenge for www.lukas.im... +HOOK: clean_challenge www.lukas.im blublublu blublublu.supersecure + + Challenge is valid! + + Requesting certificate... + + Checking certificate... + + Done! + + Creating fullchain.pem... +HOOK: deploy_cert lukas.im /etc/dehydrated/certs/lukas.im/privkey.pem /etc/dehydrated/certs/lukas.im/cert.pem /etc/dehydrated/certs/lukas.im/fullchain.pem /etc/dehydrated/certs/lukas.im/chain.pem 1460152442 + + Done! +``` + +### HOOK_CHAIN="yes" +``` +# INFO: Using main config file /etc/dehydrated/config +Processing lukas.im with alternative names: www.lukas.im + + Checking domain name(s) of existing cert... unchanged. + + Checking expire date of existing cert... + + Valid till Jul 7 20:52:00 2016 GMT (Longer than 30 days). Ignoring because renew was forced! + + Signing domains... + + Generating private key... + + Generating signing request... + + Requesting challenge for lukas.im... + + Requesting challenge for www.lukas.im... +HOOK: deploy_challenge lukas.im blablabla blablabla.supersecure www.lukas.im blublublu blublublu.supersecure + + Responding to challenge for lukas.im... + + Challenge is valid! + + Responding to challenge for www.lukas.im... + + Challenge is valid! +HOOK: clean_challenge lukas.im blablabla blablabla.supersecure www.lukas.im blublublu blublublu.supersecure + + Requesting certificate... + + Checking certificate... + + Done! + + Creating fullchain.pem... +HOOK: deploy_cert lukas.im /etc/dehydrated/certs/lukas.im/privkey.pem /etc/dehydrated/certs/lukas.im/cert.pem /etc/dehydrated/certs/lukas.im/fullchain.pem /etc/dehydrated/certs/lukas.im/chain.pem 1460152408 + + Done! +``` diff --git a/docs/logo.png b/docs/logo.png Binary files differnew file mode 100644 index 0000000..a40bfe8 --- /dev/null +++ b/docs/logo.png diff --git a/docs/man/dehydrated.1 b/docs/man/dehydrated.1 new file mode 100644 index 0000000..d866fec --- /dev/null +++ b/docs/man/dehydrated.1 @@ -0,0 +1,155 @@ +.TH DEHYDRATED 1 2018-01-13 "Dehydrated ACME Client" +.SH NAME +dehydrated \- ACME client implemented as a shell-script +.SH SYNOPSIS +.B dehydrated +[\fBcommand\fR [\fBargument\fR]] +[\fBargument\fR [\fBargument\fR]] +.IR ... +.SH DESCRIPTION +A client for ACME-based Certificate Authorities, such as LetsEncrypt. It can +be used to request and obtain TLS certificates from an ACME-based +certificate authority. + +Before any certificates can be requested, Dehydrated needs +to acquire an account with the Certificate Authorities. Optionally, an email +address can be provided. It will be used to e.g. notify about expiring +certificates. You will usually need to accept the Terms of Service of the CA. +Dehydrated will notify if no account is configured. Run with \fB--register +--accept-terms\fR to create a new account. + +Next, all domain names must be provided in domains.txt. The format is line +based: If the file contains two lines "example.com" and "example.net", +Dehydrated will request two certificate, one for "example.com" and the other +for "example.net". A single line while "example.com example.net" will request a +single certificate valid for both "example.net" and "example.com" through the \fISubject +Alternative Name\fR (SAN) field. + +For the next step, one way of verifying domain name ownership needs to be +configured. Dehydrated implements \fIhttp-01\fR and \fIdns-01\fR verification. + +The \fIhttp-01\fR verification provides proof of ownership by providing a +challenge token. In order to do that, the directory referenced in the +\fIWELLKNOWN\fR config variable needs to be exposed at +\fIhttp://{domain}/.well-known/acme-challenge/\fR, where {domain} is every +domain name specified in \fIdomains.txt\fR. Dehydrated does not provide its +own challenge responder, but relies on an existing web server to provide the +challenge response. See \fIwellknown.md\fR for configuration examples of +popular web servers. + +The \fIdns-01\fR verification works by providing a challenge token through DNS. +This is especially interesting for hosts that cannot be exposed to the public +Internet. Because adding records to DNS zones is oftentimes highly specific to +the software or the DNS provider at hand, there are many third party hooks +available for dehydrated. See \fIdns-verification.md\fR for hooks for popular +DNS servers and DNS hosters. + +Finally, the certificates need to be requested and updated on a regular basis. +This can happen through a cron job or a timer. Initially, you may enforce this +by invoking \fIdehydrated -c\fR manually. + +After a successful run, certificates are stored in +\fI/etc/dehydrated/certs/{domain}\fR, where {domain} is the domain name in the +first column of \fIdomains.txt\fR. + +.SH OPTIONS + +.BR Commands +.TP +.BR \-\-version ", " \-v +Print version information +.TP +.BR \-\-register +Register account key +.TP +.BR \-\-account +Update account contact information +.TP +.BR \-\-cron ", " \-c +Sign/renew non\-existent/changed/expiring certificates. +.TP +.BR \-\-signcsr ", " \-s " " \fIpath/to/csr.pem\fR +Sign a given CSR, output CRT on stdout (advanced usage) +.TP +.BR \-\-revoke ", " \-r " " \fIpath/to/cert.pem\fR +Revoke specified certificate +.TP +.BR \-\-cleanup ", " \-gc +Move unused certificate files to archive directory +.TP +.BR \-\-help ", " \-h +Show help text +.TP +.BR \-\-env ", " \-e +Output configuration variables for use in other scripts + +.PP +.BR Parameters +.TP +.BR \-\-accept\-terms +Accept CAs terms of service +.TP +.BR \-\-full\-chain ", " \-fc +Print full chain when using \fB\-\-signcsr\fR +.TP +.BR \-\-ipv4 ", " \-4 +Resolve names to IPv4 addresses only +.TP +.BR \-\-ipv6 ", " \-6 +Resolve names to IPv6 addresses only +.TP +.BR \-\-domain ", " \-d " " \fIdomain.tld\fR +Use specified domain name(s) instead of domains.txt entry (one certificate!) +.TP +.BR \-\-keep\-going ", " \-g +Keep going after encountering an error while creating/renewing multiple +certificates in cron mode +.TP +.BR \-\-force ", " \-x +Force renew of certificate even if it is longer valid than value in RENEW_DAYS +.TP +.BR \-\-no\-lock ", " \-n +Don't use lockfile (potentially dangerous!) +.TP +.BR \-\-lock\-suffix " " \fIexample.com\fR +Suffix lockfile name with a string (useful for use with \-d) +.TP +.BR \-\-ocsp +Sets option in CSR indicating OCSP stapling to be mandatory +.TP +.BR \-\-privkey ", " \-p " " \fIpath/to/key.pem\fR +Use specified private key instead of account key (useful for revocation) +.TP +.BR \-\-config ", " \-f " " \fIpath/to/config\fR +Use specified config file +.TP +.BR \-\-hook ", " \-k " " \fIpath/to/hook.sh\fR +Use specified script for hooks +.TP +.BR \-\-out ", " \-o " " \fIcerts/directory\fR +Output certificates into the specified directory +.TP +.BR \-\-challenge ", " \-t " " \fI[http\-01|dns\-01]\fR +Which challenge should be used? Currently http\-01 and dns\-01 are supported +.TP +.BR \-\-algo ", " \-a " " \fI[rsa|prime256v1|secp384r1]\fR +Which public key algorithm should be used? Supported: rsa, prime256v1 and +secp384r1 +.SH DIAGNOSTICS +The program exits 0 if everything was fine, 1 if an error occurred. +.SH BUGS +Please report any bugs that you may encounter at the project web site +.UR https://github.com/lukas2511/dehydrated/issues +.UE . +.SH AUTHOR +Dehydrated was written by Lukas Schauer. This man page was contributed by +Daniel Molkentin. +.SH COPYRIGHT +Copyright 2015-2018 by Lukas Schauer and the respective contributors. +Provided under the MIT License. See the LICENSE file that accompanies the +distribution for licensing information. +.SH SEE ALSO +Full documentation along with configuration examples are provided in the \fIdocs\fR +directory of the distribution, or at +.UR https://github.com/lukas2511/dehydrated/tree/master/docs +.UE . diff --git a/docs/per-certificate-config.md b/docs/per-certificate-config.md new file mode 100644 index 0000000..3dd34dc --- /dev/null +++ b/docs/per-certificate-config.md @@ -0,0 +1,29 @@ +# Config on per-certificate base + +dehydrated allows a few configuration variables to be set on a per-certificate base. + +To use this feature create a `config` file in the certificates output directory (e.g. `certs/example.org/config`). + +Currently supported options: + +- PRIVATE_KEY_RENEW +- PRIVATE_KEY_ROLLOVER +- KEY_ALGO +- KEYSIZE +- OCSP_MUST_STAPLE +- OCSP_FETCH +- OCSP_DAYS +- CHALLENGETYPE +- HOOK +- HOOK_CHAIN +- WELLKNOWN +- OPENSSL_CNF +- RENEW_DAYS +- PREFERRED_CHAIN + +## DOMAINS_D + +If `DOMAINS_D` is set, dehydrated will use it for your per-certificate configurations. +Instead of `certs/example.org/config` it will look for a configuration under `DOMAINS_D/example.org`. + +If an alias is set, it will be used instead of the primary domain name. diff --git a/docs/staging.md b/docs/staging.md new file mode 100644 index 0000000..07c675b --- /dev/null +++ b/docs/staging.md @@ -0,0 +1,14 @@ +# Staging + +Let’s Encrypt has stringent rate limits in place. + +If you start testing using the production endpoint (which is the default), +you will quickly hit these limits and find yourself locked out. + +To avoid this, please set the CA property to the Let’s Encrypt staging server URL in your config file: + +```bash +CA="https://acme-staging-v02.api.letsencrypt.org/directory" +``` + +Alternatively you can define the CA using the CLI argument `--ca letsencrypt-test` (`letsencrypt-test` is an integrated preset-CA corresponding to the URL above). diff --git a/docs/tls-alpn.md b/docs/tls-alpn.md new file mode 100644 index 0000000..fc19698 --- /dev/null +++ b/docs/tls-alpn.md @@ -0,0 +1,124 @@ +# TLS-ALPN-01 + +With `tls-alpn-01`-type verification Let's Encrypt (or the ACME-protocol in general) is checking if you are in control of a domain by accessing +your webserver using a custom ALPN and expecting a specially crafted TLS certificate containing a verification token. +It will do that for any (sub-)domain you want to sign a certificate for. + +Dehydrated generates the required verification certificates, but the delivery is out of its scope. + +### Example lighttpd config + +lighttpd can be configured to recognize ALPN `acme-tls/1` and to respond to such +requests using the specially crafted TLS certificates generated by dehydrated. +Configure lighttpd and dehydrated to use the same path for these certificates. +(Be sure to allow read access to the user account under which the lighttpd +server is running.) `mkdir -p /etc/dehydrated/alpn-certs` + +lighttpd.conf: +``` +ssl.acme-tls-1 = "/etc/dehydrated/alpn-certs" +``` + +When renewing certificates, specify `-t tls-alpn-01` and `--alpn /etc/dehydrated/alpn-certs` to dehydrated, e.g. +``` +dehydrated -t tls-alpn-01 --alpn /etc/dehydrated/alpn-certs -c --out /etc/lighttpd/certs -d www.example.com +# gracefully reload lighttpd to use the new certificates by sending lighttpd pid SIGUSR1 +systemctl reload lighttpd +``` + +### Example nginx config + +On an nginx tcp load-balancer you can use the `ssl_preread` module to map a different port for acme-tls +requests than for e.g. HTTP/2 or HTTP/1.1 requests. + +Your config should look something like this: + +```nginx +stream { + map $ssl_preread_alpn_protocols $tls_port { + ~\bacme-tls/1\b 10443; + default 443; + } + + server { + listen 443; + listen [::]:443; + proxy_pass 10.13.37.42:$tls_port; + ssl_preread on; + } +} +``` + +That way https requests are forwarded to port 443 on the backend server, and acme-tls/1 requests are +forwarded to port 10443. + +In the future nginx might support internal routing based on custom ALPNs, but for now you'll have to +use a custom responder for the alpn verification certificates (see below). + +### Example responder + +I hacked together a simple responder in Python, it might not be the best, but it works for me: + +```python +#!/usr/bin/env python3 + +import ssl +import socketserver +import threading +import re +import os + +ALPNDIR="/etc/dehydrated/alpn-certs" +PROXY_PROTOCOL=False + +FALLBACK_CERTIFICATE="/etc/ssl/certs/ssl-cert-snakeoil.pem" +FALLBACK_KEY="/etc/ssl/private/ssl-cert-snakeoil.key" + +class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + pass + +class ThreadedTCPRequestHandler(socketserver.BaseRequestHandler): + def create_context(self, certfile, keyfile, first=False): + ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_context.set_ciphers('ECDHE+AESGCM') + ssl_context.set_alpn_protocols(["acme-tls/1"]) + ssl_context.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 + if first: + ssl_context.set_servername_callback(self.load_certificate) + ssl_context.load_cert_chain(certfile=certfile, keyfile=keyfile) + return ssl_context + + def load_certificate(self, sslsocket, sni_name, sslcontext): + print("Got request for %s" % sni_name) + if not re.match(r'^(([a-zA-Z]{1})|([a-zA-Z]{1}[a-zA-Z]{1})|([a-zA-Z]{1}[0-9]{1})|([0-9]{1}[a-zA-Z]{1})|([a-zA-Z0-9][-_.a-zA-Z0-9]{0,61}[a-zA-Z0-9]))\.([a-zA-Z]{2,13}|[a-zA-Z0-9-]{2,30}.[a-zA-Z]{2,3})$', sni_name): + return + + certfile = os.path.join(ALPNDIR, "%s.crt.pem" % sni_name) + keyfile = os.path.join(ALPNDIR, "%s.key.pem" % sni_name) + + if not os.path.exists(certfile) or not os.path.exists(keyfile): + return + + sslsocket.context = self.create_context(certfile, keyfile) + + def handle(self): + if PROXY_PROTOCOL: + buf = b"" + while b"\r\n" not in buf: + buf += self.request.recv(1) + + ssl_context = self.create_context(FALLBACK_CERTIFICATE, FALLBACK_KEY, True) + newsock = ssl_context.wrap_socket(self.request, server_side=True) + +if __name__ == "__main__": + HOST, PORT = "0.0.0.0", 10443 + + server = ThreadedTCPServer((HOST, PORT), ThreadedTCPRequestHandler, bind_and_activate=False) + server.allow_reuse_address = True + try: + server.server_bind() + server.server_activate() + server.serve_forever() + except: + server.shutdown() +``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..12b2ff5 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,52 @@ +# Troubleshooting + +Generally if the following information doesn't provide a solution to your problem please take a look at existing issues (search for keywords) before creating a new one. + +## "No registration exists matching provided key" + +You probably changed from staging-CA to production-CA (or the other way). + +Currently dehydrated doesn't detect a missing registration on the selected CA, +the current workaround is to move `private_key.pem` (and, if you care, `private_key.json`) out of the way so the scripts generates and registers a new one. + +This will hopefully be fixed in the future. + +## "Error creating new cert :: Too many certificates already issued for: [...]" + +This is not an issue with dehydrated but an API limit with boulder (the ACME server). + +At the time of writing this you can only create 5 certificates per domain in a sliding window of 7 days. + +## "Certificate request has 123 names, maximum is 100." + +This also is an API limit from boulder, you are requesting to sign a certificate with way too many domains. + +## Invalid challenges + +There are a few factors that could result in invalid challenges. + +If you are using HTTP validation make sure that the path you have configured with WELLKNOWN is readable under your domain. + +To test this create a file (e.g. `test.txt`) in that directory and try opening it with your browser: `http://example.org/.well-known/acme-challenge/test.txt`. Note that if you have an IPv6 address, the challenge connection will be on IPv6. Be sure that you test HTTP connections on both IPv4 and IPv6. Checking the test file in your browser is often not sufficient because the browser just fails over to IPv4. + +If you get any error you'll have to fix your web server configuration. + +## DNS invalid challenge since dehydrated 0.6.0 / Why are DNS challenges deployed first and verified later? + +Since Let's Encrypt (and in general the ACMEv2 protocol) now supports wildcard domains there is a situation where DNS caching can become a problem. +If somebody wants to validate a certificate with `example.org` and `*.example.org` there are two tokens that have to be deployed on `_acme-challenge.example.org`. + +If dehydrated would deploy and verify each token on its own the CA would cache the first token on `_acme-challenge.example.org` and the next challenge would simply fail. +Let's Encrypt uses your DNS TTL with a max limit of 5 minutes, but this doesn't seem to be part of the ACME protocol, just some LE specific configuration, +so with other CAs and certain DNS providers who don't allow low TTLs this could potentially take hours. + +Since dehydrated now deploys all challenges first that no longer is a problem. The CA will query and cache both challenges, and both authorizations can be validated. +Some hook-scripts were written in a way that erases the old TXT record rather than adding a new entry, those should be (and many of them already have been) fixed. + +There are certain DNS providers which really only allow one TXT record on a domain. This is really odd and you should probably contact your DNS provider and ask them +to fix this. + +If for whatever reason you can't switch DNS providers and your DNS provider only supports one TXT record and doesn't want to fix that you could try splitting your +certificate into multiple certificates and add a sleep in the `deploy_cert` hook. +If you can't do that or really don't want to please leave a comment on https://github.com/lukas2511/dehydrated/issues/554, +if many people are having this unfixable problem I might try to implement a workaround. diff --git a/docs/wellknown.md b/docs/wellknown.md new file mode 100644 index 0000000..f5a9305 --- /dev/null +++ b/docs/wellknown.md @@ -0,0 +1,78 @@ +# WELLKNOWN + +With `http-01`-type verification (default in this script, there is also support for [dns based verification](dns-verification.md)) Let's Encrypt (or the ACME-protocol in general) is checking if you are in control of a domain by accessing a verification file on an URL similar to `http://example.org/.well-known/acme-challenge/m4g1C-t0k3n`. +It will do that for any (sub-)domain you want to sign a certificate for. + +At the moment you'll need to have that location available over normal HTTP on port 80 (redirect to HTTPS will work, but starting point is always HTTP!). + +dehydrated has a config variable called `WELLKNOWN`, which corresponds to the directory which should be served under `/.well-known/acme-challenge` on your domain. So in the above example the token would have been saved as `$WELLKNOWN/m4g1C-t0k3n`. + +If you only have one docroot on your server you could easily do something like `WELLKNOWN=/var/www/.well-known/acme-challenge`, for anything else look at the example below. + +## Example Usage + +If you have more than one docroot (or you are using your server as a reverse proxy / load balancer) the simple configuration mentioned above wouldn't work, but with just a few lines of webserver configuration this can be solved. + +An example would be to create a directory `/var/www/dehydrated` and set `WELLKNOWN=/var/www/dehydrated` in the scripts config. + +You'll need to configure aliases on your Webserver: + +### Nginx example config + +With Nginx you'll need to add this to any of your `server`/VHost config blocks: + +```nginx +server { + [...] + location ^~ /.well-known/acme-challenge { + alias /var/www/dehydrated; + } + [...] +} +``` + +### Apache example config + +With Apache just add this to your config and it should work in any VHost: + +```apache +Alias /.well-known/acme-challenge /var/www/dehydrated + +<Directory /var/www/dehydrated> + Options None + AllowOverride None + + # Apache 2.x + <IfModule !mod_authz_core.c> + Order allow,deny + Allow from all + </IfModule> + + # Apache 2.4 + <IfModule mod_authz_core.c> + Require all granted + </IfModule> +</Directory> +``` + +### Lighttpd example config + +With Lighttpd just add this to your config and it should work in any VHost: + +```lighttpd +server.modules += ("alias") +alias.url += ( + "/.well-known/acme-challenge/" => "/var/www/dehydrated/", +) +``` + + +### Hiawatha example config + +With Hiawatha just add an alias to your config file for each VirtualHost and it should work: +```hiawatha +VirtualHost { + Hostname = example.tld subdomain.mywebsite.tld + Alias = /.well-known/acme-challenge:/var/www/dehydrated +} +``` |