Wildcard-Zertifikate mit letsencrypt, lexicon und dehydrated
Anleitungen wie man sich Zertifikate mit letsencrypt1 erstellen lassen kann, gibt es viele. Wenn es um Wildcard-Zertifikate geht, werden die Anleitungen schon etwas weniger. Wenn doch, nutzen diese meist US-Anbieter wie Cloudflare.
Ich möchte hier einmal zeigen, wie das alles mit Hetzner2 funktioniert.
Das Verfahren ist übrigens auch sehr gut für Intranetserver geeignet, da letsencrypt hierbei keine HTTP-Verbindung zu den Servern aufbauen muss, um die Zertifikatsanforderung zu verifizieren.
Aber warum eigentlich Hetzner? Wenn man über letsencrypt Wildcard-Zertifikate erzeugen möchte, also Zertifikate die für eine Domain und all deren Subdomains gilt, funktioniert das normale Authentifizierungsverfahren nicht. Das ist auch sinnvoll so, da jemand, der sein Blog bei wordpress.com betreibt natürlich nur für seine eigene Subdomain ein Zertifikat erhalten darf, nicht für alle wordpress.com Subdomains. Wenn ich also ein Wildcard-Zertifikat haben möchte, muss ich nachweisen, dass ich über die Kontrolle über die gesamte Domain verfüge, nicht nur eine einzelne (oder mehrere) Subdomains.
Um dies zu tun, reicht es nicht aus eine Datei auf einem Webserver abzulegen, sondern ich muss einen DNS-Eintrag erzeugen können, den letsencrypt dann ausliest. Da dieser Eintrag jedes Mal anders aussieht, brauche ich einen DNS-Anbieter, der über eine API verfügt, mit der ich den nötigen Eintrag automatisiert anlegen (und wieder entfernen) kann. Hetzner bietet so eine API und ich verwalte meine Domains eh dort.
Um meine letsencrypt-Zertifikate zu erzeugen, nutze ich schon seit Jahren dehydrated
3. Erfreulicherweise kann man dehydrated über einen Hook4 so erweitern, dass ich damit auch Wildcard-Zertifikate abrufen kann. Hierzu wird das Zusatztool lexicon
5 benötigt.
Fangen wir also mit der Installation an:
apt install dehydrated lexicon wget
wget https://raw.githubusercontent.com/AnalogJ/lexicon/master/examples/dehydrated.default.sh -O /etc/dehydrated/hook.sh
dehydrated --register --accept-terms
Im Anschluss konfigurieren wir dehydrated
über die Datei /etc/dehydrated/config
bzw. /etc/dehydrated/conf.d/default.conf
:
CONFIG_D="/etc/dehydrated/conf.d"
BASEDIR="/var/lib/dehydrated"
WELLKNOWN="/var/www/html/.well-known/acme-challenge"
DOMAINS_TXT="/etc/dehydrated/domains.txt"
HOOK="/etc/dehydrated/hook.sh"
OCSP_MUST_STAPLE="yes"
OCSP_FETCH="yes"
OCSP_DAYS=5
AUTO_CLEANUP="yes"
- CONFIG_D: Legt den Ordner für dehydrated
.conf
Dateien fest, wenn wir/etc/dehydrated/conf.d/default.conf
nutzen, kann diese Zeile natürlich entfallen - BASEDIR: Legt das Verzeichnis fest, in dem dehydrated seine Daten verwaltet und auch die Zertifikate ablegt
- WELLKNOWN: Legt das Verzeichnis fest, in dem Challenge-Dateien auf dem Webserver abgelegt werden, da wir das Authentifizierungsverfahren über DNS nutzen, ist diese Angabe irrelevant, wir konfigurieren sie dennoch, falls wir das Challenge-Response Verfahren einmal als Fallback benötigen
- DOMAINS_TXT: Legt die Datei fest, in der die Domainnamen aufgeführt werden, für die wir Zertifikate erzeugen möchten
- HOOK: Legt die Datei fest, die während des Zertifizierungsvorgangs aufgerufen und unsere DNS Einträge vornehmen wird
- OCSP_MUST_STAPLE: Aktiviert die Eigenschaft
OCSP must staple
6 für unsere Zertifikate - OCSP_FETCH: Sorgt dafür dass OCSP Informationen heruntergeladen werden, diese werden für "OCSP must staple" benötigt
- OSCP_DAYS: Legt fest dass die OSCP Informationen alle 5 Tage erneut heruntergeladen werden, um aktuell zu sein
- AUTO_CLEANUP: Sorgt dafür dass nicht mehr benötigte Zertifikatsdaten entfernt werden
Nun legen wir unsere /etc/dehydrated/domains.txt
an:
example.org *.example.org
intern.example.org *.intern.example.org
Mit diesen Einstellungen bekommen wir Zertifikate für die aufgeführten Domains. Die Angabe ohne *.
dient dazu, dass die Zertifikate auch dann gültig sind, wenn man z.B. statt www.example.org
einfach nur example.org
aufruft.
Nun müssen wir uns einen Hetzner API-Key für die DNS-Verwaltung besorgen. Hierzu loggen wir uns in unser Hetzner-Kundenkonto ein und klicken in der Hetzner DNS Console auf den Button Manage API tokens
. Dort vergeben wir einen Token name
und klicken auf Create access token
. Dieser wird uns in Form einer kryptischen Zeichenkette präsentiert. Diesen kopieren wir uns (am besten in unseren Passwortsafe, da er nie wieder vollständig angezeigt wird, nachdem wir den Dialog geschlossen haben).
Nun öffnen wir die Datei /etc/dehydrated/hook.sh
die wir zuvor mit wget
heruntergeladen hatten. Da in der Datei mehrere Anpassungen nötig sind, werde ich die Datei hier einmal komplett einfügen, die Änderungen highlighten und im Anschluss erläutern:
!/usr/bin/env bash
#
# Example how to deploy a DNS challenge using lexicon
set -e
set -u
set -o pipefail
export PROVIDER_UPDATE_DELAY=${PROVIDER_UPDATE_DELAY:-"30"}
export PROVIDER=${PROVIDER:-"hetzner"}
export LEXICON_HETZNER_TOKEN=MEINGEHEIMERAPITOKENAUSDERZWISCHENABLAGE
function deploy_challenge {
local chain=($@)
for ((i=0; i < $#; i+=3)); do
local DOMAIN="${chain[i]}" TOKEN_FILENAME="${chain[i+1]}" TOKEN_VALUE="${chain[i+2]}"
echo "deploy_challenge called: ${DOMAIN}, ${TOKEN_FILENAME}, ${TOKEN_VALUE}"
lexicon $PROVIDER create ${DOMAIN} TXT --name="_acme-challenge.${DOMAIN}." \
--content="${TOKEN_VALUE}"
done
local DELAY_COUNTDOWN=$PROVIDER_UPDATE_DELAY
while [ $DELAY_COUNTDOWN -gt 0 ]; do
echo -ne "${DELAY_COUNTDOWN}\033[0K\r"
sleep 1
: $((DELAY_COUNTDOWN--))
done
# This hook is called once for every domain chain that needs to be
# validated, including any alternative names you may have listed.
#
# Parameters:
# - DOMAIN
# The domain name (CN or subject alternative name) being
# validated.
# - TOKEN_FILENAME
# The name of the file containing the token to be served for HTTP
# validation. Should be served by your web server as
# /.well-known/acme-challenge/${TOKEN_FILENAME}.
# - TOKEN_VALUE
# The token value that needs to be served for validation. For DNS
# validation, this is what you want to put in the _acme-challenge
# TXT record. For HTTP validation it is the value that is expected
# be found in the $TOKEN_FILENAME file.
}
function deploy_ocsp {
echo " + Hook: Reloading services (OCSP)..."
/etc/init.d/nginx reload
}
function clean_challenge {
local chain=($@)
for ((i=0; i < $#; i+=3)); do
local DOMAIN="${chain[i]}" TOKEN_FILENAME="${chain[i+1]}" TOKEN_VALUE="${chain[i+2]}"
echo "clean_challenge called: ${DOMAIN}, ${TOKEN_FILENAME}, ${TOKEN_VALUE}"
lexicon $PROVIDER delete ${DOMAIN} TXT --name="_acme-challenge.${DOMAIN}." \
--content="${TOKEN_VALUE}"
done
# This hook is called after attempting to validate each domain
# chain, whether or not validation was successful. Here you
# can delete files or DNS records that are no longer needed.
#
# The parameters are the same as for deploy_challenge.
}
function invalid_challenge() {
local DOMAIN="${1}" RESPONSE="${2}"
echo "invalid_challenge called: ${DOMAIN}, ${RESPONSE}"
# 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
}
function deploy_cert {
local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}"
echo "deploy_cert called: ${DOMAIN}, ${KEYFILE}, ${CERTFILE}, ${FULLCHAINFILE}, ${CHAINFILE}"
# This hook is called once for each certificate that has been
# produced. Here you might, for instance, copy your new certificates
# to service-specific locations and reload the service.
#
# Parameters:
# - DOMAIN
# The primary domain name, i.e. the certificate common
# name (CN).
# - KEYFILE
# The path of the file containing the private key.
# - CERTFILE
# The path of the file containing the signed certificate.
# - FULLCHAINFILE
# The path of the file containing the full certificate chain.
# - CHAINFILE
# The path of the file containing the intermediate certificate(s).
echo " + Hook: Reloading services (Cert)..."
/etc/init.d/nginx reload
}
function unchanged_cert {
local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}"
echo "unchanged_cert called: ${DOMAIN}, ${KEYFILE}, ${CERTFILE}, ${FULLCHAINFILE}, ${CHAINFILE}"
# This hook is called once for each certificate that is still
# valid and therefore wasn't reissued.
#
# Parameters:
# - DOMAIN
# The primary domain name, i.e. the certificate common
# name (CN).
# - KEYFILE
# The path of the file containing the private key.
# - CERTFILE
# The path of the file containing the signed certificate.
# - FULLCHAINFILE
# The path of the file containing the full certificate chain.
# - CHAINFILE
# The path of the file containing the intermediate certificate(s).
}
exit_hook() {
# This hook is called at the end of a dehydrated command and can be used
# to do some final (cleanup or other) tasks.
:
}
startup_hook() {
# This hook is called before the dehydrated command to do some initial tasks
# (e.g. starting a webserver).
:
}
HANDLER=$1; shift;
if [ -n "$(type -t $HANDLER)" ] && [ "$(type -t $HANDLER)" = function ]; then
$HANDLER "$@"
fi
Die Zeilen:
export PROVIDER=${PROVIDER:-"hetzner"}
export LEXICON_HETZNER_TOKEN=MEINGEHEIMERAPITOKENAUSDERZWISCHENABLAGE
legen Hetzner als unseren DNS Provider fest und übergeben den Zugangstoken zur DNS Console.
function deploy_ocsp {
echo " + Hook: Reloading services (OCSP)..."
/etc/init.d/nginx reload
}
Sorgt dafür dass bei einem Update der OCSP Dateien der Webserver/Proxy seine Konfiguration neu einliest, damit auch die neuen OCSP Daten verwendet werden.
echo " + Hook: Reloading services (Cert)..."
/etc/init.d/nginx reload
Sorgt dafür dass bei einem neuen Zertifikat oder bei einem Update eines Zertifikats ebenfalls der Webserver/Proxy seine Konfiguration neu einliest, damit die neuen Zertifikate genutzt werden.
Nun können wir den Vorgang einmal testen:
dehydrated --cron --challenge dns-01 --ocsp
Der Output sollte in etwa so aussehen:
Processing example.org with alternative names: *.example.org
+ Signing domains...
+ Generating private key...
+ Generating signing request...
+ Requesting new certificate order from CA...
+ Received 2 authorizations URLs from the CA
+ Handling authorization for example.org
+ Handling authorization for example.org
+ 2 pending challenge(s)
+ Deploying challenge tokens...
deploy_challenge called: example.org, Ulc5log61x0YYX1uR7tSXTe-exejP4W0IxJQVeG_Af4, xLgQMlG4l25WEj91fr-Tw2A7tLsU7mkcd6OknPCaAqo
RESULT
------
True
deploy_challenge called: example.org, riHGDe5lLsjQ9ISfCCvj56f0RquTX4TOW5jg4LzVimg, Sa57WuZ2hbu-AOaT65f7ryzwxK0GtNoZErntj6mKVHk
RESULT
------
True
+ Responding to challenge for example.org authorization...
+ Challenge is valid!
+ Responding to challenge for example.org authorization...
+ Challenge is valid!
+ Cleaning challenge tokens...
clean_challenge called: example.org, Ulc5log61x0YYX1uR7tSXTe-exejP4W0IxJQVeG_Af4, xLgQMlG4l25WEj91fr-Tw2A7tLsU7mkcd6OknPCaAqo
RESULT
------
True
clean_challenge called: example.org, riHGDe5lLsjQ9ISfCCvj56f0RquTX4TOW5jg4LzVimg, Sa57WuZ2hbu-AOaT65f7ryzwxK0GtNoZErntj6mKVHk
RESULT
------
True
+ Requesting certificate...
+ Checking certificate...
+ Done!
+ Creating fullchain.pem...
deploy_cert called: example.org, /var/lib/dehydrated/certs/example.org/privkey.pem, /var/lib/dehydrated/certs/example.org/cert.pem, /var/lib/dehydrated/certs/example.org/fullchain.pem, /var/lib/dehydrated/certs/example.org/chain.pem
+ Hook: Reloading services (Cert)...
+ Done!
+ Updating OCSP stapling file
+ Hook: Reloading services (OCSP)...
Processing intern.example.org with alternative names: *.intern.example.org
+ Creating new directory /var/lib/dehydrated/certs/intern.example.org ...
+ Signing domains...
+ Generating private key...
+ Generating signing request...
+ Requesting new certificate order from CA...
+ Received 2 authorizations URLs from the CA
+ Handling authorization for intern.example.org
+ Handling authorization for intern.example.org
+ 2 pending challenge(s)
+ Deploying challenge tokens...
deploy_challenge called: intern.example.org, _NLYwpNzg9JSZ8DPZwhSawYTh_vGWl9gJ3bvlNBcgjQ, SmOvNpyKZas2vbGljKwGX5iD95AptDvzvijmlYtregQ
RESULT
------
True
deploy_challenge called: intern.example.org, BeY06SYwVb9BDKrwZpyZVnwUindSZihd37NxA_6UKM0, tFqIBZhcYNkUz-OCsJBnAAVAtdtg_7Fgv-cZpRRTXxw
RESULT
------
True
+ Responding to challenge for intern.example.org authorization...
+ Challenge is valid!
+ Responding to challenge for intern.example.org authorization...
+ Challenge is valid!
+ Cleaning challenge tokens...
clean_challenge called: intern.example.org, _NLYwpNzg9JSZ8DPZwhSawYTh_vGWl9gJ3bvlNBcgjQ, SmOvNpyKZas2vbGljKwGX5iD95AptDvzvijmlYtregQ
RESULT
------
True
clean_challenge called: intern.example.org, BeY06SYwVb9BDKrwZpyZVnwUindSZihd37NxA_6UKM0, tFqIBZhcYNkUz-OCsJBnAAVAtdtg_7Fgv-cZpRRTXxw
RESULT
------
True
+ Requesting certificate...
+ Checking certificate...
+ Done!
+ Creating fullchain.pem...
deploy_cert called: intern.example.org, /var/lib/dehydrated/certs/intern.example.org/privkey.pem, /var/lib/dehydrated/certs/intern.example.org/cert.pem, /var/lib/dehydrated/certs/intern.example.org/fullchain.pem, /var/lib/dehydrated/certs/intern.example.org/chain.pem
+ Hook: Reloading services (Cert)...
+ Done!
+ Updating OCSP stapling file
+ Hook: Reloading services (OCSP)...
+ Running automatic cleanup
Nun sollte wir den Aufruf von dehydrated
noch automatisieren, hierzu rufen wir crontab -e
auf und fügen folgende Zeile in unsere crontab
7 ein:
@daily /usr/bin/dehydrated --cron --challenge dns-01 --ocsp 2>&1 | mail -s "$(hostname) Dehydrated status" certs@example.org
Die Zeile sorgt dafür dass dehydrated
jeden Tag ausgeführt wird, der Output der dabei produziert wird, wird uns anschließend per Mails zugestellt. So sind wir immer auf dem Laufenden, falls es zu Problemen kommen sollte.
Unsere Zertifikate liegen nun im Verzeichnis /var/lib/dehydrated/certs/
bereit und können in unsere nginx
Konfiguration eingefügt werden.
Anstelle von nginx können natürlich beliebige andere Programme genutzt werden, hierbei sollte dann nicht vergessen werden, die Reload-Anweisungen im Hook anzupassen/zu erweitern. Und unser lexicon Hook kann natürlich auch andere DNS Provider als Hetzner. Eine vollständige Auflistung findet sich unter 8. Das Prinzip als solches bleibt immer gleich.