NobGit
public nobgit read

NobMail

Based on mailcow: dockerized

Languages

Repository composition by tracked source files.

PHP
PHP 49% JavaScript 35% HTML 9% CSS 4% Shell 2% Python 1% Lua 0% Perl 0% Ruby 0% SCSS 0%
Create file Wiki Documentation
Clone
https://nobgit.com/orgs/nobgit/nobmail.git
ssh://[email protected]:2222/orgs/nobgit/nobmail.git

Commit

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

a52e977b
Carlos <[email protected]> 8 months ago
data/Dockerfiles/acme/Dockerfile                |  12 +-
 data/Dockerfiles/acme/acme.sh                   |   4 +
 data/Dockerfiles/acme/functions.sh              |   5 +
 data/Dockerfiles/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

diff --git a/data/Dockerfiles/acme/Dockerfile b/data/Dockerfiles/acme/Dockerfile
index f6e990e5..a8421bb0 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 69b18bc1..f042f830 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 183be01b..9db83291 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 00000000..83fff6f7
--- /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 f476bf66..9d727a20 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 0522a6a2..33fb20f1 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:[email protected]}
       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 393d2fce..54783dd0 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
[email protected]
+# 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