从 IP 包到 HTTP 请求,Cloudflare 的 Oxy 代理框架是怎么做到
原文From IP packets to HTTP: the many faces of our Oxy framework作者 Nuno DieguesCloudflare 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 来说都是独立到来的。解决方案是使用GUEGeneric 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 连接交给HyperRust 生态里最主流的 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 检测到目标端口是 22SSH把它升级为 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 / UDPTUN NAT 网络命名空间 ↓ 可选继续升级为 HTTPHyper ↓ 应用逻辑处理零信任策略 / 审计日志 / 内容检查 ↓ 可选降级为 TCP / UDP ↓ 可选降级为 IP 数据包逆向 NAT TUN ↓ [出口转发]每一步都有 hook应用决定是否升级、何时降级框架只负责提供能力。这个设计有一个不算明显但很关键的优点共享基础设施。无论流量在哪个层被处理可观测性、安全检查、配置管理这些横切关注点都在同一套框架里实现不需要在每个产品里重复造轮子。这也是 Cloudflare 选择把所有层都塞进一个框架的根本原因尽管一开始他们自己也觉得这范围不会太宽了吗。