8.6 接收 IPv6 数据包
上一节我们聊了地址配置——也就是「怎么让这台机器在网络上有个身份」。现在身份有了,真正的好戏才刚刚开始。
当一个带着 IPv6 头部的数据包顺着网线爬进来时,内核要做的第一件事并不是把它交给应用程序,而是要先进行一轮「安检」。这个过程发生在 ipv6_rcv() 里,它是所有 IPv6 数据包的必经之路,无论是单播还是组播(记得吗?IPv6 没有广播)。
这一节的接收路径和 IPv4 非常像,但 IPv6 有它自己的脾气。
ipv6_rcv():第一道防线
ipv6_rcv() 的任务很简单:把明显有问题或者不守规矩的包挡在门外。
我们先看源码。这个函数接收一个 sk_buff(我们通常叫它 skb),以及相关的网络设备信息。
int ipv6_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt,
struct net_device *orig_dev)
{
const struct ipv6hdr *hdr;
u32 pkt_len;
struct inet6_dev *idev;
struct net *net = dev_net(skb->dev);
...
hdr = ipv6_hdr(skb);
首先拿到网络命名空间和 IPv6 头部。接下来是几道著名的「 sanity checks」(完整性检查)。这些检查虽然琐碎,但每一条背后都是 RFC 的硬性规定或者血的教训。
第一,版本号必须是对的。
虽然以太网驱动通常已经根据 EtherType 把包分发给了 IPv6 模块,但内核还是习惯性地确认一下头部里的 version 字段是不是 6。如果不是,直接扔掉。
if (hdr->version != 6)
goto err;
第二,环路地址检测。
有一个反直觉的规定:从外面进来的包,如果目标地址是环回地址(::1),那是违法的。环回地址只应该在自己内部流通,不应该出现在网卡上。
if (!(dev->flags & IFF_LOOPBACK) &&
ipv6_addr_loopback(&hdr->daddr))
goto err;
第三,源地址不能是组播地址。
根据 RFC 4291 的规定,组播地址只能做目标地址,不能做源地址。如果你收到一个源地址是 ff00:: 开头的包,别犹豫,直接丢。
if (ipv6_addr_is_multicast(&hdr->saddr))
goto err;
处理 Hop-by-Hop 扩展头
如果 ipv6_rcv() 觉得这个包看起来还算正经,接下来就要处理一个特殊的角色:Hop-by-Hop Options 扩展头。
这是 IPv6 里很特别的一个东西。它是唯一必须被路径上每一个路由器都处理的扩展头。在 IPv6 头部里,nexthdr 字段指向下一个头部的类型。如果这个值是 0(NEXTHDR_HOP),说明紧跟在后面的是 Hop-by-Hop 头,内核必须立刻停下来解析它。
if (hdr->nexthdr == NEXTHDR_HOP) {
if (ipv6_parse_hopopts(skb) < 0) {
IP6_INC_STATS_BH(net, idev, IPSTATS_MIB_INHDRERRORS);
rcu_read_unlock();
return NET_RX_DROP;
}
}
如果解析失败(比如格式错误),统计计数器加一,包被丢弃。
通过 Netfilter 钩子
一切顺利的话,ipv6_rcv() 的最后一站是 Netfilter。
return NF_HOOK(NFPROTO_IPV6, NF_INET_PRE_ROUTING, skb, dev, NULL,
ip6_rcv_finish);
NF_HOOK 宏是内核网络栈里的一个经典设计。它会把包挂到 NF_INET_PRE_ROUTING 这个钩子上。如果你配置了 iptables 或者 nftables 的规则(比如 raw 表的 PREROUTING 链),这时候就会触发。
如果防火墙决定放行,流程就会走到 ip6_rcv_finish()。
ip6_rcv_finish():命运的岔路口
ip6_rcv_finish() 这个名字听起来像是收尾工作,但其实它才是决定数据包命运的地方。
int ip6_rcv_finish(struct sk_buff *skb)
{
...
if (!skb_dst(skb))
ip6_route_input(skb);
return dst_input(skb);
}
这里的逻辑分两步:
- 查路由:如果这个 skb 还没有绑定目的缓存(dst entry),就调用
ip6_route_input()去查路由表。底层会调用到fib6_rule_lookup()(或者fib6_lookup(),取决于你是否开启了多路由表支持)。 - 交付:拿到路由结果后,调用
dst_input(skb)。
dst_input 是一个神奇的小函数,它会直接调用路由查找结果里预设的那个 input 回调函数。
这就像把一个包裹交给快递员,快递员扫描单号后发现:
- 如果是本地的,就把它扔进本地投递篮子(
ip6_input)。 - 如果是给别人的,就把它扔进转发传送带(
ip6_forward)。 - 如果是给一群人的(组播),就交给组播处理(
ip6_mc_input)。 - 如果找不到路或者明确要丢弃,就把它扔进焚化炉(
ip6_pkt_discard),顺便发个 ICMPv6 「不可达」消息回去。
接下来,我们分两条路来看:本地投递和转发。
本地投递
当路由系统判定这个包是发给本机的,dst_input 就会调用 ip6_input()。
int ip6_input(struct sk_buff *skb)
{
return NF_HOOK(NFPROTO_IPV6, NF_INET_LOCAL_IN, skb, skb->dev, NULL,
ip6_input_finish);
}
这里又经过了一道 Netfilter 钩子(NF_INET_LOCAL_IN),也就是 iptables 的 INPUT 链。过了这关,才进入真正的处理逻辑 ip6_input_finish()。
解析扩展头与分发协议
ip6_input_finish() 的核心任务是剥洋葱。
IPv6 的核心头部后面可能挂了一串扩展头。每剥开一层,nexthdr 字段就会告诉我们里面装的是什么。最后剥到核心,才会是 TCP、UDP 或者 ICMPv6。
static int ip6_input_finish(struct sk_buff *skb)
{
struct net *net = dev_net(skb_dst(skb)->dev);
const struct inet6_protocol *ipprot;
struct inet6_dev *idev;
unsigned int nhoff;
int nexthdr;
bool raw;
rcu_read_lock();
resubmit:
idev = ip6_dst_idev(skb_dst(skb));
if (!pskb_pull(skb, skb_transport_offset(skb)))
goto discard;
nhoff = IP6CB(skb)->nhoff;
nexthdr = skb_network_header(skb)[nhoff];
raw = raw6_local_deliver(skb, nexthdr);
代码首先尝试把包交给Raw Socket。如果你用抓包工具或者自己写的 Raw Socket,这时候就能先拿到原始数据。
紧接着,内核拿着 nexthdr 去查一个全局数组 inet6_protos。这个数组就像一个分发中心,里面注册了各种协议的处理函数。
if ((ipprot = rcu_dereference(inet6_protos[nexthdr])) != NULL) {
int ret;
if (ipprot->flags & INET6_PROTO_FINAL) {
const struct ipv6hdr *hdr;
nf_reset(skb);
skb_postpull_rcsum(skb, skb_network_header(skb),
skb_network_header_len(skb));
hdr = ipv6_hdr(hdr);
关于 MLD 的一个特例(坑点)
这里有一个非常隐蔽的坑,是关于组播源过滤的。
通常,如果网卡启用了源过滤,某个组播地址如果不允许某个源地址发送数据,内核应该会把包丢弃。但是,MLD(Multicast Listener Discovery)协议包是例外。
RFC 3810 明确规定:MLD 消息不受源过滤限制,必须被处理。为什么?因为 MLD 是用来维护组播组成员关系的协议,如果你把它过滤掉了,整个组播机制就崩了。
所以,代码里特意加了一段检查:
if (ipv6_addr_is_multicast(&hdr->daddr) &&
!ipv6_chk_mcast_addr(skb->dev, &hdr->daddr,
&hdr->saddr) &&
!ipv6_is_mld(skb, nexthdr, skb_network_header_len(skb)))
goto discard;
逻辑是:如果是组播包,且源地址没通过检查,且不是 MLD 包,那才丢弃。如果是 MLD,哪怕源地址不对,也要放行。
IPsec 策略检查与协议分发
继续往下走。如果协议注册时没有设置 INET6_PROTO_NOPOLICY 标志,说明需要进行 IPsec 策略检查(xfrm6_policy_check)。这是为了安全。
if (!(ipprot->flags & INET6_PROTO_NOPOLICY) &&
!xfrm6_policy_check(NULL, XFRM_POLICY_IN, skb))
goto discard;
ret = ipprot->handler(skb);
if (ret > 0)
goto resubmit;
else if (ret == 0)
IP6_INC_STATS_BH(net, idev, IPSTATS_MIB_INDELIVERS);
ipprot->handler(skb) 这一行代码,意味着数据包终于离开了网络层,交给了传输层(比如 tcp_v6_rcv 或 udpv6_rcv)。
如果 handler 返回大于 0 的值,说明这实际上还是一个扩展头,需要继续 resubmit 重新循环解析。如果是 0,说明成功交付,统计计数器加一。
转发
如果路由查表发现这个包不是给我的,而是需要我当个中间人转交出去,dst_input 就会调用 ip6_forward()。
转发和本地投递完全是两码事。作为路由器,内核必须遵守一系列规则,既要保证包能送出去,又不能让自己被当成攻击跳板。
int ip6_forward(struct sk_buff *skb)
{
struct dst_entry *dst = skb_dst(skb);
struct ipv6hdr *hdr = ipv6_hdr(skb);
struct inet6_skb_parm *opt = IP6CB(skb);
struct net *net = dev_net(dst->dev);
u32 mtu;
if (net->ipv6.devconf_all->forwarding == 0)
goto error;
首先,系统必须开启了转发功能(/proc/sys/net/ipv6/conf/all/forwarding)。如果是普通主机(forwarding 为 0),收到别人的包理应扔掉。
LRO 与安全检查
if (skb_warn_if_lro(skb))
goto drop;
if (!xfrm6_policy_check(NULL, XFRM_POLICY_FWD, skb)) {
...
goto drop;
}
if (skb->pkt_type != PACKET_HOST)
goto drop;
这里有几个关键检查:
- LRO 检查:如果网卡开了 LRO(Large Receive Offload),合并出来的超大包在转发时可能会有问题。内核通常不支持转发这种由硬件“拼凑”出来的巨大包,直接丢弃。
- 包类型检查:
skb->pkt_type必须是PACKET_HOST(发给我们的)。如果是PACKET_BROADCAST或其他类型,说明链路层有点不对劲,不能转发。
路由器报警
IPv6 有一个很有趣的机制叫路由器报警。如果数据包里带着一个特殊的扩展头,提示上层应用「快看这个包」,转发逻辑会变得稍微复杂一点。
if (opt->ra) {
u8 *ptr = skb_network_header(skb) + opt->ra;
if (ip6_call_ra_chain(skb, (ptr[2]<<8) + ptr[3]))
return 0;
}
代码尝试把包交给注册了 IPV6_ROUTER_ALERT 选项的 Raw Socket。如果有 Socket 成功接收了这个包(ip6_call_ra_chain 返回非零),转发流程就到此为止——这个包被用户空间“截胡”了,不再继续转发。
跳数限制(Hop Limit)
这是最容易触发的转发失败原因之一。IPv6 用 hop_limit 代替了 IPv4 的 TTL。
if (hdr->hop_limit <= 1) {
skb->dev = dst->dev;
icmpv6_send(skb, ICMPV6_TIME_EXCEED, ICMPV6_EXC_HOPLIMIT, 0);
IP6_INC_STATS_BH(net,
ip6_dst_idev(dst), IPSTATS_MIB_INHDRERRORS);
kfree_skb(skb);
return -ETIMEDOUT;
}
如果 hop_limit 已经减到了 1(意味着下一跳就是 0),包就不能再转发了。内核会发送一个 ICMPv6 «Time Exceeded» 消息回给源地址,然后丢包。这和 traceroute 工具的原理是一致的。
PMTU 与 ICMPv6 Packet Too Big
这是 IPv6 转发里极其重要的一点。IPv6 中间路由器不允许分片。这意味着,如果一个巨大的包想要通过一个 MTU 很小的网络,中间路由器必须告诉源主机:「哥们,你的包太大了,这过不去。」
mtu = dst_mtu(dst);
if (mtu < IPV6_MIN_MTU)
mtu = IPV6_MIN_MTU;
if ((!skb->local_df && skb->len > mtu && !skb_is_gso(skb)) ||
(IP6CB(skb)->frag_max_size && IP6CB(skb)->frag_max_size > mtu)) {
skb->dev = dst->dev;
icmpv6_send(skb, ICMPV6_PKT_TOOBIG, 0, mtu);
IP6_INC_STATS_BH(net,
ip6_dst_idev(dst), IPSTATS_MIB_INTOOBIGERRORS);
...
return -EMSGSIZE;
}
如果发现包长度(skb->len)大于出口的 MTU,内核就会触发 icmpv6_send() 发送一个 ICMPv6 Type 2 (Packet Too Big) 消息,并在消息里带上正确的 MTU 值。
这就是 PMTU (Path MTU Discovery) 的核心:源主机收到这个消息后,会把路径上的 MTU 调小,然后重新发包。如果这段逻辑断了,你会发现能访问小网站,但大图片或大文件完全加载不出来——典型的 PMTU 黑洞。
最后的冲刺
如果上述检查都通过了,最后一步就是扣减跳数,然后发出去。
if (skb_cow(skb, dst->dev->hard_header_len)) {
...
goto drop;
}
hdr = ipv6_hdr(skb);
hdr->hop_limit--;
IP6_INC_STATS_BH(net, ip6_dst_idev(dst), IPSTATS_MIB_OUTFORWDATAGRAMS);
...
return NF_HOOK(NFPROTO_IPV6, NF_INET_FORWARD, skb, skb->dev, dst->dev,
ip6_forward_finish);
skb_cow() 确保 skb 的头部数据是可写的(因为如果它是被克隆的,我们直接修改 hop_limit 会影响其他持有这个 skb 的地方)。
把 hop_limit 减 1 之后,再次通过 Netfilter 的 NF_INET_FORWARD 钩子(也就是 FORWARD 链),最后调用 ip6_forward_finish(),其实就是调用了 dst_output(skb),把包交给驱动发出去。
小结
这一节我们追踪了一个 IPv6 数据包进入内核后的两种命运:
- 本地投递:
ipv6_rcv->ip6_rcv_finish->ip6_input->ip6_input_finish。在这个过程中,内核会像剥洋葱一样处理扩展头,最后交给 TCP/UDP 或 Raw Socket。 - 转发:
ipv6_rcv->ip6_rcv_finish->ip6_forward。在这个过程中,内核扮演路由器的角色,检查跳数限制、PMTU,并进行源地址验证,最后把包发向下一跳。
这套流程和 IPv4 看起来很像,但在细节上——尤其是扩展头的处理、PMTU 的强制要求以及 MLD 的特例处理上——体现出了 IPv6 「精简头部、把复杂性交给扩展」的设计哲学。