From 991f4dd04f111c903d9244b1dcd4584c0509e7f4 Mon Sep 17 00:00:00 2001 From: Adam Belkacemi Date: Wed, 16 Sep 2026 22:58:53 +0200 Subject: [PATCH] Phase 4 : tripoli-netctl, labo network namespaces + veth (rtnetlink) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Cargo.lock | 238 ++++++++++++++++++++++++++++++++ Cargo.toml | 2 +- README.md | 2 +- tripoli-netctl/Cargo.toml | 17 +++ tripoli-netctl/README.md | 84 +++++++++++ tripoli-netctl/src/audit.rs | 46 ++++++ tripoli-netctl/src/main.rs | 56 ++++++++ tripoli-netctl/src/naming.rs | 22 +++ tripoli-netctl/src/netns_ops.rs | 223 ++++++++++++++++++++++++++++++ 9 files changed, 688 insertions(+), 2 deletions(-) create mode 100644 tripoli-netctl/Cargo.toml create mode 100644 tripoli-netctl/README.md create mode 100644 tripoli-netctl/src/audit.rs create mode 100644 tripoli-netctl/src/main.rs create mode 100644 tripoli-netctl/src/naming.rs create mode 100644 tripoli-netctl/src/netns_ops.rs diff --git a/Cargo.lock b/Cargo.lock index ef76000..416d6d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,62 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "argon2" version = "0.5.3" @@ -208,6 +264,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chrono" version = "0.4.45" @@ -222,6 +284,52 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "const-oid" version = "0.9.6" @@ -891,6 +999,12 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -1074,6 +1188,77 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +[[package]] +name = "netlink-packet-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6e2daf9a8c2e21302714706860a0e6be02e03d17294ecc66b354ea7d4059dd" + +[[package]] +name = "netlink-packet-route" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d48ffc8b73a506e1ff0878528c72668f2bfbc3d875b6be890a96be5d0d5576" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core", + "zerocopy", +] + +[[package]] +name = "netlink-proto" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97e03ae6247c6cdb499e8f14e98c72b83954d85822b687056e3a21150b438f2b" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror", +] + +[[package]] +name = "netlink-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99d38e00b420df49e940fe0826e667c3dad82ac68eb4c2bbf754c1c14a83a10" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1140,6 +1325,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "parking" version = "2.2.1" @@ -1381,6 +1572,24 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rtnetlink" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f60a3ee149005c5819e03e3d73ba4a470bc18b52b0c8422cfe1bea2cae63c6" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "nix 0.30.1", + "thiserror", + "tokio", +] + [[package]] name = "rustls" version = "0.23.45" @@ -1821,6 +2030,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2171,6 +2386,23 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "tripoli-netctl" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "futures-util", + "nix 0.31.3", + "rtnetlink", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2240,6 +2472,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.26.1" diff --git a/Cargo.toml b/Cargo.toml index 4a7194f..5955ce3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] resolver = "2" -members = ["tripoli-edge", "tripoli-api"] +members = ["tripoli-edge", "tripoli-api", "tripoli-netctl"] diff --git a/README.md b/README.md index ff7e2a6..db40185 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ tenté, non simulé. | OpenTofu (serveur + DNS) | Code écrit — `tofu/` ; serveur en donnée en lecture seule (jamais recréé/détruit), DNS géré via le provider OVH. `plan`/`apply` pas encore exécutés (nécessitent des identifiants OVH/Hetzner non fournis) | | `tripoli-edge` (Rust, Phase 2) | Fait et déployé en prod — `tripoli-edge/`, service systemd, derrière Caddy, vérifié via `https://tripoli.cloud/healthz` et `/metrics` | | `tripoli-api` (Rust, Phase 3) | Fait et déployé en prod — `tripoli-api/`, auth par session (Argon2), upload/download, PostgreSQL dédié (Docker), vérifié via `https://tripoli.cloud/api/...` | -| `tripoli-netctl` (Rust, Phase 4) | Pas commencé | +| `tripoli-netctl` (Rust, Phase 4) | Fait — `tripoli-netctl/`, labo pédagogique (netns + veth via `rtnetlink`), testé sur le serveur (création, ping traversant la veth, destruction propre), **volontairement pas branché sur le trafic de prod** | | Observabilité dédiée (Phase 5) | Pas commencé (une stack Prometheus/Grafana existe déjà sur le serveur, partagée avec les autres projets) | | Bird/BGP (Phase 6, bonus) | Pas commencé | diff --git a/tripoli-netctl/Cargo.toml b/tripoli-netctl/Cargo.toml new file mode 100644 index 0000000..2fb9dd7 --- /dev/null +++ b/tripoli-netctl/Cargo.toml @@ -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" diff --git a/tripoli-netctl/README.md b/tripoli-netctl/README.md new file mode 100644 index 0000000..edb1220 --- /dev/null +++ b/tripoli-netctl/README.md @@ -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 ` + +1. Crée un network namespace nommé `` (`rtnetlink::NetworkNamespace::add`, + équivalent de `ip netns add` - fork + `unshare(CLONE_NEWNET)` + + bind-mount de `/proc/self/ns/net` sur `/run/netns/`). +2. Crée une paire veth (`tv-h` / `tv-p`, noms tronqués à 6 + caractères alphanumériques pour respecter la limite de 15 caractères + des interfaces Linux). +3. Déplace `tv-p` dans le namespace créé (`setns_by_fd`). +4. Adresse le côté hôte (`10.200..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..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 ` + +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é +``` diff --git a/tripoli-netctl/src/audit.rs b/tripoli-netctl/src/audit.rs new file mode 100644 index 0000000..911ef84 --- /dev/null +++ b/tripoli-netctl/src/audit.rs @@ -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, + 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}"); + } +} diff --git a/tripoli-netctl/src/main.rs b/tripoli-netctl/src/main.rs new file mode 100644 index 0000000..589fdf6 --- /dev/null +++ b/tripoli-netctl/src/main.rs @@ -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..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, + } +} diff --git a/tripoli-netctl/src/naming.rs b/tripoli-netctl/src/naming.rs new file mode 100644 index 0000000..a577bc2 --- /dev/null +++ b/tripoli-netctl/src/naming.rs @@ -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"), + } +} diff --git a/tripoli-netctl/src/netns_ops.rs b/tripoli-netctl/src/netns_ops.rs new file mode 100644 index 0000000..29db65d --- /dev/null +++ b/tripoli-netctl/src/netns_ops.rs @@ -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 { + 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(()) +}