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_OPS which is supported since kernel version v4.13
  • BPF_PROG_TYPE_CGROUP_SOCK_ADDR which 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:

Terminal bash
sudo apt install clang llvm libbpf-dev linux-headers-$(uname -r)

On Arch:

Terminal bash
sudo pacman -S clang llvm libbpf linux-headers

You'll also need the bpf2go tool from cilium/ebpf:

Terminal bash
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!
Terminal bash
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):

proxy.c 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

// ... 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):

main.go go
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:

Terminal bash
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

proxy.c c
#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:

sequenceDiagram participant Client participant Proxy Note over Client: connect(real:443) Note over Client: [cgroup/connect4]<br/>rewrite โ†’ proxy:1080 Client->>Proxy: SYN Proxy->>Client: SYN-ACK Note over Client: ACTIVE_ESTABLISHED<br/>(map updated!) Client->>Proxy: ACK

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:

proxy.c c
// 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:

main.go 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
}

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

Terminal bash
go generate ./... && go build .

2. [Session 1] โ†’ Create the cgroup transparent_proxy

Terminal bash
sudo mkdir /sys/fs/cgroup/transparent_proxy

3. [Session 1] โ†’ Execute our program

Terminal bash
sudo ./transparent_proxy

4. [Session 2] โ†’ Make a request from a process in the cgroup

Terminal bash
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

Full Code Reference

proxy.c

proxy.c 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

main.go 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
}