Aller au contenu
Atténuons les attaques DDoS avec XDP
  1. Ebpf-Another-Types/

Atténuons les attaques DDoS avec XDP

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

Nous avons vu ce qu’était un programme XDP dans la première partie : cela peut être un moyen de limiter les attaques par déni de service distribué.

Dans cette partie, je vous propose de créer un programme XDP qui va limiter les attaques par SYN flood. On va faire cela 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 un petit tcpdump avec XDP
#

Avant de faire cela, nous allons déjà expliquer comment on analyse un segment TCP avec XDP et ainsi récupérer les paquets SYN. Au fait, vous vous rappelez comment fonctionne TCP ?

Les connexions TCP
#

TCP est l’acronyme de Transmission Control Protocol. C’est probablement le protocole réseau le plus utilisé par les protocoles applicatifs : HTTP, SSH, etc. Difficile de faire l’impasse pour des articles sur du réseau. Cependant c’est probablement le plus complexe si on compare à UDP ou à ICMP.

Avant le téléchargement des données utiles, il y a une phase de connexion (3-way handshake) permettant d’établir un canal fiable.

Qu’est-ce que le SYN Flood ?
#

SYN est le premier segment TCP qui sert à initier une connexion TCP. Quand un serveur reçoit un SYN, il doit renvoyer un SYN-ACK pour accuser de réception. Une fois cela, le client doit confirmer par un ACK et le téléchargement des données peut enfin opérer.

Supposons qu’un client envoie une connexion SYN mais ne répond jamais, que se passe-t-il ?

  • Le serveur envoie un SYN-ACK en réponse puis réessaie 5 fois (valeur par défaut)
  • Le serveur ferme enfin la connexion incomplète

Supposons qu’un client innonde de plein de connexions SYN mais ne répond jamais, que se passe-t-il ?

  • Le serveur garde alors plein de connexions “en attente” (dont certain probablement légitime)
  • Le serveur continue de renvoyer les SYN-ACK jusqu’à saturer ses ressources mémoire

C’est ça le principe d’une attaque SYN Flood.

Dans cet article, pour contrer cette attaque, on va compter le nombre de SYN par IP et s’il y en a trop on bloque temporairement ces segments pour cette IP.

Comment voir les détails des segments
#

Si ce n’est pas déjà fait, recréons l’environnement de développement

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

namepaces

Démarrons un petit serveur web au niveau du namespace lb :

ip netns exec lb python3 -m http.server 8080
  • Sur l’interface veth6, nous verrons les paquets TCP qui sont envoyés par le client (comme SYN) ;
  • Sur l’interface veth7, nous verrons les paquets TCP qui sont envoyés par le serveur (comme SYN-ACK).

Nous devons donc installer le programme eBPF sur veth6.

namepaces

Générons le programme Aya :

cargo generate --name antiddos-xdp \
               -d program_type=xdp \
               -d default_iface=veth6 \
               https://github.com/aya-rs/aya-template
cd antiddos-xdp

En prévision, nous allons utiliser des crates supplémentaires :

  • aya-ebpf-bindings pour les bindings supplémentaires eBPF
  • network-types pour les structures Rust des en-têtes de niveau 1, 2 et 3
  • blog-xdp, la crate de ce blog, pour ne pas copier/coller des fonctions helpers précédemment écrites

On va donc modifier le fichier antiddos-xdp-ebpf/Cargo.toml et rajouter dans la section dependencies :

aya-ebpf-bindings = "0.1.1"
network-types = "0.1.0"
blog-xdp = { git = "https://github.com/littlejo/blog-xdp" }

Vérifions que le programme généré fonctionne bien :

On va déjà construire le binaire :

cargo build

Puis on va installer dans le namespace cible où se trouve l’interface veth6 :

ip netns exec lb cargo run
Comme le namespace n’a pas accès à Internet, il faut commencer par télécharger les crates pour les compiler au niveau du serveur puis installer le programme eBPF au niveau du namespace.

On va maintenant récupérer uniquement les paquets TCP dans le programme eBPF. On va modifier le fichier antiddos-xdp-ebpf/src/main.rs.

headers

On se retrouve avec un code similaire au code de la partie précédente :

use network_types::{
    eth::{EthHdr, EtherType},
    ip::{Ipv4Hdr, IpProto},
    tcp::TcpHdr,
};

use blog_xdp::helper::ptr_at;

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

    match unsafe { (*ethhdr).ether_type() } {
            Ok(EtherType::Ipv4) => {}
            _ => return Ok(xdp_action::XDP_PASS),
        }

    let ipv4hdr: *const Ipv4Hdr = ptr_at(&ctx, EthHdr::LEN)? ;

    match unsafe { (*ipv4hdr).proto } {
         IpProto::Tcp => {}
         _ => return Ok(xdp_action::XDP_PASS),
    }

    let tcphdr: *const TcpHdr = ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)? ;
    let src_addr = unsafe { (*ipv4hdr).src_addr };
    let dst_addr = unsafe { (*ipv4hdr).dst_addr };

    info!(&ctx, "src:{:i} => dst:{:i}", src_addr, dst_addr);
    Ok(xdp_action::XDP_PASS)
}

On peut tester que ça fonctionne :

ip netns exec lb cargo run

Verifions avec cette commande pour tester le programme XDP :

curl 10.0.0.10:8080

Au niveau de cargo, on voit alors plusieurs paquets passés :

[INFO  antiddos_xdp] src:10.0.0.1 => dst:10.0.0.10
[INFO  antiddos_xdp] src:10.0.0.1 => dst:10.0.0.10
[INFO  antiddos_xdp] src:10.0.0.1 => dst:10.0.0.10
[INFO  antiddos_xdp] src:10.0.0.1 => dst:10.0.0.10
[INFO  antiddos_xdp] src:10.0.0.1 => dst:10.0.0.10
[INFO  antiddos_xdp] src:10.0.0.1 => dst:10.0.0.10

Ce que j’expliquais au début, quand on lance une connexion TCP, il n’y a pas qu’un paquet mais plusieurs paquets.

On voit seulement les paquets qui sont envoyés par le client (ceux en ingress).

namepaces

Si on veut voir les paquets qui sont envoyés par le serveur, il faut installer le programme sur l’interface veth7. C’est tout simple, en surchargeant la variable iface :

cargo run -- --iface veth7

namepaces

Pour afficher un peu plus de détails sur la nature des paquets, regardons maintenant l’en-tête du segment TCP :

TCP Hdr

Pour les puristes, le schéma est un peu simplifié.

Voyons brièvement à quoi servent les principales données :

  • SRC/DST PORT : les ports de TCP
  • SEQUENCE/ACK NUMBER : pour le suivi des connexions
  • BITFIELD : c’est là où on définit la nature du segment (SYN, ACK, etc)
  • WINDOW : pour le contrôle de flux (utile pour les gros fichiers)
  • CHECKSUM : pour l’intégrité du segment

Comme pour UDP, on peut donc facilement récupérer les éléments en regardant la documentation de la crate network-types :

Documentation TCPHdr

Pour récupérer la nature du segment, il y a même des fonctions pour nous aider :

Documentation TCPHdr

Pour reproduire un petit tcpdump, ajoutons cela par exemple :

let src_port = u16::from_be_bytes(unsafe { (*tcphdr).source });
let dst_port = u16::from_be_bytes(unsafe { (*tcphdr).dest });
let seq = u32::from_be_bytes(unsafe {(*tcphdr).seq});
let ack_seq = u32::from_be_bytes(unsafe {(*tcphdr).ack_seq});
let syn = unsafe {(*tcphdr).syn()};
let ack = unsafe {(*tcphdr).ack()};
let psh = unsafe {(*tcphdr).psh()};
let fin = unsafe {(*tcphdr).fin()};

info!(&ctx, "seq: {}, ack_seq: {}, syn: {}, ack: {}, data: {}, fin: {}, src: {:i}:{}, dst: {:i}:{}",
                 seq, ack_seq, syn, ack, psh, fin, src_addr, src_port, dst_addr, dst_port);

Installons ce programme sur les deux interfaces réseaux :

ip netns exec lb cargo run
cargo run -- --iface veth7

Lançons une connexion :

curl 10.0.0.10:8080

Au niveau de veth6, on voit alors les détails des différents segments :

[INFO  antiddos_xdp] seq: 2481734691, ack_seq: 0, syn: 1, ack: 0, data: 0, fin: 0, src: 10.0.0.1:47632, dst: 10.0.0.10:8080
[INFO  antiddos_xdp] seq: 2481734692, ack_seq: 161636304, syn: 0, ack: 1, data: 0, fin: 0, src: 10.0.0.1:47632, dst: 10.0.0.10:8080
[INFO  antiddos_xdp] seq: 2481734692, ack_seq: 161636304, syn: 0, ack: 1, data: 1, fin: 0, src: 10.0.0.1:47632, dst: 10.0.0.10:8080
[INFO  antiddos_xdp] seq: 2481734769, ack_seq: 161636460, syn: 0, ack: 1, data: 0, fin: 0, src: 10.0.0.1:47632, dst: 10.0.0.10:8080
[INFO  antiddos_xdp] seq: 2481734769, ack_seq: 161637894, syn: 0, ack: 1, data: 0, fin: 0, src: 10.0.0.1:47632, dst: 10.0.0.10:8080
[INFO  antiddos_xdp] seq: 2481734769, ack_seq: 161637894, syn: 0, ack: 1, data: 0, fin: 1, src: 10.0.0.1:47632, dst: 10.0.0.10:8080
[INFO  antiddos_xdp] seq: 2481734770, ack_seq: 161637895, syn: 0, ack: 1, data: 0, fin: 0, src: 10.0.0.1:47632, dst: 10.0.0.10:8080

Au niveau de veth7 :

[INFO  antiddos_xdp] seq: 161636303, ack_seq: 2481734692, syn: 1, ack: 1, data: 0, fin: 0, src: 10.0.0.10:8080, dst: 10.0.0.1:47632,
[INFO  antiddos_xdp] seq: 161636304, ack_seq: 2481734769, syn: 0, ack: 1, data: 0, fin: 0, src: 10.0.0.10:8080, dst: 10.0.0.1:47632,
[INFO  antiddos_xdp] seq: 161636304, ack_seq: 2481734769, syn: 0, ack: 1, data: 1, fin: 0, src: 10.0.0.10:8080, dst: 10.0.0.1:47632,
[INFO  antiddos_xdp] seq: 161636460, ack_seq: 2481734769, syn: 0, ack: 1, data: 1, fin: 0, src: 10.0.0.10:8080, dst: 10.0.0.1:47632,
[INFO  antiddos_xdp] seq: 161637894, ack_seq: 2481734769, syn: 0, ack: 1, data: 0, fin: 1, src: 10.0.0.10:8080, dst: 10.0.0.1:47632,
[INFO  antiddos_xdp] seq: 161637895, ack_seq: 2481734770, syn: 0, ack: 1, data: 0, fin: 0, src: 10.0.0.10:8080, dst: 10.0.0.1:47632,

Maintenant qu’on a vu tous les détails d’un segment TCP, on va maintenant s’occuper uniquement des segments de type SYN :

match unsafe { (*tcphdr).syn() } {
     1 => {}
     _ => return Ok(xdp_action::XDP_PASS),
}

Si on a affaire à un SYN, on continue le traitement sinon on laisse passer le paquet.

Comme nous n’allons plus toucher à cette partie du code, nous allons créer une fonction :

#[inline(always)]
fn filter_tcp_syn_src(ctx: &XdpContext) -> Option<[u8; 4]> {
    let ethhdr: *const EthHdr = ptr_at(&ctx, 0).ok()?;

    match unsafe { (*ethhdr).ether_type() } {
            Ok(EtherType::Ipv4) => {}
            _ => return None,
        }

    let ipv4hdr: *const Ipv4Hdr = ptr_at(&ctx, EthHdr::LEN).ok()? ;

    match unsafe { (*ipv4hdr).proto } {
         IpProto::Tcp => {}
         _ => return None,
    }
    let tcphdr: *const TcpHdr = ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN).ok()? ;

    match unsafe { (*tcphdr).syn() } {
       1 => {}
       _ => return None,
    }
    let src_addr = unsafe { (*ipv4hdr).src_addr };
    Some(src_addr)
}

fn try_antiddos_xdp(ctx: XdpContext) -> Result<u32, u32> {
    let src_addr = match filter_tcp_syn_src(&ctx) {
       Some(x) => x,
       None => return Ok(xdp_action::XDP_PASS),
    };
    info!(ctx, "{}", src_addr);
    Ok(xdp_action::XDP_PASS)
}

Nous récupérons uniquement l’IP source de cette fonction pour potentiellement la bloquer.

Cela va nous permettre de nous concentrer sur le code pour empêcher l’attaque.


Essayons d’atténuer les attaques SYN flood
#

Token Bucket
#

Il y a différentes options pour atténuer les DDoS. Je vais parler de l’algorithme du seau à jetons (token bucket). C’est pas mal utilisé comme algorithme (par exemple nginx avec limit_req).

Pour comprendre cet algorithme, nous allons faire une petite analogie :

Un village impose des restrictions alimentaires :

  • Chaque foyer doit faire la queue pour obtenir des paniers
  • Le village ne peut pas stocker plus de 5 paniers à la fois par foyer
  • Si le foyer a récupéré tous ses paniers, il doit attendre le lendemain pour en avoir de nouveaux
  • Si le foyer n’a pas pris de panier depuis quelques jours, il peut en récupérer plusieurs jusqu’à la limite de 5 paniers.

Cette méthode va ainsi limiter les abus tout en étant flexible en permettant de ne pas faire la queue tous les jours.

En appliquant cette idée à la protection contre les attaques SYN Flood, on obtient :

  • Chaque IP doit faire la queue pour envoyer un paquet SYN à un serveur
  • Le serveur ne peut pas recevoir plus de 5 paquets SYN à la fois par IP
  • Si l’IP a déjà envoyé tous ses paquets SYN, elle sera bloquée temporairement et devra attendre un peu pour pouvoir envoyer de nouveaux
  • Si l’IP n’a pas envoyé de paquets SYN depuis quelques temps, il peut en envoyer plusieurs jusqu’à la limite de 5.

TCP Hdr

Bon c’est bien beau mais comment on fait concrètement en eBPF ?

Comment l’implémenter dans eBPF ?
#

Nous allons créer une map eBPF: BUCKET {ip => TOKEN}. Si l’IP n’est pas dans BUCKET, il va être initialisé au nombre maximal (qui est à 5 dans l’analogie). À chaque fois qu’on reçoit un SYN, on diminue de 1. Si le bucket est vide, on arrête le paquet.

Pour ceux qui ne savent pas ce qu’est une map eBPF : en simplifiant, on peut la voir comme une variable globale persistante à un programme eBPF. À chaque fois que le programme XDP est lancé, il peut accéder et modifier cette variable.

Pour en savoir plus, je te conseille de lire la troisième partie de ma série S’initier à eBPF avec Aya.

L’algorithme n’est pas complet car toutes les IPs finiraient par être bloquée, il faut rajouter des jetons. La solution intuitive serait de créer un programme utilisateur qui remplit la map eBPF de 1 régulièrement. Mais c’est un peu risqué si par exemple le programme plante.

Une astuce serait de créer une autre map eBPF : LAST_TS : {ip => ts}. Il contient le moment où on a remplit le BUCKET pour la dernière fois. Ainsi dans le programme eBPF, on peut calculer la durée qui s’est écoulée et rattraper éventuellement le retard en rajoutant les jetons. Supposons qu’on est censé donner un jeton par seconde, si le dernier remplissage date d’il y a 5 secondes, on rajoute 5 jetons dans la limite de la taille du seau.

Pour des questions de performance, on devrait probablement créer une unique map {ip => {num => token, last_fill => ts }. Mais je trouve cela plus simple en séparant en deux maps.

Repassons au code
#

Décrémentation de la map Bucket
#

On va déjà créer la map qui contient les jetons, on utilise une map de type BPF_MAP_TYPE_LRU_HASH :

LRU Hash Map

Elle permet de nettoyer automatiquement les entrées en cas de débordement, on limite le nombre d’IPs à 16 :

use aya_ebpf::macros::map;
use aya_ebpf::maps::LruHashMap;

#[map]
pub static BUCKET: LruHashMap<[u8; 4], u64> = LruHashMap::with_max_entries(16, 0);

Pour trouver le nombre de jetons du bucket, nous allons regarder si la map contient déjà cet IP :

let token = match unsafe { BUCKET.get(&src_addr)} {
    Some(x) => *x,
    None    => 5, //Token max
};

Le seau ne peut pas contenir plus de 5 jetons. Nous créerons une constante à la fin.

On va soustraire de 1 le nombre de jeton et on va remplir la map :

let token_final = token.saturating_sub(1);
let _ = BUCKET.insert(&source_addr, &token_final, 0);
saturating_sub est une méthode core de Rust. Elle empêche que le nombre de jetons soit négatif. Pour un entier naturel ça serait bizarre.

Si le nombre de jeton est nulle, on bloque le paquet :

if token_final == 0 {
    info!(&ctx, "drop");
    return Ok(xdp_action::XDP_DROP);
}

Sinon on affiche le nombre de token :

info!(&ctx, "received a packet, token {}", token_final);

Pour ne rien oublier, voici le code principal :

use aya_ebpf::{macros::map, maps::LruHashMap};

#[map]
pub static BUCKET: LruHashMap<[u8; 4], u64> = LruHashMap::with_max_entries(16, 0);

fn try_antiddos_xdp(ctx: XdpContext) -> Result<u32, u32> {
    let src_addr = match filter_tcp_syn_src(&ctx) {
       Some(x) => x,
       None => return Ok(xdp_action::XDP_PASS),
    };

    let token = match unsafe { BUCKET.get(&src_addr)} {
        Some(x) => *x,
        None    => 5, //Token max
    };

    let token_final = token.saturating_sub(1);
    let _ = BUCKET.insert(&src_addr, &token_final, 0);

    if token_final == 0 {
        info!(&ctx, "drop");
        return Ok(xdp_action::XDP_DROP);
    }

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

Pour plus de renseignements sur la map, n’hésitez pas à lire la doc :

Aya doc

Vérifions :

ip netns exec lb cargo run

Lançons 6 fois :

curl 10.0.0.10:8080

Sur le terminal de cargo run, on a le résultat escompté :

[INFO  antiddos_xdp] received a packet, token 4
[INFO  antiddos_xdp] received a packet, token 3
[INFO  antiddos_xdp] received a packet, token 2
[INFO  antiddos_xdp] received a packet, token 1
[INFO  antiddos_xdp] drop
[INFO  antiddos_xdp] drop
[INFO  antiddos_xdp] drop

À la fin, comme curl n’arrive pas à se connecter au serveur, il doit retransmettre des SYN.

Calcul du rajout des jetons
#

Le programme actuel ne peut que vider le seau et donc bloquer au final tous les flux. Passons maintenant au remplissage du seau. Pour cela, nous avons besoin d’une autre map LAST_TS qui va contenir, pour chaque IP, le timestamp du dernier remplissage :

Créons la map :

#[map]
static LAST_TS: LruHashMap<[u8; 4], u64> = LruHashMap::with_max_entries(16, 0);

De manière similaire que pour la map BUCKET, nous allons récupérer le timestamp de la map LAST_TS :

let ts = match unsafe { LAST_TS.get(&source_addr)} {
    Some(x) => *x,
    None    => 0,
};

Pour forcer la création de l’entrée dans la map, j’ai mis un timestamp de zéro si l’entrée n’existe pas.

Calculons le nombre de jeton qu’il faut rajouter :

let t = unsafe {bpf_ktime_get_ns()};
let duration = t - ts;
let token_add = duration / 1_000_000_000 ;

La fonction bpf_ktime_get_ns() permet de calculer le temps depuis le démarrage du système :

ebpf doc

Les timestamps sont exprimés en nano seconde. On rajoute ici 1 jeton par seconde écoulée.

S’il faut en rajouter, on met à jour la map :

if token_add > 0 {
   let _ = LAST_TS.insert(&source_addr, &t, 0);
}

On calcule le nombre total de jeton dans le seau :

let token_final = min(token.saturating_sub(1) + token_add, 5);

Avec la fonction min, on évite que le bucket peut contenir plus que 5 jetons.

Pour debugger :

info!(&ctx, "token {}, duration {}, token_add {}", token_final, duration, token_add);

En améliorant le code avec des fonctions, cela donne :

use aya_ebpf_bindings::helpers::bpf_ktime_get_ns;
use core::cmp::min;

const TOKEN_BUCKET_SIZE: u64 = 5;
const REFILL_INTERVAL_NS: u64 = 1_000_000_000;

#[inline(always)]
fn refill_tokens(src_addr: &[u8; 4]) -> u64 {
    let ts = match unsafe { LAST_TS.get(src_addr)} {
        Some(x) => *x,
        None    => 0,
    };

    let t = unsafe {bpf_ktime_get_ns()};
    let duration = t - ts;
    let token_add = duration / REFILL_INTERVAL_NS ;

    if token_add > 0 {
       let _ = LAST_TS.insert(src_addr, &t, 0);
    }
    token_add
}

#[inline(always)]
fn consume_token(ctx: &XdpContext, src_addr: &[u8; 4]) -> u64 {
    let token = match unsafe { BUCKET.get(src_addr)} {
        Some(x) => *x,
        None    => TOKEN_BUCKET_SIZE,
    };

    let token_add = refill_tokens(src_addr);
    let token_final = min(token.saturating_sub(1) + token_add, TOKEN_BUCKET_SIZE);
    let _ = BUCKET.insert(src_addr, &token_final, 0);
    info!(ctx, "token {}, token_add {}", token_final, token_add);
    token_final
}

fn try_antiddos_xdp(ctx: XdpContext) -> Result<u32, u32> {
    let src_addr = match filter_tcp_syn_src(&ctx) {
       Some(x) => x,
       None => return Ok(xdp_action::XDP_PASS),
    };

    let token_final = consume_token(&ctx, &src_addr);

    if token_final == 0 {
        info!(&ctx, "drop");
        return Ok(xdp_action::XDP_DROP);
    }

    Ok(xdp_action::XDP_PASS)
}

Testons le code maintenant :

ip netns exec lb cargo run

En lançant successivement cette commande curl :

curl 10.0.0.10:8080

On obtient ce genre d’affichage :

[INFO  antiddos_xdp] token 5, token_add 456446
[INFO  antiddos_xdp] token 5, token_add 5
[INFO  antiddos_xdp] token 5, token_add 5
[INFO  antiddos_xdp] token 4, token_add 0
[INFO  antiddos_xdp] token 4, token_add 1
[INFO  antiddos_xdp] token 3, token_add 0
[INFO  antiddos_xdp] token 3, token_add 1
[INFO  antiddos_xdp] token 2, token_add 0
[INFO  antiddos_xdp] token 2, token_add 1
[INFO  antiddos_xdp] token 1, token_add 0
[INFO  antiddos_xdp] token 1, token_add 1
[INFO  antiddos_xdp] drop
[INFO  antiddos_xdp] token 1, token_add 1
[INFO  antiddos_xdp] token 5, token_add 11

La première fois, on a initialisé à 0 donc la durée est le timestamp. On doit ajouter 456446 jetons mais comme le seau ne peut contenir que 5 jetons on a 5 jetons.

Limite du programme
#

Si tu testes avec des outils comme hping qui peuvent générer aléatoirement des IPs, le programme ne pourra pas faire grand chose contre cela. Ces outils génèrent des paquets qui n’ont pas de cohérence, il vaut mieux le détecter et arrêter le paquet. Ainsi pour des DDoS massifs, un filtrage avec des règles dynamiques, comme Cloudflare le fait serait beaucoup plus efficace.


Cet épisode est maintenant terminé ! Nous avons vu les bases des connexions TCP et des pistes pour atténuer des DDoS.

Jusqu’à maintenant, nous avons soit observé soit bloqué le paquet mais nous n’avons pas encore vu comment modifier le paquet.

Dans le prochain épisode, nous allons voir cela en créant un petit load balancer XDP.

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