#!/usr/bin/env bash set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" project_dir="${MAILHUB_CERT_PROJECT_DIR:-$(cd "${script_dir}/.." && pwd -P)}" project_dir="$(cd "${project_dir}" && pwd -P)" env_file="${MAILHUB_CERT_ENV_FILE:-${project_dir}/.env}" source_dir="${MAILHUB_CERT_SOURCE_DIR:-}" restart_app="${MAILHUB_CERT_RESTART:-0}" verify_host="${MAILHUB_CERT_VERIFY_HOST:-}" verify_endpoints="${MAILHUB_CERT_VERIFY_ENDPOINTS:-465 993}" certs_dir="${project_dir}/certs" lock_file="${MAILHUB_CERT_LOCK_FILE:-${certs_dir}/.sync-tls-certificate.lock}" work_dir="" target_cert="" target_key="" backup_cert_exists=0 backup_key_exists=0 rollback_required=0 target_owner_uid="" target_reader_gid="" fail() { echo "Certificate sync failed: $*" >&2 exit 1 } read_env_value() { local key="$1" local line value line="$(grep -E "^${key}=" "${env_file}" 2>/dev/null | tail -n 1 || true)" value="${line#*=}" value="${value%$'\r'}" if [[ "${value}" == \"*\" && "${value}" == *\" ]]; then value="${value:1:${#value}-2}" elif [[ "${value}" == \'*\' && "${value}" == *\' ]]; then value="${value:1:${#value}-2}" fi printf '%s' "${value}" } resolve_target() { local container_path="$1" local label="$2" local relative [[ "${container_path}" == /certs/* ]] || fail "${label} must point to a direct file under /certs." relative="${container_path#/certs/}" [[ -n "${relative}" && "${relative}" != */* && "${relative}" != "." && "${relative}" != ".." ]] \ || fail "${label} must point to a direct file under /certs." printf '%s/%s' "${certs_dir}" "${relative}" } validate_pair() { local certificate="$1" local private_key="$2" local label="$3" local cert_public_key="${work_dir}/${label}-cert-public.der" local key_public_key="${work_dir}/${label}-key-public.der" openssl x509 -in "${certificate}" -noout >/dev/null 2>&1 \ || fail "${label} certificate is invalid." openssl x509 -in "${certificate}" -checkend 86400 -noout >/dev/null 2>&1 \ || fail "${label} certificate expires within 24 hours." openssl x509 -in "${certificate}" -checkhost "${mail_hostname}" -noout >/dev/null 2>&1 \ || fail "${label} certificate does not cover ${mail_hostname}." openssl pkey -in "${private_key}" -check -noout >/dev/null 2>&1 \ || fail "${label} private key is invalid." openssl x509 -in "${certificate}" -pubkey -noout \ | openssl pkey -pubin -outform DER >"${cert_public_key}" 2>/dev/null openssl pkey -in "${private_key}" -pubout -outform DER >"${key_public_key}" 2>/dev/null cmp -s "${cert_public_key}" "${key_public_key}" \ || fail "${label} certificate and private key do not match." } resolve_target_ownership() { local detected_gid="" if [[ "$(id -u)" == "0" ]]; then target_owner_uid="${MAILHUB_CERT_OWNER_UID:-0}" if [[ -n "${MAILHUB_CERT_READER_GID:-}" ]]; then target_reader_gid="${MAILHUB_CERT_READER_GID}" else command -v docker >/dev/null 2>&1 \ || fail "docker is required to detect the MailHub app group id." detected_gid="$(cd "${project_dir}" && docker compose exec -T app node -e \ 'process.stdout.write(String(process.getgid()))' 2>/dev/null || true)" [[ -n "${detected_gid}" ]] \ || fail "unable to determine the MailHub app group id; set MAILHUB_CERT_READER_GID explicitly." target_reader_gid="${detected_gid}" fi [[ "${target_owner_uid}" =~ ^[0-9]+$ ]] || fail "MAILHUB_CERT_OWNER_UID must be numeric." [[ "${target_reader_gid}" =~ ^[0-9]+$ ]] || fail "MAILHUB_CERT_READER_GID must be numeric." elif [[ -n "${MAILHUB_CERT_OWNER_UID:-}" || -n "${MAILHUB_CERT_READER_GID:-}" ]]; then fail "certificate ownership can only be changed when the sync runs as root." fi } set_file_metadata() { local file="$1" local mode="$2" chmod "${mode}" "${file}" if [[ -n "${target_owner_uid}" ]]; then chown "${target_owner_uid}:${target_reader_gid}" "${file}" fi } set_target_metadata() { set_file_metadata "$1" 0644 set_file_metadata "$2" 0640 } wait_for_app_health() { local attempt for attempt in $(seq 1 45); do if docker compose exec -T app node -e \ "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" \ >/dev/null 2>&1; then return 0 fi sleep 2 done return 1 } wait_for_dovecot_health() { local attempt for attempt in $(seq 1 45); do if docker compose exec -T dovecot doveconf -n >/dev/null 2>&1; then return 0 fi sleep 2 done return 1 } restart_tls_services() { docker compose restart app dovecot wait_for_app_health || fail "MailHub app did not become healthy after the certificate restart." wait_for_dovecot_health || fail "Dovecot did not become healthy after the certificate restart." } verify_container_access() { docker compose exec -T app node -e \ "const fs=require('node:fs');const tls=require('node:tls');tls.createSecureContext({cert:fs.readFileSync(process.argv[1]),key:fs.readFileSync(process.argv[2])});" \ "${container_cert}" "${container_key}" >/dev/null 2>&1 \ || fail "MailHub app cannot read or parse the synchronized certificate pair." docker compose exec -T dovecot sh -c \ 'test -r "$1" && test -r "$2"' -- "${container_cert}" "${container_key}" >/dev/null 2>&1 \ || fail "Dovecot cannot read the synchronized certificate pair." } tls_endpoint_matches() { local port="$1" service="app" container_port="$1" local mapping container_id mapped_host mapped_port connect_host connection output presented local expected_fingerprint actual_fingerprint local -a protocol_args=() [[ "${port}" =~ ^[0-9]+$ ]] || return 1 case "${port}" in 25|587|2525) protocol_args=(-starttls smtp) ;; 110) service="dovecot"; container_port="31110"; protocol_args=(-starttls pop3) ;; 143) service="dovecot"; container_port="31143"; protocol_args=(-starttls imap) ;; 993) service="dovecot"; container_port="31993" ;; 995) service="dovecot"; container_port="31995" ;; esac if ! mapping="$(docker compose port "${service}" "${container_port}" 2>/dev/null | head -n 1)" \ || [[ -z "${mapping}" ]]; then container_id="$(docker compose ps -q "${service}" 2>/dev/null | head -n 1 || true)" if [[ -n "${container_id}" ]]; then mapping="$(docker port "${container_id}" "${container_port}/tcp" 2>/dev/null | head -n 1 || true)" else mapping="" fi fi [[ -n "${mapping}" ]] || return 1 mapped_port="${mapping##*:}" if [[ "${mapping}" == \[*\]:* ]]; then mapped_host="${mapping#\[}" mapped_host="${mapped_host%%\]*}" else mapped_host="${mapping%:*}" fi connect_host="${verify_host:-${mapped_host}}" [[ "${connect_host}" != "0.0.0.0" ]] || connect_host="127.0.0.1" [[ "${connect_host}" != "::" ]] || connect_host="::1" if [[ "${connect_host}" == *:* && "${connect_host}" != \[*\] ]]; then connection="[${connect_host}]:${mapped_port}" else connection="${connect_host}:${mapped_port}" fi output="${work_dir}/tls-${port}.txt" presented="${work_dir}/tls-${port}.pem" if command -v timeout >/dev/null 2>&1; then if ! timeout 15 openssl s_client "${protocol_args[@]}" -connect "${connection}" -servername "${mail_hostname}" -showcerts \ -verify_hostname "${mail_hostname}" -verify_return_error \ < /dev/null >"${output}" 2>/dev/null; then return 1; fi elif ! openssl s_client "${protocol_args[@]}" -connect "${connection}" -servername "${mail_hostname}" -showcerts \ -verify_hostname "${mail_hostname}" -verify_return_error \ < /dev/null >"${output}" 2>/dev/null; then return 1 fi openssl x509 -in "${output}" -out "${presented}" >/dev/null 2>&1 \ || return 1 openssl x509 -in "${presented}" -checkhost "${mail_hostname}" -noout >/dev/null 2>&1 \ || return 1 expected_fingerprint="$(openssl x509 -in "${target_cert}" -noout -fingerprint -sha256)" actual_fingerprint="$(openssl x509 -in "${presented}" -noout -fingerprint -sha256)" [[ -n "${expected_fingerprint}" && "${expected_fingerprint}" == "${actual_fingerprint}" ]] \ || return 1 } verify_tls_endpoint() { tls_endpoint_matches "$1" \ || fail "MailHub TLS endpoint $1 is not serving a valid synchronized certificate." } restore_previous_pair() { local rollback_failed=0 set +e if [[ "${backup_cert_exists}" == "1" ]]; then cp -a "${work_dir}/backup-cert.pem" "${target_cert}" else rm -f -- "${target_cert}" fi [[ "$?" == "0" ]] || rollback_failed=1 if [[ "${backup_key_exists}" == "1" ]]; then cp -a "${work_dir}/backup-key.pem" "${target_key}" else rm -f -- "${target_key}" fi [[ "$?" == "0" ]] || rollback_failed=1 set -e return "${rollback_failed}" } on_exit() { local status=$? trap - EXIT if [[ "${status}" != "0" && "${rollback_required}" == "1" ]]; then echo "Certificate sync failed after promotion; restoring the previous certificate pair." >&2 if restore_previous_pair; then if [[ "${restart_app}" == "1" ]]; then set +e docker compose restart app dovecot >/dev/null 2>&1 wait_for_app_health >/dev/null 2>&1 wait_for_dovecot_health >/dev/null 2>&1 set -e fi echo "Previous MailHub certificate pair restored." >&2 else echo "Certificate rollback failed; inspect ${target_cert} and ${target_key} immediately." >&2 fi fi if [[ -n "${work_dir}" && -d "${work_dir}" ]]; then rm -rf -- "${work_dir}" fi exit "${status}" } trap on_exit EXIT [[ -f "${env_file}" ]] || fail "environment file not found: ${env_file}" [[ "${restart_app}" == "0" || "${restart_app}" == "1" ]] \ || fail "MAILHUB_CERT_RESTART must be 0 or 1." command -v openssl >/dev/null 2>&1 || fail "openssl is required." [[ ! -L "${certs_dir}" ]] || fail "certificate directory must not be a symbolic link: ${certs_dir}" mkdir -p "${certs_dir}" canonical_certs_dir="$(cd "${certs_dir}" && pwd -P)" [[ "${canonical_certs_dir}" == "${certs_dir}" ]] \ || fail "certificate directory resolves outside the project: ${certs_dir}" if command -v flock >/dev/null 2>&1; then if [[ -L "${lock_file}" ]]; then fail "certificate lock file must not be a symbolic link: ${lock_file}" fi exec 9>"${lock_file}" if ! flock -n 9; then echo "Another MailHub certificate sync is already running; skipping." exit 0 fi fi if [[ -z "${source_dir}" ]]; then source_dir="$(read_env_value MAILHUB_CERT_SOURCE_DIR)" fi if [[ -z "${source_dir}" ]]; then echo "Certificate sync is not configured; skipping." exit 0 fi source_cert="${source_dir%/}/fullchain.pem" source_key="${source_dir%/}/privkey.pem" [[ -r "${source_cert}" ]] || fail "source certificate is not readable: ${source_cert}" [[ -r "${source_key}" ]] || fail "source private key is not readable: ${source_key}" [[ ! "${source_cert}" -ef "${source_key}" ]] || fail "source certificate and private key must be different files." container_cert="$(read_env_value SUBMISSION_TLS_CERT)" container_key="$(read_env_value SUBMISSION_TLS_KEY)" mail_hostname="$(read_env_value MAIL_HOSTNAME)" [[ -n "${container_cert}" ]] || fail "SUBMISSION_TLS_CERT is not configured." [[ -n "${container_key}" ]] || fail "SUBMISSION_TLS_KEY is not configured." [[ -n "${mail_hostname}" ]] || fail "MAIL_HOSTNAME is not configured." target_cert="$(resolve_target "${container_cert}" SUBMISSION_TLS_CERT)" target_key="$(resolve_target "${container_key}" SUBMISSION_TLS_KEY)" [[ "${target_cert}" != "${target_key}" ]] \ || fail "SUBMISSION_TLS_CERT and SUBMISSION_TLS_KEY must point to different files." [[ ! -L "${target_cert}" ]] || fail "target certificate must not be a symbolic link." [[ ! -L "${target_key}" ]] || fail "target private key must not be a symbolic link." [[ ! -L "${target_cert}.previous" ]] || fail "previous certificate backup must not be a symbolic link." [[ ! -L "${target_key}.previous" ]] || fail "previous private key backup must not be a symbolic link." [[ ! -e "${target_cert}.previous" || -f "${target_cert}.previous" ]] \ || fail "previous certificate backup must be a regular file." [[ ! -e "${target_key}.previous" || -f "${target_key}.previous" ]] \ || fail "previous private key backup must be a regular file." resolve_target_ownership work_dir="$(mktemp -d "${certs_dir}/.cert-sync.XXXXXX")" chmod 0700 "${work_dir}" staged_cert="${work_dir}/next-cert.pem" staged_key="${work_dir}/next-key.pem" cp -- "${source_cert}" "${staged_cert}" cp -- "${source_key}" "${staged_key}" set_target_metadata "${staged_cert}" "${staged_key}" validate_pair "${staged_cert}" "${staged_key}" source if [[ -f "${target_cert}" && -f "${target_key}" ]] \ && cmp -s "${staged_cert}" "${target_cert}" \ && cmp -s "${staged_key}" "${target_key}"; then set_target_metadata "${target_cert}" "${target_key}" if [[ "${restart_app}" == "1" ]]; then cd "${project_dir}" verify_container_access live_certificate_current=1 for port in ${verify_endpoints}; do if ! tls_endpoint_matches "${port}"; then live_certificate_current=0 break fi done if [[ "${live_certificate_current}" == "0" ]]; then restart_tls_services for port in ${verify_endpoints}; do verify_tls_endpoint "${port}" done echo "MailHub TLS files were current; the app was restarted to load and verify them." exit 0 fi fi echo "MailHub TLS certificate is already up to date." exit 0 fi if [[ -e "${target_cert}" ]]; then [[ -f "${target_cert}" ]] || fail "target certificate is not a regular file." cp -a -- "${target_cert}" "${work_dir}/backup-cert.pem" backup_cert_exists=1 fi if [[ -e "${target_key}" ]]; then [[ -f "${target_key}" ]] || fail "target private key is not a regular file." cp -a -- "${target_key}" "${work_dir}/backup-key.pem" backup_key_exists=1 fi rollback_required=1 mv -f -- "${staged_cert}" "${target_cert}" mv -f -- "${staged_key}" "${target_key}" validate_pair "${target_cert}" "${target_key}" synchronized if [[ "${restart_app}" == "1" ]]; then cd "${project_dir}" verify_container_access restart_tls_services for port in ${verify_endpoints}; do verify_tls_endpoint "${port}" done fi rollback_required=0 if [[ "${backup_cert_exists}" == "1" ]]; then set_file_metadata "${work_dir}/backup-cert.pem" 0644 mv -f -- "${work_dir}/backup-cert.pem" "${target_cert}.previous" fi if [[ "${backup_key_exists}" == "1" ]]; then set_file_metadata "${work_dir}/backup-key.pem" 0640 mv -f -- "${work_dir}/backup-key.pem" "${target_key}.previous" fi echo "MailHub TLS certificate synchronized for ${mail_hostname}." if [[ "${restart_app}" == "1" ]]; then echo "MailHub app and Dovecot restarted and verified on TLS endpoints: ${verify_endpoints}." fi