#!/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 <>$LOG [[ $showtoo -eq 1 ]] && echo "$txt" } function out() { local nl nl="\n" if [[ $1 == "-n" ]]; then nl="" shift fi [[ $quiet -ne 1 ]] && printf "%*s%s$nl" $((indent * 2)) '' "$*" } function inc() { indent=$((indent + 1)) } function dec() { indent=$((indent - 1)) } function copycert() { local err src dst cer src=$1 dst=$2 cer=$(basename $src) inc out -n "$cer: " err="$(scp "$src" $dst 2>&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 err 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 "Error: [$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 " -A In generate/renew modes, Also deploy certs if needed" 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 " -I Install binary symlinks in home dir" 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 alsodeploy=0 recurseargs="" if [[ -e $configfile ]]; then . $configfile fi ARGS="Acd:fhiIqr:s:v" while getopts "$ARGS" i; do case "$i" in A) alsodeploy=1 ;; c) cronmode=1 recurseargs="$recurseargs -c" ;; h) usage; exit 1; ;; I) doinstall; exit 0; ;; 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 LOG=/var/log/lehook.log if [[ $0 =~ pre|post|deploy ]]; then log -v "lehook running with cmd="$0" args='$*' CERTBOT_DOMAIN='$CERTBOT_DOMAIN' CERTBOT_VALIDATION='$CERTBOT_VALIDATION'" set -e set -u set -o pipefail if [[ -z $CERTBOT_DOMAIN ]]; then log -v "Error: no \$CERTBOT_DOMAIN env var received from certbot. vars are:" set | grep CERTBOT exit 1 fi if [[ -z $CERTBOT_VALIDATION ]]; then log -v "Error: no \$CERTBOT_VALIDATION 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 dodeploy() { local certdirs d rv local r newcerta ip domain dest_cert dest_priv local local_cert_sum local_priv_sum needupate local isrh checkcmd reloadcmd enabled local nok nfail okservs failservs s 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 "* Restarting services" inc isrh=$(ssh root@$r "test -f /etc/redhat-release && echo yes || echo no") if [[ $isrh == "no" ]]; then checkcmd="service --status-all" reloadcmd="service XX restart && echo Ok || echo failed" else checkcmd="systemctl list-unit-files --state=enabled" reloadcmd="systemctl restart XX" fi enabled=$(ssh root@$r "$checkcmd 2>&1" | awk '/:on|nabled/ { print $1 } ($2 == "+") { print $4 }') 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 } function wait_for_dns_update() { # wait_for_dns_update [-v for inverse match] domain value local ns record secs grv wantgrv verb if [[ $1 == "-v" ]]; then # want grep NOT to match verb="does NOT contain" wantgrv=1 shift 1 else # want grep to match verb="contains" wantgrv=0 fi record="_acme-challenge.$1" value="$2" secs=5 log -v "Delaying until dns is done with:" log -v " servers: $check_nameservers" log -v " record: $record" log -v "desired value: $verb $value" log -v " delay: $secs" for ns in $check_nameservers; do [[ $verbose -eq 1 || $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) (echo "$res" | grep -q "$value") && grv=0 || grv=1 while [[ $grv -ne $wantgrv ]]; do sleep $secs [[ $verbose -eq 1 || $quiet -eq 0 || $cronmode -eq 1 ]] && echo -n "." res=$(dig +short @${ns} $record IN TXT | tr -d '"' 2>&1) (echo "$res" | grep -q "$value") && grv=0 || grv=1 done [[ $verbose -eq 1 || $quiet -eq 0 || $cronmode -eq 1 ]] && echo "done" done log -v "All DNS updates done." return 0 } 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}") log -v "PREHOOK - Adding challenge key '$DATA' for domain "$DOMAIN" via:" log -v "$cmd" | sed -e 's/^/ /' echo "$cmd" | $NSUPDATE rv=$? log -v "PREHOOK - Done, now waiting for dns update." wait_for_dns_update "$DOMAIN" "$DATA" log -v "PREHOOK - Waiting for any children" wait log -v "PREHOOK - About to exit with code $rv" 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/^/ /' log -v "POSTHOOK - Removing challenge key '$DATA' via:" log -v "$cmd" | sed -e 's/^/ /' echo "$cmd" | $NSUPDATE rv=$? log -v "POSTHOOK - Done, now waiting for dns update." wait_for_dns_update -v "$DOMAIN" "$DATA" log -v "POSTHOOK - Waiting for any children" wait log -v "POSTHOOK - About to exit with code $rv" 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 --expand -d "${this},*.${this}" 2>&1) rv=$((rv + $?)) [[ $verbose -eq 1 ]] && echo "$res" done if [[ $rv -eq 0 && $alsodeploy -eq 1 ]]; then dodeploy rv=$? fi elif [[ $mode == "renew" ]]; then rv=0 extraargs="" renewed=0 [[ $force -eq 1 ]] && extraargs="$extraargs --force-renewal" cp -f /dev/null /tmp/lh-renew for this in $domains; do [[ $verbose -eq 1 ]] && echo "Will run: 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" | tee -a /tmp/lh-renew 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 | tee -a /tmp/lh-renew) 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 renewed=$((renewed + 1)) 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 if [[ $rv -eq 0 && $alsodeploy -eq 1 && $renewed -gt 0 ]]; then dodeploy rv=$? fi 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 dodeploy rv=$? fi exit $rv