Skip to main content

ados_protocol/
netif.rs

1//! Which wireless interface is which.
2//!
3//! A ground station carries two radios: the onboard chip (Broadcom `brcmfmac`
4//! on a Pi) and a USB RTL8812EU running WFB in monitor mode. Their kernel names
5//! are NOT stable — measured on the bench, `wlan0` was the onboard chip on two
6//! boots out of three and the flight radio on the third. Every consumer that
7//! hardcoded `"wlan0"` was therefore correct only by luck, and on the unlucky
8//! boot would configure an access point on the aircraft's radio link.
9//!
10//! The repo already understood this failure mode for ethernet: the uplink
11//! router resolves its NIC lazily "because early boot can race with udev: the
12//! NIC may not yet have its predictable name when this module is imported". The
13//! same reasoning applies to WiFi, and this module supplies the answer.
14//!
15//! Classification reads the GENERATED tables in [`crate::wfb_tables`] rather
16//! than a hand-written driver list. Three hand-written copies of that list
17//! already exist in the tree, which is exactly how such lists drift apart.
18//!
19//! The pure half ([`choose_ap_interface`]) is separated from the sysfs half so
20//! both name orderings can be tested without a radio present — the ordering IS
21//! the bug, so it has to be exercised deliberately.
22
23use std::path::{Path, PathBuf};
24
25use crate::wfb_tables::{DENY_DRIVER_PREFIXES, DENY_VID, WFB_COMPATIBLE, WFB_COMPATIBLE_DRIVERS};
26
27/// A wireless interface and the kernel driver bound to it.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct WirelessIface {
30    pub name: String,
31    pub driver: String,
32}
33
34/// Why no access-point interface could be chosen.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum ApIfaceError {
37    /// The box has no onboard WiFi at all. Running the access point on the
38    /// flight radio is never the better outcome, so the AP does not start.
39    NoOnboardWifi,
40    /// An operator pinned an interface that is the WFB radio.
41    ConfiguredIsRadio { iface: String, driver: String },
42    /// An operator pinned an interface that is not present.
43    ConfiguredMissing { iface: String },
44}
45
46impl std::fmt::Display for ApIfaceError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::NoOnboardWifi => write!(
50                f,
51                "no onboard WiFi interface found; refusing to run the access \
52                 point on the WFB radio"
53            ),
54            Self::ConfiguredIsRadio { iface, driver } => write!(
55                f,
56                "configured AP interface {iface} is the WFB radio (driver \
57                 {driver}); refusing to take the aircraft's radio link"
58            ),
59            Self::ConfiguredMissing { iface } => {
60                write!(f, "configured AP interface {iface} is not present")
61            }
62        }
63    }
64}
65
66/// True when this driver is a WFB injection radio (the flight link).
67///
68/// Reads the generated compatible-driver table so it cannot drift from the
69/// radio's own adapter selection.
70pub fn is_injection_driver(driver: &str) -> bool {
71    let d = driver.trim().to_ascii_lowercase();
72    WFB_COMPATIBLE_DRIVERS.iter().any(|k| *k == d)
73}
74
75/// True when an adapter is a WFB injection radio by the rule the radio's own
76/// adapter selection applies: the management-WiFi deny set first (driver
77/// prefix or vendor id), then the USB VID:PID table, then the compatible-driver
78/// table. The VID:PID leg is what catches a radio bound to a driver outside the
79/// driver table (a mainline driver on a known flight-radio id).
80pub fn is_wfb_adapter(driver: &str, vid_pid: Option<(u16, u16)>) -> bool {
81    let d = driver.trim().to_ascii_lowercase();
82    if DENY_DRIVER_PREFIXES.iter().any(|p| d.starts_with(p)) {
83        return false;
84    }
85    if let Some((vid, pid)) = vid_pid {
86        if DENY_VID.contains(&vid) {
87            return false;
88        }
89        if WFB_COMPATIBLE
90            .iter()
91            .any(|(v, p, _)| *v == vid && *p == pid)
92        {
93            return true;
94        }
95    }
96    is_injection_driver(&d)
97}
98
99/// `(idVendor, idProduct)` of the USB device backing a netdev under `root`.
100///
101/// The netdev's `device` link points at the USB *interface* node, which has no
102/// id files; they live on the parent *device* node, one (sometimes more) levels
103/// up. The walk is bounded so a non-USB device never climbs to the root.
104pub fn usb_vid_pid_in(root: &Path, iface: &str) -> Option<(u16, u16)> {
105    const MAX_PARENT_HOPS: usize = 4;
106    let mut dir = std::fs::canonicalize(root.join(iface).join("device")).ok()?;
107    for _ in 0..=MAX_PARENT_HOPS {
108        if let (Ok(vid), Ok(pid)) = (
109            std::fs::read_to_string(dir.join("idVendor")),
110            std::fs::read_to_string(dir.join("idProduct")),
111        ) {
112            return Some((
113                u16::from_str_radix(vid.trim(), 16).ok()?,
114                u16::from_str_radix(pid.trim(), 16).ok()?,
115            ));
116        }
117        dir = dir.parent()?.to_path_buf();
118    }
119    None
120}
121
122/// [`usb_vid_pid_in`] against the live `/sys/class/net`.
123pub fn usb_vid_pid(iface: &str) -> Option<(u16, u16)> {
124    usb_vid_pid_in(Path::new(NET_DIR), iface)
125}
126
127/// True when this driver is onboard management WiFi — the access-point radio.
128///
129/// This is the generated deny-set the radio uses to make sure it never grabs
130/// management WiFi for injection. Read from the other direction it is exactly
131/// "the interface the access point wants", which is why it is reused here
132/// rather than a second list being invented.
133pub fn is_onboard_wifi_driver(driver: &str) -> bool {
134    let d = driver.trim().to_ascii_lowercase();
135    DENY_DRIVER_PREFIXES.iter().any(|p| d.starts_with(p))
136}
137
138/// Choose the access-point interface. Pure — no filesystem, no radio.
139///
140/// Order: an explicit operator setting, then the first onboard-WiFi driver.
141/// `radio_iface` is the interface the radio reports it actually took (from its
142/// own sidecar); anything matching it is refused outright, so even a wrong
143/// classification cannot end with hostapd on the flight link.
144pub fn choose_ap_interface(
145    ifaces: &[WirelessIface],
146    configured: &str,
147    radio_iface: Option<&str>,
148) -> Result<String, ApIfaceError> {
149    let is_radio = |c: &WirelessIface| {
150        is_injection_driver(&c.driver) || radio_iface.is_some_and(|r| r == c.name)
151    };
152
153    let configured = configured.trim();
154    if !configured.is_empty() {
155        return match ifaces.iter().find(|c| c.name == configured) {
156            Some(c) if is_radio(c) => Err(ApIfaceError::ConfiguredIsRadio {
157                iface: c.name.clone(),
158                driver: c.driver.clone(),
159            }),
160            Some(c) => Ok(c.name.clone()),
161            // Absent is an error rather than a silent fallback: an operator who
162            // named an interface should be told it is missing, not quietly
163            // given a different one.
164            None => Err(ApIfaceError::ConfiguredMissing {
165                iface: configured.to_string(),
166            }),
167        };
168    }
169
170    ifaces
171        .iter()
172        .find(|c| is_onboard_wifi_driver(&c.driver) && !is_radio(c))
173        .map(|c| c.name.clone())
174        .ok_or(ApIfaceError::NoOnboardWifi)
175}
176
177const NET_DIR: &str = "/sys/class/net";
178
179/// The kernel driver bound to an interface, from the sysfs `device/driver` link.
180pub fn driver_name_in(root: &Path, iface: &str) -> Option<String> {
181    let link = root.join(iface).join("device").join("driver");
182    let target = std::fs::read_link(&link).ok()?;
183    Some(target.file_name()?.to_string_lossy().into_owned())
184}
185
186/// True when the interface is 802.11 (it carries a `phy80211` node).
187pub fn is_wireless_in(root: &Path, iface: &str) -> bool {
188    root.join(iface).join("phy80211").exists() || root.join(iface).join("wireless").exists()
189}
190
191/// Every wireless interface with its driver, sorted by name for determinism.
192///
193/// Sorted so the choice does not depend on readdir order, which is arbitrary.
194/// Determinism matters more than the specific order: an interface with no
195/// resolvable driver is dropped rather than guessed at.
196pub fn list_wireless_in(root: &Path) -> Vec<WirelessIface> {
197    let mut out: Vec<WirelessIface> = std::fs::read_dir(root)
198        .into_iter()
199        .flatten()
200        .flatten()
201        .filter_map(|e| {
202            let name = e.file_name().to_string_lossy().into_owned();
203            if name == "lo" || !is_wireless_in(root, &name) {
204                return None;
205            }
206            Some(WirelessIface {
207                driver: driver_name_in(root, &name)?,
208                name,
209            })
210        })
211        .collect();
212    out.sort_by(|a, b| a.name.cmp(&b.name));
213    out
214}
215
216/// [`list_wireless_in`] against the live `/sys/class/net`.
217pub fn list_wireless() -> Vec<WirelessIface> {
218    list_wireless_in(&PathBuf::from(NET_DIR))
219}
220
221/// The `wfb-stats` sidecar (contract id `wfb-stats`), where the radio publishes
222/// the interface it actually selected.
223pub const WFB_STATS_PATH: &str = "/run/ados/wfb-stats.json";
224
225/// The interface the radio reports it took, if it has published one.
226///
227/// Consuming the radio's own verdict rather than re-deriving it is the pattern
228/// the USB-rehome guard already uses to build its protected set. It is the
229/// authority here: classification can be wrong, but the radio knows what it
230/// opened.
231pub fn radio_interface_from(path: &Path) -> Option<String> {
232    let txt = std::fs::read_to_string(path).ok()?;
233    let v: serde_json::Value = serde_json::from_str(&txt).ok()?;
234    let iface = v.get("interface")?.as_str()?.trim().to_string();
235    (!iface.is_empty()).then_some(iface)
236}
237
238/// [`radio_interface_from`] against the live sidecar.
239pub fn radio_interface() -> Option<String> {
240    radio_interface_from(Path::new(WFB_STATS_PATH))
241}
242
243/// Resolve the access-point interface on this box.
244pub fn resolve_ap_interface(
245    configured: &str,
246    radio_iface: Option<&str>,
247) -> Result<String, ApIfaceError> {
248    choose_ap_interface(&list_wireless(), configured, radio_iface)
249}
250
251/// Drivers that present an Ethernet-class interface without being a wired
252/// port: the USB gadget the board itself exposes to a laptop, a phone's USB
253/// tether, and cellular modems. Their interfaces are uplinks or lifelines of
254/// their own, never the operator's cable.
255const NOT_WIRED_DRIVERS: &[&str] = &[
256    "g_ether",
257    "g_multi",
258    "rndis_host",
259    "ipheth",
260    "qmi_wwan",
261    "cdc_mbim",
262    "huawei_cdc_ncm",
263];
264
265/// True when the interface is a physical wired Ethernet port: Ethernet link
266/// type, backed by a device with a bound driver, not 802.11, and not a USB
267/// gadget, phone tether or modem. Classified by device, never by name, since
268/// wired names vary across boards (`eth0`, `end1`, `enp1s0`, `enx…`).
269pub fn is_wired_in(root: &Path, iface: &str) -> bool {
270    if iface == "lo" || is_wireless_in(root, iface) {
271        return false;
272    }
273    let dir = root.join(iface);
274    // ARPHRD_ETHER.
275    let ether = std::fs::read_to_string(dir.join("type"))
276        .map(|t| t.trim() == "1")
277        .unwrap_or(false);
278    if !ether {
279        return false;
280    }
281    // A gadget function's interface hangs off the board's own UDC.
282    let device = match std::fs::read_link(dir.join("device")) {
283        Ok(target) => target,
284        Err(_) => return false,
285    };
286    if device.to_string_lossy().contains("gadget") {
287        return false;
288    }
289    match driver_name_in(root, iface) {
290        Some(driver) => {
291            !NOT_WIRED_DRIVERS.contains(&driver.as_str()) && !driver.starts_with("usb_f_")
292        }
293        None => false,
294    }
295}
296
297/// Every physical wired interface, sorted by name for determinism.
298pub fn list_wired_in(root: &Path) -> Vec<String> {
299    let mut out: Vec<String> = std::fs::read_dir(root)
300        .into_iter()
301        .flatten()
302        .flatten()
303        .map(|e| e.file_name().to_string_lossy().into_owned())
304        .filter(|name| is_wired_in(root, name))
305        .collect();
306    out.sort();
307    out
308}
309
310/// [`list_wired_in`] against the live `/sys/class/net`.
311pub fn list_wired() -> Vec<String> {
312    list_wired_in(&PathBuf::from(NET_DIR))
313}
314
315/// True when the box carries two or more WFB-class radios, the hardware a
316/// relay or receiver mesh role needs. Derived live from driver and USB ids so
317/// it follows a replug, never from a persisted hint.
318pub fn mesh_capable_in(root: &Path) -> bool {
319    list_wireless_in(root)
320        .iter()
321        .filter(|w| is_wfb_adapter(&w.driver, usb_vid_pid_in(root, &w.name)))
322        .count()
323        >= 2
324}
325
326/// [`mesh_capable_in`] against the live `/sys/class/net`.
327pub fn mesh_capable() -> bool {
328    mesh_capable_in(Path::new(NET_DIR))
329}
330
331/// Drivers that only ever bind a cellular modem's data interface.
332const CELLULAR_DRIVERS: &[&str] = &[
333    "qmi_wwan",
334    "cdc_mbim",
335    "huawei_cdc_ncm",
336    "cdc_ncm",
337    "rndis_host",
338];
339
340/// USB vendors whose `option`-driven interfaces are modems.
341const MODEM_USB_VIDS: &[u16] = &[0x1199, 0x2c7c, 0x12d1, 0x05c6, 0x1e0e, 0x2cb7, 0x1bc7];
342
343/// True when the interface is a cellular modem's data port, by driver and
344/// bus. The board's own USB gadget (a `gadget`/`udc` device path) is never a
345/// modem even when it binds the same class driver.
346pub fn is_cellular_in(root: &Path, iface: &str) -> bool {
347    let Ok(device) = std::fs::read_link(root.join(iface).join("device")) else {
348        return false;
349    };
350    let path = device.to_string_lossy();
351    if path.contains("gadget") || path.contains("udc") {
352        return false;
353    }
354    match driver_name_in(root, iface).as_deref() {
355        Some(d) if CELLULAR_DRIVERS.contains(&d) => true,
356        Some("option") => {
357            usb_vid_pid_in(root, iface).is_some_and(|(vid, _)| MODEM_USB_VIDS.contains(&vid))
358        }
359        _ => false,
360    }
361}
362
363/// Every cellular data interface, sorted by name for determinism.
364pub fn list_cellular_in(root: &Path) -> Vec<String> {
365    let mut out: Vec<String> = std::fs::read_dir(root)
366        .into_iter()
367        .flatten()
368        .flatten()
369        .map(|e| e.file_name().to_string_lossy().into_owned())
370        .filter(|name| is_cellular_in(root, name))
371        .collect();
372    out.sort();
373    out
374}
375
376/// [`list_cellular_in`] against the live `/sys/class/net`.
377pub fn list_cellular() -> Vec<String> {
378    list_cellular_in(&PathBuf::from(NET_DIR))
379}
380
381#[cfg(test)]
382#[path = "netif_tests.rs"]
383mod tests;