diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..8c13934 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "dehydrated"] + path = dehydrated + url = https://github.com/lukas2511/dehydrated +[submodule "utm-update-certificate"] + path = utm-update-certificate + url = https://github.com/mbunkus/utm-update-certificate.git diff --git a/README.md b/README.md new file mode 100644 index 0000000..23d65a3 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# letsencrypt-sophosutm-dns +Let's Encrypt ssl cert management via Dehydrated with tsig dns-01 verification and Sophos UTM update hooks. +## Disclaimer +USE AT YOUR OWN RISK! + +This package is not meant to be used on production servers or by inexperienced users. I assume no liability if something goes wrong while you use this package. I am not responsible for any damages you may incur using these scripts. I suggest you read through the scripts dehydrated, hook.sh, and utm-update-certificate.pl to know what they are doing. +## Contents +- [Description](#description) +- [Usage](#usage) + - [Setup](#setup) + - [Automate](#automate) + - [Notes](#notes) +- [Dependencies](#dependencies) +- [Contributing](#contributing) + - [Development Setup](#development-setup) +## Description +This package is setup to provide an automated way to keep updated Let's Encrypt ssl certs on your UTM without dealing with SSH key's, SCP file transfers, etc. Everything happens on the UTM and stays on the UTM. It will work well in scenarios where you intend to perform SSL termination at the UTM WAF and intend to use DNS-01 acme-challenge verifications of your domains. Some modifications have been made to Dehydrated and the hooks to ensure things work properly when running in the UTM environment. +## Usage +### UTM Environment +- You need to ensure you have the Let's Encrypt intermediate verification CA imported in your UTM. It can be found [here](https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem). +### Setup +1. SSH into your UTM shell: `ssh -l loginuser utm.domain.local` +2. Become root: `su`, enter root password +3. Change directory to root home or wherever you intend to host this package: `cd ~` +4. Grab the package: `wget https://github.com/kyse/letsencrypt-sophosutm-dns/raw/develop/dist/leutmdns.tar.gz` +5. Unzip the package: `tar -xzvf leutmdns.tar.gz` +6. Edit ~/leutmdns/config: `vi ~/leutmdns/config` + - To start with, ensure you are using the LE staging servers until you've tested everything. Then switch the commeted lines for CA and CA_TERMS. + - Update CONTACT_EMAIL to your LE account email. +7. Edit ~/leutmdns/hook.sh: `vi ~/leutmdns/hook.sh` + - Update SERVER to your dns tsig update endpoint. + - If your UTM is behind a split brain DNS, uncomment EXTERNALNS to point to a name server on the outside. This will allow the script to ensure external name servers have received the updated TXT challenge records before asking LE to validate. +8. Edit ~/leutmdns/domains.txt: `vi ~/leutmdns/domains.txt` + - Standard Dehydrated proecdure here... enter primary domain with any additional SAN domains space seperated. 1 line per certificatee. +9. Create tsig key files in the ~/leutmdns/tsig/ folder. + - File name format: K_acme-challenge.zone.tld.+157+random.private - zone.tld = your DNS zone your updating, no need for 1 file per FQDN, just the zone being targeted for that FQDN. Random can be anything. + - File content format (the keyname and secret will come from your DNS provider): + ``` + key "keyname" { + algorithm hmac-md5; + secret "secret"; + }; + ``` +10. Create ref files in the ~/leutmdns/refs/ folder. + - First, you'll need to ensure you have existing certificates created that you want to target for updates from the LE cert renewals. + ``` + cc + OBJS + ca + host_key_cert + tab tab (hit it twice to list existing REF_* for each cert). + exit + ``` + - Create a file named after the primary domain (first domein on each line of ~/leutmdns/domains.txt). If your domains.txt file contains domain.com www.domain.com on line 1, and www.domain.net www2.domain.net on line 2: + ```bash + cd ~/leutmdns/refs + echo REF_123456789 >> domain.com + echo REF_987654321 >> www.domain.net + ``` +11. Register an account. + - `./dehydrated --register --accept-terms` +12. Run a test! + - Again ensure you're targeting the staging LE servers. + - Probably a good idea not to target any active certs in the UTM, so create a fake one to test with. + - Kick off the proces (in ~/leutmdns folder): `./dehydrated -c` +13. Update domains.txt, REF_ files, and switch staging urls to prod urls in the config file and go live with it. +### Automate +TODO: There's bound to be a better way to achieve this. Research and update this section. Also ned to update output, possibly figure out how to get it to email output & errors through the UTM notification system. +1. Add a link to dehydrated to your bin path: `ln -s /root/leutmdns/dehydrated /usr/local/bin/dehydrated` +2. Add a line to the bottom of your /etc/crontab-static file: `@monthly root /usr/local/bin/dehydrated -c` +3. Make a change in the UTM web admin site to get the crontab file updated. + - In web admin site, click the management menu item. + - Select up2date sub menu item. + - Select the configuration tab. + - Change one of the dropdowns to a different value, save, then change back to your desired value and save again. +4. Confirm /etc/crontab contains the new entry. +### Notes +- UTM uses a customized openssl.cnf file in /etc/ssl that doesn't work well unless provided proper ENV variables. Dehydrated stock script didn't provide the --cert flag during the certificate request which caused openssl to try and load up the UTM openssl.cnf file. I've updated the dehydrated script on line 619 to include the flag to the openssl.cnf file path provided in the ~/leutmdns/config file to resolve. +- Ensure you have a file for each DNS zone you will be updating using the proper naming scheme in the tsig folder. +- Ensure you have a file for each certificate named after the domain (the first domain per line/cert in domains.txt file) containing the REF_* to your UTM certificate object. +## Dependencies +### SubModules +Making use of the following submodule dependencies so as not to reinvent the wheel: +- [Dehydrated](https://github.com/lukas2511/dehydrated) - Modified +- [utm-update-certificate](https://github.com/mbunkus/utm-update-certificate.git) +### Other Imports +Also directly imported and modified the followng: +- [Dehydrated Hook Example](https://ente.limmat.ch/ftp/pub/software/bash/letsencrypt/letsencrypt_acme_dns-01_challenge_hook) - Modified +## Contributing +### Development Setup +Download the git repo to your local environment and load the submodules. +```bash +git clone --recursive https://github.com/kyse/letsencrypt-sophosutm-dns.git leutmdns +``` + +To get a new .tar.gz package built in the dist folder, just run build.sh. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..2177d2c --- /dev/null +++ b/build.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +# build script for letsencrypt-sophosutm-dns + +set -e +set -u +set -o pipefail +[[ -n "${ZSH_VERSION:-}" ]] && set -o SH_WORD_SPLIT && set +o FUNCTION_ARGZERO + +umask 077 + +# 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}" +BUILDDIR="build" +DISTDIR="dist" +SRCDIR="src" + +PROJNAME="leutmdns" + +_exiterr() { + echo "ERROR: ${1}" >&2 + exit 1 +} + +command_clean() { + [[ ! -d ${BASEDIR}/${BUILDDIR} ]] || rm -r ${BASEDIR}/${BUILDDIR} || _exiterr "Unable to clean ${BASEDIR}/${BUILDDIR} directory." + [[ ! -d ${BASEDIR}/${PROJNAME} ]] || rm -r ${BASEDIR}/${PROJNAME} || _exiterror "Unable to clean ${BASEDIR}/${PROJNAME} directory." +} + +command_build() { + command_clean + + git submodule update --init --recursive + + mkdir -p ${BASEDIR}/${BUILDDIR}/{accounts,certs,refs,tsig} || _exiterr "Unable to create ${BASEDIR}/${BUILDDIR} directory." + cp -t ${BASEDIR}/${BUILDDIR}/ ${BASEDIR}/dehydrated/docs/examples/{config,domains.txt} ${BASEDIR}/utm-update-certificate/utm_update_certificate.pl || _exiterr "An error occured copying submodule files. Make sure you run the command 'git submodule update --init --recursive' before building." + cp -t ${BASEDIR}/${BUILDDIR}/ ${BASEDIR}/${SRCDIR}/{dehydrated,hook.sh,openssl.cnf} || _exiterr "An error occured copying src files." + + # Create the package + [[ -d ${BASEDIR}/${DISTDIR} ]] || mkdir -p ${BASEDIR}/${DISTDIR} || _exiterr "Unable to create ${BASEDIR}/${DISTDIR} directry." + [[ ! -f ${BASEDIR}/${DISTDIR}/leutmdns.tar.gz ]] || rm ${BASEDIR}/${DISTDIR}/${PROJNAME}.tar.gz + printf "Creating distribution package: ${PROJNAME}.tar.gz\nLocation: ${BASEDIR}/${DISTDIR}/" + tar -czvf ${BASEDIR}/${DISTDIR}/${PROJNAME}.tar.gz -C ${BASEDIR} --transform "s/^${BUILDDIR}/${PROJNAME}/" ${BUILDDIR} + + command_clean +} + +main() { + COMMAND="" + set_command() { + [[ -z "${COMMAND}" ]] || _exiterr "Only one command can be executed at a time. See help for more information." + COMMAND="${1}" + } + + while (( ${#} )); do + case "${1}" in + build|-b) + set_command build + ;; + + clean|-c) + set_command clean + ;; + + *) + set_command build + ;; + esac + + shift 1 + done + + case "${COMMAND}" in + clean) echo "Cleaning build."; command_clean;; + *) command_build;; + esac +} + +main "${@:-}" diff --git a/dehydrated b/dehydrated new file mode 160000 index 0000000..1163864 --- /dev/null +++ b/dehydrated @@ -0,0 +1 @@ +Subproject commit 116386486b3749e4c5e1b4da35904f30f8b2749b diff --git a/dist/leutmdns.tar.gz b/dist/leutmdns.tar.gz new file mode 100644 index 0000000..b678390 Binary files /dev/null and b/dist/leutmdns.tar.gz differ diff --git a/src/config b/src/config new file mode 100644 index 0000000..ec2de19 --- /dev/null +++ b/src/config @@ -0,0 +1,94 @@ +######################################################## +# 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 # +######################################################## + +# Resolve names to addresses of IP version only. (curl) +# supported values: 4, 6 +# default: <unset> +#IP_VERSION= + +# Path to certificate authority (default: https://acme-v01.api.letsencrypt.org/directory) +#CA="https://acme-v01.api.letsencrypt.org/directory" +CA="https://acme-staging.api.letsencrypt.org/directory" + +# Path to certificate authority license terms redirect (default: https://acme-v01.api.letsencrypt.org/terms) +#CA_TERMS="https://acme-v01.api.letsencrypt.org/terms" +CA_TERMS="https://acme-staging.api.letsencrypt.org/terms" + +# Path to license agreement (default: <unset>) +#LICENSE="" + +# Which challenge should be used? Currently http-01 and dns-01 are supported +#CHALLENGETYPE="http-01" +CHALLENGETYPE="dns-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= + +# 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" + +# 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=${BASEDIR}/openssl.cnf + +# 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=${BASEDIR}/hook.sh + +# 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=rsa + +# E-mail to use during the registration (default: <unset>) +#CONTACT_EMAIL=email@email.com + +# 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" diff --git a/src/dehydrated b/src/dehydrated new file mode 100755 index 0000000..3712ce6 --- /dev/null +++ b/src/dehydrated @@ -0,0 +1,1273 @@ +#!/usr/bin/env bash + +# This script has been modified 6/4/2017 by Jared Fisher - kyse@kyse.us +# - Updated request certificate call to openssl to use specified openssl.cnf file to prevent erroring against Sophos UTM 9.5's cnf. + +# dehydrated by lukas2511 +# Source: https://github.com/lukas2511/dehydrated +# +# 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 +umask 077 # paranoid umask, we're creating private keys + +# 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}" + +# Create (identifiable) temporary files +_mktemp() { + # shellcheck disable=SC2068 + mktemp ${@:-} "${TMPDIR:-/tmp}/dehydrated-XXXXXX" +} + +# Check for script dependencies +check_dependencies() { + # just execute some dummy and/or version commands to see if required tools exist and 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." + command -v grep > /dev/null 2>&1 || _exiterr "This script requires grep." + command -v mktemp > /dev/null 2>&1 || _exiterr "This script requires mktemp." + command -v diff > /dev/null 2>&1 || _exiterr "This script requires diff." + + # curl returns with an error code in some ancient versions so we have to catch that + set +e + curl -V > /dev/null 2>&1 + retcode="$?" + set -e + if [[ ! "${retcode}" = "0" ]] && [[ ! "${retcode}" = "2" ]]; then + _exiterr "This script requires curl." + fi +} + +store_configvars() { + __KEY_ALGO="${KEY_ALGO}" + __OCSP_MUST_STAPLE="${OCSP_MUST_STAPLE}" + __PRIVATE_KEY_RENEW="${PRIVATE_KEY_RENEW}" + __KEYSIZE="${KEYSIZE}" + __CHALLENGETYPE="${CHALLENGETYPE}" + __HOOK="${HOOK}" + __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}" + PRIVATE_KEY_RENEW="${__PRIVATE_KEY_RENEW}" + KEYSIZE="${__KEYSIZE}" + CHALLENGETYPE="${__CHALLENGETYPE}" + HOOK="${__HOOK}" + WELLKNOWN="${__WELLKNOWN}" + HOOK_CHAIN="${__HOOK_CHAIN}" + OPENSSL_CNF="${__OPENSSL_CNF}" + RENEW_DAYS="${__RENEW_DAYS}" + IP_VERSION="${__IP_VERSION}" +} + +# verify configuration values +verify_config() { + [[ "${CHALLENGETYPE}" =~ (http-01|dns-01) ]] || _exiterr "Unknown challenge type ${CHALLENGETYPE}... can not continue." + if [[ "${CHALLENGETYPE}" = "dns-01" ]] && [[ -z "${HOOK}" ]]; then + _exiterr "Challenge type dns-01 needs a hook script for deployment... can not 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|prime256v1|secp384r1)$ ]] || _exiterr "Unknown public key algorithm ${KEY_ALGO}... can not continue." + if [[ -n "${IP_VERSION}" ]]; then + [[ "${IP_VERSION}" = "4" || "${IP_VERSION}" = "6" ]] || _exiterr "Unknown IP version ${IP_VERSION}... can not continue." + fi +} + +# 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 + + # Default values + CA="https://acme-v01.api.letsencrypt.org/directory" + CA_TERMS="https://acme-v01.api.letsencrypt.org/terms" + LICENSE= + CERTDIR= + ACCOUNTDIR= + CHALLENGETYPE="http-01" + CONFIG_D= + DOMAINS_D= + DOMAINS_TXT= + HOOK= + HOOK_CHAIN="no" + RENEW_DAYS="30" + KEYSIZE="4096" + WELLKNOWN= + PRIVATE_KEY_RENEW="yes" + PRIVATE_KEY_ROLLOVER="no" + KEY_ALGO=rsa + OPENSSL_CNF="$(openssl version -d | cut -d\" -f2)/openssl.cnf" + CONTACT_EMAIL= + LOCKFILE= + OCSP_MUST_STAPLE="no" + IP_VERSION= + + 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." >&2 + fi + + for check_config_d in "${CONFIG_D}"/*.sh; do + if [[ ! -e "${check_config_d}" ]]; then + echo "# !! WARNING !! Extra configuration directory ${CONFIG_D} exists, but no configuration found in it." >&2 + break + elif [[ -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." >&2 + fi + done + fi + + # Remove slash from end of BASEDIR. Mostly for cleaner outputs, doesn't change functionality. + BASEDIR="${BASEDIR%%/}" + + # Check BASEDIR and set default variables + [[ -d "${BASEDIR}" ]] || _exiterr "BASEDIR does not exist: ${BASEDIR}" + + CAHASH="$(echo "${CA}" | urlbase64)" + [[ -z "${ACCOUNTDIR}" ]] && ACCOUNTDIR="${BASEDIR}/accounts" + mkdir -p "${ACCOUNTDIR}/${CAHASH}" + [[ -f "${ACCOUNTDIR}/${CAHASH}/config" ]] && . "${ACCOUNTDIR}/${CAHASH}/config" + ACCOUNT_KEY="${ACCOUNTDIR}/${CAHASH}/account_key.pem" + ACCOUNT_KEY_JSON="${ACCOUNTDIR}/${CAHASH}/registration_info.json" + + 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 "${DOMAINS_TXT}" ]] && DOMAINS_TXT="${BASEDIR}/domains.txt" + [[ -z "${WELLKNOWN}" ]] && WELLKNOWN="/var/www/dehydrated" + [[ -z "${LOCKFILE}" ]] && LOCKFILE="${BASEDIR}/lock" + [[ -n "${PARAM_LOCKFILE_SUFFIX:-}" ]] && LOCKFILE="${LOCKFILE}-${PARAM_LOCKFILE_SUFFIX}" + [[ -n "${PARAM_NO_LOCK:-}" ]] && LOCKFILE="" + + [[ -n "${PARAM_HOOK:-}" ]] && HOOK="${PARAM_HOOK}" + [[ -n "${PARAM_CERTDIR:-}" ]] && CERTDIR="${PARAM_CERTDIR}" + [[ -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}" + + verify_config + 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}")" + 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)" && + # shellcheck disable=SC2015 + 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." + + # Export some environment variables to be used in hook script + export WELLKNOWN BASEDIR CERTDIR CONFIG COMMAND + + # Checking for private key ... + register_new_key="no" + 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" + else + # Check if private account key exists, if it doesn't exist yet generate a new one (rsa key) + if [[ ! -e "${ACCOUNT_KEY}" ]]; then + REAL_LICENSE="$(http_request head "${CA_TERMS}" | (grep Location: || true) | awk -F ': ' '{print $2}' | tr -d '\n\r')" + if [[ -z "${REAL_LICENSE}" ]]; then + printf '\n' + printf 'Error retrieving terms of service from certificate authority.\n' + printf 'Please set LICENSE in config manually.\n' + exit 1 + fi + if [[ ! "${LICENSE}" = "${REAL_LICENSE}" ]]; then + if [[ "${PARAM_ACCEPT_TERMS:-}" = "yes" ]]; then + LICENSE="${REAL_LICENSE}" + else + printf '\n' + printf 'To use dehydrated with this certificate authority you have to agree to their terms of service which you can find here: %s\n\n' "${REAL_LICENSE}" + printf 'To accept these terms of service run `%s --register --accept-terms`.\n' "${0}" + exit 1 + fi + fi + + echo "+ Generating account key..." + _openssl genrsa -out "${ACCOUNT_KEY}" "${KEYSIZE}" + register_new_key="yes" + fi + fi + openssl rsa -in "${ACCOUNT_KEY}" -check 2>/dev/null > /dev/null || _exiterr "Account key is not valid, can not continue." + + # 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)" + + thumbprint="$(printf '{"e":"%s","kty":"RSA","n":"%s"}' "${pubExponent64}" "${pubMod64}" | 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 [[ -z "${CA_NEW_REG}" ]]; then + echo "Certificate authority doesn't allow registrations." + FAILED=true + fi + + # If an email for the contact has been provided then adding it to the registration request + if [[ "${FAILED}" = "false" ]]; then + if [[ -n "${CONTACT_EMAIL}" ]]; then + (signed_request "${CA_NEW_REG}" '{"resource": "new-reg", "contact":["mailto:'"${CONTACT_EMAIL}"'"], "agreement": "'"$LICENSE"'"}' > "${ACCOUNT_KEY_JSON}") || FAILED=true + else + (signed_request "${CA_NEW_REG}" '{"resource": "new-reg", "agreement": "'"$LICENSE"'"}' > "${ACCOUNT_KEY_JSON}") || FAILED=true + fi + fi + + if [[ "${FAILED}" = "true" ]]; then + echo + echo + echo "Error registering account key. See message above for more information." + rm "${ACCOUNT_KEY}" "${ACCOUNT_KEY_JSON}" + exit 1 + fi + elif [[ "${COMMAND:-}" = "register" ]]; then + echo "+ Account already registered!" + exit 0 + fi +} + +# Different sed version for different os types... +_sed() { + if [[ "${OSTYPE}" = "Linux" ]]; then + sed -r "${@}" + else + sed -E "${@}" + fi +} + +# Print error message and exit with error +_exiterr() { + echo "ERROR: ${1}" >&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:+/:-_:' +} + +# Convert hex string to binary data +hex2bin() { + # Remove spaces, add leading zero, escape as hex string and parse with printf + printf -- "$(cat | _sed -e 's/[[:space:]]//g' -e 's/^(.(.{2})*)$/0\1/' -e 's/(.{2})/\\x\1/g')" +} + +# Get string value from json dictionary +get_json_string_value() { + local filter + filter=$(printf 's/.*"%s": *"\([^"]*\)".*/\\1/p' "$1") + sed -n "${filter}" +} + +rm_json_arrays() { + local filter + filter='s/\[[^][]*\]/null/g' + # remove three levels of nested arrays + sed -e "${filter}" -e "${filter}" -e "${filter}" +} + +# 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)" + + if [[ -n "${IP_VERSION:-}" ]]; then + ip_version="-${IP_VERSION}" + fi + + set +e + if [[ "${1}" = "head" ]]; then + statuscode="$(curl ${ip_version:-} -s -w "%{http_code}" -o "${tempcont}" "${2}" -I)" + curlret="${?}" + elif [[ "${1}" = "get" ]]; then + statuscode="$(curl ${ip_version:-} -s -w "%{http_code}" -o "${tempcont}" "${2}")" + curlret="${?}" + elif [[ "${1}" = "post" ]]; then + statuscode="$(curl ${ip_version:-} -s -w "%{http_code}" -o "${tempcont}" "${2}" -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 + if [[ ! "${2}" = "${CA_TERMS}" ]] || [[ ! "${statuscode:0:1}" = "3" ]]; then + echo " + ERROR: An error occurred while sending ${1}-request to ${2} (Status ${statuscode})" >&2 + echo >&2 + echo "Details:" >&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}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]]; then + errtxt=`cat ${tempcont}` + "${HOOK}" "request_failure" "${statuscode}" "${errtxt}" "${1}" + fi + + rm -f "${tempcont}" + + # Wait for hook script to clean the challenge if used + if [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && [[ -n "${challenge_token:+set}" ]]; then + "${HOOK}" "clean_challenge" '' "${challenge_token}" "${keyauth}" + fi + + # remove temporary domains.txt file if used + [[ -n "${PARAM_DOMAIN:-}" && -n "${DOMAINS_TXT:-}" ]] && rm "${DOMAINS_TXT}" + exit 1 + fi + fi + + cat "${tempcont}" + rm -f "${tempcont}" +} + +# Send signed request +signed_request() { + # Encode payload as urlbase64 + payload64="$(printf '%s' "${2}" | urlbase64)" + + # Retrieve nonce from acme-server + nonce="$(http_request head "${CA}" | grep Replay-Nonce: | awk -F ': ' '{print $2}' | tr -d '\n\r')" + + # Build header with just our public key and algorithm information + header='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}}' + + # 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)" + + # Sign header with nonce and our payload with our private key and encode signature as urlbase64 + signed64="$(printf '%s' "${protected64}.${payload64}" | openssl dgst -sha256 -sign "${ACCOUNT_KEY}" | urlbase64)" + + # Send header + extended header + payload + signature to the acme-server + data='{"header": '"${header}"', "protected": "'"${protected64}"'", "payload": "'"${payload64}"'", "signature": "'"${signed64}"'"}' + + http_request post "${1}" "${data}" +} + +# 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: ones signed + if grep -qv '^DNS:' <<<"${altnames}"; then + _exiterr "Certificate signing request contains non-DNS Subject Alternative Names" + fi + # strip away the DNS: prefix + altnames="$( <<<"${altnames}" _sed -e 's/^DNS://' )" + echo "${altnames}" + else + # No SANs, extract CN + altnames="$( <<<"${reqtext}" grep '^[[:space:]]*Subject:' | _sed -e 's/.* CN=([^ /,]*).*/\1/' )" + echo "${altnames}" + fi +} + +# 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 + altnames="${*:-}" + if [ -z "${altnames}" ]; then + altnames="$( extract_altnames "${csr}" )" + fi + + if [[ -z "${CA_NEW_AUTHZ}" ]] || [[ -z "${CA_NEW_CERT}" ]]; then + _exiterr "Certificate authority doesn't allow certificate signing" + fi + + local idx=0 + if [[ -n "${ZSH_VERSION:-}" ]]; then + local -A challenge_altnames challenge_uris challenge_tokens keyauths deploy_args + else + local -a challenge_altnames challenge_uris challenge_tokens keyauths deploy_args + fi + + # Request challenges + for altname in ${altnames}; do + # Ask the acme-server for new challenge token and extract them from the resulting json block + echo " + Requesting challenge for ${altname}..." + response="$(signed_request "${CA_NEW_AUTHZ}" '{"resource": "new-authz", "identifier": {"type": "dns", "value": "'"${altname}"'"}}' | clean_json)" + + challenge_status="$(printf '%s' "${response}" | rm_json_arrays | get_json_string_value status)" + if [ "${challenge_status}" = "valid" ]; then + echo " + Already validated!" + continue + fi + + challenges="$(printf '%s\n' "${response}" | sed -n 's/.*\("challenges":[^\[]*\[[^]]*]\).*/\1/p')" + repl=$'\n''{' # fix syntax highlighting in Vim + challenge="$(printf "%s" "${challenges//\{/${repl}}" | grep \""${CHALLENGETYPE}"\")" + challenge_token="$(printf '%s' "${challenge}" | get_json_string_value token | _sed 's/[^A-Za-z0-9_\-]/_/g')" + challenge_uri="$(printf '%s' "${challenge}" | get_json_string_value uri)" + + if [[ -z "${challenge_token}" ]] || [[ -z "${challenge_uri}" ]]; then + _exiterr "Can't retrieve challenges (${response})" + fi + + # Challenge response consists of the challenge token and the thumbprint of our public certificate + keyauth="${challenge_token}.${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_token}" + chmod a+r "${WELLKNOWN}/${challenge_token}" + keyauth_hook="${keyauth}" + ;; + "dns-01") + # Generate DNS entry content for dns-01 validation + keyauth_hook="$(printf '%s' "${keyauth}" | openssl dgst -sha256 -binary | urlbase64)" + ;; + esac + + challenge_altnames[${idx}]="${altname}" + challenge_uris[${idx}]="${challenge_uri}" + keyauths[${idx}]="${keyauth}" + challenge_tokens[${idx}]="${challenge_token}" + # Note: assumes args will never have spaces! + deploy_args[${idx}]="${altname} ${challenge_token} ${keyauth_hook}" + idx=$((idx+1)) + done + challenge_count="${idx}" + + # Wait for hook script to deploy the challenges if used + if [[ ${challenge_count} -ne 0 ]]; then + # shellcheck disable=SC2068 + [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" = "yes" ]] && "${HOOK}" "deploy_challenge" ${deploy_args[@]} + fi + + # Respond to challenges + reqstatus="valid" + idx=0 + if [ ${challenge_count} -ne 0 ]; then + for altname in "${challenge_altnames[@]:0}"; do + challenge_token="${challenge_tokens[${idx}]}" + keyauth="${keyauths[${idx}]}" + + # Wait for hook script to deploy the challenge if used + # shellcheck disable=SC2086 + [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && "${HOOK}" "deploy_challenge" ${deploy_args[${idx}]} + + # Ask the acme-server to verify our challenge and wait until it is no longer pending + echo " + Responding to challenge for ${altname}..." + result="$(signed_request "${challenge_uris[${idx}]}" '{"resource": "challenge", "keyAuthorization": "'"${keyauth}"'"}' | clean_json)" + + reqstatus="$(printf '%s\n' "${result}" | get_json_string_value status)" + + while [[ "${reqstatus}" = "pending" ]]; do + sleep 1 + result="$(http_request get "${challenge_uris[${idx}]}")" + reqstatus="$(printf '%s\n' "${result}" | get_json_string_value status)" + done + + [[ "${CHALLENGETYPE}" = "http-01" ]] && rm -f "${WELLKNOWN}/${challenge_token}" + + # Wait for hook script to clean the challenge if used + if [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && [[ -n "${challenge_token}" ]]; then + # shellcheck disable=SC2086 + "${HOOK}" "clean_challenge" ${deploy_args[${idx}]} + fi + idx=$((idx+1)) + + if [[ "${reqstatus}" = "valid" ]]; then + echo " + Challenge is valid!" + else + [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" != "yes" ]] && "${HOOK}" "invalid_challenge" "${altname}" "${result}" + fi + done + fi + + # Wait for hook script to clean the challenges if used + # shellcheck disable=SC2068 + if [[ ${challenge_count} -ne 0 ]]; then + [[ -n "${HOOK}" ]] && [[ "${HOOK_CHAIN}" = "yes" ]] && "${HOOK}" "clean_challenge" ${deploy_args[@]} + fi + + if [[ "${reqstatus}" != "valid" ]]; then + # Clean up any remaining challenge_tokens if we stopped early + if [[ "${CHALLENGETYPE}" = "http-01" ]] && [[ ${challenge_count} -ne 0 ]]; then + while [ ${idx} -lt ${#challenge_tokens[@]} ]; do + rm -f "${WELLKNOWN}/${challenge_tokens[${idx}]}" + idx=$((idx+1)) + done + fi + + _exiterr "Challenge is invalid! (returned: ${reqstatus}) (result: ${result})" + 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)" + 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}" )" + + # 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 +} + +# 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 +} + +# Create certificate for domain(s) +sign_domain() { + domain="${1}" + altnames="${*}" + timestamp="$(date +%s)" + + echo " + Signing domains..." + if [[ -z "${CA_NEW_AUTHZ}" ]] || [[ -z "${CA_NEW_CERT}" ]]; then + _exiterr "Certificate authority doesn't allow certificate signing" + fi + + # If there is no existing certificate directory => make it + if [[ ! -e "${CERTDIR}/${domain}" ]]; then + echo " + Creating new directory ${CERTDIR}/${domain} ..." + mkdir -p "${CERTDIR}/${domain}" || _exiterr "Unable to create directory ${CERTDIR}/${domain}" + fi + + privkey="privkey.pem" + # generate a new private key if we need or want one + if [[ ! -r "${CERTDIR}/${domain}/privkey.pem" ]] || [[ "${PRIVATE_KEY_RENEW}" = "yes" ]]; then + echo " + Generating private key..." + privkey="privkey-${timestamp}.pem" + case "${KEY_ALGO}" in + rsa) _openssl genrsa -out "${CERTDIR}/${domain}/privkey-${timestamp}.pem" "${KEYSIZE}";; + prime256v1|secp384r1) _openssl ecparam -genkey -name "${KEY_ALGO}" -out "${CERTDIR}/${domain}/privkey-${timestamp}.pem";; + esac + fi + # move rolloverkey into position (if any) + if [[ -r "${CERTDIR}/${domain}/privkey.pem" && -r "${CERTDIR}/${domain}/privkey.roll.pem" && "${PRIVATE_KEY_RENEW}" = "yes" && "${PRIVATE_KEY_ROLLOVER}" = "yes" ]]; then + echo " + Moving Rolloverkey into position.... " + mv "${CERTDIR}/${domain}/privkey.roll.pem" "${CERTDIR}/${domain}/privkey-tmp.pem" + mv "${CERTDIR}/${domain}/privkey-${timestamp}.pem" "${CERTDIR}/${domain}/privkey.roll.pem" + mv "${CERTDIR}/${domain}/privkey-tmp.pem" "${CERTDIR}/${domain}/privkey-${timestamp}.pem" + fi + # generate a new private rollover key if we need or want one + if [[ ! -r "${CERTDIR}/${domain}/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}/${domain}/privkey.roll.pem" "${KEYSIZE}";; + prime256v1|secp384r1) _openssl ecparam -genkey -name "${KEY_ALGO}" -out "${CERTDIR}/${domain}/privkey.roll.pem";; + esac + fi + # delete rolloverkeys if disabled + if [[ -r "${CERTDIR}/${domain}/privkey.roll.pem" && ! "${PRIVATE_KEY_ROLLOVER}" = "yes" ]]; then + echo " + Removing Rolloverkey (feature disabled)..." + rm -f "${CERTDIR}/${domain}/privkey.roll.pem" + fi + + # Generate signing request config and the actual signing request + echo " + Generating signing request..." + SAN="" + for altname in ${altnames}; do + SAN+="DNS:${altname}, " + done + SAN="${SAN%%, }" + local tmp_openssl_cnf + tmp_openssl_cnf="$(_mktemp)" + cat "${OPENSSL_CNF}" > "${tmp_openssl_cnf}" + printf "[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 + openssl req -new -sha256 -key "${CERTDIR}/${domain}/${privkey}" -out "${CERTDIR}/${domain}/cert-${timestamp}.csr" -subj "/CN=${domain}/" -reqexts SAN -config "${tmp_openssl_cnf}" + rm -f "${tmp_openssl_cnf}" + + crt_path="${CERTDIR}/${domain}/cert-${timestamp}.pem" + # shellcheck disable=SC2086 + sign_csr "$(< "${CERTDIR}/${domain}/cert-${timestamp}.csr" )" ${altnames} 3>"${crt_path}" + + # Create fullchain.pem + echo " + Creating fullchain.pem..." + cat "${crt_path}" > "${CERTDIR}/${domain}/fullchain-${timestamp}.pem" + walk_chain "${crt_path}" > "${CERTDIR}/${domain}/chain-${timestamp}.pem" + cat "${CERTDIR}/${domain}/chain-${timestamp}.pem" >> "${CERTDIR}/${domain}/fullchain-${timestamp}.pem" + + # Update symlinks + [[ "${privkey}" = "privkey.pem" ]] || ln -sf "privkey-${timestamp}.pem" "${CERTDIR}/${domain}/privkey.pem" + + ln -sf "chain-${timestamp}.pem" "${CERTDIR}/${domain}/chain.pem" + ln -sf "fullchain-${timestamp}.pem" "${CERTDIR}/${domain}/fullchain.pem" + ln -sf "cert-${timestamp}.csr" "${CERTDIR}/${domain}/cert.csr" + ln -sf "cert-${timestamp}.pem" "${CERTDIR}/${domain}/cert.pem" + + # Wait for hook script to clean the challenge and to deploy cert if used + [[ -n "${HOOK}" ]] && "${HOOK}" "deploy_cert" "${domain}" "${CERTDIR}/${domain}/privkey.pem" "${CERTDIR}/${domain}/cert.pem" "${CERTDIR}/${domain}/fullchain.pem" "${CERTDIR}/${domain}/chain.pem" "${timestamp}" + + unset challenge_token + echo " + Done!" +} + +# Usage: --register +# Description: Register account key +command_register() { + init_system + echo "+ Done!" + exit 0 +} + +# Usage: --cron (-c) +# Description: Sign/renew non-existant/changed/expiring certificates. +command_sign_domains() { + init_system + + if [[ -n "${PARAM_DOMAIN:-}" ]]; then + DOMAINS_TXT="$(_mktemp)" + printf -- "${PARAM_DOMAIN}" > "${DOMAINS_TXT}" + 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 $(<"${DOMAINS_TXT}" tr -d '\r' | awk '{print tolower($0)}' | _sed -e 's/^[[:space:]]*//g' -e 's/[[:space:]]*$//g' -e 's/[[:space:]]+/ /g' | (grep -vE '^(#|$)' || true)); do + reset_configvars + IFS="${ORIGIFS}" + domain="$(printf '%s\n' "${line}" | cut -d' ' -f1)" + morenames="$(printf '%s\n' "${line}" | cut -s -d' ' -f2-)" + cert="${CERTDIR}/${domain}/cert.pem" + + force_renew="${PARAM_FORCE:-no}" + + if [[ -z "${morenames}" ]];then + echo "Processing ${domain}" + else + echo "Processing ${domain} with alternative names: ${morenames}" + 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}/${domain}" + else + certconfig="${CERTDIR}/${domain}/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-)" + case "${config_var}" in + KEY_ALGO|OCSP_MUST_STAPLE|PRIVATE_KEY_RENEW|PRIVATE_KEY_ROLLOVER|KEYSIZE|CHALLENGETYPE|HOOK|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" + esac + done + IFS="${ORIGIFS}" + fi + verify_config + export WELLKNOWN CHALLENGETYPE KEY_ALGO PRIVATE_KEY_ROLLOVER + + if [[ -e "${cert}" ]]; then + printf " + Checking domain name(s) of existing cert..." + + certnames="$(openssl x509 -in "${cert}" -text -noout | grep DNS: | _sed 's/DNS://g' | tr -d ' ' | tr ',' '\n' | sort -u | tr '\n' ' ' | _sed 's/ $//')" + givennames="$(echo "${domain}" "${morenames}"| tr ' ' '\n' | sort -u | tr '\n' ' ' | _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 + + 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}"; 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}/${domain}/privkey.pem" "${CERTDIR}/${domain}/cert.pem" "${CERTDIR}/${domain}/fullchain.pem" "${CERTDIR}/${domain}/chain.pem" + continue + fi + else + echo "(Less than ${RENEW_DAYS} days). Renewing!" + fi + fi + + # shellcheck disable=SC2086 + if [[ "${PARAM_KEEP_GOING:-}" = "yes" ]]; then + sign_domain ${line} & + wait $! || true + else + sign_domain ${line} + fi + done + + # remove temporary domains.txt file if used + [[ -n "${PARAM_DOMAIN:-}" ]] && rm -f "${DOMAINS_TXT}" + + [[ -n "${HOOK}" ]] && "${HOOK}" "exit_hook" + exit 0 +} + +# Usage: --signcsr (-s) path/to/csr.pem +# Description: Sign a given CSR, output CRT on stdout (advanced usage) +command_sign_csr() { + # redirect stdout to stderr + # leave stdout over at fd 3 to output the cert + exec 3>&1 1>&2 + + init_system + + csrfile="${1}" + if [ ! -r "${csrfile}" ]; then + _exiterr "Could not read certificate signing request ${csrfile}" + fi + + # gen cert + certfile="$(_mktemp)" + sign_csr "$(< "${csrfile}" )" 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)" + response="$(signed_request "${CA_REVOKE_CERT}" '{"resource": "revoke-cert", "certificate": "'"${cert64}"'"}' | clean_json)" + # 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: --cleanup (-gc) +# Description: Move unused certificate files to archive directory +command_cleanup() { + load_config + + # Create global archive directory if not existant + if [[ ! -e "${BASEDIR}/archive" ]]; then + mkdir "${BASEDIR}/archive" + fi + + # 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 certitifaces archive directory if not existant + archivedir="${BASEDIR}/archive/${certname}" + if [[ ! -e "${archivedir}" ]]; then + mkdir "${archivedir}" + fi + + # Loop over file-types (certificates, keys, signing-requests, ...) + for filetype in cert.csr cert.pem chain.pem fullchain.pem privkey.pem; do + # Skip if symlink is broken + [[ -r "${certdir}/${filetype}" ]] || continue + + # Look up current file in use + current="$(basename "$(readlink "${certdir}/${filetype}")")" + + # 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}"; do + # Handle case where no files match the wildcard + [[ -f "${file}" ]] || break + + # Check if current file is in use, if unused move to archive directory + filename="$(basename "${file}")" + if [[ ! "${filename}" = "${current}" ]]; then + echo "Moving unused file to archive directory: ${certname}/${filename}" + mv "${certdir}/${filename}" "${archivedir}/${filename}" + fi + done + done + done + + exit 0 +} + +# 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 LICENSE CERTDIR CHALLENGETYPE DOMAINS_D DOMAINS_TXT HOOK HOOK_CHAIN RENEW_DAYS ACCOUNT_KEY ACCOUNT_KEY_JSON KEYSIZE WELLKNOWN PRIVATE_KEY_RENEW OPENSSL_CNF CONTACT_EMAIL LOCKFILE +} + +# Main method (parses script arguments and calls command_* methods) +main() { + 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 + ;; + + # PARAM_Usage: --accept-terms + # PARAM_Description: Accept CAs terms of service + --accept-terms) + PARAM_ACCEPT_TERMS="yes" + ;; + + --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}" + ;; + + --cleanup|-gc) + set_command cleanup + ;; + + # 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: --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: --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: --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: --out (-o) certs/directory + # PARAM_Description: Output certificates into the specified directory + --out|-o) + shift 1 + check_parameters "${1:-}" + PARAM_CERTDIR="${1}" + ;; + + # PARAM_Usage: --challenge (-t) http-01|dns-01 + # PARAM_Description: Which challenge should be used? Currently http-01 and dns-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;; + sign_csr) command_sign_csr "${PARAM_CSR}";; + revoke) command_revoke "${PARAM_REVOKECERT}";; + cleanup) command_cleanup;; + *) command_help; exit 1;; + esac +} + +# Determine OS type +OSTYPE="$(uname)" + +# Check for missing dependencies +check_dependencies + +# Run script +main "${@:-}" diff --git a/src/hook.sh b/src/hook.sh new file mode 100755 index 0000000..f820eae --- /dev/null +++ b/src/hook.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash + +# acme dns-01 challenge hook script v0.0.2 + +# This script has been modified 6/4/2017 by Jared Fisher - kyse@kyse.us +# - Added ability to speficy an external dns server for dig lookups to avoid split brain DNS issues. +# - Updated to only require a TSIG file for the zone, not for each individual FQDN. +# - Added support to match up domains to UTM REF_ object names. +# - Added support to call a specified UTM update certificate hook. +# - Moved initialization logic into function to prevent error output exit hook (with no params) is called. + +# Publish an acme challenge in DNS. This script is made to work with letsencrypt.sh by lukas2511. +# You need a Bind DNS Server you can publish records using nsupdate. The script was written and +# tested in Debian GNU/Linux, it uses the GNU versions of sed, grep, date, etc. You need dig to be +# installed. Version 0.0.2 of this script works only with TSIG keys (not with SIG(0) key pairs). + +# You need to fill in the DNS server you want to send your nsupdate commands to in the variable +# SERVER below the introductory remarks. Also set KEYPATH to point to your TSIG key directory. + +# You need to create a TSIG key for each domain you want to publish an acme dns challenge +# with this script. The script calls the nsupdate command to publish the challenge in your +# Bind DNS Server. Nsupdate needs the TSIG key in order to be authenticated on Bind. You need +# to configure Bind to know this key and you need to grant access for nsupdate to alter the +# _acme-challenge record for every host you include in the let's encrypt certificate. There are +# several tutorials how to set this up, e.g. see the first part of the following page to get an +# idea: +# https://www.kirya.net/articles/running-a-secure-ddns-service-with-bind/ +# +# You need to configure this on the master DNS server! +# +# Restrict the updating of the _acme-challenge record as much as possible. Use a statement like +# the following inside your zone section: +# update-policy { +# grant _acme-challenge.domain.tld. name _acme-challenge.host.domain.tld. TXT; +# }; +# ...where the first part after grant is your key name and the part before TXT a host you request +# the certificate for. Put one grant line like this for each host you include in the cert! +# If you connect to a secondary DNS with this script, in addition to the above on the master, configure +# on the slave inside the slave zone section a statement +# allow-update-forwarding { secondary; }; +# ...where "secondary" is an ACL (that could be named as you like) and has to be defined in its own +# section: +# acl secondary { +# 1.2.3.4/32; +# 127.0.0.1/32; +# }; + +# If you have many domains on your server you should not need to do all this manually. I may release some +# more scripts to assisst you with the job. Unfortunately they are not releasable in their current state. + +# Comments and corrections welcome. + +# (c) 2016 under GPL v2 by Adrian Zaugg <adi@ente.limmat.ch>. + + + +# Path to the directory where your nsupdate keys are stored (one for each domain, +# named K_acme-challenge.domain.tld.+157+<...>.private) +KEYPATH="`dirname $0`/tsig" + +# DNS Server to update +SERVER="update.dyndns.com" + +# Time To Live to set for the challenge +TTL=10 + +# Max time to try to check the challenge on all authoritative name servers for the domain +CHECK_NS_TIMEOUT=10 + +# If your using this script behind a split brain DNS, specify a DNS server here to use to retrieve +# authenticated server info for confrming DNS-01 challenge deployment. +#EXTERNALNS="ns1.mydyndns.org" + +# Path to hook used to update UTM certificates. +UPDATECERTHOOK="`dirname $0`/utm_update_certificate.pl" + +# Path to the directory where your REF_ names are stored (one for each cert. +REFPATH="`dirname $0`/refs" + + +# ------- do not edit below this line ------- + +ACME_STRING="_acme-challenge" +reason="$1" +#HOST="$2" +#CHALLENGE="$4" + + +# execute nsupdate update function +update_dns() { + echo -n " + Updating DNS $SERVER: $reason for $HOST... " >&2 + ERR="$(nsupdate -k "$KEYFILE" 2>&1 << \ +EOF +server $SERVER +zone $ZONE +$1 +send +EOF + )" + + if [ $? -ne 0 ]; then + echo "$ERR" >&2 + exit 1 + else + echo "ok." >&2 + fi +} + +setup_cmd() { + # select nsupdate key and get its zone + ZONE="$HOST" + TLD="$(echo "$ZONE" | sed -e "s/^.*\.//")" + + until [ "${ZONE}" = "$TLD" ]; do + KEYFILE="$(ls -1 "${KEYPATH}/K${ACME_STRING}.${ZONE}.+157+"*.private 2>/dev/null)" + if [ $? -eq 0 ]; then break; fi + ZONE="$(echo "$ZONE" | sed -e "s/^[^.]*\.//")" + done + if [ $(echo "$KEYFILE" | wc -l) -gt 1 ]; then + echo " ERROR: Multiple nsupdate key files for $HOST found. Please correct!" >&2 + exit 1 + elif [ -z "$KEYFILE" ]; then + echo " ERROR: No nsupdate key file for zone $HOST found. Can't publish challenge without." >&2 + exit 1 + fi + + # construct line to update dns zone with + update_data="${ACME_STRING}.${HOST}. $TTL IN TXT \"$CHALLENGE\"" + + # get all authoritative name servers + nslookup="" + [ ! -z ${EXTERNALNS} ] && nslookup="@${EXTERNALNS}" + nsservers="$(dig +noall +authority ${HOST} ${nslookup})" + if [ $(echo "$nsservers" | egrep -c "[ \t]*SOA[ \t]*") -eq 1 ]; then + # it seems the parent zone knows about the name servers + auth_zone="$(echo "$nsservers" | sed -e "s/[ \t]\+.*$//" -e "s/\.$//")" + nsservers="$(dig +noall +authority ${auth_zone} ${nslookup})" + fi + nsservers="$(echo "$nsservers" | sed -e "s/^.*\t//g" -e "s/\.$//")" +} + +get_ref() { + if [ -z "${REFPATH}/${DOMAIN}" ]; then + echo " ERROR: No ref file for domain '$DOMAIN'. Please provide a file containing the REF_ string." + exit 1 + fi + DOMAINREF="$(cat "${REFPATH}/${DOMAIN}")" + if [ -z "${DOMAINREF}" ]; then + echo " ERROR: ref file appears empty. Please correct." + exit 1 + fi +} + +case "$reason" in + + deploy_challenge) + HOST="$2" + CHALLENGE="$4" + + setup_cmd + + # delete any previous challenge + old_challenges="$(dig +short ${ACME_STRING}.${HOST}. TXT ${nslookup})" + for old_challenge in $old_challenges; do + reason="deleting previous challenge" + update_dns "update delete ${ACME_STRING}.${HOST}. $TTL IN TXT $old_challenge" + done + + # publish challenge + reason="publishing acme challenge" + update_dns "update add $update_data" + + # ensure all NS got the challenge + let -i ns_ok_cnt=0 + let -i ns_cnt=0 + + # test challenge on each name server + for ns in $nsservers; do + timestamp=$(date "+%s") + dig_result="failed." + echo -ne "\t+ Checking challenge on $ns.. " >&2 + # try max. CHECK_NS_TIMEOUT seconds + while [ $(($(date "+%s")-$timestamp)) -lt $CHECK_NS_TIMEOUT ]; do + msg="$(dig +short "${ACME_STRING}.${HOST}" TXT @${ns} 2>&1)" + if [ $? -eq 0 -a "$msg" = "\"$CHALLENGE\"" ]; then + dig_result="ok." + let "ns_ok_cnt+=1" + break; + elif [ $? -gt 0 -a -n "$msg" ]; then + dig_result="failed: $(echo "$msg" | sed -e "s/^;; //")" + fi + sleep 0.5 + done + echo "$dig_result" >&2 + let "ns_cnt+=1" + done + # if there was no answer or just errors from dig exit non-zero + [ $ns_ok_cnt -eq 0 ] && echo -e "\tERROR: None of the name server\(s\) answer the challenge correctly." >&2 && exit 1 + # Report some NS failed + [ $ns_ok_cnt -lt $ns_cnt ] && echo -e "\tWARNING: Only $ns_ok_cnt out of $ns_cnt name servers do answer the challenge correctly." >&2 + ;; + + clean_challenge) + HOST="$2" + CHALLENGE="$4" + setup_cmd + reason="removing acme challenge" + update_dns "update delete $update_data" + ;; + + deploy_cert) + DOMAIN="${2}" + KEYFILE="${3}" + CERTFILE="${4}" + FULLCHAINFILE="${5}" + CHAINFILE="${6}" + TIMESTAMP="${7}" + reason="deploy_cert" + #echo "Args for $reason" + #echo "DOMAIN: $DOMAIN" + #echo "KEYFILE: $KEYFILE" + #echo "CERTFILE: $CERTFILE" + #echo "FULLCHAINFILE: $FULLCHAINFILE" + #echo "CHAINFILE: $CHAINFILE" + #echo "TIMESTAMP: $TIMESTAMP" + + if [ -z "${UPDATECERTHOOK}" ]; then + echo " ERROR: UTM Certificate update hook couldn't be found." >&2 + exit 1 + fi + + get_ref + + echo "Args: $DOMAINREF $CERTFILE $KEYFILE" + "${UPDATECERTHOOK}" "${DOMAINREF}" "${CERTFILE}" "${KEYFILE}" + ;; + + unchanged_cert) + HOST="$2" + reason="nothing to do!" + ;; + + exit_hook) + reason="nothing to do!" + ;; + + *) + echo "Unknown hook: ${reason}" + exit 1 + ;; +esac + +exit 0 diff --git a/src/openssl.cnf b/src/openssl.cnf new file mode 100644 index 0000000..98d36c1 --- /dev/null +++ b/src/openssl.cnf @@ -0,0 +1,244 @@ +# +# OpenSSL example configuration file. +# This is mostly being used for generation of certificate requests. +# + +# This definition stops the following lines choking if HOME isn't +# defined. +HOME = . +RANDFILE = $ENV::HOME/.rnd + +# Extra OBJECT IDENTIFIER info: +#oid_file = $ENV::HOME/.oid +oid_section = new_oids + +# To use this configuration file with the "-extfile" option of the +# "openssl x509" utility, name here the section containing the +# X.509v3 extensions to use: +# extensions = +# (Alternatively, use a configuration file that has only +# X.509v3 extensions in its main [= default] section.) + +[ new_oids ] + +# We can add new OIDs in here for use by 'ca' and 'req'. +# Add a simple OID like this: +# testoid1=1.2.3.4 +# Or use config file substitution like this: +# testoid2=${testoid1}.5.6 + +#################################################################### +[ ca ] +default_ca = CA_default # The default ca section + +#################################################################### +[ CA_default ] + +dir = ./demoCA # Where everything is kept +certs = $dir/certs # Where the issued certs are kept +crl_dir = $dir/crl # Where the issued crl are kept +database = $dir/index.txt # database index file. +new_certs_dir = $dir/newcerts # default place for new certs. + +certificate = $dir/cacert.pem # The CA certificate +serial = $dir/serial # The current serial number +crl = $dir/crl.pem # The current CRL +private_key = $dir/private/cakey.pem# The private key +RANDFILE = $dir/private/.rand # private random number file + +x509_extensions = usr_cert # The extentions to add to the cert + +# Extensions to add to a CRL. Note: Netscape communicator chokes on V2 CRLs +# so this is commented out by default to leave a V1 CRL. +# crl_extensions = crl_ext + +default_days = 365 # how long to certify for +default_crl_days= 30 # how long before next CRL +default_md = md5 # which md to use. +preserve = no # keep passed DN ordering + +# A few difference way of specifying how similar the request should look +# For type CA, the listed attributes must be the same, and the optional +# and supplied fields are just that :-) +policy = policy_match + +# For the CA policy +[ policy_match ] +countryName = match +stateOrProvinceName = match +organizationName = match +organizationalUnitName = optional +commonName = supplied +emailAddress = optional + +# For the 'anything' policy +# At this point in time, you must list all acceptable 'object' +# types. +[ policy_anything ] +countryName = optional +stateOrProvinceName = optional +localityName = optional +organizationName = optional +organizationalUnitName = optional +commonName = supplied +emailAddress = optional + +#################################################################### +[ req ] +default_bits = 1024 +default_keyfile = privkey.pem +distinguished_name = req_distinguished_name +attributes = req_attributes +x509_extensions = v3_ca # The extentions to add to the self signed cert + +# Passwords for private keys if not present they will be prompted for +# input_password = secret +# output_password = secret + +# This sets a mask for permitted string types. There are several options. +# default: PrintableString, T61String, BMPString. +# pkix : PrintableString, BMPString. +# utf8only: only UTF8Strings. +# nombstr : PrintableString, T61String (no BMPStrings or UTF8Strings). +# MASK:XXXX a literal mask value. +# WARNING: current versions of Netscape crash on BMPStrings or UTF8Strings +# so use this option with caution! +string_mask = nombstr + +req_extensions = v3_req # The extensions to add to a certificate request + +[ req_distinguished_name ] +countryName = Country Name (2 letter code) +countryName_default = US +countryName_min = 2 +countryName_max = 2 + +stateOrProvinceName = State or Province Name (full name) +stateOrProvinceName_default = Virginia + +localityName = Locality Name (eg, city) + +0.organizationName = Organization Name (eg, company) +0.organizationName_default = kyse.net + +# we can do this but it is not needed normally :-) +#1.organizationName = Second Organization Name (eg, company) +#1.organizationName_default = World Wide Web Pty Ltd + +organizationalUnitName = Organizational Unit Name (eg, section) +#organizationalUnitName_default = + +commonName = Common Name (eg, YOUR name) +commonName_max = 64 + +emailAddress = Email Address +emailAddress_max = 40 + +# SET-ex3 = SET extension number 3 + +[ req_attributes ] +challengePassword = A challenge password +challengePassword_min = 4 +challengePassword_max = 20 + +unstructuredName = An optional company name + +[ usr_cert ] + +# These extensions are added when 'ca' signs a request. + +# This goes against PKIX guidelines but some CAs do it and some software +# requires this to avoid interpreting an end user certificate as a CA. + +basicConstraints=CA:FALSE + +# Here are some examples of the usage of nsCertType. If it is omitted +# the certificate can be used for anything *except* object signing. + +# This is OK for an SSL server. +# nsCertType = server + +# For an object signing certificate this would be used. +# nsCertType = objsign + +# For normal client use this is typical +# nsCertType = client, email + +# and for everything including object signing: +# nsCertType = client, email, objsign + +# This is typical in keyUsage for a client certificate. +# keyUsage = nonRepudiation, digitalSignature, keyEncipherment + +# This will be displayed in Netscape's comment listbox. +nsComment = "OpenSSL Generated Certificate" + +# PKIX recommendations harmless if included in all certificates. +subjectKeyIdentifier=hash +authorityKeyIdentifier=keyid,issuer:always + +# This stuff is for subjectAltName and issuerAltname. +# Import the email address. +# subjectAltName=email:copy + +# Copy subject details +# issuerAltName=issuer:copy + +#nsCaRevocationUrl = http://www.domain.dom/ca-crl.pem +#nsBaseUrl +#nsRevocationUrl +#nsRenewalUrl +#nsCaPolicyUrl +#nsSslServerName + +[ v3_req ] + +# Extensions to add to a certificate request + +basicConstraints = CA:FALSE +keyUsage = nonRepudiation, digitalSignature, keyEncipherment + +[ v3_ca ] + + +# Extensions for a typical CA + + +# PKIX recommendation. + +subjectKeyIdentifier=hash + +authorityKeyIdentifier=keyid:always,issuer:always + +# This is what PKIX recommends but some broken software chokes on critical +# extensions. +#basicConstraints = critical,CA:true +# So we do this instead. +basicConstraints = CA:true + +# Key usage: this is typical for a CA certificate. However since it will +# prevent it being used as an test self-signed certificate it is best +# left out by default. +# keyUsage = cRLSign, keyCertSign + +# Some might want this also +# nsCertType = sslCA, emailCA + +# Include email address in subject alt name: another PKIX recommendation +# subjectAltName=email:copy +# Copy issuer details +# issuerAltName=issuer:copy + +# DER hex encoding of an extension: beware experts only! +# obj=DER:02:03 +# Where 'obj' is a standard or added object +# You can even override a supported extension: +# basicConstraints= critical, DER:30:03:01:01:FF + +[ crl_ext ] + +# CRL extensions. +# Only issuerAltName and authorityKeyIdentifier make any sense in a CRL. + +# issuerAltName=issuer:copy +authorityKeyIdentifier=keyid:always,issuer:always diff --git a/utm-update-certificate b/utm-update-certificate new file mode 160000 index 0000000..4653785 --- /dev/null +++ b/utm-update-certificate @@ -0,0 +1 @@ +Subproject commit 4653785acae255df86238da92c35268d7b8cac45