OpenvSwitch的链接追踪教程

原文 OVS Conntrack Tutorial

声明:

  1. 本译文并未取得原作者授权,如有侵权行为,请发邮件到13581561959@163.com。我将立即删除。
  2. 翻译中,意译的地方较多。如有错误,或不准确的地方,欢迎邮件讨论,谢谢。

OpenvSwitch的链接追踪教程

OVS能够与链接跟踪系统共同工作,此时Openflow流表被用于捕获TCP、UDP、ICMP等链接各种状态下的报文。(链接跟踪模块支持跟踪有状态和无状态的协议)。
本文阐述,如何使用OVS的链路跟踪模块捕获从TCP链接建立到断开的报文。本教程是在Linux平台上完成的,数据报处理模块使用的是Linux内核模块。

术语

链接跟踪(conntrack)

链接跟踪模块主要被用于有状态的数据报检测。

流水线(pipeline)

数据报流水线由一组流表组成,在数据报的处理过程中,如果数据报与某个流表项匹配,就执行相应的动作。

网络命名空间(network namespace):

在一个单实例的Linux内核中,通过网络命名空间可以创建一个虚拟的路由域。每个网络命名空间拥有独立的网络空间(ARP,路由表)和接口,

流表项(flow):

在本文中,流表项是指Openflow协议中定义的可编程流表项。流表项定义可以由Openflows控制器或OVS的命令行工具ovs-ofctl来完成,本文中,使用ovs-ofctl定义它们。一个流表项包含匹配字段和动作。

相关字段

匹配字段

在OVS中,下列字段与链接跟踪相关:

  1. ct_state: 用于匹配数据报的链接状态。取值范围如下:
    • new
    • est
    • rel
    • rpl
    • inv
    • trk
    • snat
    • dnat
      在实际的使用中,这些标志的前缀符“+”表示设置该标志,相反,前缀符“-”表示取消该标志。多个标志可以同时被使用,例如:ct_state=+trk+new。这些标志的使用将在后面的章节中介绍。关于它们的详细的说明,请参阅OVS fileds documentation
  2. ct_zone: 一个区域是指一个独立的链接上下文,通过一个ct动作设置其值。被最近的一个ct动作(即一个链接跟踪的Openflow流表项)设置的16位的ct_zone可以被其他的流表项作为匹配域。
  3. ct_mark: 32位的元数据被提交给数据报所对应的链接。其值为ct动作exec的参数。
  4. ct_label: 128位的标签被提交给数据报所对应的链接。其值位ct动作exec的参数。
  5. ct_nw_src / ct_ipv6_src: 指定被捕获的数据报的IPv4/IPv6源地址.
  6. ct_nw_dst / ct_ipv6_dst: 指定被捕获的数据报的IPv4/IPv6目的地址。
  7. ct_nw_proto: 指定被捕获的数据报的IP协议类型。
  8. ct_tp_src: 指定被捕获的数据报源端口号。
  9. ct_tp_dst: 指定被捕获的数据报的目的端口号。

动作(Actions)

OVS支持的与链接跟踪相关的“ct”动作。

ct([argument][,argument…])

“ct”动作将数据报文发送给链接跟踪模块。

支持的参数如下:

  1. commit: 请求链接跟踪模块开始跟踪一个链接。

  2. force: 该标志被用于快速地终止一个链接,然后在当前的链路上开始一个新的链接。

  3. table=number: 将数据报流水线复制为两份。原始的数据报作为未被跟踪的数据报在当前的动作流水线中继续处理。被复制的数据报被发送到链接跟踪模块,这些数据报会重新回到Openflow流水线中触发后续的处理,但是这些数据报中的ct_state和其他的ct字段被置位。

  4. zone=value OR zone=src[start..end]: 16位的上下文id,其被用于将链接隔离到不同的域,将叠加的网络映射到不同的区域中。zone的默认值为0。

  5. exec([action][,action…]): 在链接跟踪上下文中执行特定的动作。只有更改ct_mark和ct_label地段的动作是被允许的。

  6. alg=<ftp/tftp>: 指明alg(应用层网关)来跟踪特定的链接类型。

  7. nat: 为被跟踪的链接指明地址和端口翻译。

示例的拓扑结构(Sample Topology)

本文在下面的拓扑结构上演示链路跟踪。

接下来,创建上图所示的网络。
创建网络命名空间“left”:

1
$ ip netns add left

创建网络命名空间“right”

1
$ ip netns add right

创建第一组veth虚拟网络接口:

1
$ ip link add veth_l0 type veth peer name veth_l1

将虚拟网络接口veth_l1添加到网络命名空间“left”中:

1
$ ip link set veth_l1 netns left

创建第二组veth虚拟网络接口:

1
$ ip link add veth_r0 type veth peer name veth_r1

将虚拟网络接口veth_r1添加到网络命名空间“right”中:

1
$ ip link set veth_r1 netns right

创建网桥br0:

1
$ ovs-vsctl add-br br0

将veth_l0和veth_r0添加到网桥br0中:

1
2
$ ovs-vsctl add-port br0 veth_l0
$ ovs-vsctl add-port br0 veth_r0

在网络命名空间“left”中产生的数据报的源IP/目的IP分别被设置为192.168.0.X / 10.0.0.X;在网络命名空间“right”中,则与之相反。这些数据报通过OVS路由到目的地址,看起来就像是在两个网络上 (192.168.0.X and 10.0.0.X) 的主机间的相互通信。这是常见的使用OVS模拟两个网络/子网间主机通信的方式。

Tool used to generate TCP segments

scapy是一个强大的交互式网络数据报处理程序(使用python编写)。本文在Ubuntu 16.04系统上使用scapy构造数据报完成实验。(关于scapy的安装请参阅官方文档)。

首先,在两个网络命名空间中分别创建scapy会话:

1
2
3
$ sudo ip netns exec left sudo `which scapy`

$ sudo ip netns exec right sudo `which scapy`

注意:如果遇到这个错误

1
2
ifreq = ioctl(s, SIOCGIFADDR,struct.pack("16s16x",LOOPBACK_NAME))
IOError: [Errno 99] Cannot assign requested address

运行下面的命令:
1
$ sudo ip netns exec <namespace> sudo ip link set lo up

匹配TCP数据报

TCP链接建立

在OVS中添加两条简单的流表项,数据报就能正常地从“left”到“right”和从“right”到“left”转发:

1
2
3
4
5
$ ovs-ofctl add-flow br0 \
"table=0, priority=10, in_port=veth_l0, actions=veth_r0"

$ ovs-ofctl add-flow br0 \
"table=0, priority=10, in_port=veth_r0, actions=veth_l0"

在下面的实验中,被添加的流表项是能识别TCP报文状态的流表项,而不是上面的这两条。

在试验中,TCP握手阶段的报文,即:syn,syn-ack和ack,将在“left”网络空间的主机(IP 192.168.0.2)和在“right”网络空间的主机(IP 10.0.0.2) 间发送。

首先,在OVS中添加跟踪数据报的流表项。

如何跟踪一个数据报

为了能够跟踪一个数据报,必须令其与一个包含“ct”动作的流表项匹配。“ct”动作会将数据报转给链接跟踪模块(the connection tracker)处理。为了能够识别出一个数据报是未被跟踪过(untracked)的,流表项中的ct_state字段必须设置为“-trk”,也就是说被匹配的数据报是未被跟踪过的数据报。一旦数据报被发送给链接跟踪模块(the connection tracker),我们就开始明确知道链接的状态了。(例如:这个数据报是一个新的链接,或者这个数据报属于一个已经存在的链接,或者是一个畸形数据报,等等)。

下面,看一下这样的流表项长什么样:

1
2
3
(flow #1)
$ ovs-ofctl add-flow br0 \
"table=0, priority=50, ct_state=-trk, tcp, in_port=veth_l0, actions=ct(table=0)"

从“left”网络空间被发送的TCP syn数据报文与flow #1匹配,因为,其从veth_l0端口发出并且它没有被跟踪过。(当数据报到达OVS时,所有的第一次被OVS接收的数据报文都是“未被跟踪”的(untracked))由于流表项的操作是“ct”,所以数据报被发送到链接跟踪模块(the connection tracker)。实际上,在执行“ct”动作的过程中,“table=0”被复制成两个。原始的数据报作为未被跟踪的数据报在当前的动作流水线中继续处理。(因为当前没有其他的操作,原始的数据报被丢弃。)被复制的数据报被发送到链接跟踪模块,这些数据报会重新回到Openflow流水线中触发后续的处理,但是这些数据报中的ct_state和其他的ct字段被置位。也就是说,带有ct_state和其它的ct字段的数据报从table=0开始被重新处理。

接下来,添加一个匹配从链接跟踪模块发送的数据报的流表项:

1
2
3
(flow #2)
$ ovs-ofctl add-flow br0 \
"table=0, priority=50, ct_state=+trk,+new, tcp, in_port=veth_l0, actions=ct(commit),veth_r0"

既然数据报是从链接跟踪模块转发过来的,其ct_state字段被设置为“trk”。同样,如果这是TCP链接的第一个数据报,字段ct_state被设置为“new”。(现在的状态是,在主机192.168.0.2和10.0.0.2间不存在TCP链接)。ct动作的参数“commit”将把该链接提交给链接跟踪模块。这个操作把链接的信息存储到流水线上。
下面在“left”网络空间的scapy会话中,使用scapy发送一个TCP syn数据报文

1
$ >>> sendp(Ether()/IP(src="192.168.0.2", dst="10.0.0.2")/TCP(sport=1024, dport=2048, flags=0x02, seq=100), iface="veth_l1")

这个数据报报与flow #1和flow #2匹配。
链接跟踪模块现在包含匹配这个链接的表项

1
2
$ ovs-appctl dpctl/dump-conntrack | grep "192.168.0.2"
tcp,orig=(src=192.168.0.2,dst=10.0.0.2,sport=1024,dport=2048),reply=(src=10.0.0.2,dst=192.168.0.2,sport=2048,dport=1024),protoinfo=(state=SYN_SENT)

注意:
在这个阶段中,如果TCP syn数据报文被重传,它将再次匹配flow #1(一个新的数据报是“untracked”), 并且它也将匹配flow #2。之所以与flow #2匹配是因为,此时链路的状态还没有转移到“已连接”(established state),因此,其状态为“new”。

接下来,在OVS中添加下面的流表项,处理从相反的方向/服务器端发送的syn-ack TCP数据报。

1
2
3
4
5
6
(flow #3)
$ ovs-ofctl add-flow br0 \
"table=0, priority=50, ct_state=-trk, tcp, in_port=veth_r0, actions=ct(table=0)"
(flow #4)
$ ovs-ofctl add-flow br0 \
"table=0, priority=50, ct_state=+trk,+est, tcp, in_port=veth_r0, actions=veth_l0"

从服务器(10.0.0.2)发送的回应消息被flow #3捕获,然后被发送到链接跟踪模块。(或者,把flow #1和flow #3中的in_port字段去掉,它们就被合并为一个流表项。)

从链接跟踪模块返回到OVS的syn-ack数据报的ct_state字段被设置为“est”。

注意:
当链路跟踪模块发现双向的通信后,链接的ct_state字段就会被设置为“est”。但是,此时由于还没有发现从客户端发出的ack数据报,它链路的跟踪项上设置了一个定时器,该定时器超时时,将清理该链接。

使用scapy发送syn-ack数据报的命令(flag=0x12标识该数据报的ack和syn字段置位)

1
$ >>> sendp(Ether()/IP(src="10.0.0.2", dst="192.168.0.2")/TCP(sport=2048, dport=1024, flags=0x12, seq=200, ack=101), iface="veth_r1")

上面的数据报将被flow #3和flow #4捕获

链路跟踪模块中相应的记录如下:

1
2
3
4
5
6
7
8
9
10
11
$ ovs-appctl dpctl/dump-conntrack | grep "192.168.0.2"

tcp,orig=(src=192.168.0.2,dst=10.0.0.2,sport=1024,dport=2048),reply=(src=10.0.0.2,dst=192.168.0.2,sport=2048,dport=1024),protoinfo=(state=ESTABLISHED)
```
在捕获syn和syn-ack数据报后,链路的状态为“ESTABLISHED”。此后,如果在一个较短的时间内没有收到客户端的ack数据报,该链接将会被清除。

接下来,添加如下的流表项,捕获从客户端发出的ack数据报:
``` bash
(flow #5)
$ ovs-ofctl add-flow br0 \
"table=0, priority=50, ct_state=+trk,+est, tcp, in_port=veth_l0, actions=veth_r0"

在客户端(即网络空间“left”的scapy会话中)发送TCP的ack数据报(flags=0x10标识ack字段置位):

1
$ >>> sendp(Ether()/IP(src="192.168.0.2", dst="10.0.0.2")/TCP(sport=1024, dport=2048, flags=0x10, seq=101, ack=201), iface="veth_l1")

上面的数据报,将被flow #1和flow #5捕获。

链接跟踪模块中的表项:

1
2
3
4
5
$ ovs-appctl dpctl/dump-conntrack | grep "192.168.0.2"

tcp,orig=(src=192.168.0.2,dst=10.0.0.2,sport=1024,dport=2048), \
reply=(src=10.0.0.2,dst=192.168.0.2,sport=2048,dport=1024), \
protoinfo=(state=ESTABLISHED)

此时链路的状态还维持在“ESTABLISHED”。但是,现在已经收到了客户端的ack数据报,该链路将一直保持在这个状态,即使这个链路上没有任何的数据传输。

TCP数据流

当一个载有数据的TCP报文,即网络报文中包含一个字节的TCP负荷,被从192.168.0.2发送到10.0.0.2时,其将被flow #1和flow #5捕获。
在网络空间“left”中的scapy会话中,发送载有一个字节数据的TCP报文(flags=0x10标识ack字段置位):

1
$ >>> sendp(Ether()/IP(src="192.168.0.2", dst="10.0.0.2")/TCP(sport=1024, dport=2048, flags=0x10, seq=101, ack=201)/"X", iface="veth_l1")

在网络空间“right”中的scapy会话中,发送上面的TCP报文的ack报文(flags=0x10标识ack字段置位)

1
$ >>> sendp(Ether()/IP(src="10.0.0.2", dst="192.168.0.2")/TCP(sport=2048, dport=1024, flags=0X10, seq=201, ack=102), iface="veth_r1")

数据接收的确认消息会被flow #3和flow #4捕获。

TCP链接断开

断开TCP链接的方法有很多。本文采用首先从客户端发送数据报“fin”,然后从服务端回应“fin-ack”报文,最后客户端回应“ack”报文的方式。
所有的数据报文从客户端到服务端都将被flow #1和flow #5捕获。同样,所有从服务端到客户端的报文都被flow #3和flow #4捕获。值得注意的是,在链接断开过程中,所有的数据报文都处于“+est”状态。数据报文,

注意:
事实上,当被跟踪的链接的状态为“TIME_WAIT”时(此时,所有的TCP的fin报文和它们的ack报文都已经交换完成),一个被重传的数据报文(从192.168.0.2到10.0.0.2),仍然会被flow #1和flow #5捕获。


在网络空间“left”中的scapy会话中,发送TCP的fin报文(flags=0x11标识报文中的ack和fin字段被置位)
1
$ >>> sendp(Ether()/IP(src="192.168.0.2", dst="10.0.0.2")/TCP(sport=1024, dport=2048, flags=0x11, seq=102, ack=201), iface="veth_l1")

上面的报文被flow #1和flow #5捕获。

链接跟踪模块中对应的表项:

1
2
3
$ sudo ovs-appctl dpctl/dump-conntrack | grep "192.168.0.2"

tcp,orig=(src=192.168.0.2,dst=10.0.0.2,sport=1024,dport=2048),reply=(src=10.0.0.2,dst=192.168.0.2,sport=2048,dport=1024),protoinfo=(state=FIN_WAIT_1)

在网络空间“right”中的scapy会话中,发送TCP的fin-ack报文(flags=0x11标识报文中的ack和fin字段被置位)

1
$ >>> sendp(Ether()/IP(src="10.0.0.2", dst="192.168.0.2")/TCP(sport=2048, dport=1024, flags=0X11, seq=201, ack=103), iface="veth_r1")

上面的报文将被flow #3和flow #4捕获。

链接跟踪模块中对应的表项:

1
2
3
$ sudo ovs-appctl dpctl/dump-conntrack | grep "192.168.0.2"

tcp,orig=(src=192.168.0.2,dst=10.0.0.2,sport=1024,dport=2048),reply=(src=10.0.0.2,dst=192.168.0.2,sport=2048,dport=1024),protoinfo=(state=LAST_ACK)

在网络空间“left”中的scapy会话中发送TCP的ack报文(flags=0x10标识报文的ack字段被置位)

1
$ >>> sendp(Ether()/IP(src="192.168.0.2", dst="10.0.0.2")/TCP(sport=1024, dport=2048, flags=0x10, seq=103, ack=202), iface="veth_l1")

上面的报文被flow #1和flow #5捕获。

链接跟踪模块中对应的表项:

1
2
3
$ sudo ovs-appctl dpctl/dump-conntrack | grep "192.168.0.2"

tcp,orig=(src=192.168.0.2,dst=10.0.0.2,sport=1024,dport=2048),reply=(src=10.0.0.2,dst=192.168.0.2,sport=2048,dport=1024),protoinfo=(state=TIME_WAIT)

总结

下表总结了TCP的报文与flow表项中的字段的对应关系。

注意:
相对的序列号和确认号是按照tshark的方式来标识的。

流表项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 (flow #1)
$ ovs-ofctl add-flow br0 \
"table=0, priority=50, ct_state=-trk, tcp, in_port=veth_l0, actions=ct(table=0)"

(flow #2)
$ ovs-ofctl add-flow br0 \
"table=0, priority=50, ct_state=+trk,+new, tcp, in_port=veth_l0, actions=ct(commit),veth_r0"

(flow #3)
$ ovs-ofctl add-flow br0 \
"table=0, priority=50, ct_state=-trk, tcp, in_port=veth_r0, actions=ct(table=0)"

(flow #4)
$ ovs-ofctl add-flow br0 \
"table=0, priority=50, ct_state=+trk,+est, tcp, in_port=veth_r0, actions=veth_l0"

(flow #5)
$ ovs-ofctl add-flow br0 \
"table=0, priority=50, ct_state=+trk,+est, tcp, in_port=veth_l0, actions=veth_r0"