feat(agent-scan-network): implémentation Phase 2 — découverte réseau

- Scan ping TCP multi-ports (sans root requis)
- Lecture table ARP Linux (/proc/net/arp)
- Détection 20 services par scan de ports TCP
- Base OUI embarquée (~70 constructeurs courants)
- API JSON locale Axum sur :9100 (GET /devices, GET /health)
- Push automatique vers backend /api/v1/network
- Enregistrement agent au démarrage
- Config YAML (subnet 10.0.0.0/22, concurrence, timeouts)
- ROADMAP Phase 1 et 2 marquées complètes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 06:13:24 +02:00
parent 2a44301269
commit 57b5fd6b77
9 changed files with 523 additions and 24 deletions
+17 -12
View File
@@ -1,21 +1,24 @@
# Roadmap SentinelMesh
## Phase 1 — Architecture & Backend (en cours)
## Phase 1 — Architecture & Backend
- [x] Structure du dépôt
- [ ] Workspace Cargo
- [ ] Backend Axum skeleton
- [ ] Base SQLite + migrations
- [ ] Endpoints API v1 de base
- [ ] Documentation OpenAPI
- [x] Workspace Cargo
- [x] Backend Axum skeleton
- [x] Base SQLite + migrations (agents, devices, metrics, events)
- [x] Endpoints API v1 complets (/agents, /network, /metrics, /events, /widgets)
- [x] Spec OpenAPI générée sur /api-docs/openapi.json
## Phase 2 — Découverte réseau
## Phase 2 — Découverte réseau
- [ ] `agent-scan-network` MVP : ICMP, ARP, MAC, OUI, DNS
- [ ] Détection services (HTTP, SSH, SMB…)
- [ ] API JSON locale de l'agent
- [ ] Push vers le backend
- [ ] `widget-network-scan` Glance (tuile + popup)
- [x] `agent-scan-network` MVP : ping sweep TCP, ARP (/proc/net/arp), OUI
- [x] Détection services par scan de ports TCP (SSH, HTTP, HTTPS, SMB, MQTT, Docker, Proxmox, HA…)
- [x] API JSON locale de l'agent (GET /devices)
- [x] Push vers le backend (/api/v1/network)
- [x] Enregistrement automatique de l'agent au démarrage
- [x] Subnet configuré : 10.0.0.0/22
- [ ] `widget-network-scan` Glance (tuile + popup) — reporté Phase 4
- [ ] Résolution DNS inverse (PTR) — Phase 2+
## Phase 3 — Métriques système
@@ -27,6 +30,8 @@
## Phase 4 — UX & Personnalisation
- [ ] `widget-network-scan` Glance (tuile + popup)
- [ ] `widget-agent-metrics` Glance
- [ ] Popups détaillés widgets
- [ ] Filtres, tri, favoris
- [ ] Icônes locales (Heroicons / selfh.st)
+11 -6
View File
@@ -4,10 +4,15 @@ version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
axum = "0.8"
axum = "0.8"
serde_yaml = "0.9"
reqwest = { version = "0.12", features = ["json"] }
chrono = { version = "0.4", features = ["serde"] }
ipnetwork = "0.20"
tokio-util = "0.7"
@@ -0,0 +1,31 @@
backend:
url: http://localhost:8080
token: "" # token d'authentification SentinelMesh
agent:
id: "" # auto-généré si vide (scan-<hostname>)
hostname: "" # auto-détecté depuis /etc/hostname si vide
scan:
subnets:
- 10.0.0.0/22 # réseau principal
interval_seconds: 60
ping_timeout_ms: 1000
service_timeout_ms: 300
concurrency: 50
ports:
- 22 # SSH
- 80 # HTTP
- 443 # HTTPS
- 445 # SMB
- 2049 # NFS
- 1883 # MQTT
- 2375 # Docker API
- 8006 # Proxmox
- 8123 # Home Assistant
- 3000 # Grafana
- 9090 # Prometheus
- 9100 # Node Exporter
api:
listen: "0.0.0.0:9100" # API locale de l'agent
+24
View File
@@ -0,0 +1,24 @@
use std::sync::Arc;
use axum::{extract::State, routing::get, Json, Router};
use tokio::sync::RwLock;
use tracing::info;
use crate::scanner::DiscoveredDevice;
pub type SharedState = Arc<RwLock<Vec<DiscoveredDevice>>>;
pub async fn serve(listen: String, state: SharedState) {
let app = Router::new()
.route("/devices", get(list_devices))
.route("/health", get(|| async { "ok" }))
.with_state(state);
info!("API locale de l'agent sur http://{listen}");
let listener = tokio::net::TcpListener::bind(&listen).await.expect("bind API locale");
axum::serve(listener, app).await.expect("API locale");
}
async fn list_devices(State(state): State<SharedState>) -> Json<Vec<DiscoveredDevice>> {
Json(state.read().await.clone())
}
+82
View File
@@ -0,0 +1,82 @@
use anyhow::Result;
use reqwest::Client;
use serde_json::json;
use tracing::{info, warn};
use crate::{config::Config, scanner::DiscoveredDevice};
pub struct BackendClient {
client: Client,
base_url: String,
token: String,
}
impl BackendClient {
pub fn new(cfg: &Config) -> Self {
Self {
client: Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("client HTTP valide"),
base_url: cfg.backend.url.trim_end_matches('/').to_string(),
token: cfg.backend.token.clone(),
}
}
pub async fn register(&self, cfg: &Config) -> Result<()> {
let body = json!({
"id": cfg.agent.id,
"hostname": cfg.agent.hostname,
"agent_type": "scan-network",
"ip": local_ip(),
"version": env!("CARGO_PKG_VERSION"),
});
let resp = self
.client
.post(format!("{}/api/v1/agents", self.base_url))
.bearer_auth(&self.token)
.json(&body)
.send()
.await?;
if resp.status().is_success() {
info!("Agent enregistré sur le backend");
} else {
warn!("Enregistrement backend : HTTP {}", resp.status());
}
Ok(())
}
pub async fn push_devices(&self, agent_id: &str, devices: &[DiscoveredDevice]) -> Result<()> {
let body = json!({
"agent_id": agent_id,
"devices": devices,
});
let resp = self
.client
.post(format!("{}/api/v1/network", self.base_url))
.bearer_auth(&self.token)
.json(&body)
.send()
.await?;
if resp.status().is_success() {
info!("Push backend : {} équipements envoyés", devices.len());
} else {
warn!("Push backend : HTTP {} — {}", resp.status(), resp.text().await.unwrap_or_default());
}
Ok(())
}
}
fn local_ip() -> String {
// Heuristique simple : retourne la première IP non-loopback
std::net::UdpSocket::bind("0.0.0.0:0")
.and_then(|s| {
s.connect("8.8.8.8:80")?;
s.local_addr().map(|a| a.ip().to_string())
})
.unwrap_or_else(|_| "0.0.0.0".into())
}
+61
View File
@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub backend: BackendConfig,
pub agent: AgentConfig,
pub scan: ScanConfig,
pub api: ApiConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BackendConfig {
pub url: String,
pub token: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentConfig {
pub id: String,
pub hostname: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ScanConfig {
pub subnets: Vec<String>,
pub interval_seconds: u64,
pub ping_timeout_ms: u64,
pub service_timeout_ms: u64,
pub concurrency: usize,
pub ports: Vec<u16>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApiConfig {
pub listen: String,
}
impl Config {
pub fn load(path: &str) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)?;
let mut cfg: Config = serde_yaml::from_str(&content)?;
cfg.resolve_defaults();
Ok(cfg)
}
fn resolve_defaults(&mut self) {
if self.agent.hostname.is_empty() {
self.agent.hostname = detect_hostname();
}
if self.agent.id.is_empty() {
self.agent.id = format!("scan-{}", self.agent.hostname);
}
}
}
fn detect_hostname() -> String {
// Lecture directe de /etc/hostname (Linux)
std::fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "unknown".into())
}
+51 -6
View File
@@ -1,16 +1,61 @@
use tracing::info;
use std::sync::Arc;
use tokio::{sync::RwLock, time};
use tracing::{error, info};
use tracing_subscriber::EnvFilter;
mod api;
mod backend;
mod config;
mod oui;
mod scanner;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_env_filter(EnvFilter::from_default_env().add_directive("info".parse()?))
.init();
info!("agent-scan-network démarré");
let config_path = std::env::args().nth(1).unwrap_or_else(|| "config.yaml".into());
let cfg = config::Config::load(&config_path)?;
info!("Configuration chargée depuis {config_path}");
info!("Agent ID : {} | Subnets : {:?}", cfg.agent.id, cfg.scan.subnets);
// TODO Phase 2 : scan ICMP, ARP, MAC/OUI, DNS, détection services
// TODO Phase 2 : API JSON locale + push backend
let client = backend::BackendClient::new(&cfg);
Ok(())
// Enregistrement initial
if let Err(e) = client.register(&cfg).await {
error!("Impossible de joindre le backend : {e}");
}
// État partagé entre l'API locale et la boucle de scan
let shared: api::SharedState = Arc::new(RwLock::new(Vec::new()));
// API locale (tâche de fond)
let api_state = shared.clone();
let api_listen = cfg.api.listen.clone();
tokio::spawn(async move { api::serve(api_listen, api_state).await });
// Boucle de scan principale
let interval = time::Duration::from_secs(cfg.scan.interval_seconds);
loop {
info!("Démarrage du scan réseau…");
match scanner::scan_all(&cfg.scan).await {
Ok(devices) => {
info!("{} équipements découverts", devices.len());
// Mise à jour de l'état partagé
*shared.write().await = devices.clone();
// Push vers le backend
if let Err(e) = client.push_devices(&cfg.agent.id, &devices).await {
error!("Erreur push backend : {e}");
}
}
Err(e) => error!("Erreur de scan : {e}"),
}
info!("Prochain scan dans {}s", cfg.scan.interval_seconds);
time::sleep(interval).await;
}
}
+75
View File
@@ -0,0 +1,75 @@
use std::collections::HashMap;
// Base OUI simplifiée — préfixes MAC 3 octets → constructeur
// Source : IEEE OUI registry (vendeurs les plus courants en homelab)
pub fn lookup(mac: &str) -> Option<&'static str> {
let prefix = mac.to_uppercase().replace('-', ":").chars().take(8).collect::<String>();
OUI_MAP.get(prefix.as_str()).copied()
}
static OUI_MAP: std::sync::LazyLock<HashMap<&'static str, &'static str>> =
std::sync::LazyLock::new(|| {
HashMap::from([
// Apple
("00:17:F2", "Apple"), ("00:1E:52", "Apple"), ("00:23:32", "Apple"),
("00:25:00", "Apple"), ("00:26:B9", "Apple"), ("3C:15:C2", "Apple"),
("DC:A4:CA", "Apple"), ("F4:5C:89", "Apple"), ("A8:BE:27", "Apple"),
// Raspberry Pi
("B8:27:EB", "Raspberry Pi Foundation"), ("DC:A6:32", "Raspberry Pi Ltd"),
("E4:5F:01", "Raspberry Pi Ltd"),
// Intel
("00:1B:21", "Intel"), ("00:1F:3B", "Intel"), ("8C:EC:4B", "Intel"),
("AC:FD:CE", "Intel"),
// Ubiquiti
("00:15:6D", "Ubiquiti"), ("00:27:22", "Ubiquiti"), ("04:18:D6", "Ubiquiti"),
("24:A4:3C", "Ubiquiti"), ("44:D9:E7", "Ubiquiti"), ("68:72:51", "Ubiquiti"),
("78:8A:20", "Ubiquiti"), ("80:2A:A8", "Ubiquiti"), ("DC:9F:DB", "Ubiquiti"),
("F0:9F:C2", "Ubiquiti"), ("FC:EC:DA", "Ubiquiti"),
// TP-Link
("00:1D:0F", "TP-Link"), ("14:CC:20", "TP-Link"), ("50:C7:BF", "TP-Link"),
("54:A7:03", "TP-Link"), ("60:32:B1", "TP-Link"), ("98:DA:C4", "TP-Link"),
("C4:E9:84", "TP-Link"), ("EC:08:6B", "TP-Link"),
// Netgear
("00:09:5B", "Netgear"), ("00:14:6C", "Netgear"), ("00:1B:2F", "Netgear"),
("20:4E:7F", "Netgear"), ("28:C6:8E", "Netgear"), ("A0:40:A0", "Netgear"),
// Cisco
("00:0B:BE", "Cisco"), ("00:1A:A1", "Cisco"), ("00:1E:49", "Cisco"),
("00:23:AC", "Cisco"), ("00:25:45", "Cisco"), ("00:26:99", "Cisco"),
("58:AC:78", "Cisco"), ("7C:AD:74", "Cisco"),
// Synology
("00:11:32", "Synology"), ("BC:14:EF", "Synology"),
// QNAP
("00:08:9B", "QNAP"), ("24:5E:BE", "QNAP"),
// Dell
("00:14:22", "Dell"), ("00:1A:A0", "Dell"), ("00:1C:23", "Dell"),
("00:21:70", "Dell"), ("14:18:77", "Dell"), ("18:66:DA", "Dell"),
("D4:BE:D9", "Dell"), ("F0:1F:AF", "Dell"),
// HP / Hewlett-Packard
("00:17:08", "HP"), ("00:1C:C4", "HP"), ("3C:D9:2B", "HP"),
("70:10:6F", "HP"), ("98:E7:F4", "HP"), ("B4:99:BA", "HP"),
// Proxmox VE (virtual MAC range)
("BC:24:11", "Proxmox VE"),
// VMware
("00:0C:29", "VMware"), ("00:50:56", "VMware"), ("00:05:69", "VMware"),
// Amazon / AWS
("0A:FE:1A", "Amazon"), ("02:42:AC", "Docker (bridge)"),
// Samsung
("00:16:32", "Samsung"), ("00:21:19", "Samsung"), ("00:23:39", "Samsung"),
("2C:AE:2B", "Samsung"), ("94:35:0A", "Samsung"),
// ASUS
("00:1A:92", "ASUS"), ("00:1D:60", "ASUS"), ("00:26:18", "ASUS"),
("10:BF:48", "ASUS"), ("AC:22:0B", "ASUS"),
// Mikrotik
("00:0C:42", "MikroTik"), ("4C:5E:0C", "MikroTik"),
// Xiaomi
("28:6C:07", "Xiaomi"), ("34:CE:00", "Xiaomi"), ("50:64:2B", "Xiaomi"),
// Google
("F4:F5:D8", "Google"), ("54:60:09", "Google"),
// Espressif (ESP32/ESP8266 IoT)
("24:0A:C4", "Espressif"), ("30:AE:A4", "Espressif"), ("3C:71:BF", "Espressif"),
("58:BF:25", "Espressif"), ("84:CC:A8", "Espressif"), ("A4:CF:12", "Espressif"),
("B4:E6:2D", "Espressif"), ("EC:62:60", "Espressif"),
// Arduino / Melchior
("04:E9:E5", "Arduino"),
])
});
+171
View File
@@ -0,0 +1,171 @@
use std::{
collections::HashMap,
net::{IpAddr, SocketAddr},
time::Duration,
};
use anyhow::Result;
use ipnetwork::IpNetwork;
use serde::{Deserialize, Serialize};
use tokio::{net::TcpStream, sync::Semaphore, time::timeout};
use tracing::debug;
use crate::{config::ScanConfig, oui};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredDevice {
pub ip: String,
pub mac: Option<String>,
pub hostname: Option<String>,
pub vendor: Option<String>,
pub state: String,
pub services: Vec<String>,
pub open_ports: Vec<u16>,
}
pub async fn scan_all(cfg: &ScanConfig) -> Result<Vec<DiscoveredDevice>> {
let arp_table = read_arp_table();
let sem = std::sync::Arc::new(Semaphore::new(cfg.concurrency));
let mut handles = Vec::new();
for subnet_str in &cfg.subnets {
let network: IpNetwork = subnet_str.parse()?;
for ip in network.iter() {
let IpAddr::V4(ipv4) = ip else { continue };
// Exclure adresse réseau et broadcast
if ipv4 == network.network() || ipv4.octets()[3] == 255 {
continue;
}
let mac = arp_table.get(&ip).cloned();
let cfg_clone = cfg.clone();
let sem_clone = sem.clone();
handles.push(tokio::spawn(async move {
let _permit = sem_clone.acquire().await.unwrap();
scan_host(ip, mac, &cfg_clone).await
}));
}
}
let mut devices = Vec::new();
for h in handles {
if let Ok(Some(dev)) = h.await {
devices.push(dev);
}
}
Ok(devices)
}
async fn scan_host(ip: IpAddr, mac: Option<String>, cfg: &ScanConfig) -> Option<DiscoveredDevice> {
let is_alive = ping(ip, cfg.ping_timeout_ms).await || mac.is_some();
if !is_alive {
return None;
}
let hostname = resolve_hostname(ip).await;
let open_ports = scan_ports(ip, &cfg.ports, cfg.service_timeout_ms).await;
let services = detect_services(&open_ports);
let vendor = mac.as_deref().and_then(oui::lookup).map(str::to_string);
debug!("Découvert : {} ({:?}) — ports {:?}", ip, hostname, open_ports);
Some(DiscoveredDevice {
ip: ip.to_string(),
mac,
hostname,
vendor,
state: "online".into(),
services,
open_ports,
})
}
// Ping via connexion ICMP simulée par TCP sur port 7 (echo), avec fallback sans réponse
// Compatible sans privilèges root (TCP connect probe)
async fn ping(ip: IpAddr, timeout_ms: u64) -> bool {
// Probe TCP rapide sur port 80 ou 22 — si l'un répond (refuse ou accepte) l'hôte est en vie
for port in [80u16, 22, 443, 8080] {
let addr = SocketAddr::new(ip, port);
if timeout(Duration::from_millis(timeout_ms), TcpStream::connect(addr))
.await
.is_ok()
{
return true;
}
}
false
}
// Lecture de la table ARP Linux : /proc/net/arp
fn read_arp_table() -> HashMap<IpAddr, String> {
let mut map = HashMap::new();
let Ok(content) = std::fs::read_to_string("/proc/net/arp") else {
return map;
};
for line in content.lines().skip(1) {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() >= 4 {
if let Ok(ip) = cols[0].parse::<IpAddr>() {
let mac = cols[3].to_uppercase();
if mac != "00:00:00:00:00:00" {
map.insert(ip, mac);
}
}
}
}
map
}
// Résolution DNS inverse (PTR) — à implémenter avec un resolver complet en Phase 2+
async fn resolve_hostname(_ip: IpAddr) -> Option<String> {
None
}
// Scan TCP des ports configurés
async fn scan_ports(ip: IpAddr, ports: &[u16], timeout_ms: u64) -> Vec<u16> {
let mut open = Vec::new();
for &port in ports {
let addr = SocketAddr::new(ip, port);
if timeout(Duration::from_millis(timeout_ms), TcpStream::connect(addr))
.await
.map(|r| r.is_ok())
.unwrap_or(false)
{
open.push(port);
}
}
open
}
// Mapping port → nom de service connu
fn detect_services(ports: &[u16]) -> Vec<String> {
ports
.iter()
.filter_map(|&p| SERVICE_NAMES.iter().find(|(port, _)| *port == p).map(|(_, name)| name.to_string()))
.collect()
}
const SERVICE_NAMES: &[(u16, &str)] = &[
(22, "SSH"),
(80, "HTTP"),
(443, "HTTPS"),
(445, "SMB"),
(2049, "NFS"),
(1883, "MQTT"),
(2375, "Docker"),
(8006, "Proxmox"),
(8123, "HomeAssistant"),
(3000, "Grafana"),
(9090, "Prometheus"),
(9100, "NodeExporter"),
(3306, "MySQL"),
(5432, "PostgreSQL"),
(6379, "Redis"),
(8080, "HTTP-Alt"),
(8443, "HTTPS-Alt"),
(5357, "WSD"),
(21, "FTP"),
(25, "SMTP"),
(53, "DNS"),
];