chore(deploy): 将 DKIM 组件切换为 Rspamd
- 用 Rspamd 替换 OpenDKIM 及其相关镜像、编排和启动脚本。 - 调整 Postfix、单容器镜像与 Docker 配置,改为对接 Rspamd milter。 - 增加 DKIM 私钥同步脚本与 Rspamd 本地配置,支持从 SQLite 周期导出签名密钥。 - 更新 API 启动与测试逻辑,兼容新的默认管理员邮箱与邮箱加载方式。
This commit is contained in:
+1
-1
@@ -22,7 +22,7 @@ coverage
|
||||
tmp
|
||||
data
|
||||
maildata
|
||||
dkim-keys
|
||||
rspamd-dkim
|
||||
logs
|
||||
*.log
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -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
@@ -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。
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,6 @@ services:
|
||||
build:
|
||||
context: ./dovecot
|
||||
|
||||
opendkim:
|
||||
rspamd:
|
||||
build:
|
||||
context: ./opendkim
|
||||
context: ./rspamd
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
# V1 先把 Rspamd 作为 DKIM 签名和标记组件,不直接拒信,避免误杀。
|
||||
reject = 999;
|
||||
add_header = 6;
|
||||
greylist = 999;
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
bind_socket = "0.0.0.0:11332";
|
||||
milter = yes;
|
||||
timeout = 120s;
|
||||
|
||||
upstream "local" {
|
||||
default = yes;
|
||||
self_scan = yes;
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user