| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385 |
- #!/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 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
- mapping="$(docker compose port "${service}" "${container_port}" 2>/dev/null | head -n 1 || true)"
- [[ -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
|