Skip to main content

netscli_core/
sweep.rs

1use crate::discover::{DiscoverEngine, Host};
2use crate::error::Result;
3use crate::scan::{PortResult, PortScanner};
4use futures::stream::{self, StreamExt};
5use ipnet::Ipv4Net;
6use serde::Serialize;
7use std::sync::{
8    atomic::{AtomicUsize, Ordering},
9    Arc,
10};
11
12#[derive(Debug, Clone, Serialize)]
13pub struct SweepEntry {
14    pub host: Host,
15    pub open_ports: Vec<PortResult>,
16}
17
18#[derive(Debug, Clone, Copy)]
19pub enum SweepPhase {
20    DiscoverPing,
21    DiscoverResolve,
22    Scan,
23}
24
25#[derive(Debug, Clone)]
26pub struct SweepProgress {
27    pub phase: SweepPhase,
28    pub completed: usize,
29    pub total: usize,
30    pub found: usize,
31    pub ip: std::net::IpAddr,
32}
33
34pub struct SweepEngine {
35    discover: DiscoverEngine,
36    scanner: PortScanner,
37    scan_timeout_ms: u64,
38    host_concurrency: usize,
39}
40
41impl SweepEngine {
42    pub fn new(concurrency: usize) -> Self {
43        Self::new_with_timeouts(
44            concurrency,
45            crate::DEFAULT_PING_TIMEOUT_MS,
46            crate::DEFAULT_SCAN_TIMEOUT_MS,
47            crate::DEFAULT_DNS_TIMEOUT_MS,
48        )
49    }
50
51    pub fn new_with_timeouts(
52        concurrency: usize,
53        ping_timeout_ms: u64,
54        scan_timeout_ms: u64,
55        dns_timeout_ms: u64,
56    ) -> Self {
57        let concurrency = concurrency.clamp(1, crate::MAX_CONCURRENCY);
58        Self {
59            discover: DiscoverEngine::new_with_timeouts(
60                concurrency,
61                ping_timeout_ms,
62                dns_timeout_ms,
63            ),
64            scanner: PortScanner::new(concurrency),
65            scan_timeout_ms: scan_timeout_ms.max(1),
66            host_concurrency: concurrency,
67        }
68    }
69
70    pub async fn sweep(
71        &self,
72        subnet: Ipv4Net,
73        ports: Vec<u16>,
74        resolve_hostnames: bool,
75    ) -> Result<Vec<SweepEntry>> {
76        self.sweep_with_progress(subnet, ports, resolve_hostnames, None)
77            .await
78    }
79
80    /// Sweep a network with bounded concurrency.
81    ///
82    /// Progress reporting is optional and is invoked after each host scan completes.
83    pub async fn sweep_with_progress(
84        &self,
85        subnet: Ipv4Net,
86        ports: Vec<u16>,
87        resolve_hostnames: bool,
88        progress: Option<Arc<dyn Fn(SweepProgress) + Send + Sync>>,
89    ) -> Result<Vec<SweepEntry>> {
90        // Validate before the ping phase, not after it. This is public API
91        // re-exported at the crate root, and it used to trust the caller: a
92        // library caller passing port 0 got a full slow ping sweep and then a
93        // plausible result where every host had no open ports, because the
94        // per-host error below is deliberately swallowed. The comment there
95        // claiming the caller had already validated is only true now.
96        crate::validate_ports(&ports)?;
97        let discover_progress = progress.clone().map(|cb| {
98            Arc::new(move |p: crate::discover::DiscoverProgress| {
99                let phase = match p.phase {
100                    crate::discover::DiscoverPhase::Ping => SweepPhase::DiscoverPing,
101                    crate::discover::DiscoverPhase::Resolve => SweepPhase::DiscoverResolve,
102                };
103                cb(SweepProgress {
104                    phase,
105                    completed: p.completed,
106                    total: p.total,
107                    found: p.found,
108                    ip: p.ip,
109                });
110            }) as Arc<dyn Fn(crate::discover::DiscoverProgress) + Send + Sync>
111        });
112
113        let hosts = self
114            .discover
115            .scan_subnet_with_progress(subnet, resolve_hostnames, discover_progress)
116            .await?;
117        let total_hosts = hosts.len();
118        let completed = Arc::new(AtomicUsize::new(0));
119        let open_hosts = Arc::new(AtomicUsize::new(0));
120        let ports = Arc::new(ports);
121
122        let scan_timeout_ms = self.scan_timeout_ms;
123        let scanner = self.scanner.clone();
124
125        let mut entries = stream::iter(hosts)
126            .map(|h| {
127                let scanner = scanner.clone();
128                let ports = ports.clone();
129                let completed = completed.clone();
130                let open_hosts = open_hosts.clone();
131                let progress = progress.clone();
132                async move {
133                    let host_ip = h.ip;
134                    // The port list is validated once by the caller, so a
135                    // per-host failure here would be the same error repeated
136                    // for every host. Treat it as "no open ports" for this
137                    // entry rather than aborting the whole sweep.
138                    let open_ports = scanner
139                        .scan_host(host_ip, (*ports).clone(), scan_timeout_ms)
140                        .await
141                        .unwrap_or_default()
142                        .into_iter()
143                        .filter(|p| p.open)
144                        .collect();
145
146                    let entry = SweepEntry {
147                        host: h,
148                        open_ports,
149                    };
150
151                    let done = completed.fetch_add(1, Ordering::SeqCst) + 1;
152                    if let Some(cb) = &progress {
153                        let found = if entry.open_ports.is_empty() {
154                            open_hosts.load(Ordering::SeqCst)
155                        } else {
156                            open_hosts.fetch_add(1, Ordering::SeqCst) + 1
157                        };
158                        cb(SweepProgress {
159                            phase: SweepPhase::Scan,
160                            completed: done,
161                            total: total_hosts,
162                            found,
163                            ip: host_ip,
164                        });
165                    }
166
167                    entry
168                }
169            })
170            .buffer_unordered(self.host_concurrency)
171            .collect::<Vec<SweepEntry>>()
172            .await;
173
174        // Provide deterministic output order (useful for CLI/TUI and tests).
175        entries.sort_by_key(|e| e.host.ip);
176        Ok(entries)
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::SweepEntry;
183    use crate::discover::Host;
184    use crate::scan::{PortResult, PortStatus};
185    use std::net::{IpAddr, Ipv4Addr};
186
187    #[test]
188    fn sweep_entry_preserves_host_inventory_and_open_ports() {
189        let entry = SweepEntry {
190            host: Host {
191                ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 125)),
192                hostname: Some("lamp.local".to_string()),
193                mac: Some("00:17:88:6E:6C:5C".to_string()),
194                vendor: Some("Philips Lighting BV".to_string()),
195                rtt_ms: Some(4),
196                found_by: crate::FoundBy::Probe,
197                hostname_source: Some(crate::NameSource::Mdns),
198            },
199            open_ports: vec![PortResult {
200                port: 443,
201                protocol: crate::scan::Protocol::Tcp,
202                open: true,
203                status: PortStatus::Open,
204                service: Some("https".to_string()),
205                product: None,
206                version: None,
207                latency_ms: Some(3),
208                banner: None,
209                http: None,
210                tls: None,
211                raw: None,
212                error: None,
213            }],
214        };
215
216        let value = serde_json::to_value(&entry).expect("serialize sweep entry");
217
218        assert_eq!(value["host"]["ip"], "192.168.1.125");
219        assert_eq!(value["host"]["hostname"], "lamp.local");
220        assert_eq!(value["host"]["mac"], "00:17:88:6E:6C:5C");
221        assert_eq!(value["host"]["vendor"], "Philips Lighting BV");
222        assert_eq!(value["host"]["rtt_ms"], 4);
223        assert_eq!(value["open_ports"][0]["port"], 443);
224        assert_eq!(value["open_ports"][0]["status"], "open");
225    }
226}