searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享
原创

OVS 内核 CT实现

2022-12-30 02:22:13
122
0

[TOC]

# 引言

所有连接跟踪模块的作用,都是在首包到来时,识别并在CT表中生成表项;而当该连接的特定数据包到来时,在CT表内匹配表项,如果匹配命中,则执行响应预设动作。OVS 的连接跟踪实现也不例外。

# OVS CT 定义

## CT 匹配域

这里明确几个在OVS内的术语:

```cpp
new 通过ct action指定报文经过conntrack模块处理,不一定有commit。
est 表示conntrack模块看到了报文双向数据流,一定是在commit 的conntrack后
rel 表示和已经存在的conntrack相关,比如icmp不可达消息或者ftp的数据流
rpl 表示反方向的报文
inv 无效的,表示conntrack模块没有正确识别到报文,比如L3/L4 protocol handler没有加载,或者L3/L4 protocol handler认为报文错误
trk 表示报文经过了conntrack模块处理,如果这个flag不设置,其他flag都不能被设置。任何进来的数据包,都是-trk状态,只有该数据包经过ct模块处理了,才会变为+trk状态。什么叫经过ct模块处理?流表的action指定了ct,并且报文通过了协议验证:pkt->md.ct_state = CS_TRACKED
snat 表示报文经过了snat,源ip或者port
dnat 表示报文经过了dnat,目的ip或者port
```

**commit**:ovs内的数据包都是处理完,生命期就结束;commit action用于明确定义在CT表中生成表项,用于未来匹配。

**table**: fork一份pipeline,报文copy一份送给connection tracker,然后从当前指定table重入

**ct_nw_src**、**ct_nw_dst** 、**ct_nw_proto**、**ct_tp_src**、**ct_tp_dst**:ct 五元组

**ct_zone**:表示独立的CT 上下文,其拥有独立的ct表。作为action动作时,用于新建新的zone上下文,可以通过ct zone action来设置。没有新建过,则所有ct都在zone 0下进行。

**ct_mark**:可以通过 ct exec(set_field: 1->ct_mark)来设置。报文第一次匹配后,通过此action设置ct_mark到报文的metadata,重新注入datapath时,用来匹配流表指定的ct_mark。

**ct_label**:128的值,可以通过 ct exec(set_field: 1->ct_label)来设置,用法和ct_mark类似

## CT 动作

ovs通过ct action实现ct功能,格式如下:

```
ct([argument]...)
ct(commit[, argument]...)
```

这是两种不同的模式,即带commit和不带commit。ct 支持下面的参数:

```bash
commit 只有执行了commit,才会在conntrack模块创建conntrack表项
force 强制删除已存在的conntrack表项
table 跳转到指定的table执行
zone 设置zone,隔离conntrack
exec 执行其他action,目前只支持设置ct_mark和ct_label,比如exec(set_field: 1->ct_mark)
alg=<ftp/tftp> 指定alg类型,目前只支持ftp和tftp
nat 指定ip和port
```

示例使用:

```
#添加nat表项
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, in_port=veth_l0, actions=ct(commit,nat(src=10.1.1.240-10.2.2.2:2222-3333))"

//在一个ct里指定多次nat,只有最后一个nat生效,可参考do_xlate_actions中,ctx->ct_nat_action = ofpact_get_NAT(a)只有一个ctx->ct_nat_action 
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, actions=ct(commit,nat(src=10.1.1.240-10.2.2.2:2222-3333), nat(dst=10.1.1.240-10.2.2.2:2222-3333)), veth_r0"

//可以通过指定多个ct,实现fullnat,即同时转换源目的ip。
//但是这两个ct必须指定不同的zone,否则只有第一个ct生效。因为在 handle_nat 中,判断只有zone不一样才会进行后续的nat操作
//错误方式,指定了src和dst nat,但是zone相同,只有前面的snat生效
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, actions=ct(commit,nat(src=10.1.1.240-10.2.2.2:2222-3333)), ct(commit,nat(dst=10.1.1.240-10.2.2.2:2222-3333)), veth_r0"

//正确方式,使用不同zone,指定fullnat
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, actions=ct(commit,zone=100, nat(src=10.1.1.240-10.2.2.2:2222-3333)), ct(commit, zone=200, nat(dst=10.1.1.240-10.2.2.2:2222-3333)), veth_r0"
```

# TCP 使用范例

这里以TCP为例解释OVS中CT模块的使用。

## TCP SYN包

首先下流表匹配连接首包SYN:

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, in_port=veth_10, actions=ct(table=0)"
```

第一条规则代表当收到从veh_10口进来的tcp包时,如果是从未见过的五元组(-trk)状态,则将数据包投入CT表中查询。这里ct(table=0)是一个skb clone操作。由于没有后续操作,原有数据包丢弃不用。新clone的数据包,进入CT模块。

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+new, tcp, in_port=veth_10, actions=ct(commit),veth_r0"
```

第二条规则代表当数据包从CT模块回来后,此时已经有了track 跟踪(+trk),且为新的五元组(+new),所以我们下发指令:在CT表内新建表项,并将该数据包通过veth_r0转发出去。

流表下好后,我们通过scapy发送一个SYN包:

```
sendp(Ether()/IP(src="1.1.1.1", dst="1.1.1.2")/TCP(sport=1024, dport=2048, flags=0x02, seq=100), iface="veth_11")
```

可查阅当前CT表内容

```
[root@fedora lovelylich]# ovs-appctl dpctl/dump-conntrack | grep 1.1.1.1
tcp,orig=(src=1.1.1.1,dst=1.1.1.2,sport=1024,dport=2048),reply=(src=1.1.1.2,dst=1.1.1.1,sport=2048,dport=1024),protoinfo=(state=SYN_SENT)
[root@fedora lovelylich]#
```

注意:对于此时的重传SYN包,这两条规则都会再次重新命中。

## TCP SYN-ACK 包

同样下两条流表匹配SYN-ACK包:

```
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, in_port=veth_r0, actions=ct(table=0)"
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+est, tcp, in_port=veth_r0, actions=veth_10"
```

对于SYN-ACK包而言,从CT系统转一圈回来后,CT中的表项即转换成了+est态。我们以scapy发synack包确认:

```
sendp(Ether()/IP(src="1.1.1.2", dst="1.1.1.1")/TCP(sport=2048, dport=1024, flags=0x12, seq=200, ack=101), iface="veth_r1")
```

注意:虽然此时仅收双向包,CT表项就转为了est态,但该est态存在时间较短,如果一段时间后仍没有收到第三次ack,则该ct表项将被清理掉。只有真正收到了第三个ack包,ct中的est表项才会长时间存在。

查看此时CT表:

```
[root@fedora lovelylich]# ovs-appctl dpctl/dump-conntrack | grep 1.1.1.1
tcp,orig=(src=1.1.1.1,dst=1.1.1.3,sport=1024,dport=2048),reply=(src=1.1.1.3,dst=1.1.1.1,sport=2048,dport=1024),protoinfo=(state=ESTABLISHED)
[root@fedora lovelylich]#
```

## TCP ACK 包

我们下流表匹配最后ACK包:

```
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+est, tcp, in_port=veth_10, actions=veth_r0"
```

发送ACK包:

```
sendp(Ether()/IP(src="1.1.1.1", dst="1.1.1.3")/TCP(sport=1024, dport=2048, flags=0x10, seq=101, ack=201), iface="veth_11")
```

此后,ct表中的表项将存在很长时间,即便我们什么数据都不在发送。

## TCP 断连

TCP 断连过程中,CT表项状态将根据收到的数据包进行响应的状态转换:

收到第一次FIN-ACK时,转入FIN_WAIT_1

收到第二次反向FIN-ACK时,转入LAST_ACK

收到最后ACK时,转入TIME_WAIT状态。

但无论当前CT时何状态(即便是TIME_WAIT状态),这些FIN/ACK数据包,都将与正常数据包一样,匹配命中+est+trk状态。

# OVS 内核态 CT 实现

## CT 实现框架

### OVS 侧实现 - 进入 CT 前

ofctl下发的ct流表action,最终由`parse_CT`来解析存入ofpact中:

```c
static char * OVS_WARN_UNUSED_RESULT
parse_CT(char *arg, const struct ofpact_parse_params *pp)
{
    const size_t ct_offset = ofpacts_pull(pp->ofpacts);
    struct ofpact_conntrack *oc;
    char *error = NULL;
    char *key, *value;

    oc = ofpact_put_CT(pp->ofpacts);
    oc->flags = 0;
    oc->recirc_table = NX_CT_RECIRC_NONE;
    while (ofputil_parse_key_value(&arg, &key, &value)) {
        if (!strcmp(key, "commit")) {
    ......
}
```

在数据包刚到来时,还执行任何匹配和action动作时,此时状态被默认设置为-trk,该状态是在ovs_flow_key_extract->ovs_ct_update_key设置的:

```c
static void ovs_ct_update_key(const struct sk_buff *skb,
         const struct ovs_conntrack_info *info,
         struct sw_flow_key *key, bool post_ct,
         bool keep_nat_flags)
{
 ct = nf_ct_get(skb, &ctinfo);
 if (ct) {
  ......
 } else if (post_ct) {
  state = OVS_CS_F_TRACKED | OVS_CS_F_INVALID;
 }
 __ovs_ct_update_key(key, state, zone, ct);
}
static void __ovs_ct_update_key(struct sw_flow_key *key, u8 state,
    const struct nf_conntrack_zone *zone,
    const struct nf_conn *ct)
{
 key->ct_state = state; // 这里设置数据包的默认状态为-trk
 key->ct_zone = zone->id;
 key->ct.mark = ovs_ct_get_mark(ct);
 ovs_ct_get_labels(ct, &key->ct.labels);
 key->ct_orig_proto = 0;
}
```

由于此时还没有关联到ct 表项(无论ct系统中是否已有),所以ct==NULL,state也被赋值为0,所以OVS_CS_F_TRACKED trk 标志没有被设置,也即-trk状态。所以,所有刚进入ovs的数据包,都是untrack的,所以匹配untrack流表。只有经过netfilter ct 系统转一圈后才会有trk 标记。

在dp层没有流表,触发upcall解析时,应用层时调用`do_xlate_actions`完成ofpact内容解析的:

```c
static void
do_xlate_actions(const struct ofpact *ofpacts, size_t ofpacts_len,
                 struct xlate_ctx *ctx, bool is_last_action,
                 bool group_bucket_action)
{
  case OFPACT_CT:
            compose_conntrack_action(ctx, ofpact_get_CT(a), last);
            break;
}
```

这里的ofpact_get_CT是通过宏定义的,本质是从ofpact指针转换为ofpact_conntrack指针:

```
OFPACT(CT,              ofpact_conntrack,   ofpact, "ct")
```

而在compose_conntrack_action函数中,通过解析将ofpact_conntrack中的值,转化组建成为netlink 消息,发送给内核态模块。而内核态的openvswitch模块采用netlink机制与应用层通信,此前已经通过注册dp_packet_genl_ops注册了回调函数ovs_packet_cmd_execute。

```c
static struct genl_ops dp_packet_genl_ops[] = {
 { .cmd = OVS_PACKET_CMD_EXECUTE,
   .validate = GENL_DONT_VALIDATE_STRICT | GENL_DONT_VALIDATE_DUMP,
   .flags = GENL_UNS_ADMIN_PERM, /* Requires CAP_NET_ADMIN privilege. */
   .policy = packet_policy,
   .doit = ovs_packet_cmd_execute
 }
};
```

在收到应用层的netlink响应数据时,内核`genl_family_rcv_msg_doit`netlink消息分发函数会执行对应的回调,在这里就是`ovs_packet_cmd_execute`函数。

随后,`ovs_packet_cmd_execute`->`__ovs_nla_copy_actions`->`__ovs_nla_copy_actions`->`ovs_ct_copy_action`->`parse_ct`负责解析从应用层传下来的action列表nlattr属性中的ct action部分,并存入ovs_conntrack_info结构体中:

```
static int parse_ct(const struct nlattr *attr, struct ovs_conntrack_info *info,
      const char **helper, bool log)
{
 nla_for_each_nested(a, attr, rem) {
  switch (type) {
  case OVS_CT_ATTR_FORCE_COMMIT:
   info->force = true;
   /* fall through. */
  case OVS_CT_ATTR_COMMIT:
   info->commit = true;
   break;
#ifdef CONFIG_NF_CONNTRACK_ZONES
  case OVS_CT_ATTR_ZONE:
   info->zone.id = nla_get_u16(a);
   break;
#endif
  ......
```

解析结果放入ovs_conntrack_info后,最后通过调用ovs_nla_add_action将需要执行的action列表添加到sw_flow_actions->actions数组中,留待ovs_execute_actions中执行actions时使用:

```c
static int ovs_packet_cmd_execute(struct sk_buff *skb, struct genl_info *info)
{
 ......
 //生成sw_flow_actions动作列表
 err = ovs_nla_copy_actions(net, a[OVS_PACKET_ATTR_ACTIONS],
       &flow->key, &acts, log);
 if (err)
  goto err_flow_free;
 
 rcu_assign_pointer(flow->sf_acts, acts);
 ......
 sf_acts = rcu_dereference(flow->sf_acts);
 ......
 local_bh_disable();
 //执行动作
 err = ovs_execute_actions(dp, packet, sf_acts, &flow->key);
 local_bh_enable();
 rcu_read_unlock();
```

接下来对skb 执行action列表中的所有动作,比如output到某网口,直到所有action执行完毕,最后consume_skb()释放掉当前数据包。在`do_execute_actions`执行action列表的过程中,如果发现有`OVS_ACTION_ATTR_CT` action,就会调用nla_data(a)重新取出此前在`parse_ct`中通过`ovs_nla_add_action`添加的`ovs_conntrack_info`结构体,并进入ovs_ct_execute()根据`ovs_conntrack_info`中的相关信息,执行ct对应的处理逻辑。

```c
static int do_execute_actions(struct datapath *dp, struct sk_buff *skb,
         struct sw_flow_key *key,
         const struct nlattr *attr, int len)
{
 const struct nlattr *a;
 int rem;

 for (a = attr, rem = len; rem > 0;
      a = nla_next(a, &rem)) {
     case OVS_ACTION_ATTR_CT:
   ......
   err = ovs_ct_execute(ovs_dp_get_net(dp), skb, key,
          nla_data(a));
   ......
   break;
```

这里的ovs_ct_execute就是负责执行相应的动作,如set-mark,set-label,commit等。如果都不是(比如`actions=ct(table=0)`),也会进入ct模块进行查询,查询不到则也会新增表项。但区别是ovs_ct_commit 一定会将表项移入confirmed list,使得ct表项存在时间更长。

```c
int ovs_ct_execute(struct net *net, struct sk_buff *skb,
     struct sw_flow_key *key,
     const struct ovs_conntrack_info *info)
{
 //由于netfilter的ct工作在ip层,而ovs的数据包在二层,所以这里pull掉二层头
 //偏移data指针到三层,后续从ovs提交skb给netfilter ct模块
 nh_ofs = skb_network_offset(skb);
 skb_pull_rcsum(skb, nh_ofs);

 err = ovs_skb_network_trim(skb);
 if (err)
  return err;

 if (info->commit)
  //带有commit的ct action
  err = ovs_ct_commit(net, key, info, skb);
 else
  //不带commit的ct action
  err = ovs_ct_lookup(net, key, info, skb);
 //重新恢复二层头
 skb_push(skb, nh_ofs);
 skb_postpush_rcsum(skb, skb->data, nh_ofs);
 if (err)
  kfree_skb(skb);
 return err;
}
```

### Linux 内核侧实现

先看Linux 内核 CT系统的大概框架。Linux 内核的CT系统构建于netfilter hook点之上,如下:

![](http://arthurchiao.art/assets/img/conntrack/netfilter-conntrack.png)

其中在PRE_ROUTING和OUTPUT处截获进出的包进行CT处理,但此时ct表项暂存到unconfirmed list中,并在INPUT和POST_ROUTING处确认掉unconfirmed list中的ct表项,并真正移入ct hash表。

与其他ct 系统不一样的是,内核ct 实现有unconfirmed 和confirmed 的概念。这是因为linux 内核的ct 还要考虑linux 内核协议栈本身的框架,协议栈可能在local_out/post_routing 中间的各个点失败(比如路由查找失败、mtu检查等等)导致放弃发送。收包时亦是如此。所以,在local_out的时候新建的nf_conn表项存在于unconfirmed链中,直到post_routing真正出去时,才真正移入ct hash表。

**但OVS 本身是没有这些hook点的!OVS工作在二层,netfilter 是在三层 IP 层的!所以无论进入ct 系统,抑或是离开,都跟netfilter hook点没有关系!** OVS 是直接通过调用`nf_conntrack_in`/`nf_conntrack_confirm`来使用内核CT模块的。

针对到OVS而言,数据包skb在ovs 内执行相关前期准备与流表动作后,是通过`nf_conntrack_in`进入标准内核CT 模块(Linux内核也是通过此函数入口,将数据包送入netfilter ct系统的):

```c
unsigned int
nf_conntrack_in(struct sk_buff *skb, const struct nf_hook_state *state)
{
    ......
 //获取4层协议类型
 dataoff = get_l4proto(skb, skb_network_offset(skb), state->pf, &protonum);
    ......
repeat:
    //这里如果没有已有ct项,会尝试新建ct
 ret = resolve_normal_ct(tmpl, skb, dataoff,
    protonum, state);
 ......
    //获取原有或者新建的ct表项
 ct = nf_ct_get(skb, &ctinfo);
 ......
    //处理该连接的协议状态变迁
 ret = nf_conntrack_handle_packet(ct, skb, dataoff, ctinfo, state);
 ......
 if (ctinfo == IP_CT_ESTABLISHED_REPLY &&
     !test_and_set_bit(IPS_SEEN_REPLY_BIT, &ct->status))
  nf_conntrack_event_cache(IPCT_REPLY, ct);
out:
 if (tmpl)
  nf_ct_put(tmpl);

 return ret;
}
```

此时,在`resolve_normal_ct() `中,如果发现当前是新连接,则会在`init_conntrack`新建一个ct 表项并初始化:

```c
/* On success, returns 0, sets skb->_nfct | ctinfo */
static int
resolve_normal_ct(struct nf_conn *tmpl,
    struct sk_buff *skb,
    unsigned int dataoff,
    u_int8_t protonum,
    const struct nf_hook_state *state)
{
 //先获取连接五元组
 if (!nf_ct_get_tuple(skb, skb_network_offset(skb),
        dataoff, state->pf, protonum, state->net,
        &tuple)) {
 }

 //然后根据元组查找ct表
 zone = nf_ct_zone_tmpl(tmpl, skb, &tmp);
 hash = hash_conntrack_raw(&tuple, state->net);
 h = __nf_conntrack_find_get(state->net, zone, &tuple, hash);
 if (!h) {
  //如果没有找到,就新建一个ct表项,并链入unconfirmed list
  h = init_conntrack(state->net, tmpl, &tuple,
       skb, dataoff, hash);
  if (!h)
   return 0;
 }
    ......
```

这里新建表项时,实际包含了两个工作,新建nf_conn结构体用以表示一条连接(不考虑方向),以及新建两个方向的五元组ct节点,即

```c
struct nf_conntrack_tuple_hash {
 struct hlist_nulls_node hnnode;
 struct nf_conntrack_tuple tuple;
};

struct nf_conn {
 struct nf_conntrack_tuple_hash tuplehash[IP_CT_DIR_MAX];
    unsigned long status;
}
```

这里IP_CT_DIR_MAX=2,代表连接的两个方向。这里的tuplehash是属于nf_conn结构体的,所以创建nf_conn结构体就是新建了两个方向的ct节点。这样,当reply包达到时,可以直接查找到对应的ct表项。这两个hnode都是现在就链入ct 表,他们的tuple分别代表连接的两个方向的五元组,但该条连接的状态信息,仍旧由nf_conn->status统一维护。同时把连接双向的五元组都会建立好,并将ORIGINAL方向的hnode链入percpu 的 unconfirm list链表,目前还不会直接链入ct表。

在`resolve_normal_ct`新建完ct表项,或者查找到已存在表项时,找到对应的nf_conn连接状态维护结构体,最后会设置`skb->_nfct`的状态:

```c
static int
resolve_normal_ct(struct nf_conn *tmpl,
    struct sk_buff *skb,
    unsigned int dataoff,
    u_int8_t protonum,
    const struct nf_hook_state *state)
{
    ......
 ct = nf_ct_tuplehash_to_ctrack(h);

 //接下来根据新建或者查找到的ct表项,设置当前skb->ct状态为ctinfo状态
 if (NF_CT_DIRECTION(h) == IP_CT_DIR_REPLY) {
  ctinfo = IP_CT_ESTABLISHED_REPLY;
 } else {
  /* Once we've had two way comms, always ESTABLISHED. */
  if (test_bit(IPS_SEEN_REPLY_BIT, &ct->status)) {
   pr_debug("normal packet for %p\n", ct);
   ctinfo = IP_CT_ESTABLISHED;
  } else if (test_bit(IPS_EXPECTED_BIT, &ct->status)) {
   pr_debug("related packet for %p\n", ct);
   ctinfo = IP_CT_RELATED;
  } else {
   pr_debug("new packet for %p\n", ct);
   ctinfo = IP_CT_NEW;
  }
 }
    //设置skb的_nfct指针,指向新建的nf_conn结构体,并且同时设置skb的ct状态
 nf_ct_set(skb, ct, ctinfo);
 return 0;
}
```

**!!!注意:这里设置的是skb->_nfct指针低三位bit位所代表的连接状态,这与nf_conn->status是不一样的。nf__conn->state代表的是CT表项中从CT模块角度看到的连接状态,无论当前skb是否已经结束生命期,该连接状态都始终存在,直到新的数据包到来,再次触发状态迁移。而这里的skb->\_nfct代表的是该skb数据包的ct状态,此状态用于skb离开CT系统处理后,在规则匹配中使用,该状态仅存在于数据包存在周期。**

这里**对于新数据包而言,则是将ctinfo 置位为IP_CT_NEW状态**。netfilter中skb->_nfct状态总共由7种状态定义,这些状态定义的是数据包本身的状态,用于在ct 规则中做匹配用,比如IP_CT_NEW匹配的就是+new标记:

```c
/* Connection state tracking for netfilter.  This is separated from,
   but required by, the NAT layer; it can also be used by an iptables
   extension. */
enum ip_conntrack_info {
 /* Part of an established connection (either direction). */
 IP_CT_ESTABLISHED,
 /* Like NEW, but related to an existing connection, or ICMP error
    (in either direction). */
 IP_CT_RELATED,
 /* Started a new connection to track (only
           IP_CT_DIR_ORIGINAL); may be a retransmission. */
 IP_CT_NEW,
 /* >= this indicates reply direction */
 IP_CT_IS_REPLY,
 IP_CT_ESTABLISHED_REPLY = IP_CT_ESTABLISHED + IP_CT_IS_REPLY,
 IP_CT_RELATED_REPLY = IP_CT_RELATED + IP_CT_IS_REPLY,
 /* No NEW in reply direction. */
 /* Number of distinct IP_CT types. */
 IP_CT_NUMBER,
 IP_CT_UNTRACKED = 7,
};
```

这些状态存于skb->_nfct字段的低三位,高29位则作为指针,指向skb对应的ct 表项地址。可通过`nf_ct_get()` 函数方便的获取和设置。

```c
static inline void
nf_ct_set(struct sk_buff *skb, struct nf_conn *ct, enum ip_conntrack_info info)
{
 skb_set_nfct(skb, (unsigned long)ct | info);
}

/* Return conntrack_info and tuple hash for given skb. */
static inline struct nf_conn *
nf_ct_get(const struct sk_buff *skb, enum ip_conntrack_info *ctinfo)
{
 unsigned long nfct = skb_get_nfct(skb);

 *ctinfo = nfct & NFCT_INFOMASK;
 return (struct nf_conn *)(nfct & NFCT_PTRMASK);
}
```

每种协议,都需要实现自己的新建ct表项方法,用以完成协议特定的初始化操作,比如tcp的tcp_new甚至会在nf_conn中记录最大ack值等信息。udp则是没有自己特定的新建时初始化操作。

最后,新建/查找到ct表项后,由nf_conntrack_handle_packet负责处理协议特定的ct状态变迁。该函数实际是个代理,会根据`ct->tuplehash[IP_CT_DIR_ORIGINAL].tuple.dst.protonum`取得当前skb的协议类型,转而调用对应协议的处理函数,比如udp是nf_conntrack_udp_packet,tcp是nf_conntrack_tcp_packet,这些函数负责处理自己协议特定的状态变迁。

每个协议都在nf_conn的proto定义了自己的私有成员域:

```c
struct nf_conn {
 ......
 /* Storage reserved for other modules, must be the last member */
 union nf_conntrack_proto proto;
};
/* per conntrack: protocol private data */
union nf_conntrack_proto {
 /* insert conntrack proto private data here */
 .....
 struct ip_ct_tcp tcp;
 struct nf_ct_udp udp;
 struct nf_ct_gre gre;
 .....
};
struct nf_ct_udp {
 unsigned long stream_ts;
};
struct ip_ct_tcp {
    ......
 u_int8_t state;  /* state of the connection (enum tcp_conntrack) */
 ......
};
```

简单的udp协议是直接使用nf_conn->status字段作为自身连接状态。而复杂如tcp,则是在nf_conn->proto->(tcp/udp)->state 状态单独维护自己协议的状态变迁,甚至还记录了连接建立过程中的协商字段。

这里假设当前是udp协议数据包(该协议的状态跳转比较简单,TCP协议的状态变化我们在后文单独讲解),则进入udp处理流程。如果当前udp包是一个回复包,在`resolve_normal_ct`中直接根据数据包的发送方向判定为回复包,并设置skb->_nfct状态为IP_CT_ESTABLISHED_REPLY:

````c
static int
resolve_normal_ct(struct nf_conn *tmpl,
    struct sk_buff *skb,
    unsigned int dataoff,
    u_int8_t protonum,
    const struct nf_hook_state *state)
{
    ......
 ct = nf_ct_tuplehash_to_ctrack(h);

 //接下来根据新建或者查找到的ct表项,设置当前skb->ct状态为ctinfo状态
 if (NF_CT_DIRECTION(h) == IP_CT_DIR_REPLY) {
  ctinfo = IP_CT_ESTABLISHED_REPLY;
 } else {
````

此时则代表双向数据包我们都已经收到,于是将skb->_nfct置为IP_CT_ESTABLISHED_REPLY,代表该数据包既是连接建立,又是连接的回复包。在离开CT后,在OVS侧(后文ovs_ct_get_state可以看到)OVS 再次转化该状态,这样skb就匹配上离开CT系统后的OVS 规则+trk+est状态:

```
IP_CT_ESTABLISHED_REPLY = IP_CT_ESTABLISHED + IP_CT_IS_REPLY,
```

再看ct 系统nf_conn本身的状态维护。在`nf_conntrack_in`最后,也就是数据包即将离开CT 模块时,检查是否是回复包,如果是,同时设置nf_conn->status字段为看到回复包了IPS_SEEN_REPLY_BIT:

```c
unsigned int
nf_conntrack_in(struct sk_buff *skb, const struct nf_hook_state *state)
{
 ......
 if (ctinfo == IP_CT_ESTABLISHED_REPLY &&
     !test_and_set_bit(IPS_SEEN_REPLY_BIT, &ct->status))
  nf_conntrack_event_cache(IPCT_REPLY, ct);
 ......
 return ret;
}
```

这样,设置了nf_conn为已发现回复包后,对于此后所有的数据包,无论从哪个方向来,都会发现连接已设置了IPS_SEEN_REPLY_BIT,在resolve_normal_ct里面,直接将这些数据包设置为+est状态(当然,+est状态还是OVS来转化的,CT只是设置skb为IP_CT_ESTABLISHED:

```c
 /* It exists; we have (non-exclusive) reference. */
 if (NF_CT_DIRECTION(h) == IP_CT_DIR_REPLY) {
  ctinfo = IP_CT_ESTABLISHED_REPLY;
 } else {
  /* Once we've had two way comms, always ESTABLISHED. */
  if (test_bit(IPS_SEEN_REPLY_BIT, &ct->status)) {
   pr_debug("normal packet for %p\n", ct);
   ctinfo = IP_CT_ESTABLISHED;
  } else if (test_bit(IPS_EXPECTED_BIT, &ct->status)) {
   pr_debug("related packet for %p\n", ct);
   ctinfo = IP_CT_RELATED;
  } else {
   pr_debug("new packet for %p\n", ct);
   ctinfo = IP_CT_NEW;
  }
 }
```

如果连接是单方向的新建包/重传包,这种情况只是在`nf_ct_refresh_acct`中延期ct表项的超时时间,没有其他额外设置。所以,此时的skb->_nfct的状态仍然是前述resolve_normal_ct中设置的`IP_CT_NEW`状态,而nf_conn->status自从分配后没有变化,所以仍然是`__nf_conntrack_alloc`中分配时默认设置的0。

```c
/* Returns verdict for packet, and may modify conntracktype */
int nf_conntrack_udp_packet(struct nf_conn *ct,
       struct sk_buff *skb,
       unsigned int dataoff,
       enum ip_conntrack_info ctinfo,
       const struct nf_hook_state *state)
{
 ......
 /* If we've seen traffic both ways, this is some kind of UDP
  * stream. Set Assured.
  */
 if (test_bit(IPS_SEEN_REPLY_BIT, &ct->status)) {
  unsigned long extra = timeouts[UDP_CT_UNREPLIED];

  /* Still active after two seconds? Extend timeout. */
  if (time_after(jiffies, ct->proto.udp.stream_ts))
   extra = timeouts[UDP_CT_REPLIED];

  nf_ct_refresh_acct(ct, ctinfo, skb, extra);

  /* never set ASSURED for IPS_NAT_CLASH, they time out soon */
  if (unlikely((ct->status & IPS_NAT_CLASH)))
   return NF_ACCEPT;

  /* Also, more likely to be important, and not a probe */
  if (!test_and_set_bit(IPS_ASSURED_BIT, &ct->status))
   nf_conntrack_event_cache(IPCT_ASSURED, ct);
 } else {
  nf_ct_refresh_acct(ct, ctinfo, skb, timeouts[UDP_CT_UNREPLIED]);
 }
 return NF_ACCEPT;
}
```

假如这里是udp的回复包,在数据包第一次以-trk状态进入CT系统并离开CT时,会设置nf_conn->state为IPS_SEEN_REPLY_BIT。这样,当udp数据包第二次+trk+est进入CT系统后,在`nf_conntrack_udp_packet`此时才修改udp连接的状态:nf_conn->status,将设置为IPS_ASSURED_BIT。

ct 模块定义了连接的以下状态集合,其中主要是IPS_SEEN_REPLY、IPS_CONFIRMED、IPS_ASSURED。其余主要是为ftp、nat服务的。

```c
enum ip_conntrack_status {
    IPS_EXPECTED      = (1 << IPS_EXPECTED_BIT),
    IPS_SEEN_REPLY    = (1 << IPS_SEEN_REPLY_BIT),
    IPS_ASSURED       = (1 << IPS_ASSURED_BIT),
    IPS_CONFIRMED     = (1 << IPS_CONFIRMED_BIT),
    IPS_SRC_NAT       = (1 << IPS_SRC_NAT_BIT),
    IPS_DST_NAT       = (1 << IPS_DST_NAT_BIT),
    IPS_NAT_MASK      = (IPS_DST_NAT | IPS_SRC_NAT),
    ......
};
```

至此,内核ct 系统结束,再次回到ovs 处理逻辑。

### OVS 侧实现 - 离开 CT 后

数据包经由nf_conntrack_in调用,在netfilter ct 系统跑一圈后出来,OVS通过`ovs_ct_update_key`来将key->ct_state更新,用于匹配。其中,`ovs_ct_update_key`->`ovs_ct_get_state`实现netfilter skb->_nfct 状态(比如IP_CT_NEW)和ovs 需要的匹配状态(比如OVS_CS_F_NEW)之间的转换映射,并为经过CT系统的数据报都打上+trk标记:OVS_CS_F_TRACKED。

所以当数据包跑一圈后,对应的skb 状态为IP_CT_NEW时,对应的sw_flow_key ct_state状态则为 OVS_CS_F_TRACKED | OVS_CS_F_NEW:

```c
/* Map SKB connection state into the values used by flow definition. */
static u8 ovs_ct_get_state(enum ip_conntrack_info ctinfo)
{
 u8 ct_state = OVS_CS_F_TRACKED;

 switch (ctinfo) {
 case IP_CT_ESTABLISHED_REPLY:
 case IP_CT_RELATED_REPLY:
  ct_state |= OVS_CS_F_REPLY_DIR;
  break;
 default:
  break;
 }

 switch (ctinfo) {
 case IP_CT_ESTABLISHED:
 case IP_CT_ESTABLISHED_REPLY:
  ct_state |= OVS_CS_F_ESTABLISHED;
  break;
 case IP_CT_RELATED:
 case IP_CT_RELATED_REPLY:
  ct_state |= OVS_CS_F_RELATED;
  break;
 case IP_CT_NEW:
  ct_state |= OVS_CS_F_NEW;
  break;
 default:
  break;
 }

 return ct_state;
}
```

所以,这时数据包回到OVS并再次进行匹配时,就是+trk+new状态了。就会匹配上比如下面这条规则:

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+new, tcp, in_port=veth_10, actions=ct(commit),veth_r0"
```

这时会再次进入`ovs_ct_execute`函数,并执行相关的CT action,比如commit。假设命中的是`commit` action,则不同于Linux netfilter CT 实现在LOCAL_IN/POST_ROUTING处将ct表项从unconfirmed list移入ct hash 表,OVS此时是直接调用Linux 内核 CT的接口`nf_conntrack_confirm`将skb对应的nf_conn从unconfirmed list 移入真正的hash表。另外,linux ct 在confirm时,会同时把nf_conn状态置位为IPS_CONFIRMED状态:

```c
static void __nf_conntrack_insert_prepare(struct nf_conn *ct)
{
 ......
 atomic_inc(&ct->ct_general.use);
 ct->status |= IPS_CONFIRMED;
 ......
}
```

### 内核 CT 下的TCP 状态迁移

先说核心框架。对于tcp而言,由于ct表项在有数据包到来时,需要完成状态迁移,所以每个协议都需要自定义自己的状态迁移表,比如tcp的状态迁移表则定义如下:

```c
/*
 * The TCP state transition table needs a few words...
 *
 * We are the man in the middle. All the packets go through us
 * but might get lost in transit to the destination.
 * It is assumed that the destinations can't receive segments
 * we haven't seen.
 *
 * The checked segment is in window, but our windows are *not*
 * equivalent with the ones of the sender/receiver. We always
 * try to guess the state of the current sender.
 *
 * The meaning of the states are:
 *
 * NONE: initial state
 * SYN_SENT: SYN-only packet seen
 * SYN_SENT2: SYN-only packet seen from reply dir, simultaneous open
 * SYN_RECV: SYN-ACK packet seen
 * ESTABLISHED: ACK packet seen
 * FIN_WAIT: FIN packet seen
 * CLOSE_WAIT: ACK seen (after FIN)
 * LAST_ACK: FIN seen (after FIN)
 * TIME_WAIT: last ACK seen
 * CLOSE: closed connection (RST)
 *
 * Packets marked as IGNORED (sIG):
 * if they may be either invalid or valid
 * and the receiver may send back a connection
 * closing RST or a SYN/ACK.
 *
 * Packets marked as INVALID (sIV):
 * if we regard them as truly invalid packets
 */
static const u8 tcp_conntracks[2][6][TCP_CONNTRACK_MAX] = {
 {
/* ORIGINAL */
/*       sNO, sSS, sSR, sES, sFW, sCW, sLA, sTW, sCL, sS2 */
/*syn*/    { sSS, sSS, sIG, sIG, sIG, sIG, sIG, sSS, sSS, sS2 },
/*
 * sNO -> sSS Initialize a new connection
 * sSS -> sSS Retransmitted SYN
 * sS2 -> sS2 Late retransmitted SYN
 * sSR -> sIG
 * sES -> sIG Error: SYNs in window outside the SYN_SENT state
 *   are errors. Receiver will reply with RST
 *   and close the connection.
 *   Or we are not in sync and hold a dead connection.
 * sFW -> sIG
 * sCW -> sIG
 * sLA -> sIG
 * sTW -> sSS Reopened connection (RFC 1122).
 * sCL -> sSS
 */
/*       sNO, sSS, sSR, sES, sFW, sCW, sLA, sTW, sCL, sS2 */
/*synack*/ { sIV, sIV, sSR, sIV, sIV, sIV, sIV, sIV, sIV, sSR },
/*
 * sNO -> sIV Too late and no reason to do anything
 * sSS -> sIV Client can't send SYN and then SYN/ACK
 * sS2 -> sSR SYN/ACK sent to SYN2 in simultaneous open
 * sSR -> sSR Late retransmitted SYN/ACK in simultaneous open
 * sES -> sIV Invalid SYN/ACK packets sent by the client
 * sFW -> sIV
 * sCW -> sIV
 * sLA -> sIV
 * sTW -> sIV
 * sCL -> sIV
 */
  .......
};
```

状态名字比较抽象,对应关系如下,其实就是普通的TCP状态:

```
#define sNO TCP_CONNTRACK_NONE
#define sSS TCP_CONNTRACK_SYN_SENT
#define sSR TCP_CONNTRACK_SYN_RECV
#define sES TCP_CONNTRACK_ESTABLISHED
#define sFW TCP_CONNTRACK_FIN_WAIT
#define sCW TCP_CONNTRACK_CLOSE_WAIT
#define sLA TCP_CONNTRACK_LAST_ACK
#define sTW TCP_CONNTRACK_TIME_WAIT
#define sCL TCP_CONNTRACK_CLOSE
#define sS2 TCP_CONNTRACK_SYN_SENT2
#define sIV TCP_CONNTRACK_MAX
#define sIG TCP_CONNTRACK_IGNORE
```

该转移表通过填入当前收包方向、当前收包的tcp标志位、现有ct的状态来获取新的状态值。举个例子:对于新的ct表项,收到syn包的情况下,是这样获取新的状态的:

````c
new_state = tcp_conntracks[0][TCP_SYN_SET][TCP_CONNTRACK_NONE];
````

所以得到sSS 状态,也就是TCP_CONNTRACK_SYN_SENT。另外在负责处理tcp 收到新数据包时,ct表项的状态迁移的nf_conntrack_tcp_packet函数中,也采用该迁移表维护ct表项的状态变化。

此外,对于处于连接不同状态的CT节点,TCP也有着不同的超时值,以便于节省内存,避免DDOS攻击弱点。这里定义了tcp的ct表项在各个状态下的超时值:

```c
static const unsigned int tcp_timeouts[TCP_CONNTRACK_TIMEOUT_MAX] = {
 [TCP_CONNTRACK_SYN_SENT] = 2 MINS,
 [TCP_CONNTRACK_SYN_RECV] = 60 SECS,
 [TCP_CONNTRACK_ESTABLISHED] = 5 DAYS,
 [TCP_CONNTRACK_FIN_WAIT] = 2 MINS,
 [TCP_CONNTRACK_CLOSE_WAIT] = 60 SECS,
 [TCP_CONNTRACK_LAST_ACK] = 30 SECS,
 [TCP_CONNTRACK_TIME_WAIT] = 2 MINS,
 [TCP_CONNTRACK_CLOSE]  = 10 SECS,
 [TCP_CONNTRACK_SYN_SENT2] = 2 MINS,
/* RFC1122 says the R2 limit should be at least 100 seconds.
   Linux uses 15 packets as limit, which corresponds
   to ~13-30min depending on RTO. */
 [TCP_CONNTRACK_RETRANS]  = 5 MINS,
 [TCP_CONNTRACK_UNACK]  = 5 MINS,
};
```

### 关于 NAT 实现

Linux 内核的 CT 是支持NAT 转换的,OVS也利用了这点实现动态NAT(不同于modify action)。

有几点需要注意:

1. NAT操作必须在helper(实现CT ALG功能的函数)之前完成,以便于helper函数知道NAT动作的存在
2. NAT更改IP地址,导致可能需要执行序列号调整等操作,这需要注意。

## TCP 三次握手之旅

前面是综述各个关键函数,以及实现逻辑,但CT连接状态迁移、skb的状态和OVS规则匹配涉及包序,在前文讲骨骼框架中无法细述。故接下来以最复杂的TCP三次握手为例,说明OVS 内核 CT 的实现逻辑,重点关注状态迁移,对于实现函数和实现方法,则简要带过,不再赘述。

此外,从上面框架描述也能看出,OVS中CT流程主要分为以下四步:

1. 匹配最初规则;
2. 查找/创建CT表项,设置包状态;
3. 处理协议特定状态;
4. 新状态回到OVS

### SYN 包到来时

**匹配规则**:首先syn包到来时,由于skb->_nfct状态仍为0,也就是既没有ct表项与之关联,也没有ct状态设置。所以此时数据包匹配的是-trk状态。

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, in_port=veth_10, actions=ct(table=0)"
```

由该规则action通过`nf_conntrack_in`转入CT模块处理。

**查找/创建CT表项,设置包状态**:CT模块发现新连接数据包,于是`init_conntrack`创建nf_conn结构,创建双向tuple,并设置nf_conn->status为0,最后链入unconfirmed list链表。

同时,`resolve_normal_ct`检查nf_conn->status,由于状态为0,所以把这个新连接的数据包skb->_nfct设置为IP_CT_NEW。至此,数据包匹配OVS规则的+trk+new状态。

**处理协议特定状态**:CT 本身的状态迁移处理完成,最后进入协议特定的状态处理。tcp是在`nf_conntrack_tcp_packet`处理的。

这是新的tcp 连接,所以初始化`nf_conn->proto.tcp`字段,包括以skb为信息源,初始化其中的td_end(最大序列号)、窗口尺寸以及tcp 协议选项等。

最后,通过tcp的协议状态跳转表tcp_conntracks,设置该CT连接的协议特定连接状态为TCP_CONNTRACK_SYN_SENT,以及该CT表项的超时时间。

```c
ct->proto.tcp.state = new_state
```

**新状态回到OVS**:完成CT模块处理后,skb 带着新的+trk+new状态,流转回OVS,继续匹配新的规则,执行新的action。比如经过CT处理后的SYN包就匹配下面这条规则:

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+new, tcp, in_port=veth_10, actions=ct(commit),veth_r0"
```

commit action是在`ovs_ct_commit`中处理的,主要是设置nf_conn的label、mark值。最后,将skb对应的nf_conn从unconfirmed list链表移入CT hash表,随后结束CT 处理,从veth_r0送出。

### SYN-ACK 包到来时

**匹配规则**:同样,当SYN-ACK到来时,数据包没有设置过状态,匹配如下规则:

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, in_port=veth_r0, actions=ct(table=0)"
```

**查找/创建CT表项,设置包状态**:同样转入CT模块处理。直接找到之前已经创建的CT表项,同时由于当前数据包是回包方向,所以设置数据包状态skb->_nfct为IP_CT_ESTABLISHED_REPLY,也就是匹配+trk+est+rpl状态。

**处理协议特定状态**:查找到CT表项,也更新了skb状态,同样,继续处理协议特定的状态迁移:依然在`nf_conntrack_tcp_packet`中,由于是反方向的SYN-ACK包,原有状态为TCP_CONNTRACK_SYN_SENT,查找tcp_conntracks跳转表,更新ct->proto.tcp.state新的状态为`sSR`,即TCP_CONNTRACK_SYN_RECV,同时更新超时时间。

最后在即将离开CT模块时,由于已经收到回复包,所以设置连接状态nf_conn->status为IPS_SEEN_REPLY_BIT。

**新状态回到OVS**:完成CT模块处理后,skb 带着新的+trk+est+rpl状态,流转回OVS,继续匹配新的规则,执行新的action。比如经过CT处理后的SYN-ACK包就匹配下面这条规则,最后经由veth_10口送出。

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+est, tcp, in_port=veth_r0, actions=veth_10"
```

### ACK 包到来时

**匹配规则**:数据包没有设置过状态,匹配如下规则:

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, in_port=veth_10, actions=ct(table=0)"
```

**查找/创建CT表项,设置包状态**:同样转入CT模块处理。直接找到之前已经创建的CT表项,CT表项状态已设置IPS_SEEN_REPLY_BIT,所以设置数据包状态skb->_nfct为IP_CT_ESTABLISHED,也就是匹配+trk+est状态。

**处理协议特定状态**:查找到CT表项,也更新了skb状态,同样,继续处理协议特定的状态迁移:依然在`nf_conntrack_tcp_packet`中,由于是正向ACK包,原有状态为TCP_CONNTRACK_SYN_RECV,查找tcp_conntracks跳转表,更新ct->proto.tcp.state新的状态为`sES`,即TCP_CONNTRACK_ESTABLISHED,同时更新超时时间。

此外,这里`nf_conntrack_tcp_packet`也同样更新CT表项的状态nf_conn->status为IPS_ASSURED,至此CT表项状态也转为连接建立成功:

```
 } else if (!test_bit(IPS_ASSURED_BIT, &ct->status)
     && (old_state == TCP_CONNTRACK_SYN_RECV
         || old_state == TCP_CONNTRACK_ESTABLISHED)
     && new_state == TCP_CONNTRACK_ESTABLISHED) {
  /* Set ASSURED if we see valid ack in ESTABLISHED
     after SYN_RECV or a valid answer for a picked up
     connection. */
  set_bit(IPS_ASSURED_BIT, &ct->status);
```

在此状态的CT表项,不会被GC过早清理掉:

```c
static bool gc_worker_can_early_drop(const struct nf_conn *ct)
{
 const struct nf_conntrack_l4proto *l4proto;

 if (!test_bit(IPS_ASSURED_BIT, &ct->status))
  return true;

 l4proto = nf_ct_l4proto_find(nf_ct_protonum(ct));
 if (l4proto->can_early_drop && l4proto->can_early_drop(ct))
  return true;

 return false;
}
```

**新状态回到OVS**:完成CT模块处理后,skb 带着新的+trk+est状态,流转回OVS,继续匹配新的规则,执行新的action。比如经过CT处理后的SYN-ACK包就匹配下面这条规则,最后经由veth_10口送出。

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+est, tcp, in_port=veth_10, actions=veth_r0"
```

至此,协议特定的三次握手完成。其实我们能看到,在SYNACK到来时,OVS就已经标记数据包为+est了,这点需要注意。

## TCP 四次挥手

TCP 四次挥手时,CT维护就比较简单了,没有预想中的那么复杂。OVS中数据包都是+est状态(无论此时TCP处于何种状态、skb带了何种标记),甚至在TCP在TIME_WAIT态时,skb也是+est状态!只是CT表项中协议特定的状态需要在收到特定包时,保持跟TCP本身的状态机一致。

那么,对于四次挥手而言,nf_conn->status又是如何变化的呢?答案是**没变化**!依然是IPS_SEEN_REPLY_BIT|IPS_ASSURED_BIT。这其实才是符合预期的,因为只有这样,数据包状态才会一直置为+est:

```c
 /* It exists; we have (non-exclusive) reference. */
 if (NF_CT_DIRECTION(h) == IP_CT_DIR_REPLY) {
  ctinfo = IP_CT_ESTABLISHED_REPLY;
 } else {
  /* Once we've had two way comms, always ESTABLISHED. */
  if (test_bit(IPS_SEEN_REPLY_BIT, &ct->status)) {
   pr_debug("normal packet for %p\n", ct);
   ctinfo = IP_CT_ESTABLISHED;
```

其实OVS TCP 整个CT流程仅需要nf_conn->status 和 skb的状态就能完成所有的CT用户接口需求了。但是为了精确控制各个状态下的超时值,TCP CT 表项不得不维护跟踪连接的细节TCP 状态(如TCP_CONNTRACK_SYN_RECV等)。

nf_conn->status 居然一直没变化?!!不敢相信?我们用下面的 Systemtap 来跑一波看看。(我这里用的是Fedora 34 内核:5.11.12-300.fc34.x86_64,不同内核需要修改对应probe 行号,这个不用多说)。

```sh
%{
#include <linux/skbuff.h>
#include <net/netfilter/nf_conntrack.h>
%}
function print_ct_status(pskb:long) %{
        struct sk_buff *skb;
        enum ip_conntrack_info ctinfo;

        skb = (struct sk_buff*)(long)STAP_ARG_pskb;
        printk("skb=%p\n", skb);
        printk("skb->_nfct=%p\n", nf_ct_get(skb, &ctinfo));
        printk("nf_conn->status=%lx\n", nf_ct_get(skb, &ctinfo)->status);
%}
probe module("openvswitch").statement("ovs_ct_execute@net/openvswitch/conntrack.c:1323"){
        printf("ovs_ct_execute called\n");
        print_ct_status($skb);
}
```

stap 起脚本插入模块,然后参考文档[1]来断开连接,可以看到:直到tcp协议进入timewait态,nf_conn->status状态依然是0xe,也就是IPS_CONFIRMED|IPS_SEEN_REPLY|IPS_ASSURED。这下信了吧:)

顺便说下:

1. Fedora 下面看内核模块printk日志,用journalctl来看:jourctl -k -f。

2. 要下载对应版本的内核,找到对应的源码行号,则是`dnf download --source kernel-5.11.12`,不再用yum 下载了。
## UDP 状态变化
UDP的ct 实现很简单,都在`nf_conntrack_udp_packet`完成,而这个函数事实上只做了一件事,那就是看到UDP的回复包时,将ct表项的状态置位为IPS_ASSURED,主要目的就是让这个CT表项存在时间长一点。但请注意,这里检查的是
```c
test_bit(IPS_SEEN_REPLY_BIT, &ct->status)
```
而这个bit位如前所述,是在收到resp数据包后,数据包即将离开内核ct 模块时设置的:
```c
 if (ctinfo == IP_CT_ESTABLISHED_REPLY &&
     !test_and_set_bit(IPS_SEEN_REPLY_BIT, &ct->status))
  nf_conntrack_event_cache(IPCT_REPLY, ct);
```
所以,要想将一个udp ct表项置位为IPS_ASSURED态,其实是要udp **四次握手**!在第二次看到resp 方向包时,CT表项 才会真正进入长久存在IPS_ASSURED态!

而与CT表项不同,UDP的数据包状态在第一个resp包时,就已经是+est状态。我们来看UDP包的状态变化,`nf_conntrack_udp_packet`一点都没有涉及到,全靠`resolve_normal_ct`的一点点状态迁移代码来完成:
```c
 if (NF_CT_DIRECTION(h) == IP_CT_DIR_REPLY) {
  ctinfo = IP_CT_ESTABLISHED_REPLY;
 } else {
  /* Once we've had two way comms, always ESTABLISHED. */
  if (test_bit(IPS_SEEN_REPLY_BIT, &ct->status)) {
   pr_debug("normal packet for %p\n", ct);
   ctinfo = IP_CT_ESTABLISHED;
  } else if (test_bit(IPS_EXPECTED_BIT, &ct->status)) {
   pr_debug("related packet for %p\n", ct);
   ctinfo = IP_CT_RELATED;
  } else {
   pr_debug("new packet for %p\n", ct);
   ctinfo = IP_CT_NEW;
  }
 }
```
所以,**与ovs dpdk实现不同的是,ovs dpkd 可以在看到单向第三个包,就将数据包置为+est态去匹配规则;内核ct 则是必须要看到回复包,才会将数据包置位+est**。

## ICMP 状态变化
ICMP 也没有什么可说的,同样复用`resolve_normal_ct`的状态迁移代码来设置skb的状态,回包才有+est 标记。但icmp有个特殊的地方,在正常连接连不上时,比如udp 发包到一个未监听的端口,会触发icmp 的报错报文,比如icmp unreachable报文。此时icmp resp 包会带有原有报文作为payload。
这种特殊情况,内核CT 模块会有特殊处理,主要在`nf_conntrack_icmpv4_error`中处理,逻辑也很简单。举个例子:
比如udp 1.1.1.1->1.1.1.2到一个未监听的端口,此时1.1.1.2的内核会默认回复一个icmp unreachable错误报文,payload是udp 1.1.1.1->1.1.1.2报文。此时内核CT 会用1.1.1.2->1.1.1.1 来找这个resp方向是否已有udp CT表项。如果有,icmp报文就会置为 **+rel+rpl(这是一个回包)+trk**。

# 遗留问题
1. NAT 实现未详细跟踪实现
2. ALG 实现未详细跟踪实现
3. IP分片与CT表流入流出逻辑分析

# 参考文档

1. https://docs.openvswitch.org/en/latest/tutorials/ovs-conntrack/
2. http://arthurchiao.art/blog/conntrack-design-and-implementation-zh/

0条评论
0 / 1000
李****易
4文章数
1粉丝数
李****易
4 文章 | 1 粉丝
李****易
4文章数
1粉丝数
李****易
4 文章 | 1 粉丝
原创

OVS 内核 CT实现

2022-12-30 02:22:13
122
0

[TOC]

# 引言

所有连接跟踪模块的作用,都是在首包到来时,识别并在CT表中生成表项;而当该连接的特定数据包到来时,在CT表内匹配表项,如果匹配命中,则执行响应预设动作。OVS 的连接跟踪实现也不例外。

# OVS CT 定义

## CT 匹配域

这里明确几个在OVS内的术语:

```cpp
new 通过ct action指定报文经过conntrack模块处理,不一定有commit。
est 表示conntrack模块看到了报文双向数据流,一定是在commit 的conntrack后
rel 表示和已经存在的conntrack相关,比如icmp不可达消息或者ftp的数据流
rpl 表示反方向的报文
inv 无效的,表示conntrack模块没有正确识别到报文,比如L3/L4 protocol handler没有加载,或者L3/L4 protocol handler认为报文错误
trk 表示报文经过了conntrack模块处理,如果这个flag不设置,其他flag都不能被设置。任何进来的数据包,都是-trk状态,只有该数据包经过ct模块处理了,才会变为+trk状态。什么叫经过ct模块处理?流表的action指定了ct,并且报文通过了协议验证:pkt->md.ct_state = CS_TRACKED
snat 表示报文经过了snat,源ip或者port
dnat 表示报文经过了dnat,目的ip或者port
```

**commit**:ovs内的数据包都是处理完,生命期就结束;commit action用于明确定义在CT表中生成表项,用于未来匹配。

**table**: fork一份pipeline,报文copy一份送给connection tracker,然后从当前指定table重入

**ct_nw_src**、**ct_nw_dst** 、**ct_nw_proto**、**ct_tp_src**、**ct_tp_dst**:ct 五元组

**ct_zone**:表示独立的CT 上下文,其拥有独立的ct表。作为action动作时,用于新建新的zone上下文,可以通过ct zone action来设置。没有新建过,则所有ct都在zone 0下进行。

**ct_mark**:可以通过 ct exec(set_field: 1->ct_mark)来设置。报文第一次匹配后,通过此action设置ct_mark到报文的metadata,重新注入datapath时,用来匹配流表指定的ct_mark。

**ct_label**:128的值,可以通过 ct exec(set_field: 1->ct_label)来设置,用法和ct_mark类似

## CT 动作

ovs通过ct action实现ct功能,格式如下:

```
ct([argument]...)
ct(commit[, argument]...)
```

这是两种不同的模式,即带commit和不带commit。ct 支持下面的参数:

```bash
commit 只有执行了commit,才会在conntrack模块创建conntrack表项
force 强制删除已存在的conntrack表项
table 跳转到指定的table执行
zone 设置zone,隔离conntrack
exec 执行其他action,目前只支持设置ct_mark和ct_label,比如exec(set_field: 1->ct_mark)
alg=<ftp/tftp> 指定alg类型,目前只支持ftp和tftp
nat 指定ip和port
```

示例使用:

```
#添加nat表项
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, in_port=veth_l0, actions=ct(commit,nat(src=10.1.1.240-10.2.2.2:2222-3333))"

//在一个ct里指定多次nat,只有最后一个nat生效,可参考do_xlate_actions中,ctx->ct_nat_action = ofpact_get_NAT(a)只有一个ctx->ct_nat_action 
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, actions=ct(commit,nat(src=10.1.1.240-10.2.2.2:2222-3333), nat(dst=10.1.1.240-10.2.2.2:2222-3333)), veth_r0"

//可以通过指定多个ct,实现fullnat,即同时转换源目的ip。
//但是这两个ct必须指定不同的zone,否则只有第一个ct生效。因为在 handle_nat 中,判断只有zone不一样才会进行后续的nat操作
//错误方式,指定了src和dst nat,但是zone相同,只有前面的snat生效
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, actions=ct(commit,nat(src=10.1.1.240-10.2.2.2:2222-3333)), ct(commit,nat(dst=10.1.1.240-10.2.2.2:2222-3333)), veth_r0"

//正确方式,使用不同zone,指定fullnat
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, actions=ct(commit,zone=100, nat(src=10.1.1.240-10.2.2.2:2222-3333)), ct(commit, zone=200, nat(dst=10.1.1.240-10.2.2.2:2222-3333)), veth_r0"
```

# TCP 使用范例

这里以TCP为例解释OVS中CT模块的使用。

## TCP SYN包

首先下流表匹配连接首包SYN:

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, in_port=veth_10, actions=ct(table=0)"
```

第一条规则代表当收到从veh_10口进来的tcp包时,如果是从未见过的五元组(-trk)状态,则将数据包投入CT表中查询。这里ct(table=0)是一个skb clone操作。由于没有后续操作,原有数据包丢弃不用。新clone的数据包,进入CT模块。

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+new, tcp, in_port=veth_10, actions=ct(commit),veth_r0"
```

第二条规则代表当数据包从CT模块回来后,此时已经有了track 跟踪(+trk),且为新的五元组(+new),所以我们下发指令:在CT表内新建表项,并将该数据包通过veth_r0转发出去。

流表下好后,我们通过scapy发送一个SYN包:

```
sendp(Ether()/IP(src="1.1.1.1", dst="1.1.1.2")/TCP(sport=1024, dport=2048, flags=0x02, seq=100), iface="veth_11")
```

可查阅当前CT表内容

```
[root@fedora lovelylich]# ovs-appctl dpctl/dump-conntrack | grep 1.1.1.1
tcp,orig=(src=1.1.1.1,dst=1.1.1.2,sport=1024,dport=2048),reply=(src=1.1.1.2,dst=1.1.1.1,sport=2048,dport=1024),protoinfo=(state=SYN_SENT)
[root@fedora lovelylich]#
```

注意:对于此时的重传SYN包,这两条规则都会再次重新命中。

## TCP SYN-ACK 包

同样下两条流表匹配SYN-ACK包:

```
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, in_port=veth_r0, actions=ct(table=0)"
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+est, tcp, in_port=veth_r0, actions=veth_10"
```

对于SYN-ACK包而言,从CT系统转一圈回来后,CT中的表项即转换成了+est态。我们以scapy发synack包确认:

```
sendp(Ether()/IP(src="1.1.1.2", dst="1.1.1.1")/TCP(sport=2048, dport=1024, flags=0x12, seq=200, ack=101), iface="veth_r1")
```

注意:虽然此时仅收双向包,CT表项就转为了est态,但该est态存在时间较短,如果一段时间后仍没有收到第三次ack,则该ct表项将被清理掉。只有真正收到了第三个ack包,ct中的est表项才会长时间存在。

查看此时CT表:

```
[root@fedora lovelylich]# ovs-appctl dpctl/dump-conntrack | grep 1.1.1.1
tcp,orig=(src=1.1.1.1,dst=1.1.1.3,sport=1024,dport=2048),reply=(src=1.1.1.3,dst=1.1.1.1,sport=2048,dport=1024),protoinfo=(state=ESTABLISHED)
[root@fedora lovelylich]#
```

## TCP ACK 包

我们下流表匹配最后ACK包:

```
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+est, tcp, in_port=veth_10, actions=veth_r0"
```

发送ACK包:

```
sendp(Ether()/IP(src="1.1.1.1", dst="1.1.1.3")/TCP(sport=1024, dport=2048, flags=0x10, seq=101, ack=201), iface="veth_11")
```

此后,ct表中的表项将存在很长时间,即便我们什么数据都不在发送。

## TCP 断连

TCP 断连过程中,CT表项状态将根据收到的数据包进行响应的状态转换:

收到第一次FIN-ACK时,转入FIN_WAIT_1

收到第二次反向FIN-ACK时,转入LAST_ACK

收到最后ACK时,转入TIME_WAIT状态。

但无论当前CT时何状态(即便是TIME_WAIT状态),这些FIN/ACK数据包,都将与正常数据包一样,匹配命中+est+trk状态。

# OVS 内核态 CT 实现

## CT 实现框架

### OVS 侧实现 - 进入 CT 前

ofctl下发的ct流表action,最终由`parse_CT`来解析存入ofpact中:

```c
static char * OVS_WARN_UNUSED_RESULT
parse_CT(char *arg, const struct ofpact_parse_params *pp)
{
    const size_t ct_offset = ofpacts_pull(pp->ofpacts);
    struct ofpact_conntrack *oc;
    char *error = NULL;
    char *key, *value;

    oc = ofpact_put_CT(pp->ofpacts);
    oc->flags = 0;
    oc->recirc_table = NX_CT_RECIRC_NONE;
    while (ofputil_parse_key_value(&arg, &key, &value)) {
        if (!strcmp(key, "commit")) {
    ......
}
```

在数据包刚到来时,还执行任何匹配和action动作时,此时状态被默认设置为-trk,该状态是在ovs_flow_key_extract->ovs_ct_update_key设置的:

```c
static void ovs_ct_update_key(const struct sk_buff *skb,
         const struct ovs_conntrack_info *info,
         struct sw_flow_key *key, bool post_ct,
         bool keep_nat_flags)
{
 ct = nf_ct_get(skb, &ctinfo);
 if (ct) {
  ......
 } else if (post_ct) {
  state = OVS_CS_F_TRACKED | OVS_CS_F_INVALID;
 }
 __ovs_ct_update_key(key, state, zone, ct);
}
static void __ovs_ct_update_key(struct sw_flow_key *key, u8 state,
    const struct nf_conntrack_zone *zone,
    const struct nf_conn *ct)
{
 key->ct_state = state; // 这里设置数据包的默认状态为-trk
 key->ct_zone = zone->id;
 key->ct.mark = ovs_ct_get_mark(ct);
 ovs_ct_get_labels(ct, &key->ct.labels);
 key->ct_orig_proto = 0;
}
```

由于此时还没有关联到ct 表项(无论ct系统中是否已有),所以ct==NULL,state也被赋值为0,所以OVS_CS_F_TRACKED trk 标志没有被设置,也即-trk状态。所以,所有刚进入ovs的数据包,都是untrack的,所以匹配untrack流表。只有经过netfilter ct 系统转一圈后才会有trk 标记。

在dp层没有流表,触发upcall解析时,应用层时调用`do_xlate_actions`完成ofpact内容解析的:

```c
static void
do_xlate_actions(const struct ofpact *ofpacts, size_t ofpacts_len,
                 struct xlate_ctx *ctx, bool is_last_action,
                 bool group_bucket_action)
{
  case OFPACT_CT:
            compose_conntrack_action(ctx, ofpact_get_CT(a), last);
            break;
}
```

这里的ofpact_get_CT是通过宏定义的,本质是从ofpact指针转换为ofpact_conntrack指针:

```
OFPACT(CT,              ofpact_conntrack,   ofpact, "ct")
```

而在compose_conntrack_action函数中,通过解析将ofpact_conntrack中的值,转化组建成为netlink 消息,发送给内核态模块。而内核态的openvswitch模块采用netlink机制与应用层通信,此前已经通过注册dp_packet_genl_ops注册了回调函数ovs_packet_cmd_execute。

```c
static struct genl_ops dp_packet_genl_ops[] = {
 { .cmd = OVS_PACKET_CMD_EXECUTE,
   .validate = GENL_DONT_VALIDATE_STRICT | GENL_DONT_VALIDATE_DUMP,
   .flags = GENL_UNS_ADMIN_PERM, /* Requires CAP_NET_ADMIN privilege. */
   .policy = packet_policy,
   .doit = ovs_packet_cmd_execute
 }
};
```

在收到应用层的netlink响应数据时,内核`genl_family_rcv_msg_doit`netlink消息分发函数会执行对应的回调,在这里就是`ovs_packet_cmd_execute`函数。

随后,`ovs_packet_cmd_execute`->`__ovs_nla_copy_actions`->`__ovs_nla_copy_actions`->`ovs_ct_copy_action`->`parse_ct`负责解析从应用层传下来的action列表nlattr属性中的ct action部分,并存入ovs_conntrack_info结构体中:

```
static int parse_ct(const struct nlattr *attr, struct ovs_conntrack_info *info,
      const char **helper, bool log)
{
 nla_for_each_nested(a, attr, rem) {
  switch (type) {
  case OVS_CT_ATTR_FORCE_COMMIT:
   info->force = true;
   /* fall through. */
  case OVS_CT_ATTR_COMMIT:
   info->commit = true;
   break;
#ifdef CONFIG_NF_CONNTRACK_ZONES
  case OVS_CT_ATTR_ZONE:
   info->zone.id = nla_get_u16(a);
   break;
#endif
  ......
```

解析结果放入ovs_conntrack_info后,最后通过调用ovs_nla_add_action将需要执行的action列表添加到sw_flow_actions->actions数组中,留待ovs_execute_actions中执行actions时使用:

```c
static int ovs_packet_cmd_execute(struct sk_buff *skb, struct genl_info *info)
{
 ......
 //生成sw_flow_actions动作列表
 err = ovs_nla_copy_actions(net, a[OVS_PACKET_ATTR_ACTIONS],
       &flow->key, &acts, log);
 if (err)
  goto err_flow_free;
 
 rcu_assign_pointer(flow->sf_acts, acts);
 ......
 sf_acts = rcu_dereference(flow->sf_acts);
 ......
 local_bh_disable();
 //执行动作
 err = ovs_execute_actions(dp, packet, sf_acts, &flow->key);
 local_bh_enable();
 rcu_read_unlock();
```

接下来对skb 执行action列表中的所有动作,比如output到某网口,直到所有action执行完毕,最后consume_skb()释放掉当前数据包。在`do_execute_actions`执行action列表的过程中,如果发现有`OVS_ACTION_ATTR_CT` action,就会调用nla_data(a)重新取出此前在`parse_ct`中通过`ovs_nla_add_action`添加的`ovs_conntrack_info`结构体,并进入ovs_ct_execute()根据`ovs_conntrack_info`中的相关信息,执行ct对应的处理逻辑。

```c
static int do_execute_actions(struct datapath *dp, struct sk_buff *skb,
         struct sw_flow_key *key,
         const struct nlattr *attr, int len)
{
 const struct nlattr *a;
 int rem;

 for (a = attr, rem = len; rem > 0;
      a = nla_next(a, &rem)) {
     case OVS_ACTION_ATTR_CT:
   ......
   err = ovs_ct_execute(ovs_dp_get_net(dp), skb, key,
          nla_data(a));
   ......
   break;
```

这里的ovs_ct_execute就是负责执行相应的动作,如set-mark,set-label,commit等。如果都不是(比如`actions=ct(table=0)`),也会进入ct模块进行查询,查询不到则也会新增表项。但区别是ovs_ct_commit 一定会将表项移入confirmed list,使得ct表项存在时间更长。

```c
int ovs_ct_execute(struct net *net, struct sk_buff *skb,
     struct sw_flow_key *key,
     const struct ovs_conntrack_info *info)
{
 //由于netfilter的ct工作在ip层,而ovs的数据包在二层,所以这里pull掉二层头
 //偏移data指针到三层,后续从ovs提交skb给netfilter ct模块
 nh_ofs = skb_network_offset(skb);
 skb_pull_rcsum(skb, nh_ofs);

 err = ovs_skb_network_trim(skb);
 if (err)
  return err;

 if (info->commit)
  //带有commit的ct action
  err = ovs_ct_commit(net, key, info, skb);
 else
  //不带commit的ct action
  err = ovs_ct_lookup(net, key, info, skb);
 //重新恢复二层头
 skb_push(skb, nh_ofs);
 skb_postpush_rcsum(skb, skb->data, nh_ofs);
 if (err)
  kfree_skb(skb);
 return err;
}
```

### Linux 内核侧实现

先看Linux 内核 CT系统的大概框架。Linux 内核的CT系统构建于netfilter hook点之上,如下:

![](http://arthurchiao.art/assets/img/conntrack/netfilter-conntrack.png)

其中在PRE_ROUTING和OUTPUT处截获进出的包进行CT处理,但此时ct表项暂存到unconfirmed list中,并在INPUT和POST_ROUTING处确认掉unconfirmed list中的ct表项,并真正移入ct hash表。

与其他ct 系统不一样的是,内核ct 实现有unconfirmed 和confirmed 的概念。这是因为linux 内核的ct 还要考虑linux 内核协议栈本身的框架,协议栈可能在local_out/post_routing 中间的各个点失败(比如路由查找失败、mtu检查等等)导致放弃发送。收包时亦是如此。所以,在local_out的时候新建的nf_conn表项存在于unconfirmed链中,直到post_routing真正出去时,才真正移入ct hash表。

**但OVS 本身是没有这些hook点的!OVS工作在二层,netfilter 是在三层 IP 层的!所以无论进入ct 系统,抑或是离开,都跟netfilter hook点没有关系!** OVS 是直接通过调用`nf_conntrack_in`/`nf_conntrack_confirm`来使用内核CT模块的。

针对到OVS而言,数据包skb在ovs 内执行相关前期准备与流表动作后,是通过`nf_conntrack_in`进入标准内核CT 模块(Linux内核也是通过此函数入口,将数据包送入netfilter ct系统的):

```c
unsigned int
nf_conntrack_in(struct sk_buff *skb, const struct nf_hook_state *state)
{
    ......
 //获取4层协议类型
 dataoff = get_l4proto(skb, skb_network_offset(skb), state->pf, &protonum);
    ......
repeat:
    //这里如果没有已有ct项,会尝试新建ct
 ret = resolve_normal_ct(tmpl, skb, dataoff,
    protonum, state);
 ......
    //获取原有或者新建的ct表项
 ct = nf_ct_get(skb, &ctinfo);
 ......
    //处理该连接的协议状态变迁
 ret = nf_conntrack_handle_packet(ct, skb, dataoff, ctinfo, state);
 ......
 if (ctinfo == IP_CT_ESTABLISHED_REPLY &&
     !test_and_set_bit(IPS_SEEN_REPLY_BIT, &ct->status))
  nf_conntrack_event_cache(IPCT_REPLY, ct);
out:
 if (tmpl)
  nf_ct_put(tmpl);

 return ret;
}
```

此时,在`resolve_normal_ct() `中,如果发现当前是新连接,则会在`init_conntrack`新建一个ct 表项并初始化:

```c
/* On success, returns 0, sets skb->_nfct | ctinfo */
static int
resolve_normal_ct(struct nf_conn *tmpl,
    struct sk_buff *skb,
    unsigned int dataoff,
    u_int8_t protonum,
    const struct nf_hook_state *state)
{
 //先获取连接五元组
 if (!nf_ct_get_tuple(skb, skb_network_offset(skb),
        dataoff, state->pf, protonum, state->net,
        &tuple)) {
 }

 //然后根据元组查找ct表
 zone = nf_ct_zone_tmpl(tmpl, skb, &tmp);
 hash = hash_conntrack_raw(&tuple, state->net);
 h = __nf_conntrack_find_get(state->net, zone, &tuple, hash);
 if (!h) {
  //如果没有找到,就新建一个ct表项,并链入unconfirmed list
  h = init_conntrack(state->net, tmpl, &tuple,
       skb, dataoff, hash);
  if (!h)
   return 0;
 }
    ......
```

这里新建表项时,实际包含了两个工作,新建nf_conn结构体用以表示一条连接(不考虑方向),以及新建两个方向的五元组ct节点,即

```c
struct nf_conntrack_tuple_hash {
 struct hlist_nulls_node hnnode;
 struct nf_conntrack_tuple tuple;
};

struct nf_conn {
 struct nf_conntrack_tuple_hash tuplehash[IP_CT_DIR_MAX];
    unsigned long status;
}
```

这里IP_CT_DIR_MAX=2,代表连接的两个方向。这里的tuplehash是属于nf_conn结构体的,所以创建nf_conn结构体就是新建了两个方向的ct节点。这样,当reply包达到时,可以直接查找到对应的ct表项。这两个hnode都是现在就链入ct 表,他们的tuple分别代表连接的两个方向的五元组,但该条连接的状态信息,仍旧由nf_conn->status统一维护。同时把连接双向的五元组都会建立好,并将ORIGINAL方向的hnode链入percpu 的 unconfirm list链表,目前还不会直接链入ct表。

在`resolve_normal_ct`新建完ct表项,或者查找到已存在表项时,找到对应的nf_conn连接状态维护结构体,最后会设置`skb->_nfct`的状态:

```c
static int
resolve_normal_ct(struct nf_conn *tmpl,
    struct sk_buff *skb,
    unsigned int dataoff,
    u_int8_t protonum,
    const struct nf_hook_state *state)
{
    ......
 ct = nf_ct_tuplehash_to_ctrack(h);

 //接下来根据新建或者查找到的ct表项,设置当前skb->ct状态为ctinfo状态
 if (NF_CT_DIRECTION(h) == IP_CT_DIR_REPLY) {
  ctinfo = IP_CT_ESTABLISHED_REPLY;
 } else {
  /* Once we've had two way comms, always ESTABLISHED. */
  if (test_bit(IPS_SEEN_REPLY_BIT, &ct->status)) {
   pr_debug("normal packet for %p\n", ct);
   ctinfo = IP_CT_ESTABLISHED;
  } else if (test_bit(IPS_EXPECTED_BIT, &ct->status)) {
   pr_debug("related packet for %p\n", ct);
   ctinfo = IP_CT_RELATED;
  } else {
   pr_debug("new packet for %p\n", ct);
   ctinfo = IP_CT_NEW;
  }
 }
    //设置skb的_nfct指针,指向新建的nf_conn结构体,并且同时设置skb的ct状态
 nf_ct_set(skb, ct, ctinfo);
 return 0;
}
```

**!!!注意:这里设置的是skb->_nfct指针低三位bit位所代表的连接状态,这与nf_conn->status是不一样的。nf__conn->state代表的是CT表项中从CT模块角度看到的连接状态,无论当前skb是否已经结束生命期,该连接状态都始终存在,直到新的数据包到来,再次触发状态迁移。而这里的skb->\_nfct代表的是该skb数据包的ct状态,此状态用于skb离开CT系统处理后,在规则匹配中使用,该状态仅存在于数据包存在周期。**

这里**对于新数据包而言,则是将ctinfo 置位为IP_CT_NEW状态**。netfilter中skb->_nfct状态总共由7种状态定义,这些状态定义的是数据包本身的状态,用于在ct 规则中做匹配用,比如IP_CT_NEW匹配的就是+new标记:

```c
/* Connection state tracking for netfilter.  This is separated from,
   but required by, the NAT layer; it can also be used by an iptables
   extension. */
enum ip_conntrack_info {
 /* Part of an established connection (either direction). */
 IP_CT_ESTABLISHED,
 /* Like NEW, but related to an existing connection, or ICMP error
    (in either direction). */
 IP_CT_RELATED,
 /* Started a new connection to track (only
           IP_CT_DIR_ORIGINAL); may be a retransmission. */
 IP_CT_NEW,
 /* >= this indicates reply direction */
 IP_CT_IS_REPLY,
 IP_CT_ESTABLISHED_REPLY = IP_CT_ESTABLISHED + IP_CT_IS_REPLY,
 IP_CT_RELATED_REPLY = IP_CT_RELATED + IP_CT_IS_REPLY,
 /* No NEW in reply direction. */
 /* Number of distinct IP_CT types. */
 IP_CT_NUMBER,
 IP_CT_UNTRACKED = 7,
};
```

这些状态存于skb->_nfct字段的低三位,高29位则作为指针,指向skb对应的ct 表项地址。可通过`nf_ct_get()` 函数方便的获取和设置。

```c
static inline void
nf_ct_set(struct sk_buff *skb, struct nf_conn *ct, enum ip_conntrack_info info)
{
 skb_set_nfct(skb, (unsigned long)ct | info);
}

/* Return conntrack_info and tuple hash for given skb. */
static inline struct nf_conn *
nf_ct_get(const struct sk_buff *skb, enum ip_conntrack_info *ctinfo)
{
 unsigned long nfct = skb_get_nfct(skb);

 *ctinfo = nfct & NFCT_INFOMASK;
 return (struct nf_conn *)(nfct & NFCT_PTRMASK);
}
```

每种协议,都需要实现自己的新建ct表项方法,用以完成协议特定的初始化操作,比如tcp的tcp_new甚至会在nf_conn中记录最大ack值等信息。udp则是没有自己特定的新建时初始化操作。

最后,新建/查找到ct表项后,由nf_conntrack_handle_packet负责处理协议特定的ct状态变迁。该函数实际是个代理,会根据`ct->tuplehash[IP_CT_DIR_ORIGINAL].tuple.dst.protonum`取得当前skb的协议类型,转而调用对应协议的处理函数,比如udp是nf_conntrack_udp_packet,tcp是nf_conntrack_tcp_packet,这些函数负责处理自己协议特定的状态变迁。

每个协议都在nf_conn的proto定义了自己的私有成员域:

```c
struct nf_conn {
 ......
 /* Storage reserved for other modules, must be the last member */
 union nf_conntrack_proto proto;
};
/* per conntrack: protocol private data */
union nf_conntrack_proto {
 /* insert conntrack proto private data here */
 .....
 struct ip_ct_tcp tcp;
 struct nf_ct_udp udp;
 struct nf_ct_gre gre;
 .....
};
struct nf_ct_udp {
 unsigned long stream_ts;
};
struct ip_ct_tcp {
    ......
 u_int8_t state;  /* state of the connection (enum tcp_conntrack) */
 ......
};
```

简单的udp协议是直接使用nf_conn->status字段作为自身连接状态。而复杂如tcp,则是在nf_conn->proto->(tcp/udp)->state 状态单独维护自己协议的状态变迁,甚至还记录了连接建立过程中的协商字段。

这里假设当前是udp协议数据包(该协议的状态跳转比较简单,TCP协议的状态变化我们在后文单独讲解),则进入udp处理流程。如果当前udp包是一个回复包,在`resolve_normal_ct`中直接根据数据包的发送方向判定为回复包,并设置skb->_nfct状态为IP_CT_ESTABLISHED_REPLY:

````c
static int
resolve_normal_ct(struct nf_conn *tmpl,
    struct sk_buff *skb,
    unsigned int dataoff,
    u_int8_t protonum,
    const struct nf_hook_state *state)
{
    ......
 ct = nf_ct_tuplehash_to_ctrack(h);

 //接下来根据新建或者查找到的ct表项,设置当前skb->ct状态为ctinfo状态
 if (NF_CT_DIRECTION(h) == IP_CT_DIR_REPLY) {
  ctinfo = IP_CT_ESTABLISHED_REPLY;
 } else {
````

此时则代表双向数据包我们都已经收到,于是将skb->_nfct置为IP_CT_ESTABLISHED_REPLY,代表该数据包既是连接建立,又是连接的回复包。在离开CT后,在OVS侧(后文ovs_ct_get_state可以看到)OVS 再次转化该状态,这样skb就匹配上离开CT系统后的OVS 规则+trk+est状态:

```
IP_CT_ESTABLISHED_REPLY = IP_CT_ESTABLISHED + IP_CT_IS_REPLY,
```

再看ct 系统nf_conn本身的状态维护。在`nf_conntrack_in`最后,也就是数据包即将离开CT 模块时,检查是否是回复包,如果是,同时设置nf_conn->status字段为看到回复包了IPS_SEEN_REPLY_BIT:

```c
unsigned int
nf_conntrack_in(struct sk_buff *skb, const struct nf_hook_state *state)
{
 ......
 if (ctinfo == IP_CT_ESTABLISHED_REPLY &&
     !test_and_set_bit(IPS_SEEN_REPLY_BIT, &ct->status))
  nf_conntrack_event_cache(IPCT_REPLY, ct);
 ......
 return ret;
}
```

这样,设置了nf_conn为已发现回复包后,对于此后所有的数据包,无论从哪个方向来,都会发现连接已设置了IPS_SEEN_REPLY_BIT,在resolve_normal_ct里面,直接将这些数据包设置为+est状态(当然,+est状态还是OVS来转化的,CT只是设置skb为IP_CT_ESTABLISHED:

```c
 /* It exists; we have (non-exclusive) reference. */
 if (NF_CT_DIRECTION(h) == IP_CT_DIR_REPLY) {
  ctinfo = IP_CT_ESTABLISHED_REPLY;
 } else {
  /* Once we've had two way comms, always ESTABLISHED. */
  if (test_bit(IPS_SEEN_REPLY_BIT, &ct->status)) {
   pr_debug("normal packet for %p\n", ct);
   ctinfo = IP_CT_ESTABLISHED;
  } else if (test_bit(IPS_EXPECTED_BIT, &ct->status)) {
   pr_debug("related packet for %p\n", ct);
   ctinfo = IP_CT_RELATED;
  } else {
   pr_debug("new packet for %p\n", ct);
   ctinfo = IP_CT_NEW;
  }
 }
```

如果连接是单方向的新建包/重传包,这种情况只是在`nf_ct_refresh_acct`中延期ct表项的超时时间,没有其他额外设置。所以,此时的skb->_nfct的状态仍然是前述resolve_normal_ct中设置的`IP_CT_NEW`状态,而nf_conn->status自从分配后没有变化,所以仍然是`__nf_conntrack_alloc`中分配时默认设置的0。

```c
/* Returns verdict for packet, and may modify conntracktype */
int nf_conntrack_udp_packet(struct nf_conn *ct,
       struct sk_buff *skb,
       unsigned int dataoff,
       enum ip_conntrack_info ctinfo,
       const struct nf_hook_state *state)
{
 ......
 /* If we've seen traffic both ways, this is some kind of UDP
  * stream. Set Assured.
  */
 if (test_bit(IPS_SEEN_REPLY_BIT, &ct->status)) {
  unsigned long extra = timeouts[UDP_CT_UNREPLIED];

  /* Still active after two seconds? Extend timeout. */
  if (time_after(jiffies, ct->proto.udp.stream_ts))
   extra = timeouts[UDP_CT_REPLIED];

  nf_ct_refresh_acct(ct, ctinfo, skb, extra);

  /* never set ASSURED for IPS_NAT_CLASH, they time out soon */
  if (unlikely((ct->status & IPS_NAT_CLASH)))
   return NF_ACCEPT;

  /* Also, more likely to be important, and not a probe */
  if (!test_and_set_bit(IPS_ASSURED_BIT, &ct->status))
   nf_conntrack_event_cache(IPCT_ASSURED, ct);
 } else {
  nf_ct_refresh_acct(ct, ctinfo, skb, timeouts[UDP_CT_UNREPLIED]);
 }
 return NF_ACCEPT;
}
```

假如这里是udp的回复包,在数据包第一次以-trk状态进入CT系统并离开CT时,会设置nf_conn->state为IPS_SEEN_REPLY_BIT。这样,当udp数据包第二次+trk+est进入CT系统后,在`nf_conntrack_udp_packet`此时才修改udp连接的状态:nf_conn->status,将设置为IPS_ASSURED_BIT。

ct 模块定义了连接的以下状态集合,其中主要是IPS_SEEN_REPLY、IPS_CONFIRMED、IPS_ASSURED。其余主要是为ftp、nat服务的。

```c
enum ip_conntrack_status {
    IPS_EXPECTED      = (1 << IPS_EXPECTED_BIT),
    IPS_SEEN_REPLY    = (1 << IPS_SEEN_REPLY_BIT),
    IPS_ASSURED       = (1 << IPS_ASSURED_BIT),
    IPS_CONFIRMED     = (1 << IPS_CONFIRMED_BIT),
    IPS_SRC_NAT       = (1 << IPS_SRC_NAT_BIT),
    IPS_DST_NAT       = (1 << IPS_DST_NAT_BIT),
    IPS_NAT_MASK      = (IPS_DST_NAT | IPS_SRC_NAT),
    ......
};
```

至此,内核ct 系统结束,再次回到ovs 处理逻辑。

### OVS 侧实现 - 离开 CT 后

数据包经由nf_conntrack_in调用,在netfilter ct 系统跑一圈后出来,OVS通过`ovs_ct_update_key`来将key->ct_state更新,用于匹配。其中,`ovs_ct_update_key`->`ovs_ct_get_state`实现netfilter skb->_nfct 状态(比如IP_CT_NEW)和ovs 需要的匹配状态(比如OVS_CS_F_NEW)之间的转换映射,并为经过CT系统的数据报都打上+trk标记:OVS_CS_F_TRACKED。

所以当数据包跑一圈后,对应的skb 状态为IP_CT_NEW时,对应的sw_flow_key ct_state状态则为 OVS_CS_F_TRACKED | OVS_CS_F_NEW:

```c
/* Map SKB connection state into the values used by flow definition. */
static u8 ovs_ct_get_state(enum ip_conntrack_info ctinfo)
{
 u8 ct_state = OVS_CS_F_TRACKED;

 switch (ctinfo) {
 case IP_CT_ESTABLISHED_REPLY:
 case IP_CT_RELATED_REPLY:
  ct_state |= OVS_CS_F_REPLY_DIR;
  break;
 default:
  break;
 }

 switch (ctinfo) {
 case IP_CT_ESTABLISHED:
 case IP_CT_ESTABLISHED_REPLY:
  ct_state |= OVS_CS_F_ESTABLISHED;
  break;
 case IP_CT_RELATED:
 case IP_CT_RELATED_REPLY:
  ct_state |= OVS_CS_F_RELATED;
  break;
 case IP_CT_NEW:
  ct_state |= OVS_CS_F_NEW;
  break;
 default:
  break;
 }

 return ct_state;
}
```

所以,这时数据包回到OVS并再次进行匹配时,就是+trk+new状态了。就会匹配上比如下面这条规则:

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+new, tcp, in_port=veth_10, actions=ct(commit),veth_r0"
```

这时会再次进入`ovs_ct_execute`函数,并执行相关的CT action,比如commit。假设命中的是`commit` action,则不同于Linux netfilter CT 实现在LOCAL_IN/POST_ROUTING处将ct表项从unconfirmed list移入ct hash 表,OVS此时是直接调用Linux 内核 CT的接口`nf_conntrack_confirm`将skb对应的nf_conn从unconfirmed list 移入真正的hash表。另外,linux ct 在confirm时,会同时把nf_conn状态置位为IPS_CONFIRMED状态:

```c
static void __nf_conntrack_insert_prepare(struct nf_conn *ct)
{
 ......
 atomic_inc(&ct->ct_general.use);
 ct->status |= IPS_CONFIRMED;
 ......
}
```

### 内核 CT 下的TCP 状态迁移

先说核心框架。对于tcp而言,由于ct表项在有数据包到来时,需要完成状态迁移,所以每个协议都需要自定义自己的状态迁移表,比如tcp的状态迁移表则定义如下:

```c
/*
 * The TCP state transition table needs a few words...
 *
 * We are the man in the middle. All the packets go through us
 * but might get lost in transit to the destination.
 * It is assumed that the destinations can't receive segments
 * we haven't seen.
 *
 * The checked segment is in window, but our windows are *not*
 * equivalent with the ones of the sender/receiver. We always
 * try to guess the state of the current sender.
 *
 * The meaning of the states are:
 *
 * NONE: initial state
 * SYN_SENT: SYN-only packet seen
 * SYN_SENT2: SYN-only packet seen from reply dir, simultaneous open
 * SYN_RECV: SYN-ACK packet seen
 * ESTABLISHED: ACK packet seen
 * FIN_WAIT: FIN packet seen
 * CLOSE_WAIT: ACK seen (after FIN)
 * LAST_ACK: FIN seen (after FIN)
 * TIME_WAIT: last ACK seen
 * CLOSE: closed connection (RST)
 *
 * Packets marked as IGNORED (sIG):
 * if they may be either invalid or valid
 * and the receiver may send back a connection
 * closing RST or a SYN/ACK.
 *
 * Packets marked as INVALID (sIV):
 * if we regard them as truly invalid packets
 */
static const u8 tcp_conntracks[2][6][TCP_CONNTRACK_MAX] = {
 {
/* ORIGINAL */
/*       sNO, sSS, sSR, sES, sFW, sCW, sLA, sTW, sCL, sS2 */
/*syn*/    { sSS, sSS, sIG, sIG, sIG, sIG, sIG, sSS, sSS, sS2 },
/*
 * sNO -> sSS Initialize a new connection
 * sSS -> sSS Retransmitted SYN
 * sS2 -> sS2 Late retransmitted SYN
 * sSR -> sIG
 * sES -> sIG Error: SYNs in window outside the SYN_SENT state
 *   are errors. Receiver will reply with RST
 *   and close the connection.
 *   Or we are not in sync and hold a dead connection.
 * sFW -> sIG
 * sCW -> sIG
 * sLA -> sIG
 * sTW -> sSS Reopened connection (RFC 1122).
 * sCL -> sSS
 */
/*       sNO, sSS, sSR, sES, sFW, sCW, sLA, sTW, sCL, sS2 */
/*synack*/ { sIV, sIV, sSR, sIV, sIV, sIV, sIV, sIV, sIV, sSR },
/*
 * sNO -> sIV Too late and no reason to do anything
 * sSS -> sIV Client can't send SYN and then SYN/ACK
 * sS2 -> sSR SYN/ACK sent to SYN2 in simultaneous open
 * sSR -> sSR Late retransmitted SYN/ACK in simultaneous open
 * sES -> sIV Invalid SYN/ACK packets sent by the client
 * sFW -> sIV
 * sCW -> sIV
 * sLA -> sIV
 * sTW -> sIV
 * sCL -> sIV
 */
  .......
};
```

状态名字比较抽象,对应关系如下,其实就是普通的TCP状态:

```
#define sNO TCP_CONNTRACK_NONE
#define sSS TCP_CONNTRACK_SYN_SENT
#define sSR TCP_CONNTRACK_SYN_RECV
#define sES TCP_CONNTRACK_ESTABLISHED
#define sFW TCP_CONNTRACK_FIN_WAIT
#define sCW TCP_CONNTRACK_CLOSE_WAIT
#define sLA TCP_CONNTRACK_LAST_ACK
#define sTW TCP_CONNTRACK_TIME_WAIT
#define sCL TCP_CONNTRACK_CLOSE
#define sS2 TCP_CONNTRACK_SYN_SENT2
#define sIV TCP_CONNTRACK_MAX
#define sIG TCP_CONNTRACK_IGNORE
```

该转移表通过填入当前收包方向、当前收包的tcp标志位、现有ct的状态来获取新的状态值。举个例子:对于新的ct表项,收到syn包的情况下,是这样获取新的状态的:

````c
new_state = tcp_conntracks[0][TCP_SYN_SET][TCP_CONNTRACK_NONE];
````

所以得到sSS 状态,也就是TCP_CONNTRACK_SYN_SENT。另外在负责处理tcp 收到新数据包时,ct表项的状态迁移的nf_conntrack_tcp_packet函数中,也采用该迁移表维护ct表项的状态变化。

此外,对于处于连接不同状态的CT节点,TCP也有着不同的超时值,以便于节省内存,避免DDOS攻击弱点。这里定义了tcp的ct表项在各个状态下的超时值:

```c
static const unsigned int tcp_timeouts[TCP_CONNTRACK_TIMEOUT_MAX] = {
 [TCP_CONNTRACK_SYN_SENT] = 2 MINS,
 [TCP_CONNTRACK_SYN_RECV] = 60 SECS,
 [TCP_CONNTRACK_ESTABLISHED] = 5 DAYS,
 [TCP_CONNTRACK_FIN_WAIT] = 2 MINS,
 [TCP_CONNTRACK_CLOSE_WAIT] = 60 SECS,
 [TCP_CONNTRACK_LAST_ACK] = 30 SECS,
 [TCP_CONNTRACK_TIME_WAIT] = 2 MINS,
 [TCP_CONNTRACK_CLOSE]  = 10 SECS,
 [TCP_CONNTRACK_SYN_SENT2] = 2 MINS,
/* RFC1122 says the R2 limit should be at least 100 seconds.
   Linux uses 15 packets as limit, which corresponds
   to ~13-30min depending on RTO. */
 [TCP_CONNTRACK_RETRANS]  = 5 MINS,
 [TCP_CONNTRACK_UNACK]  = 5 MINS,
};
```

### 关于 NAT 实现

Linux 内核的 CT 是支持NAT 转换的,OVS也利用了这点实现动态NAT(不同于modify action)。

有几点需要注意:

1. NAT操作必须在helper(实现CT ALG功能的函数)之前完成,以便于helper函数知道NAT动作的存在
2. NAT更改IP地址,导致可能需要执行序列号调整等操作,这需要注意。

## TCP 三次握手之旅

前面是综述各个关键函数,以及实现逻辑,但CT连接状态迁移、skb的状态和OVS规则匹配涉及包序,在前文讲骨骼框架中无法细述。故接下来以最复杂的TCP三次握手为例,说明OVS 内核 CT 的实现逻辑,重点关注状态迁移,对于实现函数和实现方法,则简要带过,不再赘述。

此外,从上面框架描述也能看出,OVS中CT流程主要分为以下四步:

1. 匹配最初规则;
2. 查找/创建CT表项,设置包状态;
3. 处理协议特定状态;
4. 新状态回到OVS

### SYN 包到来时

**匹配规则**:首先syn包到来时,由于skb->_nfct状态仍为0,也就是既没有ct表项与之关联,也没有ct状态设置。所以此时数据包匹配的是-trk状态。

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, in_port=veth_10, actions=ct(table=0)"
```

由该规则action通过`nf_conntrack_in`转入CT模块处理。

**查找/创建CT表项,设置包状态**:CT模块发现新连接数据包,于是`init_conntrack`创建nf_conn结构,创建双向tuple,并设置nf_conn->status为0,最后链入unconfirmed list链表。

同时,`resolve_normal_ct`检查nf_conn->status,由于状态为0,所以把这个新连接的数据包skb->_nfct设置为IP_CT_NEW。至此,数据包匹配OVS规则的+trk+new状态。

**处理协议特定状态**:CT 本身的状态迁移处理完成,最后进入协议特定的状态处理。tcp是在`nf_conntrack_tcp_packet`处理的。

这是新的tcp 连接,所以初始化`nf_conn->proto.tcp`字段,包括以skb为信息源,初始化其中的td_end(最大序列号)、窗口尺寸以及tcp 协议选项等。

最后,通过tcp的协议状态跳转表tcp_conntracks,设置该CT连接的协议特定连接状态为TCP_CONNTRACK_SYN_SENT,以及该CT表项的超时时间。

```c
ct->proto.tcp.state = new_state
```

**新状态回到OVS**:完成CT模块处理后,skb 带着新的+trk+new状态,流转回OVS,继续匹配新的规则,执行新的action。比如经过CT处理后的SYN包就匹配下面这条规则:

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+new, tcp, in_port=veth_10, actions=ct(commit),veth_r0"
```

commit action是在`ovs_ct_commit`中处理的,主要是设置nf_conn的label、mark值。最后,将skb对应的nf_conn从unconfirmed list链表移入CT hash表,随后结束CT 处理,从veth_r0送出。

### SYN-ACK 包到来时

**匹配规则**:同样,当SYN-ACK到来时,数据包没有设置过状态,匹配如下规则:

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, in_port=veth_r0, actions=ct(table=0)"
```

**查找/创建CT表项,设置包状态**:同样转入CT模块处理。直接找到之前已经创建的CT表项,同时由于当前数据包是回包方向,所以设置数据包状态skb->_nfct为IP_CT_ESTABLISHED_REPLY,也就是匹配+trk+est+rpl状态。

**处理协议特定状态**:查找到CT表项,也更新了skb状态,同样,继续处理协议特定的状态迁移:依然在`nf_conntrack_tcp_packet`中,由于是反方向的SYN-ACK包,原有状态为TCP_CONNTRACK_SYN_SENT,查找tcp_conntracks跳转表,更新ct->proto.tcp.state新的状态为`sSR`,即TCP_CONNTRACK_SYN_RECV,同时更新超时时间。

最后在即将离开CT模块时,由于已经收到回复包,所以设置连接状态nf_conn->status为IPS_SEEN_REPLY_BIT。

**新状态回到OVS**:完成CT模块处理后,skb 带着新的+trk+est+rpl状态,流转回OVS,继续匹配新的规则,执行新的action。比如经过CT处理后的SYN-ACK包就匹配下面这条规则,最后经由veth_10口送出。

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+est, tcp, in_port=veth_r0, actions=veth_10"
```

### ACK 包到来时

**匹配规则**:数据包没有设置过状态,匹配如下规则:

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=-trk, tcp, in_port=veth_10, actions=ct(table=0)"
```

**查找/创建CT表项,设置包状态**:同样转入CT模块处理。直接找到之前已经创建的CT表项,CT表项状态已设置IPS_SEEN_REPLY_BIT,所以设置数据包状态skb->_nfct为IP_CT_ESTABLISHED,也就是匹配+trk+est状态。

**处理协议特定状态**:查找到CT表项,也更新了skb状态,同样,继续处理协议特定的状态迁移:依然在`nf_conntrack_tcp_packet`中,由于是正向ACK包,原有状态为TCP_CONNTRACK_SYN_RECV,查找tcp_conntracks跳转表,更新ct->proto.tcp.state新的状态为`sES`,即TCP_CONNTRACK_ESTABLISHED,同时更新超时时间。

此外,这里`nf_conntrack_tcp_packet`也同样更新CT表项的状态nf_conn->status为IPS_ASSURED,至此CT表项状态也转为连接建立成功:

```
 } else if (!test_bit(IPS_ASSURED_BIT, &ct->status)
     && (old_state == TCP_CONNTRACK_SYN_RECV
         || old_state == TCP_CONNTRACK_ESTABLISHED)
     && new_state == TCP_CONNTRACK_ESTABLISHED) {
  /* Set ASSURED if we see valid ack in ESTABLISHED
     after SYN_RECV or a valid answer for a picked up
     connection. */
  set_bit(IPS_ASSURED_BIT, &ct->status);
```

在此状态的CT表项,不会被GC过早清理掉:

```c
static bool gc_worker_can_early_drop(const struct nf_conn *ct)
{
 const struct nf_conntrack_l4proto *l4proto;

 if (!test_bit(IPS_ASSURED_BIT, &ct->status))
  return true;

 l4proto = nf_ct_l4proto_find(nf_ct_protonum(ct));
 if (l4proto->can_early_drop && l4proto->can_early_drop(ct))
  return true;

 return false;
}
```

**新状态回到OVS**:完成CT模块处理后,skb 带着新的+trk+est状态,流转回OVS,继续匹配新的规则,执行新的action。比如经过CT处理后的SYN-ACK包就匹配下面这条规则,最后经由veth_10口送出。

```sh
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+est, tcp, in_port=veth_10, actions=veth_r0"
```

至此,协议特定的三次握手完成。其实我们能看到,在SYNACK到来时,OVS就已经标记数据包为+est了,这点需要注意。

## TCP 四次挥手

TCP 四次挥手时,CT维护就比较简单了,没有预想中的那么复杂。OVS中数据包都是+est状态(无论此时TCP处于何种状态、skb带了何种标记),甚至在TCP在TIME_WAIT态时,skb也是+est状态!只是CT表项中协议特定的状态需要在收到特定包时,保持跟TCP本身的状态机一致。

那么,对于四次挥手而言,nf_conn->status又是如何变化的呢?答案是**没变化**!依然是IPS_SEEN_REPLY_BIT|IPS_ASSURED_BIT。这其实才是符合预期的,因为只有这样,数据包状态才会一直置为+est:

```c
 /* It exists; we have (non-exclusive) reference. */
 if (NF_CT_DIRECTION(h) == IP_CT_DIR_REPLY) {
  ctinfo = IP_CT_ESTABLISHED_REPLY;
 } else {
  /* Once we've had two way comms, always ESTABLISHED. */
  if (test_bit(IPS_SEEN_REPLY_BIT, &ct->status)) {
   pr_debug("normal packet for %p\n", ct);
   ctinfo = IP_CT_ESTABLISHED;
```

其实OVS TCP 整个CT流程仅需要nf_conn->status 和 skb的状态就能完成所有的CT用户接口需求了。但是为了精确控制各个状态下的超时值,TCP CT 表项不得不维护跟踪连接的细节TCP 状态(如TCP_CONNTRACK_SYN_RECV等)。

nf_conn->status 居然一直没变化?!!不敢相信?我们用下面的 Systemtap 来跑一波看看。(我这里用的是Fedora 34 内核:5.11.12-300.fc34.x86_64,不同内核需要修改对应probe 行号,这个不用多说)。

```sh
%{
#include <linux/skbuff.h>
#include <net/netfilter/nf_conntrack.h>
%}
function print_ct_status(pskb:long) %{
        struct sk_buff *skb;
        enum ip_conntrack_info ctinfo;

        skb = (struct sk_buff*)(long)STAP_ARG_pskb;
        printk("skb=%p\n", skb);
        printk("skb->_nfct=%p\n", nf_ct_get(skb, &ctinfo));
        printk("nf_conn->status=%lx\n", nf_ct_get(skb, &ctinfo)->status);
%}
probe module("openvswitch").statement("ovs_ct_execute@net/openvswitch/conntrack.c:1323"){
        printf("ovs_ct_execute called\n");
        print_ct_status($skb);
}
```

stap 起脚本插入模块,然后参考文档[1]来断开连接,可以看到:直到tcp协议进入timewait态,nf_conn->status状态依然是0xe,也就是IPS_CONFIRMED|IPS_SEEN_REPLY|IPS_ASSURED。这下信了吧:)

顺便说下:

1. Fedora 下面看内核模块printk日志,用journalctl来看:jourctl -k -f。

2. 要下载对应版本的内核,找到对应的源码行号,则是`dnf download --source kernel-5.11.12`,不再用yum 下载了。
## UDP 状态变化
UDP的ct 实现很简单,都在`nf_conntrack_udp_packet`完成,而这个函数事实上只做了一件事,那就是看到UDP的回复包时,将ct表项的状态置位为IPS_ASSURED,主要目的就是让这个CT表项存在时间长一点。但请注意,这里检查的是
```c
test_bit(IPS_SEEN_REPLY_BIT, &ct->status)
```
而这个bit位如前所述,是在收到resp数据包后,数据包即将离开内核ct 模块时设置的:
```c
 if (ctinfo == IP_CT_ESTABLISHED_REPLY &&
     !test_and_set_bit(IPS_SEEN_REPLY_BIT, &ct->status))
  nf_conntrack_event_cache(IPCT_REPLY, ct);
```
所以,要想将一个udp ct表项置位为IPS_ASSURED态,其实是要udp **四次握手**!在第二次看到resp 方向包时,CT表项 才会真正进入长久存在IPS_ASSURED态!

而与CT表项不同,UDP的数据包状态在第一个resp包时,就已经是+est状态。我们来看UDP包的状态变化,`nf_conntrack_udp_packet`一点都没有涉及到,全靠`resolve_normal_ct`的一点点状态迁移代码来完成:
```c
 if (NF_CT_DIRECTION(h) == IP_CT_DIR_REPLY) {
  ctinfo = IP_CT_ESTABLISHED_REPLY;
 } else {
  /* Once we've had two way comms, always ESTABLISHED. */
  if (test_bit(IPS_SEEN_REPLY_BIT, &ct->status)) {
   pr_debug("normal packet for %p\n", ct);
   ctinfo = IP_CT_ESTABLISHED;
  } else if (test_bit(IPS_EXPECTED_BIT, &ct->status)) {
   pr_debug("related packet for %p\n", ct);
   ctinfo = IP_CT_RELATED;
  } else {
   pr_debug("new packet for %p\n", ct);
   ctinfo = IP_CT_NEW;
  }
 }
```
所以,**与ovs dpdk实现不同的是,ovs dpkd 可以在看到单向第三个包,就将数据包置为+est态去匹配规则;内核ct 则是必须要看到回复包,才会将数据包置位+est**。

## ICMP 状态变化
ICMP 也没有什么可说的,同样复用`resolve_normal_ct`的状态迁移代码来设置skb的状态,回包才有+est 标记。但icmp有个特殊的地方,在正常连接连不上时,比如udp 发包到一个未监听的端口,会触发icmp 的报错报文,比如icmp unreachable报文。此时icmp resp 包会带有原有报文作为payload。
这种特殊情况,内核CT 模块会有特殊处理,主要在`nf_conntrack_icmpv4_error`中处理,逻辑也很简单。举个例子:
比如udp 1.1.1.1->1.1.1.2到一个未监听的端口,此时1.1.1.2的内核会默认回复一个icmp unreachable错误报文,payload是udp 1.1.1.1->1.1.1.2报文。此时内核CT 会用1.1.1.2->1.1.1.1 来找这个resp方向是否已有udp CT表项。如果有,icmp报文就会置为 **+rel+rpl(这是一个回包)+trk**。

# 遗留问题
1. NAT 实现未详细跟踪实现
2. ALG 实现未详细跟踪实现
3. IP分片与CT表流入流出逻辑分析

# 参考文档

1. https://docs.openvswitch.org/en/latest/tutorials/ovs-conntrack/
2. http://arthurchiao.art/blog/conntrack-design-and-implementation-zh/

文章来自个人专栏
OVS
4 文章 | 1 订阅
0条评论
0 / 1000
请输入你的评论
0
0