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}