#220915 UDP 隧道

收包处理路径

以 fou 隧道为例,隧道创建的时候,会创建一个 udp socket,并设置其对应的 udp_sock.encap_rcvfou_udp_recv 函数。

fou_create(struct net *net, struct fou_cfg *cfg, struct socket **sockp)
|- struct socket *sock = NULL
|- udp_sock_create(net, &cfg->udp_config, &sock)
|  ...
|- struct udp_tunnel_sock_cfg tunnel_cfg
|- tunnel_cfg.encap_rcv = fou_udp_recv
|  ...
|- setup_udp_tunnel_sock(net, sock, &tunnel_cfg)
   |- struct sock *sk = sock->sk
   |  ...
   |- udp_sk(sk)->encap_rcv = tunnel_cfg->encap_rcv
   |  ...

通过 netstat 可以看到这个 socket。

# ip fou add port 19523
# netstat -nlu
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State
...
udp        0      0 0.0.0.0:19523           0.0.0.0:*
...

然后 udp_rcv 函数中会判断找到的 udp socket 的 encap_rcv 是不是为空,为空,走正常路径,如果不为空,则走隧道路径。

  udp_rcv
  |- __udp4_lib_rcv
     |- sk = __udp4_lib_lookup_skb
     |- udp_unicast_rcv_skb(sk, skb)
        |- udp_queue_rcv_skb
           |- udp_queue_rcv_one_skb
+             |- struct udp_sock *up = udp_sk(sk)
+             |- if up->encap_type
+             |     ret = up->encap_rcv(sk, skb)
+             |     if ret <= 0
+             |        return -ret
              |- __udp_queue_rcv_skb

encap_rcv 回调函数的返回值有以下三种情况:

  1. 等于 0,这个包被隧道层处理函数丢弃掉了,直接返回即可。

  2. 大于 0,内层包的协议也是 UDP,继续往下执行。

  3. 小于 0,这种内层封包的协议不是 UDP,需要回溯到网络层,在 ip_local_deliver_finish 中重新投送。

  ip_local_deliver_finish
  |- ip_protocol_deliver_rcu(skb, ip_hdr(skb)->protocol)
+    |resubmit:
     |- ipprot = inet_protos[protocol]
     |- ret = ipprot->handler/tcp_v4_rcv/udp_rcv/tunnel4_rcv/tunnel64_rcv(skb)
+    |- if ret < 0
+         protocol = -ret
+         goto resubmit

inet_protos 里除了普通传输层协议的各种处理函数,还有各种隧道协议注册的处理函数。

解包

首先在 fou 模块的设置的 encap_rcv 回调函数 fou_udp_recv 中。剥除外层的 UDP 头,将传输层指向内层 IP/IPv6 包的开头。然后回调函数返回 -IPPROTO_IPIP 或者 -IPPROTO_IPV6 来调用通用的解包函数来接包。

static int fou_recv_pull(struct sk_buff *skb, struct fou *fou, size_t len)
{
   // 从外层 IP 包中减去 UDP 头的大小
   if (fou->family == AF_INET)
      ip_hdr(skb)->tot_len = htons(ntohs(ip_hdr(skb)->tot_len) - len);
   else
      ipv6_hdr(skb)->payload_len =
          htons(ntohs(ipv6_hdr(skb)->payload_len) - len);

   // 剥除 UDP 协议头
   __skb_pull(skb, len);
   // 更新外层 IP 包的 checksum
   skb_postpull_rcsum(skb, udp_hdr(skb), len);
   // 更新传输层指向内层 IP 包的开头
   skb_reset_transport_header(skb);
   return iptunnel_pull_offloads(skb);
}

static int fou_udp_recv(struct sock *sk, struct sk_buff *skb)
{
   struct fou *fou = fou_from_sock(sk);

   if (!fou)
      return 1;

   if (fou_recv_pull(skb, fou, sizeof(struct udphdr)))
      goto drop;

   // 返回 fou 隧道的协议,可能是 IPPROTO_IPIP、IPPROTO_IPV6
   return -fou->protocol;

drop:
   kfree_skb(skb);
   return 0;
}

如果是一个 IPv6 in IPv4 的包。那就是返回 -IPPROTO_IPV6,回到 IP 层 resubmit 后就到了 tunnel64_rcv 这个回调函数中。

static int tunnel64_rcv(struct sk_buff *skb)
{
   struct xfrm_tunnel *handler;

   if (!pskb_may_pull(skb, sizeof(struct ipv6hdr)))
      goto drop;

   for_each_tunnel_rcu(tunnel64_handlers, handler)
      if (!handler->handler(skb))
         return 0;

   icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);

drop:
   kfree_skb(skb);
   return 0;
}

sit 模块在初始化的时候会调用 xfrm4_tunnel_register 注册 IPPROTO_IPV6 的处理函数 ipip6_rcv

static struct xfrm_tunnel sit_handler __read_mostly = {
   .handler =        ipip6_rcv,
   .err_handler =    ipip6_err,
   .priority =       1,
};

static int __init sit_init(void)
{
   ...
   err = xfrm4_tunnel_register(&sit_handler, AF_INET6);
   ...
}

tunnel64_rcv 中会遍历所有注册的处理 IPv6 in IPv4 的包的处理函数并调用。就会调用到 ipip6_rcv

static int ipip6_rcv(struct sk_buff *skb)
{
   // 这个是外层 IP 头
   const struct iphdr *iph = ip_hdr(skb);
   ...
   tunnel = ipip6_tunnel_lookup(dev_net(skb->dev), skb->dev,
               iph->saddr, iph->daddr, sifindex);
   if (tunnel) {
      ...

      // 让 iphdr 指针指向内层 IPv6 头
      skb_reset_network_header(skb);

      ...

      // 重新提交包到协议栈网络层
      netif_rx(skb);

      return 0;
   }

   /* no tunnel matched,  let upstream know, ipsec may handle it */
   return 1;
}

GRO 没起作用?

5.13 前内核的 UDP 隧道 有个 GRO 的 BUG,UDP 包的 checksum 为 0 的时候, udp_gro_receive 函数中会误认为不满足合并包的条件而提前终止,所以 GUE 这一类的 UDP 隧道协议虽然早就实现了协议对应的 gro_receivegro_complete 处理函数,但是因为 GRO 在 UDP 层提前终止了,实际还是没有起作用。

详细见:https://github.com/torvalds/linux/commit/89e5c58fc1e2857ccdaae506fb8bc5fed57ee063

虽然物理网卡这一层的 GRO 没有生效,但是对于 IPPROTO_IPIP 类型的隧道协议包,在解包之后 redirect 到对应的逻辑网卡时还有一次 GRO 机会。

ipip_rcv
|- ipip_tunnel_rcv(skb, IPPROTO_IPIP)
   |- ip_tunnel_rcv
      |- gro_cells_receive
         |- if (!gcells->cells || skb_cloned(skb) || !(dev->features & NETIF_F_GRO))
         |    netif_rx(skb)
         |    return
         |- __skb_queue_tail(&cell->napi_skbs, skb)
         |- napi_schedule(&cell->napi)

这个看到的现象就是 tcpdump 看物理网卡上收到的 UDP 隧道协议包都是大小 1500 以下的小包,但是在隧道对应的逻辑网卡上抓包看到的内层包能看到 1500 以上的大包。

IPPROTO_IPV6 协议没有,详细可以看上面 ipip6_rcv 函数, ipip6_rcv 没有做第二次 GRO。

内核 UDP 性能相关的一些优化和版本:https://developers.redhat.com/articles/2021/11/05/improve-udp-performance-rhel-85

外层 checksum 速算法与 LCO

不升级内核解决上面 GRO 没生效的一个方法就是填上外面隧道层 UDP 头中的 checksum 字段。外层 checksum 有快速算法。

先看 TCP/UDP 协议是如何计算头里的 checksum 字段的。

../_images/tcp-checksum.png

首先,TCP/UDP 计算 checksum 的时候,除了 TCP/UDP 包自身的数据外,还包含一部分从 IP 层提取的数据(源/目的地址等),这部分数据一般称为 pseduo-header 伪头。计算 checksum 的时候是对这两个数据来做计算。

计算的方法是将这部分数据按照 2 个字节一组作二进制循环加法(循环的意思是如果加完后溢出,溢出的部分要加到低位去),最后加完后取其补码,将补码填到 TCP/UDP 的 checksum 字段中。计算的时候 checksum 字段填 0。

校验的时候还按照计算的方法,对这部分数据计算其 checksum,此时 checksum 已经填上了值,因为 checksum 是剩余部分的补码,所以此时计算出的结果应该是 0xffff,如果计算结果不是这个,说明传输的数据包有问题。

checksum(pseudo-header) + checksum(tcp-segment) = 0xffff

https://en.wikipedia.org/wiki/Transmission_Control_Protocol#Checksum_computation

根据 checksum 计算方法可以得出:填上了 checksum 字段后,TCP 包的 checksum 就是 pseudo-header 的 checksum 的反码。好了,大头部分的 checksum 已经快速算完了,剩余部分(外层 pseduo-header,外层 UDP 头、内层 IP 头等)的 checksum 正常计算然后和速算部分相加就可以了。

这个速算方法就是内核中 LCO(Local Checksum Offload)的原理。详细见:

TSO/GSO

veth 测隧道 TSO 没有问题,但虚拟机 virtio 驱动下 TSO 似乎都有问题?(待进一步测试),在 veth 上测,GRO/GSO 关闭开启对性能影响不是特别大,但是 TSO 对性能影响巨大。

各种隧道类型

https://developers.redhat.com/blog/2019/05/17/an-introduction-to-linux-virtual-interfaces-tunnels

发包处理路径

udp 隧道的封包一般是在虚拟网卡的驱动中去做的,以 fou 隧道为例:

# ip link add name tun1 type ipip remote 192.168.1.1 local 192.168.1.2 encap fou encap-sport auto encap-dport 5555

从 fou 隧道的创建命令可以看出,fou 隧道底层实际上创建的是一个 ipip 隧道,只不过参数中指定了封包的类型为 fou。

static const struct net_device_ops ipip_netdev_ops = {
   ...
   .ndo_start_xmit   = ipip_tunnel_xmit,
   ...
};

ipip 类型的虚拟网卡设置的其网络驱动层的发包函数为 ipip_tunnel_xmit,这个函数会根据注册的封包协议找到对应的封包函数,封包,然后和其他 ipip 包一样去处理。

ipip_tunnel_xmit
|- ...
|- struct ip_tunnel *tunnel = netdev_priv(dev);
|- ip_tunnel_xmit(skb, dev, tiph, ipproto);
   |- ...
   |- ip_tunnel_encap(skb, tunnel, ...);
   |  |- ...
   |  |- ops = rcu_dereference(iptun_encaps[tunnel->encap.type])
   |  |- ops->build_header(skb, &t->encap, protocol, fl4)
   |  |- ....
   |
   |- iptunnel_xmit(NULL, rt, skb, fl4.saddr, fl4.daddr, protocol, ...);
      |- ...
      |- skb_push(skb, sizeof(struct iphdr));
      |- skb_reset_network_header(skb);
      |- iph = ip_hdr(skb);
      |- iph->version = 4;
      |- ...
      |- ip_local_out(net, sk, skb);
      |- ...

对应的封包函数是 fou 模块在初始化的时候注册的。

// fou_init 调用本函数注册 fou 封包的回调函数
static int ip_tunnel_encap_add_fou_ops(void)
{
   ...
   ret = ip_tunnel_encap_add_ops(&fou_iptun_ops, TUNNEL_ENCAP_FOU);
   ...
}
int ip_tunnel_encap_add_ops(const struct ip_tunnel_encap_ops *ops, unsigned int num)
{
   ...
   return !cmpxchg((const struct ip_tunnel_encap_ops **)
         &iptun_encaps[num],
         NULL, ops) ? 0 : -1;
}

static const struct ip_tunnel_encap_ops fou_iptun_ops = {
   .encap_hlen = fou_encap_hlen,
   .build_header = fou_build_header,
   .err_handler = gue_err,
};

因为封包是在网络驱动层去做的,在抓包程序的挂载点(网络设备层)之下,所以从虚拟网卡上可以抓包看到原始要发送的包。