From 2e35da681674c250238519f4a9e3e581393f50f2 Mon Sep 17 00:00:00 2001 From: Marcel Hofer Date: Sat, 19 Oct 2019 12:48:56 +0200 Subject: [PATCH] [SSL] create individual domain certificates, add SNI configs for Postfix/Dovecot/Nginx --- .editorconfig | 20 + .gitignore | 5 + data/Dockerfiles/acme/Dockerfile | 5 + data/Dockerfiles/acme/acme.sh | 478 ++++++------------ data/Dockerfiles/acme/functions.sh | 112 ++++ data/Dockerfiles/acme/obtain-certificate.sh | 120 +++++ .../Dockerfiles/acme/reload-configurations.sh | 43 ++ data/Dockerfiles/dovecot/docker-entrypoint.sh | 14 + data/Dockerfiles/postfix/postfix.sh | 14 + data/conf/dovecot/dovecot.conf | 1 + .../site-defaults.conf} | 17 - .../nginx/templates/listen_plain.template | 2 - data/conf/nginx/templates/listen_ssl.template | 2 - .../conf/nginx/templates/server_name.template | 1 - data/conf/nginx/templates/sites.template.sh | 40 ++ data/conf/postfix/main.cf | 3 +- docker-compose.yml | 7 +- 17 files changed, 540 insertions(+), 344 deletions(-) create mode 100644 .editorconfig create mode 100644 data/Dockerfiles/acme/functions.sh create mode 100644 data/Dockerfiles/acme/obtain-certificate.sh create mode 100644 data/Dockerfiles/acme/reload-configurations.sh rename data/conf/nginx/{site.conf => includes/site-defaults.conf} (92%) delete mode 100644 data/conf/nginx/templates/listen_plain.template delete mode 100644 data/conf/nginx/templates/listen_ssl.template delete mode 100644 data/conf/nginx/templates/server_name.template create mode 100644 data/conf/nginx/templates/sites.template.sh diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..04ba039a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] + +# Change these settings to your own preference +indent_style = space +indent_size = 2 + +# We recommend you to keep these unchanged +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index a50c63aa..4dd71779 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ data/conf/dovecot/dovecot-master.userdb mailcow.conf mailcow.conf_backup data/conf/nginx/*.active +data/conf/postfix/sni.map +data/conf/postfix/sni.map.db data/conf/postfix/extra.cf data/conf/postfix/sql data/conf/postfix/allow_mailcow_local.regexp @@ -14,6 +16,8 @@ data/conf/nextcloud-*.bak data/web/inc/vars.local.inc.php data/assets/ssl/* .vscode/* +.idea +*.iml data/web/.well-known/acme-challenge data/web/nextcloud*/ data/conf/rspamd/local.d/* @@ -26,6 +30,7 @@ data/conf/nginx/*.custom data/conf/nginx/*.bak data/conf/dovecot/acl_anyone data/conf/dovecot/mail_plugins* +data/conf/dovecot/sni.conf data/conf/dovecot/sogo-sso.conf data/conf/dovecot/extra.conf data/conf/dovecot/shared_namespace.conf diff --git a/data/Dockerfiles/acme/Dockerfile b/data/Dockerfiles/acme/Dockerfile index 00710ce2..66645f4c 100644 --- a/data/Dockerfiles/acme/Dockerfile +++ b/data/Dockerfiles/acme/Dockerfile @@ -18,6 +18,11 @@ RUN apk upgrade --no-cache \ && python3 -m pip install acme-tiny COPY acme.sh /srv/acme.sh +COPY functions.sh /srv/functions.sh +COPY obtain-certificate.sh /srv/obtain-certificate.sh +COPY reload-configurations.sh /srv/reload-configurations.sh COPY expand6.sh /srv/expand6.sh +RUN chmod +x /srv/*.sh + CMD ["/sbin/tini", "-g", "--", "/srv/acme.sh"] diff --git a/data/Dockerfiles/acme/acme.sh b/data/Dockerfiles/acme/acme.sh index a06ce01c..9efaa92b 100755 --- a/data/Dockerfiles/acme/acme.sh +++ b/data/Dockerfiles/acme/acme.sh @@ -2,6 +2,7 @@ set -o pipefail exec 5>&1 +source /srv/functions.sh # Thanks to https://github.com/cvmiller -> https://github.com/cvmiller/expand6 source /srv/expand6.sh @@ -15,26 +16,15 @@ if [[ "${SKIP_HTTP_VERIFICATION}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then SKIP_HTTP_VERIFICATION=y fi -# Request certificate for MAILCOW_HOSTNAME ony +# Request certificate for MAILCOW_HOSTNAME only if [[ "${ONLY_MAILCOW_HOSTNAME}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then ONLY_MAILCOW_HOSTNAME=y fi -log_f() { - if [[ ${2} == "no_nl" ]]; then - echo -n "$(date) - ${1}" - elif [[ ${2} == "no_date" ]]; then - echo "${1}" - elif [[ ${2} != "redis_only" ]]; then - echo "$(date) - ${1}" - fi - if [[ ${3} == "b64" ]]; then - redis-cli -h redis LPUSH ACME_LOG "{\"time\":\"$(date +%s)\",\"message\":\"base64,$(printf '%s' "${1}")\"}" > /dev/null - else - redis-cli -h redis LPUSH ACME_LOG "{\"time\":\"$(date +%s)\",\"message\":\"$(printf '%s' "${1}" | \ - tr '%&;$"[]{}-\r\n' ' ')\"}" > /dev/null - fi -} +# Request individual certificate for every domain +if [[ "${ENABLE_SSL_SNI}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then + ENABLE_SSL_SNI=y +fi if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then log_f "SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt..." @@ -56,97 +46,21 @@ mkdir -p ${ACME_BASE}/acme # Migrate [[ -f ${ACME_BASE}/acme/private/privkey.pem ]] && mv ${ACME_BASE}/acme/private/privkey.pem ${ACME_BASE}/acme/key.pem [[ -f ${ACME_BASE}/acme/private/account.key ]] && mv ${ACME_BASE}/acme/private/account.key ${ACME_BASE}/acme/account.pem - -reload_configurations(){ - # Reading container IDs - # Wrapping as array to ensure trimmed content when calling $NGINX etc. - local NGINX=($(curl --silent --insecure https://dockerapi/containers/json | jq -r '.[] | {name: .Config.Labels["com.docker.compose.service"], id: .Id}' | jq -rc 'select( .name | tostring | contains("nginx-mailcow")) | .id' | tr "\n" " ")) - local DOVECOT=($(curl --silent --insecure https://dockerapi/containers/json | jq -r '.[] | {name: .Config.Labels["com.docker.compose.service"], id: .Id}' | jq -rc 'select( .name | tostring | contains("dovecot-mailcow")) | .id' | tr "\n" " ")) - local POSTFIX=($(curl --silent --insecure https://dockerapi/containers/json | jq -r '.[] | {name: .Config.Labels["com.docker.compose.service"], id: .Id}' | jq -rc 'select( .name | tostring | contains("postfix-mailcow")) | .id' | tr "\n" " ")) - # Reloading - echo "Reloading Nginx..." - NGINX_RELOAD_RET=$(curl -X POST --insecure https://dockerapi/containers/${NGINX}/exec -d '{"cmd":"reload", "task":"nginx"}' --silent -H 'Content-type: application/json' | jq -r .type) - [[ ${NGINX_RELOAD_RET} != 'success' ]] && { echo "Could not reload Nginx, restarting container..."; restart_container ${NGINX} ; } - echo "Reloading Dovecot..." - DOVECOT_RELOAD_RET=$(curl -X POST --insecure https://dockerapi/containers/${DOVECOT}/exec -d '{"cmd":"reload", "task":"dovecot"}' --silent -H 'Content-type: application/json' | jq -r .type) - [[ ${DOVECOT_RELOAD_RET} != 'success' ]] && { echo "Could not reload Dovecot, restarting container..."; restart_container ${DOVECOT} ; } - echo "Reloading Postfix..." - POSTFIX_RELOAD_RET=$(curl -X POST --insecure https://dockerapi/containers/${POSTFIX}/exec -d '{"cmd":"reload", "task":"postfix"}' --silent -H 'Content-type: application/json' | jq -r .type) - [[ ${POSTFIX_RELOAD_RET} != 'success' ]] && { echo "Could not reload Postfix, restarting container..."; restart_container ${POSTFIX} ; } -} - -restart_container(){ - for container in $*; do - log_f "Restarting ${container}..." no_nl - C_REST_OUT=$(curl -X POST --insecure https://dockerapi/containers/${container}/restart | jq -r '.msg') - log_f "${C_REST_OUT}" no_date - done -} - -array_diff() { - # https://stackoverflow.com/questions/2312762, Alex Offshore - eval local ARR1=\(\"\${$2[@]}\"\) - eval local ARR2=\(\"\${$3[@]}\"\) - local IFS=$'\n' - mapfile -t $1 < <(comm -23 <(echo "${ARR1[*]}" | sort) <(echo "${ARR2[*]}" | sort)) -} - -verify_hash_match(){ - CERT_HASH=$(openssl x509 -noout -modulus -in "${1}" | openssl md5) - KEY_HASH=$(openssl rsa -noout -modulus -in "${2}" | openssl md5) - if [[ ${CERT_HASH} != ${KEY_HASH} ]]; then - log_f "Certificate and key hashes do not match!" - return 1 - else - log_f "Verified hashes." - return 0 +if [[ -f ${ACME_BASE}/acme/key.pem && -f ${ACME_BASE}/acme/cert.pem ]]; then + if verify_hash_match ${ACME_BASE}/acme/cert.pem ${ACME_BASE}/acme/key.pem; then + log_f "Migrating to SNI folder structure..." no_nl + CERT_DOMAIN=($(openssl x509 -noout -text -in ${ACME_BASE}/acme/cert.pem | grep "Subject:" | sed -e 's/\(Subject:\)\|\(CN = \)\|\(CN=\)//g' | sed -e 's/^[[:space:]]*//')) + CERT_DOMAINS=(${CERT_DOMAIN} $(openssl x509 -noout -text -in ${ACME_BASE}/acme/cert.pem | grep "DNS:" | sed -e 's/\(DNS:\)\|,//g' | sed "s/${CERT_DOMAIN}//" | sed -e 's/^[[:space:]]*//')) + mkdir -p ${ACME_BASE}/${CERT_DOMAIN} + mv ${ACME_BASE}/acme/cert.pem ${ACME_BASE}/${CERT_DOMAIN}/cert.pem + # key is only copied, not moved, because it is used by all other requests too + cp ${ACME_BASE}/acme/key.pem ${ACME_BASE}/${CERT_DOMAIN}/key.pem + chmod 600 ${ACME_BASE}/${CERT_DOMAIN}/key.pem + echo -n ${CERT_DOMAINS[*]} > ${ACME_BASE}/${CERT_DOMAIN}/domains + mv ${ACME_BASE}/acme/acme.csr ${ACME_BASE}/${CERT_DOMAIN}/acme.csr + log_f "OK" no_date fi -} - -get_ipv4(){ - local IPV4= - local IPV4_SRCS= - local TRY= - IPV4_SRCS[0]="ip4.mailcow.email" - IPV4_SRCS[1]="ip4.korves.net" - until [[ ! -z ${IPV4} ]] || [[ ${TRY} -ge 10 ]]; do - IPV4=$(curl --connect-timeout 3 -m 10 -L4s ${IPV4_SRCS[$RANDOM % ${#IPV4_SRCS[@]} ]} | grep -E "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$") - [[ ! -z ${TRY} ]] && sleep 1 - TRY=$((TRY+1)) - done - echo ${IPV4} -} - -get_ipv6(){ - local IPV6= - local IPV6_SRCS= - local TRY= - IPV6_SRCS[0]="ip6.korves.net" - IPV6_SRCS[1]="ip6.mailcow.email" - until [[ ! -z ${IPV6} ]] || [[ ${TRY} -ge 10 ]]; do - IPV6=$(curl --connect-timeout 3 -m 10 -L6s ${IPV6_SRCS[$RANDOM % ${#IPV6_SRCS[@]} ]} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") - [[ ! -z ${TRY} ]] && sleep 1 - TRY=$((TRY+1)) - done - echo ${IPV6} -} - -verify_challenge_path(){ - if [[ ${SKIP_HTTP_VERIFICATION} == "y" ]]; then - echo '(skipping check, returning 0)' - return 0 - fi - # verify_challenge_path URL 4|6 - RANDOM_N=${RANDOM}${RANDOM}${RANDOM} - echo ${RANDOM_N} > /var/www/acme/${RANDOM_N} - if [[ "$(curl --insecure -${2} -L http://${1}/.well-known/acme-challenge/${RANDOM_N} --silent)" == "${RANDOM_N}" ]]; then - rm /var/www/acme/${RANDOM_N} - return 0 - else - rm /var/www/acme/${RANDOM_N} - return 1 - fi -} +fi [[ ! -f ${ACME_BASE}/dhparams.pem ]] && cp ${SSL_EXAMPLE}/dhparams.pem ${ACME_BASE}/dhparams.pem @@ -158,15 +72,12 @@ if [[ -f ${ACME_BASE}/cert.pem ]] && [[ -f ${ACME_BASE}/key.pem ]] && [[ $(stat exec $(readlink -f "$0") fi else - if [[ -f ${ACME_BASE}/acme/cert.pem ]] && [[ -f ${ACME_BASE}/acme/key.pem ]]; then - if verify_hash_match ${ACME_BASE}/acme/cert.pem ${ACME_BASE}/acme/key.pem; then - log_f "Restoring previous acme certificate and restarting script..." - cp ${ACME_BASE}/acme/cert.pem ${ACME_BASE}/cert.pem - cp ${ACME_BASE}/acme/key.pem ${ACME_BASE}/key.pem - # Restarting with env var set to trigger a restart, - exec env TRIGGER_RESTART=1 $(readlink -f "$0") - fi - ISSUER="mailcow" + if [[ -f ${ACME_BASE}/${MAILCOW_HOSTNAME}/cert.pem ]] && [[ -f ${ACME_BASE}/${MAILCOW_HOSTNAME}/key.pem ]] && verify_hash_match ${ACME_BASE}/${MAILCOW_HOSTNAME}/cert.pem ${ACME_BASE}/${MAILCOW_HOSTNAME}/key.pem; then + log_f "Restoring previous acme certificate and restarting script..." + cp ${ACME_BASE}/${MAILCOW_HOSTNAME}/cert.pem ${ACME_BASE}/cert.pem + cp ${ACME_BASE}/${MAILCOW_HOSTNAME}/key.pem ${ACME_BASE}/key.pem + # Restarting with env var set to trigger a restart, + exec env TRIGGER_RESTART=1 $(readlink -f "$0") else log_f "Restoring mailcow snake-oil certificates and restarting script..." cp ${SSL_EXAMPLE}/cert.pem ${ACME_BASE}/cert.pem @@ -174,6 +85,7 @@ else exec env TRIGGER_RESTART=1 $(readlink -f "$0") fi fi + chmod 600 ${ACME_BASE}/key.pem log_f "Waiting for database... " no_nl @@ -203,10 +115,10 @@ while true; do # Re-using previous acme-mailcow account and domain keys if [[ ! -f ${ACME_BASE}/acme/key.pem ]]; then - log_f "Generating missing domain private key..." + log_f "Generating missing domain private rsa key..." openssl genrsa 4096 > ${ACME_BASE}/acme/key.pem else - log_f "Using existing domain key ${ACME_BASE}/acme/key.pem" + log_f "Using existing domain rsa key ${ACME_BASE}/acme/key.pem" fi if [[ ! -f ${ACME_BASE}/acme/account.pem ]]; then log_f "Generating missing Lets Encrypt account key..." @@ -218,25 +130,34 @@ while true; do chmod 600 ${ACME_BASE}/acme/key.pem chmod 600 ${ACME_BASE}/acme/account.pem + unset EXISTING_CERTS + declare -a EXISTING_CERTS + for cert_dir in ${ACME_BASE}/*/ ; do + if [[ ! -f ${cert_dir}domains ]] || [[ ! -f ${cert_dir}cert.pem ]] || [[ ! -f ${cert_dir}key.pem ]]; then + continue + fi + EXISTING_CERTS+=("$(basename ${cert_dir})") + done + # Cleaning up and init validation arrays unset SQL_DOMAIN_ARR unset VALIDATED_CONFIG_DOMAINS unset ADDITIONAL_VALIDATED_SAN unset ADDITIONAL_WC_ARR unset ADDITIONAL_SAN_ARR - unset SAN_CHANGE - unset SAN_ARRAY_NOW - unset ORPHANED_SAN - unset ADDED_SAN - SAN_CHANGE=0 - declare -a SAN_ARRAY_NOW - declare -a ORPHANED_SAN - declare -a ADDED_SAN + unset CERT_ERRORS + unset CERT_CHANGED + unset CERT_AMOUNT_CHANGED + unset VALIDATED_CERTIFICATES + CERT_ERRORS=0 + CERT_CHANGED=0 + CERT_AMOUNT_CHANGED=0 declare -a SQL_DOMAIN_ARR declare -a VALIDATED_CONFIG_DOMAINS declare -a ADDITIONAL_VALIDATED_SAN declare -a ADDITIONAL_WC_ARR declare -a ADDITIONAL_SAN_ARR + declare -a VALIDATED_CERTIFICATES IFS=',' read -r -a TMP_ARR <<< "${ADDITIONAL_SAN}" for i in "${TMP_ARR[@]}" ; do if [[ "$i" =~ \.\*$ ]]; then @@ -258,9 +179,9 @@ while true; do MH_CAAS=( $(dig CAA ${MH_PARENT_DOMAIN} +short | sed -n 's/\d issue "\(.*\)"/\1/p') ) if [[ ! -z ${MH_CAAS} ]]; then if [[ ${MH_CAAS[@]} =~ "letsencrypt.org" ]]; then - echo "Validated CAA for parent domain ${MH_PARENT_DOMAIN}" + log_f "Validated CAA for parent domain ${MH_PARENT_DOMAIN}" else - echo "Skipping ACME validation: Lets Encrypt disallowed for ${MAILCOW_HOSTNAME} by CAA record, retrying in 1h..." + log_f "Skipping ACME validation: Lets Encrypt disallowed for ${MAILCOW_HOSTNAME} by CAA record, retrying in 1h..." sleep 1h exec $(readlink -f "$0") fi @@ -268,84 +189,33 @@ while true; do ######################################### # IP and webroot challenge verification # + SQL_DOMAINS=$(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM domain WHERE backupmx=0" -Bs) + if [[ ! $? -eq 0 ]]; then + log_f "Failed to read SQL domains, retrying in 1 minute..." + sleep 1m + exec $(readlink -f "$0") + fi while read domains; do SQL_DOMAIN_ARR+=("${domains}") - done < <(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM domain WHERE backupmx=0" -Bs) + done <<< "${SQL_DOMAINS}" if [[ ${ONLY_MAILCOW_HOSTNAME} != "y" ]]; then for SQL_DOMAIN in "${SQL_DOMAIN_ARR[@]}"; do + unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS + declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do - if [[ "${SUBDOMAIN}.${SQL_DOMAIN}" != "${MAILCOW_HOSTNAME}" ]]; then - A_SUBDOMAIN=$(dig A ${SUBDOMAIN}.${SQL_DOMAIN} +short | tail -n 1) - AAAA_SUBDOMAIN=$(dig AAAA ${SUBDOMAIN}.${SQL_DOMAIN} +short | tail -n 1) - # Check if CNAME without v6 enabled target - if [[ ! -z ${AAAA_SUBDOMAIN} ]] && [[ -z $(echo ${AAAA_SUBDOMAIN} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") ]]; then - AAAA_SUBDOMAIN= - fi - if [[ ! -z ${AAAA_SUBDOMAIN} ]]; then - log_f "Found AAAA record for ${SUBDOMAIN}.${SQL_DOMAIN}: ${AAAA_SUBDOMAIN} - skipping A record check" - if [[ $(expand ${IPV6:-"0000:0000:0000:0000:0000:0000:0000:0000"}) == $(expand ${AAAA_SUBDOMAIN}) ]] || [[ ${SKIP_IP_CHECK} == "y" ]]; then - if verify_challenge_path "${SUBDOMAIN}.${SQL_DOMAIN}" 6; then - log_f "Confirmed AAAA record with IP ${AAAA_SUBDOMAIN}, adding SAN" - VALIDATED_CONFIG_DOMAINS+=("${SUBDOMAIN}.${SQL_DOMAIN}") - else - log_f "Confirmed AAAA record with IP ${AAAA_SUBDOMAIN}, but HTTP validation failed" - fi - else - log_f "Cannot match your IP ${IPV6:-NO_IPV6_LINK} against hostname ${SUBDOMAIN}.${SQL_DOMAIN} ($(expand ${AAAA_SUBDOMAIN}))" - fi - elif [[ ! -z ${A_SUBDOMAIN} ]]; then - log_f "Found A record for ${SUBDOMAIN}.${SQL_DOMAIN}: ${A_SUBDOMAIN}" - if [[ ${IPV4:-ERR} == ${A_SUBDOMAIN} ]] || [[ ${SKIP_IP_CHECK} == "y" ]]; then - if verify_challenge_path "${SUBDOMAIN}.${SQL_DOMAIN}" 4; then - log_f "Confirmed A record ${A_SUBDOMAIN}, adding SAN" - VALIDATED_CONFIG_DOMAINS+=("${SUBDOMAIN}.${SQL_DOMAIN}") - else - log_f "Confirmed A record with IP ${A_SUBDOMAIN}, but HTTP validation failed" - fi - else - log_f "Cannot match your IP ${IPV4} against hostname ${SUBDOMAIN}.${SQL_DOMAIN} (${A_SUBDOMAIN})" - fi - else - log_f "No A or AAAA record found for hostname ${SUBDOMAIN}.${SQL_DOMAIN}" + if [[ "${SUBDOMAIN}.${SQL_DOMAIN}" != "${MAILCOW_HOSTNAME}" ]]; then + if check_domain "${SUBDOMAIN}.${SQL_DOMAIN}"; then + VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${SUBDOMAIN}.${SQL_DOMAIN}") fi fi done + VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}") done fi - A_MAILCOW_HOSTNAME=$(dig A ${MAILCOW_HOSTNAME} +short | tail -n 1) - AAAA_MAILCOW_HOSTNAME=$(dig AAAA ${MAILCOW_HOSTNAME} +short | tail -n 1) - # Check if CNAME without v6 enabled target - if [[ ! -z ${AAAA_MAILCOW_HOSTNAME} ]] && [[ -z $(echo ${AAAA_MAILCOW_HOSTNAME} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") ]]; then - AAAA_MAILCOW_HOSTNAME= - fi - if [[ ! -z ${AAAA_MAILCOW_HOSTNAME} ]]; then - log_f "Found AAAA record for ${MAILCOW_HOSTNAME}: ${AAAA_MAILCOW_HOSTNAME} - skipping A record check" - if [[ $(expand ${IPV6:-"0000:0000:0000:0000:0000:0000:0000:0000"}) == $(expand ${AAAA_MAILCOW_HOSTNAME}) ]] || [[ ${SKIP_IP_CHECK} == "y" ]]; then - if verify_challenge_path "${MAILCOW_HOSTNAME}" 6; then - log_f "Confirmed AAAA record ${AAAA_MAILCOW_HOSTNAME}" - VALIDATED_MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME} - else - log_f "Confirmed AAAA record with IP ${AAAA_MAILCOW_HOSTNAME}, but HTTP validation failed" - fi - else - log_f "Cannot match your IP ${IPV6:-NO_IPV6_LINK} against hostname ${MAILCOW_HOSTNAME} (DNS returned $(expand ${AAAA_MAILCOW_HOSTNAME}))" - fi - elif [[ ! -z ${A_MAILCOW_HOSTNAME} ]]; then - log_f "Found A record for ${MAILCOW_HOSTNAME}: ${A_MAILCOW_HOSTNAME}" - if [[ ${IPV4:-ERR} == ${A_MAILCOW_HOSTNAME} ]] || [[ ${SKIP_IP_CHECK} == "y" ]]; then - if verify_challenge_path "${MAILCOW_HOSTNAME}" 4; then - log_f "Confirmed A record ${A_MAILCOW_HOSTNAME}" - VALIDATED_MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME} - else - log_f "Confirmed A record with IP ${A_MAILCOW_HOSTNAME}, but HTTP validation failed" - fi - else - log_f "Cannot match your IP ${IPV4} against hostname ${MAILCOW_HOSTNAME} (DNS returned ${A_MAILCOW_HOSTNAME})" - fi - else - log_f "No A or AAAA record found for hostname ${MAILCOW_HOSTNAME}" + if check_domain ${MAILCOW_HOSTNAME}; then + VALIDATED_MAILCOW_HOSTNAME="${MAILCOW_HOSTNAME}" fi if [[ ${ONLY_MAILCOW_HOSTNAME} != "y" ]]; then @@ -355,149 +225,123 @@ while true; do SAN_CAAS=( $(dig CAA ${SAN_PARENT_DOMAIN} +short | sed -n 's/\d issue "\(.*\)"/\1/p') ) if [[ ! -z ${SAN_CAAS} ]]; then if [[ ${SAN_CAAS[@]} =~ "letsencrypt.org" ]]; then - echo "Validated CAA for parent domain ${SAN_PARENT_DOMAIN} of ${SAN}" + log_f "Validated CAA for parent domain ${SAN_PARENT_DOMAIN} of ${SAN}" else - echo "Skipping ACME validation for ${SAN}: Lets Encrypt disallowed for ${SAN} by CAA record" + log_f "Skipping ACME validation for ${SAN}: Lets Encrypt disallowed for ${SAN} by CAA record" continue fi fi if [[ ${SAN} == ${MAILCOW_HOSTNAME} ]]; then continue fi - A_SAN=$(dig A ${SAN} +short | tail -n 1) - AAAA_SAN=$(dig AAAA ${SAN} +short | tail -n 1) - # Check if CNAME without v6 enabled target - if [[ ! -z ${AAAA_SAN} ]] && [[ -z $(echo ${AAAA_SAN} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") ]]; then - AAAA_SAN= - fi - if [[ ! -z ${AAAA_SAN} ]]; then - log_f "Found AAAA record for ${SAN}: ${AAAA_SAN} - skipping A record check" - if [[ $(expand ${IPV6:-"0000:0000:0000:0000:0000:0000:0000:0000"}) == $(expand ${AAAA_SAN}) ]] || [[ ${SKIP_IP_CHECK} == "y" ]]; then - if verify_challenge_path "${SAN}" 6; then - log_f "Confirmed AAAA record with IP ${AAAA_SAN}" - ADDITIONAL_VALIDATED_SAN+=("${SAN}") - else - log_f "Confirmed AAAA record with IP ${AAAA_SAN}, but HTTP validation failed" - fi - else - log_f "Cannot match your IP ${IPV6:-NO_IPV6_LINK} against hostname ${SAN} (DNS returned $(expand ${AAAA_SAN}))" - fi - elif [[ ! -z ${A_SAN} ]]; then - log_f "Found A record for ${SAN}: ${A_SAN}" - if [[ ${IPV4:-ERR} == ${A_SAN} ]] || [[ ${SKIP_IP_CHECK} == "y" ]]; then - if verify_challenge_path "${SAN}" 4; then - log_f "Confirmed A record ${A_SAN}" - ADDITIONAL_VALIDATED_SAN+=("${SAN}") - else - log_f "Confirmed A record with IP ${A_SAN}, but HTTP validation failed" - fi - else - log_f "Cannot match your IP ${IPV4} against hostname ${SAN} (DNS returned ${A_SAN})" - fi - else - log_f "No A or AAAA record found for hostname ${SAN}" + if check_domain ${SAN}; then + ADDITIONAL_VALIDATED_SAN+=("${SAN}") fi done fi - # Unique elements - ALL_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs)) - if [[ -z ${ALL_VALIDATED[*]} ]]; then - log_f "Cannot validate hostnames, skipping Let's Encrypt for 1 hour." + # Unique domains for server certificate + if [[ ${ENABLE_SSL_SNI} == "y" ]]; then + # create certificate for server name and fqdn SANs only + SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs)) + else + # create certificate for all domains, including all subdomains from other domains [*] + SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs)) + fi + if [[ ! -z ${SERVER_SAN_VALIDATED[*]} ]]; then + CERT_NAME=${SERVER_SAN_VALIDATED[0]} + VALIDATED_CERTIFICATES+=("${CERT_NAME}") + + # obtain server certificate if required + DOMAINS=${SERVER_SAN_VALIDATED[@]} /srv/obtain-certificate.sh rsa + RETURN="$?" + if [[ "$RETURN" == "0" ]]; then # 0 = cert created successfully + CERT_AMOUNT_CHANGED=1 + CERT_CHANGED=1 + elif [[ "$RETURN" == "1" ]]; then # 1 = cert renewed successfully + CERT_CHANGED=1 + elif [[ "$RETURN" == "2" ]]; then # 2 = cert not due for renewal + : + else + CERT_ERRORS=1 + fi + # copy hostname certificate to default/server certificate + cp ${ACME_BASE}/${CERT_NAME}/cert.pem ${ACME_BASE}/cert.pem + cp ${ACME_BASE}/${CERT_NAME}/key.pem ${ACME_BASE}/key.pem + fi + + # individual certificates for SNI [@] + if [[ ${ENABLE_SSL_SNI} == "y" ]]; then + for VALIDATED_DOMAINS in "${VALIDATED_CONFIG_DOMAINS[@]}"; do + VALIDATED_DOMAINS_ARR=(${VALIDATED_DOMAINS}) + + unset VALIDATED_DOMAINS_SORTED + declare -a VALIDATED_DOMAINS_SORTED + VALIDATED_DOMAINS_SORTED=(${VALIDATED_DOMAINS_ARR[0]} $(echo ${VALIDATED_DOMAINS_ARR[@]:1} | xargs -n1 | sort -u | xargs)) + + if [[ ! -z ${VALIDATED_DOMAINS_SORTED[*]} ]]; then + CERT_NAME=${VALIDATED_DOMAINS_SORTED[0]} + VALIDATED_CERTIFICATES+=("${CERT_NAME}") + # obtain certificate if required + DOMAINS=${VALIDATED_DOMAINS_SORTED[@]} /srv/obtain-certificate.sh rsa + RETURN="$?" + if [[ "$RETURN" == "0" ]]; then # 0 = cert created successfully + CERT_AMOUNT_CHANGED=1 + CERT_CHANGED=1 + elif [[ "$RETURN" == "1" ]]; then # 1 = cert renewed successfully + CERT_CHANGED=1 + elif [[ "$RETURN" == "2" ]]; then # 2 = cert not due for renewal + : + else + CERT_ERRORS=1 + fi + fi + done + fi + + if [[ -z ${VALIDATED_CERTIFICATES[*]} ]]; then + log_f "Cannot validate any hostnames, skipping Let's Encrypt for 1 hour." log_f "Use SKIP_LETS_ENCRYPT=y in mailcow.conf to skip it permanently." redis-cli -h redis SET ACME_FAIL_TIME "$(date +%s)" sleep 1h exec $(readlink -f "$0") fi - # Collecting SANs from active certificate - SAN_NAMES=$(openssl x509 -noout -text -in ${ACME_BASE}/cert.pem | awk '/X509v3 Subject Alternative Name/ {getline;gsub(/ /, "", $0); print}' | tr -d "DNS:") - if [[ ! -z ${SAN_NAMES} ]]; then - IFS=',' read -a SAN_ARRAY_NOW <<< ${SAN_NAMES} - fi - - # Finding difference in SAN array now vs. SAN array by current configuration - array_diff ORPHANED_SAN SAN_ARRAY_NOW ALL_VALIDATED - if [[ ! -z ${ORPHANED_SAN[*]} ]]; then - log_f "Found orphaned SAN ${ORPHANED_SAN[*]}" - SAN_CHANGE=1 - fi - array_diff ADDED_SAN ALL_VALIDATED SAN_ARRAY_NOW - if [[ ! -z ${ADDED_SAN[*]} ]]; then - log_f "Found new SAN ${ADDED_SAN[*]}" - SAN_CHANGE=1 - fi - - if [[ ${SAN_CHANGE} == 0 ]]; then - # Certificate did not change but could be due for renewal (4 weeks) - if ! openssl x509 -checkend 2592000 -noout -in ${ACME_BASE}/cert.pem; then - log_f "Certificate is due for renewal (< 30 days)" - else - log_f "Certificate validation done, neither changed nor due for renewal, sleeping for another day." - sleep 1d - continue - fi - fi - - DATE=$(date +%Y-%m-%d_%H_%M_%S) - log_f "Creating backups in ${ACME_BASE}/backups/${DATE}/ ..." - mkdir -p ${ACME_BASE}/backups/${DATE}/ - [[ -f ${ACME_BASE}/acme/acme.csr ]] && cp ${ACME_BASE}/acme/acme.csr ${ACME_BASE}/backups/${DATE}/ - [[ -f ${ACME_BASE}/acme/cert.pem ]] && cp ${ACME_BASE}/acme/cert.pem ${ACME_BASE}/backups/${DATE}/ - [[ -f ${ACME_BASE}/acme/key.pem ]] && cp ${ACME_BASE}/acme/key.pem ${ACME_BASE}/backups/${DATE}/ - [[ -f ${ACME_BASE}/acme/account.pem ]] && cp ${ACME_BASE}/acme/account.pem ${ACME_BASE}/backups/${DATE}/ - - # Generating CSR - printf "[SAN]\nsubjectAltName=" > /tmp/_SAN - printf "DNS:%s," "${ALL_VALIDATED[@]}" >> /tmp/_SAN - sed -i '$s/,$//' /tmp/_SAN - openssl req -new -sha256 -key ${ACME_BASE}/acme/key.pem -subj "/" -reqexts SAN -config <(cat /etc/ssl/openssl.cnf /tmp/_SAN) > ${ACME_BASE}/acme/acme.csr - - if [[ "${LE_STAGING}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then - log_f "Using Let's Encrypt staging servers" - STAGING_PARAMETER='--directory-url https://acme-staging-v02.api.letsencrypt.org/directory' - else - STAGING_PARAMETER= - fi - - # acme-tiny writes info to stderr and ceritifcate to stdout - # The redirects will do the following: - # - redirect stdout to temp certificate file - # - redirect acme-tiny stderr to stdout (logs to variable ACME_RESPONSE) - # - tee stderr to get live output and log to dockerd - - ACME_RESPONSE=$(acme-tiny ${STAGING_PARAMETER} \ - --account-key ${ACME_BASE}/acme/account.pem \ - --disable-check \ - --csr ${ACME_BASE}/acme/acme.csr \ - --acme-dir /var/www/acme/ 2>&1 > /tmp/_cert.pem | tee /dev/fd/5) - - case "$?" in - 0) # cert requested - ACME_RESPONSE_B64=$(echo "${ACME_RESPONSE}" | openssl enc -e -A -base64) - log_f "${ACME_RESPONSE_B64}" redis_only b64 - log_f "Deploying..." - # Deploy the new certificate and key - # Moving temp cert to acme/cert.pem - if verify_hash_match /tmp/_cert.pem ${ACME_BASE}/acme/key.pem; then - mv /tmp/_cert.pem ${ACME_BASE}/acme/cert.pem - cp ${ACME_BASE}/acme/cert.pem ${ACME_BASE}/cert.pem - cp ${ACME_BASE}/acme/key.pem ${ACME_BASE}/key.pem - reload_configurations - rm /var/www/acme/* 2> /dev/null - log_f "Certificate successfully deployed, removing backup, sleeping 1d" - sleep 1d - else - log_f "Certificate was successfully requested, but key and certificate have non-matching hashes, ignoring certificate" - log_f "Retrying in 30 minutes..." - sleep 30m - exec $(readlink -f "$0") + # find orphaned certificates if no errors occurred + if [[ "${CERT_ERRORS}" == "0" ]]; then + for EXISTING_CERT in "${EXISTING_CERTS[@]}"; do + if [[ ! "`printf '_%s_\n' "${VALIDATED_CERTIFICATES[@]}"`" == *"_${EXISTING_CERT}_"* ]]; then + DATE=$(date +%Y-%m-%d_%H_%M_%S) + log_f "Found orphaned certificate: ${EXISTING_CERT} - archiving it at ${ACME_BASE}/backups/${EXISTING_CERT}/" + BACKUP_DIR=${ACME_BASE}/backups/${EXISTING_CERT}/${DATE} + # archive rsa cert and any other files + mv ${ACME_BASE}/${EXISTING_CERT} ${BACKUP_DIR} + CERT_CHANGED=1 + CERT_AMOUNT_CHANGED=1 fi + done + fi + + # reload on new or changed certificates + if [[ "${CERT_CHANGED}" == "1" ]]; then + CERT_AMOUNT_CHANGED=${CERT_AMOUNT_CHANGED} /srv/reload-configurations.sh + fi + + case "$CERT_ERRORS" in + 0) # all successful + if [[ "${CERT_CHANGED}" == "1" ]]; then + if [[ "${CERT_AMOUNT_CHANGED}" == "1" ]]; then + log_f "Certificates successfully requested and renewed where required, sleeping one day" + else + log_f "Certificates were successfully renewed where required, sleeping for another day." + fi + else + log_f "Certificates were successfully validated, no changes or renewals required, sleeping for another day." + fi + sleep 1d ;; - *) # non-zero is non-fun - ACME_RESPONSE_B64=$(echo "${ACME_RESPONSE}" | openssl enc -e -A -base64) - log_f "${ACME_RESPONSE_B64}" redis_only b64 - log_f "Retrying in 30 minutes..." + *) # non-zero + log_f "Some errors occurred, retrying in 30 minutes..." redis-cli -h redis SET ACME_FAIL_TIME "$(date +%s)" sleep 30m exec $(readlink -f "$0") diff --git a/data/Dockerfiles/acme/functions.sh b/data/Dockerfiles/acme/functions.sh new file mode 100644 index 00000000..f5510b15 --- /dev/null +++ b/data/Dockerfiles/acme/functions.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +log_f() { + if [[ ${2} == "no_nl" ]]; then + echo -n "$(date) - ${1}" + elif [[ ${2} == "no_date" ]]; then + echo "${1}" + elif [[ ${2} != "redis_only" ]]; then + echo "$(date) - ${1}" + fi + if [[ ${3} == "b64" ]]; then + redis-cli -h redis LPUSH ACME_LOG "{\"time\":\"$(date +%s)\",\"message\":\"base64,$(printf '%s' "${1}")\"}" > /dev/null + else + redis-cli -h redis LPUSH ACME_LOG "{\"time\":\"$(date +%s)\",\"message\":\"$(printf '%s' "${1}" | \ + tr '%&;$"[]{}-\r\n' ' ')\"}" > /dev/null + fi +} + +verify_hash_match(){ + CERT_HASH=$(openssl x509 -in "${1}" -noout -pubkey | openssl md5) + KEY_HASH=$(openssl pkey -in "${2}" -pubout | openssl md5) + if [[ ${CERT_HASH} != ${KEY_HASH} ]]; then + log_f "Certificate and key hashes do not match!" + return 1 + else + log_f "Verified hashes." + return 0 + fi +} + +get_ipv4(){ + local IPV4= + local IPV4_SRCS= + local TRY= + IPV4_SRCS[0]="ip4.mailcow.email" + IPV4_SRCS[1]="ip4.korves.net" + until [[ ! -z ${IPV4} ]] || [[ ${TRY} -ge 10 ]]; do + IPV4=$(curl --connect-timeout 3 -m 10 -L4s ${IPV4_SRCS[$RANDOM % ${#IPV4_SRCS[@]} ]} | grep -E "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$") + [[ ! -z ${TRY} ]] && sleep 1 + TRY=$((TRY+1)) + done + echo ${IPV4} +} + +get_ipv6(){ + local IPV6= + local IPV6_SRCS= + local TRY= + IPV6_SRCS[0]="ip6.korves.net" + IPV6_SRCS[1]="ip6.mailcow.email" + until [[ ! -z ${IPV6} ]] || [[ ${TRY} -ge 10 ]]; do + IPV6=$(curl --connect-timeout 3 -m 10 -L6s ${IPV6_SRCS[$RANDOM % ${#IPV6_SRCS[@]} ]} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") + [[ ! -z ${TRY} ]] && sleep 1 + TRY=$((TRY+1)) + done + echo ${IPV6} +} + +check_domain(){ + DOMAIN=$1 + A_DOMAIN=$(dig A ${DOMAIN} +short | tail -n 1) + AAAA_DOMAIN=$(dig AAAA ${DOMAIN} +short | tail -n 1) + # 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= + fi + if [[ ! -z ${AAAA_DOMAIN} ]]; then + log_f "Found AAAA record for ${DOMAIN}: ${AAAA_DOMAIN} - skipping A record check" + if [[ $(expand ${IPV6:-"0000:0000:0000:0000:0000:0000:0000:0000"}) == $(expand ${AAAA_DOMAIN}) ]] || [[ ${SKIP_IP_CHECK} == "y" ]]; then + if verify_challenge_path "${DOMAIN}" 6; then + log_f "Confirmed AAAA record with IP ${AAAA_DOMAIN}" + return 0 + else + log_f "Confirmed AAAA record with IP ${AAAA_DOMAIN}, but HTTP validation failed" + fi + else + log_f "Cannot match your IP ${IPV6:-NO_IPV6_LINK} against hostname ${DOMAIN} (DNS returned $(expand ${AAAA_DOMAIN}))" + fi + elif [[ ! -z ${A_DOMAIN} ]]; then + log_f "Found A record for ${DOMAIN}: ${A_DOMAIN}" + if [[ ${IPV4:-ERR} == ${A_DOMAIN} ]] || [[ ${SKIP_IP_CHECK} == "y" ]]; then + if verify_challenge_path "${DOMAIN}" 4; then + log_f "Confirmed A record ${A_DOMAIN}" + return 0 + else + log_f "Confirmed A record with IP ${A_DOMAIN}, but HTTP validation failed" + fi + else + log_f "Cannot match your IP ${IPV4} against hostname ${DOMAIN} (DNS returned ${A_DOMAIN})" + fi + else + log_f "No A or AAAA record found for hostname ${DOMAIN}" + fi + return 1 +} + +verify_challenge_path(){ + if [[ ${SKIP_HTTP_VERIFICATION} == "y" ]]; then + echo '(skipping check, returning 0)' + return 0 + fi + # verify_challenge_path URL 4|6 + RANDOM_N=${RANDOM}${RANDOM}${RANDOM} + echo ${RANDOM_N} > /var/www/acme/${RANDOM_N} + if [[ "$(curl --insecure -${2} -L http://${1}/.well-known/acme-challenge/${RANDOM_N} --silent)" == "${RANDOM_N}" ]]; then + rm /var/www/acme/${RANDOM_N} + return 0 + else + rm /var/www/acme/${RANDOM_N} + return 1 + fi +} diff --git a/data/Dockerfiles/acme/obtain-certificate.sh b/data/Dockerfiles/acme/obtain-certificate.sh new file mode 100644 index 00000000..27070ae9 --- /dev/null +++ b/data/Dockerfiles/acme/obtain-certificate.sh @@ -0,0 +1,120 @@ +#!/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 +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 + log_f "Using Let's Encrypt staging servers" + STAGING_PARAMETER='--directory-url https://acme-staging-v02.api.letsencrypt.org/directory' +else + STAGING_PARAMETER= +fi + +if [[ -f ${DOMAINS_FILE} && "$(cat ${DOMAINS_FILE})" == "${CERT_DOMAINS[*]}" ]]; then + if [[ ! -f ${CERT} || ! -f "${KEY}" ]]; then + log_f "Certificate ${CERT} doesn't exist yet - start obtaining" + # Certificate exists and did not change but could be due for renewal (30 days) + 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 +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 /etc/ssl/openssl.cnf /tmp/_SAN) > ${CSR} + +# acme-tiny writes info to stderr and ceritifcate to stdout +# The redirects will do the following: +# - redirect stdout to temp certificate file +# - redirect acme-tiny stderr to stdout (logs to variable ACME_RESPONSE) +# - tee stderr to get live output and log to dockerd + +ACME_RESPONSE=$(acme-tiny ${STAGING_PARAMETER} \ + --account-key ${ACME_BASE}/acme/account.pem \ + --disable-check \ + --csr ${CSR} \ + --acme-dir /var/www/acme/ 2>&1 > /tmp/_cert.pem | 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) # cert requested + log_f "Deploying certificate ${CERT}..." + # Deploy the new certificate and key + # Moving temp cert to {domain} folder + if verify_hash_match /tmp/_cert.pem ${KEY}; then + RETURN=0 # certificate created + if [[ -f ${CERT} ]]; then + RETURN=1 # certificate renewed + fi + mv -f /tmp/_cert.pem ${CERT} + echo -n ${CERT_DOMAINS[*]} > ${DOMAINS_FILE} + rm /var/www/acme/* 2> /dev/null + log_f "Certificate successfully obtained" + exit ${RETURN} + else + log_f "Certificate was successfully requested, but key and certificate have non-matching hashes, ignoring certificate" + exit 4 + fi + ;; + *) # non-zero is non-fun + log_f "Failed to obtain certificate ${CERT} for domains '${CERT_DOMAINS[*]}'" + redis-cli -h redis SET ACME_FAIL_TIME "$(date +%s)" + exit 100${SUCCESS} + ;; +esac diff --git a/data/Dockerfiles/acme/reload-configurations.sh b/data/Dockerfiles/acme/reload-configurations.sh new file mode 100644 index 00000000..23f7ba3a --- /dev/null +++ b/data/Dockerfiles/acme/reload-configurations.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# Reading container IDs +# Wrapping as array to ensure trimmed content when calling $NGINX etc. +NGINX=($(curl --silent --insecure https://dockerapi/containers/json | jq -r '.[] | {name: .Config.Labels["com.docker.compose.service"], id: .Id}' | jq -rc 'select( .name | tostring | contains("nginx-mailcow")) | .id' | tr "\n" " ")) +DOVECOT=($(curl --silent --insecure https://dockerapi/containers/json | jq -r '.[] | {name: .Config.Labels["com.docker.compose.service"], id: .Id}' | jq -rc 'select( .name | tostring | contains("dovecot-mailcow")) | .id' | tr "\n" " ")) +POSTFIX=($(curl --silent --insecure https://dockerapi/containers/json | jq -r '.[] | {name: .Config.Labels["com.docker.compose.service"], id: .Id}' | jq -rc 'select( .name | tostring | contains("postfix-mailcow")) | .id' | tr "\n" " ")) + +reload_nginx(){ + echo "Reloading Nginx..." + NGINX_RELOAD_RET=$(curl -X POST --insecure https://dockerapi/containers/${NGINX}/exec -d '{"cmd":"reload", "task":"nginx"}' --silent -H 'Content-type: application/json' | jq -r .type) + [[ ${NGINX_RELOAD_RET} != 'success' ]] && { echo "Could not reload Nginx, restarting container..."; restart_container ${NGINX} ; } +} + +reload_dovecot(){ + echo "Reloading Dovecot..." + DOVECOT_RELOAD_RET=$(curl -X POST --insecure https://dockerapi/containers/${DOVECOT}/exec -d '{"cmd":"reload", "task":"dovecot"}' --silent -H 'Content-type: application/json' | jq -r .type) + [[ ${DOVECOT_RELOAD_RET} != 'success' ]] && { echo "Could not reload Dovecot, restarting container..."; restart_container ${DOVECOT} ; } +} + +reload_postfix(){ + echo "Reloading Postfix..." + POSTFIX_RELOAD_RET=$(curl -X POST --insecure https://dockerapi/containers/${POSTFIX}/exec -d '{"cmd":"reload", "task":"postfix"}' --silent -H 'Content-type: application/json' | jq -r .type) + [[ ${POSTFIX_RELOAD_RET} != 'success' ]] && { echo "Could not reload Postfix, restarting container..."; restart_container ${POSTFIX} ; } +} + +restart_container(){ + for container in $*; do + echo "Restarting ${container}..." + C_REST_OUT=$(curl -X POST --insecure https://dockerapi/containers/${container}/restart --silent | jq -r '.msg') + echo "${C_REST_OUT}" + done +} + +if [[ "${CERT_AMOUNT_CHANGED}" == "1" ]]; then + restart_container ${NGINX} + restart_container ${DOVECOT} + restart_container ${POSTFIX} +else + reload_nginx + reload_dovecot + reload_postfix +fi diff --git a/data/Dockerfiles/dovecot/docker-entrypoint.sh b/data/Dockerfiles/dovecot/docker-entrypoint.sh index 1702bf5a..1aa75891 100755 --- a/data/Dockerfiles/dovecot/docker-entrypoint.sh +++ b/data/Dockerfiles/dovecot/docker-entrypoint.sh @@ -142,6 +142,20 @@ if [[ $(stat -c %U /var/attachments) != "vmail" ]] ; then chown -R vmail:vmail / # Cleanup random user maildirs rm -rf /var/vmail/mailcow.local/* +# create sni configuration +echo "" > /etc/dovecot/sni.conf +for cert_dir in /etc/ssl/mail/*/ ; do + if [[ ! -f ${cert_dir}domains ]] || [[ ! -f ${cert_dir}cert.pem ]] || [[ ! -f ${cert_dir}key.pem ]]; then + continue + fi + domains=($(cat ${cert_dir}domains)) + for domain in ${domains[@]}; do + echo 'local_name '${domain}' {' >> /etc/dovecot/sni.conf; + echo ' ssl_cert = <'${cert_dir}'cert.pem' >> /etc/dovecot/sni.conf; + echo ' ssl_key = <'${cert_dir}'key.pem' >> /etc/dovecot/sni.conf; + echo '}' >> /etc/dovecot/sni.conf; + done +done # Create random master for SOGo sieve features RAND_USER=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 16 | head -n 1) diff --git a/data/Dockerfiles/postfix/postfix.sh b/data/Dockerfiles/postfix/postfix.sh index 3d060e8c..766ed967 100755 --- a/data/Dockerfiles/postfix/postfix.sh +++ b/data/Dockerfiles/postfix/postfix.sh @@ -19,6 +19,20 @@ spam: "|/usr/local/bin/rspamd-pipe-spam" EOF newaliases; +# create sni configuration +echo -n "" > /opt/postfix/conf/sni.map; +for cert_dir in /etc/ssl/mail/*/ ; do + if [[ ! -f ${cert_dir}domains ]] || [[ ! -f ${cert_dir}cert.pem ]] || [[ ! -f ${cert_dir}key.pem ]]; then + continue; + fi + IFS=" " read -r -a domains <<< "$(cat "${cert_dir}domains")" + for domain in "${domains[@]}"; do + echo -n "${domain} ${cert_dir}key.pem ${cert_dir}cert.pem" >> /opt/postfix/conf/sni.map; + echo "" >> /opt/postfix/conf/sni.map; + done +done +postmap -F hash:/opt/postfix/conf/sni.map; + cat < /opt/postfix/conf/sql/mysql_relay_recipient_maps.cf # Autogenerated by mailcow user = ${DBUSER} diff --git a/data/conf/dovecot/dovecot.conf b/data/conf/dovecot/dovecot.conf index 51e58710..f07c0e19 100644 --- a/data/conf/dovecot/dovecot.conf +++ b/data/conf/dovecot/dovecot.conf @@ -272,6 +272,7 @@ service lmtp { listen = *,[::] ssl_cert = /etc/nginx/conf.d/listen_plain.active && - envsubst < /etc/nginx/conf.d/templates/listen_ssl.template > /etc/nginx/conf.d/listen_ssl.active && - envsubst < /etc/nginx/conf.d/templates/server_name.template > /etc/nginx/conf.d/server_name.active && - envsubst < /etc/nginx/conf.d/templates/sogo.template > /etc/nginx/conf.d/sogo.active && + command: /bin/sh -c "envsubst < /etc/nginx/conf.d/templates/sogo.template > /etc/nginx/conf.d/sogo.active && envsubst < /etc/nginx/conf.d/templates/sogo_eas.template > /etc/nginx/conf.d/sogo_eas.active && . /etc/nginx/conf.d/templates/sogo.auth_request.template.sh > /etc/nginx/conf.d/sogo_proxy_auth.active && + . /etc/nginx/conf.d/templates/sites.template.sh > /etc/nginx/conf.d/sites.active && nginx -qt && until ping phpfpm -c1 > /dev/null; do sleep 1; done && until ping sogo -c1 > /dev/null; do sleep 1; done && @@ -325,6 +323,7 @@ services: - DBUSER=${DBUSER} - DBPASS=${DBPASS} - SKIP_LETS_ENCRYPT=${SKIP_LETS_ENCRYPT:-n} + - ENABLE_SSL_SNI=${ENABLE_SSL_SNI:-n} - SKIP_IP_CHECK=${SKIP_IP_CHECK:-n} - SKIP_HTTP_VERIFICATION=${SKIP_HTTP_VERIFICATION:-n} - ONLY_MAILCOW_HOSTNAME=${ONLY_MAILCOW_HOSTNAME:-n}