先说说,正常情况下大家会怎么处理这种事
如果你的邮件账号密码被盗、被用来发垃圾邮件,"标准做法"大致是这几步,按优先级排序:
① 立刻改密码,这是第一反应,也是最该做的一步——只要密码没换,封IP毫无意义,攻击者随时能换个IP继续登录。
② 看邮件服务自带的防护,比如登录失败次数限制、单IP发信速率限制(很多邮件服务比如 Haraka、Postfix 都有现成插件,不需要自己造轮子)。
③ 如果还要更进一步收紧,才考虑利用动态IP白名单库——只允许特定IP连接25/465/587这几个端口,等于把"谁能用这个邮箱发信"的范围锁死到几个固定地点。
大多数教程到这一步,会直接告诉你"用 ufw allow from <IP> 就行了",配合一个定时拉取IP列表的脚本,看起来非常合理,很多人(包括我们这次)都是这么开始的。但如果你的邮件服务是跑在 Docker 容器里的,这套标准做法很可能完全失效,而且不会有任何报错提示你"这样不行"——规则看起来加上了,日志也显示成功了,结果就是不拦人。 这篇记录就是讲这个坑是怎么一步步被发现、又是怎么解决的。
先补一点基础知识,方便看懂后面发生了什么
防火墙规则不是只有一条路。在 Linux 上,网络流量进来之后,内核会按"这个包要去哪"分成两种情况来处理:
· 如果这个包是发给本机上某个进程的(比如你直接在服务器上装了个网站,监听80端口),这条路叫 INPUT 链。
· 如果这个包是要被转发到别的地方的(比如你的服务器只是个中转站,包真正要去的是另一台机器,或者——这次的关键——另一个 Docker 容器),这条路叫 FORWARD 链。
UFW,就是市面上最常用的"防火墙管理工具",默认管的是 INPUT 链。 你可以把它想象成小区门口的保安,只负责"谁能进小区大门"。
而 Docker 的端口映射,本质上是个转发。你在 docker run 里写的 -p 587:587,意思是"外面有人连服务器的587端口,请转发给容器里的587端口"——这个过程对内核来说,根本不算"发给本机",而是货真价实的"转发",走的是 FORWARD 链,跟保安(UFW/INPUT)完全不是一个管辖范围。
打个比方:UFW 是小区门口的保安,只检查进小区大门的人。但你的邮件服务是小区里的一个"快递代收点",快递员直接从小区侧门(Docker 自己开的转发通道)进出,根本不经过正门保安。 你在正门贴再多的"禁止XX人员入内"的告示(UFW规则),对走侧门的快递员(Docker转发流量)毫无作用——这正是这次踩的坑。
排查过程:从"看起来对"到"真的对"
第一步:怀疑规则顺序,结果是干扰项
最早的UFW脚本逻辑很完整:拉取白名单、清理旧规则、加白名单allow、最后deny掉587/465。跑起来没报错,ufw status 里也确实能看到规则。
但实测非白名单IP照样能连上发信。第一反应是查规则顺序、查默认策略:
ufw status verbose
iptables -L INPUT -n -v --line-numbers
确实翻出了一批历史遗留规则,但核对后发现跟当前问题无关,是过去测试留下的死规则,纯属误导,排查方向错了。
第二步:找到真正的路——Docker 走的是 FORWARD,不是 INPUT
转折点是查这两条命令:
iptables -t nat -L -n -v --line-numbers
iptables -L FORWARD -n -v --line-numbers
nat 表里能看到这样的规则:
DNAT tcp -- !br-xxxx * 0.0.0.0/0 0.0.0.0/0 tcp dpt:587 to:172.19.0.2:587
这条规则的意思是:"所有打到587端口的流量,把目标地址改写成容器内网IP 172.19.0.2,然后继续转发"——这就是前面说的"侧门"。改写地址之后,真正决定"放不放行"的判断,落在了 FORWARD 链上,而不是UFW管的INPUT链。
于是把白名单规则直接搬到FORWARD链,目标地址换成容器IP:
iptables -I FORWARD 1 -p tcp -s <白名单IP> -d 172.19.0.2 --dport 587 -j ACCEPT
iptables -I FORWARD 1 -p tcp -d 172.19.0.2 --dport 587 -j DROP
跑完之后,DROP那条规则的计数器(pkts那一列)开始随着非白名单连接尝试而增长——终于钓上鱼了。
第三步:计数器涨了,但客户端工具还说"连上了"——别被这行字骗了
规则生效后,又冒出一个让人怀疑自己的现象:服务器这边DROP计数器明明在涨,Windows端用 ncat 测试却还是显示:
Ncat: connection succeeded.
后来对比"真连上"和"被拦截"两种情况下的完整输出,才看出区别。真连上时是这样:
Ncat: connection succeeded.
220 mail.example.com ESMTP Haraka ready (xxxxxx)
被拦截时是这样:
Ncat: connection succeeded.
Ncat: 0 bytes sent, 0 bytes received in 17.27 seconds.
"connection succeeded" 这行字,只代表客户端和本地代理之间握手成功,不代表代理真的连到了目标服务器——这是这次最容易踩的认知误区。被DROP的包,服务器是直接"装死"不回应的,客户端只能干等到超时,但有些工具/代理软件会在这个过程里提前打印"succeeded",看起来像通了,其实啥也没传过去。真正的判断标准是有没有拿到服务器的应答内容(banner),不是看连接提示语。
465端口还有个额外的坑:它是"加密直连"端口(隐式TLS),连上之后服务器根本不会用明文说话,必须客户端先发加密握手包,服务器才在加密通道里回应。所以即使465完全正常,纯文本工具 ncat 也看不到任何输出——这跟"被拦截"长得一样,但原因完全不同,得换用支持TLS的工具才能验证:
openssl s_client -connect <服务器IP>:465 -quiet
第四步:白名单测试时,居然一个"非白名单"IP也连上了
加完白名单规则后,有一次测试,一个明确不在名单里的IP(用 curl ipinfo.io/ip 查到的)居然也连上了,还拿到了完整banner。差点以为白名单又出漏洞了。
后来用服务器端最权威的依据查了一下:
conntrack -L | grep "<怀疑的IP>"
结果——这个IP在服务器侧根本没有任何连接记录。说明它从来没有真正连接过服务器。真相是:客户端所在的网络是多线路的,查IP用的连接和实际测试连接,走的不是同一个出口,对外显示的IP完全不同。从这之后,所有验证都改成"以服务器端 conntrack 记录的真实IP为准",不再信任客户端自己查到的那个IP。
最终方案
把整个机制重新梳理成一个脚本,核心设计是:
# 自动检测容器当前IP,不写死(容器重建IP可能变)
TARGET_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mailserver)
# 拉取白名单失败就直接退出,不碰现有规则
IPS_RAW=$(curl -fsSL --max-time 10 "$WHITELIST_API" || true)
# 精确删除上一次的规则(记录上次IP列表,删除时一一对应,不用模糊匹配)
# 插入新的白名单ACCEPT,再插入DROP兜底,顺序:ACCEPT必须在DROP之前
加上几个保障措施:
· 每次执行前自动存一份规则快照,方便人工回滚。
· 不依赖 iptables-persistent 做重启持久化——因为白名单本身一天更新好几次,单纯"保存某一刻快照"意义不大,改成每次定时任务都重新拉取、重新建立规则,天然适配高频更新的场景。
· 原来管邮件端口的UFW脚本完全弃用——不是脚本写得不好,是它从一开始就管错了链,逻辑再完善也不会生效。但如果是非Docker、直接跑在宿主机上的服务(不经过端口映射转发),UFW依然是有效的,这次的坑只针对"容器+端口映射"这种场景。
一句话总结
"规则添加成功" 和 "规则真的在拦人",中间隔着好几层可能的误导:选错了链(INPUT vs FORWARD)、看错了提示("succeeded"不等于真通)、用错了工具(明文工具测加密端口)、信错了IP(客户端自查的IP不一定是实际连接用的IP)。每一层都得拿服务器端最原始的证据——iptables计数器、conntrack连接记录、tcpdump抓包——去交叉验证,不能只看某个工具吐出的一行提示就下结论。
附:排查时用到的核心命令
# 抓包看流量到底有没有进来,进来之后走的什么路径
tcpdump -i eth0 -n 'tcp port 25 or tcp port 465 or tcp port 587' -vv
# 查当前真实建立的连接,确认某个IP是否真的连过服务器(比客户端自查更可信)
conntrack -L | grep "<怀疑的IP>"
# 查FORWARD链实际生效的规则和计数器
iptables -L FORWARD -n -v --line-numbers
# 查DNAT规则,确认端口被转发到了哪个容器IP
iptables -t nat -L -n -v --line-numbers
# 查容器当前的内网IP(容器重建后会变,别写死)
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' <容器名>
# 验证隐式TLS端口(465),纯文本工具看不到banner是正常现象
openssl s_client -connect <服务器IP>:465 -quiet
# 验证STARTTLS端口(25/587)的明文/加密两个阶段
ncat -v <服务器IP> 587
openssl s_client -starttls smtp -connect <服务器IP>:587 -quiet
Comments NOTHING