Skip to main content

pingall/
lib.rs

1//! Minimal library API for scanning the local network.
2//!
3//! `pingall` is primarily a command-line tool. The library API intentionally
4//! mirrors that tool's scan operation without exposing the lower-level probing
5//! implementation details.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::net::{IpAddr, Ipv6Addr};
9use std::sync::Arc;
10
11use tokio::sync::Semaphore;
12use tokio::task::JoinSet;
13
14mod util;
15
16use util::{
17    DiscoveredAddress, InterfaceAddress, PingBackend, get_addresses, hostname_resolution_supported,
18    resolve_hostname, select_ping_backend, socket_ipv6_multicast_ping, socket_ping,
19    system_ipv6_multicast_ping, system_ping,
20};
21
22/// Options for a local network scan.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct ScanOptions {
25    /// Interface to search. When unset, all non-loopback interfaces are scanned.
26    pub interface: Option<String>,
27    /// Attempt to resolve hostnames for responding addresses.
28    pub resolve_hostnames: bool,
29    /// Open raw sockets instead of using the system `ping` command where supported.
30    pub raw_socket: bool,
31    /// Timeout of pings in seconds.
32    pub timeout: usize,
33    /// Scan IPv4 addresses.
34    pub ipv4: bool,
35    /// Scan IPv6 addresses.
36    pub ipv6: bool,
37}
38
39impl Default for ScanOptions {
40    fn default() -> Self {
41        Self {
42            interface: None,
43            resolve_hostnames: true,
44            raw_socket: false,
45            timeout: 1,
46            ipv4: true,
47            ipv6: true,
48        }
49    }
50}
51
52/// Scan the local network and return the lines normally printed by the CLI.
53///
54/// Results are deduplicated and formatted as either `IP` or `IP<TAB>hostname`,
55/// depending on whether hostname resolution is requested and succeeds.
56pub async fn scan(options: ScanOptions) -> Result<Vec<String>, Box<dyn std::error::Error>> {
57    let mut results = Vec::new();
58    scan_each(options, |result| results.push(result)).await?;
59    Ok(results)
60}
61
62/// Scan the local network and call `on_result` as each result becomes available.
63///
64/// Results are deduplicated before they are passed to the callback. The callback
65/// receives the same formatted lines returned by [`scan`].
66pub async fn scan_each<F>(
67    options: ScanOptions,
68    mut on_result: F,
69) -> Result<(), Box<dyn std::error::Error>>
70where
71    F: FnMut(String),
72{
73    let resolve = options.resolve_hostnames && hostname_resolution_supported();
74    let system_ping_exists = util::command_exists("ping");
75
76    let ping_backend = select_ping_backend(options.raw_socket, system_ping_exists)?;
77    let addresses = get_addresses(options.interface);
78    let semaphore = Arc::new(Semaphore::new(150));
79
80    let mut tasks = JoinSet::new();
81    let mut ipv6_tasks = JoinSet::new();
82    let mut ipv6_interfaces = BTreeMap::new();
83    for address in addresses {
84        match address {
85            InterfaceAddress::V4(address) if options.ipv4 => {
86                run_ipv4_subnet(
87                    &mut tasks,
88                    address,
89                    resolve,
90                    ping_backend,
91                    options.timeout,
92                    semaphore.clone(),
93                );
94            }
95            InterfaceAddress::V4(_) => {}
96            InterfaceAddress::V6 {
97                ip,
98                interface,
99                index,
100            } if options.ipv6 => {
101                let source = ipv6_interfaces.entry((interface, index)).or_insert(ip);
102                if ipv6_source_preferred(*source, ip) {
103                    *source = ip;
104                }
105            }
106            InterfaceAddress::V6 { .. } => {}
107        }
108    }
109
110    let ipv6_config = Ipv6ScanConfig {
111        resolve_hostnames: resolve,
112        ping_backend,
113        system_ping_exists,
114        timeout: options.timeout,
115    };
116
117    for ((interface, index), source) in ipv6_interfaces {
118        ipv6_tasks.spawn(collect_ipv6_interface(
119            interface,
120            index,
121            source,
122            ipv6_config,
123        ));
124    }
125
126    while let Some(result) = ipv6_tasks.join_next().await {
127        let Ok(addresses) = result else {
128            continue;
129        };
130
131        for address in addresses {
132            tasks.spawn(format_successful_address(
133                address,
134                ipv6_config.resolve_hostnames,
135                semaphore.clone(),
136            ));
137        }
138    }
139
140    let mut seen = BTreeSet::new();
141    while let Some(result) = tasks.join_next().await {
142        if let Ok(Some(result)) = result
143            && seen.insert(result.clone())
144        {
145            on_result(result);
146        }
147    }
148
149    Ok(())
150}
151
152/// Ping all the IP addresses on the local IPv4 `/24`.
153fn run_ipv4_subnet(
154    tasks: &mut JoinSet<Option<String>>,
155    address: std::net::Ipv4Addr,
156    resolve_hostnames: bool,
157    ping_backend: PingBackend,
158    timeout: usize,
159    semaphore: Arc<Semaphore>,
160) {
161    let octets = address.octets();
162
163    for i in 1..255 {
164        let ip_addr = IpAddr::V4(std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], i));
165        tasks.spawn(ping_address(
166            ip_addr,
167            Some(IpAddr::V4(address)),
168            resolve_hostnames,
169            ping_backend,
170            timeout,
171            semaphore.clone(),
172        ));
173    }
174}
175
176#[derive(Clone, Copy)]
177struct Ipv6ScanConfig {
178    resolve_hostnames: bool,
179    ping_backend: PingBackend,
180    system_ping_exists: bool,
181    timeout: usize,
182}
183
184async fn collect_ipv6_interface(
185    interface: String,
186    index: Option<u32>,
187    source: Ipv6Addr,
188    config: Ipv6ScanConfig,
189) -> Vec<DiscoveredAddress> {
190    match socket_ipv6_multicast_ping(
191        &interface,
192        index,
193        source,
194        config.timeout,
195        config.ping_backend,
196    )
197    .await
198    {
199        Ok(addresses) => addresses,
200        Err(()) if config.system_ping_exists => {
201            system_ipv6_multicast_ping(&interface, index, config.timeout).await
202        }
203        Err(()) => Vec::new(),
204    }
205}
206
207async fn ping_address(
208    ip_addr: IpAddr,
209    source: Option<IpAddr>,
210    resolve_hostnames: bool,
211    ping_backend: PingBackend,
212    timeout: usize,
213    semaphore: Arc<Semaphore>,
214) -> Option<String> {
215    let _permit = match semaphore.acquire().await {
216        Ok(permit) => permit,
217        Err(_) => return None,
218    };
219
220    let success = match ping_backend {
221        PingBackend::RawSocket => socket_ping(&ip_addr, source, timeout).await,
222        PingBackend::System => system_ping(&ip_addr, timeout).await,
223    };
224
225    match (success, resolve_hostnames) {
226        (true, true) => resolve_hostname(&ip_addr)
227            .await
228            .or_else(|| Some(ip_addr.to_string())),
229        (true, false) => Some(ip_addr.to_string()),
230        _ => None,
231    }
232}
233
234fn ipv6_source_preferred(current: Ipv6Addr, candidate: Ipv6Addr) -> bool {
235    !current.is_unicast_link_local() && candidate.is_unicast_link_local()
236}
237
238async fn format_successful_address(
239    address: DiscoveredAddress,
240    resolve_hostnames: bool,
241    semaphore: Arc<Semaphore>,
242) -> Option<String> {
243    let _permit = match semaphore.acquire().await {
244        Ok(permit) => permit,
245        Err(_) => return None,
246    };
247
248    if resolve_hostnames {
249        resolve_hostname(&address.ip_addr)
250            .await
251            .or(Some(address.display_addr))
252    } else {
253        Some(address.display_addr)
254    }
255}
256
257#[doc(hidden)]
258pub mod cli_support {
259    pub use super::util::{
260        PingBackend, can_open_raw_socket, command_exists, hostname_resolution_supported,
261        raw_socket_supported, select_ping_backend,
262    };
263}
264
265#[cfg(test)]
266mod tests {
267    use super::ipv6_source_preferred;
268
269    #[test]
270    fn ipv6_source_selection_prefers_link_local_for_multicast() {
271        assert!(ipv6_source_preferred(
272            "2001:db8::1".parse().unwrap(),
273            "fe80::1".parse().unwrap(),
274        ));
275        assert!(!ipv6_source_preferred(
276            "fe80::1".parse().unwrap(),
277            "2001:db8::1".parse().unwrap(),
278        ));
279    }
280}