端口映射的两个坑

帮朋友做个端口映射,由于几个月没碰 iptables 了,遇到两个坑,与大家分享。

为方便描述,我们假定面向公网的服务器为 G(Gateway 的意思),其端口 80 映射到内网服务器 B(Backend 的意思)的端口 80。G 有两条不同 ISP 的接入线路,分别接在 eth0 和 eth1 网卡上,IP 分别是 1.1.1.1 和 2.2.2.2。B 的内网 IP 是 192.168.0.2,G 的内网 IP 是 192.168.0.1,G 接内网的网卡是 eth2。

DNAT 后有时要 SNAT

最初网关 G 上的错误配置是这样的:

iptables -t nat -A PREROUTING -d 1.1.1.1/32 -i eth0 -p tcp -m tcp –dport 80 -j DNAT –to-destination 192.168.0.2
iptables -t nat -A PREROUTING -d 2.2.2.2/32 -i eth1 -p tcp -m tcp –dport 80 -j DNAT –to-destination 192.168.0.2

发现 1.1.1.1:80 和 2.2.2.2:80 都无法连接。在网关 G 上抓包发现源 IP 没有变,只是目的 IP 变了。而后端 B 也有不经过网关直接通往互联网的线路,因此返回的数据包没有经过网关而直接发到了互联网上。用户收到的响应包来自不同的 IP(后端 B 访问互联网并不经过网关 G),自然被丢弃了。

解决方法是在 DNAT 后再做 SNAT。这里要注意,由于对新进来的连接,POSTROUTING 是在 PREROUTING 修改目的 IP 之后,因此 POSTROUTING 匹配的目的 IP 应该是映射后的内网 IP。修改后的规则如下:

iptables -t nat -A POSTROUTING -d 192.168.0.2/32 -o eth2 -p tcp -m tcp –dport 80 -j SNAT –to-source 192.168.0.1
iptables -t nat -A PREROUTING -d 1.1.1.1/32 -i eth0 -p tcp -m tcp –dport 80 -j DNAT –to-destination 192.168.0.2
iptables -t nat -A PREROUTING -d 2.2.2.2/32 -i eth1 -p tcp -m tcp –dport 80 -j DNAT –to-destination 192.168.0.2

网上的教程大多给人 “端口映射 = DNAT” 的印象,因为大部分情况下,后端服务器返回的响应包一定要经过网关。当这个假设不成立的时候,也就是后端服务器发出的包不一定经过网关的时候,就要小心这个坑,在 DNAT 后加 SNAT。

给连接打标签,选择正确的出口

按照上面的配置,只有一个公网 IP 能够连接,另一个公网 IP 无法连接。tcpdump 一看,后端 B 返回了 TCP 响应包,但这个包被送到错误的公网端口了。但这个包的源 IP 明明是正确的,也就是根据事先设定的策略路由,不应该走错。这是怎么回事呢?

Linux 在转发一个包的时候,要依次经过 PREROUTING、路由、POSTROUTING 三个阶段。一个包该从哪块网卡出去,是在路由阶段决定的。对用户发起连接的包,是在 PREROUTING 阶段匹配 DNAT 规则、修改目的 IP,在 POSTROUTING 阶段匹配 SNAT 规则、修改源 IP。对后端返回的回复包,是在 PREROUTING 阶段匹配 NAT 表中由 SNAT 规则建立的条目、修改目的 IP,在 POSTROUTING 阶段匹配 NAT 表中由 DNAT 规则建立的条目、修改源 IP。

我们考虑用户 100.100.100.100 到 1.1.1.1 的 TCP 连接。由于经过了 SNAT 和 DNAT,后端 B 收到的 TCP SYN 包是 (192.168.0.1 => 192.168.0.2),返回的 TCP SYN+ACK 响应是 (192.168.0.2 => 192.168.0.1)。到达网关 G 后,在 PREROUTING 阶段,目的 IP 被修改为 100.100.100.100,在路由阶段,它的源 IP 仍然是 192.168.0.2,策略路由根本派不上用场!要等路由结束,出口网卡已经选定之后,在 POSTROUTING 阶段,源 IP 才会被修改为 1.1.1.1。怎么办?

在路由阶段,一定要标识出这个包是该送往哪个公网出口的。凭借单个包的内容无法做到,只能寄希望于这个包所属的连接信息。好在 iptables 可以给连接(connection)打标签(mark),策略路由又可以根据标签选择出口。

添加下述命令后,两个公网 IP 的端口映射都工作正常了。

iptables -t mangle -A PREROUTING -j CONNMARK –restore-mark
iptables -t mangle -A PREROUTING -m mark ! –mark 0 -j ACCEPT
iptables -t mangle -A PREROUTING -d 1.1.1.1/32 -i eth0 -p tcp -m tcp -j MARK –set-mark 1
iptables -t mangle -A PREROUTING -d 2.2.2.2/32 -i eth1 -p tcp -m tcp -j MARK –set-mark 2
iptables -t mangle -A PREROUTING -j CONNMARK –save-mark

ip route replace default via 1.1.1.254 dev eth0 table 1000
ip rule add fwmark 1 lookup 1000
ip route replace default via 2.2.2.254 dev eth1 table 1001
ip rule add fwmark 2 lookup 1001
iptables 部分的解释:

  1. 策略路由所用的标签是在数据包上的,而我们现在要用基于连接的标签。因此在 PREROUTING 阶段,需要把连接的标签复制到数据包上。
  2. 如果这个连接已经打过标签,则直接忽略。这是一个 shortcut,去掉也不影响正确性。
  3. 给到达 1.1.1.1 的 TCP 连接打上标签 1。
  4. 给到达 2.2.2.2 的 TCP 连接打上标签 2。
  5. 把数据包上的标签保存到 TCP 连接里。
    策略路由部分的解释:

  6. 在 1000 号路由表中设置一条默认路由,指向 eth0 线路的网关(这个网关是 ISP 给我们的)。

  7. 让被打上标签 1 的数据包走 1000 号路由表,由于其中只有一条默认路由,一定能匹配上,即走 eth0 出口。
  8. 在 1001 号路由表中设置一条默认路由,指向 eth1 线路的网关(这个网关是 ISP 给我们的)。
  9. 让被打上标签 2 的数据包走 eth1 出口。

小结

本文讨论了 Linux 端口映射中两个比较常见的坑,一个是后端服务器的回复数据包有可能绕过网关的情况,一个是网关有多条公网线路的情况。这两个坑我早先都掉进去过,花了很久才爬出来,但只是几个月光景,就“好了伤疤忘了疼”。在此记录下来以备不时之需。

此外,tcpdump 之于网络调试就像 gdb 之于程序调试,调试工具这种神器真是 “学在当代,用在千秋”。

(注:人人网的读者可能发现代码比较混乱,这是因为本文是发在我的科大博客上,再自动同步到人人的,而人人把 HTML pre 标签的样式“重载”了。本文原始链接:https://ring0.me/2014/02/port-mapping-fallacies/