主题
实验 16:连接跟踪与有状态防火墙
到目前为止写的所有流表都是无状态的:一条规则看一个包,判断放行还是丢弃,不关心这个包属于哪条连接。
这一章加上"状态"。
无状态做不到的事
考虑一条很常见的安全策略:
内网机器可以主动访问外网,外网不能主动访问内网。
用无状态规则试试:
text
允许 in_port=内网 的包出去 ✓ 容易
丢弃 in_port=外网 的包进来 ✗ 出问题了第二条会把回包也丢掉。内网机器发出去的 TCP SYN 到了外网服务器,服务器回的 SYN-ACK 是"从外网进来的包",直接被拦了 —— 连接永远建立不起来。
想用无状态规则解决,只能写成"放行所有目的端口是高位端口的入站流量"之类的近似规则。这既不安全(攻击者也能用高位端口),也不精确。
根本问题:判断一个包该不该放行,需要知道它属于哪条连接、那条连接是谁发起的。 这就是连接跟踪(conntrack)。
起环境
bash
cd labs/learn-ovs/13-conntrack
./start.shtext
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(...)) 做 NATct() 本身不做放行或丢弃的决定,它只是给包打上 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 br0table 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 br0text
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-conntracktext
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: droph2 的新连接落到了 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.shtext
[通过] 存在 ct() 动作
[通过] 存在按 ct_state 匹配的规则
[通过] h1 可以主动连 h2 的 8080(内网出站放行)
[通过] h2 不能主动连 h1 的 9090(外网入站拦截)自测清单
- 为什么无状态规则做不到"内网可以出去,外网不能进来"?
ct_state=+est为什么不合法?ct(table=1)和ct(commit,table=1)有什么区别?漏了commit会怎样?- 为什么必须单独放行 ARP?
recirc_id是什么?为什么ct()会产生 recirculation?ct(zone=N)解决什么问题?- 做 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 newactions 里必须有 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_statecommit是关键:只有 commit 过的连接,回包才会被识别成est- ARP 必须单独放行,它不走连接跟踪
ct()通过 recirculation 实现:包重新进入流水线,带着新的ct_state- recirculation 有性能代价,只对需要的流量用 ct()
ct(zone=N)提供独立的跟踪域,解决多租户 IP 重叠- NAT 用
ct(commit,nat(...)),必须带 commit
下一章:实验 17:QoS 与限速。