初衷很简单,我想在本地访问运行在普通用户级别的 caddy。因为 caddy 运行在普通用户级别,所以无法监听80, 443 端口。所以我考虑用 iptables 做个端口转发。
既然 ebpf 是大势所趋,我就直接用 nftables 来实现这个需求。
我参考的文章是 redhat 的
他介绍的方法很简单,就是在 nftables 的 ip nat 表 PREROUTING chain 中加一个 tcp dport 80 redirect to :3000

  1. nft add rule ip nat prerouting tcp dport 80 redirect to :3000

但是诡异的事情发生了,无论如何我都无法在本地访问 80 端口,端口转发失败,接下来我开始了漫漫 debug 路。
首先,我学习了 counter 这个功能。满足一定的条件就可以计数,非常便利。
改成

  1. nft add rule ip nat prerouting tcp dport 80 counter redirect to :3000

注意,counter 应该跟在条件的后面,不能跟在 redirect 的后面。

  1. # 查看所有的规则
  2. nft list ruleset
  3. # -a 表示查看 handle 编号
  4. # 查看 ip nat 表
  5. nft -a list table ip nat
  6. # 查看 PREROUTING chain
  7. nft list chain ip nat PREROUTING
  8. # 插入到开始位置
  9. nft add rule ip nat PREROUTING tcp dport 80 counter redirect to :3000
  10. # 插入到结束位置
  11. 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链
image.png
所以从外部访问应该是没问题的!我一直在用回环网卡测试才会觉得没有生效。

  1. # 回环情况,单纯 prerouting 配置无效
  2. curl http://127.0.0.1:80
  3. # 外部访问情况,prerouting 有效
  4. 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。

  1. # 在 ip nat OUTPUT 中捕获本地访问流量
  2. # 可以看到 packet 是没有 oif 信息的。
  3. 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
  4. # 被这个 rule 转换
  5. 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)
  6. # 这个 packet 接着经过 postrouting chain,可以看到 dport 已经被修改
  7. 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
  8. trace id c7ed237c ip nat POSTROUTING verdict continue
  9. 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
    }
}