Files
cis/script/ssl/renewCerts.sh
Martin Berghaus 0366baf86f refactoring
2025-10-31 20:39:32 +01:00

552 lines
20 KiB
Bash

#!/bin/bash
# curl http://your.domain.net/.well-known/acme-challenge/test
# curl http://85.183.145.8/.well-known/acme-challenge/test
# /var/www/letsencrypt/.well-known/acme-challenge
function checkConfigViaHttp() {
local _DOMAIN _MODE _LOCAL_FOLDER
_MODE="${1:?"checkConfigViaHttp(): Missing first parameter MODE"}"
_DOMAIN="${2:?"checkConfigViaHttp(): Missing second parameter DOMAIN"}"
_LOCAL_FOLDER="/var/www/letsencrypt/.well-known/acme-challenge/"
readonly _DOMAIN _MODE _LOCAL_FOLDER
local _LOCAL_FILE _LOCAL_URL _PUBLIC_URL
_LOCAL_FILE="${_LOCAL_FOLDER}${_DOMAIN}"
_LOCAL_URL="http://localhost/.well-known/acme-challenge/${_DOMAIN}"
_PUBLIC_URL="http://${_DOMAIN}/.well-known/acme-challenge/${_DOMAIN}"
readonly _LOCAL_FILE _LOCAL_URL _PUBLIC_URL
# Skip check if mode is not http
[ "${_MODE}" != "http" ] \
&& return 0
# Fail because wildcard certificate
[ "${_MODE}" == "http" ] \
&& isWildcardCertificate "${_DOMAIN}" \
&& echo "Wildcard certificates are not supported via HTTP." \
&& return 1
_CHECK="Available on $(hostname)@$(date)"
echo -n "Check domain '${_DOMAIN}'..." \
&& [ -d "${_LOCAL_FOLDER}" ] \
&& echo "${_CHECK}" > "${_LOCAL_FILE}" \
&& curl -4s "${_PUBLIC_URL}" | grep -q "${_CHECK}" \
&& echo " Done" \
&& return 0
echo
echo "FAILED: configuration of domain '${_DOMAIN}' is INCORRECT:"
echo -n " ${_PUBLIC_URL} was not found."
curl -4s "${_LOCAL_URL}" | grep -q "${_CHECK}" \
&& echo " (check DNS first)" \
|| echo " (check Webserver first)"
return 1
}
function isActive() {
local _DOMAIN _MODE _RESULT_CERTS
_RESULT_CERTS="${RESULT_CERTS:?"isActive(): Missing global parameter RESULT_CERTS"}"
_MODE="${1:?"isActive(): Missing first parameter MODE"}"
_DOMAIN="${2:?"isActive(): Missing second parameter DOMAIN"}"
readonly _DOMAIN _MODE _RESULT_CERTS
# If mode is dns the domain is active always
[ "${_MODE}" == "dns" ] \
&& return 0
nginx -T 2> /dev/null | grep -q "${_RESULT_CERTS}${_DOMAIN}/fullchain.crt" \
&& return 0
echo "Domain '${_DOMAIN}' is inaktiv and therefore it will be skipped."
return 1
}
function isGitRepository() {
local _FOLDER
_FOLDER="${1:?"isGitRepository(): Missing first parameter FOLDER"}"
readonly _FOLDER
git -C "${_FOLDER}" ls-tree main &> /dev/null \
&& return 0
return 1
}
function isWildcardCertificate() {
local _DOMAIN
_DOMAIN="${1:?"isWildcardCertificate(): Missing first parameter DOMAIN"}"
readonly _DOMAIN
echo "${_DOMAIN}" | grep -q -F "_." \
&& return 0
echo "${_DOMAIN}" | grep -q -F "*." \
&& return 0
return 1
}
function tryGitPush() {
local _DOMAIN _NOW _RESULT_CERTS
_RESULT_CERTS="${RESULT_CERTS:?"tryGitPush(): Missing global parameter RESULT_CERTS"}"
_DOMAIN="${1:?"tryGitPush(): Missing first parameter DOMAIN"}"
_NOW="$(date +%Y%m%d_%H%M)"
readonly _DOMAIN _NOW _RESULT_CERTS
! isGitRepository "${_RESULT_CERTS}" \
&& echo \
&& echo "Folder '${_RESULT_CERTS}' is not part of a git repository, therefore nothing will be pushed." \
&& return 1
pushd "${_RESULT_CERTS}" > /dev/null
git pull > /dev/null
git add * > /dev/null
git commit -m "${_NOW} - Certificate for '${_DOMAIN}' was updated." \
&& git push > /dev/null \
&& popd > /dev/null \
&& echo "SUCCESS: certificate for '${_DOMAIN}' pushed." \
&& return 0
popd > /dev/null
echo "FAILED: unable to push certificate for '${_DOMAIN}'."
return 0
}
function own() {
! [ -d "${RESULT_CERTS:?"own(): Missing global parameter RESULT_CERTS"}" ] \
&& echo "Trying to derive domain names from subfolders of '${RESULT_CERTS}', but it is not a folder!" \
&& return 1
local _DOMAINS _MODE
_DOMAINS=("${RESULT_CERTS}"*)
_MODE="${1:?"own(): Missing first parameter MODE"}"
readonly _DOMAINS _MODE
local _domain
for _domain in "${_DOMAINS[@]}"; do
# just take names of folders
! [ -d "${_domain}" ] && continue
# cut pfad (like basename)
_domain="${_domain##*/}"
# folder default => skip
[ "${_domain}" == "default" ] && continue
case "${_MODE}" in
dns)
# dns supports all options
;;
http)
# http and wildcard certifikate => skip
isWildcardCertificate "${_domain}" && continue
# ssl on domain inaktiv => skip
! isActive "{_MODE}" "${_domain}" && continue
;;
*)
echo "Unknown mode: ${_MODE}"
return 1
;;
esac
single "${_MODE}" "${_domain}" "${2}"
done
return 0
}
function continueIssuingCertificate() {
local _CERT_FILE_FULLCHAIN _DOMAIN
_CERT_FILE_FULLCHAIN="${1:?"continueIssuingCertificate(): Missing first parameter CERT_FILE_FULLCHAIN"}"
_DOMAIN="${2:?"continueIssuingCertificate(): Missing second parameter DOMAIN"}"
local _CERT_FILE_FULLCHAIN _DOMAIN
local _PRETTY_DOMAIN
_PRETTY_DOMAIN="$(printPrettyDomain ${_DOMAIN})"
readonly _PRETTY_DOMAIN
# forced => should be issued
[ "${3:-""}" == "--force" ] \
&& echo "Certificate for domain '${_PRETTY_DOMAIN}' is forced to be issued." \
&& return 0
# no cert => should be issued
! [ -f "${_CERT_FILE_FULLCHAIN}" ] \
&& echo "No certificate for domain '${_PRETTY_DOMAIN}', so it will be issued." \
&& return 0
local _ENDDATE _NOW _REMAINING_DAYS
_ENDDATE="$(openssl x509 -enddate -noout -in ${_CERT_FILE_FULLCHAIN} | cut -d= -f2)"
_ENDDATE="$(date --date="${_ENDDATE}" --utc +%s)"
_NOW="$(date --date now +%s)"
_REMAINING_DAYS="$(( (_ENDDATE - _NOW) / 86400 ))"
readonly _ENDDATE _NOW _REMAINING_DAYS
# less than 30 days remaining => should be issued
[ "${_REMAINING_DAYS}" -le "30" ] \
&& echo "Certificate for domain '${_PRETTY_DOMAIN}' (${_REMAINING_DAYS} days remaining) will be issued." \
&& return 0
echo "Certificate for domain '${_PRETTY_DOMAIN}' (${_REMAINING_DAYS} days remaining) will be skipped."
return 1
}
function printBaseDomain() {
local _DOMAIN
_DOMAIN="${1:?"printBaseDomain(): Missing first parameter DOMAIN"}"
readonly _DOMAIN
local _BASE_DOMAIN
# cut front '*.' or '_.'
_BASE_DOMAIN="${_DOMAIN#\*.}"
_BASE_DOMAIN="${_BASE_DOMAIN#_.}"
readonly _BASE_DOMAIN
echo "${_BASE_DOMAIN}" \
&& return 0
return 1
}
function printPrettyDomain() {
local _BASE_DOMAIN _DOMAIN
_DOMAIN="${1:?"printPrettyDomain(): Missing first parameter DOMAIN"}"
_BASE_DOMAIN="$(printBaseDomain ${_DOMAIN})"
readonly _BASE_DOMAIN _DOMAIN
isWildcardCertificate "${_DOMAIN}" \
&& echo "*.${_BASE_DOMAIN}" \
&& return 0
echo "${_DOMAIN}" \
&& return 0
return 1
}
function printFullDomainFolder() {
local _BASE_DOMAIN _DOMAIN _RESULT_CERTS
_RESULT_CERTS="${RESULT_CERTS:?"printFullDomainFolder(): Missing global parameter RESULT_CERTS"}"
_DOMAIN="${1:?"printFullDomainFolder(): Missing first parameter DOMAIN"}"
_BASE_DOMAIN="$(printBaseDomain ${_DOMAIN})"
readonly _BASE_DOMAIN _DOMAIN _RESULT_CERTS
isWildcardCertificate "${_DOMAIN}" \
&& echo "${_RESULT_CERTS}_.${_BASE_DOMAIN}/" \
&& return 0
echo "${_RESULT_CERTS}${_DOMAIN}/" \
&& return 0
return 1
}
function prepareFullDomainFolder() {
local _DOMAIN _DOMAIN_FOLDER
_DOMAIN="${1:?"prepareFullDomainFolder(): Missing first parameter DOMAIN"}"
_DOMAIN_FOLDER="$(printFullDomainFolder "${_DOMAIN}")"
readonly _DOMAIN _DOMAIN_FOLDER
[ -d "${_DOMAIN_FOLDER}" ] \
&& return 0
# create folder for results
echo -n "Creating folder '${_DOMAIN_FOLDER}'... " \
&& mkdir -p "${_DOMAIN_FOLDER}" \
&& echo "Done"
[ -d "${_DOMAIN_FOLDER}" ] \
&& return 0
return 1
}
function prepareAndCheckAliasDomain() {
local _ALIAS_DOMAIN _DOMAIN
_DOMAIN="${1:?"prepareAndCheckAliasDomain(): Missing first parameter DOMAIN"}"
_ALIAS_DOMAIN="${2:?"prepareAndCheckAliasDomain(): Missing second parameter ALIAS_DOMAIN"}"
readonly _ALIAS_DOMAIN _DOMAIN
local _BASE_DOMAIN _CHALLENGE_ALIAS_DOMAIN_FILE _DOMAIN_FOLDER
_BASE_DOMAIN="$(printBaseDomain ${_DOMAIN})"
_DOMAIN_FOLDER="$(printFullDomainFolder ${_DOMAIN})"
_CHALLENGE_ALIAS_DOMAIN_FILE="${_DOMAIN_FOLDER}challenge-alias-domain"
readonly _BASE_DOMAIN _CHALLENGE_ALIAS_DOMAIN_FILE _DOMAIN_FOLDER
[ -d "${_DOMAIN_FOLDER}" ] \
&& [ "$(dig +short _acme-challenge.${_BASE_DOMAIN} CNAME)" == "_acme-challenge.${_ALIAS_DOMAIN}." ] \
&& echo "${_ALIAS_DOMAIN}" > "${_CHALLENGE_ALIAS_DOMAIN_FILE}" \
&& echo "SUCCESS: alias domain '${_ALIAS_DOMAIN}' is used when issuing certificates for '${_BASE_DOMAIN}' via DNS." \
&& return 0
echo "FAILED: unable to use alias domain '${_ALIAS_DOMAIN}' to issue certificates for '${_BASE_DOMAIN}'."
echo " You have to configure your domain '${_BASE_DOMAIN}' first before you can use the alias domain as proof."
echo " So check if there is a CNAME entry '_acme-challenge.${_BASE_DOMAIN}' pointing to:"
echo " - '_acme-challenge.${_ALIAS_DOMAIN}'"
return 1
}
function single() {
local _ACME_FILE _DOMAIN _MODE _RESULT_CERTS
_RESULT_CERTS="${RESULT_CERTS:?"single(): Missing global parameter RESULT_CERTS"}"
_ACME_FILE="${ACME_FILE:?"single(): Missing global parameter ACME_FILE"}"
_MODE="${1:?"single(): Missing first parameter MODE"}"
_DOMAIN="${2:?"single(): Missing second parameter DOMAIN"}"
readonly _ACME_FILE _DOMAIN _MODE _RESULT_CERTS
local _BASE_DOMAIN _CHALLENGE_ALIAS_DOMAIN_FILE _DOMAIN_FOLDER _PRETTY_DOMAIN
_BASE_DOMAIN="$(printBaseDomain ${_DOMAIN})"
_DOMAIN_FOLDER="$(printFullDomainFolder ${_DOMAIN})"
_PRETTY_DOMAIN="$(printPrettyDomain ${_DOMAIN})"
_CHALLENGE_ALIAS_DOMAIN_FILE="${_DOMAIN_FOLDER}challenge-alias-domain"
readonly _BASE_DOMAIN _CHALLENGE_ALIAS_DOMAIN_FILE _DOMAIN_FOLDER _PRETTY_DOMAIN
! [ -f "${_ACME_FILE}" ] \
&& echo "Program 'acme.sh' seams not to be installed. Try run 'renewCerts.sh --setup'." \
&& return 1
# cancel on broken configuration
! checkConfigViaHttp "${_MODE}" "${_DOMAIN}" \
&& return 1
# cancel if folder is not prepared
! [ -d "${_DOMAIN_FOLDER}" ] \
&& echo "Certificate of domain '${_PRETTY_DOMAIN}' skipped because of missing folder:" \
&& echo " - '${_DOMAIN_FOLDER}'" \
&& return 1
# check enddate if third parameter is not --force
! continueIssuingCertificate "${_DOMAIN_FOLDER}fullchain.crt" "${_DOMAIN}" "${3:-""}" \
&& return 0
# backup the keys
[ -f "${_DOMAIN_FOLDER}fullchain.crt" ] \
&& cp "${_DOMAIN_FOLDER}fullchain.crt" "${_DOMAIN_FOLDER}fullchain.crt.bak"
[ -f "${_DOMAIN_FOLDER}private.key" ] \
&& cp --preserve=mode,ownership "${_DOMAIN_FOLDER}private.key" "${_DOMAIN_FOLDER}private.key.bak"
local _OPTIONS
# always --force because we check expiring on ourself
# _OPTIONS="--issue --force --test"
_OPTIONS="--issue --force"
if [ "${_MODE}" == "dns" ]; then
_OPTIONS="${_OPTIONS} --dns ${AUTOACME_DNS_PROVIDER:?"single(): Missing global parameter AUTOACME_DNS_PROVIDER"}"
[ -f "${_CHALLENGE_ALIAS_DOMAIN_FILE}" ] \
&& _OPTIONS="${_OPTIONS} --challenge-alias $(cat "${_CHALLENGE_ALIAS_DOMAIN_FILE}")"
isWildcardCertificate "${_DOMAIN}" \
&& _OPTIONS="${_OPTIONS} --domain ${_PRETTY_DOMAIN}"
elif [ "${_MODE}" == "http" ]; then
_OPTIONS="${_OPTIONS} --webroot /var/www/letsencrypt"
fi
readonly _OPTIONS
${_ACME_FILE} ${_OPTIONS} \
--domain "${_BASE_DOMAIN}" \
--server "letsencrypt" \
--keylength "ec-384" \
--fullchain-file "${_DOMAIN_FOLDER}fullchain.crt" \
--key-file "${_DOMAIN_FOLDER}private.key" \
&& openssl pkcs12 -export -in "${_DOMAIN_FOLDER}fullchain.crt" -inkey "${_DOMAIN_FOLDER}private.key" -out "${_DOMAIN_FOLDER}bundle.pkx" -passout pass: \
&& echo "Certificate of domain '${_PRETTY_DOMAIN}' was updated." \
&& tryGitPush "${_PRETTY_DOMAIN}" \
&& return 0
echo "Certificate of domain '${_PRETTY_DOMAIN}' remains unchanged."
return 0
}
function isInstalled() {
local _ACME_FILE
_ACME_FILE="${ACME_FILE:?"isInstalled(): Missing global parameter ACME_FILE"}"
readonly _ACME_FILE
[ -f "${_ACME_FILE}" ] \
&& echo "Following version of acme.sh is installed:" \
&& echo "------------------------------------------" \
&& ${_ACME_FILE} --version | tail -n 1 \
&& return 0
return 1
}
function extractTarArchive() {
local _ACME_SETUP_FILE _ACME_TAR_FILE
_ACME_SETUP_FILE="${ACME_SETUP_FILE:?"extractTarArchive(): Missing global parameter ACME_SETUP_FILE"}"
_ACME_TAR_FILE="${ACME_TAR_FILE:?"extractTarArchive(): Missing global parameter ACME_TAR_FILE"}"
readonly _ACME_SETUP_FILE _ACME_TAR_FILE
# extracted file already exists
[ -f "${_ACME_SETUP_FILE}" ] \
&& return 0
[ -f "${_ACME_TAR_FILE}" ] \
&& mkdir -p "/tmp/acme.sh-setup/" \
&& tar -xzf "${_ACME_TAR_FILE}" -C "/tmp/acme.sh-setup/" \
&& [ -f "${_ACME_SETUP_FILE}" ] \
&& return 0
echo "Missing setup file '${_ACME_SETUP_FILE}' after trying to extract '${_ACME_TAR_FILE}'"
return 1
}
function setup() {
local _ACME_SETUP_FILE
_ACME_SETUP_FILE="${ACME_SETUP_FILE:?"setup(): Missing global parameter ACME_SETUP_FILE"}"
readonly _ACME_SETUP_FILE
isInstalled \
&& return 0
! [ $(id -u) == 0 ] \
&& echo "Setup requires execution as user 'root'." \
&& exit 1
! [ "$(echo $HOME)" == "/root" ] \
&& echo "The setup is executed with 'root' privileges but not in the 'root' user environment." \
&& exit 1
! extractTarArchive \
&& exit 1
echo "Starting install of acme.sh:"
echo "----------------------------"
pushd "${_ACME_SETUP_FILE%/*}" > /dev/null 2>&1 #Removes shortest matching pattern '/*' from the end
./acme.sh --install --no-cron --no-profile 2>&1
popd > /dev/null 2>&1
isInstalled \
&& echo \
&& echo 'Now this script can be added into cron-tab (crontab -e), like this e.g.:' \
&& echo \
&& echo '# Each day at 6:00am renew certificates:' \
&& echo '0 6 * * * /renewCerts.sh --http --own > /var/log/renewCerts.sh.log 2>&1' \
&& return 0
echo "Something went wrong during setup."
return 1
}
function usage() {
echo
echo 'Commands:'
echo ' --prepare DOMAIN --usingAlias ALIAS-DOMAIN : Prepares a domain to issue certificate using an alias domain in DNS mode.'
echo ' See: https://github.com/acmesh-official/acme.sh/wiki/DNS-alias-mode'
echo ' --dns --single DOMAIN [--force] : Issues a certificate for the given domain using DNS mode.'
echo ' --dns-withAlias --single DOMAIN [--force] : Issues a certificate for the given domain using DNS mode with challange alias.'
echo ' --http --single DOMAIN [--force] : Issues a certificate for the given domain using HTTP mode.'
echo
echo ' (--dns|--http) --own [--force] : Iterates all domains found in RESULT_CERTS.'
echo
echo 'Current environment:'
echo " Full name of this script: OWN_FULLNAME='${OWN_FULLNAME}'"
echo " Configuration:"
echo " Version of 'acme.sh' that will be installed: ACME_VERSION='${ACME_VERSION}'"
echo " Tar file containing the setup of 'acme.sh': ACME_TAR_FILE='${ACME_TAR_FILE}'"
echo " Setup file of 'acme.sh' after extraction: ACME_SETUP_FILE='${ACME_SETUP_FILE}'"
echo " Full name of the installed script 'acme.sh': ACME_FILE='${ACME_FILE}'"
echo " Output:"
echo " Path were the issued certificate are saved: RESULT_CERTS='${RESULT_CERTS}'"
return 0
}
function main() {
echo
[ -f "/autoACME.env" ] \
&& source "/autoACME.env" \
&& echo "[$(date)] Environment '/autoACME.env' loaded."
local ACME_FILE ACME_VERSION OWN_FULLNAME
OWN_FULLNAME="$(readlink -e ${0})"
ACME_FILE="/root/.acme.sh/acme.sh"
ACME_VERSION="acme.sh-3.1.1"
readonly ACME_FILE ACME_VERSION OWN_FULLNAME
local ACME_SETUP_FILE ACME_TAR_FILE RESULT_CERTS
ACME_SETUP_FILE="/tmp/acme.sh-setup/${ACME_VERSION}/acme.sh"
ACME_TAR_FILE="${OWN_FULLNAME%/*}/${ACME_VERSION}.tar.gz"
RESULT_CERTS="${AUTOACME_RESULT_CERTS%/}" #Removes shortest matching pattern '/' from the end
RESULT_CERTS="${RESULT_CERTS:-"/etc/nginx/ssl"}/"
readonly ACME_SETUP_FILE ACME_TAR_FILE RESULT_CERTS
local REPOSITORY_URL
isGitRepository "${RESULT_CERTS}" \
&& REPOSITORY_URL="$(git -C ${RESULT_CERTS} config --get remote.origin.url)"
readonly REPOSITORY_URL
case "${1}" in
--dns)
case "${2}" in
--single)
echo "[$(date)] Issue single certificate '${3}' via DNS:" \
&& prepareFullDomainFolder "${3}" \
&& single "dns" "${3}" "${4}" \
&& return 0
;;
--own)
echo "[$(date)] Renewing own certificates via DNS:"
own "dns" "${3}" \
&& echo "Finished successfully." \
&& return 0
;;
esac
;;
--http)
case "${2}" in
--single)
echo "[$(date)] Issue single certificate '${3}' via HTTP:" \
&& prepareFullDomainFolder "${3}" \
&& single "http" "${3}" "${4}" \
&& echo \
&& echo "Checking configuration of nginx and restart the webserver:" \
&& echo "==========================================================" \
&& nginx -t && systemctl reload nginx \
&& return 0
;;
--own)
echo "[$(date)] Renewing own certificates via HTTP:" \
&& own "http" "${3}" \
&& echo \
&& echo "Checking configuration of nginx and restart the webserver:" \
&& echo "==========================================================" \
&& nginx -t && systemctl reload nginx \
&& return 0
;;
esac
;;
--prepare)
case "${3}" in
--usingAlias)
echo "[$(date)] Prepare domain '${2}' using the alias-domain '${4}' via DNS:" \
&& prepareFullDomainFolder "${2}" \
&& prepareAndCheckAliasDomain "${2}" "${4}" \
&& return 0
;;
*)
echo "Unknown command '${1}' '${2}' '${3}' '${4}'"
usage
return 1
;;
esac
;;
--setup)
setup \
&& return 0
;;
*)
echo "Unknown command '${1}' '${2}'"
usage
return 1
;;
esac
return 1
}
main "$@" && exit 0 || exit 1