Explorar o código

ops: sync managed TLS certificates

AI-Co-Authored-By: Codex
chendeben hai 1 mes
pai
achega
31a1846a98

+ 3 - 0
.env.example

@@ -15,6 +15,9 @@ SUBMISSION_ALT_PORT=2525
 SUBMISSION_ALLOW_INSECURE_AUTH=false
 SUBMISSION_TLS_CERT=/certs/mailhub.example.com.crt
 SUBMISSION_TLS_KEY=/certs/mailhub.example.com.key
+# Optional host directory containing fullchain.pem and privkey.pem. Deploy and scheduled jobs sync it into ./certs.
+# The sync detects the running app container group, writes cert=0644/key=0640, and verifies container ports 465 and 993.
+MAILHUB_CERT_SOURCE_DIR=
 SUBMISSION_USERNAME=change-this-smtp-user
 SUBMISSION_PASSWORD=change-this-smtp-password
 

+ 12 - 2
README.md

@@ -127,7 +127,7 @@ curl -H "Authorization: Bearer <INBOUND_API_TOKEN>" \
 
 1. 将仓库部署到服务器目录,例如 `/opt/mailhub`。
 2. 基于 `.env.example` 创建 `.env`,填写真实域名、IP、证书路径和强随机密钥。
-3. 将 TLS 证书放在本地 `certs/` 目录,确保私钥不会进入 Git。
+3. 将 TLS 证书放在本地 `certs/` 目录,确保私钥不会进入 Git。如证书由宝塔等主机端工具续期,可在 `.env` 中设置 `MAILHUB_CERT_SOURCE_DIR`,该目录需包含 `fullchain.pem` 和 `privkey.pem`。
 4. 运行 `docker compose up -d --build`。
 5. 使用反向代理把 HTTPS 流量转发到 `127.0.0.1:${APP_PORT}`。
 6. 在云防火墙和系统防火墙中放行需要的 SMTP 端口。
@@ -141,7 +141,17 @@ MAILHUB_DEPLOY_BRANCH=main \
 npm run deploy:remote
 ```
 
-脚本会要求本地 HEAD 已推送到对应远端分支,然后在目标目录执行 `git pull --ff-only` 和 `docker compose up -d --build`。如果目标工作区存在未提交变更,脚本会停止;确认可暂存远端工作区时,可显式设置 `MAILHUB_DEPLOY_STASH_REMOTE=1`。
+脚本会要求本地 HEAD 已推送到对应远端分支,然后在目标目录执行 `git pull --ff-only` 和 `docker compose up -d --build`,并等待 `app`、`postfix` 都进入健康状态。如果目标工作区存在未提交变更,脚本会停止;确认可暂存远端工作区时,可显式设置 `MAILHUB_DEPLOY_STASH_REMOTE=1`。
+
+配置 `MAILHUB_CERT_SOURCE_DIR` 后,发布脚本会先把主机证书复制为受控快照,再校验有效期、主机名和公私钥匹配。同步过程带并发锁和失败回滚,目标证书固定为 `0644`,私钥固定为 `0640` 并授权给实际运行中的 app 容器组。证书变化后会重启 app,并通过容器端口 `465`、`993` 的 SNI、证书链、主机名和 SHA-256 指纹确认服务已加载新证书。
+
+同一脚本可由宝塔计划任务定期执行;宝塔仍负责申请和续期证书,MailHub 只读取续期结果:
+
+```bash
+cd "/opt/mailhub" && MAILHUB_CERT_RESTART=1 ./scripts/sync-tls-certificate.sh
+```
+
+默认验证容器端口 `465 993`。若部署明确关闭了其中一个 TLS 服务,可通过 `MAILHUB_CERT_VERIFY_ENDPOINTS` 调整;离线同步且无法检测 app 容器组时,必须显式设置 `MAILHUB_CERT_READER_GID`。
 
 ## 测试
 

+ 49 - 0
scripts/deploy-remote.sh

@@ -39,6 +39,39 @@ stash_remote="$4"
 
 cd "${remote_dir}"
 
+wait_for_compose_health() {
+  local timeout="${MAILHUB_DEPLOY_HEALTH_TIMEOUT:-180}"
+  local deadline=$((SECONDS + timeout))
+  local service container_id snapshot state health all_ready
+
+  while (( SECONDS < deadline )); do
+    all_ready=1
+    for service in "$@"; do
+      container_id="$(docker compose ps --all --quiet "${service}" 2>/dev/null | tail -n 1 || true)"
+      if [[ -z "${container_id}" ]]; then
+        all_ready=0
+        continue
+      fi
+      snapshot="$(docker inspect \
+        --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' \
+        "${container_id}" 2>/dev/null || true)"
+      state="${snapshot%% *}"
+      health="${snapshot#* }"
+      if [[ "${state}" == "exited" || "${state}" == "dead" ]]; then
+        docker compose logs --tail=100 "${service}" >&2 || true
+        return 1
+      fi
+      [[ "${state}" == "running" && "${health}" == "healthy" ]] || all_ready=0
+    done
+    [[ "${all_ready}" == "1" ]] && return 0
+    sleep 2
+  done
+
+  docker compose ps >&2 || true
+  docker compose logs --tail=100 "$@" >&2 || true
+  return 1
+}
+
 if ! git remote get-url origin >/dev/null 2>&1; then
   git remote add origin "${git_url}"
 fi
@@ -53,9 +86,25 @@ if [[ -n "$(git status --porcelain)" ]]; then
   fi
 fi
 
+previous_revision="$(git rev-parse HEAD)"
+on_deploy_exit() {
+  local status=$?
+  trap - EXIT
+  if [[ "${status}" != "0" ]]; then
+    echo "Deployment failed. Previous revision was ${previous_revision}; inspect the running containers before rollback." >&2
+    docker compose ps >&2 || true
+  fi
+  exit "${status}"
+}
+trap on_deploy_exit EXIT
+
 git fetch origin "${branch}"
 git checkout "${branch}"
 git pull --ff-only origin "${branch}"
 docker compose up -d --build
+wait_for_compose_health app postfix
+MAILHUB_CERT_RESTART=1 ./scripts/sync-tls-certificate.sh
+wait_for_compose_health app postfix
 docker compose ps
+trap - EXIT
 REMOTE

+ 358 - 0
scripts/sync-tls-certificate.sh

@@ -0,0 +1,358 @@
+#!/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
+}
+
+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."
+}
+
+tls_endpoint_matches() {
+  local port="$1"
+  local mapping mapped_host mapped_port connect_host connection output presented
+  local expected_fingerprint actual_fingerprint
+
+  [[ "${port}" =~ ^[0-9]+$ ]] || return 1
+  mapping="$(docker compose port app "${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 -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 -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 >/dev/null 2>&1
+        wait_for_app_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
+      docker compose restart app
+      wait_for_app_health || fail "MailHub app did not become healthy after the certificate restart."
+      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
+  docker compose restart app
+  wait_for_app_health || fail "MailHub app did not become healthy after the certificate restart."
+  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 restarted and verified on TLS endpoints: ${verify_endpoints}."
+fi

+ 0 - 1
test/admin-responsive-style.test.js

@@ -16,4 +16,3 @@ test('admin route focus and tablet header states remain visible and bounded', ()
   assert.match(styles, /@media\s*\(max-width:\s*900px\)[\s\S]*?\.environment-status__label\s*\{\s*display:\s*none;/);
   assert.match(styles, /\.user-button__name\s*\{[^}]*text-overflow:\s*ellipsis[^}]*white-space:\s*nowrap/s);
 });
-

+ 382 - 0
test/cert-sync-script.test.js

@@ -0,0 +1,382 @@
+import assert from 'node:assert/strict';
+import {
+  chmodSync,
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  readdirSync,
+  rmSync,
+  statSync,
+  writeFileSync
+} from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { spawnSync } from 'node:child_process';
+import { test } from 'node:test';
+
+const scriptPath = fileURLToPath(new URL('../scripts/sync-tls-certificate.sh', import.meta.url));
+const opensslLookup = process.platform === 'win32'
+  ? { status: 1, stdout: '' }
+  : spawnSync('sh', ['-c', 'command -v openssl'], { encoding: 'utf8' });
+const opensslPath = opensslLookup.status === 0 ? opensslLookup.stdout.trim() : '';
+const canRun = process.platform !== 'win32' && Boolean(opensslPath);
+
+test('atomically synchronizes a valid wildcard certificate and preserves safe permissions', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  generateCertificate(fixture.sourceDir, 'example.test');
+
+  const first = runSync(fixture);
+  assert.equal(first.status, 0, first.stderr);
+  assert.match(first.stdout, /MailHub TLS certificate synchronized for mail\.example\.test\./);
+  assert.deepEqual(readFileSync(fixture.targetCert), readFileSync(fixture.sourceCert));
+  assert.deepEqual(readFileSync(fixture.targetKey), readFileSync(fixture.sourceKey));
+  assert.equal(fileMode(fixture.targetCert), 0o644);
+  assert.equal(fileMode(fixture.targetKey), 0o640);
+  assert.deepEqual(stagingFiles(fixture.certsDir), []);
+
+  const previousCert = readFileSync(fixture.targetCert);
+  const previousKey = readFileSync(fixture.targetKey);
+  chmodSync(fixture.targetCert, 0o640);
+  chmodSync(fixture.targetKey, 0o600);
+  generateCertificate(fixture.sourceDir, 'example.test');
+
+  const replacement = runSync(fixture);
+  assert.equal(replacement.status, 0, replacement.stderr);
+  assert.deepEqual(readFileSync(`${fixture.targetCert}.previous`), previousCert);
+  assert.deepEqual(readFileSync(`${fixture.targetKey}.previous`), previousKey);
+  assert.deepEqual(readFileSync(fixture.targetCert), readFileSync(fixture.sourceCert));
+  assert.deepEqual(readFileSync(fixture.targetKey), readFileSync(fixture.sourceKey));
+  assert.equal(fileMode(fixture.targetCert), 0o644);
+  assert.equal(fileMode(fixture.targetKey), 0o640);
+  assert.deepEqual(stagingFiles(fixture.certsDir), []);
+});
+
+test('is idempotent when the synchronized certificate is already current', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  generateCertificate(fixture.sourceDir, 'example.test');
+
+  const first = runSync(fixture);
+  assert.equal(first.status, 0, first.stderr);
+  const certBefore = statSnapshot(fixture.targetCert);
+  const keyBefore = statSnapshot(fixture.targetKey);
+
+  const second = runSync(fixture);
+  assert.equal(second.status, 0, second.stderr);
+  assert.match(second.stdout, /MailHub TLS certificate is already up to date\./);
+  assert.deepEqual(statSnapshot(fixture.targetCert), certBefore);
+  assert.deepEqual(statSnapshot(fixture.targetKey), keyBefore);
+  assert.equal(readdirSync(fixture.certsDir).some((name) => name.endsWith('.previous')), false);
+  assert.deepEqual(stagingFiles(fixture.certsDir), []);
+});
+
+test('rejects a certificate that does not cover MAIL_HOSTNAME without touching the targets', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  generateCertificate(fixture.sourceDir, 'other.test');
+  mkdirSync(fixture.certsDir, { recursive: true });
+  writeFileSync(fixture.targetCert, 'existing certificate\n');
+  writeFileSync(fixture.targetKey, 'existing private key\n');
+  chmodSync(fixture.targetCert, 0o640);
+  chmodSync(fixture.targetKey, 0o600);
+  const certBefore = readFileSync(fixture.targetCert);
+  const keyBefore = readFileSync(fixture.targetKey);
+
+  const result = runSync(fixture);
+  assert.notEqual(result.status, 0);
+  assert.match(result.stderr, /source certificate does not cover mail\.example\.test\./);
+  assert.deepEqual(readFileSync(fixture.targetCert), certBefore);
+  assert.deepEqual(readFileSync(fixture.targetKey), keyBefore);
+  assert.equal(fileMode(fixture.targetCert), 0o640);
+  assert.equal(fileMode(fixture.targetKey), 0o600);
+  assert.equal(readdirSync(fixture.certsDir).some((name) => name.endsWith('.previous')), false);
+  assert.deepEqual(stagingFiles(fixture.certsDir), []);
+});
+
+test('rejects certificate and private key paths that resolve to the same target', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  generateCertificate(fixture.sourceDir, 'example.test');
+  mkdirSync(fixture.certsDir, { recursive: true });
+  writeEnvironment(fixture, {
+    SUBMISSION_TLS_KEY: '/certs/fullchain.pem'
+  });
+
+  const result = runSync(fixture);
+  assert.notEqual(result.status, 0);
+  assert.equal(readdirSync(fixture.certsDir).some((name) => name === 'fullchain.pem' || name === 'privkey.pem'), false);
+  assert.deepEqual(stagingFiles(fixture.certsDir), []);
+});
+
+test('rejects a mismatched certificate and private key without touching the targets', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  const unrelatedDir = path.join(fixture.root, 'unrelated');
+  mkdirSync(unrelatedDir, { recursive: true });
+  generateCertificate(fixture.sourceDir, 'example.test');
+  generateCertificate(unrelatedDir, 'example.test');
+  writeFileSync(fixture.sourceKey, readFileSync(path.join(unrelatedDir, 'privkey.pem')));
+  mkdirSync(fixture.certsDir, { recursive: true });
+  writeFileSync(fixture.targetCert, 'existing certificate\n');
+  writeFileSync(fixture.targetKey, 'existing private key\n');
+  const certBefore = readFileSync(fixture.targetCert);
+  const keyBefore = readFileSync(fixture.targetKey);
+
+  const result = runSync(fixture);
+  assert.notEqual(result.status, 0);
+  assert.match(result.stderr, /certificate and private key do not match/i);
+  assert.deepEqual(readFileSync(fixture.targetCert), certBefore);
+  assert.deepEqual(readFileSync(fixture.targetKey), keyBefore);
+  assert.equal(readdirSync(fixture.certsDir).some((name) => name.endsWith('.previous')), false);
+  assert.deepEqual(stagingFiles(fixture.certsDir), []);
+});
+
+test('tightens an overly permissive existing private key to mode 0640', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  generateCertificate(fixture.sourceDir, 'example.test');
+  const first = runSync(fixture);
+  assert.equal(first.status, 0, first.stderr);
+
+  chmodSync(fixture.targetKey, 0o666);
+  generateCertificate(fixture.sourceDir, 'example.test');
+  const replacement = runSync(fixture);
+
+  assert.equal(replacement.status, 0, replacement.stderr);
+  assert.equal(fileMode(fixture.targetKey), 0o640);
+  assert.deepEqual(readFileSync(fixture.targetKey), readFileSync(fixture.sourceKey));
+});
+
+test('validates and installs a staged source snapshot when managed files change mid-run', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  const replacementDir = path.join(fixture.root, 'replacement');
+  mkdirSync(replacementDir, { recursive: true });
+  generateCertificate(fixture.sourceDir, 'example.test');
+  generateCertificate(replacementDir, 'example.test');
+  const expectedCert = readFileSync(fixture.sourceCert);
+  const expectedKey = readFileSync(fixture.sourceKey);
+  const marker = path.join(fixture.root, 'source-mutated');
+  const mutationPath = createOpenSslMutationPath(fixture.root, fixture.path);
+
+  const result = runSync(fixture, {
+    env: {
+      PATH: mutationPath,
+      MAILHUB_TEST_REAL_OPENSSL: opensslPath,
+      MAILHUB_TEST_MUTATION_MARKER: marker,
+      MAILHUB_TEST_SOURCE_CERT: fixture.sourceCert,
+      MAILHUB_TEST_SOURCE_KEY: fixture.sourceKey,
+      MAILHUB_TEST_REPLACEMENT_CERT: path.join(replacementDir, 'fullchain.pem'),
+      MAILHUB_TEST_REPLACEMENT_KEY: path.join(replacementDir, 'privkey.pem')
+    }
+  });
+
+  assert.equal(result.status, 0, result.stderr);
+  assert.equal(readFileSync(fixture.targetCert).equals(expectedCert), true, 'installed certificate must use the staged snapshot');
+  assert.equal(readFileSync(fixture.targetKey).equals(expectedKey), true, 'installed private key must use the staged snapshot');
+  assert.notDeepEqual(readFileSync(fixture.sourceCert), expectedCert);
+  assert.notDeepEqual(readFileSync(fixture.sourceKey), expectedKey);
+  assert.deepEqual(stagingFiles(fixture.certsDir), []);
+});
+
+test('rolls back both certificate files when post-restart health validation fails', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  generateCertificate(fixture.sourceDir, 'example.test');
+  const first = runSync(fixture);
+  assert.equal(first.status, 0, first.stderr);
+  const certBefore = readFileSync(fixture.targetCert);
+  const keyBefore = readFileSync(fixture.targetKey);
+  const certModeBefore = fileMode(fixture.targetCert);
+  const keyModeBefore = fileMode(fixture.targetKey);
+
+  generateCertificate(fixture.sourceDir, 'example.test');
+  const failurePath = createFailedHealthPath(fixture.root, fixture.path);
+  const result = runSync(fixture, {
+    restart: '1',
+    env: { PATH: failurePath }
+  });
+
+  assert.notEqual(result.status, 0);
+  assert.match(result.stderr, /did not become healthy/i);
+  assert.equal(readFileSync(fixture.targetCert).equals(certBefore), true, 'certificate must roll back after failed health validation');
+  assert.equal(readFileSync(fixture.targetKey).equals(keyBefore), true, 'private key must roll back after failed health validation');
+  assert.equal(fileMode(fixture.targetCert), certModeBefore);
+  assert.equal(fileMode(fixture.targetKey), keyModeBefore);
+  assert.deepEqual(stagingFiles(fixture.certsDir), []);
+});
+
+function createFixture(t) {
+  const root = mkdtempSync(path.join(os.tmpdir(), 'mailhub-cert-sync-'));
+  const projectDir = path.join(root, 'project');
+  const sourceDir = path.join(root, 'source');
+  const certsDir = path.join(projectDir, 'certs');
+  const envFile = path.join(projectDir, '.env');
+  mkdirSync(projectDir, { recursive: true });
+  mkdirSync(sourceDir, { recursive: true });
+  writeFileSync(envFile, [
+    'MAIL_HOSTNAME=mail.example.test',
+    'SUBMISSION_TLS_CERT=/certs/fullchain.pem',
+    'SUBMISSION_TLS_KEY=/certs/privkey.pem',
+    ''
+  ].join('\n'));
+  t.after(() => rmSync(root, { recursive: true, force: true }));
+  return {
+    root,
+    projectDir,
+    sourceDir,
+    certsDir,
+    envFile,
+    sourceCert: path.join(sourceDir, 'fullchain.pem'),
+    sourceKey: path.join(sourceDir, 'privkey.pem'),
+    targetCert: path.join(certsDir, 'fullchain.pem'),
+    targetKey: path.join(certsDir, 'privkey.pem'),
+    path: createCompatibilityPath(root)
+  };
+}
+
+function writeEnvironment(fixture, overrides = {}) {
+  const values = {
+    MAIL_HOSTNAME: 'mail.example.test',
+    SUBMISSION_TLS_CERT: '/certs/fullchain.pem',
+    SUBMISSION_TLS_KEY: '/certs/privkey.pem',
+    ...overrides
+  };
+  writeFileSync(fixture.envFile, [
+    `MAIL_HOSTNAME=${values.MAIL_HOSTNAME}`,
+    `SUBMISSION_TLS_CERT=${values.SUBMISSION_TLS_CERT}`,
+    `SUBMISSION_TLS_KEY=${values.SUBMISSION_TLS_KEY}`,
+    ''
+  ].join('\n'));
+}
+
+function generateCertificate(sourceDir, domain) {
+  const result = spawnSync('openssl', [
+    'req',
+    '-x509',
+    '-newkey', 'rsa:2048',
+    '-nodes',
+    '-sha256',
+    '-days', '30',
+    '-subj', `/CN=*.${domain}`,
+    '-addext', `subjectAltName=DNS:*.${domain},DNS:${domain}`,
+    '-keyout', path.join(sourceDir, 'privkey.pem'),
+    '-out', path.join(sourceDir, 'fullchain.pem')
+  ], { encoding: 'utf8' });
+  assert.equal(result.status, 0, result.stderr);
+}
+
+function runSync(fixture, { restart = '0', env = {} } = {}) {
+  const rootOwnershipEnv = typeof process.getuid === 'function' && process.getuid() === 0
+    ? { MAILHUB_CERT_READER_GID: String(process.getgid()) }
+    : {};
+  return spawnSync('bash', [scriptPath], {
+    cwd: fixture.projectDir,
+    encoding: 'utf8',
+    env: {
+      ...process.env,
+      PATH: fixture.path,
+      MAILHUB_CERT_PROJECT_DIR: fixture.projectDir,
+      MAILHUB_CERT_ENV_FILE: fixture.envFile,
+      MAILHUB_CERT_SOURCE_DIR: fixture.sourceDir,
+      MAILHUB_CERT_RESTART: restart,
+      ...rootOwnershipEnv,
+      ...env
+    }
+  });
+}
+
+function statSnapshot(file) {
+  const stat = statSync(file);
+  return {
+    ino: stat.ino,
+    mode: stat.mode & 0o777,
+    size: stat.size,
+    mtimeMs: stat.mtimeMs
+  };
+}
+
+function fileMode(file) {
+  return statSync(file).mode & 0o777;
+}
+
+function stagingFiles(directory) {
+  return readdirSync(directory).filter((name) => name.includes('.tmp.') || name.startsWith('.cert-sync.'));
+}
+
+function createCompatibilityPath(root) {
+  if (process.platform !== 'darwin') return process.env.PATH || '';
+  const binDir = path.join(root, 'bin');
+  mkdirSync(binDir, { recursive: true });
+  writeExecutable(path.join(binDir, 'cp'), stripDoubleDashWrapper('/bin/cp'));
+  writeExecutable(path.join(binDir, 'mv'), stripDoubleDashWrapper('/bin/mv'));
+  writeExecutable(path.join(binDir, 'rm'), stripDoubleDashWrapper('/bin/rm'));
+  writeExecutable(path.join(binDir, 'chmod'), `#!/usr/bin/env bash
+set -euo pipefail
+if [[ "\${1:-}" == --reference=* ]]; then
+  reference="\${1#*=}"
+  shift
+  mode="$(/usr/bin/stat -f '%Lp' "\${reference}")"
+  exec /bin/chmod "\${mode}" "$@"
+fi
+exec /bin/chmod "$@"
+`);
+  writeExecutable(path.join(binDir, 'chown'), `#!/usr/bin/env bash
+set -euo pipefail
+if [[ "\${1:-}" == --reference=* ]]; then
+  reference="\${1#*=}"
+  shift
+  owner="$(/usr/bin/stat -f '%u:%g' "\${reference}")"
+  exec /usr/sbin/chown "\${owner}" "$@"
+fi
+exec /usr/sbin/chown "$@"
+`);
+  writeExecutable(path.join(binDir, 'sha256sum'), `#!/usr/bin/env bash
+exec /usr/bin/shasum -a 256 "$@"
+`);
+  return `${binDir}${path.delimiter}${process.env.PATH || ''}`;
+}
+
+function createOpenSslMutationPath(root, basePath) {
+  const binDir = path.join(root, 'mutating-openssl-bin');
+  mkdirSync(binDir, { recursive: true });
+  writeExecutable(path.join(binDir, 'openssl'), `#!/usr/bin/env bash
+set -euo pipefail
+if [[ ! -e "\${MAILHUB_TEST_MUTATION_MARKER}" ]]; then
+  : > "\${MAILHUB_TEST_MUTATION_MARKER}"
+  cp "\${MAILHUB_TEST_REPLACEMENT_CERT}" "\${MAILHUB_TEST_SOURCE_CERT}"
+  cp "\${MAILHUB_TEST_REPLACEMENT_KEY}" "\${MAILHUB_TEST_SOURCE_KEY}"
+fi
+exec "\${MAILHUB_TEST_REAL_OPENSSL}" "$@"
+`);
+  return `${binDir}${path.delimiter}${basePath}`;
+}
+
+function createFailedHealthPath(root, basePath) {
+  const binDir = path.join(root, 'failed-health-bin');
+  mkdirSync(binDir, { recursive: true });
+writeExecutable(path.join(binDir, 'docker'), `#!/usr/bin/env bash
+set -euo pipefail
+if [[ "$*" == *"tls.createSecureContext"* ]]; then
+  exit 0
+fi
+if [[ "\${1:-}" == "compose" && "\${2:-}" == "restart" ]]; then
+  exit 0
+fi
+exit 1
+`);
+  writeExecutable(path.join(binDir, 'sleep'), `#!/usr/bin/env bash
+exit 0
+`);
+  return `${binDir}${path.delimiter}${basePath}`;
+}
+
+function stripDoubleDashWrapper(command) {
+  return `#!/usr/bin/env bash
+set -euo pipefail
+args=()
+for argument in "$@"; do
+  [[ "\${argument}" == "--" ]] || args+=("\${argument}")
+done
+exec ${command} "\${args[@]}"
+`;
+}
+
+function writeExecutable(file, content) {
+  writeFileSync(file, content);
+  chmodSync(file, 0o755);
+}

+ 140 - 0
test/deploy-remote-script.test.js

@@ -0,0 +1,140 @@
+import assert from 'node:assert/strict';
+import {
+  chmodSync,
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  rmSync,
+  writeFileSync
+} from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+const scriptPath = fileURLToPath(new URL('../scripts/deploy-remote.sh', import.meta.url));
+const canRun = process.platform !== 'win32';
+
+test('waits for app and postfix health before and after certificate synchronization', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  const result = runDeploy(fixture);
+
+  assert.equal(result.status, 0, result.stderr);
+  const events = readEvents(fixture.logFile);
+  assert.deepEqual(
+    events.filter((event) => event.startsWith('health:') || event.startsWith('sync:')),
+    [
+      'health:app-container',
+      'health:postfix-container',
+      'sync:1',
+      'health:app-container',
+      'health:postfix-container'
+    ]
+  );
+});
+
+test('reports the previous revision when deployment fails', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  const result = runDeploy(fixture, { syncStatus: '19' });
+
+  assert.equal(result.status, 19);
+  assert.match(
+    result.stderr,
+    /Deployment failed\. Previous revision was previous-revision; inspect the running containers before rollback\./
+  );
+  const events = readEvents(fixture.logFile);
+  assert.equal(events.filter((event) => event === 'sync:1').length, 1);
+  assert.equal(events.filter((event) => event === 'health:app-container').length, 1);
+  assert.equal(events.filter((event) => event === 'health:postfix-container').length, 1);
+});
+
+function createFixture(t) {
+  const root = mkdtempSync(path.join(os.tmpdir(), 'mailhub-deploy-script-'));
+  const fakeBin = path.join(root, 'bin');
+  const remoteDir = path.join(root, 'remote');
+  const remoteScriptsDir = path.join(remoteDir, 'scripts');
+  const logFile = path.join(root, 'events.log');
+  mkdirSync(fakeBin, { recursive: true });
+  mkdirSync(remoteScriptsDir, { recursive: true });
+  writeFileSync(logFile, '');
+
+  writeExecutable(path.join(fakeBin, 'git'), `#!/bin/sh
+if [ "$1" = "remote" ] && [ "$2" = "get-url" ]; then
+  printf '%s\\n' 'ssh://git.example.test/mailhub.git'
+elif [ "$1" = "status" ]; then
+  :
+elif [ "$1" = "rev-parse" ] && [ "$2" = "HEAD" ]; then
+  if [ "$PWD" = "$MAILHUB_DEPLOY_TEST_REMOTE_DIR" ]; then
+    printf '%s\\n' 'previous-revision'
+  else
+    printf '%s\\n' 'pushed-revision'
+  fi
+elif [ "$1" = "rev-parse" ]; then
+  printf '%s\\n' 'pushed-revision'
+fi
+`);
+
+  writeExecutable(path.join(fakeBin, 'ssh'), `#!/bin/sh
+while [ "$#" -gt 0 ]; do
+  case "$1" in
+    -o) shift 2 ;;
+    *) break ;;
+  esac
+done
+[ "$#" -gt 0 ] && shift
+[ "$#" -gt 0 ] && shift
+[ "\${1:-}" = "--" ] && shift
+exec bash -s -- "$@"
+`);
+
+  writeExecutable(path.join(fakeBin, 'docker'), `#!/bin/sh
+if [ "$1" = "compose" ] && [ "$2" = "ps" ] && [ "\${3:-}" = "--all" ]; then
+  printf '%s-container\\n' "$5"
+  exit 0
+fi
+if [ "$1" = "inspect" ]; then
+  container=''
+  for argument in "$@"; do
+    container="$argument"
+  done
+  printf 'health:%s\\n' "$container" >> "$MAILHUB_DEPLOY_TEST_LOG"
+  printf '%s\\n' 'running healthy'
+fi
+`);
+
+  writeExecutable(path.join(remoteScriptsDir, 'sync-tls-certificate.sh'), `#!/bin/sh
+printf 'sync:%s\\n' "\${MAILHUB_CERT_RESTART:-}" >> "$MAILHUB_DEPLOY_TEST_LOG"
+exit "\${MAILHUB_DEPLOY_TEST_SYNC_STATUS:-0}"
+`);
+
+  t.after(() => rmSync(root, { recursive: true, force: true }));
+  return { root, fakeBin, remoteDir, logFile };
+}
+
+function runDeploy(fixture, { syncStatus = '0' } = {}) {
+  return spawnSync('bash', [scriptPath], {
+    cwd: fixture.root,
+    encoding: 'utf8',
+    env: {
+      ...process.env,
+      PATH: `${fixture.fakeBin}${path.delimiter}${process.env.PATH ?? ''}`,
+      MAILHUB_DEPLOY_REMOTE: 'deploy@example.test',
+      MAILHUB_DEPLOY_DIR: fixture.remoteDir,
+      MAILHUB_DEPLOY_BRANCH: 'main',
+      MAILHUB_DEPLOY_GIT_URL: 'ssh://git.example.test/mailhub.git',
+      MAILHUB_DEPLOY_TEST_LOG: fixture.logFile,
+      MAILHUB_DEPLOY_TEST_REMOTE_DIR: fixture.remoteDir,
+      MAILHUB_DEPLOY_TEST_SYNC_STATUS: syncStatus
+    }
+  });
+}
+
+function writeExecutable(filePath, contents) {
+  writeFileSync(filePath, contents);
+  chmodSync(filePath, 0o755);
+}
+
+function readEvents(logFile) {
+  return readFileSync(logFile, 'utf8').trim().split('\n').filter(Boolean);
+}