使用 Pipy 和 eBPF 实现透明代理

随笔2周前发布 蘋安
8 0 0

使用 Pipy 和 eBPF 实现透明代理

背景

透明代理

使用 Pipy 和 eBPF 实现透明代理

透明代理是一种网络中间件,它能够在用户不知情的情况下拦截和转发网络流量。与传统代理不同,透明代理不需要在用户端配置特定的代理设置,而是通过在网络层面拦截流量来实现代理功能。透明代理通常被用于网络管理、安全策略实施、流量监控和优化等方面。它可以用于实现诸如内容过滤、缓存加速、流量控制、负载均衡等功能。常见的 TPROXY、NAT、Divert 等技术都可以用来实现透明代理。

eBPF

eBPF 是一种高效、灵活的内核技术,允许用户空间程序安全地执行预编译和限制在 Linux 内核空间中运行的程序(即 eBPF 程序),而不需要更改内核源代码或加载内核模块。近几年 eBPF 在网络方面的广泛应用,使其成为实现透明代理的可选技术之一。

Pipy 从 0.99.1 之后加入了对 BPF 的支持,可以加载和解析 BPF 程序;1.0 的语法升级,让其在实现控制逻辑方面更加得心应手。今天我们就来介绍如何使用 Pipy + eBPF 实现一个简单的透明代理。

注:文中所有的代码都可以在 Pipy 仓库中找到:https://github.com/flomesh-io/pipy/tree/main/samples/bpf/transparent-proxy。

快速开始

克隆代码。

git clone https://github.com/flomesh-io/pipy.git
cd samples/bpf/transparent-proxy

编译 BPF 程序,之后可以在目录中找到编译好的 .o 文件。

make

启动透明代理。

sudo pipy main.js

成功运行后,代理会监听端口 18000。接着我们发送请求测试一下:

curl -L bing.com

在代理的控制台,我们可以找到请求和相应的日志。

GET / bing.com
  301 Moved Permanently
GET / www.bing.com
  200 OK

在整个过程中,代理对客户端保持透明,无需进行任何代理配置。

接下来,我们将深入探讨透明代理的实现方式。

实现

eBPF 程序设计

使用 Pipy 和 eBPF 实现透明代理

在实现中用到了三个 eBPF 程序,每个程序负责不同的网络拦截与转发任务:

  • 连接建立时的地址替换:第一个 eBPF 程序 cg_connect4 附加到 connect 系统调用上。当客户端尝试与目标服务器建立连接时,这个程序将目标的 IP 地址和端口替换为 Pipy 代理的地址(通常是本地地址 127.0.0.1)和端口。同时,它将原始的目标地址和端口保存到 struct sock 中,并将 socket 的 cookie 与该 sock 结构的映射关系保存在 map_socks 中,用于之后的查询和数据转发。
  • 连接成功后的源地址记录:第二个 eBPF 程序 cg_sock_ops 在连接成功建立后执行,负责记录源地址和端口,并将这些信息更新到 map_socks 里对应的 sock 中。此外,它还将源端口和 socket 的 cookie 的映射关系保存在 map_ports 中,为后续数据转发提供必要的信息。
  • 基于原始目的地信息的连接与转发:第三个 eBPF 程序 cg_sock_opt 触发于 Pipy 通过 getsockopt 查询原始目的地信息的操作。该程序利用源端口从 map_ports 中获取 socket 的 cookie,进而从 map_socks 中获取原始目的地信息,然后与原始目标建立连接并转发客户端的请求。

eBPF 程序的加载

使用 Pipy 和 eBPF 实现透明代理

Pipy 支持 BPF 之后,开发者可以直接在 PipyJS 中处理 BPF 程序,这意味着对 BPF 的操作不再依赖于 bpftool 这类工具,或是需要使用如 BCCGoBPF 等其他语言库开发的控制面。这种集成提供了显著的便利性和灵活性,允许在同一个进程中运行 eBPF 控制面和代理逻辑,从而简化了部署和管理。

以下是使用 Pipy 进行 eBPF 操作的关键代码解释,基于 main.js 文件中的示例:

在 PipyJS 中,你可以通过 bpf 模块提供的 API 来加载和操作 eBPF 程序。这里是一种典型的使用方式:

使用 bpf.object()API 来加载一个编译好的 eBPF 程序对象;这一步骤需要指定 eBPF 程序文件的路径;然后,使用 load() 方法将 eBPF 程序加载到内核中。这一步是必要的,因为只有加载到内核后,eBPF 程序才能开始工作。

var obj = bpf.object(pipy.load('transparent-proxy.o'))
var progCgConnect4 = obj.programs.find(p => p.name === 'cg_connect4').load('BPF_PROG_TYPE_CGROUP_SOCK_ADDR', 'BPF_CGROUP_INET4_CONNECT')
var progCgSockOps = obj.programs.find(p => p.name === 'cg_sock_ops').load('BPF_PROG_TYPE_SOCK_OPS')
var progCgSockOpt = obj.programs.find(p => p.name === 'cg_sock_opt').load('BPF_PROG_TYPE_CGROUP_SOCKOPT', 'BPF_CGROUP_GETSOCKOPT')

将配置写入到 Map map_config 中,这里的配置就是代理所监听的端口。

obj.maps.find(m => m.name === 'map_config').update(
  { i: 0 }, {
    proxy_port: PROXY_PORT,
    pipy_cgroup_id: bpf.cgroup(CGRP_PIPY)
  }
)

接下来就可以将 eBPF 程序,挂载到指定的钩点。

bpf.attach('BPF_CGROUP_INET4_CONNECT', progCgConnect4.fd, CGRP)
bpf.attach('BPF_CGROUP_SOCK_OPS', progCgSockOps.fd, CGRP)
bpf.attach('BPF_CGROUP_GETSOCKOPT', progCgSockOpt.fd, CGRP)

还有,别忘记在代理退出时对 eBPF 程序进行清理。

pipy.exit(
  function() {
    bpf.detach('BPF_CGROUP_INET4_CONNECT', progCgConnect4.fd, CGRP)
    bpf.detach('BPF_CGROUP_SOCK_OPS', progCgSockOps.fd, CGRP)
    bpf.detach('BPF_CGROUP_GETSOCKOPT', progCgSockOpt.fd, CGRP)
    os.write(`${CGRP}/cgroup.procs`, pipy.pid.toString())
    os.rmdir(CGRP_PIPY)
  }
)

完成 eBPF 控制面的逻辑后,接着就是代理转发的实现了。

使用 Pipeline API onStart 在与客户端成功连接后,通过 socket.getRawOption 来获取客户端的原始目的地信息,这里就会触发上面提到的第三个 eBPF 程序。

pipy.listen(PROXY_PORT, $=>$
  .onStart(
    function (ib) {
      var od = new Data
      ib.socket.getRawOption(SOL_IP, SO_ORIGINAL_DST, od)
      var sa = sockaddr_in.decode(od)
      var addr = sa.sin_addr
      var port = sa.sin_port
      $targetAddr = addr.join('.')
      $targetPort = (port[0] << 8) | port[1]
    }
  )
  ...

总结

这个透明代理示例虽然简单,但它有效地展示了 Pipy 与 eBPF 两种可编程技术的结合力量。尽管离实现理想中的透明代理存在差距——例如,目标服务器看到的是代理地址而非客户端的真实 IP,这一点对于需要记录真实客户 IP 的应用场景尤为关键——但通过 eBPF 技术,我们依然能够达成目标。具体做法是,利用 eBPF 程序调整 Pipy 转发的网络包中的源地址和端口,同时修改返回数据包中的目标地址和端口,以实现对目标服务器的透明性。

与其他技术方案相比,eBPF 在实现透明代理方面提供了无与伦比的灵活性和可编程性。它不仅减少了对内核网络栈处理的依赖,而且大幅提升了性能。

通过将 eBPF 程序的操作集成进 Pipy,我们能够实现应用层与内核层可编程功能的无缝集成,进一步强化了这一解决方案的实用性和效率。

© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...