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:
@@ -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"),
|
||||
];
|
||||
Reference in New Issue
Block a user