由于我的邮件服务器搭建在海外,无法正常接收国内的一些企业的邮件,因此使用这种方式在国内配置一个接收服务器,通过配置DNS的区域解析,不同区域解析到不同的服务器IP。
本教程中使用了WireGuard对涉及的服务器进行了组网,因此配置Nginx时使用的是组网IP,如果没有组网可以直接使用服务器的公网IP,但是要注意配置防火墙,防止被滥用。
本项目的示例服务器
如果你想使用公网就将下面的IP替换为你服务器的IP
服务器A: 172.16.7.1,Mailu邮件服务器
服务器B: 172.16.6.1,中转服务器
服务器C: 172.16.5.3,Nginx代理收发服务器
目标
两个地区使用相同的域名和端口号,通过智能解析按照区域解析到不同的IP,用户无需感知入口差异
服务器A能识别经服务器C转发流量的真实来源 IP,保证 SPF、RBL、rspamd IP信誉、自定义 IP 黑白名单及审计日志全部基于真实 IP。
服务器A原有的收发路径完全不变。
整体架构
C 地区用户 / 外部 MTA
智能解析 / MX → C 公网 IP
标准端口 25/465/587/993/995/143/110/4190
│
┌─────────────▼──────────────┐
│ C: nginx stream 四层透传 │
│ 连接开头附加 PROXY 协议头 │
│ (携带真实客户端 IP) │
└─────────────┬──────────────┘
│ WireGuard 隧道
▼
A 专用监听(仅绑本机与内网接口,要求 PROXY 头,行为与标准端口一致)
25 → smtp:125 (Postfix smtpd)
110 → front:126 (dovecot pop3-login, STARTTLS)
143 → front:127 (dovecot imap-login, STARTTLS)
465 → front:128 (dovecot submission-login, implicit TLS)
587 → front:129 (dovecot submission-login, STARTTLS)
993 → front:130 (dovecot imap-login, implicit TLS)
995 → front:131 (dovecot pop3-login, implicit TLS)
4190 → front:132 (dovecot managesieve-login, STARTTLS)
A 地区用户:智能解析 → A 公网 IP → 标准端口直连(原有路径,零改动)
外部 MTA: MX 兜底记录 → A front:25 直收(原有路径,零改动)真实来源 IP 的传递机制是 PROXY 协议:C 的 nginx 在转发连接前先发送一个 PROXY 协议头(内含原始客户端 IP),A 侧专用监听解析该头并把其中的地址当作真实客户端地址,后续 rspamd 检查、认证限流、日志与邮件 Received 头均基于真实 IP。邮件数据为四层透传,TLS 始终在 A 端到端完成,证书与主机名不变。
专用监听与标准端口的关系:同一服务进程内并列的两组监听,配置、认证、后端转发完全一致,唯一区别是专用监听要求连接携带 PROXY 协议头。标准端口继续服务 A 地区直连流量(不带 PROXY 头),两组互不干扰。
端口映射一览
专用端口 125–132 连续编号,与公开端口按数值升序一一对应,方便记忆、映射和防火墙管理。这些低位端口未被 Mailu 与常见服务占用,容器内由 root 进程绑定;部署前在宿主机用 ss -lnt 确认空闲。
A 配置
Mailu 数据目录为 /docker/mail/data,以下路径均基于此。
新建 Postfix 125 监听(MX 收信专用)
新建 /docker/mail/data/overrides/postfix/postfix.master,内容为单行(不可折行):
125/inet=125 inet n - n - - smtpd -o smtpd_upstream_proxy_protocol=haproxyMailu 启动时对该文件逐行执行 postconf -Me,其编辑语法为 服务/类型=完整master.cf行:等号左边 125/inet 是条目键,右边是注册进 master.cf 的完整服务行。该监听继承 main.cf 的全部限制(收件人校验、防开放中继、milter 链),仅额外要求连接携带 PROXY 协议头。端口号任选空闲端口即可(10025 已被 Mailu 内部提交服务占用),此处使用 125。overrides 仅在容器启动时加载,修改后需 docker compose restart smtp。
smtpd_upstream_proxy_protocol=haproxy 中的 haproxy 是 Postfix 对 PROXY 协议的命名——该协议最早由 HAProxy 提出,此处表示接受 PROXY v1/v2 协议头。nginx 的 proxy_protocol on; 发送的正是这一协议,与实际使用的代理软件无关。
Postfix 启用 smtpd TLS
编辑 /docker/mail/data/overrides/postfix/postfix.cf,保留已有的 milter 行,追加三行 TLS 配置(注意第一行是我自定义的milter,与本项目无关):
smtpd_milters = inet:kmilter:1788 inet:antispam:11332
smtpd_tls_security_level = may
smtpd_tls_cert_file = /certs/cert.pem
smtpd_tls_key_file = /certs/key.pemMailu 的 TLS 默认由 front 容器终结,Postfix 自身不读证书。经 C 的MX 连接由 C 四层透传,STARTTLS 协商落在 Postfix 上,因此需要为Postfix 指定证书(与 front 使用同一套)。may 为机会性 TLS,front内部明文连接不受影响。
新建 front dovecot 专用监听(客户端端口专用)
front 容器内,465/587/993/995/110/143/4190 由 dovecot 代理进程监听,其配置模板末尾通过 !include_try /overrides/dovecot/proxy.conf 预留了官方扩展入口(front 的 /overrides 挂载自宿主机 data/overrides/nginx)。
新建 /docker/mail/data/overrides/nginx/dovecot/proxy.conf:
# 代理中转专用监听:协议行为与标准端口一致,但要求连接携带 PROXY 协议头,
# 用于还原经入口节点(当前为 C,可扩展其他地区节点)转发流量的真实
# 客户端 IP。可信来源由主配置的 haproxy_trusted_networks(取自
# REAL_IP_FROM)限定。
# 专用端口 126-132 与公开端口按数值升序一一对应(110→126、143→127、
# 465→128、587→129、993→130、995→131、4190→132),另有 25→125 在
# Postfix 侧。这些端口在宿主机仅绑本机与内网接口,仅供入口节点经隧道访问。
service pop3-login {
inet_listener pop3_proxy {
port = 126
haproxy = yes
}
inet_listener pop3s_proxy {
port = 131
ssl = yes
haproxy = yes
}
}
service imap-login {
inet_listener imap_proxy {
port = 127
haproxy = yes
}
inet_listener imaps_proxy {
port = 130
ssl = yes
haproxy = yes
}
}
service submission-login {
inet_listener submissions_proxy {
port = 128
ssl = yes
haproxy = yes
}
inet_listener submission_proxy {
port = 129
haproxy = yes
}
}
service managesieve-login {
inet_listener sieve_proxy {
port = 132
haproxy = yes
}
}dovecot 会将同名 service 块与主配置合并,上述块只是在各服务中追加监听,不改变既有监听的任何行为。STARTTLS 类监听继承主配置的全局 ssl = required(认证前必须完成 TLS 升级);ssl = yes 的监听为 implicit TLS,与标准端口语义一致。
docker-compose 修改
由于我在front中使用的自己的证书文件,因此这里smtp直接相同的方式映射进去即可,如果你使用的其他方式则需要按照自己的情况调整。
smtp 服务:新增 125 端口映射与证书挂载(其余内容不变):
smtp:
image: ghcr.io/mailu/postfix:2024.06.55
restart: always
env_file: mail.env
logging:
driver: journald
options:
tag: mail-smtp
ports:
# 125 监听没有可信来源列表,绑定范围即伪造来源 IP 的信任边界,
# 仅绑本机与内网接口,禁止绑公网
- "127.0.0.1:125:125" # SMTP PROXY
- "172.16.7.1:125:125"
- "172.17.7.1:125:125"
volumes:
- "/docker/mail/data/mailqueue:/queue"
- "/docker/mail/data/overrides/postfix:/overrides:ro"
- "/docker/mail/data/custom/certs:/certs:ro" # 与 front 同一套证书
depends_on:
- front
- resolver
dns:
- 172.17.17.254front 服务:在现有 ports 段追加专用监听映射(绑定接口与 smtp 的 125 保持一致,其余内容不变):
# 代理中转专用监听(要求 PROXY 协议头),绑定范围即信任边界
- "127.0.0.1:126:126" # POP3
- "172.16.7.1:126:126"
- "172.17.7.1:126:126"
- "127.0.0.1:127:127" # IMAP4 (explicit TLS => STARTTLS)
- "172.16.7.1:127:127"
- "172.17.7.1:127:127"
- "127.0.0.1:128:128" # ESMTP (implicit TLS)
- "172.16.7.1:128:128"
- "172.17.7.1:128:128"
- "127.0.0.1:129:129" # ESMTP (explicit TLS => STARTTLS)
- "172.16.7.1:129:129"
- "172.17.7.1:129:129"
- "127.0.0.1:130:130" # IMAP4 (implicit TLS)
- "172.16.7.1:130:130"
- "172.17.7.1:130:130"
- "127.0.0.1:131:131" # POP3 (with TLS)
- "172.16.7.1:131:131"
- "172.17.7.1:131:131"
- "127.0.0.1:132:132" # Sieve
- "172.16.7.1:132:132"
- "172.17.7.1:132:132"mail.env 说明
本方案不需要 PROXY_PROTOCOL 设置(该设置的作用是把标准端口改为强制 PROXY 协议,与 A 地区直连不兼容;专用监听方案中标准端口保原样),否则标准 587/143/110 端口会要求 PROXY 头,A地区直连这三个端口的客户端将无法连接。
REAL_IP_FROM=172.16.0.0/12:同时作为专用监听 PROXY 头的信任边界(渲染为 dovecot haproxy_trusted_networks)。172.16.5.3 已在该范围内,功能上无需修改;建议收窄为实际代理地址(如 172.16.5.3/32 加 web 反代来源),缩小可伪造来源 IP 的主机范围。
REAL_IP_HEADER=X-Real-IP:仅作用于 HTTP 块,与邮件端口的 PROXY 协议无关,保持不变。
C 配置
C 的 nginx 需包含 stream 模块。以下配置按既有 stream 代理模板生成,放入 stream 配置目录(与 git-ssh 等现有端口代理同级):
upstream 多路径:v1-A~v2-A 对应到 A 的多条隧道(172.16~17 网段),含 TCP 健康检查与故障转移;如有 v3-A(172.18 路径)按同格式追加一行。
相比模板的两处功能性增补:每个 server 块必须有
proxy_protocol on(传递真实客户端 IP,本方案核心);110/143/993/995/4190 增加proxy_timeout 1h(IMAP IDLE 的空闲通知间隔为 29 分钟,超过 stream 默认 10 分钟超时会被切断)。邮件协议均为 TCP,PROXY 协议头也仅定义于 TCP,A 侧未发布 UDP,故不生成 UDP server 块。
健康检查为 TCP 连接级探测(连上即断),A 侧 Postfix/dovecot 日志会出现"short protocol header"类记录,属预期噪音;Mailu 的 Postfix 启动脚本已内置过滤该模式。
upstream mail-smtp_stream {
zone mail-smtp_stream 1M;
server v1-A:125 weight=1 max_fails=3 fail_timeout=30s backup;
server v2-A:125 weight=1 max_fails=3 fail_timeout=30s;
check interval=15000 rise=3 fall=2 timeout=2000 default_down=false type=tcp;
}
server {
listen 25 so_keepalive=30:10:3;
listen [::]:25 so_keepalive=30:10:3;
proxy_pass mail-smtp_stream;
proxy_protocol on;
include /etc/nginx/conf.d/stream-tcp-enabled/*.conf;
access_log /nginx/logs/A/00025-mail-smtp.access.log main_tcp buffer=16k flush=1m;
error_log /nginx/logs/A/00025-mail-smtp.error.log;
}
upstream mail-pop3_stream {
zone mail-pop3_stream 1M;
server v1-A:126 weight=1 max_fails=3 fail_timeout=30s backup;
server v2-A:126 weight=1 max_fails=3 fail_timeout=30s;
check interval=15000 rise=3 fall=2 timeout=2000 default_down=false type=tcp;
}
server {
listen 110 so_keepalive=30:10:3;
listen [::]:110 so_keepalive=30:10:3;
proxy_pass mail-pop3_stream;
proxy_protocol on;
proxy_timeout 1h;
include /etc/nginx/conf.d/stream-tcp-enabled/*.conf;
access_log /nginx/logs/A/00110-mail-pop3.access.log main_tcp buffer=16k flush=1m;
error_log /nginx/logs/A/00110-mail-pop3.error.log;
}
upstream mail-imap_stream {
zone mail-imap_stream 1M;
server v1-A:127 weight=1 max_fails=3 fail_timeout=30s backup;
server v2-A:127 weight=1 max_fails=3 fail_timeout=30s;
check interval=15000 rise=3 fall=2 timeout=2000 default_down=false type=tcp;
}
server {
listen 143 so_keepalive=30:10:3;
listen [::]:143 so_keepalive=30:10:3;
proxy_pass mail-imap_stream;
proxy_protocol on;
proxy_timeout 1h;
include /etc/nginx/conf.d/stream-tcp-enabled/*.conf;
access_log /nginx/logs/A/00143-mail-imap.access.log main_tcp buffer=16k flush=1m;
error_log /nginx/logs/A/00143-mail-imap.error.log;
}
upstream mail-smtps_stream {
zone mail-smtps_stream 1M;
server v1-A:128 weight=1 max_fails=3 fail_timeout=30s backup;
server v2-A:128 weight=1 max_fails=3 fail_timeout=30s;
check interval=15000 rise=3 fall=2 timeout=2000 default_down=false type=tcp;
}
server {
listen 465 so_keepalive=30:10:3;
listen [::]:465 so_keepalive=30:10:3;
proxy_pass mail-smtps_stream;
proxy_protocol on;
include /etc/nginx/conf.d/stream-tcp-enabled/*.conf;
access_log /nginx/logs/A/00465-mail-smtps.access.log main_tcp buffer=16k flush=1m;
error_log /nginx/logs/A/00465-mail-smtps.error.log;
}
upstream mail-submission_stream {
zone mail-submission_stream 1M;
server v1-A:129 weight=1 max_fails=3 fail_timeout=30s backup;
server v2-A:129 weight=1 max_fails=3 fail_timeout=30s;
check interval=15000 rise=3 fall=2 timeout=2000 default_down=false type=tcp;
}
server {
listen 587 so_keepalive=30:10:3;
listen [::]:587 so_keepalive=30:10:3;
proxy_pass mail-submission_stream;
proxy_protocol on;
include /etc/nginx/conf.d/stream-tcp-enabled/*.conf;
access_log /nginx/logs/A/00587-mail-submission.access.log main_tcp buffer=16k flush=1m;
error_log /nginx/logs/A/00587-mail-submission.error.log;
}
upstream mail-imaps_stream {
zone mail-imaps_stream 1M;
server v1-A:130 weight=1 max_fails=3 fail_timeout=30s backup;
server v2-A:130 weight=1 max_fails=3 fail_timeout=30s;
check interval=15000 rise=3 fall=2 timeout=2000 default_down=false type=tcp;
}
server {
listen 993 so_keepalive=30:10:3;
listen [::]:993 so_keepalive=30:10:3;
proxy_pass mail-imaps_stream;
proxy_protocol on;
proxy_timeout 1h;
include /etc/nginx/conf.d/stream-tcp-enabled/*.conf;
access_log /nginx/logs/A/00993-mail-imaps.access.log main_tcp buffer=16k flush=1m;
error_log /nginx/logs/A/00993-mail-imaps.error.log;
}
upstream mail-pop3s_stream {
zone mail-pop3s_stream 1M;
server v1-A:131 weight=1 max_fails=3 fail_timeout=30s backup;
server v2-A:131 weight=1 max_fails=3 fail_timeout=30s;
check interval=15000 rise=3 fall=2 timeout=2000 default_down=false type=tcp;
}
server {
listen 995 so_keepalive=30:10:3;
listen [::]:995 so_keepalive=30:10:3;
proxy_pass mail-pop3s_stream;
proxy_protocol on;
proxy_timeout 1h;
include /etc/nginx/conf.d/stream-tcp-enabled/*.conf;
access_log /nginx/logs/A/00995-mail-pop3s.access.log main_tcp buffer=16k flush=1m;
error_log /nginx/logs/A/00995-mail-pop3s.error.log;
}
upstream mail-sieve_stream {
zone mail-sieve_stream 1M;
server v1-A:132 weight=1 max_fails=3 fail_timeout=30s backup;
server v2-A:132 weight=1 max_fails=3 fail_timeout=30s;
check interval=15000 rise=3 fall=2 timeout=2000 default_down=false type=tcp;
}
server {
listen 4190 so_keepalive=30:10:3;
listen [::]:4190 so_keepalive=30:10:3;
proxy_pass mail-sieve_stream;
proxy_protocol on;
proxy_timeout 1h;
include /etc/nginx/conf.d/stream-tcp-enabled/*.conf;
access_log /nginx/logs/A/04190-mail-sieve.access.log main_tcp buffer=16k flush=1m;
error_log /nginx/logs/A/04190-mail-sieve.error.log;
}DNS 配置
客户端主机名:
mail.example.com等做区域智能解析(华为云 DNS 智能线路:同一主机记录添加两条记录集,"默认"线路指向 A 公网 IP,"境外"或指定国家/地区线路指向 C 公网 IP)。两地区主机名与端口号完全一致,客户端配置无区域差异,证书为同一张。SPF/DKIM/DMARC:发信仍从 A 发出,现有记录无需改动。
MX 记录:新增一条指向 C 的主机名(如
mx2.example.com→ C 公网 IP),优先级数值小于 A 的现有 MX(如 10 对 20)。C 接收大部分正常邮件,A 直收作为兜底,两条路径均完整校验。MX 主机名同样可以按线路智能解析。
生效与验证
A 上重建容器(overrides 与新增端口映射在启动时加载):
cd /docker/mail && docker compose up -d配置项验证:
# Postfix:125 已注册进 master.cf,TLS 已生效
docker compose exec smtp postconf -M | grep '^125 '
docker compose exec smtp postconf smtpd_tls_security_level smtpd_tls_cert_file
# 宿主机:专用监听均只绑 WireGuard IP
ss -lnt | grep -E ':(12[5-9]|13[0-2]) '协议行为验证(在 A 上执行;若已收窄 REAL_IP_FROM,A 本机不在信任列表内,需改从 C 执行):
# 不带 PROXY 头连接专用监听:125 静默等待约 5 秒后断开(无 220 banner
# 即为协议强制生效);dovecot 侧明文端口(126/127/129/132)立即断开
nc 127.0.0.1 125
# 带 PROXY 头连接:应返回服务 banner(125 → 220 ESMTP,127 → * OK Dovecot)
(printf 'PROXY TCP4 203.0.113.9 172.16.7.1 51000 25\r\n'; sleep 8) | nc 127.0.0.1 125
printf 'PROXY TCP4 203.0.113.9 172.16.7.1 51000 143\r\n' | nc 127.0.0.1 127
# 日志确认会话来源为 PROXY 头中的地址(unknown 为无 PTR 反解,属正常)
docker compose logs --since 10m smtp | grep 'connect from'
# TLS 端口用 curl 验证(--haproxy-protocol 自动先发 PROXY 头再握手)
curl -v --insecure --haproxy-protocol 'imaps://127.0.0.1:130/' -u '用户@域名:密码'端到端验证:
用 swaks 从外网经 C:25 发测试信,A 的 smtp 日志显示
connect from <真实IP>,rspamd 历史中 SPF/RBL 结果基于真实发件 IP。C 地区客户端经 465 或 587 提交邮件,smtp 日志与 Received 头显示真实客户端 IP;经 993 或 143 登录 IMAP,front 日志
rip=显示真实 IP。A 地区用户照常收发,确认原有路径无变化。
安全注意事项
专用监听的信任边界:任何能连到 125–132 端口的主机都可伪造 PROXY 头伪造来源 IP,且 Postfix 的 125 监听没有类似
haproxy_trusted_networks的可信来源列表,绑定范围即信任边界。这些端口只绑本机与必要的内网接口(C 走 172.16.7.1),禁止绑公网,并在 A 防火墙仅放行 172.16.5.3。REAL_IP_FROM 的范围:该列表内的所有地址都可声明任意来源 IP(HTTP 的 X-Real-IP 与邮件端口的 PROXY 头共用此信任列表),按最小化原则配置。
C 机房端口:入方向 25 需开放(部分云厂商默认封锁;测试方法为在 C 监听 25 后从公网第三方机器
nc -vz,refused 表示端口畅通仅无监听,超时表示被过滤)。隧道中断:C 转发失败时外部发件 MTA 会自动重试,也可改走 A 的兜底 MX,邮件不会丢失;C 地区用户客户端会直连失败,此时 DNS 智能解析故障切换(调低 TTL)可作为应急手段。