Skip to main content

netscli_core/
ops.rs

1use ipnet::Ipv4Net;
2use serde::Serialize;
3use std::net::IpAddr;
4use std::str::FromStr;
5use std::sync::Arc;
6
7use crate::error::{Error, Result};
8use crate::{
9    default_ipv4_subnet_string, default_ports, ArpEntry, DiscoverEngine, Host, InspectEngine,
10    InspectResult, InterfaceInfo, NetworkManager, PcapCancelToken, PcapConfig, PcapEngine,
11    PcapResult, PingScanner, PortResult, PortScanner, SweepEngine, SweepEntry, DEFAULT_CONCURRENCY,
12    DEFAULT_DNS_TIMEOUT_MS, DEFAULT_PING_TIMEOUT_MS, DEFAULT_SCAN_TIMEOUT_MS,
13};
14
15const MAX_SUBNET_ADDRESSES: u64 = 1 << 16; // /16
16
17fn ensure_subnet_limit(net: &Ipv4Net, subnet_str: &str) -> Result<()> {
18    let prefix = net.prefix_len() as u32;
19    let host_bits = 32u32.saturating_sub(prefix);
20    let total = 1u64.checked_shl(host_bits).unwrap_or(u64::MAX);
21    if total > MAX_SUBNET_ADDRESSES {
22        return Err(Error::invalid_input(format!(
23            "subnet too large: {subnet_str} (max /16)"
24        )));
25    }
26    Ok(())
27}
28
29#[derive(Debug, Clone)]
30pub struct OpsConfig {
31    pub concurrency: usize,
32    pub scan_timeout_ms: u64,
33    pub ping_timeout_ms: u64,
34    pub dns_timeout_ms: u64,
35}
36
37impl Default for OpsConfig {
38    fn default() -> Self {
39        Self {
40            concurrency: DEFAULT_CONCURRENCY,
41            scan_timeout_ms: DEFAULT_SCAN_TIMEOUT_MS,
42            ping_timeout_ms: DEFAULT_PING_TIMEOUT_MS,
43            dns_timeout_ms: DEFAULT_DNS_TIMEOUT_MS,
44        }
45    }
46}
47
48/// High-level operations used by CLI/TUI/GUI/MCP to keep behavior consistent.
49#[derive(Debug, Clone, Default)]
50pub struct Ops {
51    cfg: OpsConfig,
52}
53
54impl Ops {
55    pub fn new(cfg: OpsConfig) -> Self {
56        let mut cfg = cfg;
57        cfg.concurrency = cfg.concurrency.max(1);
58        cfg.scan_timeout_ms = cfg.scan_timeout_ms.max(1);
59        cfg.ping_timeout_ms = cfg.ping_timeout_ms.max(1);
60        cfg.dns_timeout_ms = cfg.dns_timeout_ms.max(1);
61        Self { cfg }
62    }
63
64    pub fn config(&self) -> &OpsConfig {
65        &self.cfg
66    }
67
68    pub async fn resolve_host_ip(&self, host: &str) -> Result<IpAddr> {
69        resolve_host_ip_with_timeout(host, self.cfg.dns_timeout_ms).await
70    }
71
72    pub async fn discover_ipv4(
73        &self,
74        subnet: Option<String>,
75        resolve: bool,
76    ) -> Result<(String, Vec<Host>)> {
77        self.discover_ipv4_with_progress(subnet, resolve, None)
78            .await
79    }
80
81    pub async fn discover_ipv4_with_progress(
82        &self,
83        subnet: Option<String>,
84        resolve: bool,
85        progress: Option<Arc<dyn Fn(crate::discover::DiscoverProgress) + Send + Sync>>,
86    ) -> Result<(String, Vec<Host>)> {
87        let subnet_str = subnet.unwrap_or_else(default_ipv4_subnet_string);
88        let net: Ipv4Net = subnet_str.parse().map_err(|e| {
89            Error::invalid_input(format!("Invalid subnet format '{subnet_str}': {e}"))
90        })?;
91        ensure_subnet_limit(&net, &subnet_str)?;
92        let engine = DiscoverEngine::new_with_timeouts(
93            self.cfg.concurrency,
94            self.cfg.ping_timeout_ms,
95            self.cfg.dns_timeout_ms,
96        );
97        let hosts = engine
98            .scan_subnet_with_progress(net, resolve, progress)
99            .await;
100        Ok((subnet_str, hosts))
101    }
102
103    pub async fn scan_ports(
104        &self,
105        host: &str,
106        ports: Option<Vec<u16>>,
107    ) -> Result<(IpAddr, Vec<PortResult>)> {
108        self.scan_ports_with_progress(host, ports, None).await
109    }
110
111    pub async fn scan_ports_with_progress(
112        &self,
113        host: &str,
114        ports: Option<Vec<u16>>,
115        progress: Option<Arc<dyn Fn(crate::scan::PortScanProgress) + Send + Sync>>,
116    ) -> Result<(IpAddr, Vec<PortResult>)> {
117        let ip = self.resolve_host_ip(host).await?;
118        let ports = ports.unwrap_or_else(default_ports);
119        let scanner = PortScanner::new(self.cfg.concurrency);
120        let results = scanner
121            .scan_host_with_progress(ip, ports, self.cfg.scan_timeout_ms, progress)
122            .await;
123        Ok((ip, results))
124    }
125
126    pub async fn inspect_host(
127        &self,
128        host: String,
129        ports: Option<Vec<u16>>,
130    ) -> Result<InspectResult> {
131        let ports = ports.unwrap_or_else(default_ports);
132        let engine = InspectEngine::new_with_timeouts(
133            self.cfg.concurrency,
134            self.cfg.ping_timeout_ms,
135            self.cfg.scan_timeout_ms,
136            self.cfg.dns_timeout_ms,
137        );
138        engine.inspect(host, ports).await
139    }
140
141    pub async fn sweep_ipv4(
142        &self,
143        subnet: Option<String>,
144        ports: Option<Vec<u16>>,
145        resolve_hostnames: bool,
146    ) -> Result<(String, Vec<SweepEntry>)> {
147        self.sweep_ipv4_with_progress(subnet, ports, resolve_hostnames, None)
148            .await
149    }
150
151    pub async fn sweep_ipv4_with_progress(
152        &self,
153        subnet: Option<String>,
154        ports: Option<Vec<u16>>,
155        resolve_hostnames: bool,
156        progress: Option<Arc<dyn Fn(crate::sweep::SweepProgress) + Send + Sync>>,
157    ) -> Result<(String, Vec<SweepEntry>)> {
158        let subnet_str = subnet.unwrap_or_else(default_ipv4_subnet_string);
159        let net: Ipv4Net = subnet_str.parse().map_err(|e| {
160            Error::invalid_input(format!("Invalid subnet format '{subnet_str}': {e}"))
161        })?;
162        ensure_subnet_limit(&net, &subnet_str)?;
163        let ports = ports.unwrap_or_else(default_ports);
164        let engine = SweepEngine::new_with_timeouts(
165            self.cfg.concurrency,
166            self.cfg.ping_timeout_ms,
167            self.cfg.scan_timeout_ms,
168            self.cfg.dns_timeout_ms,
169        );
170        let results = engine
171            .sweep_with_progress(net, ports, resolve_hostnames, progress)
172            .await?;
173        Ok((subnet_str, results))
174    }
175
176    pub async fn ping_host_summary(&self, host: &str, count: u32) -> Result<PingSummary> {
177        let ip = self.resolve_host_ip(host).await?;
178        let scanner = PingScanner::new(1);
179        let mut sent: u32 = 0;
180        let mut received: u32 = 0;
181        let mut rtts: Vec<u64> = Vec::new();
182
183        for _ in 0..count {
184            sent += 1;
185            let res = scanner.ping(ip, self.cfg.ping_timeout_ms).await;
186            if res.alive {
187                received += 1;
188                if let Some(rtt) = res.rtt_ms {
189                    rtts.push(rtt);
190                }
191            }
192        }
193
194        Ok(PingSummary::new(host.to_string(), ip, sent, received, rtts))
195    }
196
197    pub async fn dns_lookup(
198        &self,
199        host: &str,
200        record: Option<String>,
201    ) -> Result<Vec<crate::dns::DnsRecord>> {
202        let record = record.map(|r| r.trim().to_uppercase());
203        if record.as_deref().is_none() || matches!(record.as_deref(), Some("ALL" | "ANY")) {
204            return crate::dns::lookup_all_records_timeout(host, self.cfg.dns_timeout_ms).await;
205        }
206
207        let record = record.unwrap_or_else(|| "A".to_string());
208        let Some(parsed) = crate::dns::parse_record_type(&record) else {
209            return Err(Error::invalid_input(format!(
210                "unsupported DNS record type '{record}'"
211            )));
212        };
213
214        crate::dns::lookup_record_timeout(host, parsed, self.cfg.dns_timeout_ms).await
215    }
216
217    pub fn list_interfaces(&self) -> Vec<InterfaceInfo> {
218        NetworkManager::get_interfaces()
219    }
220
221    /// Discover services via mDNS/DNS-SD across a curated list of common
222    /// service types. Waits up to `timeout` for responses.
223    ///
224    /// Pass an empty `service_types` slice to use
225    /// [`crate::mdns::COMMON_SERVICE_TYPES`] as the default probe set.
226    #[cfg(feature = "mdns")]
227    pub async fn discover_mdns(
228        &self,
229        service_types: &[String],
230        timeout: std::time::Duration,
231    ) -> Result<Vec<crate::mdns::MdnsService>> {
232        if service_types.is_empty() {
233            crate::mdns::MdnsEngine::discover_common(timeout).await
234        } else {
235            let refs: Vec<&str> = service_types.iter().map(String::as_str).collect();
236            crate::mdns::MdnsEngine::discover(&refs, timeout).await
237        }
238    }
239
240    pub fn get_arp_table(&self) -> Result<Vec<ArpEntry>> {
241        NetworkManager::get_arp_table()
242    }
243
244    pub fn pcap_check_support(&self) -> Result<Vec<String>> {
245        PcapEngine::check_support()
246    }
247
248    pub fn capture_pcap(
249        &self,
250        interface: String,
251        filter: Option<String>,
252        duration: Option<u64>,
253        output_file: Option<String>,
254        max_packets: Option<usize>,
255    ) -> Result<PcapResult> {
256        let cfg = PcapConfig {
257            interface,
258            filter,
259            output_file: output_file
260                .unwrap_or_else(|| "capture.pcap".to_string())
261                .into(),
262            duration: duration.map(std::time::Duration::from_secs),
263            max_packets,
264        };
265        PcapEngine::capture(cfg)
266    }
267
268    /// Async-friendly PCAP capture wrapper.
269    ///
270    /// PCAP capture is inherently blocking (libpcap read loop + file I/O). This
271    /// runs it in a dedicated blocking thread so async runtimes (CLI/Tauri/MCP)
272    /// remain responsive.
273    pub async fn capture_pcap_async(
274        &self,
275        interface: String,
276        filter: Option<String>,
277        duration: Option<u64>,
278        output_file: Option<String>,
279        max_packets: Option<usize>,
280    ) -> Result<PcapResult> {
281        self.capture_pcap_async_with_cancel(
282            interface,
283            filter,
284            duration,
285            output_file,
286            max_packets,
287            None,
288        )
289        .await
290    }
291
292    pub async fn capture_pcap_async_with_cancel(
293        &self,
294        interface: String,
295        filter: Option<String>,
296        duration: Option<u64>,
297        output_file: Option<String>,
298        max_packets: Option<usize>,
299        cancel: Option<PcapCancelToken>,
300    ) -> Result<PcapResult> {
301        let cfg = PcapConfig {
302            interface,
303            filter,
304            output_file: output_file
305                .unwrap_or_else(|| "capture.pcap".to_string())
306                .into(),
307            duration: duration.map(std::time::Duration::from_secs),
308            max_packets,
309        };
310
311        let task =
312            tokio::task::spawn_blocking(move || PcapEngine::capture_with_cancel(cfg, cancel));
313        match task.await {
314            Ok(res) => Ok(res?),
315            Err(e) => Err(Error::Other(format!("pcap capture task failed: {e}"))),
316        }
317    }
318}
319
320#[derive(Debug, Clone, Serialize)]
321pub struct PingSummary {
322    pub host: String,
323    pub ip: IpAddr,
324    pub sent: u32,
325    pub received: u32,
326    pub loss_pct: f64,
327    pub rtt_ms_min: Option<u64>,
328    pub rtt_ms_max: Option<u64>,
329    pub rtt_ms_avg: Option<f64>,
330}
331
332impl PingSummary {
333    fn new(host: String, ip: IpAddr, sent: u32, received: u32, rtts: Vec<u64>) -> Self {
334        let loss_pct = if sent == 0 {
335            0.0
336        } else {
337            100.0 * (sent - received) as f64 / sent as f64
338        };
339        let rtt_ms_min = rtts.iter().min().copied();
340        let rtt_ms_max = rtts.iter().max().copied();
341        let rtt_ms_avg = if rtts.is_empty() {
342            None
343        } else {
344            Some((rtts.iter().sum::<u64>() as f64) / (rtts.len() as f64))
345        };
346        Self {
347            host,
348            ip,
349            sent,
350            received,
351            loss_pct,
352            rtt_ms_min,
353            rtt_ms_max,
354            rtt_ms_avg,
355        }
356    }
357}
358
359/// Resolve a host string to an IP address.
360///
361/// - Accepts literal IPv4/IPv6 strings.
362/// - Otherwise resolves A first, then AAAA.
363pub async fn resolve_host_ip(host: &str) -> Result<IpAddr> {
364    resolve_host_ip_with_timeout(host, crate::DEFAULT_DNS_TIMEOUT_MS).await
365}
366
367pub async fn resolve_host_ip_with_timeout(host: &str, dns_timeout_ms: u64) -> Result<IpAddr> {
368    if let Ok(ip) = IpAddr::from_str(host) {
369        return Ok(ip);
370    }
371
372    if let Ok(v4s) = crate::dns::resolve_a_timeout(host, dns_timeout_ms).await {
373        if let Some(first) = v4s.first() {
374            return IpAddr::from_str(first).map_err(|e| {
375                Error::dns(format!("invalid IPv4 address '{first}' from resolver: {e}"))
376            });
377        }
378    }
379    if let Ok(v6s) = crate::dns::resolve_aaaa_timeout(host, dns_timeout_ms).await {
380        if let Some(first) = v6s.first() {
381            return IpAddr::from_str(first).map_err(|e| {
382                Error::dns(format!("invalid IPv6 address '{first}' from resolver: {e}"))
383            });
384        }
385    }
386
387    Err(Error::dns(format!("unable to resolve host '{host}'")))
388}