仅使用Nftables实现Fullcone NAT的方法 🔗
本文通过详细解析Nftables的理念和语法,展示了如何利用Nftables实现Fullcone NAT。 希望本文能够帮助读者更好地理解和应用Nftables
如果只关注如何在openwrt上实现fullcone,可以转最后,直接查看openwrt下仅使用nft配置fullcone的配置文件。
引言 🔗
在探究iptables到Nftables这段历史时,我了解到了Nftables设计时受到了BPF的启发。 Nftables仅实现了6个Hook点位,但每个点位都挂载了一个可编程的虚拟机。 用户空间程序通过libnftnl实现编译整个ruleset为虚拟机字节码,并挂载到对应的Hook点位上,完成对nft下的防火墙配置。 而iptables是基于数组的固定流程的,每个可配置点都有固定的代码,读取对应的规则,执行相应的操作。 灵活性上完全比不上Nftables。
iptables到Nftables的升级,就好似图形学中固定管线渲染到非固定管线的升级。 iptables的使用是在每个可配置的地方填写配置。 而Nftable的使用是编写一个ruleset形式表示的程序,并编译为虚拟机传入内核空间运行。 虽然表现形式上相似,并且Nftables也完全兼容iptables,但是Nftables的本质完全不同,(理论上)灵活性更强。
下面是一条nft命令,用来检测,如果发往192.168.1.222的包,协议是tcp,目标端口是17900,则转发到21400。
nft --debug=netlink add rule inet thetable testchain meta l4proto tcp ip daddr { 192.168.1.222 } tcp dport 17900 redirect to 21400
inet thetable testchain
[ meta load l4proto => reg 1 ]
[ cmp eq reg 1 0x00000006 ]
[ meta load nfproto => reg 1 ]
[ cmp eq reg 1 0x00000002 ]
[ payload load 4b @ network header + 16 => reg 1 ]
[ cmp eq reg 1 0xde01a8c0 ]
[ payload load 2b @ transport header + 2 => reg 1 ]
[ cmp eq reg 1 0x0000ec45 ]
[ immediate reg 1 0x00009853 ]
[ redir proto_min reg 1 flags 0x2 ]
- –debug==netlink: 开启Debug开关,显示出规则编译成的虚拟机的字节码。可以把它当作汇编来看。
- nft add rule: 添加一条规则,添加到chain的最后。
- inet thetable testchain: inet thetable指定了一个table, testchain指定了一个chain。后面开始是规则。
- nft的每一条规则的格式都是一条语句(可以想象成函数式编程语言的语句),遵从 Matches Statements 的格式,可以省略其中一个,也可以连续写多个Matches和Statements,两者之间也没有分隔符,所以不熟悉的情况下读起来可能会摸不着头脑。但只要分清楚语句的结构,就好理解了。
- meta l4proto tcp: 一个Match, 匹配传输层协议为tcp。
- ip daddr { 192.168.1.222 }: 一个Match, 匹配目标地址为192.168.1.222。
- tcp dport 17900: 一个Match, 匹配目标端口为17900。
- redirect to 21400: 一个Statement, 表示转发到21400。
而debug打印出来的字节码指令也与上面的内容解析一一对应,可以看到Nftables的虚拟机是基于寄存器的。
Nftables的可编程能力 🔗
由此可见,Nftables不仅完全兼容iptables,还具有类似eBPF的高度可定制潜力。 然而遗憾的是,这些目前基本都停留在“潜力”层面。 Nftables虽然底层实现给防火墙配置留下了无限可能,但是它的一整套工具链都是为了兼容iptables而服务的。 Nftables依然是table chain rule三级规则组成ruleset进行配置的。 除此之外,Nftables没有一个像eBPF那样活跃的社区,如果需要高度定制化可编程的防火墙,它与eBPF是处于竞争关系的。 nftables唯一的优势是它兼容iptables、ebtables、arptables,并且有开箱即用并久经考验的conntrack模块。
仔细翻阅Nftalbes的官方文档与手册,可以分析到几点nft的可编程能力。
跳转与控制 🔗
nft提供了下面两个跳转语句(Statement):
- jump
<chain>
: Continue at the first rule of<chain>
. It will continue at the next rule after a return statement is issued - goto
<chain>
: Similar to jump, but after the new chain the evaluation will continue at the last chain instead of the one containing the goto statement
利用goto不会压栈当前chain的特性,以及Matches可以进行条件判断的特性,可以做到程序中控制块的效果。 那么还需要什么呢?还需要变量! 然而nft是用于对包进行流处理的,变量就需要自动的关联到每个包以及每个conntrack管理的流中, 对每个流附加自定义的存储内容。然而nft中只提供对包或流(由conntrack跟踪的)存储32位的整数作为变量
下面两个Statement分别设置关联到包与关联到流的变量
- ct mark set 0x123
- meta mark set 0x456
他们的数据类型:
root@Bo:/home/bo# nft describe ct mark
ct expression, datatype mark (packet mark) (basetype integer), 32 bits
root@Bo:/home/bo# nft describe meta mark
meta expression, datatype mark (packet mark) (basetype integer), 32 bits
root@Bo:/home/bo# nft describe ip saddr
payload expression, datatype ipv4_addr (IPv4 address) (basetype integer), 32 bits
很遗憾的是,尽管ipv4_addr和mark都是32位整数,在虚拟机字节码里是相同内容,但是nft目前并没有能力将
不同类型进行关联与转换的能力。从nftables的语法解析器开始就是完全不支持的。
我们最多使用$
标志定义一些只有在ruleset文件中可见的宏
,并使用这些宏硬编码一些mark作为变量使用。
表达式计算 🔗
nft提供了一些基本的表达式,以下列举可能不完全
Matches中使用
- 比较运算符 < > <= >=,在Matches中使用,可以与部分类型进行比较
- 判等 = !=,在Matches中使用,可以用于标量比较,也可用于判断一个标量是否在一个集合中。例如 udp sport != { 1000-2000, 17900, 21400 }
- 逻辑运算AND,在Matches中连续写出多个Match时,隐式为AND逻辑
- 逻辑运算OR,每个rule之间,每个规则会顺序执行,他们是隐式为OR逻辑。
- set查询,用于Matches中,见上文判等。
Statement中使用
- 赋值运算,用于Statement中,修改包或流的属性,以各种指令的形式存在,如
dnat to 192.168.1.1
,实际上是改变包头部的目的地址为192.168.1.1 - map查询,用于Statement中,如 dnat to
udp dport map { 1000-2000 : 192.168.1.1, 17900 : 192.168.1.2 ... }
, 强调部分会计算为ipv4_addr类型给dnat指令使用 - 返回指令,用于Statement中,如
return
,terminated的Statement隐含返回的语义(如NAT动作,masquerade)。 - 动态修改set与map,用于Statement中,如
add @some_map { udp dport : ip saddr }
nft的交互界面基本上是在向iptables看齐,可编程的能力其实并不高。 甚至可以这么讲,如果没有动态修改set与map的能力,nft的能力边界其实完全和iptables一样。
动态set与map带来的改变与限制 🔗
nft的set与map可以是动态的,并且可以配置元素自动timeout。比如下面语句,配置了一个默认timeout 2h的map。flags语句在配置map开启的特性。
nft add map inet sometable new_map_name { type inet_service : ipv4_addr \; flags dynamic, timeout \; timeout 2h\; }
并且可以在匹配包的Statement中动态的修改map。这意味着什么呢,如果我们把包含set,map,chain的table看作一个程序, 那么这个程序有了修改全局变量的能力。或者说,这个程序有了修改自己的能力。例如我们可以在WAN出口的hook postrouting的chain中, 动态追踪出站的udp包,添加到这个map中,并且map中的数据还会自动清理:
nft insert rule inet sometable postrouting_wan ct state new meta nfproto ipv4 meta l4proto udp ip saddr {192.168.1.111} add @new_map_name { udp sport : ip saddr }
然而目前nftables在动态更新的动作上有很多限制,甚至是无效语句。
- 动态更新有add、delete与update操作,但是update只是语法上支持,虚拟机中不会真的update
- 对map的更新操作要 update @map_name { key : value }, 但是debug=netlink查看,字节码只取key做update,与value无关,不知道未来是否与value有关,value又是何语义。
- 对联合类型set的判断只取第一个字段, 假如有一个set为 {type inet_service . ipv4_addr ;}, 那么match
udp sport . <AnyThing> @setname
只取第一个字段inet_service进行判断。然而在map语句中的取值对联合类型是正确实现的,如udp sport { 1000: 192.168.1.1 . 1234, 2000-3000: 127.0.0.1 . 4321}
能正确提取出地址端口对,给需要的动作使用(如DNAT等)。 - nftables语法解析器上就不支持,map语句提取出value内容用于Matches中判断。
- add重复添加不会报错,不会刷新timeout。(map中重复add key相同,value不同的也不会覆盖)
- update没有任何效果(openwrt 23.05下的nft的行为)
- delete map中的元素要求写{ key : value}但是字节码只取key做删除,与value无关。语法上不一致。
- 旧一点的nft工具链,可能语法解析上支持map timeout, 但是实际上不支持。
- 还有很多从虚拟机层面可以实现,但是完全不支持的功能;以及语法与语义不一致的地方。
简单来讲只有以下三种特性有效并且未来也会有效:
- 简单元素set查询
- 简单元素作为key的map查询
- map与set的add与delete指令
实现Fullcone NAT 🔗
回归正题,实现Fullcone NAT。
我们可以观察到,在一般使用场景下,netfielter的NAT动作不会改变源端口地址,只会改变源IP地址。 如果我们想要实现Fullcone NAT,我们只需要把每一个曾经向外发送UDP数据的端口开放,并转发到内部的相同端口。
但是update是不起效的,如果想要实现更新的效果只能一个规则连着写一个delete与add。这又会导致内网中使用相同源地址的UDP包互相覆盖map中的回溯的内网地址。 我们更希望的是只有第一个发出的包抢先得到这个端口,并且这个端口只能由这个抢先注册的内网地址更新。 因为update不起效,这个效果是做不到的。所以退而求其次,设置一个比较长的timeout就好。
去掉无法实现的功能后,实现变得简单起来,甚至控制流都不存在:
# 定义并创建map
nft add map inet fw4 fullcone_map { type inet_service : ipv4_addr \; flags dynamic,timeout \; timeout 1d \; }
# 抢在NAT前,对集合内的地址建立{ source port -> source ip }的映射
nft insert rule inet fw4 srcnat_wan ct state new meta l4proto udp ip saddr { 192.168.1.110 } add @fullcone_map { udp sport : ip saddr }
# 在conntrack裁决无关联后,所有端口转发规则未命中后
# 使用上条规则追踪到的可映射的fullcone端口进行转发
nft add rule inet fw4 dstnat_wan meta nfproto ipv4 meta l4proto udp dnat ip to udp dport map @fullcone_map
openwrt配置 🔗
然后我们把上面三行不可持久化的语句配置在配置文件中。参考fw4 config: Drop-in includes for package authors
/usr/share/nftables.d/table-pre/99-fullcone.nft
map fullcone_map {
type inet_service : ipv4_addr
flags dynamic,timeout
timeout 1d
}
# change 192.168.4.123 to ip addresses of LAN node need to enable fullcone
chain srcnat_wan {
ct state new meta l4proto udp ip saddr { 192.168.4.123 } add @fullcone_map { udp sport : ip saddr }
}
/usr/share/nftables.d/chain-post/dstnat_wan/99-fullcone.nft
meta nfproto ipv4 meta l4proto udp dnat ip to udp dport map @fullcone_map
注意,openwrt 23.05后携带的nft才支持map的timeout,timeout语法在更早的openwrt就支持了,但添加到map的数据永不超时。
Q&A 🔗
Q: 有办法实现NAT中源端口号改变的情况下依然起效的fullcone NAT吗?
A: 不行,因为nft ct信息中无法查询到NAT前的源端口号,也就是说无法在NAT后的hook中得到NAT前的源端口号。{ 改写后端口 : IP . 改写前端口 }的映射无法建立。
另外,如果想要在NAT前存下源端口号,也是不行的,nft缺少附加ct上任意信息的能力,nft的类型系统也没有类型转换的能力。
Q: 在较旧的openwrt中的nft不支持map的timeout, 如何规避这个问题?
A: 可以使用crontab: 30 5 * * * /usr/sbin/nft flush map inet fw4 fullcone_map
,每天早上5.30清理map
后记 🔗
其实我心目中基于目前支持的nftables语法写出的配置文件是这样的,可惜的是联合类型的set匹配都是不正确的,update指令也不起作用。
# /usr/share/nftables.d/table-pre/99-fullcone.nft
set fullcone_ports {
type inet_service
flags dynamic,timeout
timeout 2h
}
set fullcone_pairs {
type inet_service . ipv4_addr
flags dynamic,timeout
timeout 2h
}
map fullcone_map {
type inet_service : ipv4_addr
flags dynamic,timeout
timeout 2h
}
chain fullcone_update {
# update also not work
delete @fullcone_map { udp sport : ip saddr }
add @fullcone_map { udp sport : ip saddr }
delete @fullcone_pairs { udp sport . ip saddr }
add @fullcone_pairs { udp sport . ip saddr }
delete @fullcone_ports { udp sport }
add @fullcone_ports { udp sport }
}
chain fullcone_main {
udp sport . ip saddr @fullcone_pairs goto fullcone_update
udp sport @fullcone_ports return
# new rule
add @fullcone_map { udp sport : ip saddr }
add @fullcone_pairs { udp sport . ip saddr }
add @fullcone_ports { udp sport }
}
chain srcnat_wan {
ct state new meta l4proto udp ip saddr { 192.168.54.123 } jump fullcone_main
}