Files
SentinelMesh/agents/agent-scan-network/src/scanner.rs
T
gilles 57b5fd6b77 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>
2026-05-19 06:13:24 +02:00

172 lines
4.9 KiB
Rust

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"),
];