What if you could redirect network traffic from your apps without changing any source code? Let's build a transparent proxy using eBPF and Go.
Introduction
Some time ago I started playing around with this question: what if I could redirect the network traffic from my apps without changing any source code?
The idea is simple โ multiple apps deployed on a Kubernetes cluster, I might not own the code in the deployed apps, so I need the traffic to be redirected without the apps even knowing it.
Although this could've been done through a multitude of methods (iptable rules is a good example), I was also playing around for the first time with eBPF. My brain connected two neurons and said "hey, this could be interesting!"
Objectives
To keep it simple, for this first step we're going to aim to do two things:
- Load an eBPF program using cilium go-ebpf
- Redirect TCP connections of programs under a specific cgroup
But, why? you might ask!
Well, first, let's clarify one thing: the proxy should operate for some pods, but not all of them. Luckily, Kubernetes uses a feature from the linux kernel called "cgroups".
This feature enables us to use a specific type of eBPF program that allows us to hook ourselves into the loop whenever a process from any cgroup tries to use a socket!
By capturing this event, we're capable of changing the destination address without the process (our containers in the pod) knowing!
These "simple steps" are enough to get started with our transparent proxy!
In case you don't know what a socket is, you might find Beej's guide to network programming EXTREMELY useful
Pre-requisites
A couple of points โ this guide assumes you're using Linux for testing and developing, as we require cgroups v2 and support for two eBPF program types:
BPF_PROG_TYPE_SOCK_OPSwhich is supported since kernel version v4.13BPF_PROG_TYPE_CGROUP_SOCK_ADDRwhich is supported since kernel version v4.17
This post also assumes that you at least know your way around using and generating code with the ebpf-go library. If not, highly recommended to get started with their getting started guide!
Scaffolding
If you already know your way around ebpf-go, feel free to skip this. Otherwise, let's get the project set up.
Dependencies
First, install the required packages. On Ubuntu/Debian:
sudo apt install clang llvm libbpf-dev linux-headers-$(uname -r)
On Arch:
sudo pacman -S clang llvm libbpf linux-headers
You'll also need the bpf2go tool from cilium/ebpf:
go install github.com/cilium/ebpf/cmd/bpf2go@latest
Make sure $GOPATH/bin is in your PATH.
Project structure
Let's start by creating a folder proxy, inside that folder we're going to run our classic init command:
The name of the mod will also modify your output executable file, make sure to pay attention to this on the "testing" phase!
go mod init transparent_proxy # You can also change this name to whatever you prefer
Create the following files:
proxy/
โโโ proxy.c
โโโ main.go
proxy.c โ Our eBPF program (we'll fill this in throughout the post):
//go:build ignore
#include <linux/bpf.h>
#include <linux/in.h>
#include <bpf/bpf_endian.h>
#include <bpf/bpf_helpers.h>
#include <sys/socket.h>
#define MAX_CONNECTIONS 20000
// ... rest of the eBPF code goes here
char LICENSE[] SEC("license") = "GPL";
The //go:build ignore comment at the top tells Go to exclude this file during normal compilation โ it's only processed by bpf2go.
main.go โ The userspace loader (we'll build this out later):
package main
//go:generate bpf2go -cc clang proxy proxy.c -- -I/usr/include/bpf -O2 -g -Wall
func main() {
// TODO: Load and attach eBPF programs
}
Generating the Go bindings
Once your proxy.c compiles, run:
go generate ./...
This produces proxy_bpfel.go and proxy_bpfeb.go (little-endian and big-endian variants) containing Go types for your maps and programs. You'll use these to load and interact with the eBPF code from Go.
If you get compilation errors, they'll show up here โ the most common issues are missing headers or typos in the C code.
Writing eBPF
In Linux, when a user-space program (some application) wants to open a TCP connection (let's say making an HTTP request), they all need to perform certain syscalls in order (yes, even in your favourite js runtime, fetch does this under the hood): socket, connect, bind are all necessary to perform the whole lifecycle of a TCP call.
For now, let's focus on connect. When this system call is used on a system that uses cgroups v2, it will trigger a set of eBPF programs that we can take advantage of!
Imagine this as your traditional and dear middleware on a web server โ a syscall is performed, and the eBPF program will be executed before the kernel performs an operation. Nice, right?
BPF_PROG_TYPE_CGROUP_SOCK_ADDR
This eBPF program is amazing! When an app calls connect, we can modify the socket information!
So for this first eBPF program, we want to achieve the following:
- Check if the app should be forwarded
- Retrieve & store the original destination address and port
- Modify the socket destination address and port to localhost:XXXX where XXXX will be the port we use for our proxy
Code
#include <linux/bpf.h>
#include <linux/in.h>
#include <bpf/bpf_endian.h>
#include <bpf/bpf_helpers.h>
#include <sys/socket.h>
// SECTION: PROGRAM STRUCTS DEFINITIONS
// Note: BPF maps use anonymous struct syntax (`struct { ... } name`)
// which defines the variable directly, while our data types like
// `Config` use named struct syntax (`struct Name { ... }`) so we can
// reference them elsewhere.
// Proxy configuration
// This allows us to:
// - Ignore connections made by the proxy itself
// - Redirect new connections to the proxy
struct Config {
__u8 proxy_ip[4];
__u16 proxy_port;
__u64 proxy_pid;
};
// Socket
// Allows us to store the 4-tuple that identifies a connection
struct OriginalDestination {
__u32 dst_addr;
__u16 dst_port;
};
// !SECTION
// SECTION: MAP DEFINITIONS
// NOTE: Maps are used to communicate information between the eBPF programs and user-space applications
// Configuration for the eBPF programs, only one entry is expected (maps_config[0])
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, __u32);
__type(value, struct Config);
} map_config SEC(".maps");
// map_original_destinations -- socket cookie -> original destination socket information
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_CONNECTIONS);
__type(key, __u64);
__type(value, struct OriginalDestination);
} map_original_destinations SEC(".maps");
// !SECTION
// SECTION: PROGRAMS DEFINITIONS
// LINK: cgroup/connect4
// This program intercepts new IPv4 connections and modifies the
// socket destination to point to the proxy instead.
SEC("cgroup/connect4")
int connect4_prog(struct bpf_sock_addr *ctx) {
// Make sure we're parsing IPv4 TCP connections only
if (ctx->family != AF_INET || ctx->protocol != IPPROTO_TCP)
return 1; // We return 1 because we don't want to block other connections
// Load the configuration from the map_config ebpf map
__u32 key = 0; // map_config[0] -> 0 is the only expected key
struct Config *config = bpf_map_lookup_elem(&map_config, &key);
if (!config)
return 1; // If we can't load the config, don't modify the connection
// Ignore connections made by the proxy itself to prevent infinite loops
// We perform a bitwise shift to get the TGID (PID) from the full |PID|TGID| value
// the TGID is used to identify all threads of a process
// Check more on why we use TGID here: https://stackoverflow.com/a/9306150
__u64 current_pid = bpf_get_current_pid_tgid() >> 32;
if (current_pid == config->proxy_pid)
return 1; // Don't modify the connection if it's made by the proxy
// Store the original destination information in the map_original_destinations ebpf map
struct OriginalDestination original_dst = {};
original_dst.dst_addr = ctx->user_ip4;
original_dst.dst_port = bpf_ntohs(ctx->user_port);
// Socket cookies are unique identifiers for sockets, this will come handy later
__u64 socket_cookie = bpf_get_socket_cookie(ctx);
bpf_map_update_elem(&map_original_destinations, &socket_cookie, &original_dst, BPF_ANY);
// Modify the connection destination to point to the proxy
ctx->user_ip4 = *((__u32 *)config->proxy_ip);
ctx->user_port = bpf_htons(config->proxy_port);
return 1; // Allow the connection to proceed
}
BPF_PROG_TYPE_SOCK_OPS
So far, we've managed to make any program communicate to the proxy directly instead of communicating to their original destination. Now, we need to be able to retrieve the original destination from the proxy to be able to, well, proxy, right?
To do this, we're going to listen to a specific point during the connection between the to-be-proxied-app and our proxy.
Using the BPF_PROG_TYPE_SOCK_OPS we can check after the connection with the proxy was established, something like this:
This BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB notification allows us to check what's the final port assigned to this connection and store this to be able to perform the following lookup:
Origin (to-be-proxied-app) port โ socket cookie โ original destination
If you check the previous section you'll notice that the socket cookie โ original destination is already there!
Code
Adding this extra first step is relatively easy, let's add the following to our proxy.c program:
// SECTION: MAP DEFINITIONS
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_CONNECTIONS);
__type(key, __u32);
__type(value, __u64);
} map_port_to_cookie SEC(".maps");
// !SECTION
// SECTION: PROGRAMS DEFINITIONS
// LINK: sockops
// This program intercepts when a TCP connection was established
// and stores into the map the assigned port -> socket cookie.
SEC("sockops")
int bpf_sockops(struct bpf_sock_ops *skops) {
if (skops->family != AF_INET) {
return 0;
}
// port -> cookie mapping
if (skops->op == BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB) {
// Retrieve the socket cookie, this is a unique identifier for the socket
__u64 cookie = bpf_get_socket_cookie(skops);
// Check if we have an original destination stored for this socket
struct OriginalDestination *orig_dst = bpf_map_lookup_elem(&map_original_destinations, &cookie);
if (!orig_dst) {
bpf_printk("No original destination found for cookie %llu\n", cookie);
return 0; // No original destination found, this is not a proxied connection we ignore it
}
// Store the mapping port -> cookie
__u32 src_port = skops->local_port;
bpf_map_update_elem(&map_port_to_cookie, &src_port, &cookie, 0);
bpf_printk("Stored port %d for cookie %llu\n", skops->local_port, cookie);
bpf_printk("Active established connection detected\n");
}
return 0;
}
Writing the Proxy
Before doing anything on the proxy, make sure you have your go files generated using the bpf2go library. If you don't know how to use it, go to the scaffolding section.
Now, the eBPF part is done. We need to build our user-space proxy so that we can receive incoming connections and then redirect to the original destination.
I wasn't sure how to divide this, so I tried to lay down everything with a single file that comprehends all the steps we need to perform!
Under main.go we'll write the following:
package main
import (
"encoding/binary"
"fmt"
"io"
"net"
"net/netip"
"os"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/rlimit"
)
const CGROUP_TO_ATTACH = "/sys/fs/cgroup/transparent_proxy"
//go:generate bpf2go -cc clang proxy proxy.c -- -I/usr/include/bpf -O2 -g -Wall
func main() {
// First, we gather the necessary information about our proxy.
// We need the PID of the process, the IP and the port where it is being served.
pid := os.Getpid()
server, err := net.Listen("tcp4", ":15001")
if err != nil {
panic(err)
}
defer server.Close()
// Remove memlock rlimit, this is required to load eBPF programs
if err := rlimit.RemoveMemlock(); err != nil {
panic(err)
}
// Here, we load the objects into the kernel, this will allow us to start
// populating our maps before attaching the programs to the hooks.
var objs proxyObjects
if err := loadProxyObjects(&objs, nil); err != nil {
panic(err)
}
defer objs.Close()
// First, we populate the config map with the proxy information.
addr := netip.MustParseAddrPort(server.Addr().String())
config := proxyConfig{
ProxyIp: addr.Addr().As4(),
ProxyPort: 15001,
ProxyPid: uint64(pid),
}
if err := objs.MapConfig.Put(uint32(0), config); err != nil {
panic(err)
}
// Now that the config is set up, we can attach the programs to the hooks.
connect4link, err := link.AttachCgroup(link.CgroupOptions{
Path: CGROUP_TO_ATTACH,
// This attach point represents the hook for IPv4 connect syscall
Attach: ebpf.AttachCGroupInet4Connect,
// And this is the reference to the loaded program in the kernel
Program: objs.Connect4Prog,
})
if err != nil {
panic(err)
}
// Always remember to close the link when we are done
defer connect4link.Close()
// We now load the sock opts program
sockoptslink, err := link.AttachCgroup(link.CgroupOptions{
Path: CGROUP_TO_ATTACH,
// This attach point lets us hook into socket operation events,
// like when a TCP connection is established
Attach: ebpf.AttachCGroupSockOps,
// And this is the reference to the loaded program in the kernel
Program: objs.BpfSockops,
})
if err != nil {
panic(err)
}
defer sockoptslink.Close()
// So far we have:
// 1. Started a TCP server that will act as our proxy
// 2. Loaded eBPF programs into the kernel
// 3. Populated the config map with the proxy information
// 4. Attached the eBPF programs to the cgroup hooks
// At this point, any process running inside the cgroup at CGROUP_TO_ATTACH
// that tries to connect to a remote IPv4 address will have its connection
// redirected to our proxy at the IP and port specified.
// Now, to handle the actual proxying, we need to accept connections on our server
// and forward the traffic to the intended destination.
for {
clientConn, err := server.Accept()
if err != nil {
panic(err)
}
// Handle the connection in a new goroutine
go func() {
defer clientConn.Close()
// Retrieve the port from which the client is connecting
// this port should've been mapped to the socket cookie
// during the sockops ACTIVE_ESTABLISHED_CB event.
port := clientConn.RemoteAddr().(*net.TCPAddr).Port
// Now we use the port to lookup the original destination
originalDst, err := getOriginalDst(&objs, uint32(port))
if err != nil {
panic(err)
}
// Some useful logs for later!
fmt.Println("Received connection from client:", clientConn.RemoteAddr())
fmt.Println("Original destination address:", originalDst.String())
// Once we have the original destination, we'll establish a connection
// and start proxying data between the client and the target and viceversa.
targetConn, err := net.Dial("tcp", originalDst.String())
if err != nil {
panic(err)
}
defer targetConn.Close()
// Start copying data in both directions
go func() {
if _, err := io.Copy(targetConn, clientConn); err != nil {
fmt.Printf("Error copying from client to target: %v\n", err)
}
}()
if _, err := io.Copy(clientConn, targetConn); err != nil {
fmt.Printf("Error copying from target to client: %v\n", err)
}
}()
}
}
// getOriginalDst looks up the original destination address and port
// for a given proxy port using the eBPF maps that we defined earlier.
func getOriginalDst(objs *proxyObjects, port uint32) (*netip.AddrPort, error) {
var cookie uint64
if err := objs.MapPortToCookie.Lookup(&port, &cookie); err != nil {
return nil, fmt.Errorf("failed to lookup port %d in map_port_to_cookie: %w", port, err)
}
var connInfo proxyOriginalDestination
if err := objs.MapOriginalDestinations.Lookup(&cookie, &connInfo); err != nil {
return nil, fmt.Errorf("failed to lookup cookie %d in map_original_destinations: %w", cookie, err)
}
var ipBytes [4]byte
binary.NativeEndian.PutUint32(ipBytes[:], connInfo.DstAddr)
addr := netip.AddrFrom4(ipBytes)
addrPort := netip.AddrPortFrom(addr, connInfo.DstPort)
return &addrPort, nil
}
Testing
Now we have everything ready and setup to try this proxy out!
Let's follow the next steps, make sure to have two terminal sessions open:
1. [Session 1] โ Generate and build the program
go generate ./... && go build .
2. [Session 1] โ Create the cgroup transparent_proxy
sudo mkdir /sys/fs/cgroup/transparent_proxy
3. [Session 1] โ Execute our program
sudo ./transparent_proxy
4. [Session 2] โ Make a request from a process in the cgroup
sudo sh -c 'echo $$ > /sys/fs/cgroup/transparent_proxy/cgroup.procs && exec curl example.com'
In [Session 1] you should see some logs that will look something like:
Received connection from client: 127.0.0.1:34286
Original destination address: 188.114.97.5:80
And in your [Session 2] you should see the response from your request!
<!doctype html><html lang="en"><head><title>Example Domain</title>...</html>
Homework
You may have noticed that this is not proxying the DNS requests made by curl. This is because we're only capturing the connect() calls and more specifically for TCP.
Now, even though this is going to be covered by the next version of this tutorial, I challenge you to take a hit at this!
Wrapping Up
I loved making my own version of Teodor Podobnik's own version Transparent Proxy Implementation using eBPF and Go mainly as it allowed me to learn a lot of the basics and the "why's" behind Teodor's approach. If there's anything that you would like to see an "in-depth" from in these series feel free to reach out!
Even though this tutorial is really similar, I'm going to keep updating this same codebase to achieve a fully working SOCKS5 proxy that works on top of Kubernetes to redirect all the traffic made by deployed applications, so stay tuned!
And finally, if you got here, thanks! This is my very first "official blog", so I appreciate you taking the time to read until the end! See you next time!
Sources & Inspiration
- Transparent Proxy Implementation using eBPF and Go
- Linux Container Primitives: cgroups, namespaces, and more!
- Linux Kernel Docs: Cgroup v2
- eBPF docs: Program Type BPF_PROG_TYPE_SOCK_OPS
- eBPF docs: Program Type BPF_PROG_TYPE_CGROUP_SOCK_ADDR
- eBPF-Go Docs
Full Code Reference
proxy.c
//go:build ignore
#include <linux/bpf.h>
#include <linux/in.h>
#include <bpf/bpf_endian.h>
#include <bpf/bpf_helpers.h>
#include <sys/socket.h>
#define MAX_CONNECTIONS 20000
// SECTION: PROGRAM STRUCTS DEFINITIONS
// Note: BPF maps use anonymous struct syntax (`struct { ... } name`)
// which defines the variable directly, while our data types like
// `Config` use named struct syntax (`struct Name { ... }`) so we can
// reference them elsewhere.
// Proxy configuration
// This allows us to:
// - Ignore connections made by the proxy itself
// - Redirect new connections to the proxy
struct Config {
__u8 proxy_ip[4];
__u16 proxy_port;
__u64 proxy_pid;
};
// Socket
// Allows us to store the 4-tuple that identifies a connection
struct OriginalDestination {
__u32 dst_addr;
__u16 dst_port;
};
// !SECTION
// SECTION: MAP DEFINITIONS
// NOTE: Maps are used to communicate information between the eBPF programs and user-space applications
// Configuration for the eBPF programs, only one entry is expected (maps_config[0])
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, __u32);
__type(value, struct Config);
} map_config SEC(".maps");
// map_original_destinations -- socket cookie -> original destination socket information
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_CONNECTIONS);
__type(key, __u64);
__type(value, struct OriginalDestination);
} map_original_destinations SEC(".maps");
// !SECTION
// SECTION: PROGRAMS DEFINITIONS
// LINK: cgroup/connect4
// This program intercepts new IPv4 connections and modifies the
// socket destination to point to the proxy instead.
SEC("cgroup/connect4")
int connect4_prog(struct bpf_sock_addr *ctx) {
// Make sure we're parsing IPv4 TCP connections only
if (ctx->family != AF_INET || ctx->protocol != IPPROTO_TCP)
return 1; // We return 1 because we don't want to block other connections
// Load the configuration from the map_config ebpf map
__u32 key = 0; // map_config[0] -> 0 is the only expected key
struct Config *config = bpf_map_lookup_elem(&map_config, &key);
if (!config)
return 1; // If we can't load the config, don't modify the connection
// Ignore connections made by the proxy itself to prevent infinite loops
// We perform a bitwise shift to get the TGID (PID) from the full |PID|TGID| value
// the TGID is used to identify all threads of a process
// Check more on why we use TGID here: https://stackoverflow.com/a/9306150
__u64 current_pid = bpf_get_current_pid_tgid() >> 32;
if (current_pid == config->proxy_pid)
return 1; // Don't modify the connection if it's made by the proxy
// Store the original destination information in the map_original_destinations ebpf map
struct OriginalDestination original_dst = {};
original_dst.dst_addr = ctx->user_ip4;
original_dst.dst_port = bpf_ntohs(ctx->user_port);
// Socket cookies are unique identifiers for sockets, this will come handy later
__u64 socket_cookie = bpf_get_socket_cookie(ctx);
bpf_map_update_elem(&map_original_destinations, &socket_cookie, &original_dst, BPF_ANY);
// Modify the connection destination to point to the proxy
ctx->user_ip4 = *((__u32 *)config->proxy_ip);
ctx->user_port = bpf_htons(config->proxy_port);
return 1; // Allow the connection to proceed
}
// SECTION: MAP DEFINITIONS
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_CONNECTIONS);
__type(key, __u32);
__type(value, __u64);
} map_port_to_cookie SEC(".maps");
// !SECTION
// SECTION: PROGRAMS DEFINITIONS
// LINK: sockops
// This program intercepts when a TCP connection was established
// and stores into the map the assigned port -> socket cookie.
SEC("sockops")
int bpf_sockops(struct bpf_sock_ops *skops) {
if (skops->family != AF_INET) {
return 0;
}
// port -> cookie mapping
if (skops->op == BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB) {
// Retrieve the socket cookie, this is a unique identifier for the socket
__u64 cookie = bpf_get_socket_cookie(skops);
// Check if we have an original destination stored for this socket
struct OriginalDestination *orig_dst = bpf_map_lookup_elem(&map_original_destinations, &cookie);
if (!orig_dst) {
bpf_printk("No original destination found for cookie %llu\n", cookie);
return 0; // No original destination found, this is not a proxied connection we ignore it
}
// Store the mapping port -> cookie
__u32 src_port = skops->local_port;
bpf_map_update_elem(&map_port_to_cookie, &src_port, &cookie, 0);
bpf_printk("Stored port %d for cookie %llu\n", skops->local_port, cookie);
bpf_printk("Active established connection detected\n");
}
return 0;
}
char LICENSE[] SEC("license") = "GPL";
main.go
package main
import (
"encoding/binary"
"fmt"
"io"
"net"
"net/netip"
"os"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/rlimit"
)
const CGROUP_TO_ATTACH = "/sys/fs/cgroup/transparent_proxy"
//go:generate bpf2go -cc clang proxy proxy.c -- -I/usr/include/bpf -O2 -g -Wall
func main() {
// First, we gather the necessary information about our proxy.
// We need the PID of the process, the IP and the port where it is being served.
pid := os.Getpid()
server, err := net.Listen("tcp4", ":15001")
if err != nil {
panic(err)
}
defer server.Close()
// Remove memlock rlimit, this is required to load eBPF programs
if err := rlimit.RemoveMemlock(); err != nil {
panic(err)
}
// Here, we load the objects into the kernel, this will allow us to start
// populating our maps before attaching the programs to the hooks.
var objs proxyObjects
if err := loadProxyObjects(&objs, nil); err != nil {
panic(err)
}
defer objs.Close()
// First, we populate the config map with the proxy information.
addr := netip.MustParseAddrPort(server.Addr().String())
config := proxyConfig{
ProxyIp: addr.Addr().As4(),
ProxyPort: 15001,
ProxyPid: uint64(pid),
}
if err := objs.MapConfig.Put(uint32(0), config); err != nil {
panic(err)
}
// Now that the config is set up, we can attach the programs to the hooks.
connect4link, err := link.AttachCgroup(link.CgroupOptions{
Path: CGROUP_TO_ATTACH,
// This attach point represents the hook for IPv4 connect syscall
Attach: ebpf.AttachCGroupInet4Connect,
// And this is the reference to the loaded program in the kernel
Program: objs.Connect4Prog,
})
if err != nil {
panic(err)
}
// Always remember to close the link when we are done
defer connect4link.Close()
// We now load the sock opts program
sockoptslink, err := link.AttachCgroup(link.CgroupOptions{
Path: CGROUP_TO_ATTACH,
// This attach point lets us hook into socket operation events,
// like when a TCP connection is established
Attach: ebpf.AttachCGroupSockOps,
// And this is the reference to the loaded program in the kernel
Program: objs.BpfSockops,
})
if err != nil {
panic(err)
}
defer sockoptslink.Close()
// So far we have:
// 1. Started a TCP server that will act as our proxy
// 2. Loaded eBPF programs into the kernel
// 3. Populated the config map with the proxy information
// 4. Attached the eBPF programs to the cgroup hooks
// At this point, any process running inside the cgroup at CGROUP_TO_ATTACH
// that tries to connect to a remote IPv4 address will have its connection
// redirected to our proxy at the IP and port specified.
// Now, to handle the actual proxying, we need to accept connections on our server
// and forward the traffic to the intended destination.
for {
clientConn, err := server.Accept()
if err != nil {
panic(err)
}
// Handle the connection in a new goroutine
go func() {
defer clientConn.Close()
// Retrieve the port from which the client is connecting
// this port should've been mapped to the socket cookie
// during the sockops ACTIVE_ESTABLISHED_CB event.
port := clientConn.RemoteAddr().(*net.TCPAddr).Port
// Now we use the port to lookup the original destination
originalDst, err := getOriginalDst(&objs, uint32(port))
if err != nil {
panic(err)
}
// Some useful logs for later!
fmt.Println("Received connection from client:", clientConn.RemoteAddr())
fmt.Println("Original destination address:", originalDst.String())
// Once we have the original destination, we'll establish a connection
// and start proxying data between the client and the target and viceversa.
targetConn, err := net.Dial("tcp", originalDst.String())
if err != nil {
panic(err)
}
defer targetConn.Close()
// Start copying data in both directions
go func() {
if _, err := io.Copy(targetConn, clientConn); err != nil {
fmt.Printf("Error copying from client to target: %v\n", err)
}
}()
if _, err := io.Copy(clientConn, targetConn); err != nil {
fmt.Printf("Error copying from target to client: %v\n", err)
}
}()
}
}
// getOriginalDst looks up the original destination address and port
// for a given proxy port using the eBPF maps that we defined earlier.
func getOriginalDst(objs *proxyObjects, port uint32) (*netip.AddrPort, error) {
var cookie uint64
if err := objs.MapPortToCookie.Lookup(&port, &cookie); err != nil {
return nil, fmt.Errorf("failed to lookup port %d in map_port_to_cookie: %w", port, err)
}
var connInfo proxyOriginalDestination
if err := objs.MapOriginalDestinations.Lookup(&cookie, &connInfo); err != nil {
return nil, fmt.Errorf("failed to lookup cookie %d in map_original_destinations: %w", cookie, err)
}
var ipBytes [4]byte
binary.NativeEndian.PutUint32(ipBytes[:], connInfo.DstAddr)
addr := netip.AddrFrom4(ipBytes)
addrPort := netip.AddrPortFrom(addr, connInfo.DstPort)
return &addrPort, nil
}