https://github.com/dotchance/rudder
Rudder is a CLI tool for eBPF-based packet steering and multicast replication on Linux. It attaches eBPF programs to the TC (Traffic Control) ingress hook, letting you define YAML rules that match packets by interface, DSCP value, source/destination IP prefix, and protocol — then rewrite headers and redirect traffic across interfaces at wire speed in the kernel.
Two policy types are supported:
- Steer — Match ingress packets by DSCP, IP prefix, and protocol. Rewrite the destination IP and MAC, then redirect to a chosen egress interface. Useful for policy-based routing, traffic engineering, and DSCP-driven path selection.
- Replicate — Match multicast packets and fan them out as unicast copies to multiple egress interfaces, each with its own rewritten destination IP and MAC. Useful for multicast-to-unicast conversion across multiple downstream paths.
YAML rules
|
v
+---------------+
| Python engine | Compiles eBPF C with clang
| (engine/) | Attaches programs via `tc`
+-------+-------+ Populates BPF maps via `bpftool`
|
+----------+----------+
| |
ebpf/steer.c ebpf/replicate.c
| |
v v
TC ingress hook TC ingress hook
(per interface) (per interface)
| |
v v
Match + rewrite IP Match multicast dst
+ redirect to egress + clone to N unicast
destinations
When you run rudder load, the engine:
- Parses and validates YAML rule files
- Compiles
ebpf/steer.candebpf/replicate.cwith clang to BPF object files - Attaches both programs to TC ingress on each referenced interface via
tc filter add - Pins BPF maps to
/sys/fs/bpf/rudder/for userspace access - Serializes rules into the BPF array maps using
bpftool - Forks a background daemon that holds state and serves CLI queries
The eBPF programs run in-kernel. On each ingress packet they iterate the rule array, match fields, rewrite the IP and Ethernet headers, fix checksums, and call bpf_redirect() (steer) or bpf_clone_redirect() (replicate).
- Linux kernel 5.15 or later (required for bounded loops in BPF)
- Root privileges (eBPF and TC attachment require CAP_SYS_ADMIN)
- x86_64 architecture
Install on Ubuntu/Debian:
sudo apt-get install -y \
libbpf-dev \
linux-headers-$(uname -r) \
linux-tools-generic \
clang \
llvm \
iproute2 \
tcpdumplinux-tools-generic provides bpftool, which rudder uses to pin and populate BPF maps. tcpdump is optional but invaluable for verifying redirected packets on egress interfaces.
pip3 install -r requirements.txtThis installs click (CLI framework), PyYAML (rule parsing), pyroute2 (ARP neighbor table lookup), and scapy (test packet generation).
The Python engine compiles the eBPF programs automatically during rudder load, but you can also compile them manually to check for errors:
# Compile the steer program
clang -O2 -g -target bpf \
-I/usr/include \
-I/usr/include/x86_64-linux-gnu \
-c ebpf/steer.c -o /tmp/rudder_steer.o
# Compile the replicate program
clang -O2 -g -target bpf \
-I/usr/include \
-I/usr/include/x86_64-linux-gnu \
-c ebpf/replicate.c -o /tmp/rudder_replicate.oBoth commands should complete with zero warnings. If you see verifier-related errors when the program is loaded by tc, check that your kernel is 5.15 or later — earlier kernels may not support the bounded loop iteration pattern used to walk the rule array.
You can inspect the compiled objects with llvm-objdump:
llvm-objdump -d /tmp/rudder_steer.o # Disassemble BPF instructions
llvm-objdump -h /tmp/rudder_steer.o # Show sections (should include classifier and .maps)Rules are defined in YAML files under a top-level rules key. Multiple files can be loaded simultaneously — all rules are merged, sorted by priority, and validated as a single set.
| Field | Required | Description |
|---|---|---|
name |
yes | Unique human-readable label |
priority |
yes | Integer evaluation order (lower = first). Must be unique across all files. |
type |
yes | steer or replicate |
match.interface |
yes | Ingress interface name (e.g. eth0) or any |
match.src_ip |
no | Source IP or CIDR prefix (e.g. 10.1.0.0/16). Omit to match any. |
match.dst_ip |
no | Destination IP or CIDR prefix. Omit to match any. |
match.dscp |
no | DSCP value 0-63 (the 6-bit field, not the full TOS byte). Omit to match any. |
match.ip_proto |
no | tcp, udp, or any (default: any) |
action.dst_ip |
steer | Rewrite destination IP to this exact address |
action.via |
steer | Egress interface name |
action.next_hop_mac |
no | Static next-hop MAC (aa:bb:cc:dd:ee:ff). If omitted, resolved from ARP table. |
action.targets |
replicate | List of 1-12 replication targets, each with dst_ip, via, and optional next_hop_mac |
Route all EF-marked traffic (DSCP 46) destined for 10.0.0.0/8 arriving on eth0 to 192.168.100.1 via eth2:
rules:
- name: ef-to-path-a
priority: 10
type: steer
match:
interface: eth0
dscp: 46
dst_ip: 10.0.0.0/8
action:
dst_ip: 192.168.100.1
via: eth2Take any multicast packet to 239.1.1.1 on any interface, and deliver unicast copies to three destinations:
rules:
- name: mcast-replicate-stream
priority: 20
type: replicate
match:
interface: any
dst_ip: 239.1.1.1
action:
targets:
- dst_ip: 10.10.1.1
via: eth1
- dst_ip: 10.10.2.1
via: eth2
- dst_ip: 10.10.3.1
via: eth3You can split rules across files and load them together. Priorities and names must be unique across all files:
sudo python3 rudder.py load rules/steering.yaml rules/replication.yaml rules/overrides.yamlAll commands require root.
Parse rule files, compile eBPF programs, attach TC hooks, populate maps, and start the background daemon:
sudo python3 rudder.py load rules/example_steer.yamlLoading rules from: rules/example_steer.yaml
[ok] ef-to-path-a priority=10 type=steer interface=eth0
Attaching TC hooks:
[ok] eth0 ingress
[ok] eth2 ingress
Rudder running. 1 rule active (1 steer, 0 replicate). Daemon PID: 4821
Load both steer and replicate rules at once:
sudo python3 rudder.py load rules/example_steer.yaml rules/example_replicate.yamlDisplay the active rule table:
sudo python3 rudder.py show rulesPRI NAME TYPE INTERFACE MATCH ACTION
10 ef-to-path-a steer eth0 dscp=46 dst=10.0.0.0/8 via=eth2 -> 192.168.100.1
20 mcast-replicate-stream replicate any dst=239.1.1.1 3 targets: eth1 eth2 eth3
Display per-rule packet hit counters:
sudo python3 rudder.py show statsNAME TYPE PRI HITS
ef-to-path-a steer 10 14,382
mcast-replicate-stream replicate 20 891
Dump the raw BPF map contents with all fields decoded:
sudo python3 rudder.py show maps=== steer_rules ===
slot=0 name=ef-to-path-a ingress=eth0 src=0.0.0.0/0 dst=10.0.0.0/8 dscp=46 proto=0 -> new_dst=192.168.100.1 egress=eth2 mac=aa:bb:cc:dd:ee:ff
=== replicate_rules ===
slot=0 name=mcast-replicate-stream ingress=0 dst=239.1.1.1/32 targets=3:
-> 10.10.1.1 via eth1 mac=00:00:00:00:00:00
-> 10.10.2.1 via eth2 mac=00:00:00:00:00:00
-> 10.10.3.1 via eth3 mac=00:00:00:00:00:00
See which interfaces have rudder TC hooks attached:
sudo python3 rudder.py show interfacesINTERFACE IFINDEX HOOK
eth0 2 yes (rudder)
eth1 3 yes (rudder)
eth2 4 yes (rudder)
eth3 5 no
Stream real-time trace events for every matched packet. Each line shows the matched rule, event type, source/destination IPs, and egress interface:
sudo python3 rudder.py traceStreaming trace events (Ctrl-C to stop)...
[12:04:33.441] rule_id=0 type=steer src=10.1.1.5 orig_dst=10.2.2.1 new_dst=192.168.100.1 egress=eth2
[12:04:33.449] rule_id=0 type=replicate_clone src=10.1.1.9 orig_dst=239.1.1.1 new_dst=10.10.1.1 egress=eth1
[12:04:33.449] rule_id=0 type=replicate_clone src=10.1.1.9 orig_dst=239.1.1.1 new_dst=10.10.2.1 egress=eth2
[12:04:33.449] rule_id=0 type=replicate_final src=10.1.1.9 orig_dst=239.1.1.1 new_dst=10.10.3.1 egress=eth3
Press Ctrl-C to stop.
Update rules without detaching TC hooks. The engine re-populates the BPF maps in place and reports what changed:
sudo python3 rudder.py reload rules/updated_rules.yamlReloaded. Changes applied:
MODIFIED ef-to-path-a action.via: eth2 -> eth3
ADDED be-to-path-b priority=30
REMOVED old-rule priority=50
Detach all TC hooks, remove pinned BPF maps, and stop the daemon:
sudo python3 rudder.py stopRudder stopped.
You can verify cleanup with:
tc filter show dev eth0 ingress # Should show no rudder filters
ls /sys/fs/bpf/rudder/ 2>/dev/null # Directory should not existThe included packet generator uses Scapy to send crafted packets for validating rules.
Send 5 UDP packets with DSCP 46 to 10.0.0.1 on eth0, which should trigger the ef-to-path-a steer rule:
sudo python3 tests/gen_packets.py \
--mode steer \
--src-ip 10.1.1.5 \
--dst-ip 10.0.0.1 \
--dscp 46 \
--iface eth0 \
--count 5 \
--proto udpThen verify:
# Check hit counters incremented
sudo python3 rudder.py show stats
# Watch for rewritten packets on the egress interface
sudo tcpdump -i eth2 -n dst host 192.168.100.1Send 10 UDP packets to multicast group 239.1.1.1:
sudo python3 tests/gen_packets.py \
--mode replicate \
--src-ip 10.1.1.9 \
--dst-ip 239.1.1.1 \
--iface eth0 \
--count 10Verify unicast copies appear on each target interface:
sudo tcpdump -i eth1 -n dst host 10.10.1.1 &
sudo tcpdump -i eth2 -n dst host 10.10.2.1 &
sudo tcpdump -i eth3 -n dst host 10.10.3.1 &--mode steer | replicate (required)
--src-ip Source IP address (default: 10.0.0.1)
--dst-ip Destination IP address (required)
--dscp DSCP value 0-63 (default: 0)
--iface Outgoing interface (required)
--count Number of packets (default: 10)
--interval Seconds between packets (default: 0.1)
--proto tcp | udp | icmp (default: udp)
A full test cycle on a machine with eth0, eth1, eth2, and eth3:
# 1. Install dependencies
sudo apt-get install -y libbpf-dev linux-headers-$(uname -r) \
linux-tools-generic clang llvm iproute2 tcpdump
pip3 install -r requirements.txt
# 2. Load steering and replication rules
sudo python3 rudder.py load rules/example_steer.yaml rules/example_replicate.yaml
# 3. Confirm TC hooks are attached
tc filter show dev eth0 ingress
# 4. Confirm BPF maps are pinned
ls /sys/fs/bpf/rudder/
# 5. Inspect map contents
sudo python3 rudder.py show maps
# 6. Start a trace in one terminal
sudo python3 rudder.py trace
# 7. In another terminal, send test traffic
sudo python3 tests/gen_packets.py --mode steer --dst-ip 10.0.0.1 --dscp 46 --iface eth0 --count 5
# 8. Check hit counters
sudo python3 rudder.py show stats
# 9. Watch for redirected packets
sudo tcpdump -i eth2 -n dst host 192.168.100.1
# 10. Clean up
sudo python3 rudder.py stopA Dockerfile and K3s pod manifest are provided in deploy/ for running rudder inside a container with Multus multi-interface support.
docker build -t rudder:latest -f deploy/Dockerfile .
kubectl apply -f deploy/pod.yaml
kubectl exec -it rudder -- bashThe pod runs in privileged mode and mounts /sys/fs/bpf, /lib/modules, and /usr/src from the host. Multus NetworkAttachmentDefinition resources (rudder-net1, rudder-net2, rudder-net3 in the manifest) must be created separately to match your cluster's network topology.
rudder/
├── rudder.py # CLI entry point (click)
├── engine/
│ ├── __init__.py
│ ├── models.py # Dataclasses: MatchSet, SteerAction, ReplicateAction, Rule
│ ├── loader.py # YAML parsing, validation, priority sorting
│ ├── manager.py # Compile, TC attach, map pinning, map population
│ ├── observer.py # Stats, map dump, trace event formatting
│ ├── perf_reader.py # ctypes-based perf event ring buffer reader
│ └── daemon.py # Background daemon with Unix socket IPC
├── ebpf/
│ ├── maps.h # Shared struct definitions and constants
│ ├── steer.c # TC classifier: DSCP/IP steering with redirect
│ └── replicate.c # TC classifier: multicast-to-unicast replication
├── rules/
│ ├── example_steer.yaml # Example DSCP steering rule
│ └── example_replicate.yaml # Example multicast replication rule
├── tests/
│ └── gen_packets.py # Scapy packet generator for validation
├── deploy/
│ ├── Dockerfile # Ubuntu 22.04 container with all dependencies
│ └── pod.yaml # K3s pod manifest with Multus annotations
└── requirements.txt # Python dependencies
- Maximum 64 rules total (compile-time constant
MAX_RULESinebpf/maps.h) - Maximum 12 replication targets per replicate rule (
MAX_TARGETS) - IPv4 only
- No stateful connection tracking
- No VLAN or QinQ support