Skip to content
Snippets Groups Projects
cm.sh 19.11 KiB
#!/usr/bin/env bash
# ======================================================================
#
# WRAPPER FOR ACME.SH 
# Let's Encrypt client
#
# requires
# - bash
# - openssl
# - curl
# - dig (opional)
# - acme.sh client
#
# ----------------------------------------------------------------------
# 2021-02-02  <axel.hahn@iml.unibe.ch>  first lines
# 2021-02-10  <axel.hahn@iml.unibe.ch>  compare hashes, logging
# 2021-02-12  <axel.hahn@iml.unibe.ch>  added self test
# 2021-02-17  <axel.hahn@iml.unibe.ch>  ensure checks list of aliases; new: optional host filter before adding a cert
# 2021-03-12  <axel.hahn@iml.unibe.ch>  create file for haproxy
# 2021-03-22  <axel.hahn@iml.unibe.ch>  for haproxy: use chained cert instead of server cert
# 2021-04-12  <axel.hahn@iml.unibe.ch>  reject multiple usage of fqdn in cli params
# 2021-04-12  <axel.hahn@iml.unibe.ch>  optional: force excecution with a given user only
# 2021-06-24  <axel.hahn@iml.unibe.ch>  added transfer command; delete files if acme.sh --install-cert ... failes
# 2021-07-14  <axel.hahn@iml.unibe.ch>  added _wait_for_free_slot in cert actions to execute multiple processes sequentially
# 2021-09-27  <axel.hahn@iml.unibe.ch>  softer behaviour: do not revoke changed certs (add does not stop; ensure does not delete)
# 2021-12-23  <axel.hahn@iml.unibe.ch>  added param --trace as 1st param to generate a trace log 
# ======================================================================


# ----------------------------------------------------------------------
#
# CONFIG
#
# ----------------------------------------------------------------------

logdir="./log"
touchfile="$logdir/lastchange.txt"
logfile="$logdir/certmanager.log"

csrfile="./templates/csr.txt"

line="_______________________________________________________________________________"

showdebug=1
writelog=1


# ----------------------------------------------------------------------
#
# INTERNAL FUNCTIONS
#
# ----------------------------------------------------------------------

# internal function; list certificates incl. creation date and renew date
function _listCerts(){
	$ACME --list	
}

# internal function; get a list of fqdn of all existing certs
function _listCertdomains(){
	_listCerts | sed -n '2,$p' | awk '{ print $1 }'
}

# internal function; checks if a certificate for a given FQDN already exists
# used in _certMustExist, _certMustNotExist
# param  string  FQDN
function _certExists(){
	_listCertdomains | grep "^${CM_fqdn}$" >/dev/null
}

# internal function; a certificate of a given FQDN must exist - otherwise
# the script will be aborted
# param  string  FQDN
function _certMustExist(){
	_certExists
	if [ $? -ne 0 ]; then
		echo "ERROR: cert ${CM_fqdn} was not added yet."
		exit 1
	fi
}

# internal function; a certificate of a given FQDN must not exist - otherwise
# the script will be aborted
# param  string  FQDN
function _certMustNotExist(){
	_certExists
	if [ $? -eq 0 ]; then
		echo "WARNING: cert ${CM_fqdn} was added already."
		# exit 1
		echo "Press Ctrl+C to abort within the next 10 sec..."
		sleep 10
	fi
}

# internal function: transfer generated/ updated cert data to a
# known directory (based on CM_diracme - see inc_config.sh)
# used in public_add and public_renew
# used in ADD and RENEW action
function _certTransfer(){
	_wd "--- acme internal data - ~/.acme.sh/${CM_fqdn}"
	ls -l ~/.acme.sh/${CM_fqdn}

	_wd "--- delete current files in ${CM_dircerts}/ if they already exist."
    test -d ${CM_dircerts} && rm -f "${CM_dircerts}/*" 2>/dev/null

	_wd "--- transfer acme.sh files to ${CM_dircerts}/"
	$ACME \
		--install-cert \
		-d ${CM_fqdn} \
		--cert-file       ${CM_outfile_cert}  \
		--fullchain-file  ${CM_outfile_chain} \
		--ca-file         ${CM_outfile_ca}
    if [ $? -ne 0 ]; then
        echo "ERROR occured during transfer. Removing files in ${CM_dircerts} to prevent strange effects..."
        rm -f "${CM_dircerts}/*"
        exit
    fi
	echo "OK."
	
	_wd "--- copy key to ${CM_dircerts}"
	cp ${CM_filekey} ${CM_outfile_key}

	_wd "--- create chained file for haproxy"
	cat ${CM_outfile_chain} ${CM_outfile_key} > ${CM_outfile_haproxy}

	_wd "--- content of output dir $CM_dircerts:"
	ls -l $CM_dircerts/*
}

# internal function; show md5 hashsums for certificate, csr and key
# for visual comparison if the match
function _certMatching(){
	local md5_csr=$(  test -f ${CM_filecsr}      & openssl req  -noout -modulus -in ${CM_filecsr}      | openssl md5 | cut -f 2 -d " " )
	local md5_key=$(  test -f ${CM_outfile_key}  & openssl rsa  -noout -modulus -in ${CM_outfile_key}  | openssl md5 | cut -f 2 -d " " )
	local md5_cert=$( test -f ${CM_outfile_cert} & openssl x509 -noout -modulus -in ${CM_outfile_cert} | openssl md5 | cut -f 2 -d " " )

	echo
	echo "--- compare hashes"
	echo "csr  : $md5_csr (used for creation of cert)"
	echo "key  : $md5_key"
	echo "cert : $md5_cert"
	if [ "$md5_key" = "$md5_cert" ]; then
		echo "OK, key and cert match :-)"
	else
		echo "ERROR: key and cert do NOT MATCH!"
	fi
	echo
}

# internal function: dig for given fqdn.
# Function stops if fqdn was not found in DNS.
# If dig is not found the function skips the DNS check.
# This function is used in _gencsr
# param  string  fqdn to check
function _checkDig(){
    local myfqdn=$1
    which dig >/dev/null
    if [ $? -eq 0 ]; then
        _wd "CHECK: $myfqdn exists in DNS (using dig) ..."
        dig $myfqdn | grep -v '^;' | grep $myfqdn 
        if [ $? -ne 0 ]; then
            echo "ERROR: not found. Was there a typo in the hostname??"
            exit 2
        fi
        _wd "OK"
    else
        _wd "SKIP: dig was not found"
    fi
    echo

}

# internal function; generate a csr file before creating a new certifcate
# this function is used in public_add
function _gencsr(){

	altdns=
	_checkDig $CM_fqdn
	for myalt in $*
	do
		altdns="${altdns}DNS:$myalt,"
	done
	altdns=$( echo $altdns | sed "s#,\$##" )
    _wd "--- $CM_fqdn"
	_wd "DNS alternative names: $altdns"

	rm -f $CM_filecnf $CM_filekey $CM_filecsr
	mkdir -p "${CM_dircsr}" 2>/dev/null

	cat $csrfile \
		| sed "s#__FQDN__#$CM_fqdn#g"  		\
		| sed "s#__ALTNAMES__#$altdns#g"	\
		> $CM_filecnf || exit 1

	# generate csr
	_wd "creating key and csr"
	openssl req -new -config $CM_filecnf -keyout $CM_filekey -out $CM_filecsr || exit 1

	# view csr
	# openssl req -noout -text -in $CM_filecsr
	ls -ltr $CM_filecnf $CM_filekey $CM_filecsr
}

# internal function; get a sorted list of DNS aliases in the current cert
function _getAliases(){
	_sortWords $(
		openssl x509 -noout -text -in ${CM_outfile_cert} \
			| grep -E "(DNS:)" \
			| sed "s#^\ *##g"  \
			| sed "s#DNS:##g"  \
			| sed "s#,##g"
	)
}

# internal function; check if a required 2nd CLI parameter was given
# if not the script will abort
function _requiresFqdn(){
	if [ -z "$CM_fqdn" ]; then
		echo "ERROR: 2nd parameter must be a FQDN for Main_Domain."
		exit 1
	fi
}

# internal function; it shows a message if the current instance uses a stage
# server. It shows a message that it is allowed to test arround ... or to be 
# careful with LE requests on a production system
function _testStaging(){
	echo $ACME_Params | grep "\-\-staging" >/dev/null
	if [ $? -eq 0 ]; then
		_wd "Using Let's Encrypt STAGE environment ..."
		_wd "You can test and mess around. Do not use certs in production."
	else
		_wd "Using Let's Encrypt LIVE environment for production."
		_wd "Be careful with count of connects to Let's Encrypt servers."
	fi
	echo
}
# internal function; if a user was set as CM_user then verify it with
# current user
function _testUser(){
	if [ ! -z "$CM_user" ]; then
		local _sUser=`id | cut -f 2 -d "(" | cut -f 1 -d ")"`
		if [[ $_sUser != "$CM_user" ]]; then
			echo "ERROR: Run this script under user [$CM_user] - not as $_sUser."
			exit 1
		fi
	fi

}

# set update message in a file
# param  string(s)  message
function _update(){
	echo "[$( date )] $*" > ${touchfile}
	test ${writelog} && echo "[$( date )] $*" >> ${logfile}
}

# "neverending" loop that waits until the current process is
# the one with lowest PID
function _wait_for_free_slot(){
    local _bWait=true
    typeset -i local _iFirstPID=0
    _wd "--- Need to wait until own process PID $$ is on top ... "
    while [ $_bWait = true ];
    do
        _iFirstPID=$( ps -ef | grep "bash.*$0" | grep -v "grep" | sort -k 2 -n | head -1 | awk '{ print $2}' )
        if [ $_iFirstPID -eq $$ ]; then
            _bWait=false
            _wd "OK. Go!"
        else
            _wd "- all instances"
            test ${showdebug} && ps -ef | grep "bash.*$0" | grep -v "grep" | sort -k 2 -n
            sleep 10
        fi
    done
}

# write debug output if showdebug is set to 1
function _wd(){
	test ${showdebug} && echo "DEBUG: $*"
}

# set environment for a single certificate based on FQDN
# param  string  FQDN
function _setenv(){
	CM_fqdn=$1
	CM_filecsr="${CM_dircsr}/${CM_fqdn}.csr"
	CM_filecnf="${CM_dircsr}/${CM_fqdn}.cnf"
	CM_filekey="${CM_dircsr}/${CM_fqdn}.key"

	CM_dircerts="${CM_diracme}/${CM_fqdn}"
	CM_outfile_cert=${CM_dircerts}/${CM_fqdn}.cert.cer
	CM_outfile_chain=${CM_dircerts}/${CM_fqdn}.fullchain.cer
	CM_outfile_key=${CM_dircerts}/${CM_fqdn}.key.pem
	CM_outfile_haproxy=${CM_dircerts}/${CM_fqdn}.haproxy.pem
	CM_outfile_ca=${CM_dircerts}/${CM_fqdn}.ca.cer

	# echo $CM_fqdn; set | grep "^CM_"; echo

}
# internal function; helper: sort words in alphabetic order
function _sortWords(){
	echo $* | tr " " "\n" | sort | tr "\n" " "
}

# internal function; verify fqdn in cli params - each fqdn is allowed only once.
# on error it shows the count of usage of each fqdn
function _testFqdncount(){
	typeset -i local iHostsInParam=$( echo $* | wc -w )
	typeset -i iHostsUniq=$( echo $* | tr " " "\n" | sort -u | wc -w )
	if [ $iHostsInParam -ne $iHostsUniq ]; then
		echo "ERROR: each given FQDN is allowed only once. You need to remove double entries."
		
		for myhost in $( echo $* | tr " " "\n" | sort -u )
		do
			typeset -i iHostcount=$( echo $* | tr " " "\n" | grep "^$myhost$" | wc -l )
			test $iHostcount -gt 1 && echo "  $iHostcount x $myhost"
		done
		echo
		exit 1
	fi
}
# ----------------------------------------------------------------------
#
# PUBLIC FUNCTIONS
#
# ----------------------------------------------------------------------

#
# pulic function ADD certificate
# 
function public_add(){
	_wait_for_free_slot
	_requiresFqdn
    _certMustNotExist

	for myhost in $( echo $CM_fqdn $*)
	do 
		echo $myhost | grep "$CM_certmatch" >/dev/null
		if [ $? -ne 0 ]; then
			echo "ERROR: host $myhost does not match [$CM_certmatch]."
			exit 1
		fi
	done
	_gencsr $CM_fqdn $*

	_wd "--- create output dir $dircerts"
	mkdir -p "${CM_dircerts}" 2>/dev/null

	_wd "--- csr data"
	$ACME --showcsr  --csr $CM_filecsr || exit 1

	_wd "--- create certificate"
	$ACME --signcsr --csr $CM_filecsr $ACME_Params 
	if [ $? -ne 0 ]; then
		echo "ERROR: adding cert failed. Trying to delete internal data ..."
		public_delete $CM_fqdn
		exit 1
	fi
	# $ACME --issue -d $CM_fqdn $ACME_Params || exit 1

	_certTransfer
	_certMatching

	_update "added $CM_fqdn $*"
}

#
# pulic function ADD OR RENEW certificate
# 
function public_ensure(){
	_wait_for_free_slot
	_requiresFqdn
	_certExists
	if [ $? -eq 0 ]; then
		_wd "--- cert $CM_fqdn was found ... compare aliases"
		local _newAliases=$( _sortWords $CM_fqdn $* )
		local _certAliases=$( _getAliases )

		_wd "from params: $_newAliases"
		_wd "inside cert: $_certAliases"
		if [ "$_newAliases" = "$_certAliases" ]; then
			_wd "--- DNS aliases match ... renew it (ignore --force - it comes from acme.sh)"
			public_renew $*
		else
			# _wd "--- DNS aliases do NOT match ... deleting cert and create a new one"
			# public_delete $*
			_wd "--- DNS aliases do NOT match ... creating a new one"
			public_add $*
		fi
	else
		_wd "--- cert does mot exist ... add it"
		public_add $*
	fi
}

#
# public function to delete a cert
#
function public_delete(){
	_wait_for_free_slot
	_requiresFqdn
	_certMustExist

	# TODO: revoke it too??
	_wd "--- revoke cert"
	$ACME --revoke -d ${CM_fqdn} $ACME_Params
	_wd "--- delete ACME.SH data"
	$ACME --remove -d ${CM_fqdn} $ACME_Params
	_wd "--- delete local data"
	rm -rf ${CM_dircerts} ${CM_filecnf} ${CM_filekey} ${CM_filecsr} ~/.acme.sh/${CM_fqdn} && echo OK
	_update "deleted ${CM_fqdn}"
}


#
# public function; list certificates incl. creation date and renew date
# 
function public_list(){
	_listCerts
}

#
# public function - renew a certificate
# param  string  fqdn of domain to renew
function public_renew(){
	_wait_for_free_slot
	_requiresFqdn
	_certMustExist
	$ACME --renew -d ${CM_fqdn} $ACME_Params
	local _rc=$?

	case $_rc in
		0)
			_certTransfer
			_certMatching
			_update "renewed ${CM_fqdn}"
			;;
		2)
			_wd "renew was skipped ... we need to wait a while."
			;;
		*)
			_wd "Error ocured."
			exit $_rc
	esac
}

#
# public function - renew all certificates (to be used in a cronjob)
# no params
function public_renew-all(){

	_listCertdomains | while read mydomain
	do
		_wd "--- renew $mydomain"
		_setenv ${mydomain}
		public_renew
	done

}


# internal function; helper for selftest to handle a single selftest
# if a given command is successful it shows "OK" or "ERROR" followed
# by the label inparam 2.
# The value _iErrors will be incremented by 1 if an error occured.
# param  string  command to verify
# param  string  output label
function _selftestItem(){
	local _check=$1
	local _label=$2
	local _status="OK:"

	eval "$_check"
	if [ $? -ne 0 ]; then
		_status="ERROR: the check failed for the test of -"
		_iErrors=$_iErrors+1
	fi

	echo "$_status $_label"
}

#
# list existing certs
# no params
function public_selftest(){

	typeset -i _iErrors=0

	echo
	echo --- dependencies
	_selftestItem "which openssl" "opemssl was found"
	_selftestItem "which curl" "curl was found"
	echo

	echo --- acme.sh client
	_selftestItem "ls -ld ${ACME}" "${ACME} exits"
	_selftestItem "test -x ${ACME}" "${ACME} is executable"
	echo

	echo --- acme.sh installation \(may fail in future releases of acme.sh\)
	_selftestItem "ls -ld ~/.acme.sh" "internal acme data were found = [acme.sh --install] was done"
	_selftestItem "test -w ~/.acme.sh/" "it is writable"
	echo

	echo --- csr template
	_selftestItem "ls -ld ${csrfile}"  "csr base template exists"
	_selftestItem "test -r ${csrfile}" "it is readable"
	echo

	echo --- output directory for csr and key
	_selftestItem "ls -ld ${CM_dircsr}"  "data dir for csr exists"
	_selftestItem "test -w ${CM_dircsr}" "it is writable"
	echo

	echo --- output dir for centralized place of certificates
	_selftestItem "ls -ld ${CM_diracme}"  "central output dir for certificate data exists"
	_selftestItem "test -w ${CM_diracme}" "it is writable"
	echo


	echo --- logs
	_selftestItem "ls -ld ./log/" "Logdir exists"
	_selftestItem "test -w" "Logdir is writable"
	test -f $logfile    &&  _selftestItem  "test -w $logfile" "Logfile $logfile is writable"
	test -f $touchfile  && _selftestItem "test -w $touchfile" "Logfile $touchfile is writable"
	echo

	echo --- Errors: $_iErrors
	test $_iErrors -eq 0 && echo "OK, this looks fine."
	echo
	exit $_iErrors
}

#
# list existing certs
# no params
function public_show(){
	_requiresFqdn
	_certMustExist

	ls -l ${CM_filecsr} ${CM_dircerts}/*
	_certMatching
	echo $line
	echo CSR $CM_filecsr
	openssl req -noout -text -in $CM_filecsr | grep -E "(Subject:|DNS:)" | sed "s#^\ *##g"

	for myfile in ${CM_outfile_cert} ${CM_outfile_haproxy}
	do
        echo $line
        echo Cert ${myfile}
        # openssl x509 -noout -text -in ${CM_outfile_cert}
        openssl x509 -noout -text -in ${myfile} | grep -E "(Issuer:|Subject:|Not\ |DNS:)"| sed "s#^\ *##g"
    done
}

# Transfer cert from acme.sh internal cache to our output dir again
function public_transfer(){
	_wait_for_free_slot
	_requiresFqdn
	_certExists

	_certTransfer
}

# ----------------------------------------------------------------------
#
# main
#
# ----------------------------------------------------------------------

cd $( dirname $0 )

if [ "$1" = "--trace" ]; then
	tracelog="$logdir/trace__$2__$3__`date +%Y-%m-%d__%H-%M-%S`.log"
	exec  > >(tee -a ${tracelog}    )
	exec 2> >(tee -a ${tracelog} >&2)
	echo "TRACELOG was triggered."
	echo "TIME   : $(date)"
	echo "COMMAND: $0 $*"
	echo "LOG    : $tracelog"

	# set -vx
	shift 1
fi

cat <<ENDOFHEADER
$line


             	- - - ---===>>> CERT MANAGER <<<===--- - - -

$line

ENDOFHEADER

which openssl >/dev/null || exit 1

. ./inc_config.sh
if [ $? -ne 0 ]; then
	echo "ERROR: loading the config failed."
	echo "Copy the inc_config.sh.dist to inc_config.sh and make your settings in it."
	echo
	exit 1
fi

_testUser
_testStaging

test -z "${CM_diracme}" && CM_diracme=./certs
test -z "${CM_dircsr}"  && CM_dircsr=./csr

grep "function\ public_$1" $( basename $0 ) >/dev/null 
if [ $# -gt 0 -a $? -eq 0 ]; then
	# _wd $*
	action=$1
	CM_fqdn=$2
	shift 2

	_testFqdncount $CM_fqdn $*

	test -z "${ACME}" && ACME=$( which acme.sh )
	if [ ! -x "${ACME}" ]; then
		echo "ERROR: acme.sh not found. You need to install acme.sh client and configure it in inc_config.sh."
		exit 1
	fi

	_setenv $CM_fqdn

	_wd "A C T I O N -->> $action <<--"
	eval "public_$action $*"
else
	self=$( basename $0 )
	cat <<EOF

HELP

The basic syntax is
$self [--trace] ACTION [FQDN] [ALIAS_1 [.. ALIAS_N]]

The ACTIONs for SINGLE certificate handlings are:

        add FQDN [.. FQDN-N] 
                create new certificate
                The first FQDN is a hostname to generate the certificate for. 
                Following multiple hostnames will be used as DNS aliases in the 
                same certificate.
                It updates files in ${CM_diracme}

        ensure FQDN [.. FQDN-N] 
                It ensures that a certificate with given aliases exists and is up to date.
                This param is for simple usage in automation tools like Ansible or Puppet.
                It is required to add all aliases as parameters what is unhandy for
                direct usage on cli.

                If the cert does not exist it will be created (see "add").
                If fqdn and aliases are the same like in the certificate it performs a renew.
                If fqdn and aliases differ:
                - the current certificate will be rejected + deleted (see "delete")
                - a new certificate will be added ()

        delete FQDN
                delete all files of a given certificate

        renew FQDN
                renew (an already added) certificate
                and update files in ${CM_diracme}

        show FQDN
                show place of csr + certificate data and show basic certificate data
                (issuer, subject, aliases, ending date)

        transfer FQDN
                Transfer cert from acme.sh internal cache to our output dir again.
                It is done during add or renew. With transfer command you can repeat it.

ACTIONs for ALL certs

        list
                list all certificates including creation and renew date

        renew-all
                renew all certificates (fast mode - without --force)
                and update files in ${CM_diracme}
                It is useful for a cronjob.

other ACTIONs

        selftest
                check of health with current setup and requirements.
                This command is helpful for initial setups.

OPTIONS
        --trace (it must be the 1st parameter) 
                the output additionally will be written into a tracelog file 
                below $logdir.

EOF
fi

echo
_testStaging