From a52e977b89d02044f901a32c1b30568d3e08bb2a Mon Sep 17 00:00:00 2001 From: Carlos Date: Thu, 13 Nov 2025 07:19:38 +0000 Subject: [PATCH 1/2] Add DNS-01 challenge support for ACME certificates and related configurations --- data/Dockerfiles/acme/Dockerfile | 12 +- data/Dockerfiles/acme/acme.sh | 4 + data/Dockerfiles/acme/functions.sh | 5 + .../acme/obtain-certificate-dns.sh | 162 ++++++++++++++++++ data/Dockerfiles/acme/obtain-certificate.sh | 4 + docker-compose.yml | 5 +- generate_config.sh | 15 ++ 7 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 data/Dockerfiles/acme/obtain-certificate-dns.sh diff --git a/data/Dockerfiles/acme/Dockerfile b/data/Dockerfiles/acme/Dockerfile index f6e990e57..a8421bb0e 100644 --- a/data/Dockerfiles/acme/Dockerfile +++ b/data/Dockerfiles/acme/Dockerfile @@ -14,11 +14,21 @@ RUN apk upgrade --no-cache \ tini \ tzdata \ 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 functions.sh /srv/functions.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 expand6.sh /srv/expand6.sh diff --git a/data/Dockerfiles/acme/acme.sh b/data/Dockerfiles/acme/acme.sh index 69b18bc1f..f042f830e 100755 --- a/data/Dockerfiles/acme/acme.sh +++ b/data/Dockerfiles/acme/acme.sh @@ -42,6 +42,10 @@ if [[ "${ENABLE_SSL_SNI}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then ENABLE_SSL_SNI=y fi +if [[ "${ACME_DNS_CHALLENGE}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then + ACME_DNS_CHALLENGE=y +fi + if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then log_f "SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt..." sleep 365d diff --git a/data/Dockerfiles/acme/functions.sh b/data/Dockerfiles/acme/functions.sh index 183be01b0..9db832910 100644 --- a/data/Dockerfiles/acme/functions.sh +++ b/data/Dockerfiles/acme/functions.sh @@ -80,6 +80,11 @@ check_domain(){ return 1 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 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= diff --git a/data/Dockerfiles/acme/obtain-certificate-dns.sh b/data/Dockerfiles/acme/obtain-certificate-dns.sh new file mode 100644 index 000000000..83fff6f7c --- /dev/null +++ b/data/Dockerfiles/acme/obtain-certificate-dns.sh @@ -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 diff --git a/data/Dockerfiles/acme/obtain-certificate.sh b/data/Dockerfiles/acme/obtain-certificate.sh index f476bf666..9d727a202 100644 --- a/data/Dockerfiles/acme/obtain-certificate.sh +++ b/data/Dockerfiles/acme/obtain-certificate.sh @@ -20,6 +20,10 @@ if [[ "${TYPE}" != "rsa" ]]; then log_f "Unknown certificate type '${TYPE}' requested" exit 5 fi + +if [[ "${ACME_DNS_CHALLENGE}" == "y" ]]; then + exec /srv/obtain-certificate-dns.sh "$@" +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 diff --git a/docker-compose.yml b/docker-compose.yml index 0522a6a2b..33fb20f1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -465,7 +465,7 @@ services: condition: service_started unbound-mailcow: condition: service_healthy - image: ghcr.io/mailcow/acme:1.94 + image: ghcr.io/mailcow/acme:1.95 dns: - ${IPV4_NETWORK:-172.22.1}.254 environment: @@ -490,6 +490,9 @@ services: - REDISPASS=${REDISPASS} - SNAT_TO_SOURCE=${SNAT_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: - ./data/web/.well-known/acme-challenge:/var/www/acme:z - ./data/assets/ssl:/var/lib/acme/:z diff --git a/generate_config.sh b/generate_config.sh index 393d2fced..54783dd03 100755 --- a/generate_config.sh +++ b/generate_config.sh @@ -293,6 +293,21 @@ ADDITIONAL_SERVER_NAMES= # Skip running ACME (acme-mailcow, Let's Encrypt certs) - y/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 # 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 From 890295bbfc0a1c29ebf171e1f15581f0ad1f6ade Mon Sep 17 00:00:00 2001 From: Carlos Date: Fri, 14 Nov 2025 07:10:17 +0000 Subject: [PATCH 2/2] Add DNS-01 challenge support with configuration files and scripts --- data/Dockerfiles/acme/Dockerfile | 1 + data/Dockerfiles/acme/load-dns-config.sh | 57 +++++++++++++++++++ .../acme/obtain-certificate-dns.sh | 15 +++++ data/conf/acme/dns-101.conf | 3 + docker-compose.yml | 1 + 5 files changed, 77 insertions(+) create mode 100755 data/Dockerfiles/acme/load-dns-config.sh create mode 100644 data/conf/acme/dns-101.conf diff --git a/data/Dockerfiles/acme/Dockerfile b/data/Dockerfiles/acme/Dockerfile index a8421bb0e..3dcfb874a 100644 --- a/data/Dockerfiles/acme/Dockerfile +++ b/data/Dockerfiles/acme/Dockerfile @@ -29,6 +29,7 @@ COPY acme.sh /srv/acme.sh COPY functions.sh /srv/functions.sh COPY obtain-certificate.sh /srv/obtain-certificate.sh COPY obtain-certificate-dns.sh /srv/obtain-certificate-dns.sh +COPY load-dns-config.sh /srv/load-dns-config.sh COPY reload-configurations.sh /srv/reload-configurations.sh COPY expand6.sh /srv/expand6.sh diff --git a/data/Dockerfiles/acme/load-dns-config.sh b/data/Dockerfiles/acme/load-dns-config.sh new file mode 100755 index 000000000..b48d36d3f --- /dev/null +++ b/data/Dockerfiles/acme/load-dns-config.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +SCRIPT_SOURCE="${BASH_SOURCE[0]:-${0}}" +if [[ "${SCRIPT_SOURCE}" == "${0}" ]]; then + __dns_loader_standalone=1 +else + __dns_loader_standalone=0 +fi + +CONFIG_PATH="${ACME_DNS_CONFIG_FILE:-/etc/acme/dns-101.conf}" + +if [[ ! -f "${CONFIG_PATH}" ]]; then + if [[ $__dns_loader_standalone -eq 1 ]]; then + exit 0 + else + return 0 + fi +fi + +source /srv/functions.sh + +log_f "Loading DNS-01 configuration from ${CONFIG_PATH}" + +LINE_NO=0 +while IFS= read -r line || [[ -n "${line}" ]]; do + LINE_NO=$((LINE_NO+1)) + line="${line%$'\r'}" + line_trimmed="$(printf '%s' "${line}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + [[ -z "${line_trimmed}" ]] && continue + [[ "${line_trimmed:0:1}" == "#" ]] && continue + if [[ "${line_trimmed}" != *=* ]]; then + log_f "Skipping invalid DNS config line ${LINE_NO} (missing key=value)" + continue + fi + KEY="${line_trimmed%%=*}" + VALUE="${line_trimmed#*=}" + KEY="$(printf '%s' "${KEY}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + VALUE="$(printf '%s' "${VALUE}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + if [[ -z "${KEY}" ]]; then + log_f "Skipping invalid DNS config line ${LINE_NO} (empty key)" + continue + fi + if [[ "${VALUE}" =~ ^\".*\"$ ]]; then + VALUE="${VALUE:1:-1}" + elif [[ "${VALUE}" =~ ^\'.*\'$ ]]; then + VALUE="${VALUE:1:-1}" + fi + export "${KEY}"="${VALUE}" + log_f "Exported DNS config key ${KEY}" + +done < "${CONFIG_PATH}" + +if [[ $__dns_loader_standalone -eq 1 ]]; then + exit 0 +else + return 0 +fi diff --git a/data/Dockerfiles/acme/obtain-certificate-dns.sh b/data/Dockerfiles/acme/obtain-certificate-dns.sh index 83fff6f7c..0be274d42 100644 --- a/data/Dockerfiles/acme/obtain-certificate-dns.sh +++ b/data/Dockerfiles/acme/obtain-certificate-dns.sh @@ -12,6 +12,14 @@ CERT_DOMAINS=(${DOMAINS[@]}) CERT_DOMAIN=${CERT_DOMAINS[0]} ACME_BASE=/var/lib/acme +# Load optional DNS provider secrets from /etc/acme/dns-101.conf +if [[ -f /srv/load-dns-config.sh ]]; then + source /srv/load-dns-config.sh + if declare -F log_f >/dev/null; then + log_f "ACME_DNS_CHALLENGE is enabled, DNS provider secrets loaded" + fi +fi + TYPE=${1} PREFIX="" # only support rsa certificates for now @@ -129,6 +137,13 @@ for domain in "${CERT_DOMAINS[@]}"; do done log_f "Using command ${ACME_CMD[*]}" +if [[ -n "${ACME_DNS_PROVIDER}" ]]; then + log_f "DNS provider: ${ACME_DNS_PROVIDER}" +fi +if compgen -A variable | grep -Eq "^DNS_|^ACME_"; then + LOG_KEYS=$(env | grep -E "^(DNS_|ACME_)" | cut -d= -f1 | tr '\n' ' ') + log_f "Available DNS/ACME env keys: ${LOG_KEYS}" redis_only +fi 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) diff --git a/data/conf/acme/dns-101.conf b/data/conf/acme/dns-101.conf new file mode 100644 index 000000000..095079ad0 --- /dev/null +++ b/data/conf/acme/dns-101.conf @@ -0,0 +1,3 @@ +# Add here your DNS-01 challenge configuration +# For more information, visit the acme.sh documentation: +# https://github.com/acmesh-official/acme.sh/wiki/dnsapi diff --git a/docker-compose.yml b/docker-compose.yml index 33fb20f1d..a3422ae2a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -498,6 +498,7 @@ services: - ./data/assets/ssl:/var/lib/acme/:z - ./data/assets/ssl-example:/var/lib/ssl-example/:ro,Z - mysql-socket-vol-1:/var/run/mysqld/:z + - ./data/conf/acme:/etc/acme/:z restart: always networks: mailcow-network: