从 IP 包到 HTTP 请求,Cloudflare 的 Oxy 代理框架是怎么做到

3 阅读11分钟

原文: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 的做法是:

  1. 创建一个 TUN 接口
  2. 把想要"升级"的 IP 数据包写入这个 TUN 接口
  3. 内核收到这些包后,按正常 TCP 协议处理它们
  4. Oxy 在 TUN 接口对应的 IP 地址上绑定一个 TCP listener
  5. 内核完成三次握手后,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 机制:

  1. Oxy 主进程(运行在 root 命名空间)调用 clone,创建一个子进程,并让子进程进入一个新的用户命名空间和网络命名空间
  2. 父子进程之间维护一对 Unix pipe 用于通信
  3. 子进程在新的网络命名空间里创建 TUN 接口、配置路由、绑定 TCP listener
  4. 子进程通过 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 审计日志:

  1. WARP 客户端发来 IP 包,Oxy 检测到目标端口是 22(SSH),把它升级为 TCP 连接
  2. 安全网关解析 SSH 协议,记录所有执行的命令
  3. 记录完毕后,下游是另一个 WARP 设备,需要以 IP 包的形式转发过去
  4. 因此 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 选择把所有层都塞进一个框架的根本原因,尽管一开始他们自己也觉得"这范围不会太宽了吗"。