Skip to main content

alpine_protocol_sdk/discovery/
runner.rs

1use std::net::{IpAddr, Ipv4Addr, SocketAddr};
2use std::time::Duration;
3
4use alpine::attestation::AttesterRegistry;
5use thiserror::Error;
6use tokio::time::{sleep, Duration as TokioDuration};
7use tracing::{info, warn};
8
9use crate::discovery::{
10    DiscoveryAttempt, DiscoveryClient, DiscoveryClientOptions, DiscoveryError, DiscoveryOutcome,
11    DiscoveryResult,
12};
13use crate::environment::ensure_supported_environment;
14use crate::phase::claim_discovery;
15use crate::self_check::run_sdk_self_check;
16
17#[derive(Debug, Clone)]
18pub struct DiscoveryRunOptions {
19    pub local_addr: Option<SocketAddr>,
20    pub prefer_multicast: bool,
21    pub allow_broadcast: bool,
22    pub cache_first: bool,
23    pub cached_targets: Vec<SocketAddr>,
24    pub scan_subnets: bool,
25    pub scan_rate_per_sec: u32,
26    pub scan_timeout_ms: u64,
27    pub scan_max_hosts: u32,
28    pub attester_registry: Option<AttesterRegistry>,
29}
30
31impl Default for DiscoveryRunOptions {
32    fn default() -> Self {
33        Self {
34            local_addr: None,
35            prefer_multicast: false,
36            allow_broadcast: true,
37            cache_first: false,
38            cached_targets: Vec::new(),
39            scan_subnets: false,
40            scan_rate_per_sec: 200,
41            scan_timeout_ms: 500,
42            scan_max_hosts: 1024,
43            attester_registry: None,
44        }
45    }
46}
47
48impl DiscoveryRunOptions {
49    /// Sensible defaults for a typical LAN deployment (broadcast on, no subnet scan).
50    pub fn defaults_for_lan() -> Self {
51        Self::default()
52    }
53
54    /// Defaults for lab environments (no multicast, cache-first probing).
55    pub fn defaults_for_lab() -> Self {
56        Self {
57            prefer_multicast: false,
58            allow_broadcast: false,
59            cache_first: true,
60            scan_subnets: false,
61            scan_max_hosts: 0,
62            ..Self::default()
63        }
64    }
65}
66
67#[derive(Debug, Error)]
68pub enum DiscoveryRunError {
69    #[error("unicast discovery failed: {0}")]
70    Unicast(DiscoveryError),
71    #[error("broadcast discovery failed: {0}")]
72    Broadcast(DiscoveryError),
73    #[error("no viable interfaces for broadcast discovery (need a non-loopback IPv4 address)")]
74    NoInterfaces,
75    #[error("cached unicast discovery failed")]
76    CachedUnicastFailed,
77    #[error("subnet scan discovery failed")]
78    SubnetScanFailed,
79    #[error("subnet scan disabled (scan_max_hosts = 0)")]
80    SubnetScanDisabled,
81    #[error("unsupported environment: {0}")]
82    UnsupportedEnvironment(String),
83}
84
85#[derive(Debug)]
86pub struct DiscoveryRunReport {
87    pub result: Result<DiscoveryOutcome, DiscoveryRunError>,
88    pub attempts: Vec<DiscoveryAttempt>,
89}
90
91impl DiscoveryRunReport {
92    pub fn chosen_attempt(&self) -> Option<DiscoveryAttempt> {
93        let outcome = self.result.as_ref().ok()?;
94        self.attempts
95            .iter()
96            .find(|attempt| attempt.target == outcome.peer)
97            .cloned()
98    }
99
100    pub fn decision_summary(&self) -> String {
101        match &self.result {
102            Ok(outcome) => {
103                if let Some(attempt) = self
104                    .attempts
105                    .iter()
106                    .find(|attempt| attempt.target == outcome.peer)
107                {
108                    format!(
109                        "selected {} via {} (local_bind {}); {} attempts",
110                        outcome.peer,
111                        attempt.mode.as_str(),
112                        attempt.local_bind,
113                        self.attempts.len()
114                    )
115                } else {
116                    format!(
117                        "selected {} (attempt not recorded); {} attempts",
118                        outcome.peer,
119                        self.attempts.len()
120                    )
121                }
122            }
123            Err(err) => format!(
124                "no device selected: {} ({} attempts)",
125                err,
126                self.attempts.len()
127            ),
128        }
129    }
130
131    pub fn summary(&self) -> String {
132        match &self.result {
133            Ok(outcome) => format!(
134                "discovered {} (device_id={}) after {} attempts",
135                outcome.peer,
136                outcome.reply.device_id,
137                self.attempts.len()
138            ),
139            Err(err) => {
140                let mut hints = Vec::new();
141                for attempt in &self.attempts {
142                    if let Some(error) = &attempt.error {
143                        if let Some(hint) = error.hint {
144                            hints.push(hint);
145                        }
146                    }
147                }
148                hints.sort();
149                hints.dedup();
150                if hints.is_empty() {
151                    format!(
152                        "discovery failed: {} ({} attempts)",
153                        err,
154                        self.attempts.len()
155                    )
156                } else {
157                    format!(
158                        "discovery failed: {} ({} attempts); hints: {}",
159                        err,
160                        self.attempts.len(),
161                        hints.join("; ")
162                    )
163                }
164            }
165        }
166    }
167}
168
169#[derive(Debug, Clone)]
170pub struct DiscoveryInterface {
171    pub name: String,
172    pub local_ip: std::net::Ipv4Addr,
173    pub netmask: std::net::Ipv4Addr,
174    pub broadcast: std::net::Ipv4Addr,
175}
176
177#[derive(Debug, Clone)]
178pub struct DiscoveryDryRun {
179    pub remote_addr: SocketAddr,
180    pub unicast_target: Option<SocketAddr>,
181    pub prefer_multicast: bool,
182    pub allow_broadcast: bool,
183    pub cached_targets: Vec<SocketAddr>,
184    pub scan_subnets: bool,
185    pub scan_rate_per_sec: u32,
186    pub scan_timeout_ms: u64,
187    pub scan_max_hosts: u32,
188    pub interfaces: Vec<DiscoveryInterface>,
189    pub broadcast_targets: Vec<SocketAddr>,
190}
191
192impl DiscoveryDryRun {
193    pub fn print_table(&self) -> String {
194        let mut lines = Vec::new();
195        lines.push("interface, local_ip, netmask, broadcast".to_string());
196        for iface in &self.interfaces {
197            lines.push(format!(
198                "{}, {}, {}, {}",
199                iface.name, iface.local_ip, iface.netmask, iface.broadcast
200            ));
201        }
202        if let Some(target) = self.unicast_target {
203            lines.push(format!("unicast_target, {}", target));
204        }
205        for target in &self.broadcast_targets {
206            lines.push(format!("broadcast_target, {}", target));
207        }
208        if !self.cached_targets.is_empty() {
209            for target in &self.cached_targets {
210                lines.push(format!("cached_target, {}", target));
211            }
212        }
213        lines.join("\n")
214    }
215}
216
217#[derive(Debug, Clone)]
218pub struct DiscoveryRetryPolicy {
219    pub max_attempts: usize,
220    pub backoff_base_ms: u64,
221    pub backoff_max_ms: u64,
222}
223
224impl Default for DiscoveryRetryPolicy {
225    fn default() -> Self {
226        Self {
227            max_attempts: 3,
228            backoff_base_ms: 200,
229            backoff_max_ms: 2_000,
230        }
231    }
232}
233
234fn default_local_addr() -> SocketAddr {
235    SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)
236}
237
238fn resolve_local_addr(remote_addr: SocketAddr, override_addr: Option<SocketAddr>) -> SocketAddr {
239    if let Some(addr) = override_addr {
240        return addr;
241    }
242    if matches!(remote_addr.ip(), IpAddr::V4(v4) if v4.is_broadcast()) {
243        return default_local_addr();
244    }
245    let bind_addr = if remote_addr.is_ipv4() {
246        SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)
247    } else {
248        SocketAddr::new(IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), 0)
249    };
250    if let Ok(sock) = std::net::UdpSocket::bind(bind_addr) {
251        if sock.connect(remote_addr).is_ok() {
252            if let Ok(local) = sock.local_addr() {
253                return SocketAddr::new(local.ip(), 0);
254            }
255        }
256    }
257    default_local_addr()
258}
259
260pub async fn run_discovery(remote_addr: SocketAddr) -> Result<DiscoveryOutcome, DiscoveryRunError> {
261    run_discovery_with_options(remote_addr, DiscoveryRunOptions::default()).await
262}
263
264/// Runs discovery and returns both the result and per-attempt diagnostics.
265pub async fn run_discovery_with_report(remote_addr: SocketAddr) -> DiscoveryRunReport {
266    run_discovery_with_options_report(remote_addr, DiscoveryRunOptions::default()).await
267}
268
269pub async fn run_discovery_with_retry(
270    remote_addr: SocketAddr,
271    policy: DiscoveryRetryPolicy,
272) -> Result<DiscoveryOutcome, DiscoveryRunError> {
273    run_discovery_with_options_retry(remote_addr, DiscoveryRunOptions::default(), policy).await
274}
275
276pub async fn run_discovery_with_options(
277    remote_addr: SocketAddr,
278    opts: DiscoveryRunOptions,
279) -> Result<DiscoveryOutcome, DiscoveryRunError> {
280    run_discovery_with_options_inner(remote_addr, opts, None).await
281}
282
283pub async fn discover_with_cache(
284    remote_addr: SocketAddr,
285    cached_targets: Vec<SocketAddr>,
286) -> Result<DiscoveryOutcome, DiscoveryRunError> {
287    let mut opts = DiscoveryRunOptions::defaults_for_lan();
288    opts.cached_targets = cached_targets;
289    run_discovery_with_options(remote_addr, opts).await
290}
291
292pub async fn discover_with_cache_report(
293    remote_addr: SocketAddr,
294    cached_targets: Vec<SocketAddr>,
295) -> DiscoveryRunReport {
296    let mut opts = DiscoveryRunOptions::defaults_for_lan();
297    opts.cached_targets = cached_targets;
298    run_discovery_with_options_report(remote_addr, opts).await
299}
300
301pub async fn run_discovery_with_options_retry(
302    remote_addr: SocketAddr,
303    opts: DiscoveryRunOptions,
304    policy: DiscoveryRetryPolicy,
305) -> Result<DiscoveryOutcome, DiscoveryRunError> {
306    let attempts = policy.max_attempts.max(1);
307    let mut attempt = 0usize;
308    loop {
309        attempt = attempt.saturating_add(1);
310        match run_discovery_with_options(remote_addr, opts.clone()).await {
311            Ok(outcome) => {
312                if attempt > 1 {
313                    warn!(
314                        "[ALPINE][DISCOVERY][WARN] discovery succeeded after {} retries",
315                        attempt - 1
316                    );
317                }
318                return Ok(outcome);
319            }
320            Err(err) => {
321                if attempt >= attempts {
322                    return Err(err);
323                }
324                sleep(TokioDuration::from_millis(discovery_backoff(
325                    &policy, attempt,
326                )))
327                .await;
328            }
329        }
330    }
331}
332
333async fn run_discovery_with_options_inner(
334    remote_addr: SocketAddr,
335    opts: DiscoveryRunOptions,
336    mut diagnostics: Option<&mut Vec<DiscoveryAttempt>>,
337) -> Result<DiscoveryOutcome, DiscoveryRunError> {
338    run_sdk_self_check();
339    if let Err(err) = ensure_supported_environment() {
340        return Err(DiscoveryRunError::UnsupportedEnvironment(err.to_string()));
341    }
342    let _phase_guard = claim_discovery()
343        .map_err(|_| DiscoveryRunError::Unicast(DiscoveryError::PermissionDenied))?;
344    let is_broadcast_target = matches!(remote_addr.ip(), IpAddr::V4(v4) if v4.is_broadcast());
345
346    if opts.cache_first {
347        if let Ok(outcome) = attempt_cached_unicast(&opts, &mut diagnostics) {
348            return Ok(outcome);
349        }
350    }
351
352    if !is_broadcast_target {
353        let local_addr = resolve_local_addr(remote_addr, opts.local_addr);
354        let mut options =
355            DiscoveryClientOptions::new(remote_addr, local_addr, Duration::from_secs(3));
356        options.attester_registry = opts.attester_registry.clone();
357        options = options.disable_multicast().disable_broadcast();
358        info!(
359            "[ALPINE][DISCOVERY] route_hint remote={} local_bind={}",
360            remote_addr, options.local_addr
361        );
362        let local_bind = options.local_addr;
363        let client = DiscoveryClient::new(options).map_err(DiscoveryRunError::Unicast)?;
364        let report = client.discover_with_report(&["alpine-control".to_string()]);
365        append_attempts(&mut diagnostics, &report);
366        match report.into_result() {
367            Ok(outcome) => return Ok(outcome),
368            Err(err) => {
369                if opts.allow_broadcast && remote_addr.is_ipv4() {
370                    warn!(
371                        "[ALPINE][DISCOVERY][WARN] unicast discovery failed ({}); falling back to broadcast",
372                        err
373                    );
374                    match attempt_broadcast(
375                        remote_addr.port(),
376                        &opts,
377                        Some(local_bind.ip()),
378                        &mut diagnostics,
379                    ) {
380                        Ok(outcome) => return Ok(outcome),
381                        Err(broadcast_err) => {
382                            warn!(
383                                "[ALPINE][DISCOVERY][WARN] falling back to cached unicast targets"
384                            );
385                            if let Ok(outcome) = attempt_cached_unicast(&opts, &mut diagnostics) {
386                                return Ok(outcome);
387                            }
388                            if opts.scan_subnets {
389                                warn!("[ALPINE][DISCOVERY][WARN] falling back to subnet scan");
390                                return attempt_subnet_scan(
391                                    remote_addr.port(),
392                                    &opts,
393                                    Some(local_bind.ip()),
394                                    &mut diagnostics,
395                                )
396                                .await;
397                            }
398                            return Err(broadcast_err);
399                        }
400                    }
401                }
402                return Err(DiscoveryRunError::Unicast(err));
403            }
404        }
405    }
406
407    match attempt_broadcast(remote_addr.port(), &opts, None, &mut diagnostics) {
408        Ok(outcome) => Ok(outcome),
409        Err(broadcast_err) => {
410            warn!("[ALPINE][DISCOVERY][WARN] falling back to cached unicast targets");
411            if let Ok(outcome) = attempt_cached_unicast(&opts, &mut diagnostics) {
412                return Ok(outcome);
413            }
414            if opts.scan_subnets {
415                warn!("[ALPINE][DISCOVERY][WARN] falling back to subnet scan");
416                return attempt_subnet_scan(remote_addr.port(), &opts, None, &mut diagnostics)
417                    .await;
418            }
419            Err(broadcast_err)
420        }
421    }
422}
423
424pub async fn run_discovery_with_options_report(
425    remote_addr: SocketAddr,
426    opts: DiscoveryRunOptions,
427) -> DiscoveryRunReport {
428    let mut attempts = Vec::new();
429    let result = run_discovery_with_options_inner(remote_addr, opts, Some(&mut attempts)).await;
430    DiscoveryRunReport { result, attempts }
431}
432
433/// Returns the computed discovery plan without sending any packets.
434pub fn discovery_dry_run(
435    remote_addr: SocketAddr,
436    opts: DiscoveryRunOptions,
437) -> Result<DiscoveryDryRun, DiscoveryRunError> {
438    let interfaces = collect_interfaces()?;
439    let broadcast_targets = if opts.allow_broadcast && remote_addr.is_ipv4() {
440        interfaces
441            .iter()
442            .map(|iface| SocketAddr::new(IpAddr::V4(iface.broadcast), remote_addr.port()))
443            .collect::<Vec<_>>()
444    } else {
445        Vec::new()
446    };
447    let unicast_target = if matches!(remote_addr.ip(), IpAddr::V4(v4) if v4.is_broadcast()) {
448        None
449    } else {
450        Some(remote_addr)
451    };
452    Ok(DiscoveryDryRun {
453        remote_addr,
454        unicast_target,
455        prefer_multicast: opts.prefer_multicast,
456        allow_broadcast: opts.allow_broadcast,
457        cached_targets: opts.cached_targets,
458        scan_subnets: opts.scan_subnets,
459        scan_rate_per_sec: opts.scan_rate_per_sec,
460        scan_timeout_ms: opts.scan_timeout_ms,
461        scan_max_hosts: opts.scan_max_hosts,
462        interfaces: interfaces
463            .into_iter()
464            .map(|iface| DiscoveryInterface {
465                name: iface.iface,
466                local_ip: iface.local_ip,
467                netmask: iface.netmask,
468                broadcast: iface.broadcast,
469            })
470            .collect(),
471        broadcast_targets,
472    })
473}
474
475fn attempt_broadcast(
476    port: u16,
477    opts: &DiscoveryRunOptions,
478    preferred_ip: Option<IpAddr>,
479    diagnostics: &mut Option<&mut Vec<DiscoveryAttempt>>,
480) -> Result<DiscoveryOutcome, DiscoveryRunError> {
481    let mut attempts = collect_interfaces()?;
482    if attempts.is_empty() {
483        return Err(DiscoveryRunError::NoInterfaces);
484    }
485
486    if let Some(pref) = preferred_ip {
487        if let Some(idx) = attempts.iter().position(|a| IpAddr::V4(a.local_ip) == pref) {
488            let preferred = attempts.remove(idx);
489            attempts.insert(0, preferred);
490        }
491    }
492
493    let mut last_err: Option<DiscoveryError> = None;
494    for attempt in attempts.iter() {
495        let mut options = DiscoveryClientOptions::new(
496            SocketAddr::new(IpAddr::V4(attempt.broadcast), port),
497            SocketAddr::new(IpAddr::V4(attempt.local_ip), 0),
498            Duration::from_secs(3),
499        );
500        options.interface = Some(attempt.iface.clone());
501        options.attester_registry = opts.attester_registry.clone();
502        if !opts.prefer_multicast {
503            options = options.disable_multicast();
504        }
505        if !opts.allow_broadcast {
506            options = options.disable_broadcast();
507        }
508        info!(
509            "[ALPINE][DISCOVERY] iface={} local_ip={} netmask={} broadcast={} bound={}:0 so_broadcast={}",
510            attempt.iface,
511            attempt.local_ip,
512            attempt.netmask,
513            attempt.broadcast,
514            attempt.local_ip,
515            options.allow_broadcast
516        );
517
518        match DiscoveryClient::new(options) {
519            Ok(client) => {
520                let report = client.discover_with_report(&["alpine-control".to_string()]);
521                append_attempts(diagnostics, &report);
522                match report.into_result() {
523                    Ok(outcome) => return Ok(outcome),
524                    Err(err) => {
525                        warn!(
526                            "[ALPINE][DISCOVERY][WARN] iface={} error={}",
527                            attempt.iface, err
528                        );
529                        last_err = Some(err);
530                        continue;
531                    }
532                }
533            }
534            Err(err) => {
535                warn!(
536                    "[ALPINE][DISCOVERY][WARN] iface={} error={}",
537                    attempt.iface, err
538                );
539                last_err = Some(err);
540                continue;
541            }
542        }
543    }
544
545    Err(DiscoveryRunError::Broadcast(
546        last_err.unwrap_or(DiscoveryError::Timeout),
547    ))
548}
549
550fn attempt_cached_unicast(
551    opts: &DiscoveryRunOptions,
552    diagnostics: &mut Option<&mut Vec<DiscoveryAttempt>>,
553) -> Result<DiscoveryOutcome, DiscoveryRunError> {
554    if opts.cached_targets.is_empty() {
555        return Err(DiscoveryRunError::CachedUnicastFailed);
556    }
557    for target in opts.cached_targets.iter() {
558        let local_addr = resolve_local_addr(*target, None);
559        let mut options = DiscoveryClientOptions::new(
560            *target,
561            local_addr,
562            Duration::from_millis(opts.scan_timeout_ms),
563        );
564        options.attester_registry = opts.attester_registry.clone();
565        options = options.disable_multicast().disable_broadcast();
566        info!(
567            "[ALPINE][DISCOVERY] cached_unicast target={} local_bind={}",
568            target, options.local_addr
569        );
570        if let Ok(client) = DiscoveryClient::new(options) {
571            let report = client.discover_with_report(&["alpine-control".to_string()]);
572            append_attempts(diagnostics, &report);
573            match report.into_result() {
574                Ok(outcome) => return Ok(outcome),
575                Err(err) => {
576                    warn!(
577                        "[ALPINE][DISCOVERY][WARN] cached_unicast target={} error={}",
578                        target, err
579                    );
580                }
581            }
582        }
583    }
584    Err(DiscoveryRunError::CachedUnicastFailed)
585}
586
587async fn attempt_subnet_scan(
588    port: u16,
589    opts: &DiscoveryRunOptions,
590    preferred_ip: Option<IpAddr>,
591    diagnostics: &mut Option<&mut Vec<DiscoveryAttempt>>,
592) -> Result<DiscoveryOutcome, DiscoveryRunError> {
593    if opts.scan_max_hosts == 0 {
594        return Err(DiscoveryRunError::SubnetScanDisabled);
595    }
596    let mut attempts = collect_interfaces()?;
597    if attempts.is_empty() {
598        return Err(DiscoveryRunError::NoInterfaces);
599    }
600    if let Some(pref) = preferred_ip {
601        if let Some(idx) = attempts.iter().position(|a| IpAddr::V4(a.local_ip) == pref) {
602            let preferred = attempts.remove(idx);
603            attempts.insert(0, preferred);
604        }
605    }
606
607    let rate = opts.scan_rate_per_sec.max(1);
608    let delay_ms = (1000u64 / rate as u64).max(1);
609    let timeout_ms = opts.scan_timeout_ms.max(100);
610
611    for attempt in attempts.iter() {
612        let mut scanned = 0u32;
613        let (network, broadcast) = network_bounds(attempt.local_ip, attempt.netmask);
614        let start = network.saturating_add(1);
615        let end = broadcast.saturating_sub(1);
616        info!(
617            "[ALPINE][DISCOVERY] subnet_scan iface={} local_ip={} netmask={} range={}.{} timeout_ms={} rate_per_sec={} max_hosts={}",
618            attempt.iface,
619            attempt.local_ip,
620            attempt.netmask,
621            std::net::Ipv4Addr::from(network),
622            std::net::Ipv4Addr::from(broadcast),
623            timeout_ms,
624            rate,
625            opts.scan_max_hosts
626        );
627        let mut ip = start;
628        while ip <= end && scanned < opts.scan_max_hosts {
629            let target_ip = std::net::Ipv4Addr::from(ip);
630            if target_ip != attempt.local_ip {
631                let target = SocketAddr::new(IpAddr::V4(target_ip), port);
632                let local_addr = SocketAddr::new(IpAddr::V4(attempt.local_ip), 0);
633                let mut options = DiscoveryClientOptions::new(
634                    target,
635                    local_addr,
636                    Duration::from_millis(timeout_ms),
637                );
638                options.attester_registry = opts.attester_registry.clone();
639                options = options.disable_multicast().disable_broadcast();
640                if let Ok(client) = DiscoveryClient::new(options) {
641                    let report = client.discover_with_report(&["alpine-control".to_string()]);
642                    append_attempts(diagnostics, &report);
643                    if let Ok(outcome) = report.into_result() {
644                        return Ok(outcome);
645                    }
646                }
647                scanned += 1;
648                sleep(TokioDuration::from_millis(delay_ms)).await;
649            }
650            ip = ip.saturating_add(1);
651        }
652    }
653    Err(DiscoveryRunError::SubnetScanFailed)
654}
655
656struct IfaceAttempt {
657    iface: String,
658    local_ip: std::net::Ipv4Addr,
659    netmask: std::net::Ipv4Addr,
660    broadcast: std::net::Ipv4Addr,
661}
662
663fn collect_interfaces() -> Result<Vec<IfaceAttempt>, DiscoveryRunError> {
664    let mut attempts = Vec::new();
665    let ifaces = get_if_addrs::get_if_addrs().map_err(|_| DiscoveryRunError::NoInterfaces)?;
666    for iface in ifaces {
667        if iface.is_loopback() {
668            continue;
669        }
670        if let get_if_addrs::IfAddr::V4(v4) = iface.addr {
671            let ipv4 = v4.ip;
672            let maskv4 = v4.netmask;
673            let ip_u32 = u32::from_be_bytes(ipv4.octets());
674            let mask_u32 = u32::from_be_bytes(maskv4.octets());
675            let bcast = ip_u32 | (!mask_u32);
676            let bcast_ip = std::net::Ipv4Addr::from(bcast.to_be_bytes());
677            attempts.push(IfaceAttempt {
678                iface: iface.name,
679                local_ip: ipv4,
680                netmask: maskv4,
681                broadcast: bcast_ip,
682            });
683        }
684    }
685    if attempts.len() > 1 {
686        let names = attempts
687            .iter()
688            .map(|iface| iface.iface.as_str())
689            .collect::<Vec<_>>()
690            .join(", ");
691        warn!(
692            "[ALPINE][DISCOVERY][WARN] multiple NICs detected (heuristic selection): {}",
693            names
694        );
695    }
696    Ok(attempts)
697}
698
699fn network_bounds(ip: std::net::Ipv4Addr, netmask: std::net::Ipv4Addr) -> (u32, u32) {
700    let ip_u32 = u32::from_be_bytes(ip.octets());
701    let mask_u32 = u32::from_be_bytes(netmask.octets());
702    let network = ip_u32 & mask_u32;
703    let broadcast = network | (!mask_u32);
704    (network, broadcast)
705}
706
707fn append_attempts(diagnostics: &mut Option<&mut Vec<DiscoveryAttempt>>, report: &DiscoveryResult) {
708    if let Some(targets) = diagnostics.as_mut() {
709        targets.extend(report.diagnostics.attempts.clone());
710    }
711}
712
713fn discovery_backoff(policy: &DiscoveryRetryPolicy, attempt: usize) -> u64 {
714    let exponent = attempt.saturating_sub(1) as u32;
715    let factor = 1u64.checked_shl(exponent).unwrap_or(u64::MAX);
716    let delay = policy.backoff_base_ms.saturating_mul(factor);
717    delay.min(policy.backoff_max_ms)
718}