//! `alloy net` — a NetworkManager front. //! //! Descended from sysop's `net.rs`, which fronted `ip` on Alpine. Alloy is //! Fedora, so the backend is `nmcli`; the mock-or-real detection pattern is //! carried over unchanged, because it is what lets the console be developed //! and demoed on a machine whose real network state you would rather not //! touch. use alloy_tui::{AlloyBlock, AlloyList, Cursor, Hint, Severity, Theme, hint, text}; use anyhow::Result; use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::Rect; use ratatui::text::{Line, Span}; use crate::cli::{CommandLog, Invocation}; use crate::shell::{Flow, View, block_title}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { Wired, Wireless, Loopback, Other, } impl Kind { /// Map NetworkManager's device type. NM's vocabulary is open-ended /// (`bridge`, `tun`, `wireguard`, `bond`, ...); everything Alloy does not /// name specifically is `Other` and still listed, because hiding an /// interface the user can see in `nmcli` would make the console look /// broken. fn from_nm(raw: &str) -> Self { match raw { "ethernet" => Kind::Wired, "wifi" => Kind::Wireless, "loopback" => Kind::Loopback, _ => Kind::Other, } } const fn label(self) -> &'static str { match self { Kind::Wired => "wired", Kind::Wireless => "wireless", Kind::Loopback => "loopback", Kind::Other => "other", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum State { Connected, Disconnected, Unavailable, Unmanaged, } impl State { /// NM reports `GENERAL.STATE` as `"100 (connected)"`. The numeric code is /// the stable part — the parenthesized text is localized — so parse the /// number and ignore the rest. fn from_nm(raw: &str) -> Self { let code = raw .split_whitespace() .next() .and_then(|n| n.parse::().ok()) .unwrap_or(0); match code { 100 => State::Connected, 30 => State::Disconnected, 20 => State::Unavailable, _ => State::Unmanaged, } } const fn label(self) -> &'static str { match self { State::Connected => "connected", State::Disconnected => "disconnected", State::Unavailable => "unavailable", State::Unmanaged => "unmanaged", } } const fn severity(self) -> Severity { match self { State::Connected => Severity::Healthy, State::Disconnected => Severity::Warn, State::Unavailable | State::Unmanaged => Severity::Info, } } } #[derive(Debug, Clone)] pub struct Interface { pub name: String, pub kind: Kind, pub state: State, pub connection: Option, pub addresses: Vec, } /// A source of interface state. pub trait Backend { fn name(&self) -> &'static str; fn list(&self, log: &mut CommandLog) -> Result>; } /// Pick a backend: the real one when `nmcli` answers, the mock otherwise. /// /// The probe is a real invocation rather than a `which` check — an `nmcli` /// binary that cannot reach a NetworkManager daemon (a container, a live ISO /// mid-boot) is worse than no `nmcli` at all, and only running it reveals that. pub fn detect() -> Box { if Invocation::new("nmcli").arg("--version").probe() { Box::new(NmCli) } else { Box::new(Mock) } } pub struct NmCli; impl NmCli { /// One invocation for the whole device table. `nmcli device show` with no /// device dumps every device, which keeps the log pane to a single /// copy-pasteable line instead of one per interface. fn invocation() -> Invocation { Invocation::new("nmcli").args([ "-t", "-f", "GENERAL.DEVICE,GENERAL.TYPE,GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP6.ADDRESS", "device", "show", ]) } } impl Backend for NmCli { fn name(&self) -> &'static str { "nmcli" } fn list(&self, log: &mut CommandLog) -> Result> { Ok(parse_device_show(&Self::invocation().run(log)?)) } } /// Fixed sample state, for machines without NetworkManager. pub struct Mock; impl Backend for Mock { fn name(&self) -> &'static str { "mock" } fn list(&self, log: &mut CommandLog) -> Result> { // Logged as a comment rather than a command: the pane's contract is // that every line is something you could run, and there is nothing to // run here. The `#` marks it as commentary in the same way a shell // would. log.record("# no NetworkManager; showing mock interfaces", Severity::Warn); Ok(vec![ Interface { name: "wlp1s0".into(), kind: Kind::Wireless, state: State::Connected, connection: Some("Example Network".into()), addresses: vec!["192.168.1.42/24".into()], }, Interface { name: "enp2s0".into(), kind: Kind::Wired, state: State::Disconnected, connection: None, addresses: vec![], }, Interface { name: "lo".into(), kind: Kind::Loopback, state: State::Unmanaged, connection: None, addresses: vec!["127.0.0.1/8".into()], }, ]) } } /// Parse `nmcli -t -f ... device show` output. /// /// Terse mode emits `KEY:value` per line with devices separated by blank /// lines. Keys never contain a colon, so splitting on the first one is /// unambiguous and values are taken verbatim. /// /// That verbatim part is worth stating, because nmcli's terse *tabular* output /// (`device status`) does escape colons as `\:` — it has to, since its fields /// are colon-separated. Multiline output does not, and an IPv6 address here /// arrives as plain `fe80::1`. Unescaping it anyway would corrupt any value /// containing a legitimate backslash. fn parse_device_show(raw: &str) -> Vec { let mut interfaces = Vec::new(); let mut current: Option = None; for line in raw.lines() { let line = line.trim_end(); if line.is_empty() { continue; } let Some((key, value)) = line.split_once(':') else { continue; }; let value = value.to_string(); // A device block starts at GENERAL.DEVICE. Keying off that rather than // the blank-line separator means a missing separator merges nothing: // the next DEVICE always opens a new record. if key == "GENERAL.DEVICE" { if let Some(iface) = current.take() { interfaces.push(iface); } current = Some(Interface { name: value, kind: Kind::Other, state: State::Unmanaged, connection: None, addresses: Vec::new(), }); continue; } let Some(iface) = current.as_mut() else { continue; }; match key { "GENERAL.TYPE" => iface.kind = Kind::from_nm(&value), "GENERAL.STATE" => iface.state = State::from_nm(&value), // NM writes `--` for an absent connection, which would otherwise // render as a connection literally named "--". "GENERAL.CONNECTION" if value != "--" && !value.is_empty() => { iface.connection = Some(value); } // Address keys are indexed: IP4.ADDRESS[1], IP6.ADDRESS[2], ... _ if !value.is_empty() && (key.starts_with("IP4.ADDRESS") || key.starts_with("IP6.ADDRESS")) => { iface.addresses.push(value); } _ => {} } } interfaces.extend(current); interfaces } /// The `alloy net` screen. pub struct NetView { backend: Box, interfaces: Vec, cursor: Cursor, error: Option, } impl NetView { pub fn new(log: &mut CommandLog) -> Self { let mut view = Self { backend: detect(), interfaces: Vec::new(), cursor: Cursor::new(), error: None, }; view.refresh(log); view } fn refresh(&mut self, log: &mut CommandLog) { match self.backend.list(log) { Ok(interfaces) => { self.interfaces = interfaces; // Refresh can shrink the list (an interface went away); the // cursor clamps itself back into range. self.cursor.resize(self.interfaces.len()); self.error = None; } Err(err) => self.error = Some(err.to_string()), } } fn row<'a>(&self, theme: &Theme, iface: &'a Interface) -> Line<'a> { let address = iface .addresses .first() .cloned() .or_else(|| iface.connection.clone()) .unwrap_or_default(); Line::from(vec![ text::bold(theme, format!("{:<12}", iface.name)), text::muted(theme, format!("{:<10}", iface.kind.label())), Span::styled( format!("{:<14}", iface.state.label()), iface.state.severity().style(theme), ), text::secondary(theme, address), ]) } } impl View for NetView { fn title(&self) -> String { format!("network ({})", self.backend.name()) } fn hints(&self) -> Vec { vec![hint("j/k", "select"), hint("r", "refresh")] } fn status(&self) -> Option<(Severity, String)> { self.error .as_ref() .map(|message| (Severity::Error, message.clone())) } fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { let block = AlloyBlock::new(theme) .focused(true) .build() .title(block_title(&self.title())); let inner = block.inner(area); frame.render_widget(block, area); if self.interfaces.is_empty() { frame.render_widget( Line::from(text::muted(theme, "no interfaces")), inner, ); return; } let rows: Vec = self .interfaces .iter() .map(|iface| self.row(theme, iface)) .collect(); frame.render_widget( AlloyList::new(theme, rows).selected(self.cursor.selected()), inner, ); } fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow { match key.code { KeyCode::Char('j') | KeyCode::Down => self.cursor.next(), KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(), KeyCode::Char('r') => self.refresh(log), _ => {} } Flow::Continue } } #[cfg(test)] mod tests { use super::*; // Captured verbatim from `nmcli -t -f GENERAL.DEVICE,GENERAL.TYPE, // GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP6.ADDRESS device show` on // a NetworkManager 1.5x box, hostname and SSID aside. Kept real rather // than tidied: the awkward parts below (nested parens in the state, an // empty trailing connection, a bare `::1`) are all things nmcli actually // emits, and a hand-written fixture is where a parser goes to pass tests // it would fail in production. const SAMPLE: &str = "\ GENERAL.DEVICE:wlp192s0 GENERAL.TYPE:wifi GENERAL.STATE:100 (connected) GENERAL.CONNECTION:Example Network IP4.ADDRESS[1]:192.168.0.16/24 IP6.ADDRESS[1]:fe80::59a3:bc22:d95f:c06b/64 GENERAL.DEVICE:tailscale0 GENERAL.TYPE:tun GENERAL.STATE:100 (connected (externally)) GENERAL.CONNECTION:tailscale0 IP4.ADDRESS[1]:100.103.89.95/32 IP6.ADDRESS[1]:fd7a:115c:a1e0::af3b:595f/128 IP6.ADDRESS[2]:fe80::ccae:60fc:a1c5:3b13/64 GENERAL.DEVICE:lo GENERAL.TYPE:loopback GENERAL.STATE:100 (connected (externally)) GENERAL.CONNECTION:lo IP4.ADDRESS[1]:127.0.0.1/8 IP6.ADDRESS[1]:::1/128 GENERAL.DEVICE:p2p-dev-wlp192s0 GENERAL.TYPE:wifi-p2p GENERAL.STATE:30 (disconnected) GENERAL.CONNECTION: "; #[test] fn parses_every_device_block() { let ifaces = parse_device_show(SAMPLE); assert_eq!(ifaces.len(), 4); assert_eq!(ifaces[0].name, "wlp192s0"); assert_eq!(ifaces[0].kind, Kind::Wireless); assert_eq!(ifaces[0].state, State::Connected); assert_eq!(ifaces[0].connection.as_deref(), Some("Example Network")); assert_eq!( ifaces[3].name, "p2p-dev-wlp192s0", "the last block is not dropped for want of a trailing blank line" ); } // IPv6 values carry colons and arrive unescaped, so the split has to be on // the *first* colon only. Splitting on every colon shows `fe80` as the // address; `::1/128` is the case that breaks a naive rsplit as well. #[test] fn ipv6_addresses_survive_the_key_value_split() { let ifaces = parse_device_show(SAMPLE); assert_eq!( ifaces[0].addresses, vec!["192.168.0.16/24", "fe80::59a3:bc22:d95f:c06b/64"] ); assert_eq!( ifaces[1].addresses, vec![ "100.103.89.95/32", "fd7a:115c:a1e0::af3b:595f/128", "fe80::ccae:60fc:a1c5:3b13/64", ], "every indexed address is collected, not just the first" ); assert_eq!(ifaces[2].addresses[1], "::1/128"); } // NM leaves the connection field empty for a device with no active // connection. Empty must read as absent, not as a connection named "". #[test] fn treats_an_empty_connection_as_absent() { let ifaces = parse_device_show(SAMPLE); assert_eq!(ifaces[3].connection, None); assert!(ifaces[3].addresses.is_empty()); } // `--` is NM's other placeholder for "none", used where a field is // tabulated rather than left blank. #[test] fn treats_double_dash_connection_as_absent() { let raw = "GENERAL.DEVICE:enp2s0\nGENERAL.TYPE:ethernet\nGENERAL.CONNECTION:--\n"; assert_eq!(parse_device_show(raw)[0].connection, None); } // The state field nests parentheses: "100 (connected (externally))". Only // the leading numeric code is stable across locales, so that is what is // parsed; anything reading the text would misclassify this as unmanaged. #[test] fn parses_state_from_the_numeric_code_not_the_text() { let ifaces = parse_device_show(SAMPLE); assert_eq!(ifaces[1].state, State::Connected); assert_eq!(ifaces[3].state, State::Disconnected); } #[test] fn empty_output_yields_no_interfaces() { assert!(parse_device_show("").is_empty()); } // NM's device-type vocabulary is open-ended; an unknown type must still // list rather than vanish. #[test] fn unknown_device_types_are_listed_as_other() { let raw = "GENERAL.DEVICE:wg0\nGENERAL.TYPE:wireguard\nGENERAL.STATE:100 (connected)\n"; let ifaces = parse_device_show(raw); assert_eq!(ifaces.len(), 1); assert_eq!(ifaces[0].kind, Kind::Other); assert_eq!(ifaces[0].state, State::Connected); } fn mock_view() -> (NetView, CommandLog) { let mut log = CommandLog::new(); let mut view = NetView { backend: Box::new(Mock), interfaces: Vec::new(), cursor: Cursor::new(), error: None, }; view.refresh(&mut log); (view, log) } // Cursor's own tests cover the clamping; this checks the wiring, that // refresh actually tells the cursor the new length. Without that call the // cursor keeps pointing at a row that no longer exists. #[test] fn refresh_resizes_the_cursor_when_the_list_shrinks() { let (mut view, mut log) = mock_view(); view.cursor.move_by(2); assert_eq!(view.cursor.selected(), Some(2)); view.backend = Box::new(EmptyBackend); view.refresh(&mut log); assert_eq!(view.cursor.selected(), None, "no selection in an empty list"); } // A failed refresh must leave the last good list on screen rather than // blanking it, and surface the error in the status area. #[test] fn a_failed_refresh_keeps_the_previous_interfaces() { let (mut view, mut log) = mock_view(); assert_eq!(view.interfaces.len(), 3); view.backend = Box::new(FailingBackend); view.refresh(&mut log); assert_eq!(view.interfaces.len(), 3, "the stale list is still shown"); assert!(view.error.is_some(), "the failure is surfaced"); } struct EmptyBackend; struct FailingBackend; impl Backend for FailingBackend { fn name(&self) -> &'static str { "failing" } fn list(&self, _log: &mut CommandLog) -> Result> { anyhow::bail!("nmcli went away") } } impl Backend for EmptyBackend { fn name(&self) -> &'static str { "empty" } fn list(&self, _log: &mut CommandLog) -> Result> { Ok(Vec::new()) } } }