Précédemment…#
Dans la précédente partie nous avons vu comment répartir la charge d’un ping sur h2 et h3.
Cependant la commande ping détecte que l’IP vient de h2 ou h3 et non de celle du load balancer :
PING 10.0.0.10 (10.0.0.10) 56(84) bytes of data.
64 bytes from 10.0.0.3: icmp_seq=1 ttl=64 time=0.088 ms (DIFFERENT ADDRESS!)
64 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.098 ms (DIFFERENT ADDRESS!)
64 bytes from 10.0.0.3: icmp_seq=3 ttl=64 time=0.082 ms (DIFFERENT ADDRESS!)
64 bytes from 10.0.0.2: icmp_seq=4 ttl=64 time=0.088 ms (DIFFERENT ADDRESS!)
64 bytes from 10.0.0.2: icmp_seq=5 ttl=64 time=0.081 ms (DIFFERENT ADDRESS!)Nous allons déjà voir en quoi c’est génant et comment nous allons résoudre le problème.
Mais c’est quoi le problème ?#
Un peu de recul#
Dans le réseau qu’on a créé depuis le début :
Tout est à plat, tout le monde peut communiquer avec tout le monde.
En général, les clients, un load balancer et les backends sont dans des réseaux différents. Ainsi la solution de la partie précédente fonctionne uniquement si les backends peuvent router les paquets vers le client ce qui n’est pas forcément le cas pour des questions de sécurité.
Par ailleurs, cela “fonctionne” pour ICMP. Mais pour un autre protocole comme TCP, ça ne fonctionnerait pas : pendant la phase de connexion (3-way handshake), le client détecterait que le SYN envoyé au load balancer n’a pas la même IP que le paquet SYN-ACK du serveur et la connexion n’irait pas plus loin.
La solution actuelle n’est donc pas idéale. Comment fonctionne un “vrai” load balancer ? Il faudrait que le chemin de retour soit symétrique au chemin pris à l’aller pour éviter ce problème.
Actuellement, on a comme chemin aller (echo request) : client → loadbalancer → backend
Et comme chemin retour (echo reply) : backend → client
Il faudrait donc que le chemin retour soit plutôt : backend → load balancer → client
Ainsi le client croiera qu’il ping le load balancer mais pas un des deux backends.
On fait quoi alors ?#
Une solution possible serait d’installer un programme XDP sur veth3 pour rediriger vers lb. Mais il faudrait modifier chaque interface de chaque client et on n’a pas forcément accès au client (imaginez que vous voulez créer un load balancer d’un cloud).
La solution est plutôt de faire croire aux backends que l’IP source n’est pas celle du client (10.0.0.1) mais celle de lb. C’est ce qu’on appelle un SNAT (Source Network Address Translation).
La première idée serait de continuer le programme XDP de la partie précédente en rajoutant le SNAT.
Mais ça ne fonctionne pas : si on réécrit l’IP source en même temps que la destination, le paquet qui vient d’arriver sur veth6 semblerait provenir… de veth6 lui-même.
Pour contourner ce problème, il faut créer un autre programme XDP rattaché à l’interface suivante : veth7. On peut alors modifier l’IP source sans créer d’incohérence.
Mais ce n’est pas fini ! Il faut gérer le retour. En effet le paquet s’arrêterait à lb sinon. Il faut donc au retour modifier le paquet qui est arrivé pour pouvoir joindre le client.
Ainsi ce qu’on va faire aujourd’hui :
- un programme XDP SNAT sur
veth7 - gérer le retour du paquet
Commençons d’abord par créer le programme qui va changer l’IP source.
Créons le load balancer côté SNAT#
Créons le programme xdp Hello World#
Générons déjà le programme Aya :
cargo generate --name lb-xdp-snat \
-d program_type=xdp \
-d default_iface=veth7 \
https://github.com/aya-rs/aya-template
cd lb-xdp-snatEn prévision, nous allons utiliser des crates supplémentaires :
- 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 lb-xdp-snat-ebpf/Cargo.toml et rajouter dans la section dependencies :
network-types = "0.1.0"
blog-xdp = { git = "https://github.com/littlejo/blog-xdp" }Buildons le programme “hello world” et testons le rapidement :
cargo runon fait ping -c1 10.0.0.10, ça fait bien received a packet du côté cargo run.
Récupérons uniquement les pings#
Maintenant, on va récupérer uniquement les paquets ICMP.
On reprend tout ce qu’on a fait précédemment pour cela. On modifie le fichier lb-xdp-snat-ebpf/src/main.rs :
use blog_xdp::helper::{iph_csum, log_icmp, filter_icmp};
fn try_lb_xdp_snat(ctx: XdpContext) -> Result<u32, u32> {
let (ipv4hdr, icmphdr) = match filter_icmp(&ctx) {
Some(x) => x,
None => return Ok(xdp_action::XDP_PASS),
};
log_icmp(&ctx, ipv4hdr, icmphdr);
Ok(xdp_action::XDP_PASS)
}Le message src=10.0.0.10 dst=10.0.0.1 (0) est affiché seulement quand un paquet ICMP est envoyé. C’est à cet endroit où on va développer la deuxième partie du load balancer.
Vérifions que le programme eBPF est bien lancé :
cargo runSur un autre terminal, pingons le futur load balancer :
ping -c 2 10.0.0.10Sur le terminal cargo run, il est affiché :
[INFO blog_xdp::helper] src=10.0.0.10 dst=10.0.0.1 (0)
[INFO blog_xdp::helper] src=10.0.0.10 dst=10.0.0.1 (0)Maintenant qu’on n’a filtré pour avoir uniquement des paquets ICMP, regardons comment on modifie l’adresse.
Modifions l’IP source#
C’est à partir de maintenant que ça devient intéressant.
Il suffit “juste” de modifier une adresse IP par celle du load balancer pour dire aux backends (h2 et h3) de renvoyer au load balancer.
Regardons d’abord ce qu’il se passe quand les deux programmes eBPF tournent en même temps.
Installons le programme XDP qu’on vient de créer :
cargo run
Dans un autre terminal, installons le programme XDP DNAT qu’on a créé dans la précédente partie :
ip netns exec lb cargo run
ping -c 2 10.0.0.10Voilà ce qu’on a fait :
Voyons les logs du programme SNAT :
[INFO blog_xdp::helper] src=10.0.0.10 dst=10.0.0.1 (5)
[INFO blog_xdp::helper] src=10.0.0.1 dst=10.0.0.2 (8)
[INFO blog_xdp::helper] src=10.0.0.10 dst=10.0.0.1 (5)
[INFO blog_xdp::helper] src=10.0.0.1 dst=10.0.0.2 (8)- Le numéro 5 correspond à un redirect request : on ne s’en occupe pas
- Le numéro 8 correspond à un echo request
On va s’occuper uniquement des echo requests et des echo reply, on va donc rajouter le filtre suivant :
let type_ = unsafe { (*icmphdr).type_ };
match type_ {
8 => {},
0 => {},
_ => return Ok(xdp_action::XDP_PASS),
}On va maintenant créer le SNAT : on va changer l’IP source pour faire croire au backend que le paquet d’origine vient du load balancer et non du client :
unsafe {
(*ipv4hdr).src_addr = [10, 0, 0, 10];
let chksum = iph_csum(ipv4hdr);
(*ipv4hdr).set_checksum(chksum);
}Voilà ce qu’on a fait au niveau du paquet :
Le backend renvoie le paquet au load balancer.
Le code principal est alors :
fn try_lb_xdp_snat(ctx: XdpContext) -> Result<u32, u32> {
let (ipv4hdr, icmphdr) = match filter_icmp(&ctx) {
Some(x) => x,
None => return Ok(xdp_action::XDP_PASS),
};
let type_ = unsafe { (*icmphdr).type_ };
match type_ {
8 => {},
0 => {},
_ => return Ok(xdp_action::XDP_PASS),
}
unsafe {
(*ipv4hdr).src_addr = [10, 0, 0, 10];
let chksum = iph_csum(ipv4hdr);
(*ipv4hdr).set_checksum(chksum);
}
log_icmp(&ctx, ipv4hdr, icmphdr);
Ok(xdp_action::XDP_PASS)
}Relançons les deux programmes XDP et regardons ce qu’il se passe. Cela ne ping plus mais on a le log suivant sur le programme SNAT :
[INFO blog_xdp::helper] src=10.0.0.10 dst=10.0.0.2 (8)
[INFO blog_xdp::helper] src=10.0.0.10 dst=10.0.0.3 (0)
[INFO blog_xdp::helper] src=10.0.0.10 dst=10.0.0.2 (8)
[INFO blog_xdp::helper] src=10.0.0.10 dst=10.0.0.2 (0)Le load balancer reçoit bien le paquet retour !
Par ailleurs, pour le chemin retour, on a déjà fait croire à l’initiateur du ping que l’IP source sera le load balancer et non le backend.
Il ne reste plus qu’à modifier le programme pour qu’il renvoie sur l’IP du client d’origine.
Gérons le chemin retour#
On n’est plus très loin du but ! Il ne nous reste plus que cette partie fonctionne maintenant :
On a deux solutions :
- on modifie le programme XDP sur
veth6pour que l’IP de destination soit celle du client - on modifie le programme XDP sur
veth7pour que l’IP de destination soit celle du client
Les 2 sont valables. Vu qu’on a déjà modifié l’adresse de destination sur veth6, la logique est de le faire également sur veth6.
Donc pour la fin de l’article nous allons modifier uniquement le programme de la partie précédente lb_xdp(). Le code du programme SNAT est donc terminé.
Sans suivi de connexion#
On doit donc dire si on a affaire un ping de type “echo reply”, on doit modifier l’adresse de destination. Par contre, le programme eBPF ne connait pas pour l’instant cette adresse. Il va falloir l’aider. Comme on fait le test sur l’hôte, on va d’abord simplifier en hard-codant l’adresse de l’hôte (10.0.0.1) :
let type_ = unsafe { (*icmphdr).type_ };
if type_ == 0 {
unsafe {
(*ipv4hdr).dst_addr = [10, 0, 0, 1];
let chksum = iph_csum(ipv4hdr);
(*ipv4hdr).set_checksum(chksum);
}
}Testons : ça ping bien !
Au niveau de veth6 :
[INFO lb_xdp] src=10.0.0.1 dst=10.0.0.3 (8)
[INFO lb_xdp] src=10.0.0.3 dst=10.0.0.1 (0)Au niveau de veth7 :
[INFO blog_xdp::helper] src=10.0.0.10 dst=10.0.0.3 (8)
[INFO blog_xdp::helper] src=10.0.0.10 dst=10.0.0.1 (0)On va améliorer la fonction lb_dnat() :
#[inline(always)]
fn lb_dnat(ipv4hdr: *mut Ipv4Hdr, icmphdr: *const IcmpHdr) {
let dst_addr_mod = match unsafe {(*icmphdr).type_} {
0 => [10, 0, 0, 1],
_ => {
let backend_index = unsafe { bpf_get_prandom_u32() % 2 + 2 };
[10, 0, 0, backend_index as u8]
}
};
unsafe {
(*ipv4hdr).dst_addr = dst_addr_mod;
let chksum = iph_csum(ipv4hdr);
(*ipv4hdr).set_checksum(chksum);
}
}Et la fonction principale devient alors :
fn try_lb_xdp(ctx: XdpContext) -> Result<u32, u32> {
let (ipv4hdr, icmphdr) = match filter_icmp(&ctx) {
Some(x) => x,
None => return Ok(xdp_action::XDP_PASS),
};
lb_dnat(ipv4hdr, icmphdr);
log_icmp(&ctx, ipv4hdr, icmphdr);
Ok(xdp_action::XDP_PASS)
}Avec suivi de la connexion#
Les en-têtes cachées#
Bon ça fonctionne si on ping depuis l’IP 10.0.0.1 mais si on ping depuis un autre namespace, ça ne marche pas car on l’a hard-codé. Comment ne pas hard-coder cette partie du code. Il faut que le programme eBPF retienne quelle IP source est passée pour pouvoir la renvoyer.
L’idée est de créer une map eBPF qui permet d’enregistrer l’IP source qui est entrée dans veth6. Pour pouvoir le renvoyer au moment du echo reply à la bonne IP.
Par contre il y a un problème : si plusieurs clients ping en même temps, il faut réussir à identifier à qui il faut renvoyer le ping. Pour cela, il faut trouver une donnée commune à un ping.
Rappelons ce que contient l’en-tête ICMP :
- Type : à l’arrivée c’est toujours 8 (echo request) et à la sortie toujours 0 (echo reply). Cela ne va pas nous aider.
- Code : c’est un sous-type qui précise le type, il sera toujours égale à 0 pour le type 8 et 0. Cela ne va pas nous aider.
- Checksum : le checksum va changer entre le paquet en entrée et le paquet en sortie. Cela ne va pas nous aider.
Donc il n’y a rien dans l’en-tête qui peut nous identifier quelques choses de commun.
Mais si tu lis la RFC 792, il y a d’autres en-têtes qui varient selon le type.

Ainsi on peut voir qu’il y a en commun :
- Identifier : un identifiant créé par le client
- Sequence Number : le numéro de séquence qui s’incrémente à chaque ping (vous savez le
icmp_seq=?).
Une approche serait donc de créer une map eBPF : {[id, seq] → ip source}
id vraiment unique et créer une map : {[id_ebpf, seq] => [id_client, ip source]}. Il faudrait également modifier le paquet avec ce nouvel id et modifier le checksum ICMP et à la fin revenir avec l’id du client.Comment récupérer ces en-têtes en Rust ?#
Si tu regardes la documentation de network-types :

On voit qu’il y a data qui est inclus dans l’en-tête icmp. Contiendrait-il Identifier et Sequence Number ? Oui :
The
datafield contains type-specific data such as echo identifiers/sequence numbers, redirect gateway addresses, or pointers to errors in received packets.
Il y a même des méthodes pour les récupérer facilement :

Ainsi on a les méthodes echo_id() et echo_sequence(). Pour les récupérer, on peut écrire cela :
let id_: u16 = unsafe { (*icmphdr).echo_id().map_err(|_| 0u32)? };
let seq_: u16 = unsafe { (*icmphdr).echo_sequence().map_err(|_| 0u32)? };Pour créer la map eBPF :
use aya_ebpf::maps::LruHashMap;
use aya_ebpf::macros::map;
#[map]
pub static SEQ_ID: LruHashMap<[u16; 2], [u8; 4]> = LruHashMap::with_max_entries(16, 0);Au moment de l’arrivée du paquet (echo request) on va insérer l’IP dans la map :
let src_addr = unsafe { (*ipv4hdr).src_addr };
let _ = SEQ_ID.insert(&[seq_, id_], &src_addr, 0);Quand le paquet va revenir (echo reply) on va récupérer l’IP dans la map pour savoir à qui l’envoyer :
let src_ip = unsafe {*SEQ_ID.get(&[seq_, id_]).ok_or(0u32)?}En adaptant pour la fonction lb_dnat, on se retrouve avec :
use aya_ebpf::maps::LruHashMap;
use aya_ebpf::macros::map;
#[map]
pub static SEQ_ID: LruHashMap<[u16; 2], [u8; 4]> = LruHashMap::with_max_entries(16, 0);
#[inline(always)]
fn lb_dnat(ipv4hdr: *mut Ipv4Hdr, icmphdr: *const IcmpHdr) -> Result<u32,u32> {
let id_: u16 = unsafe { (*icmphdr).echo_id().map_err(|_| 0u32)? }; //ADD
let seq_: u16 = unsafe { (*icmphdr).echo_sequence().map_err(|_| 0u32)? }; //ADD
let dst_addr_mod = match unsafe {(*icmphdr).type_} {
0 => unsafe {*SEQ_ID.get(&[seq_, id_]).ok_or(0u32)?}, //INSTEAD OF 10.0.0.1
_ => {
let src_addr = unsafe { (*ipv4hdr).src_addr };
let _ = SEQ_ID.insert(&[seq_, id_], &src_addr, 0); //ADD
let backend_index = unsafe { bpf_get_prandom_u32() % 2 + 2 };
[10, 0, 0, backend_index as u8]
}
};
unsafe {
(*ipv4hdr).dst_addr = dst_addr_mod;
let chksum = iph_csum(ipv4hdr);
(*ipv4hdr).set_checksum(chksum);
}
Ok(0)
}Et le code principal est légèrement modifié:
fn try_lb_xdp(ctx: XdpContext) -> Result<u32, u32> {
let (ipv4hdr, icmphdr) = match filter_icmp(&ctx) {
Some(x) => x,
None => return Ok(xdp_action::XDP_PASS),
};
lb_dnat(ipv4hdr, icmphdr)?; //Just a ?
log_icmp(&ctx, ipv4hdr, icmphdr);
Ok(xdp_action::XDP_PASS)
}Un dernier test#
Vous pouvez tester avec des pings en parallèle, par exemple :
hping3 --icmp-ipid 1 10.0.0.10 --count 5 & \
ip netns exec h3 hping3 --icmp-ipid 3 10.0.0.10 --count 5 & \
ip netns exec h2 hping3 --icmp-ipid 2 10.0.0.10 --count 5hping3, vous pouvez modifier l’id du ping avec l’option --icmp-ipid et de garantir que chaque client utilise un identifiant unique.On a fait quoi au fait ?#
Pour récapituler ce qu’on a mis en place lors de ces deux parties :
- Lors de la réception d’un ICMP Echo Request (ping du client) :
- on a réécrit l’adresse de destination avec une IP d’un des backends → DNAT
- on a stocké l’IP du client dans une map eBPF en prévision du chemin retour → CONNTRACK
- on a réécrit l’adresse source avec l’IP du load balancer pour que le chemin retour passe par le load balancer → SNAT
- Lors de la réception d’un ICMP Echo Reply (réponse du backend) :
- on a récupéré l’IP du client dans la map eBPF → CONNTRACK
- on a réécrit l’adresse de destination avec l’IP du client → DNAT
- on a réécrit l’adresse source avec l’IP du load balancer pour que le client ne détecte pas que l’IP a changé → SNAT
Cet épisode est maintenant terminé !
Nous avons vu les bases de la création d’un load balancer XDP : le DNAT, SNAT, le CONNTRACK et le checksum.
Si vous voulez aller plus loin dans la création d’un load balancer, vous pouvez :
- implémenter un load balancer UDP et TCP
- implémenter un load balancer sur une machine virtuelle pour se rapprocher d’un environnement de production
J’espère que vous avez apprécié cette série d’article !
Vous pouvez retrouver l’ensemble des petits programmes créés pour ces articles :




