Phase 4 : tripoli-netctl, labo network namespaces + veth (rtnetlink)

- CLI (clap) : create/destroy/list. Network namespace créé via
  rtnetlink::NetworkNamespace (fork + unshare(CLONE_NEWNET) + bind-mount,
  équivalent de `ip netns add`), paire veth créée dans la racine, un
  bout déplacé dans le namespace (setns_by_fd) et adressé depuis un
  thread OS dédié qui fait setns() - jamais un thread du pool
  spawn_blocking de tokio, qui serait réutilisé pour d'autres tâches et
  resterait coincé dans le namespace.
- Journal d'audit JSON (stderr + /var/log/tripoli-netctl/audit.log) à
  chaque opération.
- Documenté explicitement comme labo pédagogique un-seul-poste :
  tripoli-edge et tripoli-api restent nativement sur l'hôte, ce projet
  ne pilote aucun trafic de production.
- Testé en conditions réelles sur le serveur : création, adressage des
  deux côtés (dont lo, down par défaut dans un netns neuf), ping
  traversant effectivement la veth, puis destruction propre (veth +
  namespace) vérifiée par absence des interfaces et du namespace.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-09-16 22:58:53 +02:00
parent 546734ad77
commit 991f4dd04f
9 changed files with 688 additions and 2 deletions

17
tripoli-netctl/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "tripoli-netctl"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "time"] }
rtnetlink = "0.23"
nix = { version = "0.31", features = ["sched", "user"] }
futures-util = "0.3"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"

84
tripoli-netctl/README.md Normal file
View file

@ -0,0 +1,84 @@
# tripoli-netctl
Labo pédagogique un-seul-poste sur les network namespaces Linux et la
crate `rtnetlink`. **Ne pilote aucun service de production** :
tripoli-edge et tripoli-api continuent de tourner nativement sur l'hôte,
dans le namespace racine. Ce n'est pas un CNI, pas un orchestrateur -
juste une démonstration, sur une seule machine, de ce qui se passe sous
le capot quand un outil comme Docker ou Kubernetes isole un service en
réseau.
## Ce que fait `create <name>`
1. Crée un network namespace nommé `<name>` (`rtnetlink::NetworkNamespace::add`,
équivalent de `ip netns add` - fork + `unshare(CLONE_NEWNET)` +
bind-mount de `/proc/self/ns/net` sur `/run/netns/<name>`).
2. Crée une paire veth (`tv-<name>h` / `tv-<name>p`, noms tronqués à 6
caractères alphanumériques pour respecter la limite de 15 caractères
des interfaces Linux).
3. Déplace `tv-<name>p` dans le namespace créé (`setns_by_fd`).
4. Adresse le côté hôte (`10.200.<subnet-id>.1/30`) et l'active.
5. Depuis un thread OS dédié qui fait `setns()` vers le namespace cible,
adresse le côté namespace (`10.200.<subnet-id>.2/30`), l'active, et
active `lo` (qui démarre toujours *down* dans un namespace neuf -
détail facile à oublier, source classique de "ça ne marche pas" dans
ce genre de labo).
Chaque étape est journalisée en JSON sur stderr et dans
`/var/log/tripoli-netctl/audit.log`.
## Pourquoi un thread dédié, pas `tokio::task::spawn_blocking`
`setns()` change la namespace réseau du **thread appelant**, pour toute
sa durée de vie. Les threads de `spawn_blocking` sont mis en pool et
réutilisés par tokio pour d'autres tâches bloquantes : y laisser un
thread « coincé » dans un namespace réseau casserait silencieusement
n'importe quelle tâche bloquante ultérieure qui tomberait dessus. Un
`std::thread::spawn` dédié, jamais réutilisé, élimine le risque.
## `destroy <name>`
Supprime le côté hôte de la veth - le noyau supprime automatiquement le
pair, même déplacé dans un autre namespace, dès qu'un des deux bouts
disparaît - puis démonte/supprime le fichier de namespace.
## `list`
Énumère `/run/netns` et vérifie, pour chacun, la présence de la veth
hôte associée.
## Utilisation
```bash
sudo ./tripoli-netctl create demo --subnet-id 5
sudo ./tripoli-netctl list
sudo ./tripoli-netctl destroy demo
```
Root est requis (namespaces + netlink privilégié) - vérifié explicitement
au démarrage plutôt que de laisser échouer la première opération avec un
message obscur.
## Limites assumées (v1)
- Pas d'allocation automatique de sous-réseau : `--subnet-id` est
explicite, une collision entre deux services donnerait une erreur
claire plutôt qu'une allocation silencieuse qui la masquerait.
- IPv4 uniquement.
- Un seul veth par service - pas de topologie plus complexe (bridge,
plusieurs interfaces).
## Vérifié
```
$ sudo tripoli-netctl create demo1 --subnet-id 5
service 'demo1' prêt :
hôte : tv-demo1h 10.200.5.1/30 (namespace racine)
ns : tv-demo1p 10.200.5.2/30 (namespace demo1, /run/netns/demo1)
$ ping -c 2 10.200.5.2 # depuis la racine, vers le namespace
2 packets transmitted, 2 received, 0% packet loss
$ sudo tripoli-netctl destroy demo1
service 'demo1' détruit # veth et namespace bien supprimés, vérifié
```

View file

@ -0,0 +1,46 @@
use std::io::Write;
use chrono::Utc;
use serde::Serialize;
const AUDIT_LOG_PATH: &str = "/var/log/tripoli-netctl/audit.log";
#[derive(Serialize)]
struct AuditEntry<'a> {
timestamp: chrono::DateTime<Utc>,
operation: &'a str,
target: &'a str,
detail: &'a str,
result: &'a str,
}
/// Une ligne JSON par opération, faite à la main (pas de logger
/// structuré générique) : ce fichier n'a qu'un rôle, tracer qui a fait
/// quoi sur les namespaces réseau de cette machine, et doit rester
/// lisible tel quel avec `jq` sans configuration.
pub fn log(operation: &str, target: &str, detail: &str, result: &str) {
let entry = AuditEntry {
timestamp: Utc::now(),
operation,
target,
detail,
result,
};
let line = serde_json::to_string(&entry).expect("sérialisation toujours valide");
eprintln!("{line}");
if let Some(parent) = std::path::Path::new(AUDIT_LOG_PATH).parent() {
if std::fs::create_dir_all(parent).is_err() {
return;
}
}
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(AUDIT_LOG_PATH)
{
let _ = writeln!(file, "{line}");
}
}

View file

@ -0,0 +1,56 @@
mod audit;
mod naming;
mod netns_ops;
use anyhow::Result;
use clap::{Parser, Subcommand};
/// tripoli-netctl - labo pédagogique un-seul-poste sur les network
/// namespaces Linux et la crate `rtnetlink`. Ne pilote AUCUN service de
/// production : tripoli-edge et tripoli-api continuent de tourner
/// nativement sur l'hôte, dans le namespace racine.
#[derive(Parser)]
#[command(name = "tripoli-netctl", version, about)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Crée un network namespace nommé, relié à la racine par une paire veth.
Create {
/// Nom du service (aussi le nom du namespace).
name: String,
/// Détermine le sous-réseau point-à-point utilisé : 10.200.<id>.0/30.
/// Pas d'allocation automatique - un labo n'a pas besoin de cette
/// complexité, et une collision explicite vaut mieux qu'une
/// allocation silencieuse qui masquerait une erreur d'usage.
#[arg(long, default_value_t = 1)]
subnet_id: u8,
},
/// Détruit un service précédemment créé (veth + namespace).
Destroy { name: String },
/// Liste les namespaces actifs et leur veth associée côté hôte.
List,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
if !nix::unistd::geteuid().is_root() {
eprintln!("tripoli-netctl doit tourner en root (network namespaces, netlink privilégié)");
std::process::exit(1);
}
let cli = Cli::parse();
match cli.command {
Command::Create { name, subnet_id } => netns_ops::create(&name, subnet_id).await,
Command::Destroy { name } => netns_ops::destroy(&name).await,
Command::List => netns_ops::list().await,
}
}

View file

@ -0,0 +1,22 @@
/// Linux limite les noms d'interface à 15 caractères (IFNAMSIZ - 1). On
/// dérive un nom court et déterministe à partir du nom de service, pour
/// que `create`/`destroy` retrouvent toujours la même paire veth sans
/// avoir à la stocker séparément.
pub struct VethNames {
pub host: String,
pub peer: String,
}
pub fn veth_names(service_name: &str) -> VethNames {
let short: String = service_name
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.take(6)
.collect();
VethNames {
host: format!("tv-{short}h"),
peer: format!("tv-{short}p"),
}
}

View file

@ -0,0 +1,223 @@
use std::net::{IpAddr, Ipv4Addr};
use std::os::fd::AsRawFd;
use anyhow::{anyhow, bail, Context, Result};
use futures_util::TryStreamExt;
use rtnetlink::{new_connection, Handle, LinkUnspec, LinkVeth, NetworkNamespace};
use crate::audit;
use crate::naming::{veth_names, VethNames};
async fn find_link_index(handle: &Handle, name: &str) -> Result<u32> {
let mut links = handle.link().get().match_name(name.to_string()).execute();
match links
.try_next()
.await
.with_context(|| format!("recherche de l'interface {name}"))?
{
Some(link) => Ok(link.header.index),
None => bail!("interface {name} introuvable"),
}
}
/// Crée un service isolé : un network namespace + une paire veth qui le
/// relie au namespace racine. Labo pédagogique un-seul-poste : ce n'est
/// jamais branché sur le trafic réel de tripoli-edge/tripoli-api, qui
/// continuent de tourner nativement sur l'hôte.
pub async fn create(service_name: &str, subnet_id: u8) -> Result<()> {
let VethNames { host, peer } = veth_names(service_name);
let ns_path = format!("/run/netns/{service_name}");
NetworkNamespace::add(service_name.to_string())
.await
.map_err(|e| anyhow!("création du namespace : {e}"))?;
audit::log("namespace_created", service_name, &ns_path, "ok");
let (connection, handle, _) =
new_connection().context("connexion netlink (namespace racine)")?;
tokio::spawn(connection);
handle
.link()
.add(LinkVeth::new(&host, &peer).build())
.execute()
.await
.with_context(|| format!("création de la paire veth {host}/{peer}"))?;
// L'index du pair doit être résolu AVANT de le déplacer dans le
// namespace : une fois déplacé, il n'est plus visible depuis le
// handle netlink de la racine.
let peer_index = find_link_index(&handle, &peer).await?;
let host_index = find_link_index(&handle, &host).await?;
let ns_file = std::fs::File::open(&ns_path)
.with_context(|| format!("ouverture de {ns_path}"))?;
handle
.link()
.set(LinkUnspec::new_with_index(peer_index).setns_by_fd(ns_file.as_raw_fd()).build())
.execute()
.await
.context("déplacement de l'interface pair dans le namespace")?;
let host_ip = Ipv4Addr::new(10, 200, subnet_id, 1);
let peer_ip = Ipv4Addr::new(10, 200, subnet_id, 2);
handle
.address()
.add(host_index, IpAddr::V4(host_ip), 30)
.execute()
.await
.context("adresse côté hôte")?;
handle
.link()
.set(LinkUnspec::new_with_index(host_index).up().build())
.execute()
.await
.context("activation de l'interface hôte")?;
audit::log(
"veth_created",
&format!("{host}<->{peer}"),
&format!("hôte {host_ip}/30, namespace {service_name}"),
"ok",
);
configure_inside_namespace(ns_path.clone(), peer.clone(), peer_ip)
.context("configuration à l'intérieur du namespace")?;
audit::log(
"namespace_interface_configured",
service_name,
&format!("{peer} {peer_ip}/30"),
"ok",
);
println!("service '{service_name}' prêt :");
println!(" hôte : {host} {host_ip}/30 (namespace racine)");
println!(" ns : {peer} {peer_ip}/30 (namespace {service_name}, {ns_path})");
Ok(())
}
/// Entre dans le namespace cible pour y configurer l'adresse et activer
/// l'interface - et `lo`, qui démarre toujours down dans un namespace
/// neuf. Volontairement un `std::thread::spawn` dédié, jamais réutilisé
/// (pas `tokio::task::spawn_blocking`, dont les threads sont mis en
/// pool et réutilisés) : `setns` change la namespace RÉSEAU du thread
/// appelant pour toute sa durée de vie, et laisser un thread du pool
/// tokio « coincé » dans un namespace ferait planter n'importe quelle
/// tâche bloquante ultérieure qui tomberait dessus.
fn configure_inside_namespace(ns_path: String, if_name: String, ip: Ipv4Addr) -> Result<()> {
let handle = std::thread::spawn(move || -> Result<()> {
let ns_file =
std::fs::File::open(&ns_path).with_context(|| format!("ouverture de {ns_path}"))?;
nix::sched::setns(&ns_file, nix::sched::CloneFlags::CLONE_NEWNET)
.map_err(|e| anyhow!("setns : {e}"))?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("runtime tokio dédié à ce thread")?;
rt.block_on(async move {
let (connection, handle, _) =
new_connection().context("connexion netlink (dans le namespace)")?;
tokio::spawn(connection);
if let Ok(lo_index) = find_link_index(&handle, "lo").await {
handle
.link()
.set(LinkUnspec::new_with_index(lo_index).up().build())
.execute()
.await
.context("activation de lo dans le namespace")?;
}
let if_index = find_link_index(&handle, &if_name).await?;
handle
.address()
.add(if_index, IpAddr::V4(ip), 30)
.execute()
.await
.context("adresse côté namespace")?;
handle
.link()
.set(LinkUnspec::new_with_index(if_index).up().build())
.execute()
.await
.context("activation de l'interface dans le namespace")?;
Ok(())
})
});
handle
.join()
.map_err(|_| anyhow!("le thread de configuration a paniqué"))?
}
/// Supprime la paire veth (la suppression d'un bout supprime
/// automatiquement l'autre, même dans un namespace différent - c'est
/// une propriété du noyau, pas quelque chose que ce code doit gérer),
/// puis le namespace lui-même.
pub async fn destroy(service_name: &str) -> Result<()> {
let VethNames { host, .. } = veth_names(service_name);
let (connection, handle, _) = new_connection().context("connexion netlink")?;
tokio::spawn(connection);
match find_link_index(&handle, &host).await {
Ok(index) => {
handle
.link()
.del(index)
.execute()
.await
.with_context(|| format!("suppression de {host}"))?;
audit::log("veth_deleted", &host, service_name, "ok");
}
Err(_) => {
audit::log("veth_deleted", &host, service_name, "absente, rien à faire");
}
}
NetworkNamespace::del(service_name.to_string())
.await
.map_err(|e| anyhow!("suppression du namespace : {e}"))?;
audit::log("namespace_deleted", service_name, "", "ok");
println!("service '{service_name}' détruit");
Ok(())
}
pub async fn list() -> Result<()> {
let mut names = Vec::new();
if let Ok(entries) = std::fs::read_dir("/run/netns") {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
}
}
if names.is_empty() {
println!("aucun namespace tripoli-netctl actif");
return Ok(());
}
let (connection, handle, _) = new_connection().context("connexion netlink")?;
tokio::spawn(connection);
for name in names {
let VethNames { host, .. } = veth_names(&name);
match find_link_index(&handle, &host).await {
Ok(_) => println!("{name} : veth {host} présente côté hôte"),
Err(_) => {
println!("{name} : namespace présent, pas de veth {host} associée")
}
}
}
Ok(())
}