implement decision logic

This commit is contained in:
Sahilb315
2026-07-27 13:51:09 +05:30
parent 70e2d34a48
commit 05a484ea5e
8 changed files with 335 additions and 85 deletions
+33 -15
View File
@@ -14,23 +14,33 @@ import (
)
type bpfEvent struct {
_ structs.HostLayout
Pid uint32
Uid uint32
Daddr uint32
Dport uint16
Proto uint8
Comm [16]uint8
_ [1]byte
_ structs.HostLayout
Pid uint32
Uid uint32
Daddr uint32
Dport uint16
Proto uint8
Action uint8
Comm [16]uint8
}
type bpfTarget struct {
_ structs.HostLayout
Ip uint32
Port uint16
_ [2]byte
}
// Names of all BPF objects in the ELF.
//
// Used for safe lookups in a Collection or CollectionSpec.
const (
bpfMapEvents = "events"
bpfProgConnect4 = "connect4"
bpfVarUnusedEvent = "unused_event"
bpfMapEvents = "events"
bpfMapExemptMap = "exempt_map"
bpfMapTargetMap = "target_map"
bpfProgConnect4 = "connect4"
bpfVarUnusedEvent = "unused_event"
bpfVarUnusedTarget = "unused_target"
)
// loadBpf returns the embedded CollectionSpec for bpf.
@@ -82,14 +92,17 @@ type bpfProgramSpecs struct {
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfMapSpecs struct {
Events *ebpf.MapSpec `ebpf:"events"`
Events *ebpf.MapSpec `ebpf:"events"`
ExemptMap *ebpf.MapSpec `ebpf:"exempt_map"`
TargetMap *ebpf.MapSpec `ebpf:"target_map"`
}
// bpfVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfVariableSpecs struct {
UnusedEvent *ebpf.VariableSpec `ebpf:"unused_event"`
UnusedEvent *ebpf.VariableSpec `ebpf:"unused_event"`
UnusedTarget *ebpf.VariableSpec `ebpf:"unused_target"`
}
// bpfObjects contains all objects after they have been loaded into the kernel.
@@ -112,12 +125,16 @@ func (o *bpfObjects) Close() error {
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfMaps struct {
Events *ebpf.Map `ebpf:"events"`
Events *ebpf.Map `ebpf:"events"`
ExemptMap *ebpf.Map `ebpf:"exempt_map"`
TargetMap *ebpf.Map `ebpf:"target_map"`
}
func (m *bpfMaps) Close() error {
return _BpfClose(
m.Events,
m.ExemptMap,
m.TargetMap,
)
}
@@ -125,7 +142,8 @@ func (m *bpfMaps) Close() error {
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfVariables struct {
UnusedEvent *ebpf.Variable `ebpf:"unused_event"`
UnusedEvent *ebpf.Variable `ebpf:"unused_event"`
UnusedTarget *ebpf.Variable `ebpf:"unused_target"`
}
// bpfPrograms contains all programs after they have been loaded into the kernel.
Binary file not shown.
+33 -15
View File
@@ -14,23 +14,33 @@ import (
)
type bpfEvent struct {
_ structs.HostLayout
Pid uint32
Uid uint32
Daddr uint32
Dport uint16
Proto uint8
Comm [16]uint8
_ [1]byte
_ structs.HostLayout
Pid uint32
Uid uint32
Daddr uint32
Dport uint16
Proto uint8
Action uint8
Comm [16]uint8
}
type bpfTarget struct {
_ structs.HostLayout
Ip uint32
Port uint16
_ [2]byte
}
// Names of all BPF objects in the ELF.
//
// Used for safe lookups in a Collection or CollectionSpec.
const (
bpfMapEvents = "events"
bpfProgConnect4 = "connect4"
bpfVarUnusedEvent = "unused_event"
bpfMapEvents = "events"
bpfMapExemptMap = "exempt_map"
bpfMapTargetMap = "target_map"
bpfProgConnect4 = "connect4"
bpfVarUnusedEvent = "unused_event"
bpfVarUnusedTarget = "unused_target"
)
// loadBpf returns the embedded CollectionSpec for bpf.
@@ -82,14 +92,17 @@ type bpfProgramSpecs struct {
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfMapSpecs struct {
Events *ebpf.MapSpec `ebpf:"events"`
Events *ebpf.MapSpec `ebpf:"events"`
ExemptMap *ebpf.MapSpec `ebpf:"exempt_map"`
TargetMap *ebpf.MapSpec `ebpf:"target_map"`
}
// bpfVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfVariableSpecs struct {
UnusedEvent *ebpf.VariableSpec `ebpf:"unused_event"`
UnusedEvent *ebpf.VariableSpec `ebpf:"unused_event"`
UnusedTarget *ebpf.VariableSpec `ebpf:"unused_target"`
}
// bpfObjects contains all objects after they have been loaded into the kernel.
@@ -112,12 +125,16 @@ func (o *bpfObjects) Close() error {
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfMaps struct {
Events *ebpf.Map `ebpf:"events"`
Events *ebpf.Map `ebpf:"events"`
ExemptMap *ebpf.Map `ebpf:"exempt_map"`
TargetMap *ebpf.Map `ebpf:"target_map"`
}
func (m *bpfMaps) Close() error {
return _BpfClose(
m.Events,
m.ExemptMap,
m.TargetMap,
)
}
@@ -125,7 +142,8 @@ func (m *bpfMaps) Close() error {
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfVariables struct {
UnusedEvent *ebpf.Variable `ebpf:"unused_event"`
UnusedEvent *ebpf.Variable `ebpf:"unused_event"`
UnusedTarget *ebpf.Variable `ebpf:"unused_target"`
}
// bpfPrograms contains all programs after they have been loaded into the kernel.
Binary file not shown.
+83 -10
View File
@@ -5,35 +5,108 @@
char __license[] SEC("license") = "Dual MIT/GPL";
#define IPPROTO_TCP 6
// Only HTTPS is a candidate for redirect. Every other destination port is left
// alone, otherwise ssh, dns, postgres and everything else on the host would be
// steered into a proxy that cannot speak their protocol.
#define REDIRECT_DPORT 443
// What the hook decided. Recorded on every event so the ladder can be checked
// against real traffic before any rewrite is switched on.
#define ACTION_REDIRECT 0
#define ACTION_SKIP_PROTO 1
#define ACTION_SKIP_LOOPBACK 2
#define ACTION_SKIP_EXEMPT 3
#define ACTION_SKIP_DPORT 4
#define ACTION_SKIP_NOTARGET 5
struct event {
__u32 pid;
__u32 uid;
__u32 daddr;
__u16 dport;
__u8 proto;
__u8 action;
__u8 comm[16]; // command
};
struct event *unused_event __attribute__((unused));
// target is the redirect destination, written from userspace once the proxy is
// listening. Keeping it in a map means a proxy restart on a different port is
// one map update rather than a reload of the program.
struct target {
__u32 ip; // network byte order, same layout as ctx->user_ip4
__u16 port; // host byte order, converted when written back to ctx
};
struct target *unused_target __attribute__((unused));
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, __u32);
__type(value, struct target);
} target_map SEC(".maps");
// Uids whose traffic is never redirected. The proxy's own upstream fetches live
// here. Without this the rewrite sends the proxy back into itself forever.
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8);
__type(key, __u32);
__type(value, __u8);
} exempt_map SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} events SEC(".maps");
static __always_inline __u8 decide(struct bpf_sock_addr *ctx, __u32 uid, __u16 dport) {
if (ctx->protocol != IPPROTO_TCP)
return ACTION_SKIP_PROTO;
// Loopback is never registry traffic, and this also covers clients already
// configured to reach the proxy directly.
if ((bpf_ntohl(ctx->user_ip4) >> 24) == 127)
return ACTION_SKIP_LOOPBACK;
if (bpf_map_lookup_elem(&exempt_map, &uid))
return ACTION_SKIP_EXEMPT;
if (dport != REDIRECT_DPORT)
return ACTION_SKIP_DPORT;
__u32 key = 0;
struct target *t = bpf_map_lookup_elem(&target_map, &key);
if (!t || t->port == 0)
return ACTION_SKIP_NOTARGET;
return ACTION_REDIRECT;
}
SEC("cgroup/connect4")
int connect4(struct bpf_sock_addr *ctx) {
__u32 uid = bpf_get_current_uid_gid();
__u16 dport = bpf_ntohs(ctx->user_port);
__u8 action = decide(ctx, uid, dport);
// A full ring buffer costs an event, never a change in behaviour.
struct event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e)
return 1;
if (e) {
e->pid = bpf_get_current_pid_tgid() >> 32;
e->uid = uid;
e->daddr = ctx->user_ip4;
e->dport = dport;
e->proto = ctx->protocol;
e->action = action;
bpf_get_current_comm(&e->comm, sizeof(e->comm));
bpf_ringbuf_submit(e, 0);
}
e->pid = bpf_get_current_pid_tgid() >> 32;
e->uid = bpf_get_current_uid_gid();
e->daddr = ctx->user_ip4;
e->dport = bpf_ntohs(ctx->user_port);
e-> proto = ctx->protocol;
bpf_get_current_comm(&e->comm, sizeof(e->comm));
bpf_ringbuf_submit(e, 0);
// Shadow mode. The decision is recorded and never acted on. Turning
// ACTION_REDIRECT into a rewrite of user_ip4 and user_port is the next step.
return 1;
}
+3 -4
View File
@@ -2,7 +2,6 @@ module ebpf-poc
go 1.25.0
require (
github.com/cilium/ebpf v0.22.0 // indirect
golang.org/x/sys v0.43.0 // indirect
)
require github.com/cilium/ebpf v0.22.0
require golang.org/x/sys v0.43.0 // indirect
+22
View File
@@ -1,4 +1,26 @@
github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY=
github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM=
github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+161 -41
View File
@@ -1,4 +1,4 @@
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -type event bpf connect.c -- -I/usr/include/aarch64-linux-gnu
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -type event -type target bpf connect.c -- -I/usr/include/aarch64-linux-gnu
package main
@@ -7,10 +7,13 @@ import (
"context"
"encoding/binary"
"errors"
"flag"
"fmt"
"net"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"github.com/cilium/ebpf"
@@ -19,46 +22,65 @@ import (
"github.com/cilium/ebpf/rlimit"
)
// Mirrors the ACTION_* constants in connect.c.
var actionNames = map[uint8]string{
0: "REDIRECT",
1: "skip/proto",
2: "skip/loopback",
3: "skip/exempt",
4: "skip/dport",
5: "skip/no-target",
}
func main() {
err := rlimit.RemoveMemlock()
if err != nil {
fmt.Printf("Failed to remove mem lock: %v\n", err)
return
proxyFlag := flag.String("proxy", "", "redirect target as host:port, e.g. 127.0.0.1:8443")
exemptFlag := flag.String("exempt-uid", "", "comma separated uids that are never redirected")
tcpOnlyFlag := flag.Bool("tcp-only", false, "only print TCP events, hiding DNS and route probe noise")
flag.Parse()
if err := run(*proxyFlag, *exemptFlag, *tcpOnlyFlag); err != nil {
fmt.Fprintf(os.Stderr, "pmgwatch: %v\n", err)
os.Exit(1)
}
}
func run(proxyAddr, exemptUIDs string, tcpOnly bool) error {
if err := rlimit.RemoveMemlock(); err != nil {
return fmt.Errorf("remove memlock: %w", err)
}
var objs bpfObjects
err = loadBpfObjects(&objs, nil)
if err != nil {
fmt.Printf("Failed to load bpf objects: %v\n", err)
return
if err := loadBpfObjects(&objs, nil); err != nil {
return fmt.Errorf("load bpf objects: %w", err)
}
defer objs.Close()
rootCgroup := "/sys/fs/cgroup"
if err := configureTarget(&objs, proxyAddr); err != nil {
return err
}
if err := configureExempt(&objs, exemptUIDs); err != nil {
return err
}
l, err := link.AttachCgroup(link.CgroupOptions{
Path: rootCgroup,
Path: "/sys/fs/cgroup",
Attach: ebpf.AttachCGroupInet4Connect,
Program: objs.Connect4,
})
if err != nil {
fmt.Printf("Failed to attach cgroup: %v\n", err)
return
return fmt.Errorf("attach cgroup: %w", err)
}
defer l.Close()
rd, err := ringbuf.NewReader(objs.Events)
if err != nil {
fmt.Printf("Failed to create ringbuf reader: %v\n", err)
return
return fmt.Errorf("open ringbuf: %w", err)
}
defer rd.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() // Cleans up resources allocated by the signal package
fmt.Println("Application started. Press Ctrl+C to exit.")
defer stop()
go func() {
<-ctx.Done()
@@ -66,41 +88,139 @@ func main() {
rd.Close()
}()
fmt.Println("Shadow mode: decisions are recorded, nothing is redirected. Ctrl+C to exit.")
fmt.Printf("%-15s %-17s %-7s %-8s %-22s %s\n", "ACTION", "COMMAND", "UID", "PID", "DESTINATION", "PROTO")
return drain(rd, tcpOnly)
}
func drain(rd *ringbuf.Reader, tcpOnly bool) error {
var e bpfEvent
for {
rec, err := rd.Read()
if err != nil {
if errors.Is(err, ringbuf.ErrClosed) {
return
return nil
}
fmt.Printf("failed to read raw event: %v\n", err)
fmt.Fprintf(os.Stderr, "read event: %v\n", err)
continue
}
err = binary.Read(bytes.NewReader(rec.RawSample), binary.LittleEndian, &e)
if err != nil {
fmt.Printf("failed to read event: %v\n", err)
if err := binary.Read(bytes.NewReader(rec.RawSample), binary.LittleEndian, &e); err != nil {
fmt.Fprintf(os.Stderr, "decode event: %v\n", err)
continue
}
ip := make(net.IP, 4)
binary.LittleEndian.PutUint32(ip, e.Daddr)
name := e.Comm[:]
if i := bytes.IndexByte(name, 0); i != -1 {
name = name[:i] // keep bytes before the first NUL
}
comm := string(name)
proto := ""
switch e.Proto {
case syscall.IPPROTO_TCP:
proto = "TCP"
case syscall.IPPROTO_UDP:
proto = "UDP"
if tcpOnly && e.Proto != syscall.IPPROTO_TCP {
continue
}
fmt.Println()
fmt.Printf("PID: %v\nUID: %v\ndAddr: %v\ndPort: %v\nProto: %v\nCommand: %v\n", e.Pid, e.Uid, ip, e.Dport, proto, comm)
printEvent(&e)
}
}
func printEvent(e *bpfEvent) {
ip := make(net.IP, 4)
binary.LittleEndian.PutUint32(ip, e.Daddr)
action, ok := actionNames[e.Action]
if !ok {
action = fmt.Sprintf("action/%d", e.Action)
}
fmt.Printf("%-15s %-17s %-7d %-8d %-22s %s\n",
action, comm(e.Comm[:]), e.Uid, e.Pid,
net.JoinHostPort(ip.String(), strconv.Itoa(int(e.Dport))), proto(e.Proto))
}
// comm is a fixed 16 byte buffer, NUL terminated when the name is shorter.
func comm(raw []byte) string {
if i := bytes.IndexByte(raw, 0); i != -1 {
raw = raw[:i]
}
return string(raw)
}
func proto(p uint8) string {
switch p {
case syscall.IPPROTO_TCP:
return "TCP"
case syscall.IPPROTO_UDP:
return "UDP"
default:
return strconv.Itoa(int(p))
}
}
// configureTarget writes the redirect destination the hook will use. Leaving it
// unset is valid: every otherwise eligible connection then reports
// skip/no-target, which is how shadow mode runs before a proxy exists.
func configureTarget(objs *bpfObjects, addr string) error {
if addr == "" {
fmt.Println("No redirect target set, eligible connections will report skip/no-target")
return nil
}
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("parse proxy address %q: %w", addr, err)
}
ip := net.ParseIP(host)
if ip == nil || ip.To4() == nil {
return fmt.Errorf("proxy address %q must be an IPv4 address", addr)
}
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return fmt.Errorf("parse proxy port %q: %w", portStr, err)
}
// The kernel holds the address in network byte order, which is what reading
// the four octets little endian reproduces.
target := bpfTarget{
Ip: binary.LittleEndian.Uint32(ip.To4()),
Port: uint16(port),
}
if err := objs.TargetMap.Put(uint32(0), target); err != nil {
return fmt.Errorf("write redirect target: %w", err)
}
fmt.Printf("Redirect target: %s\n", addr)
return nil
}
func configureExempt(objs *bpfObjects, list string) error {
if list == "" {
fmt.Println("No exempt uids set, nothing bypasses the ladder")
return nil
}
var applied []string
for _, field := range strings.Split(list, ",") {
field = strings.TrimSpace(field)
if field == "" {
continue
}
uid, err := strconv.ParseUint(field, 10, 32)
if err != nil {
return fmt.Errorf("parse exempt uid %q: %w", field, err)
}
if err := objs.ExemptMap.Put(uint32(uid), uint8(1)); err != nil {
return fmt.Errorf("write exempt uid %d: %w", uid, err)
}
applied = append(applied, field)
}
fmt.Printf("Exempt uids: %s\n", strings.Join(applied, ", "))
return nil
}