Aller au contenu
Implémentons un petit firewall avec XDP
  1. Ebpf-Another-Types/

Implémentons un petit firewall avec XDP

·1703 mots·8 mins·
Joseph Ligier
Auteur
Joseph Ligier
CNCF ambassador | Kubestronaut 🐝
Sommaire
Apprenons XDP avec Aya - Cet article fait partie d'une série.
Partie 2: Cet article

Nous avons vu ce qu’était un programme XDP dans la première partie : cela peut être un moyen de filtrer un flux réseau.

Dans cette partie je vous propose une petite balade dans les différentes en-têtes et de montrer comment on peut opérer le filtrage. Nous allons ainsi voir comment redévelopper xdp-filter avec le framework Rust Aya.

Suivez le guide !

lab

Je suppose que vous êtes déjà dans un environnement pour développer avec Aya. Si ce n’est pas le cas, vous pouvez utiliser le lab Killercoda :

Killer coda screenshot


Créons le hello world en XDP
#

Recréons l’environnement de développement
#

Comme nous l’avons vu dans la précédente partie, je vous propose d’installer des namespaces :

git clone https://github.com/littlejo/eunomia.dev
cd eunomia.dev/docs/tutorials/42-xdp-loadbalancer/
./setup.sh

Rappelons ce qu’on a créé :

namepaces

Créons le programme “hello world”
#

Comme nous l’avons fait avec xdp-filter, nous allons installer le programme XDP sur veth0.

Générons déjà le programme Aya :

cargo generate --name browser-xdp \
               -d program_type=xdp \
               -d default_iface=veth0 \
               https://github.com/aya-rs/aya-template

Buildons et installons le programme “hello world” :

cd browser-xdp/
cargo run

Sur un autre terminal, testons le programme :

ip netns exec lb ping -c 2 10.0.0.1

Du côté du terminal cargo run, on voit bien deux paquets :

[INFO  browser_xdp] received a packet
[INFO  browser_xdp] received a packet

Ainsi à chaque fois que l’interface reçoit un paquet, le programme XDP est lancé.

namepaces with xdp program

Maintenant nous allons voir comment arrêter un paquet et quel type de paquet on a affaire.


Les bases pour créer un programme XDP
#

Les actions XDP
#

Regardons d’abord le code généré par cargo.

Le fichier le plus important est celui-là : browser-xdp-ebpf/src/main.rs c’est à dire le code côté noyau.

Globalement le code ressemble aux codes générés pour d’autres types de programmes eBPF comme les Tracepoints :

#[xdp]
pub fn browser_xdp(ctx: XdpContext) -> u32 {
    match try_browser_xdp(ctx) {
        Ok(ret) => ret,
        Err(_) => xdp_action::XDP_ABORTED,
    }
}

fn try_browser_xdp(ctx: XdpContext) -> Result<u32, u32> {
    info!(&ctx, "received a packet");
    Ok(xdp_action::XDP_PASS)
}

Cependant on peut remarquer une petite spécificité : xdp_action::*.

Cela permet de déterminer ce que fait le paquet une fois qu’il a fini de parcourir le programme eBPF.

Documentation ebpf

On a 5 actions différentes :

  • XDP_ABORTED : Arrête le paquet avec erreur
  • XDP_DROP : Arrête le paquet silencieusement
  • XDP_PASS : Laisse passer le paquet
  • XDP_REDIRECT : Redirige le paquet vers une autre interface ou une socket AF_XDP attachée à une interface
  • XDP_TX : Redirige le paquet vers la même interface

Dans cet article, nous utiliserons uniquement XDP_PASS et XDP_DROP.

Le contexte XDP et ses méthodes
#

Comme pour tous les autres programmes eBPF, pour aller au-delà du hello world, il faut comprendre comment jouer avec le contexte.

Regardons la documentation :

Documentation aya

Contrairement aux précédents types de programmes où on utilisait qu’une seule méthode, il y en a 4 différentes pour XDP :

  • data() retourne l’adresse mémoire du début du paquet réseau.
  • data_end() retourne l’adresse mémoire de fin du paquet réseau.
  • metadata() retourne l’adresse mémoire du début de la métadonnées de XDP lié au kernel linux ou au driver.
  • metadata_end() retourne l’adresse mémoire de fin de la métadonnées. De la même manière que data_end(),

Dans cet article, nous utiliserons uniquement les méthodes data() et data_end() pour récupérer les différentes en-têtes.

Comment récupérer les en-têtes ?
#

Rappelons le schéma suivant :

headers

  • data() se trouve au début de l’en-tête ethHdr
  • data_end() se trouve à la fin du payload

Comment récupérer l’en-tête ethernet (ethHdr) ? Elle se situe au tout début du paquet.

let ethhdr = ctx.data();

Pour récupérer l’en-tête suivante, il suffit de rajouter la longueur de l’en-tête ethernet, en pseudo-code Rust ça donnerait :

let ipv4hdr = ctx.data() + sizeof(ethhdr);

Etc.

Concentrons-nous sur l’en-tête ethernet. Comment récupérer ses éléments ? ctx.data() est de type usize. Un entier naturel qui représente une adresse mémoire. Il faut donc récupérer son contenu. Comment faire cela ?

De la même manière que *const u8 représente un pointeur vers une chaine de caractères, ici on doit faire la même chose pour l’en-tête ethernet.

Ainsi il faut convertir dans une structure qui représente l’en-tête de la trame Ethernet. On a plusieurs possibilités pour le faire comme aller voir le code C et le convertir en Rust ce qui est un bon exercice mais un peu fastidieux. Heureusement, comme Rust est désormais bien présent dans l’écosystème d’eBPF, il y a déjà une crate Rust qui nous a fait le travail : network-types.

Documentation network-types

Pour récupérer cette crate, on va modifier le fichier browser-xdp-ebpf/Cargo.toml et rajouter dans la section dependencies :

network-types = "0.1.0"

On va ainsi pouvoir l’inclure dans le code principal (browser-xdp-ebpf/src/main.rs) :

use network_types::eth::EthHdr;

[...]

let ethhdr = ctx.data() as *const EthHdr;

Maintenant qu’on a récupéré la structure de l’en-tête ethernet, nous pouvons récupérer les différents éléments qui composent l’en-tête :

ethhdr

  • DST MAC : l’adresse MAC destination
  • SRC MAC : l’adresse MAC source
  • ETHERTYPE : détermine le protocole du niveau supérieur (Comme IPv4, IPv6, Arp pour citer les plus connus).

Comment le faire avec Aya ? Regardons la documentation :

Documentation ethhdr

Ainsi pour récupérer l’adresse de destination, il suffit de coder :

let dst_addr = unsafe { (*ethhdr).dst_addr};

info!(&ctx, "dst_addr {:mac} ", dst_addr);

Nous devons utiliser unsafe car nous déréférençons un pointeur brut.

On peut remarquer que la macro info! nous permet de convertir des tableaux de 6 octets en une adresse MAC. Si vous préférez la notation en majuscule, il suffit d’écrire :MAC.

Essayons maintenant. Voici le code principal modifié :

use network_types::eth::EthHdr;

fn try_browser_xdp(ctx: XdpContext) -> Result<u32, u32> {
    let ethhdr = ctx.data() as *const EthHdr;

    let dst_addr = unsafe { (*ethhdr).dst_addr};

    info!(&ctx, "dst_addr {:mac} ", dst_addr);

    info!(&ctx, "received a packet");
    Ok(xdp_action::XDP_PASS)
}

Buildons le programme :

cargo build

La compilation fonctionne.

Installons le dans le noyau :

cargo run

On a alors l’erreur suivante :

Error: the BPF_PROG_LOAD syscall failed. Verifier output: 0: R1=ctx() R10=fp0
; unsafe { (*self.ctx).data as usize } @ xdp.rs:16
0: (61) r1 = *(u32 *)(r1 +0)          ; R1_w=pkt(r=0)
; let dst_addr = unsafe { (*ethhdr).dst_addr}; @ main.rs:20
1: (71) r0 = *(u8 *)(r1 +1)
invalid access to packet, off=1 size=1, R1(id=0,off=1,r=0)
R1 offset is outside of the packet
verification time 65 usec
stack depth 0
processed 2 insns (limit 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0


Caused by:
    Permission denied (os error 13)

Le verifier n’aime pas vraiment cette partie du code :

let ethhdr = ctx.data() as *const EthHdr;
Quand on essaie d’installer un programme eBPF dans le noyau Linux, il y a un programme Linux (le verifier) qui fait des vérifications dans le code pour éviter qu’on casse la sécurité du noyau Linux.

L’erreur peut se traduire en français par :

Accès invalide au paquet.
L'offset du registre 1 est en dehors du paquet

Pas super clair. Essayons d’expliquer mieux. Quand on tente let ethhdr = ctx.data() as *const EthHdr;, Il y a un accès à la mémoire. Le verifier exige qu’on prouve que cet accès soit toujours dans le paquet (entre data() et data_end()).

Il faut donc faire une vérification pour le rassurer avant cet accès :

if ctx.data() + EthHdr::LEN > ctx.data_end() {
    return Ok(xdp_action::XDP_PASS);
}

On peut traduire par : si l’accès ne se trouve pas au niveau du paquet, le programme XDP ne traite pas le paquet : il le laisse continuer son chemin dans la pile réseau de noyau Linux.

Sinon on peut alors convertir en EthHdr :

let ethhdr = ctx.data() as *const EthHdr;

Voici le code complet de la fonction principale :

fn try_browser_xdp(ctx: XdpContext) -> Result<u32, u32> {
    let start = ctx.data() ;
    let end = ctx.data_end() ;

    if start + EthHdr::LEN > end { //Check for the verifier
        return Ok(xdp_action::XDP_PASS);
    }

    let ethhdr = start as *const EthHdr;

    let dst_addr = unsafe { (*ethhdr).dst_addr};

    info!(&ctx, "dst_addr {:mac} ", dst_addr);
    info!(&ctx, "received a packet");

    Ok(xdp_action::XDP_PASS)
}

Vérifions que ça marche :

cargo run

Lançons des ping. On récupère bien l’adresse mac :

[INFO  browser_xdp] dst_addr de:ad:be:ef:00:01
[INFO  browser_xdp] received a packet
[INFO  browser_xdp] dst_addr de:ad:be:ef:00:01
[INFO  browser_xdp] received a packet

Maintenant qu’on a réussi à récupérer l’en-tête ethHdr, comment récupérer les autres en-têtes ? Il suffit de rajouter la longueur des en-têtes qui les précèdent.

De manière assez évidente, nous allons utiliser des méthodes très proches pour toutes les en-têtes. Il serait judicieux de créer une fonction. La voilà :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
use core::mem::size_of;

#[inline(always)]
fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, u16> {
    let start = ctx.data();
    let end = ctx.data_end();
    let len = size_of::<T>();

    if start + offset + len > end {
        return Err(1);
    }

    Ok((start + offset) as *const T)
}

Quelques remarques sur la création de cette fonction :

  • (L3) On inline la fonction pour des questions de performance
  • (L4) <T> : permet de créer une fonction générique non dépendante de la structure
  • l’offset permet de naviguer d’en-tête en en-tête (offset = 0 pour ethHdr)
  • (L7) On a dû changer la méthode pour calculer la longueur de la structure avec un size_of pour que ça soit possible pour n’importe quelle structure

Le code principal devient alors :

fn try_browser_xdp(ctx: XdpContext) -> Result<u32, u32> {
    let ethhdr: *const EthHdr = ptr_at(&ctx, 0)?;

    let dst_addr = unsafe { (*ethhdr).dst_addr};

    info!(&ctx, "dst_addr {:mac} ", dst_addr);

    info!(&ctx, "received a packet");
    Ok(xdp_action::XDP_PASS)
}

Maintenant qu’on a créé la fonction permettant de se balader dans les différentes en-têtes, regardons comment on fait pour bloquer ou laisser passer les différents paquets en fonction des en-têtes réseaux.


Visite guidée des différentes en-têtes
#

headers

Suite de l’article réservée aux membres premium ✨

L’article complet est accessible uniquement aux membres premium.

Devenir membre premium, c’est simple : il suffit de faire un petit don 💖

En échange, vous aurez pendant 1 an (offre early bird) :

  • Accès à tous les articles complets dès leur publication
  • Lecture anticipée avant la mise en ligne publique
  • Participation au soutien de ce blog indépendant
  • Accès exclusif à mes photos de vacances à Dubaï

Votre don permet de :

  • Me rendre moins dépendant des grandes plateformes
  • M’encourager à créer plus de contenu technique
  • Lever le paywall plus rapidement pour tous

👉 Devenir membre premium dès maintenant

Apprenons XDP avec Aya - Cet article fait partie d'une série.
Partie 2: Cet article