From 67b6d5495f640096ac8e7a84a924d9bf96bff4d4 Mon Sep 17 00:00:00 2001 From: Rob Pearce Date: Sun, 13 Jun 2021 13:42:59 +1000 Subject: [PATCH] initial checkin --- README.md | 42 ++++++ lehook.sh | 441 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 483 insertions(+) create mode 100644 README.md create mode 100755 lehook.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3c0b6f --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Overview + +Script to automate management of letsencrypt SSL certificates, supporting wildcard certs. + +# Requirements +- [certbot](https://certbot.eff.org/) + + +# Features + +- Generation and renewal of SSL certificates using certbot +- Handles DNS challenges +- Supports wildcard certificates +- Supports "silent master" DNS architectures +- Pushes generated/renewed certificates out to web servers + +# Usage + + # Generate configuration and scripts in ~/.lehook/ + bash$ ./lehook.sh -i + Creating config in /Users/rob/.lehook... + Creating hardlinks in /Users/rob/.lehook... + Init complete. Files are in /Users/rob/.lehook. + Main binary in /usr/local/bin/lehook.sh. + + # Usage + bash$ ./lehook.sh -h +usage: ./lehook.sh OPTIONS mode [domain1] ... [domainX] + Pushes wildcard SSL certs for the given domains to hosts [default: example.net]. + + mode is one of: deploy|renew|generate|test + + -c Cron mode - only output if something is done. + -f Push out certs even if they haven't changed. + -i Iniitalise new config file in /Users/rpearce/.lehook/config + -r remotes Only push certs to the given remotes [default: webserver1 webserver2.example.org webserver3.example.net] + -s services Only restart the given services [default: nginx httpd ngircd dovecot postfix] + -d domain Only push cert for given domain [default: example.net]. + -q Quiet mode - no output except errors + -v Verbose mode - show certbot output + -h Show this text. + diff --git a/lehook.sh b/lehook.sh new file mode 100755 index 0000000..423d2e4 --- /dev/null +++ b/lehook.sh @@ -0,0 +1,441 @@ +#!/usr/bin/env bash +DIR=$HOME/.lehook +configfile=$DIR/config +VALIDMODES="deploy|renew|generate|test" + +function doinit() { + mkdir -p $DIR + if [[ ! -e $configfile ]]; then + echo "Creating config in $DIR..." + cat > $configfile <&1 >/dev/null)" + if [[ -z $err ]]; then + [[ $quiet -ne 1 ]] && echo "Ok" + else + [[ $quiet -ne 1 ]] && echo "$err" + fi + dec +} +function dossh() { + local h desc cmd rv + h=$1 + desc="$2" + cmd="$3" + + inc + out -n "$desc: " + + rv=0 + err="$(ssh root@$h "$cmd" 2>&1 >/dev/null)" + if [[ $? -eq 0 ]]; then + [[ $quiet -ne 1 ]] && echo "Ok" + else + [[ $quiet -ne 1 ]] && echo "$err" + rv=1 + fi + dec + return $rv +} + +function usage() { + echo "usage: $0 OPTIONS mode [domain1] ... [domainX]" + echo " Pushes wildcard SSL certs for the given domains to hosts [default: $domains]." + echo + echo " mode is one of: $VALIDMODES" + echo + echo " -c Cron mode - only output if something is done." + echo " -f Push out certs even if they haven't changed." + echo " -i Iniitalise new config file in $configfile" + echo " -r remotes Only push certs to the given remotes [default: $remotes]" + echo " -s services Only restart the given services [default: $services]" + echo " -d domain Only push cert for given domain [default: $domains]." + echo " -q Quiet mode - no output except errors" + echo " -v Verbose mode - show certbot output" + echo " -h Show this text." +} + +function checkcert() { + local desc host lsum path rsum rv + desc="$1" + host="$2" + lsum="$3" + path="$4" + rv=0 + inc + out -n "$desc: " + rsum=$(ssh root@$r "sha256sum $path 2>&1") + if [[ $? -ne 0 ]]; then + [[ $quiet -ne 1 ]] && echo "Doesn't exist" + rv=1 + else + rsum=$(echo "$rsum" | awk '{ print $1 }') + if [[ $lsum == $rsum ]]; then + [[ $quiet -ne 1 ]] && echo "Ok" + else + [[ $quiet -ne 1 ]] && echo "Needs updating [$lsum vs $rsum]" + rv=1 + fi + fi + dec + return $rv +} + + + + +indent=0 +force=0 +quiet=0 +verbose=0 +cronmode=0 +init=0 +recurseargs="" + +if [[ -e $configfile ]]; then + . $configfile +fi + +ARGS="cd:fhiqr:s:v" +# TODO: add renew mode +# TODO: add generate mode +while getopts "$ARGS" i; do + case "$i" in + c) + cronmode=1 + recurseargs="$recurseargs -c" + ;; + h) + usage; + exit 1; + ;; + i) + doinit; + exit 0; + ;; + d) + domains="$OPTARG" + ;; + f) + force=1 + recurseargs="$recurseargs -$i" + ;; + q) + quiet=1 + recurseargs="$recurseargs -$i" + ;; + r) + remotes="$OPTARG" + recurseargs="$recurseargs -$i $OPTARG" + ;; + s) + services="$OPTARG" + recurseargs="$recurseargs -$i $OPTARG" + ;; + v) + verbose=1 + recurseargs="$recurseargs -$i" + ;; + *) + error "invalid argument: $i"; + usage; + ;; + esac +done +shift $((OPTIND - 1)) + +if [[ ! -e $configfile ]]; then + echo "$0: ERROR: cant find $configfile" + echo "Use $0 -i to initialise it." + exit 1 +fi + +if [[ $0 =~ pre|post|deploy ]]; then + set -e + set -u + set -o pipefail + if [[ -z $CERTBOT_DOMAIN ]]; then + echo "Error: no domain env var received from certbot. vars are:" + set | grep CERTBOT + exit 1 + fi + DOMAIN="$CERTBOT_DOMAIN" + DATA="$CERTBOT_VALIDATION" + rv=0 +else + if [[ $# -lt 1 ]]; then + echo "Error: no mode specified" + usage + exit 1 + fi + mode=$1 + if [[ ! $mode =~ $VALIDMODES ]]; then + echo "Error: invalid mode '$mode'. Valid options: $VALIDMODES" + exit 1 + fi + shift + + if [[ $# -gt 0 ]]; then + domains="$*" + fi +fi + + +function wait_for_dns_update() { # wait_for_dns_update domain value + local ns record + record="_acme-challenge.$1" + value="$2" + for ns in $check_nameservers; do + [[ $quiet -eq 0 || $cronmode -eq 1 ]] && echo -n "Waiting for challenge record to update on $ns..." + res=$(dig +short @${ns} $record IN TXT | tr -d '"' 2>&1) + while [[ $res != $value ]]; do + sleep 5 + echo -n "." + res=$(dig +short @${ns} $record IN TXT | tr -d '"' 2>&1) + done + echo "done" + done +} + +if [[ $0 == *pre* ]]; then + DOMAIN="$CERTBOT_DOMAIN" + DATA="$CERTBOT_VALIDATION" + cmd=$(printf "server %s\nlocal $LOCALADDR\n update add _acme-challenge.%s. %d in TXT \"%s\"\nsend\n" "${DNSSERVER}" "${DOMAIN}" "${TTL}" "${DATA}") + #echo "PREHOOK - ABOUT TO RUN:" + #echo "$cmd" | sed -e 's/^/ /' + echo "$cmd" | $NSUPDATE + rv=$? + wait_for_dns_update "$DOMAIN" "$DATA" + exit $rv +elif [[ $0 == *post* ]]; then + DOMAIN="$CERTBOT_DOMAIN" + DATA="$CERTBOT_VALIDATION" + cmd=$(printf "server %s\nlocal $LOCALADDR\n update delete _acme-challenge.%s. %d in TXT \"%s\"\nsend\n" "${DNSSERVER}" "${DOMAIN}" "${TTL}" "${DATA}") + #echo "POSTHOOK - ABOUT TO RUN" + #echo "$cmd" | sed -e 's/^/ /' + echo "$cmd" | $NSUPDATE + rv=$? + wait_for_dns_update "$DOMAIN" "" + exit $rv +elif [[ $0 == *deploy* ]]; then + DOMAIN="$CERTBOT_DOMAIN" + /usr/local/bin/lehook.sh deploy -d $DOMAIN $recurseargs + exit 1 +fi + + + +rv=0 +if [[ $mode == "generate" ]]; then + rv=0 + for this in $domains; do + res=$(certbot certonly -n --manual --preferred-challenges=dns --email $email --agree-tos --manual-auth-hook $DIR/lehook-pre.sh --manual-cleanup-hook $DIR/lehook-post.sh --deploy-hook $DIR/lehook-deploy.sh -d *.${this} 2>&1) + rv=$((rv + $?)) + [[ $verbose -eq 1 ]] && echo "$res" + done +elif [[ $mode == "renew" ]]; then + rv=0 + extraargs="" + [[ $force -eq 1 ]] && extraargs="$extraargs --force-renewal" + for this in $domains; do + res=$(certbot renew -n --manual --preferred-challenges=dns --email $email --agree-tos --manual-auth-hook $DIR/lehook-pre.sh --manual-cleanup-hook $DIR/lehook-post.sh --deploy-hook $DIR/lehook-deploy.sh $extraargs --cert-name ${this} 2>&1) + rv=$((rv + $?)) + [[ $verbose -eq 1 ]] && echo "$res" + if [[ "$res" =~ "not due for" ]]; then + if [[ $quiet -eq 0 && $cronmode -eq 0 ]]; then + exp=$(certbot certificates --cert-name $this 2>&1 | grep Expiry | sed -e 's/^.*Date: //') + echo "$this not due for renewal yet. Expiry: $exp" + fi + elif [[ "$res" =~ "renewals succeeded" ]]; then + if [[ $quiet -eq 0 || $cronmode -eq 1 ]]; then + exp=$(certbot certificates --cert-name $this 2>&1 | grep Expiry | sed -e 's/^.*Date: //') + echo "$this has been renewed. New expiry: $exp" + fi + else + echo "Error renewing $this. Certbot output:" + echo "$res" + fi + done +elif [[ $mode == "test" ]]; then + nowdate="$(date +%s)" + echo "== Test mode." + echo "== About to run pre-hook" + export CERTBOT_DOMAIN="${domains%% *}" # just use the first one + export CERTBOT_VALIDATION="test-${nowdate}" + $DIR/lehook-pre.sh + echo "== Pre-hook done. Checking DNS record:" + dig -b $LOCALADDR @${DNSSERVER} _acme-challenge.${domains%% *}. IN txt + echo -n "The above should show a TXT record saying '$nowdate'.... hit ENTER: " + read dummy + echo "== About to run post-hook" + export CERTBOT_DOMAIN="${domains%% *}" + export CERTBOT_VALIDATION="test-${nowdate}" + echo "== Post-hook done. Checking DNS record:" + $DIR/lehook-post.sh + dig -b $LOCALADDR @${DNSSERVER} _acme-challenge.${domains%% *}. IN txt + echo -n "The above should NOT show a TXT record any more. Hit ENTER: " + read dummy + exit 1 +elif [[ $mode == "deploy" ]]; then + certdirs="" + for d in $domains; do + thisone=$certbase/$d + if [[ -d $thisone ]]; then + certdirs="$certdirs $thisone" + else + echo "Error: $thisone not found, skipping." >/dev/stderr + rv=$((rv + 1)) + fi + done + + if [[ -z $certdirs ]]; then + echo "Error: No certificates found. Aborting." + exit 2 + fi + + + for r in $remotes; do + out "Processing $r..." + inc + + newcerts="" + ip=$(getent hosts $r 2>/dev/null) + if [[ -z $ip ]]; then + out "* No DNS entry for '$r'. Skipping." + rv=$((rv + 1)) + else + for d in $certdirs; do + domain=$(basename $d) + dest_cert=$remotecertdir/certs/$domain + dest_priv=$remotecertdir/private/$domain + + local_cert_sum=$(sha256 -q $d/fullchain.pem 2>&1) + local_priv_sum=$(sha256 -q $d/privkey.pem 2>&1) + + if [[ $force -eq 1 ]]; then + needupdate=1 + else + out "* Checking existing certs" + needupdate=0 + checkcert "Certificate" $r $local_cert_sum $dest_cert/fullchain.pem || needupdate=1 + if [[ $needupdate -eq 0 ]]; then + checkcert "Private key" $r $local_priv_sum $dest_priv/privkey.pem || needupdate=1 + fi + fi + + if [[ $needupdate -eq 1 ]]; then + out "* Creating cert dirs in $remotecertdir" + ssh root@$r "[ -d $dest_cert ] || mkdir -p $dest_cert && chmod 755 $dest_cert; [ -d $dest_priv ] || mkdir -p $dest_priv && chmod 700 $dest_priv; grep -qi ubuntu /etc/os-release 2>/dev/null && chown -R root:ssl-cert $dest_cert $dest_priv" + out "* Uploading certs" + copycert $d/fullchain.pem root@$r:$dest_cert + copycert $d/privkey.pem root@$r:$dest_priv + newcerts="$newcerts $domain" + + out "* Reloading services" + inc + ssh root@$r "grep -qi 'Release 6' /etc/redhat-release 2>/dev/null" + if [[ $? -eq 0 ]]; then + checkcmd="chkconfig" + reloadcmd="service XX reload && echo Ok || echo failed" + else + checkcmd="systemctl list-unit-files --state=enabled" + reloadcmd="systemctl reload XX" + fi + enabled=$(ssh root@$r "$checkcmd" | awk '/:on|nabled/ { print $1 }') + nok=0 + nfail=0 + okservs="" + failservs="" + for s in $services; do + if [[ $enabled == *$s* ]]; then + reloadcmd=$(echo "$reloadcmd" | sed -e "s/XX/$s/") + dossh $r $s "$reloadcmd" + thisrv=$? + rv=$((rv + $thisrv)) + if [[ $thisrv -eq 0 ]]; then + nok=$((nok + 1)) + okservs="$okservs $s" + else + nfail=$((nfail + 1)) + failservs="$failservs $s" + fi + else + inc + out "$s: Not enabled" + dec + fi + done + dec + else + out "* No update needed" + fi + + done + fi + if [[ $quiet -eq 0 || $cronmode -eq 1 ]]; then + if [[ ! -z $newcerts ]]; then + echo -n "Refreshed these SSL certs on '$r': $newcerts (restarted $okservs" + if [[ $nfail -ge 1 ]]; then + echo ", FAILED to restart $failservs)" + else + echo ")" + fi + fi + fi + dec + done +fi +exit $rv