从 IP 包到 HTTP 请求,Cloudflare 的 Oxy 代理框架是怎么做到
原文:From IP packets to HTTP: the many faces of our Oxy framework,作者 Nuno Diegues,Cloudflare Blog。 代理这个词,在网络编程里太常见了,以至于很多人对它的理解停留在"转发 HTTP 请求"的层面。但真正的网络代理系统,要处理的远不止于此:它需要在 OSI 模型的不同层之间自如地穿梭,既能接收原始 IP 数据包,又能理解 HTTP 语义,还要在两者之间任意转换。 这篇文章是 Cloudflare 工程师 Nuno Diegues 对 Oxy 框架的技术详解。Oxy 是他们用 Rust 构建的代理框架,目前支撑着 WARP、Cloudflare One、Magic WAN 等多个核心产品,每天为数百万用户处理流量。 这篇文章试图把原文的核心思路讲清楚,同时补充一些背景,帮助理解每个设计决策背后的原因。 Oxy 本质上是一个可扩展的代理框架,应用层(Application)基于 Oxy 构建,通过 hook 函数介入各个处理节点,决定流量的走向和行为。 框架的一个核心设计思想是:流量可以在 OSI 模型的不同层之间向上升级(upgrade)或向下降级(downgrade)。 这种能力之所以必要,是因为 Cloudflare 同时运营着两类截然不同的服务: 一类是需要在 L3 接入流量的服务,比如 Cloudflare One 的零信任网络。企业客户的设备通过 WARP 客户端,把所有网络流量(不限协议)都发给 Cloudflare。这些流量涵盖 TCP、UDP 乃至其他协议,只能以原始 IP 数据包的形式接入,没有更高层的协议可以依赖。 另一类是只关心 L7 语义的服务,比如 HTTP 代理、安全网关。它们需要检查 HTTP 头部、执行访问策略,完全不需要关心底层是如何传输的。 Oxy 把这两类需求统一在同一个框架里,让应用开发者选择自己关心的层,其余部分由框架负责。 Oxy 中,接收流量的入口叫做 on-ramp(入口坡道),对应的出口叫 off-ramp(出口坡道)。 对于 Cloudflare One 这类产品,on-ramp 需要在 IP 层接收数据包。但接收 IP 包只是第一步,紧接着的问题是:如何区分来自不同客户的数据包? Cloudflare 的整个基础设施是多租户的,同一台服务器上跑着成千上万个客户的流量。一个来自客户 A 的 IP 包和来自客户 B 的 IP 包,在网络层可能完全相同(私有 IP 地址重叠是很常见的),必须通过某种方式把租户上下文附加到每个数据包上。 为此,Oxy 定义了两种 IP 隧道类型: 连接型 IP 隧道(Connected IP Tunnel) 用于 WARP 场景。WARP 客户端先用 WireGuard 协议建立一条隧道,终止在 Cloudflare 最近的数据中心节点,该节点再通过一个 非连接型 IP 隧道(Unconnected IP Tunnel) 用于 Magic WAN 场景,即企业通过 GRE 或 IPsec 隧道接入 Cloudflare。这类流量由 Linux 内核直接解封装,内核不维护两个相邻数据包之间的状态,每个包对 Oxy 来说都是独立到来的。 解决方案是使用 GUE(Generic UDP Encapsulation):在每个 IP 包外面再包一层 UDP 头,把租户上下文编码进去。每个包自带上下文,不依赖连接状态。代价是额外的封装开销,但由于 Cloudflare 数据中心内部没有 MTU 限制,不会触发分片,总体可以接受。 IP 数据包到达 Oxy 后,需要决定每个包该怎么处理。Oxy 的做法是基于五元组进行流追踪(源 IP、目标 IP、源端口、目标端口、协议号),把具有相同五元组的一系列数据包识别为同一个"IP 流"。 流追踪的实现依赖 这个逻辑和路由器做的事本质上是一样的。 流追踪的真正价值在于,它暴露出了流的生命周期事件,让上层应用可以在这些节点介入: 这是整篇文章技术含量最高的部分。 当 Oxy 决定把一个 IP 流"升级"为 TCP 连接时,需要从一堆原始 IP 数据包中重建出一个可用的 TCP socket。这件事听起来简单,实际上非常复杂。 为什么不用 Rust 的用户态 TCP 实现? Rust 生态里有 他们的选择是:继续用 Linux 内核的 TCP 实现——毕竟这是世界上经过最充分验证的 TCP 栈。 TUN 接口的妙用 TUN 接口是 Linux 提供的虚拟网络设备,它的数据不来自物理网卡,而来自用户空间程序写入的内容。但对内核来说,它和真实网卡没有区别。 Oxy 的做法是: 这样,一堆原始 IP 包就变成了一个标准的 TCP socket,后续操作和普通 TCP 编程完全一致。 NAT 和网络命名空间 上面的方案有两个细节问题: 第一,客户的 IP 地址在 Cloudflare 机器上没有路由,内核会直接丢弃这些包。解决方案是 Oxy 自己维护一张有状态 NAT 表,把客户的 IP 地址改写成 TUN 接口所在网段的地址,让内核能正确路由。 第二,TUN 接口用的本地 IP 地址可能和机器上其他进程冲突。解决方案是使用 Linux 网络命名空间——给每个 Oxy 的 TUN 实例创建一个独立的网络命名空间,在里面可以自由使用任意 IP 地址,与外部完全隔离。 但问题来了:Oxy 进程本身运行在默认(root)命名空间,TUN 接口在独立命名空间里,两者如何协作? 跨命名空间的文件描述符传递 Oxy 的解决方案利用了 Linux 的 最终结果:Oxy 主进程在 root 命名空间里正常运行,却持有一个监听在独立命名空间里的 TCP listener,完美实现了隔离与可用性的兼顾。整个过程不需要任何提权(no elevated permissions)。 一旦 Oxy 拿到了 TCP 连接,后续处理就相对常规了。应用可以选择把这条 TCP 连接交给 Hyper(Rust 生态里最主流的 HTTP 库)处理,必要时还可以在外面套一层 TLS。至此,流量就完成了从原始 IP 包到 HTTP 请求的全程升级。 相比 TCP 的复杂,UDP 的处理要直接得多。 把 IP 包升级为 UDP 数据报,只需要在用户空间里剥掉 IP 头和 UDP 头;反过来降级,也只需要把这两个头加回去。不需要 TUN 接口,不需要内核 TCP 栈,全在用户空间搞定。 但这不代表 UDP 不重要。现代 HTTPS 流量有相当一部分跑在 QUIC 上(即 HTTP/3),而 QUIC 的底层就是 UDP。Oxy 的 UDP 路径同样支撑着这部分流量。 有时候流量需要反向操作:一条 TCP 连接,在某个处理阶段结束后,需要被"降级"回 IP 数据包,转发给下一跳。 一个典型场景是 SSH 审计日志: TCP 降级比升级更复杂。升级时,Oxy 在命名空间里绑定一个 TCP listener,等内核把连接送上来;降级时,Oxy 需要主动发起一个 TCP 连接到 TUN 接口,让内核产生对应的 IP 包,再从 TUN 接口读出来、撤销 NAT,得到原始 IP 包。 整个过程需要 Oxy 主进程向子进程发送请求(通过那条 pipe),子进程在命名空间里建立 TCP 连接,把 socket 文件描述符通过 步骤多,但逻辑上是升级的镜像操作,理解了升级再看降级,基本上是顺水推舟。 测试涉及原始 IP 包处理的代码,通常需要在测试中手动构造 IP 包,很麻烦。 Oxy 的做法是:测试代码直接复用 Oxy 内部的命名空间管理和 TCP 降级逻辑。测试用例发送普通的 TCP 连接,由一个"TCP 降级器"把它转换为 IP 包,再把这些 IP 包输入给被测的 Oxy 实例。 测试用 TCP,被测系统处理 IP 包,中间的转换由框架自己完成,整个设计自洽又优雅,同时还把 TUN 接口相关逻辑纳入了测试覆盖范围。 Oxy 的整体流量路径可以这样描述: 每一步都有 hook,应用决定是否升级、何时降级,框架只负责提供能力。 这个设计有一个不算明显但很关键的优点:共享基础设施。无论流量在哪个层被处理,可观测性、安全检查、配置管理这些横切关注点都在同一套框架里实现,不需要在每个产品里重复造轮子。这也是 Cloudflare 选择把所有层都塞进一个框架的根本原因,尽管一开始他们自己也觉得"这范围不会太宽了吗"。Oxy 是什么,为什么要跨层处理
第一层:如何接收原始 IP 数据包
SOCK_SEQPACKET 类型的 Unix 域套接字把流量传给 Oxy。SOCK_SEQPACKET 是一种面向数据报、有连接、保序可靠的 Unix socket——它只接受本机内部的连接,保证了安全性。Oxy 在这条连接的第一个数据报里读取租户上下文(身份信息、策略等),之后的所有数据报都被当作原始 IP 数据包直接处理,没有额外开销。第二层:IP 流追踪
etherparse 这个 Rust crate 来解析 IP 头和传输层头部,从中提取流签名(flow signature)。然后查找哈希表:第三层:IP 流升级为 TCP 连接
smoltcp 这个用户态 TCP 实现,但 Cloudflare 明确放弃了它。原因是 smoltcp 不实现 TCP 的诸多性能和可靠性扩展(拥塞控制算法、SACK、TCP Fast Open 等),无法满足生产环境的要求。clone 系统调用和 SCM_RIGHTS 机制:clone,创建一个子进程,并让子进程进入一个新的用户命名空间和网络命名空间SCM_RIGHTS 机制,把 TCP listener 的文件描述符传递给父进程SCM_RIGHTS 是 Unix 域套接字的一个特性,允许在进程之间传递打开的文件描述符(包括 socket)。传递之后,父进程就拥有了那个 TCP listener 的访问权,尽管它在物理上属于另一个网络命名空间。从 TCP 继续向上:到 HTTP
UDP 的处理:相对简单
反向操作:从 TCP 降级回 IP 包
SCM_RIGHTS 传回给父进程,父进程再用这个 socket 代理原本的 TCP 流量,最终产生可以转发的 IP 包。测试:用框架本身来测框架
回顾整体设计
[入口流量]
↓
IP 数据包接收(SEQPACKET / GUE 封装)
↓
IP 流追踪与路由决策
↓
可选:升级为 TCP / UDP(TUN + NAT + 网络命名空间)
↓
可选:继续升级为 HTTP(Hyper)
↓
应用逻辑处理(零信任策略 / 审计日志 / 内容检查)
↓
可选:降级为 TCP / UDP
↓
可选:降级为 IP 数据包(逆向 NAT + TUN)
↓
[出口转发]