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}