Lance Yang 老师昨晚在评论区为我指明方向,我觉得这个很重要所以整理了一下单独发出来,因为这个问题在互联网上似乎搜不到正确的信息,AI 也全军覆没。
envoy sidecar 在云原神里被用做透明代理,pod -> remote 的流量(几乎)被全量劫持到同一 netns 里的 envoy,实际路径是 pod -> envoy -> reomte (正向代理,两个 tcp 会话)。不过 envoy sidecar 劫持流量默认不使用炫酷的 tproxy,而是古典的 REDIRECT
把 packets DNAT 成 127.0.0.1:15001。
envoy 劫持到这个流量之后,通过 getsockopt(SO_ORIGINAL_DST) 拿到原始的 dip:dport。
回程的 rev-DNAT 由 nf conntrack 隐式处理。
这看起来没有问题,但是注意力稍微集中一点就能意识到 DNAT 之后可以导致连接冲突。我们可以显式地尝试构造这种冲突:
先运行 python main.py 1.1.1.1:80 建立一个 nodeip:19233 -> 1.1.1.1:80,然后再运行 python main.py 1.0.0.1:80 建立一个 nodeip:19233 -> 1.0.0.1:80。两个 tcp 连接有不同的 dip,零冲突,很合规。
但是 iptables DNAT 之后,两个连接都变成了 nodeip:19233 -> 127.0.0.1:15001,这样后发起握手的连接似乎必被 reset,就算安慰自己“这应该是小概率事件”,严肃的工程师都应该意识到这绝对会让生产爆炸。
但是真的如此吗?
立刻着手测试,发现第二个 tcp 其实也能成功握手,ss 看到第二个连接的 sport 居然被 SNAT 了,这就是昨晚 Lance Yang 的发现:
用 pwru 追一下 skb,会发现还要稍微复杂一点点,以下是
额外的 SNAT 并非发生在 -j REDIRECT 的 OUTPUT nat 里,而推迟到了 POSTROUTING nat,通过 ct 来传递 SNAT 信息。nf_nat_redirect_ipv4 只会计算出需要 SNAT 的新端口,记录到 ct 里,待到 POSTROUTING 再 SNAT。
如果此时再加入一些 SNAT --to-source 会怎样,其实也不会怎样,ct 只有一条干净的 entry 记录 10.0.0.16:19233 -> 1.0.0.1:80 被 NAT 成 127.0.0.1:13902 -> 127.0.0.1:15001,所有 ip port 都被换过。
这对 envoy 用户态调用 getsockopt(SO_ORIGINAL_DST) 没有影响,rev-NAT 也无需用户态操心,延迟 SNAT 的行为目测半完全正确(否则在 OUTPUT 发生隐式 SNAT 、影响 POSTROUTING 的规则命中就绝了😊 ),就算 POSTROUTING 再次命中显式 SNAT 也没关系,几乎不会踩坑(除了要消耗 ct),唯一需要注意的就是在旁路观测的时候不能通过 socket tuple 来配对 client socket 和被劫持的 conn socket😭 因为我真的就在这么干,周一改。
envoy sidecar 在云原神里被用做透明代理,pod -> remote 的流量(几乎)被全量劫持到同一 netns 里的 envoy,实际路径是 pod -> envoy -> reomte (正向代理,两个 tcp 会话)。不过 envoy sidecar 劫持流量默认不使用炫酷的 tproxy,而是古典的 REDIRECT
iptables -t nat -A OUTPUT -j REDIRECT --to-ports 15001把 packets DNAT 成 127.0.0.1:15001。
envoy 劫持到这个流量之后,通过 getsockopt(SO_ORIGINAL_DST) 拿到原始的 dip:dport。
回程的 rev-DNAT 由 nf conntrack 隐式处理。
这看起来没有问题,但是注意力稍微集中一点就能意识到 DNAT 之后可以导致连接冲突。我们可以显式地尝试构造这种冲突:
import socket, sys, time
sk = socket.socket()
sk.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sk.bind(('', 19233))
sk.connect((sys.argv[1], int(sys.argv[2])))
time.sleep(1000)先运行 python main.py 1.1.1.1:80 建立一个 nodeip:19233 -> 1.1.1.1:80,然后再运行 python main.py 1.0.0.1:80 建立一个 nodeip:19233 -> 1.0.0.1:80。两个 tcp 连接有不同的 dip,零冲突,很合规。
但是 iptables DNAT 之后,两个连接都变成了 nodeip:19233 -> 127.0.0.1:15001,这样后发起握手的连接似乎必被 reset,就算安慰自己“这应该是小概率事件”,严肃的工程师都应该意识到这绝对会让生产爆炸。
但是真的如此吗?
立刻着手测试,发现第二个 tcp 其实也能成功握手,ss 看到第二个连接的 sport 居然被 SNAT 了,这就是昨晚 Lance Yang 的发现:
nf_nat_redirect_ipv4
-> nf_nat_redirect
-> nf_nat_setup_info
-> get_unique_tuple
-> nf_nat_l4proto_unique_tuple
-> nf_nat_used_tuple_harder
有趣。最新代码,NAT REDIRECT 如果源端口检测到冲突后会自动进行源端口重分配 。。。
用 pwru 追一下 skb,会发现还要稍微复杂一点点,以下是
pwru --all-kmods --filter-track-skb 'tcp[tcpflags]=tcp-syn and src port 19233' 的导演剪辑版:10.0.0.16:19233->1.0.0.1:80 ip_local_out
10.0.0.16:19233->1.0.0.1:80 __ip_local_out
10.0.0.16:19233->1.0.0.1:80 nf_hook_slow
// iptables -A OUTPUT -t nat -j REDIRECT
10.0.0.16:19233->1.0.0.1:80 nft_nat_do_chain[nft_chain_nat]
10.0.0.16:19233->1.0.0.1:80 redirect_tg4[xt_REDIRECT]
// DNAT, be aware of changes of dport and dip, and re-route
10.0.0.16:19233->1.0.0.1:80 l4proto_manip_pkt[nf_nat]
10.0.0.16:19233->1.0.0.1:15001 nf_csum_update[nf_nat]
10.0.0.16:19233->1.0.0.1:15001 inet_proto_csum_replace4
10.0.0.16:19233->1.0.0.1:15001 inet_proto_csum_replace4
10.0.0.16:19233->127.0.0.1:15001 ip_route_me_harder
// iptables POSTROUTING
10.0.0.16:19233->127.0.0.1:15001 apparmor_ip_postroute
// extra SNAT due to conflicts, be aware of the change of sport
10.0.0.16:19233->127.0.0.1:15001 l4proto_manip_pkt[nf_nat]
10.0.0.16:46801->127.0.0.1:15001 nf_csum_update[nf_nat]额外的 SNAT 并非发生在 -j REDIRECT 的 OUTPUT nat 里,而推迟到了 POSTROUTING nat,通过 ct 来传递 SNAT 信息。nf_nat_redirect_ipv4 只会计算出需要 SNAT 的新端口,记录到 ct 里,待到 POSTROUTING 再 SNAT。
如果此时再加入一些 SNAT --to-source 会怎样,其实也不会怎样,ct 只有一条干净的 entry 记录 10.0.0.16:19233 -> 1.0.0.1:80 被 NAT 成 127.0.0.1:13902 -> 127.0.0.1:15001,所有 ip port 都被换过。
这对 envoy 用户态调用 getsockopt(SO_ORIGINAL_DST) 没有影响,rev-NAT 也无需用户态操心,延迟 SNAT 的行为目测半完全正确(否则在 OUTPUT 发生隐式 SNAT 、影响 POSTROUTING 的规则命中就绝了