·约 17 分钟阅读·DeepSeek

用 nftables 做 IPv6 防火墙:一张 inet 表同时管两个协议族

一次真机 IPv6 防火墙实验:用一张 nftables inet 表同时给 IPv4 和 IPv6 设防,讲清 IPv6 离不开的 ICMPv6/NDP 规则、链路本地与多播放行、默认拒绝的 input 链,附真实放行与拦截的测试输出、计数器,以及和 ip6tables 的对比。

networkingsecurity

我跑的每一台机器现在都是双栈,而 IPv6 上线的那一周教会了我最不舒服的那一课: IPv6 防火墙并不是”地址更长的 IPv4 防火墙”。没有 NAT 可以躲在后面,ICMPv6 是承重墙而不是可选噪音,而你继承下来的那套工具——一边 iptables、一边 ip6tables——正是 nftables inet 族存在的意义所要消灭的重复。所以这篇文章只讲 这件事里 IPv6 专属的那一片:一个装在单张 inet 表里的真实默认拒绝 input 策略, 用真实的放行与拦截流量测试过,外加那些 IPv6 一旦缺了就彻底罢工的 ICMPv6 规则。 我不会复述 nftables 的通用模型——那在我的 nftables 实战 里;SLAAC 和 /64 我也假设你已经从这篇 IPv6 实操入门 里知道了。下面的实验是私有 IPv6 网络上的两个一次性容器,只给 NET_ADMIN 能力(不用 --privileged),全是我自己的机器,跑完即删。

为什么 IPv6 防火墙是另一份工作

有四个差异比其余全部加起来都重要:

  • 没有 NAT。 每台主机都持有全局可路由地址,所以从 v6 地址被分配的那一刻起, 这台机器就可能被整个互联网触达——防火墙不再是可选项,而是唯一的边界。IPv4 那种”因为地址稀缺而意外隐蔽”的红利没有了,你的 input 策略现在是一个真实的安全 决策,不是形式主义。
  • ICMPv6 是强制的,不是友好的。 邻居发现(类型 133–136)替代 ARP;路径 MTU 发现依赖 “Packet Too Big”(类型 2)。成片丢弃 ICMPv6,协议栈就无法解析邻居、 无法发现 MTU。IPv4 时代”ping 也封掉”的习惯,在 v6 上就是自找的故障。
  • 链路本地与多播是一等流量。 fe80::/10 是 NDP、路由器公告(RA)和 DHCPv6 说话的地方;ff02::/16 多播是 NDP 的被请求节点组和全节点组。一份只按全局单播 地址推演的规则集,过滤的是这套协议的错的那一半。
  • ULA 不是”像 RFC1918 那样的私网”。 fd00::/8 唯一本地地址给你的是可路由、 默认没有任何过滤的实验室空间,但那是你的地址,不是 RFC1918 的假装私密。任何 拿着 2000::/3 全局地址的主机,在它的前缀被路由的那一刻就在公网上。
一张 nftables inet 表用同一条链过滤 IPv4 和 IPv6 一个 IPv4 包和一个 IPv6 包进入同一张 inet 表的 input 链,链上的规则放行两个协议族的服务端口,并按策略丢弃其余流量。 IPv4 包 172.17.0.10 → .9:8443 IPv6 包 fd00:ab:ce::3 → ::2:8443 table inet fw chain input · type filter hook input · priority filter policy drop iifname "lo" accept ct state established,related accept ip6 nexthdr ipv6-icmp icmpv6 type { nd-*, packet-too-big } ip6 saddr fe80::/10 accept ip6 daddr ff02::/16 accept tcp dport 8443 accept ip6 saddr fd00:ab:ce::/64 tcp dport 2222 accept counter drop 本地套接字 *:8443(一个监听) 已丢弃 · 已计数 counter packets 12
一张 inet 表,一条挂在 filter 优先级的 input 链。同一条规则既看得见来自 172.17.0.10 的 IPv4 SYN,也看得见来自 fd00:ab:ce::3 的 IPv6 SYN——保护一台机器的规则集从此不必存在两份。在我的实验里,IPv4 与 IPv6 流量流过同一条链的同时,丢弃计数器最终涨到了 12 个包。

nftables 的 inet 族:一张表,两个协议族

nftables 里你要做的第一个决定是表的协议族。ip 只过滤 IPv4,ip6 只过滤 IPv6,而 inet 两个都过滤——一张表、一组链、一个 nft list ruleset 视图。整个 特性就这一句话:写在 inet 链里的 tcp dport 8443 accept 这一行,同时匹配来自 172.17.0.10 的 IPv4 SYN 和来自 fd00:ab:ce::3 的 IPv6 SYN。

有两个细节只在真实流量下才看得见,我想把它们演示出来而不是空口断言。第一,我 在文件里怎么写规则,和内核在 nft list ruleset 里怎么回显它们:

# 我在文件里写的:
meta nfproto ipv6 ip6 saddr fd00:ab:ce::/64 tcp dport 2222 accept
meta nfproto ipv6 icmpv6 type echo-request accept
# nft list ruleset 打印回来的:
	ip6 saddr fd00:ab:ce::/64 tcp dport 2222 accept
	icmpv6 type echo-request accept

地址和协议匹配本身就隐含了协议族,所以 nftables 把我多余的 meta nfproto ipv6 删掉了。它是被接受的合法语法,在一条规则确实没有其他协议族 线索时有用,但实战中 icmpv6 type … 和 ip6 saddr … 就是你想要的协议族限定。 第二,ICMPv6 的类型名是符号化的,而且可以直接去问 nftables:

$ nft describe icmpv6 type
pre-defined symbolic constants (in decimal):
	destination-unreachable       	                   1
	packet-too-big                	                   2
	time-exceeded                 	                   3
	parameter-problem             	                   4
	echo-request                  	                 128
	echo-reply                    	                 129
	nd-neighbor-solicit           	                 135
	nd-neighbor-advert            	                 136

我就是这样确认 133–136 是 NDP、2 是 PMTUD 的:用短名字,而不是用一年后会回来 读的规则集里的魔法数字。(完整列表还包括 MLD 和重定向/重编号类型。)

带齐 IPv6 必备项的默认拒绝规则集

下面是我实际加载的完整文件——一张 inet 表、input 上 policy drop,中间夹一小 批 v6 专属放行。实验里的地址是 ULA(fd00::/8),不涉及任何公网地址。

#!/usr/sbin/nft -f
# /root/fw6.nft —— 一张 inet 表:input 默认拒绝,同时覆盖 IPv4 和 IPv6。

flush ruleset

table inet fw {
    chain input {
        type filter hook input priority filter; policy drop;

        # 1. 回环永远只在本机说话
        iifname "lo" accept

        # 2. 我们自己发起的连接的回包,以及属于这些流的 ICMP 差错
        ct state established,related accept
        ct state invalid drop

        # 3a. 协议栈自己产生、也自己消费的 ICMPv6:
        #     NDP(133-136)和用于 PMTUD 的 Packet-Too-Big。
        ip6 nexthdr icmpv6 icmpv6 type {
            nd-router-solicit, nd-router-advert,
            nd-neighbor-solicit, nd-neighbor-advert,
            packet-too-big
        } accept

        # 3b. 邻居用链路本地地址和我们说话
        ip6 saddr fe80::/10 accept

        # 3c. NDP/MLD 把链路本地多播组当作目的地址
        ip6 daddr ff02::/16 accept

        # 4. 两个协议族的 ping 都放行,但限速,成本保持便宜
        ip  protocol icmp icmp type echo-request limit rate over 30/second counter drop
        ip  protocol icmp icmp type echo-request accept
        meta nfproto ipv6 icmpv6 type echo-request limit rate over 30/second counter drop
        meta nfproto ipv6 icmpv6 type echo-request accept

        # 5. 服务本身:一条规则,两个协议族
        tcp dport 8443 accept

        # 6. 一个只应存在于 v6 覆盖网络上的服务
        meta nfproto ipv6 ip6 saddr fd00:ab:ce::/64 tcp dport 2222 accept

        # 7. 其余全部丢弃,并且计数
        counter drop
    }

    chain forward {
        type filter hook forward priority filter; policy drop;
    }

    chain output {
        type filter hook output priority filter; policy accept;
    }
}
$ nft --check -f /root/fw6.nft   # 只查语法,不应用(无输出即通过)
$ nft -f /root/fw6.nft           # 原子加载
$ nft list ruleset               # 当前真正在跑的
table inet fw {
	chain input {
		type filter hook input priority filter; policy drop;
		iifname "lo" accept
		ct state established,related accept
		ct state invalid drop
		ip6 nexthdr ipv6-icmp icmpv6 type { packet-too-big, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
		ip6 saddr fe80::/10 accept
		ip6 daddr ff02::/16 accept
		ip protocol icmp icmp type echo-request limit rate over 30/second counter packets 0 bytes 0 drop
		ip protocol icmp icmp type echo-request accept
		icmpv6 type echo-request limit rate over 30/second counter packets 0 bytes 0 drop
		icmpv6 type echo-request accept
		tcp dport 8443 accept
		ip6 saddr fd00:ab:ce::/64 tcp dport 2222 accept
		counter packets 0 bytes 0 drop
	}

	chain forward {
		type filter hook forward priority filter; policy drop;
	}

	chain output {
		type filter hook output priority filter; policy accept;
	}
}

留意这些归一化:ip6 nexthdr icmpv6 变成了 ip6 nexthdr ipv6-icmp,两行 echo 也丢掉了多余的 meta nfproto ipv6。规则 4 里成对写法的门道在于:第一行只丢弃 超过限额的部分(健康的实验里它的计数器一直是零),第二行放行其余全部——这是 一种不必彻底封锁 ICMPv6 就能扛住 ping 洪泛的廉价办法。

实测:真实的放行与真实的拦截

实验阵容:srv6 是被测机器(eth0 上 fd00:ab:ce::2,eth1 上 172.17.0.9), cli6 是一个普通客户端(fd00:ab:ce::3、172.17.0.10)。一个监听同时服务两个 协议族——python 绑在 :: 上:

$ ss -lntp
State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
LISTEN 0      4096      127.0.0.11:37605      0.0.0.0:*
LISTEN 0      5                  *:8443             *:*    users:(("python3",pid=3558,fd=3))

现在上流量。两个协议族都通,走的是同一份规则:

$ ping6 -c 3 fd00:ab:ce::2                 # IPv6,放行
PING fd00:ab:ce::2(fd00:ab:ce::2) 56 data bytes
64 bytes from fd00:ab:ce::2: icmp_seq=1 ttl=64 time=0.104 ms
64 bytes from fd00:ab:ce::2: icmp_seq=2 ttl=64 time=0.075 ms
64 bytes from fd00:ab:ce::2: icmp_seq=3 ttl=64 time=0.056 ms

--- fd00:ab:ce::2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2058ms
rtt min/avg/max/mdev = 0.056/0.078/0.104/0.019 ms

$ ping -c 3 172.17.0.9                      # IPv4,同一份规则
PING 172.17.0.9 (172.17.0.9) 56(84) bytes of data.
64 bytes from 172.17.0.9: icmp_seq=1 ttl=64 time=0.115 ms
64 bytes from 172.17.0.9: icmp_seq=2 ttl=64 time=0.047 ms
64 bytes from 172.17.0.9: icmp_seq=3 ttl=64 time=0.062 ms

--- 172.17.0.9 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2026ms
rtt min/avg/max/mdev = 0.047/0.074/0.115/0.029 ms

服务规则覆盖两个协议族——注意 remote_ip:

$ curl -6 -m 5 -sS -o /dev/null -w 'HTTP %{http_code} via %{remote_ip}\n' http://[fd00:ab:ce::2]:8443/
HTTP 200 via fd00:ab:ce::2
$ curl -4 -m 5 -sS -o /dev/null -w 'HTTP %{http_code} via %{remote_ip}\n' http://172.17.0.9:8443/
HTTP 200 via 172.17.0.9

再来一个没有规则放行的端口,两个协议族都试——连接直接超时,因为 policy drop 是丢弃而不是拒绝:

$ sh -c 'curl -6 -m 4 -sS -o /dev/null -w "%{http_code} via %{remote_ip}\n" http://[fd00:ab:ce::2]:9999/; echo curl exit=$?'
curl: (28) Connection timed out after 4000 milliseconds
000 via
curl exit=28

证明丢包确实发生在防火墙里——最后的计数器就是策略丢弃:

$ nft list chain inet fw input
table inet fw {
	chain input {
		type filter hook input priority filter; policy drop;
		iifname "lo" accept
		ct state established,related accept
		ct state invalid drop
		ip6 nexthdr ipv6-icmp icmpv6 type { packet-too-big, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
		ip6 saddr fe80::/10 accept
		ip6 daddr ff02::/16 accept
		ip protocol icmp icmp type echo-request limit rate over 30/second counter packets 0 bytes 0 drop
		ip protocol icmp icmp type echo-request accept
		icmpv6 type echo-request limit rate over 30/second counter packets 0 bytes 0 drop
		icmpv6 type echo-request accept
		tcp dport 8443 accept
		ip6 saddr fd00:ab:ce::/64 tcp dport 2222 accept
		counter packets 8 bytes 560 drop
	}
}

8 个包、560 字节——被拦截连接那几次重传的 SYN,全部记在最后一条规则上。一个 诚实的备注:nft monitor trace 在我的容器里是沉默的(逐包追踪需要我的实验网络 命名空间没有授予的上下文),所以包级别的证据靠的是计数器;普通的 nft monitor (监视规则集变更)则工作正常——下面是我动态加一条规则时它的真实输出:

$ nft add rule inet fw input tcp dport 9443 accept        # 一个 shell 里
add rule inet fw input tcp dport 9443 accept              # nft monitor,另一个 shell
# new generation 13 by process 1207483 (nft)

链路本地流量正好验证 fe80::/10 那条放行——并且和入门文里说的一样,zone ID 是 必须的:

$ ping6 -I eth0 -c 2 fe80::fc29:25ff:fe16:9846%eth0      # 从 cli6 打到 srv6 的链路本地地址
ping6: Warning: source address might be selected on device other than: eth0
PING fe80::fc29:25ff:fe16:9846%eth0(fe80::fc29:25ff:fe16:9846%eth0) from :: eth0: 56 data bytes
64 bytes from fe80::fc29:25ff:fe16:9846%eth0: icmp_seq=1 ttl=64 time=0.084 ms
64 bytes from fe80::fc29:25ff:fe16:9846%eth0: icmp_seq=2 ttl=64 time=0.060 ms

--- fe80::fc29:25ff:fe16:9846%eth0 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1016ms
rtt min/avg/max/mdev = 0.060/0.072/0.084/0.012 ms

ICMPv6:搞坏了它,真实的样子

我故意犯一次那个经典错误,让你以后不必亲历:在 input 钩子上成片丢弃所有 ICMPv6,然后强制一次全新的邻居解析。

$ nft insert rule inet fw input ip6 nexthdr ipv6-icmp counter drop
$ ip -6 neigh flush dev eth0             # 忘掉已经学到的邻居信息
$ ping6 -c 2 fd00:ab:ce::3                # srv6 -> cli6:需要 NDP 来解析
PING fd00:ab:ce::3(fd00:ab:ce::3) 56 data bytes

--- fd00:ab:ce::3 ping statistics ---
2 packets transmitted, 0 received, 100% packet loss, time 1015ms

$ ping6 -c 2 fd00:ab:ce::2                # cli6 -> srv6:echo 本身被丢
--- fd00:ab:ce::2 ping statistics ---
2 packets transmitted, 0 received, +2 errors, 100% packet loss, time 1013ms

$ ping -c 2 172.17.0.9                     # 同一台机器的 IPv4:完全正常
2 packets transmitted, 2 received, 0% packet loss, time 1031ms
rtt min/avg/max/mdev = 0.059/0.074/0.089/0.015 ms

这就是全部的失败形态:主机 IPv4 连通性完好、自我感觉健康,而 IPv6 已经死了—— 邻居请求发得出去,回来的邻居公告在 input 钩子上被丢,ip -6 neigh 永远到不了 REACHABLE。计数器记录下这次错误的代价:

$ nft -a list chain inet fw input | head -6
table inet fw {
	chain input { # handle 1
		type filter hook input priority filter; policy drop;
		ip6 nexthdr ipv6-icmp counter packets 11 bytes 952 drop # handle 20
		iifname "lo" accept # handle 4

删掉那条坏规则,网络自己恢复——NDP 只是把没干完的活干完:

$ nft delete rule inet fw input handle 20
$ ip -6 neigh flush dev eth0
$ ping6 -c 2 fd00:ab:ce::2                  # 从 cli6
2 packets transmitted, 2 received, 0% packet loss, time 1043ms

$ ip -6 neigh show dev eth0
fd00:ab:ce::3 lladdr de:52:e7:55:19:1b DELAY

所以实操结论就是 RFC 4890 用更大嗓门说过的那句话:永远不要成片丢弃 ICMPv6。 NDP 类型(133–136)和 packet-too-big(2)必须放行;echo-request(128)可以 限速;echo-reply 和 destination-unreachable 最好交给 ct state established,related 带回,而前面那条规则已经覆盖了。

ip6tables 与现代发行版上的 nftables

“ip6tables 与 nftables”早已不是 2010 年的迁移故事。在 Debian 12 上,随系统 发行的 iptables 就是一个 nft 后端:

$ iptables --version
iptables v1.8.9 (nf_tables)

也就是说旧工作流照样能用——也照样要维护一份和 IPv4 规则分开的 IPv6 规则集:

$ ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT
$ ip6tables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
$ ip6tables -A INPUT -p ipv6-icmp -j ACCEPT
$ ip6tables -A INPUT -p tcp --dport 8443 -j ACCEPT
$ ip6tables -P INPUT DROP
$ ip6tables -L INPUT -n -v
Chain INPUT (policy DROP 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
    0     0 ACCEPT     6    --  *      *       ::/0                 ::/0                 tcp dpt:22
    0     0 ACCEPT     0    --  *      *       ::/0                 ::/0                 ctstate RELATED,ESTABLISHED
    0     0 ACCEPT    58    --  *      *       ::/0                 ::/0
    0     0 ACCEPT     6    --  *      *       ::/0                 ::/0                 tcp dpt:8443

两套视图,一个内核:nft list ruleset 把我的 inet 表和它写下的 legacy ip6 filter 表并排列出,还附带一句警告:

table inet fw {
	chain input {
		type filter hook input priority filter; policy drop;
		# ……就是上面那份规则集,未改动
	}
	# 这里省略 forward/output 链
}
table ip6 filter {
	chain INPUT {
		type filter hook input priority filter; policy drop;
		tcp dport 22 counter packets 0 bytes 0 accept
		ct state related,established counter packets 0 bytes 0 accept
		meta l4proto ipv6-icmp counter packets 0 bytes 0 accept
		tcp dport 8443 counter packets 0 bytes 0 accept
	}
}
# Warning: table ip6 filter is managed by iptables-nft, do not touch!

注意这行输出里的堆叠陷阱:两条链都挂在同一优先级的 input 上,所以它们同时 生效——legacy ip6 表的策略丢弃是我这条 inet 链底下的第二张网(它当时还在为 我的表早已放行的 ICMPv6 计数)。混用两套工具意味着你要同时推理两份互相重叠的 规则集;只写一张 inet 表则意味着只有一个文件要读。如果迁移后你还留着任何 legacy 规则,先用 nft list tables 确认还有哪些协议族的表存在,再相信一次 curl。

最小可用规则集

把这套东西搬到真机上只是替换,不是重设计:换成你真实的接口(该用 iifname "eth0" 就用)、真实的服务端口、真实的地址策略——全局 2000::/3 来源 或某个 ULA 范围,要有意放行,而不是无意中放行。持久化方面和 nftables 那篇一 样:/etc/nftables.conf 挂在 nftables.service 后面,每次加载前先 nft --check,SSH 上改防火墙之前先架一个回滚定时器。

唯一我想纹在脑子里的:永远不要成片丢弃 ICMPv6。 无条件放行 NDP 和 packet-too-big,放行链路本地 fe80::/10 来源与 ff02::/16 多播目的,echo 限速,让 ct state established,related 把差错报文带回来。在这之上的一切—— 默认拒绝、计数器、一张 inet 表管两个协议族——相比之下都平淡无奇,而这正是重 点所在。