写点什么

基于 eBPF 和 Go 实现透明代理

作者:俞凡
  • 2025-10-19
    上海
  • 本文字数:6936 字

    阅读完需:约 23 分钟

本文介绍了如何利用 eBPF 的能力,并通过 Go 和 ebpf-go 包实现高性能透明代理。原文:Transparent Proxy Implementation using eBPF and Go


透明代理(transparent proxy),也称为内联代理(inline proxy),可以拦截并重定向客户端请求,而无需修改请求或对客户端进行配置。透明代理对用户来说是不可见的,意味着用户不会察觉到它的存在,也无需调整网络设置。这项技术对于网络管理、安全策略执行、流量监控和优化来说至关重要。透明代理能够执行一系列功能,包括内容过滤、缓存加速、流量控制和负载均衡等。实现透明代理的常用技术包括 TPROXY、NAT 等。


本文将展示如何利用 eBPF 实现透明代理,具体来说就是结合 Go 语言以及 ebpf-go 包来实现。



⚠️注意:关于 eBPF,本文不会涉及太多细节,如果对这个话题不太熟悉,可以查看其他文章。

Introduction to BPF and eBPF

Security Evaluation: eBPF vs. WebAssembly

eBPF-Powered Load Balancing for SO_REUSEPORT

Optimizing Local Socket Communication: SOCKMAP and eBPF

eBPF sk_lookup: Socket Lookup and Redirection

eBPF 在透明代理中的应用

扩展的伯克利数据包过滤器(eBPF,Extended Berkeley Packet Filter)是一种强大工具,可用于实现透明代理,能够在 Linux 内核中运行沙盒程序。这种能力使其能够进行高性能数据包处理和实时流操作,避免在用户空间和内核空间之间进行上下文切换所带来的开销。


用 eBPF 实现透明代理涉及三个 eBPF 程序,每个程序负责网络拦截和转发的不同功能:


  • 连接建立时的地址替换:第一个 eBPF 程序 cgroup/connect4 附加到 connect 系统调用。当客户端尝试连接目标服务器时,此程序拦截连接尝试,将目标 IP 地址和端口替换为本地透明代理的地址和端口 —— 这种重定向对客户端来说是完全透明的。同时,原目标地址和端口被存储在 map_socks eBPF 映射中,以便其他 eBPF 程序稍后引用此信息。

  • 连接建立后的源地址记录:第二个 eBPF 程序 sockops 在代理与目标服务器成功建立连接后执行。其主要功能是记录连接的源地址和端口。此信息会更新 map_socks eBPF 映射中的相应条目。此外,源端口和套接字的 cookie(唯一标识符)被映射到 map_ports eBPF 映射中,确保所有必要的连接详情可供其他 eBPF 程序使用。此步骤对于维护网络连接状态至关重要。

  • 基于原始目标信息的转发:第三个 eBPF 程序 cgroup/getsockopt 在 Pipy 代理使用 getsockopt 调用查询原始目标信息时被触发。该程序通过源端口从 map_ports 中获取原始套接字的 cookie,然后访问存储在 map_socks 中的原始目标信息。利用这些信息,与原始目标服务器建立连接,并转发客户端请求。从而确保流量在经过代理处理后能透明的重定向到其预期的目的地。


这三个程序都链接到特定的 cgroup,在我们的例子中是根 cgroup,但也可以是其他任何 cgroup。从而确保只有当该组内的进程执行指定系统调用(syscalls)时,才会被激活。目前,我们的配置仅代理 TCP IPv4 连接,但也可以适应其他协议。


下图展示了整个设置,每种颜色代表一个不同阶段,按以下顺序执行:

红色 -> 绿色 -> 紫色 -> 粉色



这就是理论上需要的全部内容,接下来我们看一下代码。


💡提示:代码里添加了一些有用的注释帮助读者理解

用户空间代码

package main
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -type Config proxy proxy.c
import ( "fmt" "io" "log" "net" "os" "syscall" "time" "unsafe"
"github.com/cilium/ebpf" "github.com/cilium/ebpf/link" "github.com/cilium/ebpf/rlimit")
const ( CGROUP_PATH = "/sys/fs/cgroup" // Root cgroup 路径 PROXY_PORT = 18000 // 代理监听的端口 SO_ORIGINAL_DST = 80 // 获取原目标地址的套接字选项)
// SockAddrIn 是用于保存通过 SO_ORIGINAL_DST 检索的 IPv4 sockaddr_in 结构。type SockAddrIn struct { SinFamily uint16 SinPort [2]byte SinAddr [4]byte // 填充以匹配 sockaddr_in 大小 Pad [8]byte}
// getsockopt 辅助函数func getsockopt(s int, level int, optname int, optval unsafe.Pointer, optlen *uint32) (err error) { _, _, e := syscall.Syscall6(syscall.SYS_GETSOCKOPT, uintptr(s), uintptr(level), uintptr(optname), uintptr(optval), uintptr(unsafe.Pointer(optlen)), 0) if e != 0 { return e } return}
// HTTP 代理请求处理程序func handleConnection(conn net.Conn) { defer conn.Close()
// 在 Go 中需要通过 RawConn 对底层 socket 文件描述符执行低级操作。 // 从而让我们可以用 getsockopt 来检索由 SO_ORIGINAL_DST 选项设置的原始目的地址, // 该信息不能通过 Go 高级网络 API 直接访问。 rawConn, err := conn.(*net.TCPConn).SyscallConn() if err != nil { log.Printf("Failed to get raw connection: %v", err) return }
var originalDst SockAddrIn // 如果 Control 不为 nil,则在创建网络连接后,在绑定到操作系统之前调用。 rawConn.Control(func(fd uintptr) { optlen := uint32(unsafe.Sizeof(originalDst)) // 通过 SO_ORIGINAL_DST 选项进行系统调用来检索原始目的地址。 err = getsockopt(int(fd), syscall.SOL_IP, SO_ORIGINAL_DST, unsafe.Pointer(&originalDst), &optlen) if err != nil { log.Printf("getsockopt SO_ORIGINAL_DST failed: %v", err) } })
targetAddr := net.IPv4(originalDst.SinAddr[0], originalDst.SinAddr[1], originalDst.SinAddr[2], originalDst.SinAddr[3]).String() targetPort := (uint16(originalDst.SinPort[0]) << 8) | uint16(originalDst.SinPort[1])
fmt.Printf("Original destination: %s:%d\n", targetAddr, targetPort)
// 检查原始目的地址是否可以从代理访问 targetConn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", targetAddr, targetPort), 5*time.Second) if err != nil { log.Printf("Failed to connect to original destination: %v", err) return } defer targetConn.Close()
fmt.Printf("Proxying connection from %s to %s\n", conn.RemoteAddr(), targetConn.RemoteAddr())
// 下面的代码创建了两个数据传输通道: // - 从客户端到目标服务器(由单独例程处理)。 // - 从目标服务器到客户端(由主程序处理)。 go func() { _, err = io.Copy(targetConn, conn) if err != nil { log.Printf("Failed copying data to target: %v", err) } }() _, err = io.Copy(conn, targetConn) if err != nil { log.Printf("Failed copying data from target: %v", err) }}
func main() { // 删除 <5.11 内核的资源限制。 if err := rlimit.RemoveMemlock(); err != nil { log.Print("Removing memlock:", err) }
// 加载编译好的 eBPF ELF 并将其加载到内核中 // 注意:也可以 pin eBPF 程序 var objs proxyObjects if err := loadProxyObjects(&objs, nil); err != nil { log.Print("Error loading eBPF objects:", err) } defer objs.Close()
// 将 eBPF 程序附加到 root cgroup connect4Link, err := link.AttachCgroup(link.CgroupOptions{ Path: CGROUP_PATH, Attach: ebpf.AttachCGroupInet4Connect, Program: objs.CgConnect4, }) if err != nil { log.Print("Attaching CgConnect4 program to Cgroup:", err) } defer connect4Link.Close()
sockopsLink, err := link.AttachCgroup(link.CgroupOptions{ Path: CGROUP_PATH, Attach: ebpf.AttachCGroupSockOps, Program: objs.CgSockOps, }) if err != nil { log.Print("Attaching CgSockOps program to Cgroup:", err) } defer sockopsLink.Close()
sockoptLink, err := link.AttachCgroup(link.CgroupOptions{ Path: CGROUP_PATH, Attach: ebpf.AttachCGroupGetsockopt, Program: objs.CgSockOpt, }) if err != nil { log.Print("Attaching CgSockOpt program to Cgroup:", err) } defer sockoptLink.Close()
// 在 localhost 上启动代理服务器 // 本例中只演示了 IPv4,但同样方法也可用于 IPv6 proxyAddr := fmt.Sprintf("127.0.0.1:%d", PROXY_PORT) listener, err := net.Listen("tcp", proxyAddr) if err != nil { log.Fatalf("Failed to start proxy server: %v", err) } defer listener.Close()
// 更新 proxyMaps 映射表以包含代理服务器配置信息,因为我们需要知道代理服务器的进程标识符, // 以便过滤掉由代理服务器自身生成的 eBPF 事件,从而避免其循环代理自身的数据包。 var key uint32 = 0 config := proxyConfig{ ProxyPort: PROXY_PORT, ProxyPid: uint64(os.Getpid()), } err = objs.proxyMaps.MapConfig.Update(&key, &config, ebpf.UpdateAny) if err != nil { log.Fatalf("Failed to update proxyMaps map: %v", err) }
log.Printf("Proxy server with PID %d listening on %s", os.Getpid(), proxyAddr) for { conn, err := listener.Accept() if err != nil { log.Printf("Failed to accept connection: %v", err) continue }
go handleConnection(conn) }}
复制代码

内核空间代码

//go:build ignore#include <stddef.h>#include <linux/bpf.h>#include <linux/netfilter_ipv4.h>#include <linux/in.h>#include <sys/socket.h>
#include "bpf-builtin.h"#include "bpf-utils.h"
#undef bpf_printk#define bpf_printk(fmt, ...) \({ \ static const char ____fmt[] = fmt; \ bpf_trace_printk(____fmt, sizeof(____fmt), \ ##__VA_ARGS__); \})
#define MAX_CONNECTIONS 20000
struct Config { __u16 proxy_port; __u64 proxy_pid;};
struct Socket { __u32 src_addr; __u16 src_port; __u32 dst_addr; __u16 dst_port;};
struct { int (*type)[BPF_MAP_TYPE_ARRAY]; int (*max_entries)[1]; __u32 *key; struct Config *value;} map_config SEC(".maps");
struct { int (*type)[BPF_MAP_TYPE_HASH]; int (*max_entries)[MAX_CONNECTIONS]; __u64 *key; struct Socket *value;} map_socks SEC(".maps");
struct { int (*type)[BPF_MAP_TYPE_HASH]; int (*max_entries)[MAX_CONNECTIONS]; __u16 *key; __u64 *value;} map_ports SEC(".maps");
// 当进程(在附加了该钩子的 cgroup 中)调用 connect() 系统调用时触发该钩子// 钩子将连接重定向到透明代理,但将原始目的地址和端口存储在 map_socks 中SEC("cgroup/connect4")int cg_connect4(struct bpf_sock_addr *ctx) { // 只转发 IPv4 TCP 连接 if (ctx->user_family != AF_INET) return 1; if (ctx->protocol != IPPROTO_TCP) return 1;
// 防止代理代理自己 __u32 key = 0; struct Config *conf = bpf_map_lookup_elem(&map_config, &key); if (!conf) return 1; if ((bpf_get_current_pid_tgid() >> 32) == conf->proxy_pid) return 1;
// 该字段包含传递给 connect() 系统调用的 IPv4 地址,即连接到此套接字的目标地址及端口。 __u32 dst_addr = ntohl(ctx->user_ip4); // 该字段包含传递给 connect() 系统调用的端口号 __u16 dst_port = ntohl(ctx->user_port) >> 16; // 目的套接字的唯一标识符 __u64 cookie = bpf_get_socket_cookie(ctx);
// 在 cookie key 下存储目标套接字 struct Socket sock; __builtin_memset(&sock, 0, sizeof(sock)); sock.dst_addr = dst_addr; sock.dst_port = dst_port; bpf_map_update_elem(&map_socks, &cookie, &sock, 0);
// 将连接重定向到代理 ctx->user_ip4 = htonl(0x7f000001); // 127.0.0.1 == proxy IP ctx->user_port = htonl(conf->proxy_port << 16); // Proxy port
bpf_printk("Redirecting client connection to proxy\n");
return 1;}
// 当特定 cgroup 上有套接字操作(重传超时,建立连接等)时,该程序被调用。// 在与代理成功建立连接后,记录客户端源地址和端口SEC("sockops")int cg_sock_ops(struct bpf_sock_ops *ctx) { // 只转发 IPv4 连接 if (ctx->family != AF_INET) return 0;
// 已建立连接的活跃套接字 if (ctx->op == BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB) { __u64 cookie = bpf_get_socket_cookie(ctx);
// 在 map 中查找对应 cookie // 如果 socket 存在,存储源端口和 socket 映射 struct Socket *sock = bpf_map_lookup_elem(&map_socks, &cookie); if (sock) { __u16 src_port = ctx->local_port; bpf_map_update_elem(&map_ports, &src_port, &cookie, 0); } }
bpf_printk("sockops hook successful\n");
return 0;}
// 代理通过 getsockopt SO_ORIGINAL_DST 查询原始目的地信息时触发。// 该程序利用客户端源端口从 map_ports 中获取套接字 cookie,// 再从 map_socks 中获取原始目标信息,// 然后与原始目标建立连接并转发客户端请求。SEC("cgroup/getsockopt")int cg_sock_opt(struct bpf_sockopt *ctx) { // SO_ORIGINAL_DST 套接字选项主要用于网络地址转换(NAT)和透明代理。 // 在典型 NAT 或透明代理设置中,传入的数据包从原始目的地重定向到代理服务器。 // 代理服务器在接收到数据包后,通常需要知道原始目的地址,以便以合适的方式处理流量。 // 这就是 SO_ORIGINAL_DST 发挥作用的地方。 if (ctx->optname != SO_ORIGINAL_DST) return 1; // 只转发 IPv4 TCP 连接 if (ctx->sk->family != AF_INET) return 1; if (ctx->sk->protocol != IPPROTO_TCP) return 1;
// 获取客户端源端口。 // 实际上应该是 sk->dst_port,因为通过设置了 SO_ORIGINAL_DST 套接字选项的 getsockopt() 系统调用会获取客户端原始目的端口,所以这是在查询客户端的最终目的端口。 __u16 src_port = ntohs(ctx->sk->dst_port);
// 基于客户端 src_port 获取 socket cookie __u64 *cookie = bpf_map_lookup_elem(&map_ports, &src_port); if (!cookie) return 1;
// 通过该 cookie(套接字标识符),从 map_socks 中获取原始套接字(客户端连接至目标端的套接字) struct Socket *sock = bpf_map_lookup_elem(&map_socks, cookie); if (!sock) return 1;
struct sockaddr_in *sa = ctx->optval; if ((void*)(sa + 1) > ctx->optval_end) return 1;
// 与原目标地址建立连接 ctx->optlen = sizeof(*sa); sa->sin_family = ctx->sk->family; // 地址族 sa->sin_addr.s_addr = htonl(sock->dst_addr); // 目标地址 sa->sin_port = htons(sock->dst_port); // 目标端口 ctx->retval = 0;
bpf_printk("Redirecting connection to original destination\n");
return 1;}
char __LICENSE[] SEC("license") = "GPL";
复制代码


如果想尝试一下,这是 GitHub 仓库链接:GitHub - dorkamotorka/transparent-proxy-ebpf

性能评估

下面是基本性能测试,以评估该 eBPF 项目对主机服务器的影响,特别关注在拦截流量时的延迟和 CPU 负载情况。测试内容包括测量 10,000 次请求的平均延迟。



研究结果表明,eBPF 程序平均会带来约 1 毫秒固定开销。此外,每个钩子引入的平均 CPU 负载情况如下:套接字操作为 0.4%cgroup/connect40.1%cgroup/getsockopt0.09%。基本上没什么影响。


研究结果表明,尽管 eBPF 程序会带来额外延迟和 CPU 负载,但其带来的流量拦截优势却足以弥补这些不足。


⚠️ 注意:此实现的灵感来源于 Pipy,通过使用 ebpf-go 添加了用户空间实现,从而扩展其功能。此外,还对内核空间代码进行了少量修改,并添加了多处注释以增强可读性。

结论

总之,基于 eBPF 实现透明代理为网络拦截和转发提供了强大的解决方案。通过利用 eBPF 的能力,例如高性能数据包处理和在 Linux 内核中进行实时流量操作,透明代理能够高效管理各种用途的网络流量,包括安全执行、流量监控和优化。示例展示了 eBPF 与 Go 的集成,展示了不同 eBPF 程序如何无缝协作以实现透明代理功能。这种方法不仅确保了最小的开销,还为扩展代理功能以支持除 TCP IPv4 连接之外的其他协议提供了灵活性。




你好,我是俞凡,在 Motorola 做过研发,现在在 Mavenir 做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI 等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!

发布于: 刚刚阅读数: 4
用户头像

俞凡

关注

公众号:DeepNoMind 2017-10-18 加入

俞凡,Mavenir Systems研发总监,关注高可用架构、高性能服务、5G、人工智能、区块链、DevOps、Agile等。公众号:DeepNoMind

评论

发布
暂无评论
基于 eBPF 和 Go 实现透明代理_golang_俞凡_InfoQ写作社区