Add DNS-01 challenge support for ACME certificates and related configurations

This commit is contained in:
Carlos 2025-11-13 07:19:38 +00:00
parent 674b41ce08
commit a52e977b89
No known key found for this signature in database
GPG Key ID: 866CF2102A53BC65
7 changed files with 205 additions and 2 deletions

View File

@ -14,11 +14,21 @@ RUN apk upgrade --no-cache \
tini \ tini \
tzdata \ tzdata \
python3 \ python3 \
acme-tiny acme-tiny \
git \
socat \
&& git clone --depth 1 https://github.com/acmesh-official/acme.sh.git /opt/acme.sh \
&& chmod +x /opt/acme.sh/acme.sh \
&& mkdir -p /var/lib/acme/acme-sh
ENV ACME_SH_BIN=/opt/acme.sh/acme.sh \
ACME_SH_HOME=/opt/acme.sh \
ACME_SH_CONFIG_HOME=/var/lib/acme/acme-sh
COPY acme.sh /srv/acme.sh COPY acme.sh /srv/acme.sh
COPY functions.sh /srv/functions.sh COPY functions.sh /srv/functions.sh
COPY obtain-certificate.sh /srv/obtain-certificate.sh COPY obtain-certificate.sh /srv/obtain-certificate.sh
COPY obtain-certificate-dns.sh /srv/obtain-certificate-dns.sh
COPY reload-configurations.sh /srv/reload-configurations.sh COPY reload-configurations.sh /srv/reload-configurations.sh
COPY expand6.sh /srv/expand6.sh COPY expand6.sh /srv/expand6.sh

View File

@ -42,6 +42,10 @@ if [[ "${ENABLE_SSL_SNI}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
ENABLE_SSL_SNI=y ENABLE_SSL_SNI=y
fi fi
if [[ "${ACME_DNS_CHALLENGE}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
ACME_DNS_CHALLENGE=y
fi
if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
log_f "SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt..." log_f "SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt..."
sleep 365d sleep 365d

View File

@ -80,6 +80,11 @@ check_domain(){
return 1 return 1
fi fi
fi fi
if [[ ${ACME_DNS_CHALLENGE} == "y" ]]; then
log_f "ACME_DNS_CHALLENGE=y - skipping IP and HTTP validation for ${DOMAIN}"
return 0
fi
# Check if CNAME without v6 enabled target # Check if CNAME without v6 enabled target
if [[ ! -z ${AAAA_DOMAIN} ]] && [[ -z $(echo ${AAAA_DOMAIN} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") ]]; then if [[ ! -z ${AAAA_DOMAIN} ]] && [[ -z $(echo ${AAAA_DOMAIN} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") ]]; then
AAAA_DOMAIN= AAAA_DOMAIN=

View File

@ -0,0 +1,162 @@
#!/bin/bash
# Return values / exit codes
# 0 = cert created successfully
# 1 = cert renewed successfully
# 2 = cert not due for renewal
# * = errors
source /srv/functions.sh
CERT_DOMAINS=(${DOMAINS[@]})
CERT_DOMAIN=${CERT_DOMAINS[0]}
ACME_BASE=/var/lib/acme
TYPE=${1}
PREFIX=""
# only support rsa certificates for now
if [[ "${TYPE}" != "rsa" ]]; then
log_f "Unknown certificate type '${TYPE}' requested"
exit 5
fi
if [[ -z "${ACME_DNS_PROVIDER}" ]]; then
log_f "ACME_DNS_PROVIDER is required when ACME_DNS_CHALLENGE is enabled"
exit 6
fi
DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains
CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem
SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist
KEY=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}key.pem
CSR=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}acme.csr
if [[ -z ${CERT_DOMAINS[*]} ]]; then
log_f "Missing CERT_DOMAINS to obtain a certificate"
exit 3
fi
if [[ "${LE_STAGING}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
if [[ ! -z "${DIRECTORY_URL}" ]]; then
log_f "Cannot use DIRECTORY_URL with LE_STAGING=y - ignoring DIRECTORY_URL"
fi
log_f "Using Let's Encrypt staging servers"
ACME_SH_SERVER_ARGS=("--staging")
elif [[ ! -z "${DIRECTORY_URL}" ]]; then
log_f "Using custom directory URL ${DIRECTORY_URL}"
ACME_SH_SERVER_ARGS=("--server" "${DIRECTORY_URL}")
else
log_f "Using Let's Encrypt production servers"
ACME_SH_SERVER_ARGS=("--server" "letsencrypt")
fi
if [[ -f ${DOMAINS_FILE} && "$(cat ${DOMAINS_FILE})" == "${CERT_DOMAINS[*]}" ]]; then
if [[ ! -f ${CERT} || ! -f "${KEY}" || -f "${ACME_BASE}/force_renew" ]]; then
log_f "Certificate ${CERT} doesn't exist yet or forced renewal - start obtaining"
elif ! openssl x509 -checkend 2592000 -noout -in ${CERT} > /dev/null; then
log_f "Certificate ${CERT} is due for renewal (< 30 days) - start renewing"
else
log_f "Certificate ${CERT} validation done, neither changed nor due for renewal."
exit 2
fi
else
log_f "Certificate ${CERT} missing or changed domains '${CERT_DOMAINS[*]}' - start obtaining"
fi
# Make backup
if [[ -f ${CERT} ]]; then
DATE=$(date +%Y-%m-%d_%H_%M_%S)
BACKUP_DIR=${ACME_BASE}/backups/${CERT_DOMAIN}/${PREFIX}${DATE}
log_f "Creating backups in ${BACKUP_DIR} ..."
mkdir -p ${BACKUP_DIR}/
[[ -f ${DOMAINS_FILE} ]] && cp ${DOMAINS_FILE} ${BACKUP_DIR}/
[[ -f ${CERT} ]] && cp ${CERT} ${BACKUP_DIR}/
[[ -f ${KEY} ]] && cp ${KEY} ${BACKUP_DIR}/
[[ -f ${CSR} ]] && cp ${CSR} ${BACKUP_DIR}/
fi
mkdir -p ${ACME_BASE}/${CERT_DOMAIN}
if [[ ! -f ${KEY} ]]; then
log_f "Copying shared private key for this certificate..."
cp ${SHARED_KEY} ${KEY}
chmod 600 ${KEY}
fi
# Generating CSR to keep layout parity with HTTP challenge flow
printf "[SAN]\nsubjectAltName=" > /tmp/_SAN
printf "DNS:%s," "${CERT_DOMAINS[@]}" >> /tmp/_SAN
sed -i '$s/,$//' /tmp/_SAN
openssl req -new -sha256 -key ${KEY} -subj "/" -reqexts SAN -config <(cat "$(openssl version -d | sed 's/.*\"\(.*\)\"/\1/g')/openssl.cnf" /tmp/_SAN) > ${CSR}
log_f "Checking resolver..."
until dig letsencrypt.org +time=3 +tries=1 @unbound > /dev/null; do
sleep 2
done
log_f "Resolver OK"
ACME_SH_BIN_PATH=${ACME_SH_BIN:-/opt/acme.sh/acme.sh}
ACME_SH_WORK_HOME=${ACME_SH_CONFIG_HOME:-/var/lib/acme/acme-sh}
mkdir -p ${ACME_SH_WORK_HOME}
if [[ ! -x ${ACME_SH_BIN_PATH} ]]; then
log_f "acme.sh binary not found at ${ACME_SH_BIN_PATH}"
exit 7
fi
if [[ ! -f ${ACME_SH_WORK_HOME}/account.conf ]]; then
if [[ -z "${ACME_ACCOUNT_EMAIL}" ]]; then
log_f "ACME_ACCOUNT_EMAIL is required to register a new acme.sh account"
exit 8
fi
log_f "Registering acme.sh account for ${ACME_ACCOUNT_EMAIL}"
REGISTER_CMD=("${ACME_SH_BIN_PATH}" "--home" "${ACME_SH_WORK_HOME}" "--config-home" "${ACME_SH_WORK_HOME}" "--cert-home" "${ACME_SH_WORK_HOME}" "--register-account" "-m" "${ACME_ACCOUNT_EMAIL}")
REGISTER_CMD+=("${ACME_SH_SERVER_ARGS[@]}")
REGISTER_RESPONSE=$("${REGISTER_CMD[@]}" 2>&1)
if [[ $? -ne 0 ]]; then
log_f "Failed to register acme.sh account: ${REGISTER_RESPONSE}"
exit 9
fi
fi
TMP_CERT=$(mktemp /tmp/acme-cert.XXXXXX)
TMP_FULLCHAIN=$(mktemp /tmp/acme-fullchain.XXXXXX)
ACME_CMD=("${ACME_SH_BIN_PATH}" "--home" "${ACME_SH_WORK_HOME}" "--config-home" "${ACME_SH_WORK_HOME}" "--cert-home" "${ACME_SH_WORK_HOME}")
ACME_CMD+=("${ACME_SH_SERVER_ARGS[@]}")
ACME_CMD+=("--issue" "--dns" "${ACME_DNS_PROVIDER}" "--key-file" "${KEY}" "--cert-file" "${TMP_CERT}" "--fullchain-file" "${TMP_FULLCHAIN}" "--force")
for domain in "${CERT_DOMAINS[@]}"; do
ACME_CMD+=("-d" "${domain}")
done
log_f "Using command ${ACME_CMD[*]}"
ACME_RESPONSE=$("${ACME_CMD[@]}" 2>&1 | tee /dev/fd/5; exit ${PIPESTATUS[0]})
SUCCESS="$?"
ACME_RESPONSE_B64=$(echo "${ACME_RESPONSE}" | openssl enc -e -A -base64)
log_f "${ACME_RESPONSE_B64}" redis_only b64
case "$SUCCESS" in
0)
log_f "Deploying certificate ${CERT}..."
if verify_hash_match ${TMP_FULLCHAIN} ${KEY}; then
RETURN=0
if [[ -f ${CERT} ]]; then
RETURN=1
fi
mv -f ${TMP_FULLCHAIN} ${CERT}
rm -f ${TMP_CERT}
echo -n ${CERT_DOMAINS[*]} > ${DOMAINS_FILE}
log_f "Certificate successfully obtained via DNS challenge"
exit ${RETURN}
else
log_f "Certificate was requested, but key and certificate hashes do not match"
rm -f ${TMP_CERT} ${TMP_FULLCHAIN}
exit 4
fi
;;
*)
log_f "Failed to obtain certificate ${CERT} for domains '${CERT_DOMAINS[*]}' via DNS challenge"
redis-cli -h redis -a ${REDISPASS} --no-auth-warning SET ACME_FAIL_TIME "$(date +%s)"
rm -f ${TMP_CERT} ${TMP_FULLCHAIN}
exit 100${SUCCESS}
;;
esac

View File

@ -20,6 +20,10 @@ if [[ "${TYPE}" != "rsa" ]]; then
log_f "Unknown certificate type '${TYPE}' requested" log_f "Unknown certificate type '${TYPE}' requested"
exit 5 exit 5
fi fi
if [[ "${ACME_DNS_CHALLENGE}" == "y" ]]; then
exec /srv/obtain-certificate-dns.sh "$@"
fi
DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains
CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem
SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist

View File

@ -465,7 +465,7 @@ services:
condition: service_started condition: service_started
unbound-mailcow: unbound-mailcow:
condition: service_healthy condition: service_healthy
image: ghcr.io/mailcow/acme:1.94 image: ghcr.io/mailcow/acme:1.95
dns: dns:
- ${IPV4_NETWORK:-172.22.1}.254 - ${IPV4_NETWORK:-172.22.1}.254
environment: environment:
@ -490,6 +490,9 @@ services:
- REDISPASS=${REDISPASS} - REDISPASS=${REDISPASS}
- SNAT_TO_SOURCE=${SNAT_TO_SOURCE:-n} - SNAT_TO_SOURCE=${SNAT_TO_SOURCE:-n}
- SNAT6_TO_SOURCE=${SNAT6_TO_SOURCE:-n} - SNAT6_TO_SOURCE=${SNAT6_TO_SOURCE:-n}
- ACME_DNS_CHALLENGE=${ACME_DNS_CHALLENGE:-n}
- ACME_DNS_PROVIDER=${ACME_DNS_PROVIDER:-dns_xxx}
- ACME_ACCOUNT_EMAIL=${ACME_ACCOUNT_EMAIL:-me@example.com}
volumes: volumes:
- ./data/web/.well-known/acme-challenge:/var/www/acme:z - ./data/web/.well-known/acme-challenge:/var/www/acme:z
- ./data/assets/ssl:/var/lib/acme/:z - ./data/assets/ssl:/var/lib/acme/:z

View File

@ -293,6 +293,21 @@ ADDITIONAL_SERVER_NAMES=
# Skip running ACME (acme-mailcow, Let's Encrypt certs) - y/n # Skip running ACME (acme-mailcow, Let's Encrypt certs) - y/n
SKIP_LETS_ENCRYPT=n SKIP_LETS_ENCRYPT=n
# Enable DNS-01 challenge for ACME (acme-mailcow) - y/n
# This requires you to set ACME_DNS_PROVIDER and ACME_ACCOUNT_EMAIL below
ACME_DNS_CHALLENGE=n
ACME_DNS_PROVIDER=dns_xxx
ACME_ACCOUNT_EMAIL=me@example.com
# You will need to pass provider-specific environment variables to the acme-mailcow container.
# See the dns-101 provider documentation for more information.
# for example for Azure DNS:
#AZUREDNS_SUBSCRIPTIONID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
#AZUREDNS_TENANTID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
#AZUREDNS_APPID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
#AZUREDNS_CLIENTSECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
#AZUREDNS_RESOURCEGROUP="your-resource-group"
#AZUREDNS_ZONE="your-zone-name"
# Create separate certificates for all domains - y/n # Create separate certificates for all domains - y/n
# this will allow adding more than 100 domains, but some email clients will not be able to connect with alternative hostnames # this will allow adding more than 100 domains, but some email clients will not be able to connect with alternative hostnames
# see https://doc.dovecot.org/admin_manual/ssl/sni_support # see https://doc.dovecot.org/admin_manual/ssl/sni_support