IP分片报文的接收与重组

对于长度超过接口MTU的数据包,需要进行分片处理,IP报头中与分片相关的字段有如下几个:

Identification       -  用来确认不同的分片是否属于同一个IP报文;
Flags                  -  其中IP_MF表示还有分片,此分片为中间分片;
Fragment Offset -  表示此分片在整个报文中的偏移地址。

了解了这几个字段之后,来看一下内核中的实现。

分片接收队列

内核将接收到的分片报文暂存在一个ipq结构的队列中。由ipq结构的定义与查找匹配函数(ip4_frag_match)可知,以下几个值唯一确定一个分片队列:

user         重组报文的用户
saddr       源IP地址
daddr       目的IP地址
id              IP报文ID标识
protocol    传输层协议号
vif             L3 master device index

user
重组报文的用户,也就是重组之后报文的使用者。例如在ip_local_deliver中调用重组函数(ip_defrag),user参数使用的是IP_DEFRAG_LOCAL_DELIVER值,即此处的数据包重组是为了要传给本机的上层应用程序使用。另外,netfilter的透明代理(tproxy)和socket匹配也需要将所有分片进行重组,如是此目的,user参数使用IP_DEFRAG_CONNTRACK_IN。user参数的引入可使内核对同一组数据分片同时进行不同目的的重组,完整的user值参见内核代码ip_defrag_users枚举类型定义。

saddr#daddr#id#protocl
四个IP报头的字段。

vif
L3mdev设备索引。l3mdev用于实现VRF(Virtual Forwarding and Routing)功能,不同的VRF之间是三层相互隔离的,在两个VRF中可存在其它几个参数相同的数据包,此时需要vif索引加以区分。

struct ipq {
    struct inet_frag_queue q;
    u32         user;
    __be32      saddr;
    __be32      daddr;
    __be16      id;
    u8          protocol;
    int         vif;   /* L3 master device index */
};

实际的分片保存在结构体ipq中的成员q(inet_frag_queue)内,其中fragments指向分片队列的头,fragments_tail指向分片的队列尾。

struct inet_frag_queue {
    struct hlist_node   list;
    struct sk_buff      *fragments;
    struct sk_buff      *fragments_tail;
};

 

分片存储结构

IP分片在内核中分两级存储。其一,根据IP报头的4个字段计算得到一个hash值,数据包按照此hash值散列于相应的bucket中。此hash数组大小为1024。所以,此处的查找非常简单,只需要将计算得到的hash值作为索引(ip4_frags.hash[hash])即可得到相应的bucket。全局变量ip4_frags保存有所有ipv4相关的分片信息。

struct inet_frags {
    struct inet_frag_bucket hash[INETFRAGS_HASHSZ];
};
static struct inet_frags ip4_frags;

hash = ipqhashfn(iph->id, iph->saddr, iph->daddr, iph->protocol);

其二,在inet_frag_bucket结构中,chain链表用于将所有已接收到的分片通过分片队列结构(inet_frag_queue)中的list成员链接起来。但是上文提到的ipq结构又在什么地方呢?实际内核代码中inet_frag_queue属于ipq的一部分,为ipq的第一个成员,内核并不单独分配inet_frag_queue结构,而是通过分配一个ipq将其一并创建出来。所以chain链表上也可以说是链接的ipq结构。

struct inet_frag_bucket {
    struct hlist_head   chain;
    spinlock_t      chain_lock;
};

#define INETFRAGS_MAXDEPTH  128

以chain开头的链表长度最大为128,即内核最大能接收具有128个分片的数据包。

IP分片报文的接收与重组_第1张图片

分片队列查找

内核在接收到一个分片时,首先查找是否已接收过同一个报文的其它分片。查找过程在分片存储的二级结构中进行,第一级通过IP报头的id、saddr、daddr和 protocol字段找到相应bucket。

第二级遍历bucket的chain链表,找到正确的ipq分片队列,匹配函数见ip4_frag_match实现。

static bool ip4_frag_match(const struct inet_frag_queue *q, const void *a)
{
    return  qp->id == arg->iph->id &&
        qp->saddr == arg->iph->saddr &&
        qp->daddr == arg->iph->daddr &&
        qp->protocol == arg->iph->protocol &&
        qp->user == arg->user &&
        qp->vif == arg->vif;
}

如果是数据包的第一个分片没有ipq,此时如果chain链表的长度还没有超出INETFRAGS_MAXDEPTH(128),并且分片队列所占内存没有超出高阈值,分配一个新的ipq队列结构,添加到chain链表上。

 

分片的插入

现在找到了当前接收的分片所需放入的队列ipq(ipq->inet_frag_queue),需要考虑插入的位置了。在结构inet_frag_queue中,成员fragments(struct sk_buff)指向第一个分片,fragments_tail指向最后一个。分片之间通过sk_buff的next成员组成一个单向链表,分片按照IP头部OFFSET字段的有小到大依次排列。来看插入处理函数ip_frag_queue。

a)正常情况下顺序接收到分片数据包,当前接收到的分片的OFFSET就会大于已接收的最后一个分片的OFFSET,或者如果是接收到第一个分片报文,分片链表末尾fragments_tail为空,此两种情况下,当前接收的分片都需要添加到sk_buff链表末尾,仅需要获得前一个sk_buff(prev)指针。

    prev = qp->q.fragments_tail;
    if (!prev || FRAG_CB(prev)->offset < offset) {
        next = NULL;
        goto found;
    }

b)接收到乱序分片。需要遍历sk_buff分片链表查找合适插入位置,获取前一个(prev)与后一个(next)分片的sk_buff指针。

    for (next = qp->q.fragments; next != NULL; next = next->next) {
        if (FRAG_CB(next)->offset >= offset)
            break;  /* bingo! */
        prev = next;
    }

c)丢弃不合法分片。正常情况下,每接收一个分片就将队列的接收计数加一,同时将相应的对端系统的接收计数加一,二者一致。但是,内核有可能在此期间接收到相同的源地址设备发送的另外一组需要分片的数据流,其会对应到另外一个分片队列,将会导致内核的对端系统(peer->rid)接收计数增加。此后,再此接收到前一个队列的分片时,分片队列ipq的rid就会小于对端系统的rid,如果二者的差值大于64时,内核认为是非法的分片,将会丢弃整个分片队列。

static int ip_frag_too_far(struct ipq *qp)
{
    struct inet_peer *peer = qp->peer;
    unsigned int max = qp->q.net->max_dist;

    start = qp->rid;
    end = atomic_inc_return(&peer->rid);
    qp->rid = end;
}
static int __net_init ipv4_frags_init_net(struct net *net)
{   
    net->ipv4.frags.max_dist = 64;
}

至此,我们也获得了当前分片的插入位置(prev和next),将分片链接到prev之后,next之前。

    /* Insert this fragment in the chain of fragments. */
    skb->next = next;
    if (!next)
        qp->q.fragments_tail = skb;
    if (prev)
        prev->next = skb;
    else
        qp->q.fragments = skb;

当前接收的分片数据包可能与前一个或者后一个已有分片存在重叠部分,需要进行合并。如果与前一个分片(prev)重叠,采用增加当前分片的OFFSET值的方法避开重叠部分;如果是与后一个分片(next)重叠,一种情况是与后一分片的以部分重叠,采用增加后一分片的OFFSET的值来避开重叠部分;另外一种情况是重叠的部分包含整个后一分片,此时就可以free是否掉后一分片,继续检查是否与后后的分片重叠,循环进行处理,直到解决重叠问题为止。

 

分片重组

 

重组的前提是接收到所有的分片。内核判断一个队列是否接收到了所有分片需要满足三个条件:

a)INET_FRAG_FIRST_IN  - 在接收到OFFSET为0值的数据包时设置此标志;
b)INET_FRAG_LAST_IN   - 接收到IP报头中More Fragmentation(IP_MF)标志等于0的分片时,设置此标志位;
c)inet_frag_queue中meat等于len - meat在每次成功插入一个分片后增加此分片的长度值,len值由最后一个分片的OFFSET值加上其长度获得。

    if (qp->q.flags == (INET_FRAG_FIRST_IN | INET_FRAG_LAST_IN) &&
        qp->q.meat == qp->q.len)
        err = ip_frag_reasm(qp, prev, dev);

来看重组函数ip_frag_reasm,首先检查最近插入的分片报文是否为数据包的第一个分片,如果不是必定存在前一个分片(prev不为空),此时,如下的代码将使用最近插入的这个分片结构(sk_buff)作为分片链表的头。

首先克隆clone一份最近接收的这一分片,将克隆之后的分片重新链接到分片链表中,替换掉之前的分片。之后将链表头分片(fragments)克隆到最近接收的这一分片中,释放位于链表头的分片,将最近接收分片设置为链表头。

    if (prev) {
        head = prev->next;
        fp = skb_clone(head, GFP_ATOMIC);
        if (!fp)
            goto out_nomem;

        fp->next = head->next;
        if (!fp->next)
            qp->q.fragments_tail = fp;
        prev->next = fp;

        skb_morph(head, qp->q.fragments);
        head->next = qp->q.fragments->next;

        consume_skb(qp->q.fragments);
        qp->q.fragments = head;
    }

其次检查所有分片的总长度是否超过65535,超出65535的数据包不做重组,函数直接返回。

    ihlen = ip_hdrlen(head);
    len = ihlen + qp->q.len;

    if (len > 65535) goto out_oversize;

对于分片链表的头一个分片(head),如果其自身包括分片,需要做一些特殊处理。为其分片数据单独创建一个sk_buff,将其链接在链表头head之后。head仅包含数据区与页面数据存储区。修改相应的长度信息。

    if (skb_has_frag_list(head)) {
        struct sk_buff *clone;

        clone = alloc_skb(0, GFP_ATOMIC);

        clone->next = head->next;
        head->next = clone;
        skb_shinfo(clone)->frag_list = skb_shinfo(head)->frag_list;
        skb_frag_list_init(head);

        for (i = 0; i < skb_shinfo(head)->nr_frags; i++)
            plen += skb_frag_size(&skb_shinfo(head)->frags[i]);
        clone->len = clone->data_len = head->data_len - plen;
        head->data_len -= clone->len;
        head->len -= clone->len;
    }

真正的重组操作,其实很简单。涉及到需要修改的为长度信息,包括data_len、len和truesize。head为最终重组完成后的sk_buff结构。

    for (fp=head->next; fp; fp = fp->next) {
        head->data_len += fp->len;
        head->len += fp->len;
        head->truesize += fp->truesize;
    }

分片生存时间

现实网络环境中,有可能接收不到一个数据包的所有分片,无法重组数据包将导致这些分片一直驻留在分片队列中。内核采用超时机制处理这些分片。在接收到第一个数据包分片,创建分片队列后,内核随即启动超时计时器,超时时间从网络命名空间结构中获取(timeout), 默认情况下超时时间为30秒钟(IP_FRAG_TIME)。

static struct inet_frag_queue *inet_frag_alloc(struct netns_frags *nf, struct inet_frags *f, ...)
{
    timer_setup(&q->timer, f->frag_expire, 0);
}
static struct inet_frag_queue *inet_frag_intern(struct netns_frags *nf, ...)
{
    if (!mod_timer(&qp->timer, jiffies + nf->timeout))
        refcount_inc(&qp->refcnt);
}

#define IP_FRAG_TIME    (30 * HZ)       /* fragment lifetime    */

static int __net_init ipv4_frags_init_net(struct net *net)
{   
    net->ipv4.frags.timeout = IP_FRAG_TIME;
}
void __init ipfrag_init(void)
{   
    ip4_frags.frag_expire = ip_expire;
}

超时定时器在到期之后,使用ip_expire函数释放分片队列(ipq_put)。如果本机就是这些分片报文的目的主机,回复ICMP的分片重组超时消息(type=ICMP_TIME_EXCEEDED(11), code=ICMP_EXC_FRAGTIME(1))。

 

分片重组内存管理

分片重组系统在初始化时,限定其内存使用不超过4M字节(high_thresh)的内存(基于网络命名空间),如果超过high_thresh,内核会释放一部分分片,将内存使用见底到3M字节(low_thresh)。

每个分片占用的内存使用其sk_buff的truesize值统计(其包括sk_buff结构体占用内存、skb_shared_info结构体占用内存与数据包占用内存的总和)。对于第一个分片,还要分配分片队列(ipq),也要计入到内存占用中。当分片重组或者超时删除之后,减低内容占用统计。

当接收到一个分片报文,查找是否已存在相应的分片队列时,检查当前网络命名空间中分片占用内存是否大于low_thresh,如大于,调唤醒初始化时注册的工作队列函数(inet_frag_worker)释放部分分片占用的内存。
在分配新的分片队列(inet_frag_queue)时,首先检查当前占用内存是否超过high_thresh,如超过不再分片新的分片队列。

struct inet_frag_queue *inet_frag_find(...)
{
    if (frag_mem_limit(nf) > nf->low_thresh)
        inet_frag_schedule_worker(f);
}
static struct inet_frag_queue *inet_frag_alloc(...)
{
    if (!nf->high_thresh || frag_mem_limit(nf) > nf->high_thresh) {
        inet_frag_schedule_worker(f);
		return NULL;
	}
}

由于内存超限,inet_frag_worker函数每次执行最多扫描128个bucket中的分片队列,每次最多释放512个分片队列(inet_frag_queue)。执行完成之后记录最后扫描的bucket索引,下次被唤醒时,由此索引开始继续扫描。

#define INETFRAGS_EVICT_BUCKETS   128
#define INETFRAGS_EVICT_MAX   512

 

分片队列重建

分片bucket中的链表chain深度超过128时(系统中同时进行处理重组数据包的用户(IP_DEFRAG_LOCAL_DELIVER)或者l3mdev过多),将不能够再创建出新的分片队列(ipq)。此时也需要唤醒工作队列(inet_frag_worker)释放部分分片,并且在重建间隔大于5秒钟时,重建分片结构。

#define INETFRAGS_MAXDEPTH  128
#define INETFRAGS_MIN_REBUILD_INTERVAL (5 * HZ)

重建主要是改变了分片的hash值,rebuild重建函数修改了hash函数的参数ip4_frags.rnd,从而导致ip4_frags中以hash值为索引存储在bucket结构中的ipq链表出现索引与hash值不一致的情况。重建就是将二者调整一致。

static void inet_frag_secret_rebuild(struct inet_frags *f)
{
    get_random_bytes(&f->rnd, sizeof(u32));
}
static unsigned int ipqhashfn(__be16 id, __be32 saddr, __be32 daddr, u8 prot)
{
    net_get_random_once(&ip4_frags.rnd, sizeof(ip4_frags.rnd));
    return jhash_3words((__force u32)id << 16 | prot, (__force u32)saddr, (__force u32)daddr,
                ip4_frags.rnd);
}
struct inet_frags {
    struct inet_frag_bucket hash[INETFRAGS_HASHSZ];
}

 

重组参数控制

PROC系统中的控制文件(/proc/sys/net/ipv4):

ipfrag_high_thresh    -  分片占用内存的高阈值
ipfrag_low_thresh      -  分片占用内存的低阈值
ipfrag_max_dist         -  分片有效的最长间隔距离,表示允许的分片队列中接收计数(ipq->rid)与对端系统(inet_peer->rid)中的接收计数的最大差值。 从同一个源地址接收到的分片
ipfrag_secret_interval  -  目前没有在使用。今后可能用于控制分片重组系统重建时间间隔
ipfrag_time                    -  分片超时时间

 

内核版本

Linux-4.15

 

你可能感兴趣的:(TCPIP协议)