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 in jedem Fall auffallen.

#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 Funktionen 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 Funktionen ist egal. Es können auch mehrere Prozeduren in einer Datei existieren, aus denen man später beim Andocken auswählt.

Eine XDP-Funktion 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 Frames, 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

XDP-Programme sollen auf verschiedenster Hardware laufen. Einige Netzwerkkarten erlauben es, diese Programme direkt auf dem Netzwerkchip abarbeiten zu lassen. Die CPU-Architektur zum Arbeitszeitpunkt kann also stark variieren: Es muss nicht die Host-CPU sein. Deswegen werden XDP-Programme als eBPF-Zwischenformat übertragen. Der Compiler darf keinen Maschinen-Code für die aktuelle CPU erstellen, sondern muss für die fremde Architektur cross-compilieren. Dies kann der clang-Compiler mit dem Target "bpf".

# apt install llvm clang xdp-tools make linux-headers-amd64 libbpf-dev
$ clang -target bpf -c nat-on-stick.c

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.

In meinem Fall heißt die Quelldatei "nat-on-stick.c". 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 weist auf eine Stolperfalle hin: 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, die es in meiner 64bit Host-Architektur nicht gibt. Da kommt der Compiler ins Stolpern, weil er die 32bit-Header nicht findet. Glücklicherweise kann man diese aber nachinstallieren, so dass die auch auf der 64bit Host-Architektur parallel genutzt werden können.

# apt install libc6-dev-i386

Den Objektcode kann man sich ü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 symbolische Ü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 (pinning). Normalerweise bekommt man beim Laden nur 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 in den Kernel 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 genau dem, was der Objekt-Dump 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:

xdp-drop

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 der Client 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?

Post a comment

Verwandter Inhalt