At Sinodun we use dehydrated https://github.com/lukas2511/dehydrated to manage our certificates. Also we use the dns-01 challenge to renew them.
Since we run multiple DNS-over-TLS servers, the method used here employs a single ‘certificate management’ server to renew the certificates, update the zone with the dns-01 challenge and make the renewed certificates available via ftp. A script is then run on each DNS server to download any new certificates using ftp to each server and restart the DNS service. In this example we use TLS proxies in front of BIND, or Knot resolver.
You could put the dns-01 challenge responses in your organisations main zone. However it is more flexible to dedicate a separate zone for that, especially if you are responsible for running the servers but someone else or some other organisation is responsible for the DNS.
For authoritative DNS we use knot https://www.knot-dns.cz/ because it has a nice interface for managing zones. The same effect could be obtained using dynamic updates.
It is a good idea to sign your zone if you are going to be putting challenge response records in it. However, the nameserver must be able to sign updates as they are applied.
Lets assume that we are managing certificates for two servers dnsovertls1.<YOURDOMAIN>. and dnsovertls.<YOURDOMAIN>.
Add CNAME RRs to the <YOURDOMAIN> zone like these. These will redirect queries for the dns-01 challenge to a dedicated zone which can exist on and be served by a dedicated certificate management server
_acme-challenge.dnsovertls1.<YOURDOMAIN>. 300 CNAME dnsovertls1.<YOURDOMAIN>.acme.<YOURDOMAIN>.
_acme-challenge.dnsovertls.<YOURDOMAIN>. 300 CNAME dnsovertls.<YOURDOMAIN>.acme.<YOURDOMAIN>.
Delegate acme.<YOURDOMAIN> to the certificate management server by adding NS and DS RRs to the <YOURDOMAIN> zone. Lets assume the server is called ns1.acme.<YOURDOMAIN>
Create an empty zone for acme.<YOURDOMAIN> (SOA and NS and A/AAAA RRs). Configure knot to sign the domain
server:
listen: <YOURIP4>@53
listen: <YOURIP6>@53
user: knot:knot
log:
- target: syslog
any: info
policy:
- id: dnssec
algorithm: ECDSAP256SHA256
zone:
- domain: acme.<YOURDOMAIN>
file: "acme.<YOURDOMAIN>.zone"
dnssec-signing: on
dnssec-policy: dnssec
acme.<YOURDOMAIN>. 3600 SOA ns1.acme.<YOURDOMAIN>. user.<YOURDOMAIN>. 2016120927 30000 300 604800 300
acme.<YOURDOMAIN>. 3600 NS ns1.acme.<YOURDOMAIN>.
ns1.acme.<YOURDOMAIN>. 3600 A <YOURIP4>
ns1.acme.<YOURDOMAIN>. 3600 AAAA <YOURIP6>
Get dehydrated https://github.com/lukas2511/dehydrated and create some config.
Uncomment the first two lines until you are sure everything is working. This will allow you to run the scripts for debugging without exceeding the production Let’s Encrypt limits
## CA="https://acme-staging.api.letsencrypt.org/directory"
## CA_TERMS="https://acme-staging.api.letsencrypt.org/terms"
LICENSE="https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf"
CERTDIR=/<PATH>/dehydrated/certs
DOMAINS_TXT=/<PATH>/dehydrated/dehydrated.domains
CHALLENGETYPE="dns-01"
HOOK=/<PATH>/dehydrated/hook.sh
PRIVATE_KEY_RENEW="no"
PRIVATE_KEY_ROLLOVER="no"
CONTACT_EMAIL=you AT email
The two PRIVATE_KEY lines ensure that the key is not replaced and so SPKI pinning will work.
Create a dehydrated.domains file (/<PATH>/dehydrated/dehydrated.domains)
dnsovertls.<YOURDOMAIN>
dnsovertls1.<YOURDOMAIN>
Create a hook.sh script (/<PATH>/dehydrated/hook.sh).
#!/usr/bin/env bash
set -e
set -u
set -o pipefail
check_knotc_exit_code() {
if [[ ! $1 -eq 0 ]] ; then
/usr/sbin/knotc zone-abort acme.<YOURDOMAIN>.
exit 1
fi
}
deploy_challenge() {
local DOMAIN="${1}.acme.<YOURDOMAIN>." RDATA="${3}"
if [[ "${1}" == "ns1.acme.<YOURDOMAIN>" ]] ; then
DOMAIN="_acme-challenge.${1}."
fi
echo "Adding $DOMAIN 10 TXT \"$RDATA\""
echo "zone-begin acme.<YOURDOMAIN>."
/usr/sbin/knotc zone-begin acme.<YOURDOMAIN>.
check_knotc_exit_code $?
echo "zone-set acme.<YOURDOMAIN>. $DOMAIN 10 TXT \"$RDATA\""
/usr/sbin/knotc zone-set acme.<YOURDOMAIN>. $DOMAIN 10 TXT \"$RDATA\"
check_knotc_exit_code $?
echo "zone-commit acme.<YOURDOMAIN>."
/usr/sbin/knotc zone-commit acme.<YOURDOMAIN>.
check_knotc_exit_code $?
}
clean_challenge() {
local DOMAIN="${1}.acme.<YOURDOMAIN>." RDATA="${3}"
if [[ "${1}" == "ns1.acme.<YOURDOMAIN>" ]] ; then
DOMAIN="_acme-challenge.${1}."
fi
echo "Removing $DOMAIN 10 TXT \"$RDATA\""
echo "zone-begin acme.<YOURDOMAIN>."
/usr/sbin/knotc zone-begin acme.<YOURDOMAIN>.
check_knotc_exit_code $?
echo "zone-unset acme.<YOURDOMAIN>. $DOMAIN 10 TXT \"$RDATA\""
/usr/sbin/knotc zone-unset acme.<YOURDOMAIN>. $DOMAIN TXT
check_knotc_exit_code $?
echo "zone-commit acme.<YOURDOMAIN>."
/usr/sbin/knotc zone-commit acme.<YOURDOMAIN>.
check_knotc_exit_code $?
}
deploy_cert() {
local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}"
## Copy cert files (not the keys) to your download site of choice - web, ftp etc...
echo "Deploying certs to ftp server"
echo "Deploy for domain ${DOMAIN}: ${CERTFILE} ${FULLCHAINFILE} ${CHAINFILE}"
cp ${CERTFILE} /srv/ftp/certs/${DOMAIN}/
cp ${FULLCHAINFILE} /srv/ftp/certs/${DOMAIN}/
cp ${CHAINFILE} /srv/ftp/certs/${DOMAIN}/
chmod 644 /srv/ftp/certs/${DOMAIN}/*
}
unchanged_cert() {
local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}"
## nothing yet..
}
startup_hook() {
## This hook is called before the cron command to do some initial tasks
## (e.g. starting a webserver).
for i in $(awk ' { print $1 } ' /<PATH>/dehydrated/dehydrated.domains ) ; do
CERTPATH=/srv/ftp/certs/$i
if [[ ! -d ${CERTPATH} ]] || \
[[ $(stat -c %a ${CERTPATH}) -ne 770 ]] || \
[[ $(stat -c %U ${CERTPATH}) != "<USER THE CERTS ARE CREATED BY>" ]] || \
[[ $(stat -c %G ${CERTPATH}) != "ftp" ]] ; then
echo "Creating, chmod, chown FTP directory for $i"
mkdir -p ${CERTPATH}
chown <USER THE CERTS ARE CREATED BY>:ftp ${CERTPATH}
chmod 770 ${CERTPATH}
fi
done
for i in $(cat /<PATH>/dehydrated/dehydrated.domains) ; do
## Now check that the zone owner has a CNAME pointing to us for this domain
CNAME=$(dig _acme-challenge.$i CNAME +short)
if [[ -z ${CNAME} ]] ; then
echo "There is no CNAME pointing here for $i in the DNS"
exit 1
fi
done
}
exit_hook() {
## This hook is called at the end of the cron command and can be used to
## do some final (cleanup or other) tasks.
:
}
invalid_challenge() {
local DOMAIN="${1}" RESPONSE="${2}"
## This hook is called if the challenge response has failed, so domain
## owners can be aware and act accordingly.
#
## Parameters:
## - DOMAIN
## The primary domain name, i.e. the certificate common
## name (CN).
## - RESPONSE
## The response that the verification server returned
}
request_failure() {
local STATUSCODE="${1}" REASON="${2}" REQTYPE="${3}"
## This hook is called when an HTTP request fails (e.g., when the ACME
## server is busy, returns an error, etc). It will be called upon any
## response code that does not start with '2'. Useful to alert admins
## about problems with requests.
#
## Parameters:
## - STATUSCODE
## The HTML status code that originated the error.
## - REASON
## The specified reason for the error.
## - REQTYPE
## The kind of request that was made (GET, POST...)
}
HANDLER="$1"; shift
if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge|deploy_cert|unchanged_cert|invalid_challenge|request_failure|startup_hook|exit_hook)$ ]]; then
"$HANDLER" "$@"
fi
Do something like the following on each servers to download the certificates from your main certificate server.
#!/usr/bin/env bash
set -e
set -u
set -o pipefail
if [[ ${LOGNAME} != "root" ]] ; then
echo "Must be root. Exiting..."
exit 1
fi
## depending on what you are using to terminate the TLS connections
## you might need to ensure the correct user can access the certificate files
SERVICEAPP=$1
if [[ ${SERVICEAPP} == "haproxy" ]] || \
[[ ${SERVICEAPP} == "knot-resolver" ]] ; then
GROUPUSER=${SERVICEAPP}
elif [[ ${SERVICEAPP} == "apache2" ]] || \
[[ ${SERVICEAPP} == "apache" ]] || \
[[ ${SERVICEAPP} == "nginx" ]] ; then
GROUPUSER="www-data"
else
echo "Unsupported service application. Exiting..."
exit 1
fi
CERTPATH="/etc/certs"
if [[ ! -d ${CERTPATH} ]] || \
[[ $(stat -c %a ${CERTPATH}) -ne 750 ]] || \
[[ $(stat -c %U ${CERTPATH}) != "root" ]] || \
[[ $(stat -c %G ${CERTPATH}) != ${GROUPUSER} ]] ; then
echo "No ${CERTPATH} directory or permissions not good."
echo "Do something like:"
echo " sudo mkdir -p ${CERTPATH}"
echo " sudo chmod 750 ${CERTPATH}"
echo " sudo chown root:${GROUPUSER} ${CERTPATH}"
echo "Exiting..."
exit 1
fi
cd ${CERTPATH}
if [[ ! -f privkey.pem ]] || \
[[ $(stat -c %a privkey.pem) -ne 640 ]] || \
[[ $(stat -c %U privkey.pem) != "root" ]] || \
[[ $(stat -c %G ${CERTPATH}/privkey.pem) != ${GROUPUSER} ]] || \
[[ ! -s privkey.pem ]] ; then
echo "Private key not found or permissions not good. Exiting..."
exit 1
fi
## Use the hostname to figure out the cert CN
CN=$(hostname -f)
if [[ "${CN}" == "test1.dnsovertls.nl" ]] ; then
CN="dnsovertls.<YOURDOMAIN>"
elif [[ "${CN}" == "test2.dnsovertls.nl" ]] ; then
CN="dnsovertls1.<YOURDOMAIN>"
fi
tar -cf backup-$(date +%s).tar *.pem
curl -s --ssl-reqd --tlsv1 -O ftp://ns1.acme.<YOURDOMAIN>:21/certs/${CN}/cert.pem
curl -s --ssl-reqd --tlsv1 -O ftp://ns1.acme.<YOURDOMAIN>:21/certs/${CN}/chain.pem
curl -s --ssl-reqd --tlsv1 -O ftp://ns1.acme.<YOURDOMAIN>:21/certs/${CN}/fullchain.pem
if [[ ! -s cert.pem ]] || \
[[ ! -s chain.pem ]] || \
[[ ! -s fullchain.pem ]] ; then
echo "At least one of the certificate files is empty or missing. Exiting..."
exit 1
fi
cat fullchain.pem > keycert.pem
cat privkey.pem >> keycert.pem
chmod 640 cert.pem chain.pem fullchain.pem keycert.pem
chown root:${GROUPUSER} cert.pem chain.pem fullchain.pem keycert.pem
if [[ ${SERVICEAPP} == "haproxy" ]] || \
[[ ${SERVICEAPP} == "apache2" ]] || \
[[ ${SERVICEAPP} == "nginx" ]] ; then
INITNAME=${SERVICEAPP}
elif [[ ${SERVICEAPP} == "apache" ]] ; then
INITNAME="apache2"
else
echo "Unsupported service application. Exiting..."
exit 1
fi
systemctl reload-or-restart ${INITNAME}