XDP CGNAT: 2. Erstes XDP-Programm
Nachdem in ein Testbed steht, geht es nun an das erste XDP-Programm. Das Augenmerk liegt zunächst auf einer funktionsfähigen Entwicklungsumgebung für solche Programme.
Mein erstes Programm
So ziemlich das kürzeste Programm, dessen Wirkung man auch bemerkt, wirft einfach alle eingehenden Frames weg. Ausnahmslos. Das sollte man in jedem Fall bemerken können.
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
SEC("xdp")
int nat_on_stick(struct xdp_md *ctx) {
return XDP_DROP;
}
char _license[] SEC("license") = "GPL";Die Includes gestatten die einfache Formulierung der zugrundeliegenden Struktur eines eBPF-Programms, das an die XDP-Schnittstelle eines Interfaces gebunden werden kann. Beim Laden von Code in den Kernel werden viele Dinge überprüft. Dazu gehört auch, dass eine passende Lizenz des Codes vorliegt, die vom Kernel akzeptiert wird. In dem Fall nehme ich die GPL.
Als Programm wird der Kernel nur Prozeduren in der Section "xdp" akzeptieren, wenn dann die Bindung ans Interface erfolgen soll. Die Section gibt also an, für welchen Andockpunkt (Hook) das eBPF Programm geeignet ist. Der Name der Prozeduren ist egal. Es können auch mehrere Prozeduren in einer Datei existieren, aus denen man später beim Andocken auswählt.
Eine XDP-Prozedur bekommt einen Datensatz (Context) übergeben, der den aktuellen Frame, der die Bearbeitung getriggert hat, enthält. Man findet dort Informationen über die beteiligten Interfaces, ggfls. Zeitstempel, aber auch bereits von der Hardware extrahierte Teildaten (z.B. das Vlan) des eingegangenen Frame. Und natürlich das Frame selbst.
Der Rückgabewert beschreibt, wie mit dem Frame nach der XDP-Behandlung verfahren werden soll:
- XDP_DROP: Wirf den Frame weg.
- XDP_PASS: Bearbeite ihn weiter, als wenn es kein XDP gäbe (wobei der Frame aber verändert worden sein kann)
- XDP_REDIRECT: Schicke den Frame an eine andere Stelle (Interface, CPU, ...) zur weiteren Bearbeitung
- XDP_TX: Schicke den (bearbeiteten) Frame zurück an das Interface, von dem er kam.
Es gibt noch weitere Rückgabewerte, aber die sind produktiv nicht von Belang.
Dieses Programm ignoriert also den auslösenden Frame und weist den Device-Treiber an, den Frame zu verwerfen. Es ist also so, als wäre gar nichts auf dem Interface angekommen. Würde man mit tcpdump versuchen, hier Netzwerk-Aktivitäten mitzuschneiden, so sagt der Netzwerk-Treiber stets: Keine Daten angekommen,
Kompilieren
Der Compiler darf keinen Maschinen-Code für die aktuelle CPU erstellen, sondern muss für die fremde Architektur "eBPF" cross-compilieren. Dies kann der clang-Compiler mit dem Target "bpf".
# apt install llvm clang xdp-tools make linux-headers-amd64 libbpf-dev
Das Ergebnis ist eine Art Interpreter-Bytecode für eine streng definierte virtuelle Maschine innerhalb des Kernels. Beim Laden übersetzt der Kernel diesen Bytecode in native Maschinesprache, die aber keinen Schaden anrichten kann, weil die strenge Definition eine rigorose Validierung des Codes ermöglicht.
$ clang -target bpf -c nat-on-stick.c
In meinem Fall heißt die Quelldatei "nat-on-stick.c", weil das die Aufgabe ist, an der ich arbeite.
Der minimale Compileraufruf soll nur das Objekt-File "nat-on-stick.o" mit dem Bytecode erzeugen, kein direkt ausführbares Programm erstellen. Deswegen die Einschränkung mit "-c".
Es gibt weitere sinnvolle Optionen beim Kompilieren:
- -g Erzeugen von Debug-Informationen (BTF), die später beim Arbeiten mit dem eBPF Dateien sehr nützlich sind.
- -O2 Erzeugen von gutem Code, auch wenn die Formulierungen durch die Programmiersprache das nicht so her geben.
- -I /usr/include/x86_64-linux-gnu Einbinden der benötigten Grunddefinitionen für die Architektur, auf der der Cross-Compiler arbeitet
Die letzte Option ist tatsächlich eine Stolperfalle: Dadurch, dass der Cross-Compiler nicht selbst als BPF-Programm geschrieben ist, sind seine Systembibliotheken normalerweise unbenutzbar. Man müsste die alle für eBPF portieren. Das macht natürlich keinen Sinn, also nimmt man die Systembibliotheken des Hostumgebung und hofft, dass der Compiler den Unterschied schon erkennen wird.
Einige Datentypen sind auf der eBPF-Architektur als 32bit-Typen definiert, nicht als 64bit wie die Host-Architektur es vorgibt. Da kommt der Compiler schnell ins Stolpern, weil er die 32bit-Header nicht findet. Aber die kann man nachinstallieren, so dass die auch auf der 64bit Host-Architektur parallel genutzt werden können.
# apt install libc6-dev-i386
Den Objektcode kann man übrigens auch ansehen:
$ llvm-objdump-19 -S nat-on-stick.o nat-on-stick.o: file format elf64-bpf Disassembly of section xdp: 0000000000000000 <nat_on_stick>: ; return XDP_DROP; 0: b7 00 00 00 01 00 00 00 r0 = 0x1 1: 95 00 00 00 00 00 00 00 exit
Man erkennt den 64bit breiten Bytecode und die Übersetzung. Für dieses triviale Programm ist der Code auch trivial.
Aktivieren von XDP
Für das Arbeiten mit eBPF-Programmen gibt es ein besonderes Werkzeug "bpftool"
# apt install bpftool
Das Script "xdp-on.sh" läd ein Objekt-File an den xdp-Hook des "nat"-Intefaces im Namespace des "Router"s.
#! /bin/sh PROG="$1" if [ ! -s "$PROG" ] ; then echo "$0 object-file" exit 1 fi BPFFS="/sys/fs/bpf" fgrep -q "$BPFFS" /proc/mounts || mount -t bpf bpffs "$BPFFS" ip netns exec router sh -c " bpftool prog load $PROG $BPFFS/nat-proc bpftool net attach xdp pinned $BPFFS/nat-proc dev nat rm -f $BPFFS/nat-proc "
Der Weg wirkt etwas kompliziert:
- Zunächst mal braucht man tatsächlich ein Object-File.
- Zur Kommunikation mit dem Kernel hilft es, das bpf-Filesystem zu mounten.
- Damit kann man ein Objekt-File in den Kernel laden und es unter einem bestimmten Namen wieder finden. Normalerweise bekommt man eine ID zurück, die man sich merken muss, um das Programm später wieder zu finden. Dies ersetzt hier der Name.
- Anhand des Names kann man dann das schon geladene Programm an die Netzwerk-Schnittstelle binden.
- Danach wird der Name nicht mehr benötigt.
In umgekehrter Richtung geht es mit "xdp-off.sh" ähnlich:
#! /bin/sh ip netns exec router \ bpftool net detach xdp dev nat
Es ist auch möglich, sich den Programmcode nach der Bearbeitung durch den Kernel anzusehen
# bpftool prog dump xlated name nat_on_stick int nat_on_stick(struct xdp_md * ctx): ; return XDP_DROP; 0: (b7) r0 = 1 1: (95) exit
Und das entspricht ja ziemlich dem, was der Objektdump nach dem Kompilieren erzählt hat.
Zusammenfassung
Natürlich will man nicht alles immer von Hand tippen. Also gibt's ein Makefile:
CC = clang CFLAGS = -target bpf -c -O2 -g -I /usr/include/x86_64-linux-gnu OBJDUMP = llvm-objdump-19 SUDO = sudo TESTBED_NS = /run/netns/router OBJ = nat-on-stick.o .PHONY: tests tests: | $(TESTBED_NS) $(SUDO) ./tests.sh .PHONY: install install: $(OBJ) | $(TESTBED_NS) $(SUDO) ./xdp-on.sh $^ .PHONY: clean clean: rm -f *.o *~ *.S *.dot $(SUDO) ./xdp-off.sh $(SUDO) ./testbed-off.sh $(TESTBED_NS): $(SUDO) ./testbed-on.sh .c.o: $(CC) $(CFLAGS) -o $@ $^ .o.S: $(OBJDUMP) -S $^ > $@ test.dot: install $(SUDO) ./xdp-dump.sh > $@
Der Trick mit TESTBED_NS prüft, ob der Namespace schon existiert und baut bei Bedarf das Testbed auf.
"xdp-dump.sh" ist ein Script, was den geladenen Code zur besseren Visualisierung nach GraphWiz umwandelt:
#! /bin/sh bpftool prog dump xlated name nat_on_stick visual
Das Ergebnis sieht dann so aus:
Und der Test?
$ make install tests clang -target bpf -c -O2 -g -I /usr/include/x86_64-linux-gnu -o nat-on-stick.o nat-on-stick.c sudo ./testbed-on.sh sudo ./xdp-on.sh nat-on-stick.o sudo ./tests.sh
Jetzt geht's los!
### Client2 pings the gateway PING 192.168.20.254 (192.168.20.254) 56(84) bytes of data. --- 192.168.20.254 ping statistics --- 3 packets transmitted, 0 received, 100% packet loss, time 2054ms ### Client2 pings behind the gateway PING 100.64.0.1 (100.64.0.1) 56(84) bytes of data. --- 100.64.0.1 ping statistics --- 3 packets transmitted, 0 received, 100% packet loss, time 2025ms ### Packet dump at the switch port towards the gateway 22:07:33.077927 66:b8:47:f2:ad:8f > ff:ff:ff:ff:ff:ff, ethertype 802.1Q (0x8100), length 46: vlan 20, p 0, ethertype ARP (0x0806), Request who-has 192.168.20.254 tell 192.168.20.2, length 28 22:07:34.101949 66:b8:47:f2:ad:8f > ff:ff:ff:ff:ff:ff, ethertype 802.1Q (0x8100), length 46: vlan 20, p 0, ethertype ARP (0x0806), Request who-has 192.168.20.254 tell 192.168.20.2, length 28 22:07:36.053988 66:b8:47:f2:ad:8f > ff:ff:ff:ff:ff:ff, ethertype 802.1Q (0x8100), length 46: vlan 20, p 0, ethertype ARP (0x0806), Request who-has 192.168.20.254 tell 192.168.20.2, length 28 22:07:37.077923 66:b8:47:f2:ad:8f > ff:ff:ff:ff:ff:ff, ethertype 802.1Q (0x8100), length 46: vlan 20, p 0, ethertype ARP (0x0806), Request who-has 192.168.20.254 tell 192.168.20.2, length 28
Man sieht nur, wie jemand verzweifelt versucht, eine Adressauflösung für die Gateway-Adresse zu bekommen. Aber es kommt keine Antwort zurück.
Das XDP-Programm hat also erfolgreich die Kommunikation mit dem Router verhindert, indem es alle eingehenden Frames verwarf. Oder ist einfach was kaputt?
