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