From f8ff2a814b5015ea4a8473ce8ec1f0bacc6b2548 Mon Sep 17 00:00:00 2001 From: LanQin Date: Mon, 15 Jun 2026 23:41:21 +0800 Subject: [PATCH] =?UTF-8?q?chore(deploy):=20=E5=B0=86=20DKIM=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=88=87=E6=8D=A2=E4=B8=BA=20Rspamd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用 Rspamd 替换 OpenDKIM 及其相关镜像、编排和启动脚本。 - 调整 Postfix、单容器镜像与 Docker 配置,改为对接 Rspamd milter。 - 增加 DKIM 私钥同步脚本与 Rspamd 本地配置,支持从 SQLite 周期导出签名密钥。 - 更新 API 启动与测试逻辑,兼容新的默认管理员邮箱与邮箱加载方式。 --- .dockerignore | 2 +- .github/workflows/docker.yml | 8 +- README.md | 5 +- apps/api/internal/app/app.go | 42 ++++++---- apps/api/internal/app/app_test.go | 78 +++++++++++-------- apps/api/internal/app/mail_handlers.go | 7 +- deploy/.env.example | 5 +- deploy/README.md | 10 ++- deploy/all-in-one/Dockerfile | 8 +- deploy/all-in-one/entrypoint.sh | 30 +++---- deploy/all-in-one/supervisord.conf | 14 +++- deploy/docker-compose.stack.build.yml | 4 +- deploy/docker-compose.stack.yml | 10 +-- deploy/docker-compose.yml | 4 +- deploy/opendkim/entrypoint.sh | 19 ----- deploy/opendkim/opendkim.conf | 12 --- deploy/postfix/entrypoint.sh | 5 +- deploy/postfix/main.cf | 7 +- deploy/{opendkim => rspamd}/Dockerfile | 10 +-- deploy/rspamd/entrypoint.sh | 13 ++++ deploy/rspamd/local.d/actions.conf | 4 + deploy/rspamd/local.d/dkim_signing.conf | 19 +++++ .../local.d/sign_networks.map} | 3 +- deploy/rspamd/local.d/worker-proxy.inc | 8 ++ deploy/rspamd/sync-dkim.sh | 44 +++++++++++ 25 files changed, 234 insertions(+), 137 deletions(-) delete mode 100644 deploy/opendkim/entrypoint.sh delete mode 100644 deploy/opendkim/opendkim.conf rename deploy/{opendkim => rspamd}/Dockerfile (60%) create mode 100644 deploy/rspamd/entrypoint.sh create mode 100644 deploy/rspamd/local.d/actions.conf create mode 100644 deploy/rspamd/local.d/dkim_signing.conf rename deploy/{opendkim/TrustedHosts => rspamd/local.d/sign_networks.map} (66%) create mode 100644 deploy/rspamd/local.d/worker-proxy.inc create mode 100644 deploy/rspamd/sync-dkim.sh diff --git a/.dockerignore b/.dockerignore index 2e05e77..7bdd550 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,7 +22,7 @@ coverage tmp data maildata -dkim-keys +rspamd-dkim logs *.log diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ea6f1c8..a6641cf 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -94,10 +94,10 @@ jobs: suffix: -dovecot context: ./deploy/dovecot file: ./deploy/dovecot/Dockerfile - - name: opendkim - suffix: -opendkim - context: ./deploy/opendkim - file: ./deploy/opendkim/Dockerfile + - name: rspamd + suffix: -rspamd + context: ./deploy/rspamd + file: ./deploy/rspamd/Dockerfile steps: - name: Checkout diff --git a/README.md b/README.md index b2eff46..2c22954 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # LanQin Email -LanQin Email 是一个自建邮箱 Webmail MVP:React/Vite + shadcn 风格组件前端,Go + SQLite 后端,默认使用单容器集成 Postfix、Dovecot、OpenDKIM。 +LanQin Email 是一个自建邮箱 Webmail MVP:React/Vite + shadcn 风格组件前端,Go + SQLite 后端,默认使用单容器集成 Postfix、Dovecot、Rspamd。 ## 快速开发 @@ -54,7 +54,7 @@ docker compose pull docker compose up -d ``` -这个容器内部集成:API、Web、Nginx、Postfix、Dovecot、OpenDKIM。 +这个容器内部集成:API、Web、Nginx、Postfix、Dovecot、Rspamd。 如果 GHCR 镜像是私有的,先登录: @@ -94,3 +94,4 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up - 收件同步:Postfix/Dovecot 收到的 Maildir 邮件会由 API 的 Maildir worker 同步到 SQLite 后展示在 Webmail。 - Maildir worker 通过 `LANQIN_MAILDIR_ROOT` 和 `LANQIN_MAILDIR_SCAN_SECONDS` 控制,默认服务器路径为 `/var/mail/vhosts`。 + diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 138d951..9839e7a 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -294,9 +294,32 @@ func (a *App) migrate(ctx context.Context) error { if err := a.migrateMailRulesBuilder(ctx); err != nil { return err } + if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil { + return err + } return nil } +func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error { + adminEmail := normalizeEmail(a.cfg.AdminEmail) + if adminEmail == "" || !strings.Contains(adminEmail, "@") { + return nil + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := a.db.ExecContext(ctx, ` + UPDATE mailboxes + SET status='disabled', updated_at=? + WHERE address=? + AND display_name='LanQin Admin' + AND EXISTS ( + SELECT 1 FROM users + WHERE users.id=mailboxes.user_id + AND users.email=? + AND users.role='admin' + )`, now, adminEmail, adminEmail) + return err +} + func (a *App) migrateMailRulesBuilder(ctx context.Context) error { rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(mail_rules)`) if err != nil { @@ -533,32 +556,21 @@ func (a *App) seed(ctx context.Context) error { return nil } - domainName := strings.Split(a.cfg.AdminEmail, "@")[1] - domainID, err := a.createDomainTx(ctx, nil, domainName) - if err != nil { - return err - } - passwordHash, err := bcrypt.GenerateFromPassword([]byte(a.cfg.AdminPassword), bcrypt.DefaultCost) if err != nil { return err } now := a.now().UTC().Format(time.RFC3339Nano) userID := newID("usr") + adminEmail := normalizeEmail(a.cfg.AdminEmail) + if adminEmail == "" || !strings.Contains(adminEmail, "@") { + return errors.New("invalid admin email") + } _, err = a.db.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, userID, a.cfg.AdminEmail, "LanQin Admin", "admin", string(passwordHash), 0, now, now) if err != nil { return err } - - local := strings.Split(a.cfg.AdminEmail, "@")[0] - mailboxID, err := a.createMailbox(ctx, userID, domainID, local, "LanQin Admin", a.cfg.AdminPassword, 2048, "active") - if err != nil { - return err - } - if err := a.seedWelcomeMessage(ctx, mailboxID); err != nil { - return err - } a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", a.cfg.AdminEmail) return nil } diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 66fe225..25e219f 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -148,6 +148,28 @@ func (c *testClient) do(method, path string, body any, out any) int { return resp.StatusCode } +func createTestDomain(t *testing.T, admin *testClient, name string) Domain { + t.Helper() + var domain Domain + if code := admin.do("POST", "/api/admin/domains", map[string]string{"name": name}, &domain); code != http.StatusCreated { + t.Fatalf("create domain %s code=%d domain=%+v", name, code, domain) + } + return domain +} + +func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, displayName, password string, extra map[string]any) Mailbox { + t.Helper() + payload := map[string]any{"domainId": domainID, "localPart": localPart, "displayName": displayName, "password": password} + for key, value := range extra { + payload[key] = value + } + var mailbox Mailbox + if code := admin.do("POST", "/api/admin/mailboxes", payload, &mailbox); code != http.StatusCreated { + t.Fatalf("create mailbox %s code=%d mailbox=%+v", localPart, code, mailbox) + } + return mailbox +} + func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) @@ -159,22 +181,10 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) { t.Fatalf("login code=%d body=%v", code, login) } - var domains struct { - Items []Domain `json:"items"` - } - if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 { - t.Fatalf("domains code=%d items=%d", code, len(domains.Items)) - } - domainID := domains.Items[0].ID + domainID := createTestDomain(t, admin, "lanqin.local").ID - var mb1 Mailbox - if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{"domainId": domainID, "localPart": "alice", "displayName": "Alice", "password": "Password123!"}, &mb1); code != http.StatusCreated { - t.Fatalf("create alice code=%d mailbox=%+v", code, mb1) - } - var mb2 Mailbox - if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{"domainId": domainID, "localPart": "bob", "displayName": "Bob", "password": "Password123!"}, &mb2); code != http.StatusCreated { - t.Fatalf("create bob code=%d mailbox=%+v", code, mb2) - } + mb1 := createTestMailbox(t, admin, domainID, "alice", "Alice", "Password123!", nil) + mb2 := createTestMailbox(t, admin, domainID, "bob", "Bob", "Password123!", nil) var alias Alias if code := admin.do("POST", "/api/admin/aliases", map[string]any{"domainId": domainID, "source": "sales", "destination": mb1.Address}, &alias); code != http.StatusCreated { @@ -269,22 +279,11 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) { t.Fatalf("login code=%d body=%v", code, login) } - var domains struct { - Items []Domain `json:"items"` - } - if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 { - t.Fatalf("domains code=%d items=%d", code, len(domains.Items)) - } - domainID := domains.Items[0].ID + domainID := createTestDomain(t, admin, "lanqin.local").ID + createTestMailbox(t, admin, domainID, "admin", "Admin", "ChangeMe123!", map[string]any{"ownerEmail": "admin@lanqin.local", "role": "admin"}) - var primary Mailbox - if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{"domainId": domainID, "localPart": "multi", "displayName": "Multi", "password": "Password123!"}, &primary); code != http.StatusCreated { - t.Fatalf("create primary code=%d mailbox=%+v", code, primary) - } - var secondary Mailbox - if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{"domainId": domainID, "localPart": "multi-work", "displayName": "Multi Work", "password": "Password456!", "ownerEmail": primary.Address}, &secondary); code != http.StatusCreated { - t.Fatalf("create secondary code=%d mailbox=%+v", code, secondary) - } + primary := createTestMailbox(t, admin, domainID, "multi", "Multi", "Password123!", nil) + secondary := createTestMailbox(t, admin, domainID, "multi-work", "Multi Work", "Password456!", map[string]any{"ownerEmail": primary.Address}) if primary.UserID != secondary.UserID { t.Fatalf("mailboxes were not bound to one user: primary=%s secondary=%s", primary.UserID, secondary.UserID) } @@ -331,6 +330,8 @@ func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) { if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { t.Fatalf("login code=%d body=%v", code, login) } + domainID := createTestDomain(t, admin, "lanqin.local").ID + createTestMailbox(t, admin, domainID, "admin", "Admin", "ChangeMe123!", map[string]any{"ownerEmail": "admin@lanqin.local", "role": "admin"}) payload := map[string]any{ "to": []string{"ghost@lanqin.local"}, @@ -530,7 +531,11 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) { func TestDNSRecords(t *testing.T) { a := newTestApp(t) - d, err := a.domainByID(context.Background(), mustDefaultDomainID(t, a)) + domainID, err := a.createDomainTx(context.Background(), nil, "lanqin.local") + if err != nil { + t.Fatal(err) + } + d, err := a.domainByID(context.Background(), domainID) if err != nil { t.Fatal(err) } @@ -548,6 +553,17 @@ func TestMaildirSyncImportsRFC822(t *testing.T) { ctx := context.Background() root := t.TempDir() a.cfg.MaildirRoot = root + domainID, err := a.createDomainTx(ctx, nil, "lanqin.local") + if err != nil { + t.Fatal(err) + } + adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local") + if err != nil { + t.Fatal(err) + } + if _, err := a.createMailbox(ctx, adminUser.ID, domainID, "admin", "Admin", "ChangeMe123!", 1024, "active"); err != nil { + t.Fatal(err) + } mailboxes, err := a.maildirMailboxes(ctx) if err != nil { diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 483987c..31311ce 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -46,8 +46,11 @@ type storedMessage struct { func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) { user := currentUser(r) - rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at - FROM mailboxes WHERE user_id=? AND status='active' ORDER BY address`, user.ID) + rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at + FROM mailboxes mb + JOIN domains d ON d.id=mb.domain_id + WHERE mb.user_id=? AND mb.status='active' AND d.status='active' + ORDER BY mb.address`, user.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load mailboxes") return diff --git a/deploy/.env.example b/deploy/.env.example index c5dff6c..ec64b6e 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -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 diff --git a/deploy/README.md b/deploy/README.md index 7d2b00d..d578fd9 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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。 + diff --git a/deploy/all-in-one/Dockerfile b/deploy/all-in-one/Dockerfile index 69d1ed5..a75d951 100644 --- a/deploy/all-in-one/Dockerfile +++ b/deploy/all-in-one/Dockerfile @@ -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"] diff --git a/deploy/all-in-one/entrypoint.sh b/deploy/all-in-one/entrypoint.sh index 9732b76..c5a0123 100644 --- a/deploy/all-in-one/entrypoint.sh +++ b/deploy/all-in-one/entrypoint.sh @@ -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 diff --git a/deploy/all-in-one/supervisord.conf b/deploy/all-in-one/supervisord.conf index 0fd1044..bd19505 100644 --- a/deploy/all-in-one/supervisord.conf +++ b/deploy/all-in-one/supervisord.conf @@ -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 diff --git a/deploy/docker-compose.stack.build.yml b/deploy/docker-compose.stack.build.yml index 18171f6..f55a533 100644 --- a/deploy/docker-compose.stack.build.yml +++ b/deploy/docker-compose.stack.build.yml @@ -17,6 +17,6 @@ services: build: context: ./dovecot - opendkim: + rspamd: build: - context: ./opendkim + context: ./rspamd diff --git a/deploy/docker-compose.stack.yml b/deploy/docker-compose.stack.yml index 954ce13..22ae1f2 100644 --- a/deploy/docker-compose.stack.yml +++ b/deploy/docker-compose.stack.yml @@ -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: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 2bca6f3..3abf94e 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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: diff --git a/deploy/opendkim/entrypoint.sh b/deploy/opendkim/entrypoint.sh deleted file mode 100644 index c9c3dd1..0000000 --- a/deploy/opendkim/entrypoint.sh +++ /dev/null @@ -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 diff --git a/deploy/opendkim/opendkim.conf b/deploy/opendkim/opendkim.conf deleted file mode 100644 index 43aea05..0000000 --- a/deploy/opendkim/opendkim.conf +++ /dev/null @@ -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 diff --git a/deploy/postfix/entrypoint.sh b/deploy/postfix/entrypoint.sh index b8d8664..e25005a 100644 --- a/deploy/postfix/entrypoint.sh +++ b/deploy/postfix/entrypoint.sh @@ -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 diff --git a/deploy/postfix/main.cf b/deploy/postfix/main.cf index 13a4a03..6c075cd 100644 --- a/deploy/postfix/main.cf +++ b/deploy/postfix/main.cf @@ -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 diff --git a/deploy/opendkim/Dockerfile b/deploy/rspamd/Dockerfile similarity index 60% rename from deploy/opendkim/Dockerfile rename to deploy/rspamd/Dockerfile index b9696ba..a589408 100644 --- a/deploy/opendkim/Dockerfile +++ b/deploy/rspamd/Dockerfile @@ -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"] diff --git a/deploy/rspamd/entrypoint.sh b/deploy/rspamd/entrypoint.sh new file mode 100644 index 0000000..6d0fbb7 --- /dev/null +++ b/deploy/rspamd/entrypoint.sh @@ -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 diff --git a/deploy/rspamd/local.d/actions.conf b/deploy/rspamd/local.d/actions.conf new file mode 100644 index 0000000..2398434 --- /dev/null +++ b/deploy/rspamd/local.d/actions.conf @@ -0,0 +1,4 @@ +# V1 先把 Rspamd 作为 DKIM 签名和标记组件,不直接拒信,避免误杀。 +reject = 999; +add_header = 6; +greylist = 999; diff --git a/deploy/rspamd/local.d/dkim_signing.conf b/deploy/rspamd/local.d/dkim_signing.conf new file mode 100644 index 0000000..7413f8c --- /dev/null +++ b/deploy/rspamd/local.d/dkim_signing.conf @@ -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; diff --git a/deploy/opendkim/TrustedHosts b/deploy/rspamd/local.d/sign_networks.map similarity index 66% rename from deploy/opendkim/TrustedHosts rename to deploy/rspamd/local.d/sign_networks.map index d33ea04..a96f8ca 100644 --- a/deploy/opendkim/TrustedHosts +++ b/deploy/rspamd/local.d/sign_networks.map @@ -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 diff --git a/deploy/rspamd/local.d/worker-proxy.inc b/deploy/rspamd/local.d/worker-proxy.inc new file mode 100644 index 0000000..4ecc3e4 --- /dev/null +++ b/deploy/rspamd/local.d/worker-proxy.inc @@ -0,0 +1,8 @@ +bind_socket = "0.0.0.0:11332"; +milter = yes; +timeout = 120s; + +upstream "local" { + default = yes; + self_scan = yes; +} diff --git a/deploy/rspamd/sync-dkim.sh b/deploy/rspamd/sync-dkim.sh new file mode 100644 index 0000000..af2bf41 --- /dev/null +++ b/deploy/rspamd/sync-dkim.sh @@ -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