初衷很简单,我想在本地访问运行在普通用户级别的 caddy。因为 caddy 运行在普通用户级别,所以无法监听80, 443 端口。所以我考虑用 iptables 做个端口转发。
既然 ebpf 是大势所趋,我就直接用 nftables 来实现这个需求。
我参考的文章是 redhat 的
他介绍的方法很简单,就是在 nftables 的 ip nat 表 PREROUTING chain 中加一个 tcp dport 80 redirect to :3000
nft add rule ip nat prerouting tcp dport 80 redirect to :3000
但是诡异的事情发生了,无论如何我都无法在本地访问 80 端口,端口转发失败,接下来我开始了漫漫 debug 路。
首先,我学习了 counter 这个功能。满足一定的条件就可以计数,非常便利。
改成
nft add rule ip nat prerouting tcp dport 80 counter redirect to :3000
注意,counter 应该跟在条件的后面,不能跟在 redirect 的后面。
# 查看所有的规则
nft list ruleset
# -a 表示查看 handle 编号
# 查看 ip nat 表
nft -a list table ip nat
# 查看 PREROUTING chain
nft list chain ip nat PREROUTING
# 插入到开始位置
nft add rule ip nat PREROUTING tcp dport 80 counter redirect to :3000
# 插入到结束位置
nft insert rule ip nat PREROUTING tcp dport 80 counter redirect to :3000
但是,依然没有反应。ip nat
表专门管 prerouting, input, output, postrouting。ip filter
表专门管
最后,我发现问题关键出在我不理解 nftables 的 hook。普通情况才应该走 FORWARD 链,这种 local 访问 local 的情况应该会进入 Input chain,输出会走 output chain。
为什么会有这种差别呢?
因为转发 (forward) 和普通情况有个很重要的区别:转发是不会经过应用程序的,但是普通情况(input, output) 是会经过应用程序的。
你本机访问自己,实际上不会走prerouting 链,而是走output链
所以从外部访问应该是没问题的!我一直在用回环网卡测试才会觉得没有生效。
# 回环情况,单纯 prerouting 配置无效
curl http://127.0.0.1:80
# 外部访问情况,prerouting 有效
curl http://192.168.124.139:80
nftables 机制
priority
priority 越小,规则越早被应用。
比如 prerouting hook 有三个默认 priority,分别对应了 packet 的分片、connection track 和 DNAT阶段。
四种 nat
nat 有 snat, masquerade, dnat 和 redirect 4种。
snat 指的是修改 tcp 的 saddr,dnat 指的是修改 tcp 的 daddr。
masquerade 是一种特殊的 saddr,它会将 saddr 自动修改为 output interface 的 IP。
redirect 是一种特殊的 dnat,它用于本地的重定向(本地端口转发)。
解决办法
首先我们要搞懂数据包的流向,从本地访问本地,会先后经过 OUTPUT -> INPUT。
需要注意到的是,OUTPUT chain 你用 iifname "lo"
去匹配是无效的,为什么呢?
因为在 output 这个 hook 的位置,是看不到 iif 的。你需要用 fib daddr 这种表达式来根据 fib (转发表) 猜测 iif。
# 在 ip nat OUTPUT 中捕获本地访问流量
# 可以看到 packet 是没有 oif 信息的。
trace id c7ed237c ip nat OUTPUT packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 11471 ip length 60 tcp sport 48348 tcp dport 80 tcp flags == syn tcp window 65495
# 被这个 rule 转换
trace id c7ed237c ip nat OUTPUT rule oifname "lo" tcp dport 80 counter packets 1 bytes 60 meta nftrace set 1 redirect to :3000 (verdict accept)
# 这个 packet 接着经过 postrouting chain,可以看到 dport 已经被修改
trace id c7ed237c ip nat POSTROUTING packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 11471 ip length 60 tcp sport 48348 tcp dport 3000 tcp flags == syn tcp window 65495
trace id c7ed237c ip nat POSTROUTING verdict continue
trace id c7ed237c ip nat POSTROUTING policy accept
# 在 ip filter INPUT 中捕获访问流量
trace id 8ef54b2d ip filter INPUT packet: iif "lo" @ll,0,112 2048 ip saddr 127.0.0.1 ip daddr 127.0.0.1 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 47288 ip length 60 tcp sport 48392 tcp dport 3000 tcp flags == syn tcp window 65495
# 将外界访问 80 的流量导向 3000
nft add rule ip nat PREROUTING tcp dport 80 counter redirect to :3000
# 将本地访问 80 的流量导向 3000
nft add rule ip nat OUTPUT oifname "lo" tcp dport 80 counter redirect to :3000
扩展阅读
nftables 有一个 wiki,一个 man,man 依然是最详细的。
nftables 的 hooks 列表
这篇 wiki 详细地讲了有哪些 hooks,以及不同的 priority 的意义。
这是一篇介绍Docker 网络的文章,我对Docker iptables 规则的知识来自于此。
解读 Docker nftables 规则
table ip nat {
# === 2 === 接着可以看 DOCKER 的处理
chain DOCKER {
# 如果是访问网桥的,那么直接返回,这里 return 就是不做任何处理结束这个 chain 的意思
iifname "docker0" counter packets 0 bytes 0 return
iifname "br-7b74d12b95e0" counter packets 0 bytes 0 return
# 如果不是访问网桥的,并且访问的是 8090 端口,那么转发到 172.17.0.3:80
# dnat 指的是会修改 TCP 的 destination 部分
# meta l4proto 的意思是 ipv4
iifname != "docker0" meta l4proto tcp tcp dport 8090 counter packets 0 bytes 0 dnat to 172.17.0.3:80
}
chain POSTROUTING {
type nat hook postrouting priority srcnat; policy accept;
# POSTROUTING 表中的规则是为了让容器可以通过网桥与外界通信
# docker network 为每个网桥创建一个规则
# 从容器发往外界世界的包需要配置,source addr 需要修改为物理网卡的 ip 地址,这种行为由 masquerade 自动管理
oifname != "docker0" ip saddr 172.17.0.0/16 counter packets 0 bytes 0 masquerade
oifname != "br-7b74d12b95e0" ip saddr 172.18.0.0/16 counter packets 0 bytes 0 masquerade
meta l4proto tcp ip saddr 172.17.0.3 ip daddr 172.17.0.3 tcp dport 80 counter packets 0 bytes 0 masquerade
}
# === 1 === 从PREROUTING 开始读代码
chain PREROUTING {
# 这里写了 policy accept,表示没有 reject 的都会被采纳
# 这个 chain 的 priority 是 dstnat = -100,越低优先级的越早被处理
type nat hook prerouting priority dstnat; policy accept;
# fib daddr 是根据路由表计算daddr对应的网卡
# fib 的含义是 forwarding information base
# oif 类型为 local 的,跳转到 DOCKER chain 进行处理(jump 的功能类似于调用函数)
fib daddr type local counter packets 17 bytes 1040 jump DOCKER
tcp dport 80 counter packets 11 bytes 572 redirect to :3000
}
chain OUTPUT {
type nat hook output priority -100; policy accept;
ip daddr != 127.0.0.0/8 fib daddr type local counter packets 0 bytes 0 jump DOCKER
tcp dport 80 counter packets 49 bytes 2940 redirect to :3000
}
}
table ip filter {
chain DOCKER {
iifname != "docker0" oifname "docker0" meta l4proto tcp ip daddr 172.17.0.3 tcp dport 80 counter packets 0 bytes 0 accept
}
chain DOCKER-ISOLATION-STAGE-1 {
iifname "docker0" oifname != "docker0" counter packets 0 bytes 0 jump DOCKER-ISOLATION-STAGE-2
iifname "br-7b74d12b95e0" oifname != "br-7b74d12b95e0" counter packets 0 bytes 0 jump DOCKER-ISOLATION-STAGE-2
counter packets 0 bytes 0 return
}
chain DOCKER-ISOLATION-STAGE-2 {
oifname "docker0" counter packets 0 bytes 0 drop
oifname "br-7b74d12b95e0" counter packets 0 bytes 0 drop
counter packets 0 bytes 0 return
}
chain FORWARD {
type filter hook forward priority filter; policy drop;
counter packets 0 bytes 0 jump DOCKER-USER
counter packets 0 bytes 0 jump DOCKER-ISOLATION-STAGE-1
# docker network 实际上创建的是网桥,默认 host 网络的网桥名称为 docker0
# 每个网桥创建 4 个规则,核心就是让网桥下面接的虚拟网卡之间可以互相通信
# 由于policy 是 drop,所以需要显式地指定哪些情况可以 accept
oifname "docker0" ct state related,established counter packets 0 bytes 0 accept
oifname "docker0" counter packets 0 bytes 0 jump DOCKER
iifname "docker0" oifname != "docker0" counter packets 0 bytes 0 accept
iifname "docker0" oifname "docker0" counter packets 0 bytes 0 accept
oifname "br-7b74d12b95e0" ct state related,established counter packets 0 bytes 0 accept
oifname "br-7b74d12b95e0" counter packets 0 bytes 0 jump DOCKER
iifname "br-7b74d12b95e0" oifname != "br-7b74d12b95e0" counter packets 0 bytes 0 accept
iifname "br-7b74d12b95e0" oifname "br-7b74d12b95e0" counter packets 0 bytes 0 accept
}
chain DOCKER-USER {
counter packets 0 bytes 0 return
}
}