ch10_6
10.6 XFRM Lookup
上一节,我们看着一个加密包历经千辛万苦,穿过协议栈的层层关卡,最终被解密还原成明文。那是「路漫漫其修远兮」的接收路径。
现在,让我们转过身来,看看另一条路——发送。
当你的本地程序想要发送一个受保护的 IPsec 包时,它不会自己去查找密钥、填充 ESP 头部。它只需要把明文包交给内核,剩下的脏活累活,都由 xfrm_lookup() 来接盘。
这个函数是 IPsec 发送路径的心脏。你希望它跳得尽可能快——毕竟每个外发包都要经过它。
但在理解「它有多快」之前,我们得先搞清楚「它要查什么」。
10.6.1 发送路径的核心抉择
xfrm_lookup() 的名字很诚实:它确实是在「查找」。
查什么?
它是在找一个答案:「这个包到底该怎么发?」
这个答案不仅仅包含「下一跳是哪个路由器」(这是路由表的事),还包含「它需要被加密吗?用哪种算法?经过几个 SA?」
为了不让我们每次发包都把这些繁琐的步骤走一遍,XFRM 框架引入了一个核心优化机制:Bundle(捆绑)。
你可以把 Bundle 想象成一张「预打包的快递单」——它把路由信息、安全策略、甚至涉及的 SA 数量和指针都打包在了一起。
但「快递单」这个比喻在这里只有一半是对的:真正的快递单是一次性的,但 Bundle 是可以复用的。只要属于同一条流量流,后续所有的包都可以直接抄这张单子。这就是性能优化的关键:把查表变成了缓存命中。
为了存储这张「快递单」,内核定义了 xfrm_dst 结构体:
struct xfrm_dst {
union {
struct dst_entry dst;
struct rtable rt;
struct rt6_info rt6;
} u;
struct dst_entry *route; /* 底层路由 */
struct flow_cache_object flo; /* 流缓存对象,用于查找 */
struct xfrm_policy *pols[XFRM_POLICY_MAX]; /* 匹配的策略数组 */
int num_pols, num_xfrms; /* 策略数量和变换层数 */
#ifdef CONFIG_XFRM_SUB_POLICY
struct flowi *origin; /* 原始流信息 */
struct xfrm_selector *partner; /* 子策略选择器 */
#endif
u32 xfrm_genid; /* XFRM 生成计数(用于失效检测) */
u32 policy_genid; /* 策略生成计数 */
u32 route_mtu_cached; /* 缓存的 MTU */
u32 child_mtu_cached;
u32 route_cookie;
u32 path_cookie;
};
这里埋了一个伏笔:注意那个 flo 成员。它是连接 XFRM 机制和内核通用流缓存的桥梁。我们马上就会看到它是如何工作的。
10.6.2 发送路径的入口:xfrm_lookup()
xfrm_lookup() 函数的签名看似简单,实则暗流涌动:
struct dst_entry *xfrm_lookup(struct net *net, struct dst_entry *dst_orig,
const struct flowi *fl, struct sock *sk, int flags);
它只处理 Tx(发送)路径。所以第一步,先把方向定死:
u8 dir = policy_to_flow_dir(XFRM_POLICY_OUT);
接下来的逻辑,是一个典型的「双重检查」模式。我们先看路径,再找路径。
第一步:Socket 策略的特权通道
内核首先会问:这个包是不是来自某个特权用户(具体的说是,绑定了特定策略的 Socket)?
如果是本地发出的流量(sk 不为空),并且这个 Socket 自己绑定了一个外出策略(sk_policy[OUT]),那么内核会直接走「VIP 通道」,调用 xfrm_sk_policy_lookup()。
if (sk && sk->sk_policy[XFRM_POLICY_OUT]) {
num_pols = 1;
pols[0] = xfrm_sk_policy_lookup(sk, XFRM_POLICY_OUT, fl);
...
}
这一步绕过了全局策略查找,效率极高。但大多数普通流量(比如你没专门用 setsockopt 给 Socket 配策略)都走不到这里。
第二步:流缓存的拦截
如果 Socket 没有绑定策略,内核就会转向通用的流缓存——这是 xfrm_lookup() 最精彩的部分。
代码里,它是这样调用的:
flo = flow_cache_lookup(net, fl, family, dir, xfrm_bundle_lookup, dst_orig);
你可以把这个 flow_cache_lookup() 想象成一个图书馆的管理员。你递给它一张索书单(fl,即流信息),告诉它你要找书。
- 如果是第一次来借这本书,管理员会在目录里没找到,于是它会启动一个「Resolver」(解析器),也就是我们的回调函数
xfrm_bundle_lookup,去书库里把书找出来(或者创建一本),并把这个位置记录在案。 - 如果是第二次来(后续的数据包),管理员会发现:「嘿,这本书我刚找过」,直接从缓存里把结果给你。
如果缓存命中,我们拿到的 flo 对象实际上就嵌在 xfrm_dst 结构里。通过 container_of 宏,我们一捞就能捞出整个 Bundle:
xdst = container_of(flo, struct xfrm_dst, flo);
一旦拿到了 xdst,所有后续需要的信息——路由、策略、SA 数量——就都到手了:
num_pols = xdst->num_pols;
num_xfrms = xdst->num_xfrms;
memcpy(pols, xdst->pols, sizeof(struct xfrm_policy*) * num_pols);
route = xdst->route;
最后,把内核通用的 dst_entry 指针指过来,查找结束:
dst = &xdst->u.dst;
这套机制保证了:只有每条流的第一个包会经历完整的策略匹配和查找过程,后续包全部走高速缓存。
10.6.3 真正的坑:当 SA 还没准备好(Larval State)
但事情到这里还没完。如果你在跑 IPsec,你迟早会遇到一种令人抓狂的情况:策略有了,但 SA 还没协商好。
这时候,xfrm_bundle_lookup() 会查到一个尴尬的结果:策略是存在的,但是对应的 xfrm_state(即 SA)是缺失的或者处于「幼虫」状态。
这就好比:你的快递单填好了,但仓库里还没货。内核返回了一个特殊的 Bundle——Dummy Bundle(虚拟捆绑)。
这个 Bundle 的特征是:route 成员是 NULL。
这就把我们带入了代码里最棘手的 if 分支:
if (route == NULL && num_xfrms > 0) {
/* ... 只有当模板无法解析时,
* xfrm_bundle_lookup() 才会返回一个 route 为 NULL 的 bundle。
* 这意味着策略有了,但 bundle 创建不出来,
* 因为我们还没有 xfrm_state。
* 我们要么等 KM(密钥管理)协商出新的 SA,
* 要么就报错放弃。 */
if (net->xfrm.sysctl_larval_drop) {
...
return make_blackhole(net, family, dst_orig);
}
...
}
这里有一个内核参数掌控着生杀大权:sysctl_larval_drop(对应 /proc/sys/net/core/xfrm_larval_drop)。
场景 A:sysctl_larval_drop = 1(默认值,无情模式)
这是默认行为。如果 SA 没准备好,内核不会陪你等,直接把包丢弃。
代码里调用的是 make_blackhole()。对于 IPv4,它会调用 ipv4_blackhole_route()。
这个名字很形象——你的包被扔进了一个黑洞路由。它就像发到了/dev/null,石沉大海,没有任何 ICMP 错误回送。
这通常是你预期的行为:在 VPN 隧道真正建立好之前,泄露明文流量或者发送无效的加密包都是不安全的。
场景 B:sysctl_larval_drop = 0(有情模式,队列等待)
如果你把这个参数设为 0,内核就会变得很有耐心。
它会调用 xdst_queue_output(),把这些还没法处理的包塞进一个队列里——polq.hold_queue。
这个队列最多能装 100 个包(由 XFRM_MAX_QUEUE_LEN 定义)。内核会把这些包扣下来,静静地等待 IKE 守护进程(比如 Charon 或 Pluto)把 SA 谈判出来。
如果运气好,SA 协商成功了,内核会把积压的包一股脑发出去。
如果时间太久了(xfrm_policy_queue 的超时时间到了),内核就不等了,调用 xfrm_queue_purge() 把队列里的包全部清空。
这种模式在某些网络抖动或者频繁重连的场景下很有用,但在高吞吐场景下,可能会因为内存占用而引入新的问题。
走到这里,xfrm_lookup() 的使命就完成了。它成功地把一个原始的 dst_entry 变成了一个(可能被加密、可能被缓存、也可能被扔进黑洞)的最终路由结果。
但我们的 IPsec 之旅还没结束。现实中,网络往往比我们想象的要「脏」——比如,中间横亘着一台 NAT 设备。
下一节,我们会讨论 IPsec 最著名的「补丁」:NAT 穿透(NAT-T),看看内核是如何在加密包外再套一层 UDP 皮,欺骗那些不认识 ESP 的防火墙的。