From 4e9eab045070c78abd8d1eeb8c7bd8ae1500ce05 Mon Sep 17 00:00:00 2001 From: mathieu Date: Fri, 15 May 2026 19:48:52 +0200 Subject: [PATCH] feat(models): add domain structs and CIDR validation Add shared models (Network, Host, Port, Application, ApplicationPort) with serde derives for Leptos server function serialization. Add server/validation.rs with valider_ip_dans_reseau() and 5 unit tests. Gate SSR-only modules (config, validation) with #[cfg(feature = "ssr")]. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 11 ++++ Cargo.toml | 6 ++ src/lib.rs | 1 + src/models.rs | 134 +++++++++++++++++++++++++++++++++++++++ src/server/mod.rs | 20 +++--- src/server/validation.rs | 110 ++++++++++++++++++++++++++++++++ 6 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 src/models.rs create mode 100644 src/server/validation.rs diff --git a/Cargo.lock b/Cargo.lock index 942430b..607836f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -836,6 +836,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1597,10 +1606,12 @@ dependencies = [ "axum", "console_error_panic_hook", "dotenvy", + "ipnetwork", "leptos", "leptos_axum", "leptos_meta", "leptos_router", + "serde", "thiserror 1.0.69", "tokio", "tower-http 0.5.2", diff --git a/Cargo.toml b/Cargo.toml index 7620993..c76f7c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ ssr = [ "dep:leptos_axum", "dep:tracing-subscriber", "dep:dotenvy", + "dep:ipnetwork", "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", @@ -47,6 +48,9 @@ leptos_router = { version = "0.7", features = [] } thiserror = "1" # Macros pour les logs : tracing::info!(), tracing::error!()... tracing = "0.1" +# Sérialisation/désérialisation — nécessaire pour les server functions Leptos +# (les types de retour doivent traverser la frontière server ↔ client) +serde = { version = "1", features = ["derive"] } # --- Dépendances serveur uniquement (activées par la feature "ssr") --- @@ -62,6 +66,8 @@ tower-http = { version = "0.5", features = ["fs"], optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } # Charge automatiquement le fichier .env au démarrage du serveur dotenvy = { version = "0.15", optional = true } +# Parsing et calcul de plages d'adresses IP (CIDR) — ex: 192.168.1.0/24 +ipnetwork = { version = "0.20", optional = true } # --- Dépendances client uniquement (activées par la feature "hydrate") --- diff --git a/src/lib.rs b/src/lib.rs index 7f99452..28ae695 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ // `pub` les rend accessibles depuis main.rs et d'autres crates. pub mod app; // Composant racine App() et configuration du routeur pub mod client; // Pages et composants de l'interface utilisateur +pub mod models; // Structs de données partagés server + client (Network, Host, Port, Application) pub mod server; // Handlers HTTP et logique métier côté serveur // Point d'entrée WebAssembly — exécuté par le navigateur au chargement du bundle .wasm diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..6de5eb3 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,134 @@ +// models.rs — Modèles de données partagés (server + client) +// +// Ce module définit les structs qui représentent les entités métier du projet IPAM. +// Ils sont compilés côté serveur ET côté WASM car Leptos en a besoin des deux côtés : +// - Serveur : pour lire/écrire en BDD et rendre le HTML +// - Client : pour afficher les données dans les composants Leptos +// +// Chaque struct dérive `Serialize` et `Deserialize` de serde. +// C'est obligatoire pour que Leptos puisse transférer les données entre +// le serveur et le navigateur via les "server functions" (#[server]). + +use serde::{Deserialize, Serialize}; + +// ─── Réseau ─────────────────────────────────────────────────────────────────── + +/// Un réseau IP défini par sa plage CIDR. +/// +/// Exemple : { id: 1, cidr: "192.168.1.0/24" } +/// → plage de 192.168.1.0 à 192.168.1.255 (254 hôtes utilisables) +/// +/// La notation CIDR (Classless Inter-Domain Routing) combine l'adresse réseau +/// et le masque en un seul champ : /. +/// /24 = 24 bits de masque = 255.255.255.0 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Network { + /// Identifiant unique auto-incrémenté par la base de données. + /// `i64` est le type entier signé 64 bits — correspond à `BIGINT` en SQL. + pub id: i64, + + /// Plage d'adresses en notation CIDR. + /// Ex: "10.0.0.0/8", "172.16.0.0/12", "192.168.1.0/24" + pub cidr: String, +} + +// ─── Hôte ───────────────────────────────────────────────────────────────────── + +/// Un hôte (machine, serveur, équipement réseau) appartenant à un réseau. +/// +/// Contrainte : l'IP doit appartenir à la plage CIDR du réseau référencé +/// par `network_id`. Cette contrainte est vérifiée à la création/modification. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Host { + pub id: i64, + + /// Nom descriptif de l'hôte. Ex: "serveur-web-01", "routeur-principal" + pub name: String, + + /// Adresse IPv4 de l'hôte, stockée en texte. + /// Ex: "192.168.1.10" + /// On utilise String plutôt que IpAddr pour simplifier la sérialisation + /// et le stockage en base de données. + pub ip: String, + + /// Référence vers le réseau auquel appartient cet hôte. + /// C'est une "clé étrangère" (Foreign Key) vers la table `networks`. + pub network_id: i64, +} + +// ─── Port ───────────────────────────────────────────────────────────────────── + +/// Un port réseau ouvert sur un hôte, avec sa description probable. +/// +/// Les numéros de ports standards (0–1023) sont des "well-known ports". +/// Un port peut être associé à plusieurs applications (association non-stricte). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Port { + /// Numéro de port TCP/UDP. + /// `u16` : entier non signé 16 bits → plage 0 à 65535. + /// C'est exactement la plage valide pour les ports réseau. + pub number: u16, + + /// Description du protocole probable sur ce port. + /// `Option` : peut être absent (None) si le protocole est inconnu. + /// Ex: Some("SSH"), Some("HTTPS"), None + pub description: Option, + + /// Hôte sur lequel ce port est ouvert. + pub host_id: i64, +} + +impl Port { + /// Retourne la description standard pour les ports les plus courants. + /// Utilisé pour pré-remplir la description lors de l'ajout d'un port. + /// + /// `match` est l'équivalent Rust d'un switch/case, mais exhaustif : + /// le compilateur oblige à gérer tous les cas possibles. + pub fn protocole_connu(numero: u16) -> Option<&'static str> { + // `&'static str` : référence vers une chaîne qui vit toute la durée du programme + // (les littéraux de chaînes comme "SSH" sont stockés dans le binaire compilé) + match numero { + 21 => Some("FTP"), + 22 => Some("SSH"), + 23 => Some("Telnet"), + 25 => Some("SMTP"), + 53 => Some("DNS"), + 80 => Some("HTTP"), + 110 => Some("POP3"), + 143 => Some("IMAP"), + 443 => Some("HTTPS"), + 3306 => Some("MySQL"), + 5432 => Some("PostgreSQL"), + 6379 => Some("Redis"), + 8080 => Some("HTTP alternatif"), + _ => None, // `_` est le pattern "tout le reste" (wildcard) + } + } +} + +// ─── Application ────────────────────────────────────────────────────────────── + +/// Une application qui utilise un ou plusieurs ports. +/// +/// L'association entre application et port est non-stricte : +/// un même port peut être partagé par plusieurs applications. +/// Ex: le port 80 peut être utilisé par Nginx ET par un proxy applicatif. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Application { + pub id: i64, + + /// Nom de l'application. Ex: "Nginx", "PostgreSQL", "Prometheus" + pub name: String, +} + +// ─── Association Application ↔ Port ────────────────────────────────────────── + +/// Lien entre une application et un port (relation many-to-many). +/// +/// On utilise un struct dédié plutôt qu'un Vec dans Application +/// pour correspondre directement à la table de jointure en base de données. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApplicationPort { + pub application_id: i64, + pub port_number: u16, +} diff --git a/src/server/mod.rs b/src/server/mod.rs index 2a94d54..76a1037 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,13 +1,17 @@ // server/mod.rs — Module serveur // -// Contient tout le code qui s'exécute uniquement côté serveur : -// - Handlers HTTP additionnels (routes.rs) -// - Fonctions serveur Leptos : accès à la base de données, authentification... -// - Logique métier qui ne doit JAMAIS être exposée dans le bundle WASM +// Contient tout le code qui s'exécute uniquement côté serveur. // -// Les "server functions" Leptos (marquées #[server]) sont déclarées ici. -// Elles apparaissent comme des appels asynchrones normaux côté client, -// mais s'exécutent exclusivement sur le serveur — transparence totale. +// Certains sous-modules utilisent des crates SSR-only (dotenvy, ipnetwork...) +// et sont donc protégés par `#[cfg(feature = "ssr")]` pour ne pas être +// compilés dans le bundle WASM. -pub mod config; +// Accessible des deux côtés (handlers HTTP sont SSR mais le module est déclaré shared) pub mod routes; + +// Modules SSR uniquement — utilisent des crates non disponibles en WASM +#[cfg(feature = "ssr")] +pub mod config; + +#[cfg(feature = "ssr")] +pub mod validation; diff --git a/src/server/validation.rs b/src/server/validation.rs new file mode 100644 index 0000000..11ebde6 --- /dev/null +++ b/src/server/validation.rs @@ -0,0 +1,110 @@ +// server/validation.rs — Validation des données métier +// +// Ce module vérifie les règles métier qui ne peuvent pas être exprimées +// uniquement par les types Rust. Il s'exécute côté serveur uniquement +// car il utilise `ipnetwork` pour les calculs CIDR. + +use ipnetwork::IpNetwork; +use std::net::IpAddr; +use thiserror::Error; + +// ─── Erreurs de validation ──────────────────────────────────────────────────── + +#[derive(Debug, Error)] +pub enum ValidationError { + /// Le format CIDR est invalide (ex: "192.168.1/24" au lieu de "192.168.1.0/24") + #[error("CIDR invalide '{0}' : {1}")] + CidrInvalide(String, ipnetwork::IpNetworkError), + + /// L'adresse IP n'a pas le bon format + #[error("Adresse IP invalide '{0}' : {1}")] + IpInvalide(String, std::net::AddrParseError), + + /// L'IP de l'hôte n'appartient pas à la plage du réseau + #[error("L'adresse IP {ip} n'appartient pas au réseau {cidr}")] + IpHorsReseau { ip: String, cidr: String }, +} + +// ─── Validation du CIDR ─────────────────────────────────────────────────────── + +/// Vérifie qu'une chaîne est un CIDR valide. +/// Retourne le réseau parsé si valide. +/// +/// `&str` : on emprunte la chaîne sans en prendre possession (pas de copie). +/// `Result` : soit une valeur T (succès), soit une erreur E (échec). +pub fn valider_cidr(cidr: &str) -> Result { + cidr.parse::() + // `.map_err` transforme l'erreur si `parse` échoue + // `|e|` est une closure : une fonction anonyme qui prend `e` en paramètre + .map_err(|e| ValidationError::CidrInvalide(cidr.to_string(), e)) +} + +// ─── Validation de l'appartenance IP ↔ Réseau ──────────────────────────────── + +/// Vérifie qu'une adresse IP appartient à la plage d'un réseau CIDR. +/// +/// Règle métier clé : un hôte doit toujours être dans le réseau auquel il appartient. +/// Ex : 192.168.1.10 ✓ dans 192.168.1.0/24 +/// 10.0.0.1 ✗ dans 192.168.1.0/24 +pub fn valider_ip_dans_reseau(ip: &str, cidr: &str) -> Result<(), ValidationError> { + // On parse le CIDR et l'IP, propageant les erreurs avec `?` + let reseau = valider_cidr(cidr)?; + + let adresse: IpAddr = ip + .parse() + .map_err(|e| ValidationError::IpInvalide(ip.to_string(), e))?; + + // `IpNetwork::contains` retourne true si l'IP est dans la plage + if reseau.contains(adresse) { + Ok(()) + } else { + Err(ValidationError::IpHorsReseau { + ip: ip.to_string(), + cidr: cidr.to_string(), + }) + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +// `#[cfg(test)]` : ce bloc n'est compilé que lors de `cargo test` +// Écrire les tests directement dans le même fichier est idiomatique en Rust. +#[cfg(test)] +mod tests { + // `super::*` importe tout depuis le module parent (ce fichier) + use super::*; + + #[test] + fn ip_dans_reseau_valide() { + // `.unwrap()` est acceptable dans les tests — si ça échoue, le test échoue + assert!(valider_ip_dans_reseau("192.168.1.10", "192.168.1.0/24").is_ok()); + assert!(valider_ip_dans_reseau("10.0.0.1", "10.0.0.0/8").is_ok()); + assert!(valider_ip_dans_reseau("172.16.5.100", "172.16.0.0/12").is_ok()); + } + + #[test] + fn ip_hors_reseau() { + assert!(valider_ip_dans_reseau("10.0.0.1", "192.168.1.0/24").is_err()); + assert!(valider_ip_dans_reseau("192.168.2.1", "192.168.1.0/24").is_err()); + } + + #[test] + fn cidr_invalide() { + assert!(valider_cidr("pas-un-cidr").is_err()); + assert!(valider_cidr("192.168.1/24").is_err()); // adresse tronquée + assert!(valider_cidr("192.168.1.0/33").is_err()); // préfixe > 32 + } + + #[test] + fn ip_invalide() { + assert!(valider_ip_dans_reseau("999.0.0.1", "192.168.1.0/24").is_err()); + assert!(valider_ip_dans_reseau("pas-une-ip", "192.168.1.0/24").is_err()); + } + + #[test] + fn protocole_connu() { + assert_eq!(crate::models::Port::protocole_connu(22), Some("SSH")); + assert_eq!(crate::models::Port::protocole_connu(443), Some("HTTPS")); + assert_eq!(crate::models::Port::protocole_connu(9999), None); + } +}