From e052241f80f1b6256774f2d254e03d1a23c7f626 Mon Sep 17 00:00:00 2001 From: Eric Renfro Date: Tue, 15 Aug 2017 09:39:05 -0400 Subject: [PATCH] Initial commit --- acme-tool | 499 ++++++++++++++++++++++++++++++++++++++++++ acme-tool.conf.sample | 7 + 2 files changed, 506 insertions(+) create mode 100755 acme-tool create mode 100644 acme-tool.conf.sample diff --git a/acme-tool b/acme-tool new file mode 100755 index 0000000..d9213e5 --- /dev/null +++ b/acme-tool @@ -0,0 +1,499 @@ +#!/bin/bash + +# Internal Initialization + +if [[ -d "${PWD}/.acme.sh" ]]; then + export LE_WORKING_DIR="${PWD}/.acme.sh" + export AWS_CONFIG_FILE="${PWD}/.acme.sh/aws/config" + export AWS_SHARED_CREDENTIALS_FILE="${PWD}/.acme.sh/aws/credentials" +elif [[ -d "${HOME}/.acme.sh" ]]; then + export LE_WORKING_DIR="${HOME}/.acme.sh" + export AWS_CONFIG_FILE="${HOME}/.acme.sh/aws/config" + export AWS_SHARED_CREDENTIALS_FILE="${HOME}/.acme.sh/aws/credentials" +elif [[ -d "/etc/acme" ]]; then + export LE_WORKING_DIR="/etc/acme" + export AWS_CONFIG_FILE="/etc/acme/aws/config" + export AWS_SHARED_CREDENTIALS_FILE="/etc/acme/aws/credentials" +else + if [[ "${1,,}" != "install" ]]; then + echo "ERROR: Cannot find acme.sh working directory in:" + echo " \"${PWD}/.acme.sh\"" + echo " \"${HOME}/.acme.sh\"" + echo " \"/etc/acme\"" + echo "Either one of these directories need to exist to act on behalve of acme.sh's" + echo "home directory for configuration, hooks, and certificates." + exit 99 + fi +fi + +script_name=$(readlink -e $0) +script_dir=$(dirname "$script_name") + +if [[ -r "${LE_WORKING_DIR}/acme-tool.conf" ]]; then + . "${LE_WORKING_DIR}/acme-tool.conf" +else + echo "ACME-Tool Configuration not found in '${LE_WORKING_DIR}/acme-tool.conf'" + exit 99 +fi + +if [[ ! -d "${LE_WORKING_DIR}/domains" ]]; then + mkdir "${LE_WORKING_DIR}/domains" +fi + +if [[ -d "${PWD}/hooks/pre.d" || -d "${PWD}/hooks/post.d" ]]; then + hook_dir="${PWD}/hooks" +elif [[ -d "${LE_WORKING_DIR}/hooks/pre.d" || -d "${LE_WORKING_DIR}/hooks/post.d" ]]; then + hook_dir="${LE_WORKING_DIR}/hooks" +elif [[ -d "/etc/acme/hooks/pre.d" || -d "/etc/acme/hooks/post.d" ]]; then + hook_dir="/etc/acme/hooks" +else + if [[ "${1,,}" != "install" ]]; then + echo "Hook dir does not exist in at least one of the following paths:" + echo " \"${PWD}/hooks\"" + echo " \"${LE_WORKING_DIR}/hooks\"" + echo " \"/etc/acme/hooks\"" + echo "Either of these directories need to contain one or both of pre.d and post.d" + echo "directories for hooks to run." + exit 99 + fi +fi + + +# Functions + +trim() { + local extglobWasOff=1 + local var=$1 + + shopt extglob >/dev/null && extglobWasOff=0 + (( extglobWasOff )) && shopt -s extglob + var=${var##+([[:space:]])} + var=${var%%+([[:space:]])} + (( extglobWasOff )) && shopt -u extglob + echo -n "$var" +} + +run-parts() { + # Ignore *~ and *, scripts + for i in $(LC_ALL=C; echo ${1%/}/*[^~,]) ; do + [[ -d $i ]] && continue + # Don't run *.{rpmsave,rpmorig,rpmnew,swp,cfsaved} scripts + [[ "${i%.cfsaved}" != "${i}" ]] && continue + [[ "${i%.rpmsave}" != "${i}" ]] && continue + [[ "${i%.rpmorig}" != "${i}" ]] && continue + [[ "${i%.rpmnew}" != "${i}" ]] && continue + [[ "${i%.swp}" != "${i}" ]] && continue + [[ "${i%,v}" != "${i}" ]] && continue + + # jobs.deny prevents specific files from being executed + # jobs.allow prohibits all non-named jobs from being run. + # can be used in conjunction but there's no reason to do so. + if [[ -r "${1}/jobs.deny" ]]; then + grep -q "^$(basename $i)$" "${1}/jobs.deny" && continue + fi + if [[ -r "${1}/jobs.allow" ]]; then + grep -q "^$(basename $i)$" "${1}/jobs.allow" || continue + fi + + if [[ -e $i ]]; then + if [[ -r "${1}/whitelist" ]]; then + grep -q "^$(basename $i)$" "${1}/whitelist" && continue + fi + + if [[ -x "$i" ]]; then + # run executable files + echo "$i" + fi + fi + done +} + +run-hook() { + local hook=$1 + local errors=0 + shift + + if [[ ! -d "${hook_dir}/${hook}" ]]; then + return 0 + fi + + while read s + do + "$s" $* + err=$? + + if [[ $err -ne 0 ]]; then + let errors++ + fi + done < <(run-parts "${hook_dir}/${hook}") + + return $errors +} + +get_arg_domains() { + local domain=$1 + + if [[ ! -f "${LE_WORKING_DIR}/domains/${domain}" ]]; then + return 1 + fi + + while read d; do + d=$(trim "$d") + if [[ ${d:0:1} == '#' ]] || [[ -z "$d" ]]; then + continue + fi + echo -n " -d $d" + done < "${LE_WORKING_DIR}/domains/${domain}" +} + +get_conf_domains() { + local di + local dn + + while read di; + do + echo "$di" + done < <(find "${LE_WORKING_DIR}/domains/" -mindepth 1 -maxdepth 1 -type f -exec basename "{}" \; | sort) +} + +get_acme_domains() { + local di + + while read di; + do + echo "$di" + done < <(find "${LE_WORKING_DIR}/" -mindepth 1 -maxdepth 1 -type d -name '*.*' -exec basename "{}" \; | sed -e 's/_ecc//' | uniq | sort) +} + +get_acme_domains_types() { + local domain=$1 + + if [[ -d "${LE_WORKING_DIR}/${domain}" ]]; then + echo -n "(rsa)" + fi + + if [[ -d "${LE_WORKING_DIR}/${domain}_ecc" ]]; then + echo -n "(ecc)" + fi +} + +check_domains_file() { + local domain=$1 + if [[ -f "${LE_WORKING_DIR}/domains/${domain}" ]]; then + return 0 + else + return 1 + fi +} + +is_certs_different() { + local domain=$1 + local file_domains + local cert_domains + + if check_domains_file "$domain"; then + if [[ ! -f "${LE_WORKING_DIR}/${domain}/${domain}.cer" ]] && \ + [[ ! -f "${LE_WORKING_DIR}/${domain}_ecc/${domain}.cer" ]]; then + return 1 + fi + else + echo "ERROR: '${LE_WORKING_DIR}/domains/${domain}' does not exist." + exit 3 + fi + + cert_domains=$( + openssl x509 -in certs/"${domain}"/"${domain}".cer -noout -text | awk ' + /X509v3 Subject Alternative Name/ { + getline + gsub(/ /, "", $0) + gsub(/DNS:/, "", $0) + gsub(/IPAddress:/, "", $0) + gsub(",", "\n") + print + }' | sort | tr -d '\n') + file_domains=$(sort < "${LE_WORKING_DIR}/domains/${domain}" | tr -d '\n') + + if [[ "$cert_domains" == "$file_domains" ]]; then + return 0 + else + return 1 + fi +} + +issue_certs() { + local domain=$1 + shift + local args=$* + + if [[ -d "${LE_WORKING_DIR}/${domain}" ]]; then + echo "Running Lets Encrypt on $domain for RSA${keysize_rsa}" + "$LE_WORKING_DIR"/acme.sh \ + --issue --dns dns_aws --keylength $keysize_rsa \ + --post-hook "$script_name hook sync.d" \ + $(get_arg_domains "$domain") $args + fi + + if [[ -d "${LE_WORKING_DIR}/${domain}_ecc" ]]; then + echo "Running Lets Encrypt on $domain for EC${keysize_ecc}" + "$LE_WORKING_DIR"/acme.sh \ + --issue --dns dns_aws --keylength ec-$keysize_ecc \ + --post-hook "$script_name hook sync.d" \ + $(get_arg_domains "$domain") $args + fi +} + +cron_certs() { + "${LE_WORKING_DIR}"/acme.sh --cron --home ${LE_WORKING_DIR} --renew-hook "${script_name} sync upload" +} + +create_certs() { + local domain=$1 + shift + local types=$* + + for t in $types; do + case ${t,,} in + rsa) + if [[ ! -d "${LE_WORKING_DIR}/${domain}" ]]; then + mkdir "${LE_WORKING_DIR}/${domain}" + else + if [[ -f "${LE_WORKING_DIR}/${domain}/${domain}.cer" ]]; then + echo "ERROR: '$domain' already initialized" + fi + fi + ;; + ec|ecc) + if [[ ! -d "${LE_WORKING_DIR}/${domain}_ecc" ]]; then + mkdir "${LE_WORKING_DIR}/${domain}_ecc" + else + if [[ -f "${LE_WORKING_DIR}/${domain}_ecc/${domain}.cer" ]]; then + echo "ERROR: '$domain' already initialized" + fi + fi + ;; + esac + done + issue_certs $domain +} + +s3_upload() { + local domain + local dompart + local errors=0 + local totalerrors=0 + + for domain in $(get_acme_domains); do + errors=0 + + for dompart in "${domain}" "${domain}_ecc"; do + if [[ -d "${LE_WORKING_DIR}/${dompart}" ]]; then + run-hook pre.d "$domain" + aws s3 sync "${LE_WORKING_DIR}/${dompart}/" "${s3_bucket}${s3_folder}${dompart}/" + if [[ $? -ne 0 ]]; then + let errors++ + fi + run-hook post.d "$domain" + fi + done + + if [[ $errors -eq 0 ]]; then + run-hook deploy.d "$domain" + fi + totalerrors=$((totalerrors+errors)) + done + return $totalerrors +} + +s3_check() { + local domain + local dompart + local status=1 + + for domain in $(get_acme_domains); do + for dompart in "$domain" "${domain}_ecc"; do + if [[ -d "${LE_WORKING_DIR}/${dompart}" ]]; then + aws --dryrun s3 sync "${s3_bucket}${s3_folder}${dompart}/" "${LE_WORKING_DIR}/${dompart}/" | grep download &>/dev/null + if [[ $? -eq 0 ]]; then + status=0 + fi + fi + done + done + return $status +} + +s3_show() { + local domain + local dompart + + for domain in $(get_acme_domains); do + for dompart in "$domain" "${domain}_ecc"; do + if [[ -d "${LE_WORKING_DIR}/${dompart}" ]]; then + aws --dryrun s3 sync "${s3_bucket}${s3_folder}${dompart}/" "${LE_WORKING_DIR}/${dompart}/" | sed -e "s|.* to .*\/\(${dompart}.*\)$|\1|" + fi + done + done +} + +s3_download() { + local domain + local dompart + local errors=0 + local totalerrors=0 + + for domain in $(get_acme_domains); do + errors=0 + + for dompart in "${domain}" "${domain}_ecc"; do + if [[ -d "${LE_WORKING_DIR}/${dompart}" ]]; then + aws s3 sync "${s3_bucket}${s3_folder}${dompart}/" "${LE_WORKING_DIR}/${dompart}/" + if [[ $? -ne 0 ]]; then + let errors++ + fi + fi + done + + if [[ $errors -eq 0 ]]; then + run-hook deploy.d "$domain" + fi + totalerrors=$((totalerrors+errors)) + done + return $totalerrors +} + +install_system() { + local acme_basedir + + if [[ "$USER" == "root" ]]; then + acme_basedir="/etc/acme" + else + acme_basedir="${HOME}/.acme.sh" + fi + + mkdir -p "${acme_basedir}/domains" + mkdir -p "${acme_basedir}/hooks/pre.d" + mkdir -p "${acme_basedir}/hooks/post.d" + mkdir -p "${acme_basedir}/hooks/sync.d" + mkdir -p "${acme_basedir}/hooks/deploy.d" +} + + +# Main + +case ${1,,} in + install) + install_system + ;; + init) + if [[ -n "$2" ]] && [[ -n "$3" ]]; then + do=${2,,} + shift 2 + if [[ -n "$EDITOR" ]]; then + $EDITOR "${LE_WORKING_DIR}/domains/${do}" + fi + create_certs $do $* + else + echo "ERROR: init requires a domain and type" + exit 6 + fi + ;; + issue|update) + for d in $(get_conf_domains); do + if is_certs_different $d; then + echo "$d: Match" + else + echo "$d: Changes" + issue_certs "$d" --force + fi + done + ;; + list) + echo "Configured Domains:" + get_conf_domains + echo + echo "ACME Domains:" + for d in $(get_acme_domains); do + echo "$d $(get_acme_domains_types "$d")" + done + ;; + sync) + case "${2,,}" in + upload) + echo "Synchronizing Let's Encrypt Certificates (upload)" + s3_upload + err=$? + if [[ $err -ne 0 ]]; then + echo "Upload(s) failed: $err" + fi + echo "Done" + ;; + check|"") + echo "Checking for new Let's Encrypt Certificates" + if s3_check; then + echo "There are new certificates available" + else + echo "No new certificates available" + fi + ;; + show) + echo "Listing updated Let's Encrypt Certificates:" + s3_show + ;; + down|download) + echo "Synchronizing Let's Encrypt Certificates (download)" + if s3_check; then + s3_download + err=$? + if [[ $err -ne 0 ]]; then + echo "Download(s) failed: $err" + fi + echo "Done" + fi + ;; + *) + echo "ERROR: Unknown sync command. Available sync commands:" + echo " check, show, download, upload" + exit 9 + ;; + esac + ;; + edit) + if [[ -z "$EDITOR" ]]; then + echo "ERROR: EDITOR environment not set" + exit 3 + fi + + for d in $(get_conf_domains); do + if [[ "$d" == "${2,,}" ]]; then + echo "Editing domain: $d" + $EDITOR "${LE_WORKING_DIR}/domains/${d}" + exit 0 + fi + done + ;; + hook) + if [[ -n "$2" ]]; then + shift 2 + + case ${2,,} in + pre.d) run-hook pre.d $*;; + post.d) run-hook post.d $*;; + sync.d) run-hook sync.d $*;; + deploy.d) run-hook deploy.d $*;; + *) echo "ERROR: Unknown hook \"${2,,}\". Available hooks:" + echo " pre.d Before running issue/renew/sync" + echo " post.d After running issue/renew/sync" + echo " sync.d After running issue/renew" + echo " deploy.d After successfully running issue/renew/sync" + error 6 + ;; + esac + fi + ;; + cron) + cron_certs + ;; + *) + echo "Unknown command '$1'. Available commands are: init, issue, list, sync, cron" + ;; +esac diff --git a/acme-tool.conf.sample b/acme-tool.conf.sample new file mode 100644 index 0000000..7fe3ab4 --- /dev/null +++ b/acme-tool.conf.sample @@ -0,0 +1,7 @@ +# Configuration + +keysize_rsa=4096 +keysize_ecc=256 +s3_bucket=s3://linux-help-certs/ +s3_folder= +