Skip to content

实验 16:连接跟踪与有状态防火墙 ​

到目前为止写的所有流表都是无状态的:一条规则看一个包,判断放行还是丢弃,不关心这个包属于哪条连接。

这一章加上"状态"。

无状态做不到的事 ​

考虑一条很常见的安全策略:

内网机器可以主动访问外网,外网不能主动访问内网。

用无状态规则试试:

text
允许 in_port=内网 的包出去          ✓ 容易
丢弃 in_port=外网 的包进来          ✗ 出问题了

第二条会把回包也丢掉。内网机器发出去的 TCP SYN 到了外网服务器,服务器回的 SYN-ACK 是"从外网进来的包",直接被拦了 —— 连接永远建立不起来。

想用无状态规则解决,只能写成"放行所有目的端口是高位端口的入站流量"之类的近似规则。这既不安全(攻击者也能用高位端口),也不精确。

根本问题:判断一个包该不该放行,需要知道它属于哪条连接、那条连接是谁发起的。 这就是连接跟踪(conntrack)。

起环境 ​

bash
cd labs/learn-ovs/13-conntrack
./start.sh
text
   h1 ── ovs1 ── h2
   内网          外网
   .1            .2
   监听 9090     监听 8080

两台主机各起了一个 TCP 监听,方便双向测试。现在是普通交换机,双向都通:

bash
docker exec clab-conntrack-h1 sh -c 'echo x | nc -w 3 192.168.10.2 8080 && echo 通'
docker exec clab-conntrack-h2 sh -c 'echo x | nc -w 3 192.168.10.1 9090 && echo 通'
text
h1→h2 通
h2→h1 通

目标是让第二条变成不通,同时保证 h1 发起的连接回包能回来。

ct_state:包的连接状态 ​

OVS 的连接跟踪给每个包打上一组状态标记:

标记含义
trk已经过连接跟踪。没有这个标记时其它标记都无意义
new这条连接的第一个包
est属于一条已建立的连接(established)
rel相关连接(比如 FTP 数据连接、ICMP 差错报文)
inv非法状态(比如乱序的 TCP 标志组合)
rpl这个包的方向和连接发起方向相反(reply)
dnat / snat这个包被 NAT 改写过

匹配时用 + 表示"必须有",- 表示"必须没有":

text
ct_state=+est+trk        已建立的连接
ct_state=+new+trk        新连接的第一个包
ct_state=+inv+trk        非法状态
ct_state=-trk            还没查过连接跟踪

trk 必须显式写出来

ct_state=+est 这样写是不合法的,OVS 会报:

text
invalid connection state: If "trk" is unset, no other flags are set

因为没过连接跟踪的包,所有状态位都是 0。永远写成 +est+trk 这种形式。

ct() 动作 ​

text
ct(table=1)         送连接跟踪,查完从 table 1 继续
ct(commit)          把这条连接记入连接跟踪表
ct(commit,table=1)  两件事一起做
ct(zone=100)        用独立的跟踪域(多租户隔离用)
ct(nat(...))        做 NAT

ct() 本身不做放行或丢弃的决定,它只是给包打上 ct_state 标记。决策要靠后面的规则去匹配这些标记。

commit 是什么意思 ​

不带 commit 的 ct() 是只查不记:查一下这个包属于哪条已知连接,打上标记。

带 commit 才会把这条新连接写进连接跟踪表。

这个区分是有状态防火墙的关键:

text
h1 发出 SYN  →  ct_state=+new  →  规则允许,且 ct(commit) 记下这条连接
h2 回 SYN-ACK →  ct_state=+est  →  因为上一步 commit 过,这里才认得出来

只有 commit 过的连接,回包才会被识别成 est。 忘了写 commit,回包会一直是 new,然后被入站规则挡掉。

写规则 ​

bash
N="docker exec clab-conntrack-ovs1"
OF="ovs-ofctl -O OpenFlow13"

$N $OF del-flows br0

table 0:分流 ​

bash
# ARP 不走连接跟踪,直接放行 —— 否则二层都建立不起来
$N $OF add-flow br0 "table=0,priority=200,arp,actions=NORMAL"

# IP 包送连接跟踪,查完到 table 1
$N $OF add-flow br0 "table=0,priority=100,ip,actions=ct(table=1)"

$N $OF add-flow br0 "table=0,priority=0,actions=drop"

别忘了放行 ARP

ARP 不是 IP 协议,连接跟踪不处理它。漏了这条规则,主机之间连 MAC 地址都学不到,所有东西都不通 —— 而且症状看起来像"防火墙规则写错了",很容易查错方向。

table 1:按状态决策 ​

bash
# 已建立的连接:双向放行。这一条让回包能回来
$N $OF add-flow br0 "table=1,priority=200,ct_state=+est+trk,ip,actions=NORMAL"

# 新连接:只允许从 1 号口(内网)发起,并 commit
$N $OF add-flow br0 \
  "table=1,priority=150,ct_state=+new+trk,ip,in_port=1,actions=ct(commit),NORMAL"

# 非法状态丢弃
$N $OF add-flow br0 "table=1,priority=100,ct_state=+inv+trk,ip,actions=drop"

# 其余一律丢弃(包括外网主动发起的新连接)
$N $OF add-flow br0 "table=1,priority=0,actions=drop"

五条规则,一个完整的有状态防火墙。

验证 ​

bash
docker exec clab-conntrack-h1 sh -c 'echo x | nc -w 3 192.168.10.2 8080 && echo 通 || echo 不通'
docker exec clab-conntrack-h2 sh -c 'echo x | nc -w 3 192.168.10.1 9090 && echo 通 || echo 不通'
text
h1 → h2:8080   通       ← 内网出站放行,回包也回得来
h2 → h1:9090   不通     ← 外网入站被拦

看命中计数 ​

bash
$N $OF dump-flows br0
text
 table=0, n_packets=18, priority=100,ip actions=ct(table=1)
 table=0, n_packets=6,  priority=0 actions=drop
 table=1, n_packets=13, priority=200,ct_state=+est+trk,ip actions=NORMAL
 table=1, n_packets=2,  priority=150,ct_state=+new+trk,ip,in_port=1 actions=ct(commit),NORMAL
 table=1, n_packets=0,  priority=100,ct_state=+inv+trk,ip actions=drop
 table=1, n_packets=3,  priority=0 actions=drop

数字讲了完整的故事:

  • 2 个 +new 包被 commit(h1 发起的两次连接)
  • 13 个 +est 包放行(握手和数据的回包)
  • 3 个 包落到兜底规则被丢(h2 的主动连接尝试)

看连接跟踪表 ​

bash
$N ovs-appctl dpctl/dump-conntrack
text
tcp,orig=(src=192.168.10.1,dst=192.168.10.2,sport=42628,dport=8080),
    reply=(src=192.168.10.2,dst=192.168.10.1,sport=8080,dport=42628),
    protoinfo=(state=TIME_WAIT)

每条连接记录了双向的五元组和协议状态。reply 那一行就是回包的样子 —— OVS 靠它把回包识别成 est。

h2 主动发起的连接不在这张表里,因为从来没被 commit 过。

recirculation:ct 是怎么工作的 ​

ct() 的实现方式很特别,用 trace 看得最清楚:

bash
$N ovs-appctl ofproto/trace br0 \
  "in_port=1,ip,nw_src=192.168.10.1,nw_dst=192.168.10.2,nw_proto=6,tp_src=12345,tp_dst=8080" \
  --ct-next "trk,new"

第一段:

text
bridge("br0")
-------------
 0. ip, priority 100
    ct(table=1)
    drop
     -> A clone of the packet is forked to recirculate.
        The forked pipeline will be resumed at table 1.

Datapath actions: ct,recirc(0x7)

第二段:

text
===============================================================================
recirc(0x7) - resume conntrack with ct_state=new|trk
===============================================================================

bridge("br0")
-------------
    thaw
        Resuming from table 1
 1. ct_state=+new+trk,ip,in_port=1, priority 150
    ct(commit)
    NORMAL

Datapath actions: ct(commit),1,3

流水线被切成了两段。 ct() 把包送进连接跟踪模块,然后重新进入流水线(recirculate),带着新的 ct_state 从 table 1 继续。

recirc_id 就是这个"第几轮"的标识。实验 4 里 megaflow 开头的 recirc_id(0) 现在有了含义 —— 0 表示第一轮,还没重入过。

recirculation 有性能代价

每次重入都要重新查一遍 datapath 缓存,缓存条目也会按 recirc_id 拆开。

所以只对需要的流量用 ct()。 把 ARP、组播这类不需要跟踪的流量在 table 0 就分流掉,别一股脑全送进去。

看被拦截的方向:

text
recirc(0x6) - resume conntrack with ct_state=new|trk

bridge("br0")
-------------
    thaw
        Resuming from table 1
 1. priority 0
    drop

Datapath actions: drop

h2 的新连接落到了 priority=0 的兜底规则,Datapath actions: drop。

ct zone:多租户的连接跟踪 ​

不同租户可能用相同的 IP 网段。如果共用一张连接跟踪表,五元组会撞车。

zone 就是解决这个的:

bash
add-flow br0 "table=0,ip,in_port=1,actions=ct(zone=100,table=1)"
add-flow br0 "table=0,ip,in_port=2,actions=ct(zone=200,table=1)"

每个 zone 是一张独立的连接跟踪表。 租户A 的 10.0.0.1:80 和租户B 的 10.0.0.1:80 互不干扰。

zone 也可以从字段动态取:

text
ct(zone=NXM_NX_REG0[0..15],table=1)

OVN 的安全组就是这么实现的:每个逻辑端口一个 zone。

conntrack 也能做 NAT ​

bash
# SNAT:出去时把源地址改成 203.0.113.1
add-flow br0 "table=1,ct_state=+new+trk,ip,in_port=1,\
actions=ct(commit,nat(src=203.0.113.1)),NORMAL"

# DNAT:进来时把目的地址改成内网服务器
add-flow br0 "table=1,ct_state=+new+trk,ip,in_port=2,tcp,tp_dst=80,\
actions=ct(commit,nat(dst=192.168.10.10:8080)),NORMAL"

NAT 必须和 commit 一起用 —— 连接跟踪表要记住地址映射关系,回包才能正确还原。

自动检查 ​

bash
./verify.sh
text
  [通过] 存在 ct() 动作
  [通过] 存在按 ct_state 匹配的规则
  [通过] h1 可以主动连 h2 的 8080(内网出站放行)
  [通过] h2 不能主动连 h1 的 9090(外网入站拦截)

自测清单 ​

  1. 为什么无状态规则做不到"内网可以出去,外网不能进来"?
  2. ct_state=+est 为什么不合法?
  3. ct(table=1) 和 ct(commit,table=1) 有什么区别?漏了 commit 会怎样?
  4. 为什么必须单独放行 ARP?
  5. recirc_id 是什么?为什么 ct() 会产生 recirculation?
  6. ct(zone=N) 解决什么问题?
  7. 做 NAT 时为什么必须带 commit?

常见问题 ​

配完规则什么都不通 ​

九成是漏了放行 ARP:

bash
ovs-ofctl -O OpenFlow13 add-flow br0 "table=0,priority=200,arp,actions=NORMAL"

出站连接建立不起来 ​

检查有没有 commit:

bash
ovs-ofctl -O OpenFlow13 dump-flows br0 | grep new

actions 里必须有 ct(commit),否则回包识别不成 est。

连接跟踪表是空的 ​

bash
ovs-appctl dpctl/dump-conntrack

空的说明没有任何连接被 commit 过。检查 +new+trk 那条规则的 n_packets 是不是 0 —— 如果是 0,说明包压根没走到那条规则,用 trace 往前查。

ICMP(ping)不通但 TCP 通 ​

ICMP 也有连接跟踪状态(echo request 是 new,reply 是 est),规则里用 ip 匹配是覆盖了 ICMP 的。如果你写的是 tcp,那 ICMP 就落到兜底规则被丢了。

性能下降明显 ​

ct() 会触发 recirculation,缓存按 recirc_id 拆开,条数翻倍。优化方向:

  • 只对需要的流量用 ct() —— ARP、组播、已知安全的内部流量在 table 0 分流掉
  • 检查 ovs-dpctl show 里 flows 和 masks 的增长

小结 ​

  • 无状态规则无法表达"允许回包",因为它不知道包属于哪条连接
  • ct_state 用 +/- 匹配,trk 必须显式写出来
  • ct() 只打标记,不做放行/丢弃决定,决策靠后续规则匹配 ct_state
  • commit 是关键:只有 commit 过的连接,回包才会被识别成 est
  • ARP 必须单独放行,它不走连接跟踪
  • ct() 通过 recirculation 实现:包重新进入流水线,带着新的 ct_state
  • recirculation 有性能代价,只对需要的流量用 ct()
  • ct(zone=N) 提供独立的跟踪域,解决多租户 IP 重叠
  • NAT 用 ct(commit,nat(...)),必须带 commit

下一章:实验 17:QoS 与限速。