#! /bin/bash function _setup_ssl { _notify 'task' 'Setting up SSL' local POSTFIX_CONFIG_MAIN='/etc/postfix/main.cf' local POSTFIX_CONFIG_MASTER='/etc/postfix/master.cf' local DOVECOT_CONFIG_SSL='/etc/dovecot/conf.d/10-ssl.conf' local TMP_DMS_TLS_PATH='/tmp/docker-mailserver/ssl' # config volume local DMS_TLS_PATH='/etc/dms/tls' mkdir -p "${DMS_TLS_PATH}" # Primary certificate to serve for TLS function _set_certificate { local POSTFIX_KEY_WITH_FULLCHAIN=${1} local DOVECOT_KEY=${1} local DOVECOT_CERT=${1} # If a 2nd param is provided, a separate key and cert was received instead of a fullkeychain if [[ -n ${2} ]] then local PRIVATE_KEY=$1 local CERT_CHAIN=$2 POSTFIX_KEY_WITH_FULLCHAIN="${PRIVATE_KEY} ${CERT_CHAIN}" DOVECOT_KEY="${PRIVATE_KEY}" DOVECOT_CERT="${CERT_CHAIN}" fi # Postfix configuration # NOTE: `smtpd_tls_chain_files` expects private key defined before public cert chain # Value can be a single PEM file, or a sequence of files; so long as the order is key->leaf->chain sedfile -i -r "s|^(smtpd_tls_chain_files =).*|\1 ${POSTFIX_KEY_WITH_FULLCHAIN}|" "${POSTFIX_CONFIG_MAIN}" # Dovecot configuration sedfile -i -r \ -e "s|^(ssl_key =).*|\1 <${DOVECOT_KEY}|" \ -e "s|^(ssl_cert =).*|\1 <${DOVECOT_CERT}|" \ "${DOVECOT_CONFIG_SSL}" } # Enables supporting two certificate types such as ECDSA with an RSA fallback function _set_alt_certificate { local COPY_KEY_FROM_PATH=$1 local COPY_CERT_FROM_PATH=$2 local PRIVATE_KEY_ALT="${DMS_TLS_PATH}/fallback_key" local CERT_CHAIN_ALT="${DMS_TLS_PATH}/fallback_cert" cp "${COPY_KEY_FROM_PATH}" "${PRIVATE_KEY_ALT}" cp "${COPY_CERT_FROM_PATH}" "${CERT_CHAIN_ALT}" chmod 600 "${PRIVATE_KEY_ALT}" chmod 644 "${CERT_CHAIN_ALT}" # Postfix configuration # NOTE: This operation doesn't replace the line, it appends to the end of the line. # Thus this method should only be used when this line has explicitly been replaced earlier in the script. # Otherwise without `docker-compose down` first, a `docker-compose up` may # persist previous container state and cause a failure in postfix configuration. sedfile -i "s|^smtpd_tls_chain_files =.*|& ${PRIVATE_KEY_ALT} ${CERT_CHAIN_ALT}|" "${POSTFIX_CONFIG_MAIN}" # Dovecot configuration # Conditionally checks for `#`, in the event that internal container state is accidentally persisted, # can be caused by: `docker-compose up` run again after a `ctrl+c`, without running `docker-compose down` sedfile -i -r \ -e "s|^#?(ssl_alt_key =).*|\1 <${PRIVATE_KEY_ALT}|" \ -e "s|^#?(ssl_alt_cert =).*|\1 <${CERT_CHAIN_ALT}|" \ "${DOVECOT_CONFIG_SSL}" } function _apply_tls_level { local TLS_CIPHERS_ALLOW=$1 local TLS_PROTOCOL_IGNORE=$2 local TLS_PROTOCOL_MINIMUM=$3 # Postfix configuration sed -i -r \ -e "s|^(smtpd?_tls_mandatory_protocols =).*|\1 ${TLS_PROTOCOL_IGNORE}|" \ -e "s|^(smtpd?_tls_protocols =).*|\1 ${TLS_PROTOCOL_IGNORE}|" \ -e "s|^(tls_high_cipherlist =).*|\1 ${TLS_CIPHERS_ALLOW}|" \ "${POSTFIX_CONFIG_MAIN}" # Dovecot configuration (secure by default though) sed -i -r \ -e "s|^(ssl_min_protocol =).*|\1 ${TLS_PROTOCOL_MINIMUM}|" \ -e "s|^(ssl_cipher_list =).*|\1 ${TLS_CIPHERS_ALLOW}|" \ "${DOVECOT_CONFIG_SSL}" } # 2020 feature intended for Traefik v2 support only: # https://github.com/docker-mailserver/docker-mailserver/pull/1553 # Extracts files `key.pem` and `fullchain.pem`. # `_extract_certs_from_acme` is located in `helper-functions.sh` # NOTE: See the `SSL_TYPE=letsencrypt` case below for more details. function _traefik_support { if [[ -f /etc/letsencrypt/acme.json ]] then # Variable only intended for troubleshooting via debug output local EXTRACTED_DOMAIN # Conditional handling depends on the success of `_extract_certs_from_acme`, # Failure tries the next fallback FQDN to try extract a certificate from. # Subshell not used in conditional to ensure extraction log output is still captured if [[ -n ${SSL_DOMAIN} ]] && _extract_certs_from_acme "${SSL_DOMAIN}" then EXTRACTED_DOMAIN=('SSL_DOMAIN' "${SSL_DOMAIN}") elif _extract_certs_from_acme "${HOSTNAME}" then EXTRACTED_DOMAIN=('HOSTNAME' "${HOSTNAME}") elif _extract_certs_from_acme "${DOMAINNAME}" then EXTRACTED_DOMAIN=('DOMAINNAME' "${DOMAINNAME}") else _notify 'err' "'setup-stack.sh' | letsencrypt (acme.json) failed to identify a certificate to extract" fi _notify 'inf' "'setup-stack.sh' | letsencrypt (acme.json) extracted certificate using ${EXTRACTED_DOMAIN[0]}: '${EXTRACTED_DOMAIN[1]}'" fi } # TLS strength/level configuration case "${TLS_LEVEL}" in ( "modern" ) local TLS_MODERN_SUITE='ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384' local TLS_MODERN_IGNORE='!SSLv2,!SSLv3,!TLSv1,!TLSv1.1' local TLS_MODERN_MIN='TLSv1.2' _apply_tls_level "${TLS_MODERN_SUITE}" "${TLS_MODERN_IGNORE}" "${TLS_MODERN_MIN}" _notify 'inf' "TLS configured with 'modern' ciphers" ;; ( "intermediate" ) local TLS_INTERMEDIATE_SUITE='ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA' local TLS_INTERMEDIATE_IGNORE='!SSLv2,!SSLv3' local TLS_INTERMEDIATE_MIN='TLSv1' _apply_tls_level "${TLS_INTERMEDIATE_SUITE}" "${TLS_INTERMEDIATE_IGNORE}" "${TLS_INTERMEDIATE_MIN}" # Lowers the minimum acceptable TLS version connection to `TLSv1` (from Debian upstream `TLSv1.2`) # Lowers Security Level to `1` (from Debian upstream `2`, openssl release defaults to `1`) # https://www.openssl.org/docs/man1.1.1/man3/SSL_CTX_set_security_level.html # https://wiki.debian.org/ContinuousIntegration/TriagingTips/openssl-1.1.1 # https://dovecot.org/pipermail/dovecot/2020-October/120225.html # TODO: This is a fix for Debian Bullseye Dovecot. Can remove when we only support TLS >=1.2. # WARNING: This applies to all processes that use openssl and respect these settings. sedfile -i -r \ -e 's|^(MinProtocol).*|\1 = TLSv1|' \ -e 's|^(CipherString).*|\1 = DEFAULT@SECLEVEL=1|' \ /usr/lib/ssl/openssl.cnf _notify 'inf' "TLS configured with 'intermediate' ciphers" ;; ( * ) _notify 'err' "TLS_LEVEL not found [ in ${FUNCNAME[0]} ]" ;; esac local SCOPE_SSL_TYPE="TLS Setup [SSL_TYPE=${SSL_TYPE}]" # SSL certificate Configuration # TODO: Refactor this feature, it's been extended multiple times for specific inputs/providers unnecessarily. # NOTE: Some `SSL_TYPE` logic uses mounted certs/keys directly, some make an internal copy either retaining filename or renaming. case "${SSL_TYPE}" in ( "letsencrypt" ) _notify 'inf' "Configuring SSL using 'letsencrypt'" # `docker-mailserver` will only use one certificate from an FQDN folder in `/etc/letsencrypt/live/`. # We iterate the sequence [SSL_DOMAIN, HOSTNAME, DOMAINNAME] to find a matching FQDN folder. # This same sequence is used for the Traefik `acme.json` certificate extraction process, which outputs the FQDN folder. # # eg: If HOSTNAME (mail.example.test) doesn't exist, try DOMAINNAME (example.test). # SSL_DOMAIN if set will take priority and is generally expected to have a wildcard prefix. # SSL_DOMAIN will have any wildcard prefix stripped for the output FQDN folder it is stored in. # TODO: A wildcard cert needs to be provisioned via Traefik to validate if acme.json contains any other value for `main` or `sans` beyond the wildcard. # # NOTE: HOSTNAME is set via `helper-functions.sh`, it is not the original system HOSTNAME ENV anymore. # TODO: SSL_DOMAIN is Traefik specific, it no longer seems relevant and should be considered for removal. _traefik_support # letsencrypt folders and files mounted in /etc/letsencrypt local LETSENCRYPT_DOMAIN local LETSENCRYPT_KEY # Identify a valid letsencrypt FQDN folder to use. if [[ -n ${SSL_DOMAIN} ]] && [[ -e /etc/letsencrypt/live/$(_strip_wildcard_prefix "${SSL_DOMAIN}")/fullchain.pem ]] then LETSENCRYPT_DOMAIN=$(_strip_wildcard_prefix "${SSL_DOMAIN}") elif [[ -e /etc/letsencrypt/live/${HOSTNAME}/fullchain.pem ]] then LETSENCRYPT_DOMAIN=${HOSTNAME} elif [[ -e /etc/letsencrypt/live/${DOMAINNAME}/fullchain.pem ]] then LETSENCRYPT_DOMAIN=${DOMAINNAME} else _notify 'err' "Cannot find a valid DOMAIN for '/etc/letsencrypt/live//', tried: '${SSL_DOMAIN}', '${HOSTNAME}', '${DOMAINNAME}'" dms_panic__misconfigured 'LETSENCRYPT_DOMAIN' "${SCOPE_SSL_TYPE}" return 1 fi # Verify the FQDN folder also includes a valid private key (`privkey.pem` for Certbot, `key.pem` for extraction by Traefik) if [[ -e /etc/letsencrypt/live/${LETSENCRYPT_DOMAIN}/privkey.pem ]] then LETSENCRYPT_KEY='privkey' elif [[ -e /etc/letsencrypt/live/${LETSENCRYPT_DOMAIN}/key.pem ]] then LETSENCRYPT_KEY='key' else _notify 'err' "Cannot find key file ('privkey.pem' or 'key.pem') in '/etc/letsencrypt/live/${LETSENCRYPT_DOMAIN}/'" dms_panic__misconfigured 'LETSENCRYPT_KEY' "${SCOPE_SSL_TYPE}" return 1 fi # Update relevant config for Postfix and Dovecot _notify 'inf' "Adding ${LETSENCRYPT_DOMAIN} SSL certificate to the postfix and dovecot configuration" # LetsEncrypt `fullchain.pem` and `privkey.pem` contents are detailed here from CertBot: # https://certbot.eff.org/docs/using.html#where-are-my-certificates # `key.pem` was added for `simp_le` support (2016): https://github.com/docker-mailserver/docker-mailserver/pull/288 # `key.pem` is also a filename used by the `_extract_certs_from_acme` method (implemented for Traefik v2 only) local PRIVATE_KEY="/etc/letsencrypt/live/${LETSENCRYPT_DOMAIN}/${LETSENCRYPT_KEY}.pem" local CERT_CHAIN="/etc/letsencrypt/live/${LETSENCRYPT_DOMAIN}/fullchain.pem" _set_certificate "${PRIVATE_KEY}" "${CERT_CHAIN}" _notify 'inf' "SSL configured with 'letsencrypt' certificates" ;; ( "custom" ) # (hard-coded path) Use a private key with full certificate chain all in a single PEM file. _notify 'inf' "Adding ${HOSTNAME} SSL certificate" # NOTE: Dovecot works fine still as both values are bundled into the keychain local COMBINED_PEM_NAME="${HOSTNAME}-full.pem" local TMP_KEY_WITH_FULLCHAIN="${TMP_DMS_TLS_PATH}/${COMBINED_PEM_NAME}" local KEY_WITH_FULLCHAIN="${DMS_TLS_PATH}/${COMBINED_PEM_NAME}" if [[ -f ${TMP_KEY_WITH_FULLCHAIN} ]] then cp "${TMP_KEY_WITH_FULLCHAIN}" "${KEY_WITH_FULLCHAIN}" chmod 600 "${KEY_WITH_FULLCHAIN}" _set_certificate "${KEY_WITH_FULLCHAIN}" _notify 'inf' "SSL configured with 'CA signed/custom' certificates" else dms_panic__no_file "${TMP_KEY_WITH_FULLCHAIN}" "${SCOPE_SSL_TYPE}" fi ;; ( "manual" ) # (dynamic path via ENV) Use separate private key and cert/chain files (should be PEM encoded) _notify 'inf' "Configuring certificates using key ${SSL_KEY_PATH} and cert ${SSL_CERT_PATH}" # Source files are copied internally to these destinations: local PRIVATE_KEY="${DMS_TLS_PATH}/key" local CERT_CHAIN="${DMS_TLS_PATH}/cert" # Fail early: if [[ -z ${SSL_KEY_PATH} ]] && [[ -z ${SSL_CERT_PATH} ]] then dms_panic__no_env 'SSL_KEY_PATH or SSL_CERT_PATH' "${SCOPE_SSL_TYPE}" fi if [[ -n ${SSL_ALT_KEY_PATH} ]] \ && [[ -n ${SSL_ALT_CERT_PATH} ]] \ && [[ ! -f ${SSL_ALT_KEY_PATH} ]] \ && [[ ! -f ${SSL_ALT_CERT_PATH} ]] then dms_panic__no_file "(ALT) ${SSL_ALT_KEY_PATH} or ${SSL_ALT_CERT_PATH}" "${SCOPE_SSL_TYPE}" fi if [[ -f ${SSL_KEY_PATH} ]] && [[ -f ${SSL_CERT_PATH} ]] then cp "${SSL_KEY_PATH}" "${PRIVATE_KEY}" cp "${SSL_CERT_PATH}" "${CERT_CHAIN}" chmod 600 "${PRIVATE_KEY}" chmod 644 "${CERT_CHAIN}" _set_certificate "${PRIVATE_KEY}" "${CERT_CHAIN}" # Support for a fallback certificate, useful for hybrid/dual ECDSA + RSA certs if [[ -n ${SSL_ALT_KEY_PATH} ]] && [[ -n ${SSL_ALT_CERT_PATH} ]] then _notify 'inf' "Configuring fallback certificates using key ${SSL_ALT_KEY_PATH} and cert ${SSL_ALT_CERT_PATH}" _set_alt_certificate "${SSL_ALT_KEY_PATH}" "${SSL_ALT_CERT_PATH}" else # If the Dovecot settings for alt cert has been enabled (doesn't start with `#`), # but required ENV var is missing, reset to disabled state: sed -i -r \ -e 's|^(ssl_alt_key =).*|#\1 The plaintext authentication is always allowed (and SSL not required) for connections from localhost, as they’re assumed to be secure anyway. # > This applies to all connections where the local and the remote IP addresses are equal. # > Also IP ranges specified by login_trusted_networks setting are assumed to be secure. # # no => insecure auth allowed, yes (default) => plaintext auth only allowed over a secure connection (insecure connection acceptable for non-plaintext auth) local DISABLE_PLAINTEXT_AUTH='no' # no => disabled, yes => optional (secure connections not required), required (default) => mandatory (only secure connections allowed) local DOVECOT_SSL_ENABLED='no' sed -i -r "s|^#?(disable_plaintext_auth =).*|\1 ${DISABLE_PLAINTEXT_AUTH}|" /etc/dovecot/conf.d/10-auth.conf sed -i -r "s|^(ssl =).*|\1 ${DOVECOT_SSL_ENABLED}|" "${DOVECOT_CONFIG_SSL}" ;; ( 'snakeoil' ) # This is a temporary workaround for testing only, using the insecure snakeoil cert. # mail_privacy.bats and mail_with_ldap.bats both attempt to make a starttls connection with openssl, # failing if SSL/TLS is not available. ;; ( * ) # Unknown option, panic. dms_panic__invalid_value 'SSL_TYPE' "${SCOPE_TLS_LEVEL}" ;; esac } export -f _setup_ssl function _extract_certs_from_acme { local CERT_DOMAIN=${1} if [[ -z ${CERT_DOMAIN} ]] then _notify 'err' "_extract_certs_from_acme | CERT_DOMAIN is empty" return 1 fi local KEY CERT KEY=$(acme_extract /etc/letsencrypt/acme.json "${CERT_DOMAIN}" --key) CERT=$(acme_extract /etc/letsencrypt/acme.json "${CERT_DOMAIN}" --cert) if [[ -z ${KEY} ]] || [[ -z ${CERT} ]] then _notify 'warn' "_extract_certs_from_acme | Unable to find key and/or cert for '${CERT_DOMAIN}' in '/etc/letsencrypt/acme.json'" return 1 fi # Currently we advise SSL_DOMAIN for wildcard support using a `*.example.com` value, # The filepath however should be `example.com`, avoiding the wildcard part: if [[ ${SSL_DOMAIN} == "${CERT_DOMAIN}" ]] then CERT_DOMAIN=$(_strip_wildcard_prefix "${SSL_DOMAIN}") fi mkdir -p "/etc/letsencrypt/live/${CERT_DOMAIN}/" echo "${KEY}" | base64 -d > "/etc/letsencrypt/live/${CERT_DOMAIN}/key.pem" || exit 1 echo "${CERT}" | base64 -d > "/etc/letsencrypt/live/${CERT_DOMAIN}/fullchain.pem" || exit 1 _notify 'inf' "_extract_certs_from_acme | Certificate successfully extracted for '${CERT_DOMAIN}'" } export -f _extract_certs_from_acme # Remove the `*.` prefix if it exists, else returns the input value function _strip_wildcard_prefix { [[ ${1} == "*."* ]] && echo "${1:2}" || echo "${1}" } export -f _strip_wildcard_prefix # Compute checksums of monitored files, # returned output on `stdout`: hash + filepath tuple on each line function _monitored_files_checksums { local DMS_DIR=/tmp/docker-mailserver [[ -d ${DMS_DIR} ]] || return 1 # If a wildcard path pattern (or an empty ENV) would yield an invalid path # or no results, `shopt -s nullglob` prevents it from being added. shopt -s nullglob declare -a STAGING_FILES CHANGED_FILES STAGING_FILES=( "${DMS_DIR}/postfix-accounts.cf" "${DMS_DIR}/postfix-virtual.cf" "${DMS_DIR}/postfix-aliases.cf" "${DMS_DIR}/dovecot-quotas.cf" ) if [[ ${SSL_TYPE:-} == 'manual' ]] then # When using "manual" as the SSL type, # the following variables may contain the certificate files STAGING_FILES+=( "${SSL_CERT_PATH:-}" "${SSL_KEY_PATH:-}" "${SSL_ALT_CERT_PATH:-}" "${SSL_ALT_KEY_PATH:-}" ) elif [[ ${SSL_TYPE:-} == 'letsencrypt' ]] then # React to any cert changes within the following Let'sEncrypt locations: STAGING_FILES+=( /etc/letsencrypt/acme.json /etc/letsencrypt/live/"${SSL_DOMAIN}"/*.pem /etc/letsencrypt/live/"${HOSTNAME}"/*.pem /etc/letsencrypt/live/"${DOMAINNAME}"/*.pem ) fi for FILE in "${STAGING_FILES[@]}" do [[ -f "${FILE}" ]] && CHANGED_FILES+=("${FILE}") done sha512sum -- "${CHANGED_FILES[@]}" } export -f _monitored_files_checksums