引言
所有连接跟踪模块的作用,都是在首包到来时,识别并在CT表中生成表项;而当该连接的特定数据包到来时,在CT表内匹配表项,如果匹配命中,则执行响应预设动作。OVS 的连接跟踪实现也不例外。
OVS CT 定义
CT 匹配域
这里明确几个在OVS内的术语:
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 支持下面的参数:
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:
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模块。
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中:
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设置的:
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;
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内容解析的:
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。
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,
.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;
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时使用:
static int ovs_packet_cmd_execute(struct sk_buff *skb, struct genl_info *info)
{
......
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对应的处理逻辑。
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表项存在时间更长。
int ovs_ct_execute(struct net *net, struct sk_buff *skb,
struct sw_flow_key *key,
const struct ovs_conntrack_info *info)
{
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)
err = ovs_ct_commit(net, key, info, skb);
else
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点之上,如下:
其中在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系统的):
unsigned int
nf_conntrack_in(struct sk_buff *skb, const struct nf_hook_state *state)
{
......
dataoff = get_l4proto(skb, skb_network_offset(skb), state->pf, &protonum);
......
repeat:
ret = resolve_normal_ct(tmpl, skb, dataoff,
protonum, state);
......
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 表项并初始化:
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)) {
}
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) {
h = init_conntrack(state->net, tmpl, &tuple,
skb, dataoff, hash);
if (!h)
return 0;
}
......
这里新建表项时,实际包含了两个工作,新建nf_conn结构体用以表示一条连接(不考虑方向),以及新建两个方向的五元组ct节点,即
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 的状态:
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);
if (NF_CT_DIRECTION(h) == IP_CT_DIR_REPLY) {
ctinfo = IP_CT_ESTABLISHED_REPLY;
} else {
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_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标记:
enum ip_conntrack_info {
IP_CT_ESTABLISHED,
IP_CT_RELATED,
IP_CT_NEW,
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,
IP_CT_NUMBER,
IP_CT_UNTRACKED = 7,
};
这些状态存于skb->_nfct字段的低三位,高29位则作为指针,指向skb对应的ct 表项地址。可通过nf_ct_get() 函数方便的获取和设置。
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);
}
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定义了自己的私有成员域:
struct nf_conn {
......
union nf_conntrack_proto proto;
};
union nf_conntrack_proto {
.....
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;
......
};
简单的udp协议是直接使用nf_conn->status字段作为自身连接状态。而复杂如tcp,则是在nf_conn->proto->(tcp/udp)->state 状态单独维护自己协议的状态变迁,甚至还记录了连接建立过程中的协商字段。
这里假设当前是udp协议数据包(该协议的状态跳转比较简单,TCP协议的状态变化我们在后文单独讲解),则进入udp处理流程。如果当前udp包是一个回复包,在resolve_normal_ct 中直接根据数据包的发送方向判定为回复包,并设置skb->_nfct状态为IP_CT_ESTABLISHED_REPLY:
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);
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:
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:
if (NF_CT_DIRECTION(h) == IP_CT_DIR_REPLY) {
ctinfo = IP_CT_ESTABLISHED_REPLY;
} else {
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。
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 (test_bit(IPS_SEEN_REPLY_BIT, &ct->status)) {
unsigned long extra = timeouts[UDP_CT_UNREPLIED];
if (time_after(jiffies, ct->proto.udp.stream_ts))
extra = timeouts[UDP_CT_REPLIED];
nf_ct_refresh_acct(ct, ctinfo, skb, extra);
if (unlikely((ct->status & IPS_NAT_CLASH)))
return NF_ACCEPT;
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服务的。
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:
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状态了。就会匹配上比如下面这条规则:
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状态:
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的状态迁移表则定义如下:
static const u8 tcp_conntracks[2][6][TCP_CONNTRACK_MAX] = {
{
{ sSS, sSS, sIG, sIG, sIG, sIG, sIG, sSS, sSS, sS2 },
{ sIV, sIV, sSR, sIV, sIV, sIV, sIV, sIV, sIV, sSR },
.......
};
状态名字比较抽象,对应关系如下,其实就是普通的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包的情况下,是这样获取新的状态的:
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表项在各个状态下的超时值:
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,
[TCP_CONNTRACK_RETRANS] = 5 MINS,
[TCP_CONNTRACK_UNACK] = 5 MINS,
};
关于 NAT 实现
Linux 内核的 CT 是支持NAT 转换的,OVS也利用了这点实现动态NAT(不同于modify action)。
有几点需要注意:
- NAT操作必须在helper(实现CT ALG功能的函数)之前完成,以便于helper函数知道NAT动作的存在
- NAT更改IP地址,导致可能需要执行序列号调整等操作,这需要注意。
TCP 三次握手之旅
前面是综述各个关键函数,以及实现逻辑,但CT连接状态迁移、skb的状态和OVS规则匹配涉及包序,在前文讲骨骼框架中无法细述。故接下来以最复杂的TCP三次握手为例,说明OVS 内核 CT 的实现逻辑,重点关注状态迁移,对于实现函数和实现方法,则简要带过,不再赘述。
此外,从上面框架描述也能看出,OVS中CT流程主要分为以下四步:
- 匹配最初规则;
- 查找/创建CT表项,设置包状态;
- 处理协议特定状态;
- 新状态回到OVS
SYN 包到来时
匹配规则:首先syn包到来时,由于skb->_nfct状态仍为0,也就是既没有ct表项与之关联,也没有ct状态设置。所以此时数据包匹配的是-trk状态。
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表项的超时时间。
ct->proto.tcp.state = new_state
新状态回到OVS:完成CT模块处理后,skb 带着新的+trk+new状态,流转回OVS,继续匹配新的规则,执行新的action。比如经过CT处理后的SYN包就匹配下面这条规则:
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到来时,数据包没有设置过状态,匹配如下规则:
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+new状态,流转回OVS,继续匹配新的规则,执行新的action。比如经过CT处理后的SYN-ACK包就匹配下面这条规则,最后经由veth_10口送出。
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+est, tcp, in_port=veth_r0, actions=veth_10"
ACK 包到来时
匹配规则:数据包没有设置过状态,匹配如下规则:
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,同时更新超时时间。
新状态回到OVS:完成CT模块处理后,skb 带着新的+trk+est状态,流转回OVS,继续匹配新的规则,执行新的action。比如经过CT处理后的SYN-ACK包就匹配下面这条规则,最后经由veth_10口送出。
ovs-ofctl add-flow br0 "table=0, priority=50, ct_state=+trk+est, tcp, in_port=veth_10, actions=veth_r0"
至此,协议特定的三次握手完成。其实我们能看到,在SYNACK到来时,OVS就已经标记数据包为+est了,这点需要注意。
遗留问题
- NAT 实现未详细跟踪实现
- ALG 实现未详细跟踪实现
- IP分片与CT表流入流出逻辑分析
参考文档
- https://docs.openvswitch.org/en/latest/tutorials/ovs-conntrack/
- http://arthurchiao.art/blog/conntrack-design-and-implementation-zh/
|