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