iptables、nftables 与 legacy 表:一次封禁规则「消失」的排查
现代发行版里 iptables 命令指向的其实是 iptables-nft,规则被翻译进 nftables 表;fail2ban 的链则要到第一次封禁才创建。常有人说规则不见了,多半是观测后端选错,或链本就还没被创建。
现象
同一台主机上,几句话给出互相矛盾的说法。先是 fail2ban 自己的状态:
fail2ban-client status sshd
|- Filter
| |- Currently failed: 0
| `- Journal matches: _SYSTEMD_UNIT=sshd.service
`- Actions
|- Currently banned: 0
|- Total banned: 1
jail 本身一切正常,累计封禁计数是 1——这个机制确实工作过。但到内核里去找对应的链:
iptables -S INPUT | grep f2b # 无输出:INPUT 上没有 f2b-* 的跳转
nft list chain ip filter f2b-sshd
# Error: No such file or directory
而在链确实存在(封禁生效中)的时候,按名字查同样可能得到另外两种说法:
iptables-legacy -S f2b-sshd
# iptables: No chain/target/match by that name.
iptables -S f2b-sshd # 当这条链是 nft 原生创建的时候
# iptables v1.8.7 (nf_tables): chain `f2b-sshd' in table `filter' is
# incompatible, use 'nft' tool.
「不存在」「用错工具」「工具不认识」——三种说法里只有一种是真故障。要分开它们,得先知道 iptables 这个命令背后站着谁。
背景:iptables 只是一个入口名
/usr/sbin/iptables 在现代发行版上是一串 alternatives 软链:
/usr/sbin/iptables -> /etc/alternatives/iptables -> /usr/sbin/iptables-nft
/usr/sbin/iptables-nft -> xtables-nft-multi
/usr/sbin/iptables-legacy -> xtables-legacy-multi
iptables -V # iptables v1.8.7 (nf_tables)
iptables-legacy -V # iptables v1.8.7 (legacy)
两个命令的版本号一模一样,差别只在括号里。iptables-nft 不碰旧的内核 iptables 模块,而是把你写的规则翻译成 nftables 表达式,写进 nft 的表里。所以真正的权威视图是 nft 那边:
nft list tables
# table ip filter
# table ip6 filter
# table ip nat
# table ip raw
iptables -S 读写的正是这些表。与此同时,legacy 后端可能也被加载了,于是系统里存在第二套彼此独立的表:
iptables-legacy -S
# -P INPUT ACCEPT
# -P FORWARD ACCEPT
# -P OUTPUT ACCEPT
只有三条默认链、零条规则——表存在但为空。这是 iptable_filter 模块被加载的痕迹,不参与任何过滤。两组表并存,是所有混乱的根:每条 iptables 命令都得先问清楚它操作的是哪一套。
三种「看不到」,只有一种是故障
| 情况 | 观测命令 | 典型输出 | 性质 |
|---|---|---|---|
| 后端选错 | iptables-legacy -S f2b-sshd |
iptables: No chain/target/match by that name. |
命令用错了 |
| 对象真不存在 | nft list chain ip filter f2b-sshd |
Error: No such file or directory |
尚未创建,见下一节 |
| 存在但形态不符 | iptables -S <base chain> |
chain ... is incompatible, use 'nft' tool. |
观测工具读不了 |
判断属于哪一种,靠的不是重试同一条命令,而是换一个正交的观测手段:nft 原生命令。它不经过 xtables 的翻译层,看到的就是内核里的真实对象。三条命令各自回答一个不同的问题:
| 命令 | 回答的问题 |
|---|---|
iptables -V |
我手上的工具是哪个后端 |
iptables-legacy -S |
旧后端那套表里有什么(本机:什么都没有) |
nft list ruleset |
内核里实际生效的是什么 |
把「工具报错」和「真实状态」当成两件事,这一步比记住任何一条命令都重要。工具只报它自己看到的世界:后端选错时它看不到链,形态不认识时它又说链非法——两种都可能在链明明正常工作时报出「有问题」的字样。
首行那句 Warning 不是线索
只要系统里存在另一套表,每次 iptables -S 都会打印:
# Warning: iptables-legacy tables present, use iptables-legacy to see them
它只说明「有另一套表存在」,不表示你的规则跑到 legacy 里去了。跟着它去翻 iptables-legacy -S,永远只会看到那三条空的默认链。把它当噪声,别当病因。
incompatible 报错:链在,只是不该用这个工具读
iptables v1.8.7 (nf_tables): chain `f2b-sshd' in table `filter' is incompatible, use 'nft' tool.
iptables-nft 只认识自己写进去的链,那些链带一层兼容元数据。nft 侧的链分两类,实测区别很干净:
nft add chain ip filter probe-reg # 普通自定义链
iptables -S probe-reg
# -N probe-reg ← 能读
nft add chain ip filter probe-base '{ type filter hook input priority 10 ; }'
iptables -S probe-base
# iptables v1.8.7 (nf_tables): chain `probe-base' in table `filter' is
# incompatible, use 'nft' tool.
iptables -L probe-base
# 同样的报错
nft delete chain ip filter probe-base probe-reg # 清理
差别在是不是 base chain(带 hook)。普通链的模型两边一致,前端能翻译;base chain 有自己的 hook 与 priority 语义,xtables 的模型表达不了,前端就干脆拒读。所以这条消息的信息量是:对象存在,而且大概率是被 nft 原生命令创建或改写过的。它既不是规则丢失,也不是权限问题。
遇到它就换工具,-a 会带上 handle:
nft -a list chains
nft list chain ip filter f2b-sshd
nft list ruleset | grep -n f2b
handle 不只是给眼睛看的——当前端拒绝操作某个链时,nft delete rule ip filter <链> handle <N> 是绕开它、直接删掉那条规则的退路。
怎么判断一条规则是谁写进去的
nft 输出里,凡是由 iptables 前端写入的规则都自动带一个 counter:
counter packets 82654 bytes 58210977 jump ufw-before-input
nft 原生写的规则只有在显式写 counter 时才带。这是个粗糙但实用的指纹——它来自前端的实现选择,不是文档承诺的语义,换版本可能变,所以只能当线索。再配合链名的来源(ufw-*、f2b-*、DOCKER-* 各出自一个具体工具)和 handle 号,基本能还原出每条规则是谁写的。
备份与恢复最容易写错地方
iptables-save 和 iptables-legacy-save 是两个命令,各导一套表。审计时只跑前者,拿到的只是 nft 那套;万一 legacy 那边真有人手工加过规则,这部分永远不出现在备份里。
反向的坑更隐蔽:从 legacy 导出,再用 iptables-restore(nft 后端)恢复。两边的规则语法兼容,恢复过程没有任何报错,规则却全部落进了另一套表。看起来「已恢复」,实际不生效——和开头的「规则看不见」是同一类现象,只是方向相反。所以改完规则后的验收标准只有一句:换一个工具再看一眼,写入用一个,复核用另一个。
第二个原因:actionstart 是懒执行的
即便后端选对了,开机后看不到 f2b-sshd 也可能本来就是正常的。看 /etc/fail2ban/action.d/iptables-multiport.conf 里的定义:
# Notes.: command executed on demand at the first ban
# (or at the start of Fail2Ban if actionstart_on_demand is set to false).
actionstart = <iptables> -N f2b-<name>
<iptables> -A f2b-<name> -j <returntype>
<iptables> -I <chain> -p <protocol> -m multiport --dports <port> -j f2b-<name>
链、RETURN 兜底、以及 INPUT 上的跳转,全都在 actionstart 里,而它默认「第一次真正要封人时才执行」。每次操作前跑的那条自检是:
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
自检失败就自动补一次 actionstart。也就是说:开机后没人攻击,链就不存在;来第一个坏人,链和跳转被当场建出来。这是设计,不是故障。
actionstart_on_demand 在 action 文件里通常不写死。fail2ban 未显式设置时的判定规则是「该 action 是否含按 family 分段的条件配置」——iptables 系列经 iptables-common.conf 引入了 [Init?family=inet6] 段,于是这个开关自动取真。想让链在启动时就建好,可以显式设成 false;但那样每次启动都会先写一遍规则,懒加载本身没什么值得修的地方。
验证方式(先记录现状,做完还原):
systemctl restart fail2ban && sleep 3
nft list chain ip filter f2b-sshd
# Error: No such file or directory ← 重启后链确实不在
fail2ban-client set sshd banip 198.51.100.7 # 人为触发一次封禁
iptables -S INPUT | grep f2b
# -A INPUT -p tcp -m multiport --dports <端口> -j f2b-sshd
nft -a list chain ip filter f2b-sshd
# table ip filter {
# chain f2b-sshd { # handle 180
# ip saddr 198.51.100.7 counter packets 0 bytes 0 reject # handle 183
# counter packets 0 bytes 0 return # handle 181
# }
# }
fail2ban-client set sshd unbanip 198.51.100.7 # 还原
一次实验同时确认三件事:
- 链、跳转、
RETURN兜底都是首次封禁时出现的,说明之前的「不存在」不是故障; - 封禁在 nft 里落地为
reject(来自blocktype = REJECT --reject-with icmp-port-unreachable),不是drop——找规则时按reject搜才搜得到; - unban 只删掉那条
reject,链本身留着,直到 jail 停止或被 flush。所以「链在、但没有那个 IP」是正常的中间态。
--dports <端口> 取自该 jail 的 port 设置。jail 里写的端口和守护进程实际监听的端口不一致时,链看起来一切正常,封禁却一条也命中不了——改了服务端口就要同步改 jail,这是同一类「规则在位但不起作用」的另一种形态。
操作要点
- 先确认后端:
iptables -V括号里是nf_tables,就把nft list ruleset/nft -a list chains作为权威观测手段;iptables前端只当作写入别名。 iptables-legacy -S只有三条空默认链、以及首行那句 Warning,都属正常,不必处理。- 按报错分流:
No chain/target/match by that name是查错后端;incompatible, use 'nft' tool是对象在、工具不对;Error: No such file or directory才是真的还没创建。 - 开机后看不到
f2b-*链,先fail2ban-client set <jail> banip <文档保留 IP>触发一次再看,能建出来即正常,随后unbanip还原。 - 判断封禁是否真的生效,看链上的规则与计数器,不要只看
fail2ban-client status的 banned 计数——那是软件层的判罚记录,不是内核里的实际动作。两者会脱钩(链根本不存在时,banned 计数照样能是 1)。
相关:/posts/docker-bypasses-ufw.html 里的链顺序问题。