lehook/lehook.sh

553 lines
15 KiB
Bash
Raw Permalink Normal View History

2021-06-13 13:42:59 +10:00
#!/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 <<EOF
DIR=$HOME/.lehook
NSUPDATE="nsupdate -k /var/named/acme-tsig-key.key"
# DNS server to make updates to
DNSSERVER="127.0.0.1"
# Address to use when talking to DNS server (useful for setups with views)
LOCALADDR="10.99.99.254"
TTL=300
certbase="/etc/letsencrypt/live"
domains="example.net"
email="root@example.net"
# Nameservers to check to see whether the update worked
# (may be different to DNSSERVER above is setups with a silent master)
check_nameservers="ns1.example.net ns2.example.net"
remotes="webserver1 webserver2.example.org webserver3.example.net"
remotecertdir=/etc/ssl
services="nginx httpd ngircd dovecot postfix"
EOF
else
echo "$configfile already exists. Did you mean -I? (install binary)"
2021-06-13 13:42:59 +10:00
fi
doinstall
}
function doinstall() {
local x
if [[ -z $DIR ]]; then
echo "Error: no install dir defined"
exit 1
elif [[ ! -d $DIR ]]; then
echo "Error: install dir $DIR doesn't exist"
exit 1
fi
echo "Creating hardlinks in $DIR..."
cp -f $0 $DIR/lehook.sh
for x in pre post deploy; do
ln -f $DIR/lehook.sh $DIR/lehook-${x}.sh
done
if [[ $(id -u) == "0" ]]; then
ln -f $DIR/lehook.sh /usr/local/bin/lehook.sh
else
echo "Calling sudo to install to /usr/local/bin, enter password if prompted"
sudo ln -f $DIR/lehook.sh /usr/local/bin/lehook.sh
fi
echo "Init complete. Files are in $DIR."
echo "Main binary in /usr/local/bin/lehook.sh."
2021-06-13 13:42:59 +10:00
}
function log() {
local showtoo txt
if [[ $1 == "-v" ]]; then
showtoo=1
shift 1
else
showtoo=0
fi
txt="$(date): $*"
echo "$txt" >>$LOG
[[ $showtoo -eq 1 ]] && echo "$txt"
}
2021-06-13 13:42:59 +10:00
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
2021-06-13 13:42:59 +10:00
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]"
2021-06-13 13:42:59 +10:00
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"
2021-06-13 13:42:59 +10:00
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"
2021-06-13 13:42:59 +10:00
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 [$rsum]"
2021-06-13 13:42:59 +10:00
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
2021-06-13 13:42:59 +10:00
recurseargs=""
if [[ -e $configfile ]]; then
. $configfile
fi
ARGS="Acd:fhiIqr:s:v"
2021-06-13 13:42:59 +10:00
while getopts "$ARGS" i; do
case "$i" in
A)
alsodeploy=1
;;
2021-06-13 13:42:59 +10:00
c)
cronmode=1
recurseargs="$recurseargs -$i"
2021-06-13 13:42:59 +10:00
;;
h)
usage;
exit 1;
;;
I)
doinstall;
exit 0;
;;
2021-06-13 13:42:59 +10:00
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
2021-06-13 13:42:59 +10:00
if [[ $0 =~ pre|post|deploy ]]; then
log -v "lehook running with cmd="$0" args='$*' CERTBOT_DOMAIN='$CERTBOT_DOMAIN' CERTBOT_VALIDATION='$CERTBOT_VALIDATION'"
2021-06-13 13:42:59 +10:00
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:"
2021-06-13 13:42:59 +10:00
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
2021-06-13 13:42:59 +10:00
certdirs=""
for d in $domains; do
[[ $d == -* ]] && continue; # Just in case we somehow catch arguments
2021-06-13 13:42:59 +10:00
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 for $domain on $r"
2021-06-13 13:42:59 +10:00
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"
2021-06-13 13:42:59 +10:00
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"
2021-06-13 13:42:59 +10:00
else
checkcmd="systemctl list-unit-files --state=enabled"
reloadcmd="systemctl restart XX"
2021-06-13 13:42:59 +10:00
fi
enabled=$(ssh root@$r "$checkcmd 2>&1" | awk '/:on|nabled/ { print $1 } ($2 == "+") { print $4 }')
2021-06-13 13:42:59 +10:00
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}"
2021-06-13 13:42:59 +10:00
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=""
deployargs=""
renewed=0
[[ $force -eq 1 ]] && extraargs="$extraargs --force-renewal"
[[ $alsodeploy -eq 1 ]] && deployargs="--deploy-hook $DIR/lehook-deploy.sh"
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 $deployargs $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"
if [[ $alsodeploy -eq 1 ]]; then
# Show which certs were deployed
grep "^Refreshed" <<< "$res" | sed 's/^Refreshed/Deployed/'
fi
fi
else
echo "Error renewing $this. Certbot output:"
echo "$res"
fi
done
# Not required - depooy is done via --deploy-hook
#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=$?
2021-06-13 13:42:59 +10:00
fi
exit $rv