我花了一个晚上修好一个 2014 年就存在、至今未关的问题:ufw 管不住 Docker 端口。ufw 说只开了 3 个端口,实际开了 12 个,包括一个能部署容器、读所有密钥、开 shell 的管理面板。
但这篇文章真正想说的不是那个 bug。是我把成果发上 GitHub 时做的一个决定:仓库里没有可执行的脚本。主体是一个 PROMPT.md,你把它粘进自己的 coding agent,指向自己的服务器,让它先读完你这台机器的真实情况,再生成属于你的实现。
我认为对这一类工具,这比发一个脚本更诚实。但这话空口说没有意义——所以下面先讲那个 bug,讲我怎么在自己机器上把它修错了两次。没有那部分,后半段就只是一句漂亮话。
三句话版本
- ufw 过滤 INPUT 链,容器流量走 FORWARD,两者不相交。
ufw deny 8000对容器完全无效,而且它会告诉你规则加上了。 - 正确的位置是
DOCKER-USER链,策略必须是默认拒绝,端口必须用--ctorigdstport匹配而不是--dport。用错了会以一种「看起来生效了」的方式失败。 - 这个工具强依赖于你那台机器长什么样,做不成通用脚本。所以我开源的是让 agent 造出它的那份规格,而不是我这台机器上的产物。
第一部分:ufw 说 3 个端口,实际 12 个
起因很平常。磁盘满了,几个容器该关了,我顺手让 agent 看看配置有没有问题。半小时后我盯着终端,意识到我的 Coolify 管理面板在公网上裸奔了不知道多久。
而 ufw 全程 active,并且告诉我一切正常。
防火墙在骗你
这是那台机器上 ufw status 的输出,白名单条目我省掉了:
$ ufw status
Status: active
22/tcp ALLOW Anywhere
80/tcp ALLOW Anywhere
443/tcp ALLOW Anywhere
三个端口。很安心。这是同一台机器上,从公网实际能连通的东西:
3000 gogs-app Git 服务,Web UI
6001 coolify-realtime websocket
6002 coolify-realtime websocket
8000 coolify ← 管理面板。部署、密钥、shell
8080 traefik dashboard
8081 probe-china
8082 telegram-bot
8083 probe-foreign
8089 ipgeo-nginx
10022 gogs-app Git over SSH
多出来九个。ufw 一个都没提。这不是我配错了,也不是哪个版本的 bug——这是 Docker 和 ufw 共存时的默认行为。
为什么 ufw 看不见容器
ufw 本质上是 iptables 的前端,它把规则写进 INPUT 链。INPUT 管的是「目的地是本机的包」。
而发往容器的流量,目的地不是本机——是容器的私有 IP。它属于转发,走 FORWARD 链。ufw 从来不碰这条链。
你 ──▶ 宿主机进程 ──▶ PREROUTING ──▶ INPUT ──▶ sshd 等
▲
└── ufw 在这里
你 ──▶ 容器 ──▶ PREROUTING ──▶ FORWARD ──▶ DOCKER ──▶ 容器
▲
└── ufw 不在这里
更进一步,Docker 根本不通过 ufw,它直接往 nat/PREROUTING 里写 DNAT 规则,往 filter/DOCKER 里写 ACCEPT。你 docker run -p 8000:8000 的那一刻,这个端口就对全世界开放了,ufw 的配置里不会多出任何一行。
所以 ufw deny 8000 对容器完全无效。你会看到规则加上了,ufw status 里明明白白写着 DENY,然后端口该通还是通。这个问题 2014 年就报到上游了(moby/moby#4737),十一年过去,issue 还开着。
坑一:我的检测工具在撒谎
第一件事当然是搞清楚到底哪些端口能通。我从本地跑了一轮 nc -z 扫描。
结果:22 个端口全部 open。包括 9093、18080、23000——这几个我已经确认过是 127.0.0.1 绑定的,物理上不可能从公网连上。
原因是本地的代理拦截了这些连接。TCP 握手是跟代理完成的,不是跟服务器。同样的机制还让 DNS 返回了 198.18.x.x 的 fake-ip。
教训很直接:裸的 TCP connect 不能作为端口可达性的证据。中间任何一环——代理、透明网关、运营商的 TCP 劫持——都能给你一个假的成功握手。后面所有验证我都改成了协议层检查:拿 HTTP 状态码,或者读 SSH banner。要么服务器真的回了应用层的东西,要么就不算通。
还有一个更隐蔽的版本:你自己的出口 IP 很可能已经在白名单里了。你从自己电脑测「封住了没有」,测出来是「没封住」也可能是假的。得先确认自己的公网 IP 在不在允许列表里,或者临时把白名单清空再测。
正确的位置:DOCKER-USER
既然 ufw 够不着 FORWARD,就得直接在 FORWARD 上做文章。但你不能随便往 FORWARD 里塞规则——Docker 会在容器启停、网络变化时重建自己的链,你的规则会被冲掉。
Docker 为此留了一个钩子:DOCKER-USER。这条链在 FORWARD 里,位置在 Docker 自己的 ACCEPT 规则之前,而且 Docker 承诺永远不清空它。这是唯一可靠的过滤点。
顺带一个让人放松的事实:DOCKER-USER 里的规则不可能把你 SSH 锁在门外。sshd 是宿主机进程,走 INPUT;DOCKER-USER 在 FORWARD。两条路完全不相交。知道这一点之后,可以非常大胆地做实验——最坏情况是网站挂掉,但你一定还能登进去修。
坑二:--dport 匹配不到你以为的端口
这是整件事里最值得写下来的一个坑。第一版规则很直观,把要封的端口列出来:
iptables -A DOCKER-USER -i ens160 -p tcp \
-m multiport --dports 3000,6001,6002,8000,8080,8081,8082,8083,8089 -j DROP
应用,从外面测。九个端口里,八个成功被封。第九个——8089——照样返回 HTTP 200。
如果这时候图省事,只会想「8089 有点怪,单独再加条规则」。但一条规则在同一个链里对八个端口生效、对第九个不生效,这本身就说明我对它的理解是错的。停下来查。
原因在链的执行顺序里:DNAT 发生在 PREROUTING,早于 FORWARD。包到达 DOCKER-USER 的时候,目的端口已经被改写成容器侧的端口了。
宿主机 8089 ──DNAT──▶ 容器 10.0.2.2:80
▲
--dport 8089 在这里匹配不到 ┘
那另外八个为什么成了?我去查了 iptables -t nat -L DOCKER -n,答案让人后背发凉:它们的容器侧端口恰好都是 8080,而 8080 正好在我的封禁列表里。八个端口是被「8080」这一条误伤封掉的,跟我写的 3000、8000、8081 那些数字毫无关系。
换句话说,我的规则从一开始就是错的,只是靠巧合看起来对了 89%。
而如果我当时按「8089 单独处理」的思路走下去,最自然的修法是把它的容器端口 80 加进列表。那会匹配到每一个监听 80 的容器——包括 traefik。整站瞬间下线。一个看起来只是补漏的改动,实际是全站故障。
正确写法是用 conntrack 拿 DNAT 之前的原始目的端口:
-m conntrack --ctorigdstport 8089
它匹配的是连接跟踪记录里的原始目的端口,也就是你在 -p 8089:80 里写的那个 8089。既正确,又正好是运维脑子里真正在想的那个数字。额外好处是不依赖容器 IP——容器重建之后 IP 会变,基于容器 IP 的规则会失效,基于原始端口的不会。
坑三:黑名单保护不了明天的容器
端口封住了,验证也通过了。然后我问了自己一个问题:那我下次 docker run -p 一个新服务呢?
$ docker run -d -p 9999:80 nginx
$ curl http://服务器:9999/ # 从公网
HTTP/1.1 200 OK
裸奔。当然会裸奔——我写的是一份枚举出来的黑名单,9999 不在里面。
这才是真正的问题所在。黑名单意味着安全性依赖于「每次开新容器你都记得回来改防火墙」。三个月后你半夜起一个临时服务调试,绝不会记得这回事。
所以规则整个反过来了,改成默认拒绝:
iptables -F DOCKER-USER
# 1. 已建立的连接直接放行
iptables -A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
# 2. 白名单 IP 放行
for ip in $TRUSTED; do
iptables -A DOCKER-USER -i "$EXT_IF" -s "$ip" -j RETURN
done
# 3. 明确声明要公开的端口放行
for p in $PUBLIC_TCP; do
iptables -A DOCKER-USER -i "$EXT_IF" -p tcp -m conntrack --ctorigdstport "$p" -j RETURN
done
# 4. 外网口进来的其他一切,丢弃 ← 关键的一条
iptables -A DOCKER-USER -i "$EXT_IF" -j DROP
iptables -A DOCKER-USER -j RETURN
第 4 条是整件事的意义所在。它把「新容器默认暴露」变成了「新容器默认安全」。再测一次:
$ docker run -d -p 9876:80 nginx # 全新容器,什么都没配
$ curl http://服务器:9876/ # 从公网
(无响应) # ← 已经是安全的
$ fw allow 9876
$ curl http://服务器:9876/
HTTP/1.1 200 OK # ← 你说开才开
持久化用 systemd oneshot,关键是 PartOf=docker.service——这样不只是重启后生效,systemctl restart docker 之后也会自动重新应用。Docker 重启会重建自己的链,只写 After= 是不够的。
真正的长期问题:两个防火墙
规则都对了,验证也都过了。但用了两天我发现真正难受的地方不是规则本身,是我现在有两套防火墙,每次要动一个端口,得先在脑子里回答一个问题:这个端口归谁管?
9100 是 node_exporter,宿主机进程,归 ufw。8000 是 Coolify,容器,归 DOCKER-USER。8388 是 shadowsocks,宿主机进程,归 ufw。这个分类没有任何直觉可言,每次都得去查 ss -tulnp 才敢下手。加白名单更糟——两边各加一遍,忘了一边就是静默的不一致,而且没有任何东西会提醒你。
所以最后交付的不是一份 iptables 规则,是一个 CLI,把这个认知负担整个消掉:
$ fw check
Port Proto Kind Owner Governed by Public
22 tcp host sshd ufw EXPOSED
80 tcp container traefik docker EXPOSED
443 tcp container traefik docker EXPOSED
3000 tcp container gogs-app docker trusted IPs only
8000 tcp container coolify docker trusted IPs only
8388 tcp host shadowsocks ufw EXPOSED
9100 tcp host node_exporter ufw trusted IPs only
23000 tcp container claude-code-hub loopback localhost only
宿主机和容器在同一张表里,每一行标明归哪一层管、公网到底能不能碰到。写操作自己判断该改哪边:
fw allow <port> 开放到公网(自动判断层)
fw deny <port> 关闭
fw trust <ip> 加白名单 —— 两层同时写
fw sync 对齐两层的白名单
fw status 两层概览,不一致时告警
fw status 会主动检测漂移。我故意只往 ufw 加了一个 IP,它立刻报出来:
198.51.100.77 ufw layer only
做这张表的时候还踩了几个小的:IPv4/IPv6 双栈监听会出两行,得按 (协议, 端口) 聚合;-p 6001-6002:6001-6002 这种范围发布不展开的话表里就少一个端口——一个会漏报的清单工具比没有更危险;客户端侧的临时 UDP socket 会把表刷满,得过滤掉。
顺手捞到的几件事
- 6001/6002 我一度不敢关,怕 websocket 断。读了 traefik 的 dynamic config 才确认它已经通过
Host(...) && PathPrefix(/app)走反代了,直接发布的端口纯属冗余。关端口之前先查反代路由,很多端口是白开的。 - traefik 的 8080 dashboard 端口根本没服务——它是用
--api.insecure=false启动的,本地 curl 直接 000。一个发布出去但后端没监听的端口,纯粹是攻击面。 - 装 fail2ban 之后 8 秒内封了 5 个 IP。查历史日志,头号选手已经试了 9471 次。好消息是成功登录记录里只有我自己的 IP 和内部地址,没被打穿。
- 有个容器一直 unhealthy,我差点当成「服务挂了」。实际上它返回 200 完全正常,是健康检查里写了
localhost,在容器里解析到 IPv6 的::1,而 nginx 只listen 80监听 IPv4。unhealthy 不等于服务挂了,先自己 curl 一下再下结论。
两条命令自检你的机器
ufw status # ufw 以为开了什么
iptables -t nat -L DOCKER -n # Docker 实际开了什么
第二条输出里每一行 DNAT ... dpt:XXXX 都是一个公网可达的端口。如果它没出现在第一条的输出里——那就是你的盲区。
第二部分:为什么我开源的是 prompt 而不是脚本
上面这套东西我整理成了一个仓库:1oca1h0st/ufw-docker-blindspot。
里面没有 install.sh,没有 curl | bash。主体是一个 PROMPT.md。
这不是偷懒,也不是赶时髦。是我认真想过之后认为,这个具体的东西本来就做不成一个到处能跑的脚本。
它做不成通用脚本
下面每一项,在不同机器上都不一样,而且每一项都会改变代码本身:
| 外网网卡名 | eth0 / ens160 / enp3s0 / 云厂商各种命名 |
| 宿主机防火墙 | ufw / firewalld / 裸 nftables / 没有 |
| iptables 后端 | legacy 还是 nft,语法和查看方式都不同 |
| 反向代理 | traefik / nginx / caddy / Cloudflare Tunnel / 没有 |
| 哪些端口能关 | 只有你自己知道 |
最后一行是关键。它不是一个技术问题,是一个只有本地才有答案的问题——而它恰恰决定了脚本该做什么。
传统解法有两条路。一条是给脚本配二十个开关的配置文件,本质是把复杂度原样转嫁给用户,而且用户得先理解这二十个开关才填得对——他要是理解了,也就不需要这个脚本了。另一条是让脚本自己猜,猜错了就是把你的站点关掉。
而且,如果我真把第一版打包成脚本发出去会怎样?回去看坑二:那一版在我自己机器上就是错的,只是靠容器端口恰好都是 8080 这个巧合,看起来对了 89%。它到别人机器上会以另一种方式错——而且没人会发现,因为它「看起来生效了」。
对一个安全工具来说,沉默的假阳性是最坏的失败模式。它比什么都不做更糟,因为它让你以为自己安全。
脚本做不到的三件事
一、先读机器,再动手。脚本执行的是作者写代码那一刻对世界的假设。agent 是先 ss -tulnp、先读 traefik 的 dynamic config、先看 cloudflared 日志里的 originService=,然后才决定写什么。我那台机器上 6001/6002 能安全关掉,是因为读了反代配置确认它已经被 PathPrefix(/app) 覆盖了——这个判断没有任何脚本能替我做。
二、从外部验证自己。脚本没法证明自己生效了。PROMPT.md 里写死了一个两阶段验证协议:先把白名单清空、从外部逐个端口确认真的不通,再恢复白名单确认可信 IP 恢复;然后起一个临时容器发布一个没用过的端口,证明它开箱就不通。「我改完了」和「我验证过了」是两件事,而后者恰好是 agent 能做、脚本做不到的。
三、报告自己的失败。prompt 的最后一条规则是:「你断言的每一件事都必须是你实测过的;没验证过就说没验证过。」8089 那个坑就是这么暴露出来的——如果验证环节只是走个过场,它会一直藏着。
prompt 里编码的不是代码,是判断
这是我觉得最有意思的一点:PROMPT.md 里几乎没有代码。它写的是这些东西——
- 不要相信任何单一信息源,
ss/iptables -t nat -L DOCKER/ufw status三个交叉验证 nc -z隔着代理会假阳性,改用 HTTP 状态码或 SSH banner- 注意操作者自己的出口 IP 可能已经在白名单里,会掩盖封禁失败
- 必须用
--ctorigdstport,以及为什么——DNAT 在 PREROUTING,早于 FORWARD - 不要做黑名单,做默认拒绝,因为黑名单保护不了明天的容器
- 关端口之前先查反代是不是已经覆盖了它
这些东西写进脚本会变成注释。而注释是会被「优化」掉的——下一个人看到 --ctorigdstport 觉得写得啰嗦,顺手改成 --dport,测一下八个端口都通过,提交。bug 就这么回来了。
在 prompt 里,这些判断不是注释,它们是可执行的部分。
换个说法:脚本开源的是我这台机器上的答案,prompt 开源的是我得出答案的过程。对这类工具,过程才是可迁移的那部分。
代价(这部分必须说清楚)
如果只讲好处,这就是一篇软文。这种发布方式有实打实的代价:
- 不可复现。同一段 prompt,不同模型、不同机器,产出不同代码。没有「pin 一个版本」这回事。
- 没法事前审计。你 clone 一个脚本,可以读完再决定跑不跑。prompt 的产物在你运行之前根本不存在。对一个要改防火墙规则的工具来说,这是真实风险,不是理论风险。
- 它可能很自信地做错。agent 会犯我犯过的错,也会犯我没犯过的。
- 门槛真实存在。你得有一个能 SSH 的 agent,并且愿意为它付费。这不是「clone 下来就能用」。
所以仓库里还放了 reference/:一台真实机器上跑出来的完整产物——规则脚本、systemd unit、那个 CLI 的全部代码。
它不是拿来直接装的(网卡名、白名单 IP、端口策略都属于另一台机器),是拿来对照的:让你知道「对」长什么样,好去 diff 你的 agent 写出来的东西。
以及一句我写在 README 里、也想写在这里的话:应用之前,先读一遍它写了什么。这是一段 prompt,不是一个承诺。
边界:什么不适合这么发
我不认为万物皆可 prompt。这种发布方式要成立,我觉得得同时满足四个条件:
- 强环境依赖——同一份逻辑在不同机器上必须长得不一样,否则你就该直接发脚本
- 需要判断,而不只是执行——存在「哪些端口能关」这种只有本地才知道答案的问题
- 产物可以被验证——能从外部客观测出来对不对。这条最关键,它是防止 agent 胡说八道的唯一保险
- 出错可恢复——DOCKER-USER 在 FORWARD 链,锁不掉 SSH,最坏是网站挂了但你还能登进去修
反过来,不适合的:库、协议实现、任何需要跨机器行为一致的东西、任何错一次就不可逆的操作。你不会想要一个每台机器上行为都不一样的 JSON 解析器,也不会想让 agent 现场发挥去写你的数据库迁移。
还有一个我自己也没想清楚的问题:脚本会因为无人维护而腐烂,prompt 会因为模型迭代而漂移。哪种更耐久,我现在给不出答案。三年后回头看,可能是脚本赢,也可能这篇文章本身已经过时了。
这不是「AI 写的代码」
最后澄清一个容易混淆的地方。
现在 GitHub 上有大量 AI 写的代码,那是用 AI 生产、然后按传统方式发布——交付物还是代码,只是作者换了。我说的是另一件事:交付物本身就是那份规格说明,代码在每个使用者那里各自生成一次。
另外,分享 prompt 本身一点都不新鲜,网上到处是 prompt 合集。我觉得有点不一样的是把它当成仓库的主交付物来对待:README 展示的是成品长什么样、跟现有方案的差异在哪、有哪几个命令,而不是「怎么安装」;然后配一份真实的 reference 输出,作为对照物,也作为诚实性的担保。
顺带一提,这个领域已经有一个成熟工具 chaifeng/ufw-docker,做得很好。如果你想要今天就能装上、经过大量验证的东西,用它。主要区别是它默认是允许列表(没列出来的端口保持开放),而这套是默认拒绝;以及它只管容器层,不解决「两个防火墙我该看哪个」的问题。
回到开头那三行输出。
它没有报错,没有警告,语气笃定,而且它说的每一个字都是真的——ufw 确实只在 INPUT 链上开了那三个端口。它只是没告诉你,它管的范围不等于你以为的范围。
工具也一样。一个在我机器上验证过的脚本,它说的每个字在我这台机器上也都是真的——但它不知道你的网卡叫什么,不知道你哪个端口关了会出事,更不会在改完之后从外面测一遍,再回来告诉你「好了」。
所以这次我没发那个脚本。
发表回复