Skip to main content

epics_libcom_rs/net/
iface_map.rs

1//! IPv4 network interface enumeration with periodic refresh.
2//!
3//! Wraps the `if-addrs` crate (cross-platform) into an
4//! [`IfaceMap`] keyed by `ifindex`. Built once at startup and
5//! refreshable on demand — multi-NIC environments where interfaces
6//! come and go (USB Ethernet, hot-plug iface) need a fresh snapshot
7//! per search burst, but the cost is small.
8//!
9//! Mirrors the data carried by pvxs `IfaceMap::Current` (src/iface.cpp).
10
11use std::io;
12use std::net::Ipv4Addr;
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use parking_lot::Mutex;
17
18/// Snapshot of one IPv4 interface.
19#[derive(Debug, Clone)]
20pub struct IfaceInfo {
21    /// Kernel interface index (`if_nametoindex`). 0 means "let the
22    /// kernel pick" — useful as a sentinel when the platform did
23    /// not surface an index.
24    pub index: u32,
25    /// Interface name (`eth0`, `en0`, `Wi-Fi`, ...).
26    pub name: String,
27    /// IPv4 address bound on this interface.
28    pub ip: Ipv4Addr,
29    /// IPv4 netmask.
30    pub netmask: Ipv4Addr,
31    /// Subnet broadcast address (when reported by the OS), e.g.
32    /// `10.0.0.255`. `None` for point-to-point links.
33    pub broadcast: Option<Ipv4Addr>,
34    /// `true` when the interface carries the OS `IFF_UP` flag **and**
35    /// is not loopback — the eligibility test for SEARCH/beacon
36    /// fanout. C parity: `osiSockDiscoverBroadcastAddresses`
37    /// (`osdNetIfConf.c:170-181`) skips `!(IFF_UP)` and `IFF_LOOPBACK`
38    /// interfaces. On Linux (where the crate links `libc`) this
39    /// consults the live kernel flags via `getifaddrs`; on other
40    /// targets it falls back to `!is_loopback()` because the
41    /// platform's enumerator already excludes operationally-down
42    /// adapters.
43    pub up_non_loopback: bool,
44}
45
46/// Refreshable cache of IPv4 interfaces.
47///
48/// Cheap to clone (Arc-shared internal state). Spawned tasks share a
49/// single map and refresh on demand via [`IfaceMap::refresh_if_stale`].
50#[derive(Clone)]
51pub struct IfaceMap {
52    inner: Arc<Mutex<Inner>>,
53}
54
55struct Inner {
56    ifaces: Vec<IfaceInfo>,
57    last_refresh: Instant,
58}
59
60impl IfaceMap {
61    /// Build a fresh map by enumerating interfaces now.
62    ///
63    /// Fails when the OS enumeration fails, so an empty map means exactly
64    /// one thing — the host reported no IPv4 interfaces. It used to mean
65    /// that *or* that `getifaddrs` had errored, and every caller that asks
66    /// "is there an external NIC here?" read the two as the same answer.
67    pub fn new() -> io::Result<Self> {
68        let me = Self {
69            inner: Arc::new(Mutex::new(Inner {
70                ifaces: Vec::new(),
71                // Overwritten by `refresh()` on the next line, so any valid
72                // Instant works. Must NOT back-date with `Instant - Duration`:
73                // that panics on Windows (where `Instant` is QPC-since-boot)
74                // whenever the machine's uptime is shorter than the span.
75                last_refresh: Instant::now(),
76            })),
77        };
78        me.refresh()?;
79        Ok(me)
80    }
81
82    /// Force-refresh the snapshot.
83    ///
84    /// The single writer of `Inner.ifaces`, and it writes only what a
85    /// successful enumeration returned: on failure the previous snapshot
86    /// stands rather than being replaced by an empty one, so a transient
87    /// `getifaddrs` error cannot blank the fanout list under a running
88    /// sender.
89    pub fn refresh(&self) -> io::Result<()> {
90        let new = enumerate_v4()?;
91        let mut g = self.inner.lock();
92        g.ifaces = new;
93        g.last_refresh = Instant::now();
94        Ok(())
95    }
96
97    /// Refresh if the snapshot is older than `max_age`. Returns the
98    /// snapshot age before any refresh.
99    pub fn refresh_if_stale(&self, max_age: Duration) -> io::Result<Duration> {
100        let age = self.inner.lock().last_refresh.elapsed();
101        if age > max_age {
102            self.refresh()?;
103        }
104        Ok(age)
105    }
106
107    /// Spawn a background tokio task that refreshes the snapshot
108    /// every `period` until the returned [`tokio::task::JoinHandle`]
109    /// is aborted. Mirrors pvxs `IfMapDaemon` (evhelper.cpp:715-758)
110    /// which polls every 15 s.
111    ///
112    /// Returns the handle so callers that own the runtime can store
113    /// it for shutdown; dropping it does NOT cancel the task — abort
114    /// it explicitly. Idempotent: multiple background refreshers on
115    /// the same map cost extra wakeups but are harmless.
116    ///
117    /// Without this, dynamic infrastructure (DHCP renewals changing
118    /// the broadcast address; K8s pod network re-attach; VM live
119    /// migration; cable hot-plug) leaves the snapshot stale, and
120    /// any sender that derives a broadcast destination from the
121    /// snapshot ends up sending to the wrong subnet.
122    pub fn spawn_refresh(
123        &self,
124        reactor: &crate::runtime::task::Reactor,
125        period: Duration,
126    ) -> crate::runtime::task::TaskHandle<()> {
127        let me = self.clone();
128        reactor.spawn(async move {
129            let mut tick = crate::runtime::task::interval(period);
130            // First tick fires immediately — skip it so we don't
131            // refresh twice in a row right after `IfaceMap::new()`
132            // (which already populated the snapshot).
133            tick.tick().await;
134            loop {
135                tick.tick().await;
136                // Keep the previous snapshot when enumeration fails and let
137                // the next tick retry — a periodic refresher must not be
138                // able to turn a momentary OS error into "this host has no
139                // interfaces" for everyone reading the map.
140                if let Err(e) = me.refresh() {
141                    tracing::debug!("iface map refresh failed, keeping previous snapshot: {e}");
142                }
143            }
144        })
145    }
146
147    /// Snapshot of all IPv4 interfaces. Includes loopback unless
148    /// callers filter via [`IfaceInfo::up_non_loopback`].
149    pub fn all(&self) -> Vec<IfaceInfo> {
150        self.inner.lock().ifaces.clone()
151    }
152
153    /// Snapshot of up, non-loopback IPv4 interfaces — the typical
154    /// fanout target list for SEARCH/beacon traffic.
155    pub fn up_non_loopback(&self) -> Vec<IfaceInfo> {
156        self.inner
157            .lock()
158            .ifaces
159            .iter()
160            .filter(|i| i.up_non_loopback)
161            .cloned()
162            .collect()
163    }
164
165    /// Look up an interface by its kernel index. Returns `None` when
166    /// the index isn't known to this snapshot — caller may want to
167    /// `refresh()` and retry once.
168    pub fn by_index(&self, index: u32) -> Option<IfaceInfo> {
169        self.inner
170            .lock()
171            .ifaces
172            .iter()
173            .find(|i| i.index == index)
174            .cloned()
175    }
176
177    /// Pick the interface index that should originate traffic
178    /// destined for `dest`. The selection rules (in priority order):
179    ///
180    /// 1. **Subnet match** — `dest` falls within an interface's
181    ///    `(ip, netmask)`. Returned when present.
182    /// 2. **Broadcast match** — `dest` equals an interface's
183    ///    subnet broadcast.
184    /// 3. **Loopback** — `127.0.0.0/8` → loopback interface.
185    /// 4. **Default route** — an interface with a `0.0.0.0` netmask
186    ///    matches any destination; used only as a fallback so it never
187    ///    shadows a specific subnet match.
188    /// 5. Otherwise `None` — caller treats this as "no per-NIC
189    ///    pinning, let the OS route". For limited broadcast and
190    ///    multicast destinations the caller fanouts across all
191    ///    interfaces explicitly.
192    pub fn route_to(&self, dest: Ipv4Addr) -> Option<IfaceInfo> {
193        let g = self.inner.lock();
194        // (1) subnet match
195        for i in &g.ifaces {
196            if subnet_contains(i.ip, i.netmask, dest) {
197                return Some(i.clone());
198            }
199        }
200        // (2) explicit subnet broadcast
201        for i in &g.ifaces {
202            if Some(dest) == i.broadcast {
203                return Some(i.clone());
204            }
205        }
206        // (3) loopback
207        if dest.is_loopback() {
208            return g.ifaces.iter().find(|i| i.ip.is_loopback()).cloned();
209        }
210        // (4) default-route interface — a `0.0.0.0` netmask matches
211        // every destination. `subnet_contains` rejects it in pass (1)
212        // so it never shadows a specific subnet; here it is the
213        // explicit fallback for an otherwise-unrouted dest.
214        if let Some(i) = g
215            .ifaces
216            .iter()
217            .find(|i| !i.ip.is_loopback() && u32::from(i.netmask) == 0)
218        {
219            return Some(i.clone());
220        }
221        None
222    }
223}
224
225fn subnet_contains(ip: Ipv4Addr, mask: Ipv4Addr, candidate: Ipv4Addr) -> bool {
226    let net = u32::from(ip) & u32::from(mask);
227    let cnet = u32::from(candidate) & u32::from(mask);
228    net == cnet && u32::from(mask) != 0
229}
230
231/// Per-interface-name OS flag snapshot used to compute
232/// `up_non_loopback`. C parity: the `IFF_UP` / `IFF_LOOPBACK` checks in
233/// `osiSockDiscoverBroadcastAddresses` (`osdNetIfConf.c:170-181`).
234#[cfg(target_os = "linux")]
235fn interface_up_flags() -> std::collections::HashMap<String, bool> {
236    use std::collections::HashMap;
237    use std::ffi::CStr;
238
239    let mut map: HashMap<String, bool> = HashMap::new();
240    // SAFETY: standard getifaddrs / freeifaddrs pairing; the pointer is
241    // only dereferenced while non-null and freed exactly once.
242    unsafe {
243        let mut head: *mut libc::ifaddrs = std::ptr::null_mut();
244        if libc::getifaddrs(&mut head) != 0 || head.is_null() {
245            return map;
246        }
247        let mut cur = head;
248        while !cur.is_null() {
249            let ifa = &*cur;
250            if !ifa.ifa_name.is_null() {
251                if let Ok(name) = CStr::from_ptr(ifa.ifa_name).to_str() {
252                    // C: skip interfaces without IFF_UP, skip IFF_LOOPBACK.
253                    let flags = ifa.ifa_flags as libc::c_int;
254                    let up = (flags & libc::IFF_UP) != 0;
255                    let loopback = (flags & libc::IFF_LOOPBACK) != 0;
256                    let eligible = up && !loopback;
257                    // An interface can have several addresses; if any
258                    // entry reports it up+non-loopback, keep that.
259                    map.entry(name.to_string())
260                        .and_modify(|e| *e |= eligible)
261                        .or_insert(eligible);
262                }
263            }
264            cur = ifa.ifa_next;
265        }
266        libc::freeifaddrs(head);
267    }
268    map
269}
270
271fn enumerate_v4() -> io::Result<Vec<IfaceInfo>> {
272    let list = if_addrs::get_if_addrs()?;
273    // C parity: on Linux consult the live kernel `IFF_UP`/`IFF_LOOPBACK`
274    // flags via `getifaddrs` so an administratively-down interface that
275    // still has an IPv4 address configured is not reported as a fanout
276    // target. (Linux-only because `getifaddrs`/`SIOCGIFFLAGS` is the Linux
277    // path — the crate itself links `libc` on every unix.)
278    #[cfg(target_os = "linux")]
279    let up_flags = interface_up_flags();
280
281    let mut out = Vec::with_capacity(list.len());
282    for iface in list {
283        let if_addrs::IfAddr::V4(v4) = &iface.addr else {
284            continue;
285        };
286        // `if-addrs` 0.13+ surfaces the kernel ifindex on every
287        // platform we target. `None` means the OS didn't report one
288        // (rare, but treat as 0 sentinel — the per-NIC fanout
289        // backend keys on the bound IP, not the index, so this is
290        // benign).
291        let index = iface.index.unwrap_or(0);
292
293        // C `osiSockDiscoverBroadcastAddresses`: an interface is a
294        // fanout target only when it is `IFF_UP` and not loopback.
295        #[cfg(target_os = "linux")]
296        let up_non_loopback = match up_flags.get(&iface.name) {
297            // Kernel flags known — honour them (and never treat
298            // loopback as a fanout target even if a stale flag map
299            // somehow disagrees).
300            Some(&eligible) => eligible && !iface.is_loopback(),
301            // Interface absent from the getifaddrs snapshot (raced a
302            // hot-unplug, or getifaddrs failed) — fall back to the
303            // loopback test rather than dropping the interface.
304            None => !iface.is_loopback(),
305        };
306        // Non-Linux (macOS / Windows / *BSD): the crate does not link
307        // `libc` here, and the platform enumerator already excludes
308        // operationally-down adapters, so the loopback test is the
309        // best portable approximation.
310        #[cfg(not(target_os = "linux"))]
311        let up_non_loopback = !iface.is_loopback();
312
313        out.push(IfaceInfo {
314            index,
315            name: iface.name.clone(),
316            ip: v4.ip,
317            netmask: v4.netmask,
318            broadcast: v4.broadcast,
319            up_non_loopback,
320        });
321    }
322    Ok(out)
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn enumerate_returns_loopback_at_minimum() {
331        let map = IfaceMap::new().expect("getifaddrs must succeed on the host");
332        let all = map.all();
333        // Every machine has at least one loopback v4 (127.0.0.1).
334        assert!(
335            all.iter().any(|i| i.ip.is_loopback()),
336            "loopback IPv4 interface should be present (got {all:?})"
337        );
338    }
339
340    #[test]
341    fn loopback_routing_lands_on_loopback() {
342        let map = IfaceMap::new().expect("getifaddrs must succeed on the host");
343        let r = map.route_to(Ipv4Addr::LOCALHOST);
344        assert!(r.is_some(), "127.0.0.1 must route to a known interface");
345        assert!(r.unwrap().ip.is_loopback());
346    }
347
348    #[test]
349    fn refresh_updates_timestamp() {
350        let map = IfaceMap::new().expect("getifaddrs must succeed on the host");
351        std::thread::sleep(Duration::from_millis(20));
352        let age = map
353            .refresh_if_stale(Duration::from_millis(10))
354            .expect("getifaddrs must succeed on the host");
355        assert!(
356            age >= Duration::from_millis(20),
357            "refresh_if_stale should report the pre-refresh age (got {age:?})"
358        );
359    }
360
361    /// M5 C-parity: loopback is never reported as a fanout target,
362    /// and every interface flagged `up_non_loopback` is genuinely
363    /// non-loopback (C `osiSockDiscoverBroadcastAddresses` skips
364    /// `IFF_LOOPBACK`).
365    #[test]
366    fn up_non_loopback_excludes_loopback() {
367        let map = IfaceMap::new().expect("getifaddrs must succeed on the host");
368        for iface in map.all() {
369            if iface.ip.is_loopback() {
370                assert!(
371                    !iface.up_non_loopback,
372                    "loopback {iface:?} must not be a fanout target"
373                );
374            }
375        }
376        // Everything in up_non_loopback() must be non-loopback.
377        for iface in map.up_non_loopback() {
378            assert!(
379                !iface.ip.is_loopback(),
380                "up_non_loopback() must not surface loopback: {iface:?}"
381            );
382        }
383    }
384
385    /// M5 C-parity: on Linux the live `IFF_UP` kernel flag is consulted.
386    /// The loopback interface is `IFF_UP` but `IFF_LOOPBACK`, so the
387    /// flag map reports it as ineligible.
388    #[cfg(target_os = "linux")]
389    #[test]
390    fn interface_up_flags_marks_loopback_ineligible() {
391        let flags = interface_up_flags();
392        // `lo`/`lo0` is up but loopback -> ineligible. Accept either
393        // name; the machine must have at least one loopback entry.
394        let lo_ineligible = flags
395            .iter()
396            .any(|(name, &eligible)| (name == "lo" || name == "lo0") && !eligible);
397        assert!(
398            lo_ineligible || flags.is_empty(),
399            "loopback must be flagged ineligible in the IFF_UP map: {flags:?}"
400        );
401    }
402
403    #[test]
404    fn subnet_contains_basic() {
405        // 10.0.0.5/24 contains 10.0.0.99 but not 10.0.1.1
406        let ip = Ipv4Addr::new(10, 0, 0, 5);
407        let mask = Ipv4Addr::new(255, 255, 255, 0);
408        assert!(subnet_contains(ip, mask, Ipv4Addr::new(10, 0, 0, 99)));
409        assert!(!subnet_contains(ip, mask, Ipv4Addr::new(10, 0, 1, 1)));
410    }
411
412    #[test]
413    fn subnet_contains_zero_mask_rejects() {
414        // 0.0.0.0 mask matches everything, which is meaningless for
415        // routing — we explicitly reject it.
416        assert!(!subnet_contains(
417            Ipv4Addr::UNSPECIFIED,
418            Ipv4Addr::UNSPECIFIED,
419            Ipv4Addr::new(8, 8, 8, 8)
420        ));
421    }
422
423    /// `spawn_refresh` actually fires the periodic refresh — verify
424    /// the snapshot's `last_refresh` advances at least once in the
425    /// poll window. Mirrors pvxs `IfMapDaemon` 15 s behaviour at a
426    /// short test cadence (50 ms × ~3 ticks ≈ 150 ms total).
427    #[epics_macros_rs::epics_test]
428    async fn spawn_refresh_advances_last_refresh() {
429        let map = IfaceMap::new().expect("getifaddrs must succeed on the host");
430        let initial = map.inner.lock().last_refresh;
431        let reactor =
432            crate::runtime::task::Reactor::current().expect("the test driver enters an executor");
433        let handle = map.spawn_refresh(&reactor, Duration::from_millis(50));
434        crate::runtime::task::sleep(Duration::from_millis(200)).await;
435        let after = map.inner.lock().last_refresh;
436        assert!(
437            after > initial,
438            "background refresh must update last_refresh"
439        );
440        handle.abort();
441    }
442}