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