Browse Source

fix: harden Dovecot production deployment

AI-Co-Authored-By: Codex
chendeben 1 month ago
parent
commit
e520d6f4b3

+ 1 - 1
.env.example

@@ -41,7 +41,7 @@ DOVECOT_AUTH_PORT=3001
 DOVECOT_AUTH_SECRET_FILE=/run/secrets/dovecot_auth_secret
 # Relative path for host-side migration commands. Compose overrides this with /data/maildir.
 MAILDIR_ROOT=./data/maildir
-MAILDIR_SYNC_INTERVAL_MS=5000
+MAILDIR_SYNC_INTERVAL_MS=300000
 
 # Default outbound identity used in SPF, HELO, and Postfix myhostname.
 MAIL_HOSTNAME=smtp.mailhub.example.com

+ 2 - 2
README.md

@@ -48,7 +48,7 @@ docker compose logs -f app postfix dovecot
 - `SUBMISSION_HOST`、`SUBMISSION_PORTS`:SMTP Submission 对外连接信息。
 - `SUBMISSION_TLS_CERT`、`SUBMISSION_TLS_KEY`:TLS 证书路径。证书文件应放在本地 `certs/`,不要提交到 Git。
 - `MAIL_ACCESS_BACKEND`:Docker 部署使用 `dovecot`;仅本地协议回归时可使用 `legacy`。
-- `MAILDIR_ROOT`、`MAILDIR_SYNC_INTERVAL_MS`:Maildir 持久化目录及管理界面索引同步周期。
+- `MAILDIR_ROOT`、`MAILDIR_SYNC_INTERVAL_MS`:Maildir 持久化目录及管理界面索引同步周期;默认 `300000` 毫秒(5 分钟),可按邮件量调整
 - `DEFAULT_SPF_MECHANISMS`:需要保留的第三方 SPF include,例如事务邮件服务商。
 - `SEND_REQUIRES_VERIFIED`:是否要求域名 DNS 验证通过后才能发信。
 - `LIST_UNSUBSCRIBE_MAILTO`、`LIST_UNSUBSCRIBE_URL`:可选退订头配置,支持 `{eventId}`、`{recipient}`、`{sender}`、`{domain}`、`{userId}` 占位符。
@@ -147,7 +147,7 @@ curl -H "Authorization: Bearer <INBOUND_API_TOKEN>" \
 3. 将 TLS 证书放在本地 `certs/` 目录,确保私钥不会进入 Git。如证书由宝塔等主机端工具续期,可在 `.env` 中设置 `MAILHUB_CERT_SOURCE_DIR`,该目录需包含 `fullchain.pem` 和 `privkey.pem`。
 4. 以 root(或 Linux 宿主 UID 1000)运行 `npm run prepare:dovecot`,生成仅 app 与 Dovecot 可读的内部认证 secret;脚本会在不兼容的 Linux UID 下直接失败,避免启动后才发现 Maildir/secret 无权限。
 5. 运行 `docker compose build app postfix`,然后用 `docker compose stop app dovecot` 开始短维护窗口。
-6. 运行 `docker compose run --rm --no-deps app node scripts/migrate-sqlite-maildir.js`,把现有 SQLite 邮件可重入地写入 Maildir;迁移完成前不要让旧 IMAP 或 SMTP 入站继续写邮件。
+6. 运行 `docker compose run --rm --no-deps -T app node scripts/migrate-sqlite-maildir.js`,把现有 SQLite 邮件可重入地写入 Maildir;迁移完成前不要让旧 IMAP 或 SMTP 入站继续写邮件。
 7. 运行 `docker compose up -d` 恢复服务。
 8. 使用反向代理把 HTTPS 流量转发到 `127.0.0.1:${APP_PORT}`。
 9. 在云防火墙和系统防火墙中放行需要的 SMTP、IMAP 和 POP3 端口。

+ 2 - 0
docker-compose.yml

@@ -66,6 +66,7 @@ services:
       - dovecot_auth_secret
     networks:
       - dovecot_internal
+      - dovecot_public
     depends_on:
       app:
         condition: service_healthy
@@ -111,3 +112,4 @@ networks:
   mailhub:
   dovecot_internal:
     internal: true
+  dovecot_public:

+ 1 - 1
scripts/deploy-remote.sh

@@ -181,7 +181,7 @@ stopped_app_container="$(docker compose ps --all --quiet app 2>/dev/null | tail
 stopped_dovecot_container="$(docker compose ps --all --quiet dovecot 2>/dev/null | tail -n 1 || true)"
 mail_services_stopped_for_migration=1
 docker compose stop app dovecot
-docker compose run --rm --no-deps app node scripts/migrate-sqlite-maildir.js
+docker compose run --rm --no-deps -T app node scripts/migrate-sqlite-maildir.js
 maildir_cutover_committed=1
 mail_services_stopped_for_migration=0
 docker compose up -d

+ 10 - 2
scripts/sync-tls-certificate.sh

@@ -151,7 +151,7 @@ verify_container_access() {
 
 tls_endpoint_matches() {
   local port="$1" service="app" container_port="$1"
-  local mapping mapped_host mapped_port connect_host connection output presented
+  local mapping container_id mapped_host mapped_port connect_host connection output presented
   local expected_fingerprint actual_fingerprint
   local -a protocol_args=()
 
@@ -163,7 +163,15 @@ tls_endpoint_matches() {
     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)"
+  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

+ 2 - 2
src/maildir-sync.js

@@ -224,11 +224,11 @@ export async function reconcileAllMaildirs({ root, missingCounts = new Map() } =
 export function startMaildirReconciler({
   root,
   enabled = true,
-  intervalMs = 5_000,
+  intervalMs = 300_000,
   logger = console
 } = {}) {
   if (!enabled) return { stop() {} };
-  const delay = Math.max(1_000, Number(intervalMs) || 5_000);
+  const delay = Math.max(1_000, Number(intervalMs) || 300_000);
   const missingCounts = new Map();
   let stopped = false;
   let timer = null;

+ 1 - 1
src/server.js

@@ -198,7 +198,7 @@ const envConfig = {
   dovecotAuthPort: Number(process.env.DOVECOT_AUTH_PORT || 3001),
   dovecotAuthSecretFile: process.env.DOVECOT_AUTH_SECRET_FILE || '',
   maildirRoot: maildirRootFromEnvironment(process.env),
-  maildirSyncIntervalMs: Number(process.env.MAILDIR_SYNC_INTERVAL_MS || 5000),
+  maildirSyncIntervalMs: Number(process.env.MAILDIR_SYNC_INTERVAL_MS || 300000),
   inboundEnabled: String(process.env.INBOUND_ENABLED || 'true').toLowerCase() !== 'false',
   sessionSecret: process.env.SESSION_SECRET || fallbackSecret,
   trackingSecret: process.env.TRACKING_SECRET || process.env.SESSION_SECRET || fallbackSecret,

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

@@ -29,6 +29,29 @@ test('uses protocol-specific STARTTLS probes for explicit TLS upgrade ports', ()
   assert.match(scriptSource, /143\).*protocol_args=\(-starttls imap\)/);
 });
 
+test('falls back to docker port when compose cannot resolve the Dovecot mapping', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  generateCertificate(fixture.sourceDir, 'example.test');
+  const fallback = createComposePortFallbackPath(fixture.root, fixture.path);
+
+  const result = runSync(fixture, {
+    restart: '1',
+    env: {
+      PATH: fallback.path,
+      MAILHUB_CERT_VERIFY_ENDPOINTS: '993',
+      MAILHUB_TEST_DOCKER_LOG: fallback.logFile,
+      MAILHUB_TEST_PRESENTED_CERT: fixture.sourceCert,
+      MAILHUB_TEST_REAL_OPENSSL: opensslPath
+    }
+  });
+
+  assert.equal(result.status, 0, result.stderr);
+  const commands = readFileSync(fallback.logFile, 'utf8').trim().split('\n');
+  assert.ok(commands.includes('compose port dovecot 31993'), commands.join('\n'));
+  assert.ok(commands.includes('compose ps -q dovecot'), commands.join('\n'));
+  assert.ok(commands.includes('port dovecot-fallback 31993/tcp'), commands.join('\n'));
+});
+
 test('atomically synchronizes a valid wildcard certificate and preserves safe permissions', { skip: !canRun }, (t) => {
   const fixture = createFixture(t);
   generateCertificate(fixture.sourceDir, 'example.test');
@@ -375,6 +398,58 @@ exit 0
   return `${binDir}${path.delimiter}${basePath}`;
 }
 
+function createComposePortFallbackPath(root, basePath) {
+  const binDir = path.join(root, 'compose-port-fallback-bin');
+  const logFile = path.join(root, 'compose-port-fallback.log');
+  mkdirSync(binDir, { recursive: true });
+  writeFileSync(logFile, '');
+  writeExecutable(path.join(binDir, 'docker'), `#!/usr/bin/env bash
+set -euo pipefail
+printf '%s\\n' "$*" >> "\${MAILHUB_TEST_DOCKER_LOG}"
+if [[ "\${1:-}" == "compose" && "\${2:-}" == "exec" ]]; then
+  exit 0
+fi
+if [[ "\${1:-}" == "compose" && "\${2:-}" == "restart" ]]; then
+  exit 0
+fi
+if [[ "\${1:-}" == "compose" && "\${2:-}" == "port" ]]; then
+  printf '%s\\n' 'invalid IP:0' >&2
+  exit 1
+fi
+if [[ "\${1:-}" == "compose" && "\${2:-}" == "ps" && "\${3:-}" == "-q" ]]; then
+  printf '%s\\n' 'dovecot-fallback'
+  exit 0
+fi
+if [[ "\${1:-}" == "port" && "\${2:-}" == "dovecot-fallback" && "\${3:-}" == "31993/tcp" ]]; then
+  printf '%s\\n' '0.0.0.0:1993'
+  exit 0
+fi
+exit 1
+`);
+  writeExecutable(path.join(binDir, 'openssl'), `#!/usr/bin/env bash
+set -euo pipefail
+if [[ "\${1:-}" == "s_client" ]]; then
+  connection=""
+  previous=""
+  for argument in "$@"; do
+    if [[ "\${previous}" == "-connect" ]]; then
+      connection="\${argument}"
+      break
+    fi
+    previous="\${argument}"
+  done
+  [[ "\${connection}" == "127.0.0.1:1993" ]] || exit 71
+  cat "\${MAILHUB_TEST_PRESENTED_CERT}"
+  exit 0
+fi
+exec "\${MAILHUB_TEST_REAL_OPENSSL}" "$@"
+`);
+  return {
+    logFile,
+    path: `${binDir}${path.delimiter}${basePath}`
+  };
+}
+
 function stripDoubleDashWrapper(command) {
   return `#!/usr/bin/env bash
 set -euo pipefail

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

@@ -21,6 +21,13 @@ test('checks Maildir access with Dovecot mail worker uid instead of container ro
   assert.match(scriptSource, /compose exec -T --user 1000:1000 dovecot/);
 });
 
+test('runs the Maildir migration without consuming the SSH heredoc stdin', () => {
+  assert.match(
+    scriptSource,
+    /docker compose run --rm --no-deps -T app node scripts\/migrate-sqlite-maildir\.js/
+  );
+});
+
 test('waits for app and postfix health before and after certificate synchronization', { skip: !canRun }, (t) => {
   const fixture = createFixture(t);
   const result = runDeploy(fixture);

+ 19 - 0
test/dovecot-config.test.js

@@ -3,11 +3,25 @@ import { readFileSync } from 'node:fs';
 import { test } from 'node:test';
 
 const compose = readFileSync(new URL('../docker-compose.yml', import.meta.url), 'utf8');
+const appService = compose.match(/^  app:\n[\s\S]*?(?=^  dovecot:)/m)?.[0] || '';
+const dovecotService = compose.match(/^  dovecot:\n[\s\S]*?(?=^  postfix:)/m)?.[0] || '';
+const envExample = readFileSync(new URL('../.env.example', import.meta.url), 'utf8');
+const readme = readFileSync(new URL('../README.md', import.meta.url), 'utf8');
+const maildirSyncSource = readFileSync(new URL('../src/maildir-sync.js', import.meta.url), 'utf8');
+const serverSource = readFileSync(new URL('../src/server.js', import.meta.url), 'utf8');
 const authConfig = readFileSync(new URL('../docker/dovecot/auth.conf', import.meta.url), 'utf8');
 const mailConfig = readFileSync(new URL('../docker/dovecot/mailhub.conf', import.meta.url), 'utf8');
 const sslConfig = readFileSync(new URL('../docker/dovecot/ssl.conf', import.meta.url), 'utf8');
 const authLua = readFileSync(new URL('../docker/dovecot/auth.lua', import.meta.url), 'utf8');
 
+test('Maildir reconciliation defaults to a five-minute polling interval', () => {
+  assert.match(serverSource, /MAILDIR_SYNC_INTERVAL_MS \|\| 300000/);
+  assert.match(maildirSyncSource, /intervalMs = 300_000/);
+  assert.match(maildirSyncSource, /Number\(intervalMs\) \|\| 300_000/);
+  assert.match(envExample, /^MAILDIR_SYNC_INTERVAL_MS=300000$/m);
+  assert.match(readme, /MAILDIR_SYNC_INTERVAL_MS[^\n]*`300000` 毫秒(5 分钟)/);
+});
+
 test('Compose delegates public IMAP and POP3 ports to rootless Dovecot', () => {
   assert.match(compose, /image: dovecot\/dovecot:2\.4\.4/);
   for (const mapping of ['143:31143', '993:31993', '110:31110', '995:31995']) {
@@ -17,6 +31,11 @@ test('Compose delegates public IMAP and POP3 ports to rootless Dovecot', () => {
     assert.equal(compose.includes(oldMapping), false, `app still owns ${oldMapping}`);
   }
   assert.match(compose, /dovecot_internal:\n\s+internal: true/);
+  assert.match(dovecotService, /networks:\n\s+- dovecot_internal\n\s+- dovecot_public/);
+  assert.match(compose, /^  dovecot_public:\s*$/m);
+  assert.doesNotMatch(compose, /dovecot_public:\n\s+internal: true/);
+  assert.match(appService, /networks:\n\s+- mailhub\n\s+- dovecot_internal/);
+  assert.doesNotMatch(appService, /dovecot_public/);
   assert.match(compose, /file: \.\/data\/secrets\/dovecot_auth_secret/);
   assert.match(compose, /MAIL_ACCESS_BACKEND: dovecot/);
   assert.match(compose, /MAILDIR_ROOT: \/data\/maildir/);