从 ss-redir 的实现到 Linux NAT

引子

今年 4 月,在家的时候意外看到了 ss-redir 透明代理,对其中的详细说明持有怀疑态度:

由于笔者才疏学浅,刚开始居然以为 TCP 透明代理和 UDP 透明代理是一样的,只要无脑 REDIRECT 到 ss-redir 监听端口就可以了。

但是,上面这种情况只针对 TCP;对于 UDP,如果你做了 DNAT,就无法再获取数据包的原目的地址和目的端口了。

于是对此专门做了一番调研。整篇分为三部分:第一部分是我对上述叙述的调研结果,第二部分讨论 TPROXY,第三部分叙述一些 NAT 的知识。

 ss-redir 中的 UDP REDIRECT 问题

ss-redir 的原理很简单:使用 iptables 对 PREROUTING 与 OUTPUT 的 TCP/UDP 流量进行 REDIRECT(REDIRECT 是 DNAT 的特例),ss—redir 在捕获网络流量后,通过一些技术手段获取 REDIRECT 之前的目的地址(dst)与端口(port),连同网络流量一起转发至远程服务器。

针对 TCP 连接,的确是因为 Linux Kernel 连接跟踪机制的实现才使获取数据包原本的 dst 和 port 成为可能,但这种连接跟踪机制并非只存在于 TCP 连接中,UDP 连接同样存在,conntrack -p udp 便能看到 UDP 的连接跟踪记录。内核中有关 TCP 与 UDP 的 NAT 源码 /net/netfilter/nf_nat_proto_tcp.c/net/netfilter/nf_nat_proto_udp.c 几乎一模一样,都是根据 NAT 的类型做 SNAT 或 DNAT。

那这究竟是怎么一回事?为什么对于 UDP 连接就失效了呢?

回过头来看看 ss-redir 有关获取 TCP 原本的 dst 和 port 的源码,核心函数是 getdestaddr

 
 
static int
getdestaddr(int fd, struct sockaddr_storage *destaddr)
{
socklen_t socklen = sizeof(*destaddr);
int error = 0;

error = getsockopt(fd, SOL_IPV6, IP6T_SO_ORIGINAL_DST, destaddr, &socklen);
if (error) { // Didn't find a proper way to detect IP version.
error = getsockopt(fd, SOL_IP, SO_ORIGINAL_DST, destaddr, &socklen);
if (error) {
return -1;
}
}
return 0;
}

在内核源码中搜了下有关 SO_ORIGINAL_DST 的东西,看到了 getorigdst

 
 
/* Reversing the socket's dst/src point of view gives us the reply
mapping. */
static int
getorigdst(struct sock *sk, int optval, void __user *user, int *len)
{
const struct inet_sock *inet = inet_sk(sk);
const struct nf_conntrack_tuple_hash *h;
struct nf_conntrack_tuple tuple;

memset(&tuple, 0, sizeof(tuple));

lock_sock(sk);
tuple.src.u3.ip = inet->inet_rcv_saddr;
tuple.src.u.tcp.port = inet->inet_sport;
tuple.dst.u3.ip = inet->inet_daddr;
tuple.dst.u.tcp.port = inet->inet_dport;
tuple.src.l3num = PF_INET;
tuple.dst.protonum = sk->sk_protocol;
release_sock(sk);

/* We only do TCP and SCTP at the moment: is there a better way? */
if (tuple.dst.protonum != IPPROTO_TCP &&
tuple.dst.protonum != IPPROTO_SCTP) {
pr_debug("SO_ORIGINAL_DST: Not a TCP/SCTP socket\n");
return -ENOPROTOOPT;
}

We only do TCP and SCTP at the moment。Oh,shit!只针对 TCP 与 SCTP 才能这么做,并非技术上不可行,只是人为地阻止罢了。

 TPROXY

为了在 redirect UDP 后还能够获取原本的 dst 和 port,ss-redir 采用了 TPROXY。Linux 系统有关 TPROXY 的设置是以下三条命令:

 
 
# iptables -t mangle -A PREROUTING -p udp -j TPROXY --tproxy-mark 0x2333/0x2333 --on-ip 127.0.0.1 --on-port 1080
# ip rule add fwmark 0x2333/0x2333 pref 100 table 100
# ip route add local default dev lo table 100

获取原本的 dst 和 port 的相关源码如下:

 
 
static int
get_dstaddr(struct msghdr *msg, struct sockaddr_storage *dstaddr)
{
struct cmsghdr *cmsg;

for (cmsg = CMSG_FIRSTHDR(msg); cmsg; cmsg = CMSG_NXTHDR(msg, cmsg)) {
if (cmsg->cmsg_level == SOL_IP && cmsg->cmsg_type == IP_RECVORIGDSTADDR) {
memcpy(dstaddr, CMSG_DATA(cmsg), sizeof(struct sockaddr_in));
dstaddr->ss_family = AF_INET;
return 0;
} else if (cmsg->cmsg_level == SOL_IPV6 && cmsg->cmsg_type == IPV6_RECVORIGDSTADDR) {
memcpy(dstaddr, CMSG_DATA(cmsg), sizeof(struct sockaddr_in6));
dstaddr->ss_family = AF_INET6;
return 0;
}
}

return 1;
}

int
create_server_socket(const char *host, const char *port)
{
...
#ifdef MODULE_REDIR
if (setsockopt(server_sock, SOL_IP, IP_TRANSPARENT, &opt, sizeof(opt))) {
ERROR("[udp] setsockopt IP_TRANSPARENT");
exit(EXIT_FAILURE);
}
if (rp->ai_family == AF_INET) {
if (setsockopt(server_sock, SOL_IP, IP_RECVORIGDSTADDR, &opt, sizeof(opt))) {
FATAL("[udp] setsockopt IP_RECVORIGDSTADDR");
}
} else if (rp->ai_family == AF_INET6) {
if (setsockopt(server_sock, SOL_IPV6, IPV6_RECVORIGDSTADDR, &opt, sizeof(opt))) {
FATAL("[udp] setsockopt IPV6_RECVORIGDSTADDR");
}
}
#endif
...
}

大意就是在 mangle 表的 PREROUTING 中为每个 UDP 数据包打上 0x2333/0x2333 标志,之后在路由选择中将具有 0x2333/0x2333 标志的数据包投递到本地环回设备上的 1080 端口;对监听 0.0.0.0 地址的 1080 端口的 socket 启用 IP_TRANSPARENT 标志,使 IPv4 路由能够将非本机的数据报投递到传输层,传递给监听 1080 端口的 ss-redir。IP_RECVORIGDSTADDRIPV6_RECVORIGDSTADDR 则表示获取送达数据包的 dst 与 port。

可问题来了:要知道 mangle 表并不会修改数据包,那么 TPROXY 是如何做到在不修改数据包的前提下将非本机 dst 的数据包投递到换回设备上的 1080 端口呢?

与之有关的内核源码我没有完全看懂。根据 TProxy - Transparent proxying, again2.6.26 时代的 patch set,在 netfilter 中的 PREROUTING 阶段,将符合规则的 IP 数据报 skb_buff 中的成员 sk(它表示数据包从属的套接字)给 assign_sock,这个 sock 就是利用 iptables TPROXY 的 target 信息找到的:

 
 
// /net/netfilter/xt_TPROXY.c
static unsigned int
tproxy_tg4(struct net *net, struct sk_buff *skb, __be32 laddr, __be16 lport,
u_int32_t mark_mask, u_int32_t mark_value)
{
...
/* UDP has no TCP_TIME_WAIT state, so we never enter here */
if (sk && sk->sk_state == TCP_TIME_WAIT)
/* reopening a TIME_WAIT connection needs special handling */
sk = tproxy_handle_time_wait4(net, skb, laddr, lport, sk);
else if (!sk)
/* no, there's no established connection, check if
* there's a listener on the redirected addr/port */
sk = nf_tproxy_get_sock_v4(net, skb, hp, iph->protocol,
iph->saddr, laddr,
hp->source, lport,
skb->dev, NFT_LOOKUP_LISTENER);

/* NOTE: assign_sock consumes our sk reference */
if (sk && tproxy_sk_is_transparent(sk)) {
/* This should be in a separate target, but we don't do multiple
targets on the same rule yet */
skb->mark = (skb->mark & ~mark_mask) ^ mark_value;

pr_debug("redirecting: proto %hhu %pI4:%hu -> %pI4:%hu, mark: %x\n",
iph->protocol, &iph->daddr, ntohs(hp->dest),
&laddr, ntohs(lport), skb->mark);

nf_tproxy_assign_sock(skb, sk);
return NF_ACCEPT;
}
...
}

sock 是根据四元组 saddr, sport, daddr, dport 来选择的,其中 saddrsport 来自 skb_buff,另外俩为 target 所定义。没搞懂的地方在于:在 ip_rcv_finish 中,是怎样将数据包投递到上层协议以及指定端口的?

目前的猜测如下:

 
 
// kernel version 4.17
- ip_route_input_noref
- ip_route_input_rcu
- ip_route_input_slow
- fib_lookup
- fib_table_lookup
- res->type = fa->fa_type;
- if (res->type == RTN_LOCAL) {
...
goto local_input;
}
- skb_dst_set_noref(skb, &rth->dst);
- rth = rt_dst_alloc(l3mdev_master_dev_rcu(dev) ? : net->loopback_dev,
flags | RTCF_LOCAL, res->type,
IN_DEV_CONF_GET(in_dev, NOPOLICY), false, do_cache);
- if (flags & RTCF_LOCAL)
rt->dst.input = ip_local_deliver;

通过查找路由表确定 res-type 的类型为 RTN_LOCAL,goto 到 local_input,进而调用 rt_dst_alloc,形参参数 (flag & RTCF_LOCAL) == true,设置了 rt->dst.inputip_local_deliverip_local_deliver 中使用协议回调函数 handler 来进一步处理数据包。

进入传输层后,对 IPv4 下的 UDP 协议来说,它的 handlerudplite_rcv(v4.17),通过调用 skb_steal_sock 来获取 sock,这个 sock 与 TPROXY 中在 nf_tproxy_get_sock_v4 获取到的 sock 是一致的。sock 的判断是根据 compute_score 计算的得分来选择的,分高者赢。

 
 
// UDP
.handler = udplite_rcv

- udplite_rcv
- __udp4_lib_rcv
- sk = skb_steal_sock(skb);
...
ret = udp_queue_rcv_skb(sk, skb);

// TPROXY
- nf_tproxy_get_sock_v4
- udp4_lib_lookup
- __udp4_lib_lookup
- __udp4_lib_lookup_skb
- __udp4_lib_lookup
- udp4_lib_lookup2

// /net/ipv4/udp.c: get sock
static struct sock *udp4_lib_lookup2(struct net *net,
__be32 saddr, __be16 sport,
__be32 daddr, unsigned int hnum,
int dif, int sdif, bool exact_dif,
struct udp_hslot *hslot2,
struct sk_buff *skb)
{
struct sock *sk, *result;
int score, badness;
u32 hash = 0;

result = NULL;
badness = 0;
udp_portaddr_for_each_entry_rcu(sk, &hslot2->head) {
score = compute_score(sk, net, saddr, sport,
daddr, hnum, dif, sdif, exact_dif);
if (score > badness) {
if (sk->sk_reuseport) {
hash = udp_ehashfn(net, daddr, hnum,
saddr, sport);
result = reuseport_select_sock(sk, hash, skb,
sizeof(struct udphdr));
if (result)
return result;
}
badness = score;
result = sk;
}
}
return result;
}

有趣的是,在查找资料过程中,我还看到了这篇文章:TPROXY 之殇 - NAT 设备加代理的恶果

最后来回到原点,谈一谈 NAT。

 NAT

根据 RFC 2663,NAT 分为基本网络地址转换(Basic NAT,also called a one-to-one NAT)和网络地址端口转换(NAPT(network address and port translation),other names include port address translation (PAT), IP masquerading, NAT overload and many-to-one NAT)两类。基本网络地址转换仅支持地址转换,不支持端口映射,要求每一个内网地址都对应一个公网地址;网络地址端口转换支持端口的映射,允许多台主机共享一个公网地址。支持端口转换的 NAT 又可以分为两类:源地址转换(SNAT)和目的地址转换(DNAT)。前一种情形下发起连接的计算机的 IP 地址将会被重写,使得内网主机发出的数据包能够到达外网主机。后一种情况下被连接计算机的 IP 地址将被重写,使得外网主机发出的数据包能够到达内网主机。

Linux 下,iptables 的 SNAT 除了 SNAT target 外,还有 MASQUERADE、NETMAP。MASQUERADE target 与 SNAT 差不多,区别主要是 MASQUERADE 能够自动选择出口网卡的动态 IP 地址,而 NETMAP 则是只转换 IP 地址。DNAT 的 target 有 DNAT、REDIRECT,区别是 REDIRECT 只进行端口转换而 IP 地址并不会改变。还有一类 target 叫做 NETMAP,只转换 IP 地址,同时拥有 SNAT 与 DNAT 的功能。讨论 Linux kernelNAT 实现的文章不少,比如 iptables 深入解析 - nat 篇,在此不想谈论这些,而是其他的一些东西。

时隔一个半月,继续…

  • REDIRECT 只进行端口映射,并不改变 IP 地址。这点可以在源码中看到
 
 
// /net/netfilter/nf_nat_redirect.c, function: nf_nat_redirect_ipv4
/* Local packets: make them go to loopback */
if (hooknum == NF_INET_LOCAL_OUT) {
newdst = htonl(0x7F000001);
} else {
struct in_device *indev;
struct in_ifaddr *ifa;

newdst = 0;

rcu_read_lock();
indev = __in_dev_get_rcu(skb->dev);
if (indev && indev->ifa_list) {
ifa = indev->ifa_list;
newdst = ifa->ifa_local;
}
rcu_read_unlock();

if (!newdst)
return NF_DROP;
}

 NAT 穿透

NAT 穿透是比较常见的一个问题,在 P2P 中被广泛应用。在了解 NAT 穿透之前需要了解 NAT 的种类,Wikipedia 上面给了很详细的说明STUN 是一种网络协议,它允许位于 NAT(或多重 NAT)后的客户端找出自己的公网地址,查出自己位于哪种类型的 NAT 之后以及 NAT 为某一个本地端口所绑定的 Internet 端端口。这些信息被用来在两个同时处于 NAT 路由器之后的主机之间建立 UDP 通信。该协议由 RFC 5389 定义。UPnP 是由 “通用即插即用论坛” 推广的一套网络协议。该协议的目标是使家庭网络(数据共享、通信和娱乐)和公司网络中的各种设备能够相互无缝连接,并简化相关网络的实现。UPnP 通过定义和发布基于开放、因特网通讯网协议标准的 UPnP 设备控制协议来实现这一目标,也是 NAT 穿透的标准之一。

 IPSec 中的 NAT

提起 NAT 的源于一篇 gist 朴素 VPN:一个纯内核级静态隧道,上面提到的东西在这里不提。值得注意的是,IPSec 本身就有 UDP 封装的配置,也有响应的 RFC 规定了如何穿透 NAT,但这里为什么要多此一举呢?简单地说,这在 RFC 有关 IPSec 的地方中有提及。

结束与 08 月 01 日 18 点 38 分,太懒了,不写了。

 参考资料