Skip to main content

rigtop_core/linux/
net.rs

1//! Network link state from `/sys/class/net/<iface>` and, for Wi-Fi, `iw`.
2//!
3//! Wired links report their negotiated speed in `speed` (Mbit/s, `-1` or an
4//! error when unknown). Wireless drivers do not; their current bit rates come
5//! from nl80211, which is read here through `iw dev <iface> link` at most
6//! every [`WIRELESS_REFRESH`] to keep the cost of spawning it negligible.
7
8use std::collections::HashMap;
9use std::process::Command;
10use std::time::{Duration, Instant};
11
12use super::{SysRoots, read_i64, read_trimmed};
13use crate::snapshot::{NetworkStats, WirelessLink};
14
15/// How often Wi-Fi bit rates are refreshed.
16const WIRELESS_REFRESH: Duration = Duration::from_secs(10);
17
18/// Adds link information to network statistics, caching Wi-Fi queries.
19#[derive(Debug, Default)]
20pub struct NetLinkReader {
21    /// Last Wi-Fi query per interface: when it ran and what it returned.
22    wireless: HashMap<String, (Instant, Option<WirelessLink>)>,
23}
24
25impl NetLinkReader {
26    /// Creates a reader with an empty cache.
27    pub fn new() -> Self {
28        Self::default()
29    }
30
31    /// Fills `link_speed_mbps`, `link_up` and `wireless` of each interface.
32    pub fn annotate(&mut self, roots: &SysRoots, networks: &mut [NetworkStats], now: Instant) {
33        for net in networks.iter_mut() {
34            let dir = roots.sys.join("class/net").join(&net.name);
35            net.link_speed_mbps = read_i64(&dir.join("speed"))
36                .filter(|s| *s > 0)
37                .and_then(|s| u32::try_from(s).ok());
38            net.link_up =
39                read_trimmed(&dir.join("operstate")).and_then(|state| match state.as_str() {
40                    "up" => Some(true),
41                    "down" | "lowerlayerdown" | "dormant" | "notpresent" => Some(false),
42                    // "unknown" is common for tunnels and loopback.
43                    _ => None,
44                });
45            if dir.join("wireless").is_dir() {
46                net.wireless = self.wireless_link(&net.name, now);
47            }
48        }
49        // Forget interfaces that disappeared (USB adapters, VPNs).
50        self.wireless
51            .retain(|name, _| networks.iter().any(|n| &n.name == name));
52    }
53
54    fn wireless_link(&mut self, name: &str, now: Instant) -> Option<WirelessLink> {
55        if let Some((at, link)) = self.wireless.get(name)
56            && now.saturating_duration_since(*at) < WIRELESS_REFRESH
57        {
58            return link.clone();
59        }
60        let link = Command::new("iw")
61            .args(["dev", name, "link"])
62            .output()
63            .ok()
64            .filter(|out| out.status.success())
65            .and_then(|out| parse_iw_link(&String::from_utf8_lossy(&out.stdout)));
66        self.wireless.insert(name.to_owned(), (now, link.clone()));
67        link
68    }
69}
70
71/// Parses `iw dev <iface> link` output. Returns `None` when not connected.
72pub(crate) fn parse_iw_link(text: &str) -> Option<WirelessLink> {
73    if !text.trim_start().starts_with("Connected") {
74        return None;
75    }
76    let mut link = WirelessLink::default();
77    for line in text.lines() {
78        let Some((key, value)) = line.trim().split_once(':') else {
79            continue;
80        };
81        // Values look like "576.4 MBit/s 160MHz HE-MCS 5 ..." or "-74 dBm".
82        let first = value.split_whitespace().next();
83        match key {
84            "rx bitrate" => link.rx_bitrate_mbps = first.and_then(|v| v.parse().ok()),
85            "tx bitrate" => link.tx_bitrate_mbps = first.and_then(|v| v.parse().ok()),
86            "signal" => link.signal_dbm = first.and_then(|v| v.parse().ok()),
87            _ => {}
88        }
89    }
90    Some(link)
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::linux::testutil::write;
97
98    const IW_CONNECTED: &str = "\
99Connected to 04:42:1a:e4:c4:34 (on wlp7s0)
100\tSSID: example
101\tfreq: 5500.0
102\tsignal: -74 dBm
103\trx bitrate: 576.4 MBit/s 160MHz HE-MCS 5 HE-NSS 1 HE-GI 0 HE-DCM 0
104\ttx bitrate: 490.0 MBit/s 160MHz HE-MCS 3 HE-NSS 2 HE-GI 2 HE-DCM 0
105";
106
107    #[test]
108    fn parses_iw_bitrates_and_signal() {
109        let link = parse_iw_link(IW_CONNECTED).unwrap();
110        assert_eq!(link.rx_bitrate_mbps, Some(576.4));
111        assert_eq!(link.tx_bitrate_mbps, Some(490.0));
112        assert_eq!(link.signal_dbm, Some(-74));
113        assert!(parse_iw_link("Not connected.\n").is_none());
114    }
115
116    #[test]
117    fn reads_wired_speed_and_operstate() {
118        let tmp = tempfile::tempdir().unwrap();
119        let root = tmp.path();
120        write(root, "sys/class/net/enp9s0/speed", "10000\n");
121        write(root, "sys/class/net/enp9s0/operstate", "up\n");
122        // Unplugged ports report -1 (or fail to read) and are down.
123        write(root, "sys/class/net/enp8s0/speed", "-1\n");
124        write(root, "sys/class/net/enp8s0/operstate", "down\n");
125        write(root, "sys/class/net/tailscale0/operstate", "unknown\n");
126        let roots = SysRoots {
127            sys: root.join("sys"),
128            proc: root.join("proc"),
129        };
130        let mut networks: Vec<NetworkStats> = ["enp9s0", "enp8s0", "tailscale0"]
131            .into_iter()
132            .map(|name| NetworkStats {
133                name: name.to_owned(),
134                ..Default::default()
135            })
136            .collect();
137
138        NetLinkReader::new().annotate(&roots, &mut networks, Instant::now());
139        assert_eq!(networks[0].link_speed_mbps, Some(10_000));
140        assert_eq!(networks[0].link_up, Some(true));
141        assert_eq!(networks[1].link_speed_mbps, None);
142        assert_eq!(networks[1].link_up, Some(false));
143        assert_eq!(networks[2].link_up, None);
144        assert!(networks[0].wireless.is_none());
145    }
146}