Chips

UFW 管不到 Docker 发布的端口——原理与补法

· 约 5 分钟 · 1675 字

容器发布端口走的是 FORWARD 而不是 INPUT,所以 ufw 的 deny incoming 对它完全无效。给出 DOCKER-USER 守卫的写法、验证因果的实验方法,以及三个已经踩过的坑。

现象

ufw status 显示 deny (incoming),但容器用 ports: 发布的端口从公网照样能连上。看起来防火墙开着,实际上守的是一张别的门。

原理:DNAT 发生在 INPUT 之前

外部连接到达 eth0
   │
   ├─ nat/PREROUTING ── DNAT 改写目的地址为容器 IP:端口
   │                          │
   │                          ▼
   └────────────────────► FORWARD 链 ──► DOCKER-USER ──► DOCKER-FORWARD ──► ACCEPT
                                                      (全程不经过 INPUT)

目的地址被改写之后,这个包对内核来说就是"转发给另一个地址"的包,走 FORWARD。而 UFW 的入站过滤全部挂在 INPUT 上。两者根本不在同一条路径上。

所以单开 UFW 会给人虚假的安全感——它保护的是宿主自己监听的服务,不包括任何容器发布的端口。

补法:在 FORWARD 最早的一跳上守卫

Docker 保留了 DOCKER-USER 链作为用户自定义入口,它在 Docker 自己的规则之前被求值:

# 白名单:私网与回环
-A DOCKER-USER -m conntrack --ctstate NEW -s 10.0.0.0/8     -j RETURN
-A DOCKER-USER -m conntrack --ctstate NEW -s 172.16.0.0/12  -j RETURN
-A DOCKER-USER -m conntrack --ctstate NEW -s 192.168.0.0/16 -j RETURN
-A DOCKER-USER -m conntrack --ctstate NEW -s 127.0.0.0/8    -j RETURN
# 兜底:其余来源的新连接一律丢弃
-A DOCKER-USER -m conntrack --ctstate NEW -j DROP

三个设计点:

为什么按来源而不是按端口。 按端口写就得在每次新增服务时回来改规则,漏一次就是一个敞口。按来源写是"只有私网能进容器",与暴露了哪些端口无关——新增服务天然安全。

为什么必须带 --ctstate NEW 容器出网请求的回程包是 ESTABLISHED。不带这个条件,容器自己就再也连不回来了。只要新连接受控,已经建立的连接不受影响。

为什么必须放行回环。 宿主上用 localhost 访问已发布端口时,源地址是 127.0.0.1,而这个包经 DNAT 之后同样走 FORWARD。不放行的话,本机调试会莫名其妙失败,而公网行为反而"正常"——这种反差很容易带偏排查方向。

验证:要做出因果对照,不是只看现象

"公网连不上"这个现象本身不能证明是这条规则起的作用——可能是别的东西挡的。做一个能证否的实验:

# 用 network namespace + veth 伪造一个公网来源,源地址取文档保留段
ip netns add probe
ip link add veth-p type veth peer name veth-h
ip link set veth-p netns probe
ip addr add 203.0.113.1/24 dev veth-h && ip link set veth-h up
ip netns exec probe ip addr add 203.0.113.5/24 dev veth-p
ip netns exec probe ip link set veth-p up
ip netns exec probe ip route add default via 203.0.113.1

# 对照组 1:宿主经公网 IP 访问已发布端口 → 期望放行
# 对照组 2:公网来源访问同一端口      → 期望阻断,且 DROP 计数 +1
iptables -L DOCKER-USER -n -v | grep DROP

# 对照组 3:移除守卫后,同一来源重试 → 期望放行

第三组是关键的因果证明:不是别的原因挡住的,就是这条守卫。计数器的增量则是第二重证据——拦截真的发生在这个链上。

三个已经踩过的坑

1. ufw enable ≠ 开机自启

ufw enable 输出的那句 "enabled on system startup" 只是把 ENABLED=yes 写进 /etc/ufw/ufw.conf。systemd 的 ufw.service 可能是 disabled 的,重启后规则不会应用。

systemctl is-enabled ufw        # 要确认这里是 enabled

此时 ufw status 仍会显示 active(因为规则已经加载在内核里了),所以这个状态无法用来判断开机时会不会生效。要判断得看单元状态,或者更彻底一点——重启一次看规则还在不在。后者才是最终证据。

2. dockerd 重启会清空 DOCKER-USER

Docker 守护进程重启时会重建自己的一套链,DOCKER-USER 里的自定义内容随之消失。用一个跟随 docker 的单元来兜住:

[Unit]
Description=Restore DOCKER-USER ingress guard
After=docker.service
PartOf=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/docker-ufw-guard.sh

PartOf=docker.service 让这个单元在 docker 重启时跟着重跑。验证方式是直接重启 docker 再看规则是否回来——不要假设。

3. 守卫脚本必须幂等且静默

脚本会被 systemd 和定时看护任务反复调用。用 iptables -C 先判断再 -A,避免规则堆积。

一个容易漏的细节:iptables -C 在某些版本会把规则文本写到 stdout。如果这个脚本的约定是"正常时无输出"(供 cron 判断异常),就必须 >/dev/null 2>&1,否则每次正常调用都会产生输出,看护逻辑要么误报要么被噪声淹没。

fail2ban 与 UFW 的链顺序

这条容易静默失效,单独说:

fail2ban 用 iptables -If2b-sshd 插到 INPUT 的第 1 位。如果 UFW 的链排在前面并已经 ACCEPT 了该端口,被封的 IP 就永远走不到封禁链——封禁看起来在生效(fail2ban-client status 里 ban 列表有它),实际完全没用

本机实测的顺序是对的:

1  f2b-sshd                  ← fail2ban,先判封禁
2  ufw-before-logging-input
3  ufw-before-input
...

原因是 ufw.servicenetwork-pre.target 之前运行,早于 fail2ban;而 UFW 的跳转规则是追加-A)而不是插入,所以后启动的 fail2ban 反而占据了第 1 位。这个顺序不是设计出来的,是启动顺序的副产品——所以它也会因为启动顺序变化而失效。检查方法:

iptables -S INPUT | head -3

顺序不对时 systemctl restart fail2ban 可以恢复。

顺带清掉的一类死规则

raw PREROUTING 里曾有若干形如下的规则:

iifname != "br-xxxxxxxxxxxx" ip daddr 172.18.0.2 counter packets 0 bytes 0 drop

双重问题:写死了会被重新分配的容器 IP(容器重建后就对不上号),而且匹配不到任何流量——raw 表在 conntrack 之前求值,那时回程包的目的地址还是宿主公网 IP,DNAT 尚未发生。实测计数器恒为 0。

这类规则最消耗时间的地方在于:它看起来在做一件正确的事,注释也写得很有道理。判断它是否真的在工作,只能靠计数器。

小结

负责 实现位置
UFW 宿主自身监听的服务的入站过滤 INPUT
DOCKER-USER 守卫 容器流量的入站过滤 FORWARD 最早一跳
fail2ban 来源封禁(必须排在 UFW 之前) INPUT 第 1 位

三样都要有,且都要确认开机后仍然存在。防火墙配置的失效方式很安静:状态命令说 active,功能却已经不在。

← 全部文章