chore(deploy): 将 DKIM 组件切换为 Rspamd

- 用 Rspamd 替换 OpenDKIM 及其相关镜像、编排和启动脚本。
- 调整 Postfix、单容器镜像与 Docker 配置,改为对接 Rspamd milter。
- 增加 DKIM 私钥同步脚本与 Rspamd 本地配置,支持从 SQLite 周期导出签名密钥。
- 更新 API 启动与测试逻辑,兼容新的默认管理员邮箱与邮箱加载方式。
This commit is contained in:
LanQin
2026-06-15 23:41:21 +08:00
parent 2a5c4fcc0c
commit f8ff2a814b
25 changed files with 234 additions and 137 deletions
+4 -1
View File
@@ -14,7 +14,7 @@ LANQIN_API_IMAGE=ghcr.io/lanqin996/lanqin-email-api:latest
LANQIN_WEB_IMAGE=ghcr.io/lanqin996/lanqin-email-web:latest
LANQIN_POSTFIX_IMAGE=ghcr.io/lanqin996/lanqin-email-postfix:latest
LANQIN_DOVECOT_IMAGE=ghcr.io/lanqin996/lanqin-email-dovecot:latest
LANQIN_OPENDKIM_IMAGE=ghcr.io/lanqin996/lanqin-email-opendkim:latest
LANQIN_RSPAMD_IMAGE=ghcr.io/lanqin996/lanqin-email-rspamd:latest
# =========================
# 对外访问地址
@@ -96,6 +96,9 @@ LANQIN_MAILDIR_ROOT=/var/mail/vhosts
# 扫描 Maildir 的间隔,单位:秒。
LANQIN_MAILDIR_SCAN_SECONDS=30
# Rspamd 从 SQLite 导出 DKIM 私钥到签名目录的间隔,单位:秒。
LANQIN_RSPAMD_DKIM_SYNC_SECONDS=60
# 无人收件开关。false:未注册邮箱默认拒收;true:未注册收件地址进入全部邮件。
LANQIN_CATCH_ALL_ENABLED=false
+6 -4
View File
@@ -34,7 +34,7 @@ lanqin-email
- Nginx
- Postfix
- Dovecot
- OpenDKIM
- Rspamd
常用命令:
@@ -60,7 +60,7 @@ ghcr.io/lanqin996/lanqin-email-api:latest
ghcr.io/lanqin996/lanqin-email-web:latest
ghcr.io/lanqin996/lanqin-email-postfix:latest
ghcr.io/lanqin996/lanqin-email-dovecot:latest
ghcr.io/lanqin996/lanqin-email-opendkim:latest
ghcr.io/lanqin996/lanqin-email-rspamd:latest
```
如果拉取时报:
@@ -92,7 +92,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
## 可选:多容器调试部署
如果需要分别查看 Postfix / Dovecot / OpenDKIM 日志,可以使用 stack 编排。
如果需要分别查看 Postfix / Dovecot / Rspamd 日志,可以使用 stack 编排。
拉取镜像版:
@@ -123,7 +123,8 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
- Postfix 读取 `/data/lanqin.db` 中的 `domains``mailboxes``aliases`
- Dovecot 读取同一个 SQLite 数据库进行邮箱认证,并使用 `/var/mail/vhosts` 作为 Maildir 根目录。
- OpenDKIM 启动时从 SQLite 导出域名 DKIM 私钥到容器内 `/etc/opendkim/keys`
- Rspamd 通过 milter 接入 Postfix,负责 DKIM 签名和垃圾邮件标记
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`
- Go API 是 Webmail 和管理后台唯一入口;浏览器不直接连接 SMTP/IMAP。
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
@@ -132,3 +133,4 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
- 建议在服务器或边缘网关配置 HTTPS。
- 云厂商通常默认封禁 25 端口,需要单独申请解封。
- SQLite 适合 V1 单机部署;多节点部署前迁移到 PostgreSQL,并把 Postfix/Dovecot maps 改为 PostgreSQL。
+4 -4
View File
@@ -27,7 +27,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
ca-certificates tzdata sqlite3 supervisor nginx \
postfix postfix-sqlite \
dovecot-core dovecot-imapd dovecot-lmtpd dovecot-sqlite ssl-cert \
opendkim opendkim-tools
rspamd
COPY --from=api-build /out/lanqin-api /usr/local/bin/lanqin-api
COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html
@@ -41,10 +41,10 @@ COPY deploy/postfix/master.cf /etc/postfix/master.cf
COPY deploy/postfix/sqlite-*.cf /etc/postfix/
COPY deploy/dovecot/dovecot.conf /etc/dovecot/dovecot.conf
COPY deploy/dovecot/dovecot-sql.conf.ext /etc/dovecot/dovecot-sql.conf.ext
COPY deploy/opendkim/opendkim.conf /etc/opendkim.conf
COPY deploy/opendkim/TrustedHosts /etc/opendkim/TrustedHosts
COPY deploy/rspamd/local.d/ /etc/rspamd/local.d/
COPY deploy/rspamd/sync-dkim.sh /usr/local/bin/lanqin-rspamd-sync-dkim
RUN chmod +x /entrypoint.sh && mkdir -p /run/nginx /var/mail/vhosts /data /etc/opendkim/keys
RUN chmod +x /entrypoint.sh /usr/local/bin/lanqin-rspamd-sync-dkim && mkdir -p /run/nginx /run/rspamd /var/mail/vhosts /data /var/lib/rspamd/dkim
EXPOSE 80 443 25 587 993
CMD ["/entrypoint.sh"]
+11 -19
View File
@@ -13,17 +13,23 @@ export LANQIN_DATA_DIR LANQIN_DB_PATH LANQIN_ADDR LANQIN_SMTP_HOST LANQIN_SMTP_P
addgroup --system --gid 5000 vmail 2>/dev/null || true
adduser --system --uid 5000 --gid 5000 --home /var/mail/vhosts --no-create-home vmail 2>/dev/null || true
mkdir -p /data /var/mail/vhosts /etc/opendkim/keys /var/spool/postfix /var/run/dovecot
mkdir -p /data /var/mail/vhosts /var/lib/rspamd/dkim /run/rspamd /var/spool/postfix /var/run/dovecot
chown -R 5000:5000 /var/mail/vhosts
if id _rspamd >/dev/null 2>&1; then
chown -R _rspamd:_rspamd /run/rspamd /var/lib/rspamd 2>/dev/null || true
elif id rspamd >/dev/null 2>&1; then
chown -R rspamd:rspamd /run/rspamd /var/lib/rspamd 2>/dev/null || true
fi
postconf -e "myhostname = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "virtual_transport = lmtp:inet:127.0.0.1:24"
postconf -e "smtpd_sasl_path = inet:127.0.0.1:12345"
postconf -e "smtpd_milters = inet:127.0.0.1:8891"
postconf -e "non_smtpd_milters = inet:127.0.0.1:8891"
postconf -e "milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}"
postconf -e "smtpd_milters = inet:127.0.0.1:11332"
postconf -e "non_smtpd_milters = inet:127.0.0.1:11332"
# OpenDKIM keys are generated after API seed/migrations create the SQLite DB.
# Rspamd DKIM keys are exported after API seed/migrations create the SQLite DB.
/usr/local/bin/lanqin-api >/tmp/lanqin-api-bootstrap.log 2>&1 &
bootstrap_pid=$!
for i in $(seq 1 60); do
@@ -39,21 +45,7 @@ done
kill "$bootstrap_pid" 2>/dev/null || true
wait "$bootstrap_pid" 2>/dev/null || true
: > /etc/opendkim/KeyTable
: > /etc/opendkim/SigningTable
if [ -f "$LANQIN_DB_PATH" ]; then
sqlite3 -separator '|' "$LANQIN_DB_PATH" "SELECT name, dkim_selector, dkim_private_key FROM domains WHERE status='active';" | while IFS='|' read -r domain selector private_key; do
[ -n "$domain" ] || continue
dir="/etc/opendkim/keys/$domain"
mkdir -p "$dir"
keyfile="$dir/$selector.private"
printf '%s' "$private_key" | base64 -d > "$keyfile"
chmod 600 "$keyfile"
echo "$selector._domainkey.$domain $domain:$selector:$keyfile" >> /etc/opendkim/KeyTable
echo "*@${domain} ${selector}._domainkey.${domain}" >> /etc/opendkim/SigningTable
done
fi
chown -R opendkim:opendkim /etc/opendkim
/usr/local/bin/lanqin-rspamd-sync-dkim --once || true
postfix check
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/lanqin.conf
+12 -2
View File
@@ -24,8 +24,8 @@ stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
priority=20
[program:opendkim]
command=/usr/sbin/opendkim -f -x /etc/opendkim.conf
[program:rspamd-dkim-sync]
command=/usr/local/bin/lanqin-rspamd-sync-dkim
autostart=true
autorestart=true
stdout_logfile=/dev/fd/1
@@ -34,6 +34,16 @@ stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
priority=30
[program:rspamd]
command=/usr/bin/rspamd -f
autostart=true
autorestart=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
priority=35
[program:postfix]
command=/usr/sbin/postfix start-fg
autostart=true
+2 -2
View File
@@ -17,6 +17,6 @@ services:
build:
context: ./dovecot
opendkim:
rspamd:
build:
context: ./opendkim
context: ./rspamd
+5 -5
View File
@@ -37,7 +37,7 @@ services:
- "587:587"
depends_on:
- dovecot
- opendkim
- rspamd
restart: unless-stopped
dovecot:
@@ -50,15 +50,15 @@ services:
- "993:993"
restart: unless-stopped
opendkim:
image: ${LANQIN_OPENDKIM_IMAGE:-ghcr.io/lanqin996/lanqin-email-opendkim:latest}
rspamd:
image: ${LANQIN_RSPAMD_IMAGE:-ghcr.io/lanqin996/lanqin-email-rspamd:latest}
env_file: .env
volumes:
- lanqin-data:/data:ro
- dkim-keys:/etc/opendkim/keys
- rspamd-dkim:/var/lib/rspamd/dkim
restart: unless-stopped
volumes:
lanqin-data:
maildata:
dkim-keys:
rspamd-dkim:
+2 -2
View File
@@ -11,10 +11,10 @@ services:
volumes:
- lanqin-data:/data
- maildata:/var/mail/vhosts
- dkim-keys:/etc/opendkim/keys
- rspamd-dkim:/var/lib/rspamd/dkim
restart: unless-stopped
volumes:
lanqin-data:
maildata:
dkim-keys:
rspamd-dkim:
-19
View File
@@ -1,19 +0,0 @@
#!/bin/sh
set -eu
mkdir -p /etc/opendkim/keys
: > /etc/opendkim/KeyTable
: > /etc/opendkim/SigningTable
if [ -f /data/lanqin.db ]; then
sqlite3 -separator '|' /data/lanqin.db "SELECT name, dkim_selector, dkim_private_key FROM domains WHERE status='active';" | while IFS='|' read -r domain selector private_key; do
[ -n "$domain" ] || continue
dir="/etc/opendkim/keys/$domain"
mkdir -p "$dir"
keyfile="$dir/$selector.private"
printf '%s' "$private_key" | base64 -d > "$keyfile"
chmod 600 "$keyfile"
echo "$selector._domainkey.$domain $domain:$selector:$keyfile" >> /etc/opendkim/KeyTable
echo "*@${domain} ${selector}._domainkey.${domain}" >> /etc/opendkim/SigningTable
done
fi
chown -R opendkim:opendkim /etc/opendkim
exec opendkim -f -x /etc/opendkim.conf
-12
View File
@@ -1,12 +0,0 @@
Syslog yes
UMask 002
Mode sv
Canonicalization relaxed/simple
SubDomains no
OversignHeaders From
Socket inet:8891@0.0.0.0
UserID opendkim:opendkim
KeyTable /etc/opendkim/KeyTable
SigningTable refile:/etc/opendkim/SigningTable
ExternalIgnoreList /etc/opendkim/TrustedHosts
InternalHosts /etc/opendkim/TrustedHosts
+3 -2
View File
@@ -3,7 +3,8 @@ set -eu
: "${LANQIN_PUBLIC_HOSTNAME:=mail.example.com}"
postconf -e "myhostname = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "smtpd_milters = inet:opendkim:8891"
postconf -e "non_smtpd_milters = inet:opendkim:8891"
postconf -e "milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}"
postconf -e "smtpd_milters = inet:rspamd:11332"
postconf -e "non_smtpd_milters = inet:rspamd:11332"
postfix check
exec postfix start-fg
+4 -3
View File
@@ -23,8 +23,9 @@ smtpd_sasl_auth_enable = yes
smtpd_tls_security_level = may
smtp_tls_security_level = may
# DKIM milter.
# Rspamd milter: DKIM signing + spam headers.
milter_protocol = 6
milter_default_action = accept
smtpd_milters = inet:opendkim:8891
non_smtpd_milters = inet:opendkim:8891
milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}
smtpd_milters = inet:rspamd:11332
non_smtpd_milters = inet:rspamd:11332
@@ -5,10 +5,10 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean && \
apt-get update && apt-get install -y --no-install-recommends opendkim opendkim-tools sqlite3 ca-certificates
COPY opendkim.conf /etc/opendkim.conf
COPY TrustedHosts /etc/opendkim/TrustedHosts
apt-get update && apt-get install -y --no-install-recommends rspamd sqlite3 ca-certificates
COPY local.d/ /etc/rspamd/local.d/
COPY sync-dkim.sh /usr/local/bin/lanqin-rspamd-sync-dkim
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8891
RUN chmod +x /entrypoint.sh /usr/local/bin/lanqin-rspamd-sync-dkim && mkdir -p /run/rspamd /var/lib/rspamd/dkim
EXPOSE 11332 11334
CMD ["/entrypoint.sh"]
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
mkdir -p /run/rspamd /var/lib/rspamd/dkim
if id _rspamd >/dev/null 2>&1; then
chown -R _rspamd:_rspamd /run/rspamd /var/lib/rspamd 2>/dev/null || true
elif id rspamd >/dev/null 2>&1; then
chown -R rspamd:rspamd /run/rspamd /var/lib/rspamd 2>/dev/null || true
fi
/usr/local/bin/lanqin-rspamd-sync-dkim --once || true
/usr/local/bin/lanqin-rspamd-sync-dkim &
exec rspamd -f
+4
View File
@@ -0,0 +1,4 @@
# V1 先把 Rspamd 作为 DKIM 签名和标记组件,不直接拒信,避免误杀。
reject = 999;
add_header = 6;
greylist = 999;
+19
View File
@@ -0,0 +1,19 @@
enabled = true;
# LanQin API 固定生成 selector=lanqin。路径支持 $domain / $selector 模板。
path = "/var/lib/rspamd/dkim/$domain.$selector.key";
selector = "lanqin";
try_fallback = true;
use_domain = "header";
use_esld = false;
use_redis = false;
# Go API / Postfix 在容器内提交的邮件需要签名。
sign_authenticated = true;
sign_local = true;
sign_networks = "/etc/rspamd/local.d/sign_networks.map";
allow_username_mismatch = true;
allow_hdrfrom_mismatch_local = true;
allow_hdrfrom_mismatch_sign_networks = true;
allow_hdrfrom_multiple = true;
@@ -1,5 +1,4 @@
127.0.0.1
localhost
127.0.0.0/8
10.0.0.0/8
172.16.0.0/12
192.168.0.0/16
+8
View File
@@ -0,0 +1,8 @@
bind_socket = "0.0.0.0:11332";
milter = yes;
timeout = 120s;
upstream "local" {
default = yes;
self_scan = yes;
}
+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
set -eu
: "${LANQIN_DB_PATH:=/data/lanqin.db}"
: "${LANQIN_RSPAMD_DKIM_DIR:=/var/lib/rspamd/dkim}"
: "${LANQIN_RSPAMD_DKIM_SYNC_SECONDS:=60}"
chown_dkim_dir() {
if id _rspamd >/dev/null 2>&1; then
chown -R _rspamd:_rspamd "$LANQIN_RSPAMD_DKIM_DIR" 2>/dev/null || true
elif id rspamd >/dev/null 2>&1; then
chown -R rspamd:rspamd "$LANQIN_RSPAMD_DKIM_DIR" 2>/dev/null || true
fi
}
sync_keys() {
mkdir -p "$LANQIN_RSPAMD_DKIM_DIR"
if [ ! -f "$LANQIN_DB_PATH" ]; then
chown_dkim_dir
return 0
fi
sqlite3 -separator '|' "$LANQIN_DB_PATH" "SELECT name, dkim_selector, dkim_private_key FROM domains WHERE status='active';" 2>/dev/null | while IFS='|' read -r domain selector private_key; do
[ -n "$domain" ] || continue
[ -n "$selector" ] || selector="lanqin"
keyfile="$LANQIN_RSPAMD_DKIM_DIR/${domain}.${selector}.key"
tmpfile="${keyfile}.tmp"
printf '%s' "$private_key" | base64 -d > "$tmpfile"
chmod 0640 "$tmpfile"
mv "$tmpfile" "$keyfile"
done
chown_dkim_dir
}
if [ "${1:-}" = "--once" ]; then
sync_keys
exit 0
fi
while true; do
sync_keys || true
sleep "$LANQIN_RSPAMD_DKIM_SYNC_SECONDS"
done