diff --git a/local/recipes/system/redbear-netctl-console/recipe.toml b/local/recipes/system/redbear-netctl-console/recipe.toml new file mode 100644 index 00000000..6697f122 --- /dev/null +++ b/local/recipes/system/redbear-netctl-console/recipe.toml @@ -0,0 +1,13 @@ +[source] +path = "source" + +[package] +dependencies = ["terminfo"] + +[build] +template = "cargo" +dependencies = ["ncursesw"] + +[package.files] +"/usr/bin/redbear-netctl-console" = "redbear-netctl-console" +"/usr/bin/netctl-console" = "redbear-netctl-console" diff --git a/local/recipes/system/redbear-netctl-console/source/Cargo.toml b/local/recipes/system/redbear-netctl-console/source/Cargo.toml new file mode 100644 index 00000000..c1a294e8 --- /dev/null +++ b/local/recipes/system/redbear-netctl-console/source/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "redbear-netctl-console" +version = "0.1.0" +edition = "2024" + +[lib] +name = "redbear_netctl_console" +path = "src/lib.rs" + +[[bin]] +name = "redbear-netctl-console" +path = "src/main.rs" + +[dependencies] +ratatui = { version = "0.30", default-features = false, features = ["termion"] } +termion = "4" diff --git a/local/recipes/system/redbear-netctl-console/source/src/app.rs b/local/recipes/system/redbear-netctl-console/source/src/app.rs new file mode 100644 index 00000000..2566deac --- /dev/null +++ b/local/recipes/system/redbear-netctl-console/source/src/app.rs @@ -0,0 +1,586 @@ +use termion::event::Key; + +use crate::backend::{ConsoleBackend, IpMode, Profile, ScanResult, SecurityKind, WifiRuntimeState}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Focus { + Profiles, + Scan, + Fields, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Field { + Name, + Description, + Interface, + Ssid, + Security, + Key, + IpMode, + Address, + Gateway, + Dns, +} + +impl Field { + pub fn label(&self) -> &'static str { + match self { + Self::Name => "Profile", + Self::Description => "Description", + Self::Interface => "Interface", + Self::Ssid => "SSID", + Self::Security => "Security", + Self::Key => "Key", + Self::IpMode => "IP", + Self::Address => "Address", + Self::Gateway => "Gateway", + Self::Dns => "DNS", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorState { + pub field: Field, + pub buffer: String, +} + +pub struct App { + backend: B, + pub profiles: Vec, + pub selected_profile: usize, + pub draft: Profile, + pub scans: Vec, + pub selected_scan: usize, + pub selected_field: usize, + pub focus: Focus, + pub editor: Option, + pub active_profile: Option, + pub status: WifiRuntimeState, + pub message: String, + pub dirty: bool, + pub should_quit: bool, +} + +impl App { + pub fn new(backend: B) -> Result { + let mut app = Self { + backend, + profiles: Vec::new(), + selected_profile: 0, + draft: Profile::default(), + scans: Vec::new(), + selected_scan: 0, + selected_field: 0, + focus: Focus::Profiles, + editor: None, + active_profile: None, + status: WifiRuntimeState::default(), + message: "Tab switches panes. r scans. s saves. c connects. d disconnects.".to_string(), + dirty: false, + should_quit: false, + }; + app.reload_profiles()?; + app.refresh_status(); + Ok(app) + } + + pub fn visible_fields(&self) -> Vec { + let mut fields = vec![ + Field::Name, + Field::Description, + Field::Interface, + Field::Ssid, + Field::Security, + ]; + + if matches!(self.draft.security, SecurityKind::Wpa2Psk) { + fields.push(Field::Key); + } + + fields.push(Field::IpMode); + + if matches!(self.draft.ip_mode, IpMode::Static) { + fields.extend([Field::Address, Field::Gateway, Field::Dns]); + } + + fields + } + + pub fn selected_field(&self) -> Field { + let fields = self.visible_fields(); + fields[self.selected_field.min(fields.len().saturating_sub(1))] + } + + pub fn field_value(&self, field: Field) -> String { + match field { + Field::Name => self.draft.name.clone(), + Field::Description => self.draft.description.clone(), + Field::Interface => self.draft.interface.clone(), + Field::Ssid => self.draft.ssid.clone(), + Field::Security => self.draft.security.as_str().to_string(), + Field::Key => mask_secret(&self.draft.key), + Field::IpMode => self.draft.ip_mode.as_str().to_string(), + Field::Address => self.draft.address.clone(), + Field::Gateway => self.draft.gateway.clone(), + Field::Dns => self.draft.dns.clone(), + } + } + + pub fn handle_key(&mut self, key: Key) { + if self.editor.is_some() { + self.handle_editor_key(key); + return; + } + + match key { + Key::Char('q') => self.should_quit = true, + Key::Char('\t') => self.cycle_focus(true), + Key::BackTab => self.cycle_focus(false), + Key::Char('r') => self.scan(), + Key::Char('s') => self.save_current_profile(), + Key::Char('a') => self.activate_current_profile(), + Key::Char('c') => self.connect_current_profile(), + Key::Char('d') => self.disconnect_current_profile(), + Key::Char('n') => self.start_new_profile(), + Key::Up | Key::Char('k') => self.move_selection(-1), + Key::Down | Key::Char('j') => self.move_selection(1), + Key::Left | Key::Char('h') => self.adjust_field_enum(false), + Key::Right | Key::Char('l') => self.adjust_field_enum(true), + Key::Char('\n') => self.activate_current_focus(), + _ => {} + } + } + + pub fn reload_profiles(&mut self) -> Result<(), String> { + self.active_profile = self.backend.active_profile_name()?; + self.profiles = self.backend.list_wifi_profiles()?; + + if let Some(active) = &self.active_profile + && let Some(index) = self.profiles.iter().position(|name| name == active) + { + self.selected_profile = index; + self.load_selected_profile()?; + return Ok(()); + } + + if let Some(index) = self + .profiles + .iter() + .position(|name| name == &self.draft.name) + { + self.selected_profile = index; + return Ok(()); + } + + if !self.profiles.is_empty() { + self.selected_profile = 0; + self.load_selected_profile()?; + } + + Ok(()) + } + + pub fn refresh_status(&mut self) { + self.status = self.backend.read_status(&self.draft.interface); + } + + fn cycle_focus(&mut self, forward: bool) { + self.focus = match (self.focus, forward) { + (Focus::Profiles, true) => Focus::Scan, + (Focus::Scan, true) => Focus::Fields, + (Focus::Fields, true) => Focus::Profiles, + (Focus::Profiles, false) => Focus::Fields, + (Focus::Scan, false) => Focus::Profiles, + (Focus::Fields, false) => Focus::Scan, + }; + } + + fn move_selection(&mut self, delta: isize) { + match self.focus { + Focus::Profiles => adjust_index(&mut self.selected_profile, self.profiles.len(), delta), + Focus::Scan => adjust_index(&mut self.selected_scan, self.scans.len(), delta), + Focus::Fields => { + let field_count = self.visible_fields().len(); + adjust_index(&mut self.selected_field, field_count, delta) + } + } + } + + fn activate_current_focus(&mut self) { + match self.focus { + Focus::Profiles => { + if let Err(err) = self.load_selected_profile() { + self.message = format!("Error: {err}"); + } + } + Focus::Scan => self.apply_selected_scan(), + Focus::Fields => self.activate_selected_field(), + } + } + + fn activate_selected_field(&mut self) { + match self.selected_field() { + Field::Security => { + self.draft.security = self.draft.security.next(); + self.selected_field = self + .selected_field + .min(self.visible_fields().len().saturating_sub(1)); + self.dirty = true; + } + Field::IpMode => { + self.draft.ip_mode = self.draft.ip_mode.next(); + self.selected_field = self + .selected_field + .min(self.visible_fields().len().saturating_sub(1)); + self.dirty = true; + } + field => { + self.editor = Some(EditorState { + field, + buffer: self.raw_field_value(field), + }); + } + } + } + + fn adjust_field_enum(&mut self, forward: bool) { + if self.focus != Focus::Fields { + return; + } + + match self.selected_field() { + Field::Security => { + self.draft.security = if forward { + self.draft.security.next() + } else { + self.draft.security.previous() + }; + self.selected_field = self + .selected_field + .min(self.visible_fields().len().saturating_sub(1)); + self.dirty = true; + } + Field::IpMode => { + self.draft.ip_mode = if forward { + self.draft.ip_mode.next() + } else { + self.draft.ip_mode.previous() + }; + self.selected_field = self + .selected_field + .min(self.visible_fields().len().saturating_sub(1)); + self.dirty = true; + } + _ => {} + } + } + + fn handle_editor_key(&mut self, key: Key) { + let Some(editor) = self.editor.as_mut() else { + return; + }; + + match key { + Key::Esc => self.editor = None, + Key::Backspace => { + editor.buffer.pop(); + } + Key::Char('\n') => { + let field = editor.field; + let buffer = editor.buffer.clone(); + self.editor = None; + self.commit_field(field, buffer); + } + Key::Char(ch) if !ch.is_control() => editor.buffer.push(ch), + _ => {} + } + } + + fn commit_field(&mut self, field: Field, value: String) { + match field { + Field::Name => self.draft.name = value, + Field::Description => self.draft.description = value, + Field::Interface => { + self.draft.interface = value; + self.refresh_status(); + } + Field::Ssid => self.draft.ssid = value, + Field::Key => self.draft.key = value, + Field::Address => self.draft.address = value, + Field::Gateway => self.draft.gateway = value, + Field::Dns => self.draft.dns = value, + Field::Security | Field::IpMode => {} + } + + self.dirty = true; + } + + fn load_selected_profile(&mut self) -> Result<(), String> { + let Some(name) = self.profiles.get(self.selected_profile).cloned() else { + return Ok(()); + }; + self.draft = self.backend.load_profile(&name)?; + self.editor = None; + self.selected_field = 0; + self.selected_scan = 0; + self.scans.clear(); + self.refresh_status(); + self.message = format!("Loaded {name}"); + self.dirty = false; + Ok(()) + } + + fn apply_selected_scan(&mut self) { + let Some(scan) = self.scans.get(self.selected_scan).cloned() else { + self.message = "No scan result selected".to_string(); + return; + }; + self.draft.ssid = scan.ssid.clone(); + if let Some(security_hint) = scan.security_hint { + self.draft.security = security_hint; + } + self.dirty = true; + self.message = format!("Selected SSID {}", scan.ssid); + } + + fn save_current_profile(&mut self) { + match self.backend.save_profile(&self.draft) { + Ok(()) => { + self.dirty = false; + self.message = format!("Saved {}", self.draft.name); + if let Err(err) = self.reload_profiles() { + self.message = format!("Saved {}, but reload failed: {err}", self.draft.name); + } + } + Err(err) => self.message = format!("Error: {err}"), + } + } + + fn activate_current_profile(&mut self) { + match self.backend.save_profile(&self.draft) { + Ok(()) => match self.backend.set_active_profile(&self.draft.name) { + Ok(()) => { + self.active_profile = Some(self.draft.name.clone()); + self.dirty = false; + self.message = format!("Activated {} for boot/profile reuse", self.draft.name); + let _ = self.reload_profiles(); + } + Err(err) => self.message = format!("Error: {err}"), + }, + Err(err) => self.message = format!("Error: {err}"), + } + } + + fn connect_current_profile(&mut self) { + match self.backend.connect(&self.draft) { + Ok(message) => { + self.active_profile = Some(self.draft.name.clone()); + self.dirty = false; + self.message = message; + let _ = self.reload_profiles(); + self.refresh_status(); + } + Err(err) => { + self.refresh_status(); + self.message = format!("Error: {err}"); + } + } + } + + fn disconnect_current_profile(&mut self) { + match self + .backend + .disconnect(Some(&self.draft.name), &self.draft.interface) + { + Ok(message) => { + if self.active_profile.as_deref() == Some(self.draft.name.as_str()) { + self.active_profile = None; + } + self.message = message; + let _ = self.reload_profiles(); + self.refresh_status(); + } + Err(err) => { + self.refresh_status(); + self.message = format!("Error: {err}"); + } + } + } + + fn scan(&mut self) { + match self.backend.scan(&self.draft.interface) { + Ok(scans) => { + self.scans = scans; + self.selected_scan = 0; + self.refresh_status(); + self.message = if self.scans.is_empty() { + format!( + "Scan completed for {} with no visible results", + self.draft.interface + ) + } else { + format!( + "Scan completed for {} with {} result(s)", + self.draft.interface, + self.scans.len() + ) + }; + } + Err(err) => { + self.refresh_status(); + self.message = format!("Error: {err}"); + } + } + } + + fn start_new_profile(&mut self) { + self.draft = Profile::default(); + self.scans.clear(); + self.selected_scan = 0; + self.selected_field = 0; + self.focus = Focus::Fields; + self.editor = None; + self.dirty = true; + self.refresh_status(); + self.message = "Started a new Wi-Fi profile draft".to_string(); + } + + fn raw_field_value(&self, field: Field) -> String { + match field { + Field::Name => self.draft.name.clone(), + Field::Description => self.draft.description.clone(), + Field::Interface => self.draft.interface.clone(), + Field::Ssid => self.draft.ssid.clone(), + Field::Key => self.draft.key.clone(), + Field::Address => self.draft.address.clone(), + Field::Gateway => self.draft.gateway.clone(), + Field::Dns => self.draft.dns.clone(), + Field::Security => self.draft.security.as_str().to_string(), + Field::IpMode => self.draft.ip_mode.as_str().to_string(), + } + } +} + +fn adjust_index(index: &mut usize, len: usize, delta: isize) { + if len == 0 { + *index = 0; + return; + } + + if delta < 0 { + *index = index.saturating_sub(delta.unsigned_abs()); + } else { + *index = (*index + delta as usize).min(len.saturating_sub(1)); + } +} + +fn mask_secret(secret: &str) -> String { + if secret.is_empty() { + "".to_string() + } else { + "•".repeat(secret.chars().count()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::ScanResult; + + #[derive(Default)] + struct MockBackend { + profiles: Vec, + active_profile: Option, + loaded_profile: Profile, + scans: Vec, + } + + impl ConsoleBackend for MockBackend { + fn list_wifi_profiles(&self) -> Result, String> { + Ok(self.profiles.clone()) + } + + fn active_profile_name(&self) -> Result, String> { + Ok(self.active_profile.clone()) + } + + fn load_profile(&self, _name: &str) -> Result { + Ok(self.loaded_profile.clone()) + } + + fn save_profile(&self, _profile: &Profile) -> Result<(), String> { + Ok(()) + } + + fn set_active_profile(&self, _profile_name: &str) -> Result<(), String> { + Ok(()) + } + + fn clear_active_profile(&self) -> Result<(), String> { + Ok(()) + } + + fn read_status(&self, interface: &str) -> WifiRuntimeState { + WifiRuntimeState { + interface: interface.to_string(), + status: "unknown".to_string(), + address: "unconfigured".to_string(), + ..WifiRuntimeState::default() + } + } + + fn scan(&self, _interface: &str) -> Result, String> { + Ok(self.scans.clone()) + } + + fn connect(&self, _profile: &Profile) -> Result { + Ok("applied wifi-profile via wlan0".to_string()) + } + + fn disconnect( + &self, + _profile_name: Option<&str>, + _interface: &str, + ) -> Result { + Ok("disconnected wlan0".to_string()) + } + } + + #[test] + fn visible_fields_follow_security_and_ip_mode() { + let backend = MockBackend::default(); + let mut app = App::new(backend).unwrap(); + + assert!(!app.visible_fields().contains(&Field::Key)); + assert!(!app.visible_fields().contains(&Field::Address)); + + app.draft.security = SecurityKind::Wpa2Psk; + app.draft.ip_mode = IpMode::Static; + + assert!(app.visible_fields().contains(&Field::Key)); + assert!(app.visible_fields().contains(&Field::Address)); + assert!(app.visible_fields().contains(&Field::Gateway)); + assert!(app.visible_fields().contains(&Field::Dns)); + } + + #[test] + fn selecting_scan_applies_ssid_and_security_hint() { + let backend = MockBackend { + scans: vec![ScanResult { + raw: "ssid=demo-open security=open".to_string(), + ssid: "demo-open".to_string(), + security_hint: Some(SecurityKind::Open), + }], + ..MockBackend::default() + }; + let mut app = App::new(backend).unwrap(); + app.handle_key(Key::Char('r')); + app.focus = Focus::Scan; + app.handle_key(Key::Char('\n')); + + assert_eq!(app.draft.ssid, "demo-open"); + assert_eq!(app.draft.security, SecurityKind::Open); + } +} diff --git a/local/recipes/system/redbear-netctl-console/source/src/backend.rs b/local/recipes/system/redbear-netctl-console/source/src/backend.rs new file mode 100644 index 00000000..f95dd43f --- /dev/null +++ b/local/recipes/system/redbear-netctl-console/source/src/backend.rs @@ -0,0 +1,825 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::thread; +use std::time::{Duration, Instant}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SecurityKind { + Open, + Wpa2Psk, +} + +impl SecurityKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::Open => "open", + Self::Wpa2Psk => "wpa2-psk", + } + } + + pub fn next(&self) -> Self { + match self { + Self::Open => Self::Wpa2Psk, + Self::Wpa2Psk => Self::Open, + } + } + + pub fn previous(&self) -> Self { + self.next() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum IpMode { + Bounded, + Dhcp, + Static, +} + +impl IpMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::Bounded => "bounded", + Self::Dhcp => "dhcp", + Self::Static => "static", + } + } + + pub fn next(&self) -> Self { + match self { + Self::Bounded => Self::Dhcp, + Self::Dhcp => Self::Static, + Self::Static => Self::Bounded, + } + } + + pub fn previous(&self) -> Self { + match self { + Self::Bounded => Self::Static, + Self::Dhcp => Self::Bounded, + Self::Static => Self::Dhcp, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Profile { + pub name: String, + pub description: String, + pub interface: String, + pub ssid: String, + pub security: SecurityKind, + pub key: String, + pub ip_mode: IpMode, + pub address: String, + pub gateway: String, + pub dns: String, +} + +impl Default for Profile { + fn default() -> Self { + Self { + name: "wifi-profile".to_string(), + description: "Red Bear Wi-Fi profile".to_string(), + interface: "wlan0".to_string(), + ssid: String::new(), + security: SecurityKind::Open, + key: String::new(), + ip_mode: IpMode::Bounded, + address: String::new(), + gateway: String::new(), + dns: String::new(), + } + } +} + +impl Profile { + pub fn validate(&self) -> Result<(), String> { + validate_profile_name(&self.name)?; + validate_scalar("interface", &self.interface)?; + validate_scalar("ssid", &self.ssid)?; + validate_scalar("description", &self.description)?; + + if matches!(self.security, SecurityKind::Wpa2Psk) && self.key.trim().is_empty() { + return Err("WPA2-PSK profiles require a key".to_string()); + } + + if matches!(self.ip_mode, IpMode::Static) && self.address.trim().is_empty() { + return Err("static profiles require an address".to_string()); + } + + Ok(()) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct WifiRuntimeState { + pub interface: String, + pub address: String, + pub status: String, + pub link_state: String, + pub firmware_status: String, + pub transport_status: String, + pub transport_init_status: String, + pub activation_status: String, + pub connect_result: String, + pub disconnect_result: String, + pub last_error: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScanResult { + pub raw: String, + pub ssid: String, + pub security_hint: Option, +} + +impl ScanResult { + pub fn label(&self) -> String { + match self.security_hint { + Some(SecurityKind::Open) => format!("{} [open]", self.ssid), + Some(SecurityKind::Wpa2Psk) => format!("{} [wpa2-psk]", self.ssid), + None => self.ssid.clone(), + } + } +} + +pub trait ConsoleBackend { + fn list_wifi_profiles(&self) -> Result, String>; + fn active_profile_name(&self) -> Result, String>; + fn load_profile(&self, name: &str) -> Result; + fn save_profile(&self, profile: &Profile) -> Result<(), String>; + fn set_active_profile(&self, profile_name: &str) -> Result<(), String>; + fn clear_active_profile(&self) -> Result<(), String>; + fn read_status(&self, interface: &str) -> WifiRuntimeState; + fn scan(&self, interface: &str) -> Result, String>; + fn connect(&self, profile: &Profile) -> Result; + fn disconnect(&self, profile_name: Option<&str>, interface: &str) -> Result; +} + +#[derive(Clone, Debug)] +pub struct RuntimePaths { + pub profile_dir: PathBuf, + pub active_profile_path: PathBuf, + pub wifictl_root: PathBuf, + pub netcfg_root: PathBuf, + pub dhcpd_command: String, + pub dhcp_wait_timeout: Duration, + pub dhcp_poll_interval: Duration, +} + +impl RuntimePaths { + pub fn from_env() -> Self { + let profile_dir = env::var_os("REDBEAR_NETCTL_PROFILE_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/etc/netctl")); + let active_profile_path = env::var_os("REDBEAR_NETCTL_ACTIVE") + .map(PathBuf::from) + .unwrap_or_else(|| profile_dir.join("active")); + let wifictl_root = env::var_os("REDBEAR_WIFICTL_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/scheme/wifictl")); + let netcfg_root = env::var_os("REDBEAR_NETCFG_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/scheme/netcfg")); + let dhcpd_command = env::var("REDBEAR_DHCPD_CMD").unwrap_or_else(|_| "dhcpd".to_string()); + let dhcp_wait_timeout = env::var("REDBEAR_DHCPD_WAIT_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .map(Duration::from_millis) + .unwrap_or_else(|| Duration::from_millis(1000)); + let dhcp_poll_interval = env::var("REDBEAR_DHCPD_POLL_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .map(Duration::from_millis) + .unwrap_or_else(|| Duration::from_millis(50)); + + Self { + profile_dir, + active_profile_path, + wifictl_root, + netcfg_root, + dhcpd_command, + dhcp_wait_timeout, + dhcp_poll_interval, + } + } +} + +#[derive(Clone, Debug)] +pub struct FsBackend { + paths: RuntimePaths, +} + +impl FsBackend { + pub fn from_env() -> Self { + Self { + paths: RuntimePaths::from_env(), + } + } + + pub fn new(paths: RuntimePaths) -> Self { + Self { paths } + } + + fn ensure_profile_dir(&self) -> Result<(), String> { + fs::create_dir_all(&self.paths.profile_dir).map_err(|err| { + format!( + "failed to prepare {}: {err}", + self.paths.profile_dir.display() + ) + }) + } + + fn profile_path(&self, name: &str) -> PathBuf { + self.paths.profile_dir.join(name) + } + + fn write_wifictl(&self, interface: &str, node: &str, value: &str) -> Result<(), String> { + let iface_root = self.paths.wifictl_root.join("ifaces").join(interface); + fs::create_dir_all(&iface_root) + .map_err(|err| format!("failed to prepare {}: {err}", iface_root.display()))?; + let path = iface_root.join(node); + fs::write(&path, format!("{}\n", value.trim())) + .map_err(|err| format!("failed to write {}: {err}", path.display())) + } + + fn read_wifictl_value(&self, interface: &str, node: &str) -> Option { + fs::read_to_string( + self.paths + .wifictl_root + .join("ifaces") + .join(interface) + .join(node), + ) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + } + + fn write_netcfg(&self, node: &str, value: &str) -> Result<(), String> { + let path = self.paths.netcfg_root.join(node); + fs::write(&path, format!("{}\n", value.trim())) + .map_err(|err| format!("failed to write {}: {err}", path.display())) + } + + fn current_addr(&self, interface: &str) -> Option { + fs::read_to_string( + self.paths + .netcfg_root + .join("ifaces") + .join(interface) + .join("addr") + .join("list"), + ) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + } + + fn wait_for_address(&self, interface: &str) -> Result<(), String> { + let deadline = Instant::now() + self.paths.dhcp_wait_timeout; + + loop { + match self.current_addr(interface).as_deref() { + Some(addr) if addr != "Not configured" && !addr.is_empty() => return Ok(()), + _ if Instant::now() >= deadline => { + return Err(format!("timed out waiting for DHCP address on {interface}")); + } + _ => thread::sleep(self.paths.dhcp_poll_interval), + } + } + } + + fn checked_status( + &self, + interface: &str, + node: &str, + failed_value: &str, + context: &str, + ) -> Result<(), String> { + if self.read_wifictl_value(interface, node).as_deref() == Some(failed_value) + || self.read_wifictl_value(interface, "status").as_deref() == Some("failed") + { + let last_error = self + .read_wifictl_value(interface, "last-error") + .unwrap_or_else(|| format!("{context} failed")); + return Err(format!("wifictl {context} failed: {last_error}")); + } + Ok(()) + } + + fn apply_profile(&self, profile: &Profile) -> Result<(), String> { + profile.validate()?; + self.write_wifictl(&profile.interface, "ssid", &profile.ssid)?; + self.write_wifictl(&profile.interface, "security", profile.security.as_str())?; + match profile.security { + SecurityKind::Open => { + self.write_wifictl(&profile.interface, "key", "")?; + } + SecurityKind::Wpa2Psk => { + self.write_wifictl(&profile.interface, "key", &profile.key)?; + } + } + + self.write_wifictl(&profile.interface, "prepare", "1")?; + self.checked_status(&profile.interface, "status", "failed", "prepare")?; + + self.write_wifictl(&profile.interface, "init-transport", "1")?; + self.checked_status( + &profile.interface, + "transport-init-status", + "transport_init=failed", + "init-transport", + )?; + + self.write_wifictl(&profile.interface, "activate-nic", "1")?; + self.checked_status( + &profile.interface, + "activation-status", + "activation=failed", + "activate-nic", + )?; + + self.write_wifictl(&profile.interface, "connect", "1")?; + self.checked_status(&profile.interface, "status", "failed", "connect")?; + + match profile.ip_mode { + IpMode::Bounded => {} + IpMode::Dhcp => { + let address = self.current_addr(&profile.interface); + if address.is_none() || address.as_deref() == Some("Not configured") { + let _child = Command::new(&self.paths.dhcpd_command) + .arg(&profile.interface) + .spawn() + .map_err(|err| format!("failed to spawn dhcpd: {err}"))?; + self.wait_for_address(&profile.interface)?; + } + } + IpMode::Static => { + self.write_netcfg( + &format!("ifaces/{}/addr/set", profile.interface), + &profile.address, + )?; + if !profile.gateway.trim().is_empty() { + self.write_netcfg( + "route/add", + &format!("default via {}", profile.gateway.trim()), + )?; + } + if !profile.dns.trim().is_empty() { + self.write_netcfg("resolv/nameserver", &profile.dns)?; + } + } + } + + Ok(()) + } +} + +impl ConsoleBackend for FsBackend { + fn list_wifi_profiles(&self) -> Result, String> { + self.ensure_profile_dir()?; + let entries = fs::read_dir(&self.paths.profile_dir) + .map_err(|err| format!("failed to read {}: {err}", self.paths.profile_dir.display()))?; + let mut names = Vec::new(); + + for entry in entries { + let entry = entry.map_err(|err| format!("failed to read profile entry: {err}"))?; + let path = entry.path(); + if !path.is_file() { + continue; + } + + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if name == "active" || name.starts_with('.') { + continue; + } + + if self.load_profile(name).is_ok() { + names.push(name.to_string()); + } + } + + names.sort(); + Ok(names) + } + + fn active_profile_name(&self) -> Result, String> { + match fs::read_to_string(&self.paths.active_profile_path) { + Ok(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed.to_string())) + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(format!( + "failed to read {}: {err}", + self.paths.active_profile_path.display() + )), + } + } + + fn load_profile(&self, name: &str) -> Result { + validate_profile_name(name)?; + let path = self.profile_path(name); + let content = fs::read_to_string(&path) + .map_err(|err| format!("failed to read {}: {err}", path.display()))?; + parse_profile(name, &content) + } + + fn save_profile(&self, profile: &Profile) -> Result<(), String> { + profile.validate()?; + self.ensure_profile_dir()?; + let path = self.profile_path(&profile.name); + fs::write(&path, serialize_profile(profile)?) + .map_err(|err| format!("failed to write {}: {err}", path.display())) + } + + fn set_active_profile(&self, profile_name: &str) -> Result<(), String> { + validate_profile_name(profile_name)?; + self.ensure_profile_dir()?; + fs::write(&self.paths.active_profile_path, format!("{profile_name}\n")).map_err(|err| { + format!( + "failed to write {}: {err}", + self.paths.active_profile_path.display() + ) + }) + } + + fn clear_active_profile(&self) -> Result<(), String> { + match fs::remove_file(&self.paths.active_profile_path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(format!( + "failed to remove {}: {err}", + self.paths.active_profile_path.display() + )), + } + } + + fn read_status(&self, interface: &str) -> WifiRuntimeState { + let interface = if interface.trim().is_empty() { + "wlan0".to_string() + } else { + interface.trim().to_string() + }; + + WifiRuntimeState { + interface: interface.clone(), + address: self + .current_addr(&interface) + .unwrap_or_else(|| "unconfigured".to_string()), + status: self + .read_wifictl_value(&interface, "status") + .unwrap_or_else(|| "unknown".to_string()), + link_state: self + .read_wifictl_value(&interface, "link-state") + .unwrap_or_else(|| "unknown".to_string()), + firmware_status: self + .read_wifictl_value(&interface, "firmware-status") + .unwrap_or_else(|| "unknown".to_string()), + transport_status: self + .read_wifictl_value(&interface, "transport-status") + .unwrap_or_else(|| "unknown".to_string()), + transport_init_status: self + .read_wifictl_value(&interface, "transport-init-status") + .unwrap_or_else(|| "unknown".to_string()), + activation_status: self + .read_wifictl_value(&interface, "activation-status") + .unwrap_or_else(|| "unknown".to_string()), + connect_result: self + .read_wifictl_value(&interface, "connect-result") + .unwrap_or_else(|| "unknown".to_string()), + disconnect_result: self + .read_wifictl_value(&interface, "disconnect-result") + .unwrap_or_else(|| "unknown".to_string()), + last_error: self + .read_wifictl_value(&interface, "last-error") + .unwrap_or_else(|| "none".to_string()), + } + } + + fn scan(&self, interface: &str) -> Result, String> { + validate_scalar("interface", interface)?; + self.write_wifictl(interface, "prepare", "1")?; + self.checked_status(interface, "status", "failed", "prepare")?; + + self.write_wifictl(interface, "init-transport", "1")?; + self.checked_status( + interface, + "transport-init-status", + "transport_init=failed", + "init-transport", + )?; + + self.write_wifictl(interface, "activate-nic", "1")?; + self.checked_status( + interface, + "activation-status", + "activation=failed", + "activate-nic", + )?; + + self.write_wifictl(interface, "scan", "1")?; + let raw = fs::read_to_string( + self.paths + .wifictl_root + .join("ifaces") + .join(interface) + .join("scan-results"), + ) + .unwrap_or_default(); + + let results = raw + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(parse_scan_result) + .collect::>(); + + Ok(results) + } + + fn connect(&self, profile: &Profile) -> Result { + self.save_profile(profile)?; + self.apply_profile(profile)?; + self.set_active_profile(&profile.name)?; + Ok(format!( + "applied {} via {}", + profile.name, profile.interface + )) + } + + fn disconnect(&self, profile_name: Option<&str>, interface: &str) -> Result { + validate_scalar("interface", interface)?; + self.write_wifictl(interface, "disconnect", "1")?; + if let Some(name) = profile_name + && self.active_profile_name()?.as_deref() == Some(name) + { + self.clear_active_profile()?; + } + + Ok(format!("disconnected {}", interface.trim())) + } +} + +fn validate_profile_name(name: &str) -> Result<(), String> { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("profile name is required".to_string()); + } + if trimmed == "active" || trimmed == "." || trimmed == ".." || trimmed.contains('/') { + return Err(format!("unsupported profile name {trimmed}")); + } + validate_text_value("profile name", trimmed) +} + +fn validate_scalar(label: &str, value: &str) -> Result<(), String> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(format!("{label} is required")); + } + validate_text_value(label, trimmed) +} + +fn validate_text_value(label: &str, value: &str) -> Result<(), String> { + if value.contains('\n') || value.contains('\r') { + return Err(format!("{label} must be a single line")); + } + Ok(()) +} + +fn serialize_profile(profile: &Profile) -> Result { + let mut lines = vec![ + format!("Description={}", quote_value(&profile.description)?), + format!("Interface={}", quote_value(&profile.interface)?), + "Connection=wifi".to_string(), + format!("SSID={}", quote_value(&profile.ssid)?), + format!("Security={}", profile.security.as_str()), + ]; + + if matches!(profile.security, SecurityKind::Wpa2Psk) { + lines.push(format!("Key={}", quote_value(&profile.key)?)); + } + + lines.push(format!("IP={}", profile.ip_mode.as_str())); + + if matches!(profile.ip_mode, IpMode::Static) { + lines.push(format!("Address=({})", quote_value(&profile.address)?)); + if !profile.gateway.trim().is_empty() { + lines.push(format!("Gateway={}", quote_value(&profile.gateway)?)); + } + if !profile.dns.trim().is_empty() { + lines.push(format!("DNS=({})", quote_value(&profile.dns)?)); + } + } + + Ok(lines.join("\n") + "\n") +} + +fn quote_value(value: &str) -> Result { + validate_text_value("value", value)?; + if !value.contains('\'') { + return Ok(format!("'{}'", value.trim())); + } + if !value.contains('"') { + return Ok(format!("\"{}\"", value.trim())); + } + Err("values containing both quote styles are not supported yet".to_string()) +} + +fn parse_profile(name: &str, content: &str) -> Result { + let mut profile = Profile { + name: name.to_string(), + ..Profile::default() + }; + let mut connection = None; + let mut ip_mode = None; + let mut saw_ssid = false; + + for raw_line in content.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let Some((key, value)) = line.split_once('=') else { + continue; + }; + + match key.trim() { + "Description" => profile.description = parse_scalar(value), + "Interface" => profile.interface = parse_scalar(value), + "Connection" => connection = Some(parse_scalar(value).to_ascii_lowercase()), + "SSID" => { + saw_ssid = true; + profile.ssid = parse_scalar(value); + } + "Security" => { + profile.security = match parse_scalar(value).to_ascii_lowercase().as_str() { + "open" => SecurityKind::Open, + "wpa2-psk" => SecurityKind::Wpa2Psk, + other => return Err(format!("unsupported Security={other}")), + }; + } + "Key" | "Passphrase" => profile.key = parse_scalar(value), + "IP" => { + ip_mode = Some(match parse_scalar(value).to_ascii_lowercase().as_str() { + "bounded" | "none" => IpMode::Bounded, + "dhcp" => IpMode::Dhcp, + "static" => IpMode::Static, + other => return Err(format!("unsupported IP={other}")), + }); + } + "Address" => profile.address = parse_first_array_item(value).unwrap_or_default(), + "Gateway" => profile.gateway = parse_scalar(value), + "DNS" => profile.dns = parse_first_array_item(value).unwrap_or_default(), + _ => {} + } + } + + match connection.as_deref() { + Some("wifi") => {} + Some(other) => { + return Err(format!( + "profile {name} is not a Wi-Fi profile: Connection={other}" + )); + } + None => return Err(format!("profile {name} is missing Connection=")), + } + + profile.ip_mode = ip_mode.ok_or_else(|| format!("profile {name} is missing IP="))?; + + if !saw_ssid { + return Err(format!("profile {name} is missing SSID=")); + } + + profile.validate()?; + Ok(profile) +} + +fn parse_scan_result(line: &str) -> ScanResult { + let mut ssid = None; + let mut security_hint = None; + + for token in line.split_whitespace() { + let Some((key, value)) = token.split_once('=') else { + continue; + }; + match key { + "ssid" => ssid = Some(parse_scalar(value)), + "security" => { + security_hint = match parse_scalar(value).to_ascii_lowercase().as_str() { + "open" => Some(SecurityKind::Open), + "wpa2-psk" => Some(SecurityKind::Wpa2Psk), + _ => None, + }; + } + _ => {} + } + } + + if security_hint.is_none() { + let lowercase = line.to_ascii_lowercase(); + if lowercase.contains("wpa2-psk") { + security_hint = Some(SecurityKind::Wpa2Psk); + } else if lowercase.contains("open") { + security_hint = Some(SecurityKind::Open); + } + } + + ScanResult { + raw: line.to_string(), + ssid: ssid.unwrap_or_else(|| line.trim().to_string()), + security_hint, + } +} + +fn parse_scalar(value: &str) -> String { + let trimmed = value.trim(); + trimmed + .trim_start_matches('(') + .trim_end_matches(')') + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string() +} + +fn parse_first_array_item(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.starts_with('(') && trimmed.ends_with(')') { + let inner = &trimmed[1..trimmed.len().saturating_sub(1)]; + inner + .split_whitespace() + .next() + .map(parse_scalar) + .filter(|item| !item.is_empty()) + } else { + let item = parse_scalar(trimmed); + (!item.is_empty()).then_some(item) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_wifi_profile_with_static_fields() { + let profile = parse_profile( + "wifi-static", + "Description='Wi-Fi'\nInterface=wlan0\nConnection=wifi\nSSID='demo'\nSecurity=wpa2-psk\nKey='secret'\nIP=static\nAddress=('192.168.1.10/24')\nGateway='192.168.1.1'\nDNS=('1.1.1.1')\n", + ) + .unwrap(); + + assert_eq!(profile.name, "wifi-static"); + assert_eq!(profile.interface, "wlan0"); + assert_eq!(profile.ssid, "demo"); + assert_eq!(profile.security, SecurityKind::Wpa2Psk); + assert_eq!(profile.key, "secret"); + assert_eq!(profile.ip_mode, IpMode::Static); + assert_eq!(profile.address, "192.168.1.10/24"); + assert_eq!(profile.gateway, "192.168.1.1"); + assert_eq!(profile.dns, "1.1.1.1"); + } + + #[test] + fn serializes_round_trip_profile() { + let profile = Profile { + name: "wifi-open-bounded".to_string(), + description: "Wi-Fi bounded".to_string(), + interface: "wlan0".to_string(), + ssid: "demo-open".to_string(), + security: SecurityKind::Open, + key: String::new(), + ip_mode: IpMode::Bounded, + address: String::new(), + gateway: String::new(), + dns: String::new(), + }; + + let serialized = serialize_profile(&profile).unwrap(); + let parsed = parse_profile(&profile.name, &serialized).unwrap(); + assert_eq!(parsed, profile); + } + + #[test] + fn parses_scan_result_hints() { + let open = parse_scan_result("ssid=demo-open security=open"); + assert_eq!(open.ssid, "demo-open"); + assert_eq!(open.security_hint, Some(SecurityKind::Open)); + + let raw = parse_scan_result("demo-wpa2-network"); + assert_eq!(raw.ssid, "demo-wpa2-network"); + assert_eq!(raw.security_hint, None); + } +} diff --git a/local/recipes/system/redbear-netctl-console/source/src/lib.rs b/local/recipes/system/redbear-netctl-console/source/src/lib.rs new file mode 100644 index 00000000..903f17e9 --- /dev/null +++ b/local/recipes/system/redbear-netctl-console/source/src/lib.rs @@ -0,0 +1,3 @@ +pub mod app; +pub mod backend; +pub mod ui; diff --git a/local/recipes/system/redbear-netctl-console/source/src/main.rs b/local/recipes/system/redbear-netctl-console/source/src/main.rs new file mode 100644 index 00000000..ad896142 --- /dev/null +++ b/local/recipes/system/redbear-netctl-console/source/src/main.rs @@ -0,0 +1,272 @@ +use std::ffi::CString; +use std::os::raw::{c_char, c_int, c_void}; +use std::process; + +use termion::event::Key; + +use redbear_netctl_console::app::{App, Focus}; +use redbear_netctl_console::backend::FsBackend; + +#[link(name = "ncursesw")] +unsafe extern "C" { + static mut stdscr: *mut c_void; + + fn initscr() -> *mut c_void; + fn raw() -> c_int; + fn noecho() -> c_int; + fn keypad(win: *mut c_void, bf: c_int) -> c_int; + fn curs_set(visibility: c_int) -> c_int; + fn endwin() -> c_int; + fn erase() -> c_int; + fn getch() -> c_int; + fn clrtoeol() -> c_int; + fn mvaddnstr(y: c_int, x: c_int, s: *const c_char, n: c_int) -> c_int; + fn refresh() -> c_int; +} + +fn main() { + if let Err(err) = run() { + // SAFETY: best-effort terminal restore on failure path. + unsafe { + let _ = endwin(); + } + eprintln!("redbear-netctl-console: {err}"); + process::exit(1); + } +} + +fn run() -> Result<(), String> { + if std::env::args() + .skip(1) + .any(|arg| matches!(arg.as_str(), "-h" | "--help" | "help")) + { + print_help(); + return Ok(()); + } + + let backend = FsBackend::from_env(); + let mut app = App::new(backend)?; + + // SAFETY: ncurses global terminal initialization/teardown is process-global and called once. + unsafe { + let _ = initscr(); + let _ = raw(); + let _ = noecho(); + let _ = keypad(stdscr, 1); + let _ = curs_set(0); + } + + loop { + render(&app); + if app.should_quit { + break; + } + + // SAFETY: ncurses is initialized above; getch reads one key event. + let ch = unsafe { getch() }; + if let Some(key) = map_key(ch) { + app.handle_key(key); + } + } + + // SAFETY: matching teardown for the ncurses session started above. + unsafe { + let _ = endwin(); + } + Ok(()) +} + +fn map_key(ch: i32) -> Option { + match ch { + 9 => Some(Key::Char('\t')), + 10 | 13 => Some(Key::Char('\n')), + 27 => Some(Key::Esc), + 127 | 8 => Some(Key::Backspace), + c if (32..=126).contains(&c) => Some(Key::Char(c as u8 as char)), + _ => None, + } +} + +fn render(app: &App) { + // SAFETY: ncurses is initialized for the lifetime of the main loop. + unsafe { + let _ = erase(); + } + + let width = 120usize; + + draw_line( + 0, + &format!( + "Red Bear Netctl Console [ncurses] focus={} active={}{}", + focus_label(app.focus), + app.active_profile.as_deref().unwrap_or(""), + if app.dirty { " dirty=*" } else { "" } + ), + ); + draw_line(1, &truncate(&app.message, width)); + + let mut line = 3; + draw_line(line, "Profiles"); + line += 1; + if app.profiles.is_empty() { + draw_line(line, " "); + line += 1; + } else { + for (idx, profile) in app.profiles.iter().enumerate().take(6) { + let marker = if idx == app.selected_profile && app.focus == Focus::Profiles { + '>' + } else { + ' ' + }; + let active = if app.active_profile.as_deref() == Some(profile.as_str()) { + "*" + } else { + " " + }; + draw_line( + line, + &truncate(&format!("{}{} {}", marker, active, profile), width), + ); + line += 1; + } + } + + line += 1; + draw_line(line, "Scan Results"); + line += 1; + if app.scans.is_empty() { + draw_line(line, " "); + line += 1; + } else { + for (idx, scan) in app.scans.iter().enumerate().take(6) { + let marker = if idx == app.selected_scan && app.focus == Focus::Scan { + '>' + } else { + ' ' + }; + draw_line( + line, + &truncate(&format!("{} {}", marker, scan.label()), width), + ); + line += 1; + } + } + + line += 1; + draw_line(line, "Profile Draft"); + line += 1; + for field in app.visible_fields() { + let marker = if app.focus == Focus::Fields && app.selected_field() == field { + '>' + } else { + ' ' + }; + draw_line( + line, + &truncate( + &format!( + "{} {:<12} {}", + marker, + field.label(), + app.field_value(field) + ), + width, + ), + ); + line += 1; + } + + if let Some(editor) = &app.editor { + line += 1; + draw_line( + line, + &truncate( + &format!("Editing {}: {}", editor.field.label(), editor.buffer), + width, + ), + ); + line += 1; + draw_line(line, "Enter saves. Esc cancels. Backspace deletes."); + line += 1; + } + + line += 1; + draw_line(line, "Runtime Status"); + line += 1; + for status_line in [ + format!("iface={} addr={}", app.status.interface, app.status.address), + format!( + "status={} link={}", + app.status.status, app.status.link_state + ), + format!( + "fw={} transport={} init={} activation={}", + app.status.firmware_status, + app.status.transport_status, + app.status.transport_init_status, + app.status.activation_status + ), + format!("connect={}", app.status.connect_result), + format!("disconnect={}", app.status.disconnect_result), + format!("last_error={}", app.status.last_error), + ] { + draw_line(line, &truncate(&status_line, width)); + line += 1; + } + + // SAFETY: drawing to stdscr is valid while ncurses session is active. + unsafe { + draw_raw_line( + 28, + "Keys: Tab switch panes | Enter select/edit | h/l cycle | j/k move | r scan | s save | a activate | c connect | d disconnect | n new | q quit", + ); + let _ = refresh(); + } +} + +fn draw_line(row: i32, text: &str) { + // SAFETY: drawing to stdscr is valid while ncurses session is active. + unsafe { + draw_raw_line(row, text); + } +} + +fn truncate(value: &str, width: usize) -> String { + if value.chars().count() <= width { + return value.to_string(); + } + value + .chars() + .take(width.saturating_sub(1)) + .collect::() + + "…" +} + +fn focus_label(focus: Focus) -> &'static str { + match focus { + Focus::Profiles => "profiles", + Focus::Scan => "scan", + Focus::Fields => "fields", + } +} + +unsafe fn move_add_line(row: i32, text: &str) { + let sanitized = text.replace('\0', "?"); + if let Ok(cstr) = CString::new(sanitized) { + let _ = unsafe { mvaddnstr(row, 0, cstr.as_ptr(), i32::MAX) }; + } +} + +unsafe fn draw_raw_line(row: i32, text: &str) { + let blank = " ".repeat(140); + unsafe { move_add_line(row, &blank) }; + unsafe { move_add_line(row, text) }; + let _ = unsafe { clrtoeol() }; +} + +fn print_help() { + println!( + "Usage: redbear-netctl-console\n\nA ncurses-based console client for the bounded Red Bear Wi-Fi/netctl flow.\n\nKeys:\n Tab switch panes\n Enter load profile, apply scan result, or edit selected field\n h / l cycle Security or IP mode on the selected field\n j / k move selection down / up\n r scan with /scheme/wifictl\n s save draft to /etc/netctl/\n a save draft and write /etc/netctl/active\n c save + connect through the bounded wifictl/netctl flow\n d disconnect current interface and clear /etc/netctl/active when it matches\n n start a new Wi-Fi profile draft\n q quit\n\nEnvironment overrides match redbear-netctl tests:\n REDBEAR_NETCTL_PROFILE_DIR\n REDBEAR_NETCTL_ACTIVE\n REDBEAR_WIFICTL_ROOT\n REDBEAR_NETCFG_ROOT\n REDBEAR_DHCPD_CMD\n REDBEAR_DHCPD_WAIT_MS\n REDBEAR_DHCPD_POLL_MS" + ); +} diff --git a/local/recipes/system/redbear-netctl-console/source/src/ui.rs b/local/recipes/system/redbear-netctl-console/source/src/ui.rs new file mode 100644 index 00000000..176f8719 --- /dev/null +++ b/local/recipes/system/redbear-netctl-console/source/src/ui.rs @@ -0,0 +1,346 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; + +use crate::app::{App, Field, Focus}; +use crate::backend::{ConsoleBackend, SecurityKind}; + +struct Palette { + title: Color, + accent: Color, + selected: Color, + success: Color, + danger: Color, + muted: Color, +} + +const PALETTE: Palette = Palette { + title: Color::Cyan, + accent: Color::Yellow, + selected: Color::LightYellow, + success: Color::Green, + danger: Color::Red, + muted: Color::DarkGray, +}; + +pub fn render(frame: &mut ratatui::Frame, app: &App) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(18), + Constraint::Length(5), + ]) + .split(frame.area()); + + render_header(frame, app, layout[0]); + render_body(frame, app, layout[1]); + render_footer(frame, app, layout[2]); + + if app.editor.is_some() { + render_editor(frame, app); + } +} + +fn render_header(frame: &mut ratatui::Frame, app: &App, area: Rect) { + let mut status_spans = vec![Span::styled( + " Red Bear Wi-Fi Console ", + Style::default() + .fg(PALETTE.title) + .add_modifier(Modifier::BOLD), + )]; + + if let Some(active) = &app.active_profile { + status_spans.push(Span::raw(" ")); + status_spans.push(Span::styled( + format!("active={active}"), + Style::default().fg(PALETTE.success), + )); + } + + status_spans.push(Span::raw(" ")); + status_spans.push(Span::styled( + format!("interface={}", app.status.interface), + Style::default().fg(PALETTE.accent), + )); + + if app.dirty { + status_spans.push(Span::raw(" ")); + status_spans.push(Span::styled( + "unsaved changes", + Style::default().fg(PALETTE.danger), + )); + } + + let paragraph = Paragraph::new(vec![Line::from(status_spans)]) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(paragraph, area); +} + +fn render_body(frame: &mut ratatui::Frame, app: &App, area: Rect) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(28), + Constraint::Length(34), + Constraint::Min(34), + ]) + .split(area); + + render_profiles(frame, app, columns[0]); + + let middle = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(12), Constraint::Min(6)]) + .split(columns[1]); + render_status(frame, app, middle[0]); + render_scan(frame, app, middle[1]); + render_editor_fields(frame, app, columns[2]); +} + +fn render_profiles(frame: &mut ratatui::Frame, app: &App, area: Rect) { + let items = if app.profiles.is_empty() { + vec![ListItem::new("No Wi-Fi profiles yet")] + } else { + app.profiles + .iter() + .enumerate() + .map(|(index, name)| { + let mut line = name.clone(); + if app.active_profile.as_deref() == Some(name.as_str()) { + line = format!("* {line}"); + } + + let style = if index == app.selected_profile { + selected_style(app.focus == Focus::Profiles) + } else { + Style::default() + }; + + ListItem::new(line).style(style) + }) + .collect::>() + }; + + frame.render_widget( + List::new(items).block( + Block::default() + .title("Profiles") + .borders(Borders::ALL) + .border_style(border_style(app.focus == Focus::Profiles)), + ), + area, + ); +} + +fn render_status(frame: &mut ratatui::Frame, app: &App, area: Rect) { + let lines = vec![ + kv_line("Address", &app.status.address), + kv_line("Status", &app.status.status), + kv_line("Link", &app.status.link_state), + kv_line("Firmware", &app.status.firmware_status), + kv_line("Transport", &app.status.transport_status), + kv_line("Init", &app.status.transport_init_status), + kv_line("Activation", &app.status.activation_status), + kv_line("Connect", &app.status.connect_result), + kv_line("Disconnect", &app.status.disconnect_result), + kv_line("Last error", &app.status.last_error), + ]; + + frame.render_widget( + Paragraph::new(lines) + .block(Block::default().title("Live Status").borders(Borders::ALL)) + .wrap(Wrap { trim: false }), + area, + ); +} + +fn render_scan(frame: &mut ratatui::Frame, app: &App, area: Rect) { + let items = if app.scans.is_empty() { + vec![ListItem::new("Press r to scan the selected interface")] + } else { + app.scans + .iter() + .enumerate() + .map(|(index, scan)| { + let style = if index == app.selected_scan { + selected_style(app.focus == Focus::Scan) + } else { + Style::default() + }; + ListItem::new(scan.label()).style(style) + }) + .collect::>() + }; + + frame.render_widget( + List::new(items).block( + Block::default() + .title("Scan Results") + .borders(Borders::ALL) + .border_style(border_style(app.focus == Focus::Scan)), + ), + area, + ); +} + +fn render_editor_fields(frame: &mut ratatui::Frame, app: &App, area: Rect) { + let selected = app.selected_field(); + let rows = app + .visible_fields() + .into_iter() + .map(|field| render_field_line(app, field, field == selected)) + .collect::>(); + + frame.render_widget( + Paragraph::new(rows) + .block( + Block::default() + .title("Profile Draft") + .borders(Borders::ALL) + .border_style(border_style(app.focus == Focus::Fields)), + ) + .wrap(Wrap { trim: false }), + area, + ); +} + +fn render_footer(frame: &mut ratatui::Frame, app: &App, area: Rect) { + let message_style = if app.message.starts_with("Error:") { + Style::default().fg(PALETTE.danger) + } else { + Style::default() + }; + + let security_note = match app.draft.security { + SecurityKind::Open => "open network selected", + SecurityKind::Wpa2Psk => "wpa2-psk key required", + }; + + let lines = vec![ + Line::from(vec![ + Span::styled("Message: ", Style::default().fg(PALETTE.accent)), + Span::styled(app.message.clone(), message_style), + ]), + Line::from(vec![ + Span::styled("Keys: ", Style::default().fg(PALETTE.accent)), + Span::raw( + "Tab focus Enter load/apply/edit r scan s save a activate c connect d disconnect n new q quit", + ), + ]), + Line::from(vec![ + Span::styled("Hints: ", Style::default().fg(PALETTE.accent)), + Span::styled(security_note, Style::default().fg(PALETTE.muted)), + Span::raw(" • "), + Span::styled( + "connect uses /scheme/wifictl plus /etc/netctl persistence", + Style::default().fg(PALETTE.muted), + ), + ]), + ]; + + frame.render_widget( + Paragraph::new(lines) + .block(Block::default().title("Console Flow").borders(Borders::ALL)) + .wrap(Wrap { trim: false }), + area, + ); +} + +fn render_editor(frame: &mut ratatui::Frame, app: &App) { + let Some(editor) = &app.editor else { + return; + }; + + let area = centered_rect(frame.area(), 72, 22); + let lines = vec![ + Line::from(vec![Span::styled( + format!("Editing {}", editor.field.label()), + Style::default() + .fg(PALETTE.title) + .add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(editor.buffer.clone()), + Line::from(""), + Line::from(vec![Span::styled( + "Enter saves • Esc cancels • Backspace deletes", + Style::default().fg(PALETTE.muted), + )]), + ]; + + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new(lines) + .block(Block::default().title("Field Editor").borders(Borders::ALL)) + .wrap(Wrap { trim: false }), + area, + ); +} + +fn render_field_line( + app: &App, + field: Field, + selected: bool, +) -> Line<'static> { + let label_style = if selected && app.focus == Focus::Fields { + Style::default() + .fg(PALETTE.selected) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(PALETTE.accent) + }; + + let marker = if selected { ">" } else { " " }; + Line::from(vec![ + Span::styled(format!("{marker} {:<12}", field.label()), label_style), + Span::raw(app.field_value(field)), + ]) +} + +fn kv_line(label: &str, value: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{label:<10} "), Style::default().fg(PALETTE.accent)), + Span::raw(value.to_string()), + ]) +} + +fn border_style(active: bool) -> Style { + if active { + Style::default().fg(PALETTE.selected) + } else { + Style::default() + } +} + +fn selected_style(active: bool) -> Style { + if active { + Style::default() + .fg(Color::Black) + .bg(PALETTE.selected) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(PALETTE.selected) + } +} + +fn centered_rect(area: Rect, width_percent: u16, height_percent: u16) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - height_percent) / 2), + Constraint::Percentage(height_percent), + Constraint::Percentage((100 - height_percent) / 2), + ]) + .split(area); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - width_percent) / 2), + Constraint::Percentage(width_percent), + Constraint::Percentage((100 - width_percent) / 2), + ]) + .split(vertical[1])[1] +} diff --git a/local/recipes/system/redbear-netctl-console/source/tests/console.rs b/local/recipes/system/redbear-netctl-console/source/tests/console.rs new file mode 100644 index 00000000..465c1d5f --- /dev/null +++ b/local/recipes/system/redbear-netctl-console/source/tests/console.rs @@ -0,0 +1,254 @@ +use std::fs; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use redbear_netctl_console::backend::{ + ConsoleBackend, FsBackend, IpMode, Profile, RuntimePaths, SecurityKind, +}; + +fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +fn temp_root(prefix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("{prefix}-{nanos}")); + fs::create_dir_all(&root).unwrap(); + root +} + +fn runtime_paths( + profile_dir: &PathBuf, + wifictl_root: &PathBuf, + netcfg_root: &PathBuf, + dhcpd_command: &str, +) -> RuntimePaths { + RuntimePaths { + profile_dir: profile_dir.clone(), + active_profile_path: profile_dir.join("active"), + wifictl_root: wifictl_root.clone(), + netcfg_root: netcfg_root.clone(), + dhcpd_command: dhcpd_command.to_string(), + dhcp_wait_timeout: std::time::Duration::from_millis(500), + dhcp_poll_interval: std::time::Duration::from_millis(10), + } +} + +#[test] +fn saves_and_loads_profile_using_fake_roots() { + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let profile_dir = temp_root("rbos-netctl-console-profiles"); + let wifictl_root = temp_root("rbos-netctl-console-wifictl"); + let netcfg_root = temp_root("rbos-netctl-console-netcfg"); + let backend = FsBackend::new(runtime_paths( + &profile_dir, + &wifictl_root, + &netcfg_root, + "/usr/bin/true", + )); + + let profile = Profile { + name: "wifi-open-bounded".to_string(), + description: "Wi-Fi bounded".to_string(), + interface: "wlan0".to_string(), + ssid: "demo-open".to_string(), + security: SecurityKind::Open, + key: String::new(), + ip_mode: IpMode::Bounded, + address: String::new(), + gateway: String::new(), + dns: String::new(), + }; + + backend.save_profile(&profile).unwrap(); + let loaded = backend.load_profile("wifi-open-bounded").unwrap(); + assert_eq!(loaded, profile); + assert_eq!( + backend.list_wifi_profiles().unwrap(), + vec!["wifi-open-bounded".to_string()] + ); +} + +#[test] +fn scan_writes_bounded_wifictl_flow_nodes() { + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let profile_dir = temp_root("rbos-netctl-console-scan-profiles"); + let wifictl_root = temp_root("rbos-netctl-console-scan-wifictl"); + let netcfg_root = temp_root("rbos-netctl-console-scan-netcfg"); + fs::create_dir_all(wifictl_root.join("ifaces/wlan0")).unwrap(); + fs::write(wifictl_root.join("ifaces/wlan0/status"), "scanning\n").unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/transport-init-status"), + "transport_init=ok\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/activation-status"), + "activation=ok\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/scan-results"), + "ssid=demo-open security=open\nssid=demo-secure security=wpa2-psk\n", + ) + .unwrap(); + + let backend = FsBackend::new(runtime_paths( + &profile_dir, + &wifictl_root, + &netcfg_root, + "/usr/bin/true", + )); + let results = backend.scan("wlan0").unwrap(); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].ssid, "demo-open"); + assert_eq!(results[1].ssid, "demo-secure"); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/prepare")).unwrap(), + "1\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/init-transport")).unwrap(), + "1\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/activate-nic")).unwrap(), + "1\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/scan")).unwrap(), + "1\n" + ); +} + +#[test] +fn connect_uses_wifictl_and_marks_active_profile() { + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let profile_dir = temp_root("rbos-netctl-console-connect-profiles"); + let wifictl_root = temp_root("rbos-netctl-console-connect-wifictl"); + let netcfg_root = temp_root("rbos-netctl-console-connect-netcfg"); + fs::create_dir_all(wifictl_root.join("ifaces/wlan0")).unwrap(); + fs::create_dir_all(netcfg_root.join("ifaces/wlan0/addr")).unwrap(); + fs::write( + netcfg_root.join("ifaces/wlan0/addr/list"), + "Not configured\n", + ) + .unwrap(); + fs::write(wifictl_root.join("ifaces/wlan0/status"), "connected\n").unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/transport-init-status"), + "transport_init=ok\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/activation-status"), + "activation=ok\n", + ) + .unwrap(); + + let dhcp_script = profile_dir.join("fake-dhcpd.sh"); + fs::write( + &dhcp_script, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf '10.0.0.44/24\\n' > '{}/ifaces/wlan0/addr/list'\n", + netcfg_root.display() + ), + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&dhcp_script).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&dhcp_script, perms).unwrap(); + } + + let backend = FsBackend::new(runtime_paths( + &profile_dir, + &wifictl_root, + &netcfg_root, + dhcp_script.to_str().unwrap(), + )); + let profile = Profile { + name: "wifi-dhcp".to_string(), + description: "Wi-Fi DHCP".to_string(), + interface: "wlan0".to_string(), + ssid: "demo".to_string(), + security: SecurityKind::Wpa2Psk, + key: "secret".to_string(), + ip_mode: IpMode::Dhcp, + address: String::new(), + gateway: String::new(), + dns: String::new(), + }; + + let message = backend.connect(&profile).unwrap(); + assert!(message.contains("applied wifi-dhcp")); + assert_eq!( + backend.active_profile_name().unwrap().as_deref(), + Some("wifi-dhcp") + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/ssid")).unwrap(), + "demo\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/security")).unwrap(), + "wpa2-psk\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/key")).unwrap(), + "secret\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/prepare")).unwrap(), + "1\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/connect")).unwrap(), + "1\n" + ); + assert_eq!( + fs::read_to_string(netcfg_root.join("ifaces/wlan0/addr/list")).unwrap(), + "10.0.0.44/24\n" + ); +} + +#[test] +fn disconnect_clears_active_profile_when_it_matches() { + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let profile_dir = temp_root("rbos-netctl-console-disconnect-profiles"); + let wifictl_root = temp_root("rbos-netctl-console-disconnect-wifictl"); + let netcfg_root = temp_root("rbos-netctl-console-disconnect-netcfg"); + fs::create_dir_all(wifictl_root.join("ifaces/wlan0")).unwrap(); + + let backend = FsBackend::new(runtime_paths( + &profile_dir, + &wifictl_root, + &netcfg_root, + "/usr/bin/true", + )); + backend.set_active_profile("wifi-dhcp").unwrap(); + + let message = backend.disconnect(Some("wifi-dhcp"), "wlan0").unwrap(); + assert!(message.contains("disconnected wlan0")); + assert_eq!(backend.active_profile_name().unwrap(), None); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/disconnect")).unwrap(), + "1\n" + ); +} diff --git a/local/recipes/system/redbear-netctl-console/source/tests/ncurses_smoke.rs b/local/recipes/system/redbear-netctl-console/source/tests/ncurses_smoke.rs new file mode 100644 index 00000000..8d482caa --- /dev/null +++ b/local/recipes/system/redbear-netctl-console/source/tests/ncurses_smoke.rs @@ -0,0 +1,101 @@ +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn temp_root(prefix: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("{prefix}-{stamp}")); + fs::create_dir_all(&path).unwrap(); + path +} + +#[test] +fn ncurses_binary_launches_and_quits_on_q() { + if Command::new("script") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_err() + { + eprintln!("skipping ncurses smoke test: 'script' utility not available"); + return; + } + + let profile_dir = temp_root("rbos-netctl-console-tty-profiles"); + let wifictl_root = temp_root("rbos-netctl-console-tty-wifictl"); + let netcfg_root = temp_root("rbos-netctl-console-tty-netcfg"); + fs::create_dir_all(wifictl_root.join("ifaces/wlan0")).unwrap(); + fs::create_dir_all(netcfg_root.join("ifaces/wlan0/addr")).unwrap(); + fs::write( + netcfg_root.join("ifaces/wlan0/addr/list"), + "Not configured\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/status"), + "device-detected\n", + ) + .unwrap(); + fs::write(wifictl_root.join("ifaces/wlan0/link-state"), "link=down\n").unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/firmware-status"), + "firmware=present\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/transport-status"), + "transport=ready\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/transport-init-status"), + "transport_init=not-run\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/activation-status"), + "activation=not-run\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/connect-result"), + "connect_result=not-run\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/disconnect-result"), + "disconnect_result=not-run\n", + ) + .unwrap(); + + let mut child = Command::new("script") + .args([ + "-qec", + env!("CARGO_BIN_EXE_redbear-netctl-console"), + "/dev/null", + ]) + .env("REDBEAR_NETCTL_PROFILE_DIR", &profile_dir) + .env("REDBEAR_WIFICTL_ROOT", &wifictl_root) + .env("REDBEAR_NETCFG_ROOT", &netcfg_root) + .env("TERM", "xterm") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + child.stdin.as_mut().unwrap().write_all(b"q").unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "ncurses console failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/local/recipes/system/redbear-netctl/recipe.toml b/local/recipes/system/redbear-netctl/recipe.toml index 4e47e6bb..483cb20a 100644 --- a/local/recipes/system/redbear-netctl/recipe.toml +++ b/local/recipes/system/redbear-netctl/recipe.toml @@ -3,3 +3,7 @@ path = "source" [build] template = "cargo" + +[package.files] +"/usr/bin/redbear-netctl" = "redbear-netctl" +"/usr/bin/netctl" = "redbear-netctl" diff --git a/local/recipes/system/redbear-netctl/source/Cargo.toml b/local/recipes/system/redbear-netctl/source/Cargo.toml index 48f0574b..c4a0091c 100644 --- a/local/recipes/system/redbear-netctl/source/Cargo.toml +++ b/local/recipes/system/redbear-netctl/source/Cargo.toml @@ -3,10 +3,9 @@ name = "redbear-netctl" version = "0.1.0" edition = "2024" -[[bin]] -name = "netctl" -path = "src/main.rs" - [[bin]] name = "redbear-netctl" path = "src/main.rs" + +[dev-dependencies] +redbear-netctl-console = { path = "../../redbear-netctl-console/source" } diff --git a/local/recipes/system/redbear-netctl/source/src/main.rs b/local/recipes/system/redbear-netctl/source/src/main.rs index d26928e6..437daaa7 100644 --- a/local/recipes/system/redbear-netctl/source/src/main.rs +++ b/local/recipes/system/redbear-netctl/source/src/main.rs @@ -2,6 +2,8 @@ use std::env; use std::fs; use std::path::PathBuf; use std::process::{self, Command}; +use std::thread; +use std::time::{Duration, Instant}; fn program_name() -> String { env::args() @@ -16,13 +18,14 @@ fn program_name() -> String { fn usage() -> String { format!( - "Usage: {} [--boot|list|status [profile]|start |stop |enable |disable [profile]|is-enabled [profile]]", + "Usage: {} [--boot|list|status [profile]|scan |retry |start |stop |enable |disable [profile]|is-enabled [profile]]", program_name() ) } #[derive(Clone, Debug)] enum ProfileIpMode { + Bounded, Dhcp, Static { address: String, @@ -31,11 +34,29 @@ enum ProfileIpMode { }, } +#[derive(Clone, Debug)] +enum WifiSecurity { + Open, + Wpa2Psk { key: String }, +} + +#[derive(Clone, Debug)] +struct WifiSettings { + ssid: String, + security: WifiSecurity, +} + +#[derive(Clone, Debug)] +enum ConnectionMode { + Ethernet, + Wifi(WifiSettings), +} + #[derive(Clone, Debug)] struct Profile { name: String, interface: String, - connection: String, + connection: ConnectionMode, ip_mode: ProfileIpMode, } @@ -56,6 +77,8 @@ fn run() -> Result<(), String> { "--boot" => run_boot_profile(), "list" => list_profiles(), "status" => status(args.next().as_deref()), + "scan" => scan_wifi(&required_profile(args.next())?), + "retry" => retry_wifi(&required_profile(args.next())?), "start" => start_profile(&required_profile(args.next())?, false), "stop" => stop_profile(&required_profile(args.next())?), "enable" => enable_profile(&required_profile(args.next())?), @@ -92,20 +115,69 @@ fn list_profiles() -> Result<(), String> { fn status(profile: Option<&str>) -> Result<(), String> { let active = active_profile_name()?; let selected = profile.map(str::to_string).or(active.clone()); - let address = current_addr().unwrap_or_else(|| "unconfigured".into()); match selected { Some(name) => { + let loaded = load_profile(&name)?; let enabled = active.as_deref() == Some(name.as_str()); - println!( - "profile={} enabled={} address={}", - name, - if enabled { "yes" } else { "no" }, - address - ); + let address = current_addr(&loaded.interface).unwrap_or_else(|| "unconfigured".into()); + let connection = connection_name(&loaded.connection); + match &loaded.connection { + ConnectionMode::Wifi(_) => { + let wifi_status = read_wifictl_value(&loaded.interface, "status") + .unwrap_or_else(|| "unknown".to_string()); + let link_state = read_wifictl_value(&loaded.interface, "link-state") + .unwrap_or_else(|| "unknown".to_string()); + let firmware_status = read_wifictl_value(&loaded.interface, "firmware-status") + .unwrap_or_else(|| "unknown".to_string()); + let transport_status = + read_wifictl_value(&loaded.interface, "transport-status") + .unwrap_or_else(|| "unknown".to_string()); + let transport_init_status = + read_wifictl_value(&loaded.interface, "transport-init-status") + .unwrap_or_else(|| "unknown".to_string()); + let activation_status = + read_wifictl_value(&loaded.interface, "activation-status") + .unwrap_or_else(|| "unknown".to_string()); + let connect_result = read_wifictl_value(&loaded.interface, "connect-result") + .unwrap_or_else(|| "unknown".to_string()); + let disconnect_result = + read_wifictl_value(&loaded.interface, "disconnect-result") + .unwrap_or_else(|| "unknown".to_string()); + let last_error = read_wifictl_value(&loaded.interface, "last-error") + .unwrap_or_else(|| "none".to_string()); + println!( + "profile={} enabled={} connection={} interface={} address={} wifi_status={} link_state={} firmware_status={} transport_status={} transport_init_status={} activation_status={} connect_result={} disconnect_result={} last_error={}", + name, + if enabled { "yes" } else { "no" }, + connection, + loaded.interface, + address, + wifi_status, + link_state, + firmware_status, + transport_status, + transport_init_status, + activation_status, + connect_result, + disconnect_result, + last_error + ); + } + ConnectionMode::Ethernet => { + println!( + "profile={} enabled={} connection={} interface={} address={}", + name, + if enabled { "yes" } else { "no" }, + connection, + loaded.interface, + address + ); + } + } } None => { - println!("profile=none enabled=no address={address}"); + println!("profile=none enabled=no address=unconfigured"); } } @@ -113,7 +185,6 @@ fn status(profile: Option<&str>) -> Result<(), String> { } fn start_profile(name: &str, boot: bool) -> Result<(), String> { - ensure_runtime_surfaces()?; let profile = load_profile(name)?; apply_profile(&profile, boot)?; println!("started {}", profile.name); @@ -121,6 +192,11 @@ fn start_profile(name: &str, boot: bool) -> Result<(), String> { } fn stop_profile(name: &str) -> Result<(), String> { + if let Ok(profile) = load_profile(name) { + if let ConnectionMode::Wifi(_) = profile.connection { + write_wifictl(&profile.interface, "disconnect", "1")?; + } + } if active_profile_name()?.as_deref() == Some(name) { let _ = fs::remove_file(active_profile_path()); } @@ -161,28 +237,23 @@ fn is_enabled(profile: Option<&str>) -> Result<(), String> { } fn apply_profile(profile: &Profile, boot: bool) -> Result<(), String> { - if profile.connection != "ethernet" { - return Err(format!( - "unsupported Connection={} (only ethernet is supported)", - profile.connection - )); - } - if profile.interface != "eth0" { - return Err(format!( - "unsupported Interface={} (only eth0 is supported)", - profile.interface - )); + match &profile.connection { + ConnectionMode::Ethernet => {} + ConnectionMode::Wifi(wifi) => apply_wifi_profile(&profile.interface, wifi)?, } match &profile.ip_mode { + ProfileIpMode::Bounded => {} ProfileIpMode::Dhcp => { if boot - || current_addr().as_deref() == Some("Not configured") - || current_addr().is_none() + || current_addr(&profile.interface).as_deref() == Some("Not configured") + || current_addr(&profile.interface).is_none() { - let _child = Command::new("dhcpd") + let _child = Command::new(dhcpd_command()) + .arg(&profile.interface) .spawn() .map_err(|err| format!("failed to spawn dhcpd: {err}"))?; + wait_for_address(&profile.interface)?; } } ProfileIpMode::Static { @@ -190,7 +261,7 @@ fn apply_profile(profile: &Profile, boot: bool) -> Result<(), String> { gateway, dns, } => { - write_netcfg("ifaces/eth0/addr/set", address)?; + write_netcfg(&format!("ifaces/{}/addr/set", profile.interface), address)?; if let Some(gateway) = gateway { write_netcfg("route/add", &format!("default via {gateway}"))?; } @@ -209,17 +280,181 @@ fn apply_profile(profile: &Profile, boot: bool) -> Result<(), String> { Ok(()) } -fn ensure_runtime_surfaces() -> Result<(), String> { - let addr_path = format!("{}/ifaces/eth0/addr/list", netcfg_root().display()); +fn ensure_runtime_surfaces_for(interface: &str) -> Result<(), String> { + let addr_path = format!("{}/ifaces/{interface}/addr/list", netcfg_root().display()); fs::read_to_string(&addr_path) .map(|_| ()) .map_err(|err| format!("failed to access {addr_path}: {err}")) } -fn current_addr() -> Option { - fs::read_to_string(format!("{}/ifaces/eth0/addr/list", netcfg_root().display())) +fn current_addr(interface: &str) -> Option { + fs::read_to_string(format!( + "{}/ifaces/{interface}/addr/list", + netcfg_root().display() + )) + .ok() + .map(|value| value.trim().to_string()) +} + +fn connection_name(connection: &ConnectionMode) -> &'static str { + match connection { + ConnectionMode::Ethernet => "ethernet", + ConnectionMode::Wifi(_) => "wifi", + } +} + +fn scan_wifi(target: &str) -> Result<(), String> { + let interface = match load_profile(target) { + Ok(profile) => match profile.connection { + ConnectionMode::Wifi(_) => profile.interface, + ConnectionMode::Ethernet => { + return Err(format!("profile {target} is not a Wi-Fi profile")); + } + }, + Err(_) => target.to_string(), + }; + + write_wifictl(&interface, "prepare", "1")?; + if read_wifictl_value(&interface, "status").as_deref() == Some("failed") { + let last_error = read_wifictl_value(&interface, "last-error") + .unwrap_or_else(|| "prepare failed".to_string()); + return Err(format!("wifictl prepare failed: {last_error}")); + } + write_wifictl(&interface, "init-transport", "1")?; + if read_wifictl_value(&interface, "transport-init-status").as_deref() + == Some("transport_init=failed") + || read_wifictl_value(&interface, "status").as_deref() == Some("failed") + { + let last_error = read_wifictl_value(&interface, "last-error") + .unwrap_or_else(|| "transport init failed".to_string()); + return Err(format!("wifictl init-transport failed: {last_error}")); + } + write_wifictl(&interface, "activate-nic", "1")?; + if read_wifictl_value(&interface, "activation-status").as_deref() == Some("activation=failed") + || read_wifictl_value(&interface, "status").as_deref() == Some("failed") + { + let last_error = read_wifictl_value(&interface, "last-error") + .unwrap_or_else(|| "activation failed".to_string()); + return Err(format!("wifictl activate-nic failed: {last_error}")); + } + write_wifictl(&interface, "scan", "1")?; + let results = read_wifictl_value(&interface, "scan-results").unwrap_or_default(); + let status = read_wifictl_value(&interface, "status").unwrap_or_else(|| "unknown".to_string()); + let firmware_status = + read_wifictl_value(&interface, "firmware-status").unwrap_or_else(|| "unknown".to_string()); + let transport_status = + read_wifictl_value(&interface, "transport-status").unwrap_or_else(|| "unknown".to_string()); + let transport_init_status = read_wifictl_value(&interface, "transport-init-status") + .unwrap_or_else(|| "unknown".to_string()); + let activation_status = read_wifictl_value(&interface, "activation-status") + .unwrap_or_else(|| "unknown".to_string()); + + println!( + "interface={} status={} firmware_status={} transport_status={} transport_init_status={} activation_status={} scan_results={}", + interface, + status, + firmware_status, + transport_status, + transport_init_status, + activation_status, + if results.is_empty() { + "none".to_string() + } else { + results + } + ); + Ok(()) +} + +fn retry_wifi(target: &str) -> Result<(), String> { + let interface = match load_profile(target) { + Ok(profile) => match profile.connection { + ConnectionMode::Wifi(_) => profile.interface, + ConnectionMode::Ethernet => { + return Err(format!("profile {target} is not a Wi-Fi profile")); + } + }, + Err(_) => target.to_string(), + }; + + write_wifictl(&interface, "retry", "1")?; + let status = read_wifictl_value(&interface, "status").unwrap_or_else(|| "unknown".to_string()); + let link_state = + read_wifictl_value(&interface, "link-state").unwrap_or_else(|| "unknown".to_string()); + let last_error = + read_wifictl_value(&interface, "last-error").unwrap_or_else(|| "none".to_string()); + println!( + "interface={} status={} link_state={} last_error={}", + interface, status, link_state, last_error + ); + Ok(()) +} + +fn apply_wifi_profile(interface: &str, wifi: &WifiSettings) -> Result<(), String> { + let root = wifictl_root(); + let iface_root = root.join("ifaces").join(interface); + fs::create_dir_all(&iface_root) + .map_err(|err| format!("failed to prepare {}: {err}", iface_root.display()))?; + + write_wifictl(interface, "ssid", &wifi.ssid)?; + match &wifi.security { + WifiSecurity::Open => { + write_wifictl(interface, "security", "open")?; + } + WifiSecurity::Wpa2Psk { key } => { + write_wifictl(interface, "security", "wpa2-psk")?; + write_wifictl(interface, "key", key)?; + } + } + write_wifictl(interface, "prepare", "1")?; + if read_wifictl_value(interface, "status").as_deref() == Some("failed") { + let last_error = read_wifictl_value(interface, "last-error") + .unwrap_or_else(|| "prepare failed".to_string()); + return Err(format!("wifictl prepare failed: {last_error}")); + } + write_wifictl(interface, "init-transport", "1")?; + if read_wifictl_value(interface, "transport-init-status").as_deref() + == Some("transport_init=failed") + || read_wifictl_value(interface, "status").as_deref() == Some("failed") + { + let last_error = read_wifictl_value(interface, "last-error") + .unwrap_or_else(|| "transport init failed".to_string()); + return Err(format!("wifictl init-transport failed: {last_error}")); + } + write_wifictl(interface, "activate-nic", "1")?; + if read_wifictl_value(interface, "activation-status").as_deref() == Some("activation=failed") + || read_wifictl_value(interface, "status").as_deref() == Some("failed") + { + let last_error = read_wifictl_value(interface, "last-error") + .unwrap_or_else(|| "activation failed".to_string()); + return Err(format!("wifictl activate-nic failed: {last_error}")); + } + write_wifictl(interface, "connect", "1")?; + if read_wifictl_value(interface, "status").as_deref() == Some("failed") { + let last_error = read_wifictl_value(interface, "last-error") + .unwrap_or_else(|| "connect failed".to_string()); + return Err(format!("wifictl connect failed: {last_error}")); + } + ensure_runtime_surfaces_for(interface) +} + +fn write_wifictl(interface: &str, node: &str, value: &str) -> Result<(), String> { + let path = wifictl_root().join("ifaces").join(interface).join(node); + fs::write(&path, format!("{}\n", value.trim())) + .map_err(|err| format!("failed to write {}: {err}", path.display())) +} + +fn wifictl_root() -> PathBuf { + env::var_os("REDBEAR_WIFICTL_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/scheme/wifictl")) +} + +fn read_wifictl_value(interface: &str, node: &str) -> Option { + fs::read_to_string(wifictl_root().join("ifaces").join(interface).join(node)) .ok() .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) } fn write_netcfg(node: &str, value: &str) -> Result<(), String> { @@ -298,6 +533,44 @@ fn netcfg_root() -> PathBuf { .unwrap_or_else(|| PathBuf::from("/scheme/netcfg")) } +fn dhcpd_command() -> String { + env::var("REDBEAR_DHCPD_CMD").unwrap_or_else(|_| "dhcpd".to_string()) +} + +fn dhcp_wait_timeout() -> Duration { + env::var("REDBEAR_DHCPD_WAIT_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .map(Duration::from_millis) + .unwrap_or_else(|| Duration::from_millis(1000)) +} + +fn dhcp_poll_interval() -> Duration { + env::var("REDBEAR_DHCPD_POLL_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .map(Duration::from_millis) + .unwrap_or_else(|| Duration::from_millis(50)) +} + +fn wait_for_address(interface: &str) -> Result<(), String> { + let deadline = Instant::now() + dhcp_wait_timeout(); + let poll = dhcp_poll_interval(); + + loop { + match current_addr(interface).as_deref() { + Some(addr) if addr != "Not configured" && !addr.is_empty() => return Ok(()), + _ if Instant::now() >= deadline => { + return Err(format!( + "timed out waiting for DHCP address on {}", + interface + )); + } + _ => thread::sleep(poll), + } + } +} + fn parse_profile(name: &str, content: &str) -> Result { let mut interface = None; let mut connection = None; @@ -305,6 +578,9 @@ fn parse_profile(name: &str, content: &str) -> Result { let mut address = None; let mut gateway = None; let mut dns = None; + let mut ssid = None; + let mut security = None; + let mut wifi_key = None; for raw_line in content.lines() { let line = raw_line.trim(); @@ -326,17 +602,43 @@ fn parse_profile(name: &str, content: &str) -> Result { "Address" => address = parse_first_array_item(value), "Gateway" => gateway = Some(parse_scalar(value)), "DNS" => dns = parse_first_array_item(value), + "SSID" => ssid = Some(parse_scalar(value)), + "Security" => security = Some(parse_scalar(value)), + "Key" | "Passphrase" => wifi_key = Some(parse_scalar(value)), _ => {} } } let interface = interface.ok_or_else(|| format!("profile {name} is missing Interface="))?; - let connection = connection.ok_or_else(|| format!("profile {name} is missing Connection="))?; + let connection = match connection + .ok_or_else(|| format!("profile {name} is missing Connection="))? + .to_ascii_lowercase() + .as_str() + { + "ethernet" => ConnectionMode::Ethernet, + "wifi" => { + let ssid = ssid.ok_or_else(|| format!("profile {name} is missing SSID="))?; + let security = match security + .ok_or_else(|| format!("profile {name} is missing Security="))? + .to_ascii_lowercase() + .as_str() + { + "open" => WifiSecurity::Open, + "wpa2-psk" => WifiSecurity::Wpa2Psk { + key: wifi_key.ok_or_else(|| format!("profile {name} is missing Key="))?, + }, + other => return Err(format!("unsupported Security={other}")), + }; + ConnectionMode::Wifi(WifiSettings { ssid, security }) + } + other => return Err(format!("unsupported Connection={other}")), + }; let ip_mode = match ip .ok_or_else(|| format!("profile {name} is missing IP="))? .to_ascii_lowercase() .as_str() { + "bounded" | "none" => ProfileIpMode::Bounded, "dhcp" => ProfileIpMode::Dhcp, "static" => ProfileIpMode::Static { address: address.ok_or_else(|| format!("profile {name} is missing Address="))?, @@ -349,11 +651,379 @@ fn parse_profile(name: &str, content: &str) -> Result { Ok(Profile { name: name.to_string(), interface, - connection: connection.to_ascii_lowercase(), + connection, ip_mode, }) } +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn temp_root(prefix: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = env::temp_dir().join(format!("{prefix}-{stamp}")); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn parses_wifi_profile() { + let profile = parse_profile( + "wifi-dhcp", + "Description='Wi-Fi'\nInterface=wlan0\nConnection=wifi\nSSID='test-ssid'\nSecurity=wpa2-psk\nKey='secret'\nIP=dhcp\n", + ) + .unwrap(); + + assert_eq!(profile.interface, "wlan0"); + match profile.connection { + ConnectionMode::Wifi(wifi) => { + assert_eq!(wifi.ssid, "test-ssid"); + match wifi.security { + WifiSecurity::Wpa2Psk { key } => assert_eq!(key, "secret"), + _ => panic!("expected WPA2 profile"), + } + } + _ => panic!("expected wifi connection"), + } + } + + #[test] + fn parses_bounded_wifi_profile() { + let profile = parse_profile( + "wifi-open-bounded", + "Description='Wi-Fi bounded'\nInterface=wlan0\nConnection=wifi\nSSID='test-ssid'\nSecurity=open\nIP=bounded\n", + ) + .unwrap(); + + assert_eq!(profile.interface, "wlan0"); + match profile.connection { + ConnectionMode::Wifi(wifi) => { + assert_eq!(wifi.ssid, "test-ssid"); + assert!(matches!(wifi.security, WifiSecurity::Open)); + } + _ => panic!("expected wifi connection"), + } + assert!(matches!(profile.ip_mode, ProfileIpMode::Bounded)); + } + + #[test] + fn applies_wifi_profile_to_fake_roots() { + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let netcfg = temp_root("rbos-netcfg"); + let wifictl = temp_root("rbos-wifictl"); + let dhcp_script = temp_root("rbos-dhcp-script").join("fake-dhcpd.sh"); + fs::create_dir_all(netcfg.join("ifaces/wlan0/addr")).unwrap(); + fs::create_dir_all(wifictl.join("ifaces/wlan0")).unwrap(); + fs::write(netcfg.join("ifaces/wlan0/addr/list"), "Not configured\n").unwrap(); + fs::write( + &dhcp_script, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf '10.0.0.44/24\\n' > '{}/ifaces/wlan0/addr/list'\n", + netcfg.display() + ), + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&dhcp_script).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&dhcp_script, perms).unwrap(); + } + + unsafe { + env::set_var("REDBEAR_NETCFG_ROOT", &netcfg); + env::set_var("REDBEAR_WIFICTL_ROOT", &wifictl); + env::set_var("REDBEAR_DHCPD_CMD", &dhcp_script); + env::set_var("REDBEAR_DHCPD_WAIT_MS", "500"); + env::set_var("REDBEAR_DHCPD_POLL_MS", "10"); + } + + let profile = parse_profile( + "wifi-dhcp", + "Description='Wi-Fi'\nInterface=wlan0\nConnection=wifi\nSSID='test-ssid'\nSecurity=wpa2-psk\nKey='secret'\nIP=dhcp\n", + ) + .unwrap(); + + apply_profile(&profile, false).unwrap(); + + assert_eq!( + fs::read_to_string(netcfg.join("ifaces/wlan0/addr/list")).unwrap(), + "10.0.0.44/24\n" + ); + + assert_eq!( + fs::read_to_string(wifictl.join("ifaces/wlan0/ssid")).unwrap(), + "test-ssid\n" + ); + assert_eq!( + fs::read_to_string(wifictl.join("ifaces/wlan0/security")).unwrap(), + "wpa2-psk\n" + ); + assert_eq!( + fs::read_to_string(wifictl.join("ifaces/wlan0/key")).unwrap(), + "secret\n" + ); + assert_eq!( + fs::read_to_string(wifictl.join("ifaces/wlan0/prepare")).unwrap(), + "1\n" + ); + assert_eq!( + fs::read_to_string(wifictl.join("ifaces/wlan0/init-transport")).unwrap(), + "1\n" + ); + assert_eq!( + fs::read_to_string(wifictl.join("ifaces/wlan0/connect")).unwrap(), + "1\n" + ); + } + + #[test] + fn applies_bounded_wifi_profile_without_dhcp_handoff() { + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let netcfg = temp_root("rbos-netcfg-bounded"); + let wifictl = temp_root("rbos-wifictl-bounded"); + let dhcp_log = temp_root("rbos-dhcp-bounded").join("dhcp.log"); + let dhcp_script = temp_root("rbos-dhcp-script-bounded").join("fake-dhcpd.sh"); + fs::create_dir_all(netcfg.join("ifaces/wlan0/addr")).unwrap(); + fs::create_dir_all(wifictl.join("ifaces/wlan0")).unwrap(); + fs::write(netcfg.join("ifaces/wlan0/addr/list"), "Not configured\n").unwrap(); + fs::write( + &dhcp_script, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf 'called\n' > '{}'\n", + dhcp_log.display() + ), + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&dhcp_script).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&dhcp_script, perms).unwrap(); + } + + unsafe { + env::set_var("REDBEAR_NETCFG_ROOT", &netcfg); + env::set_var("REDBEAR_WIFICTL_ROOT", &wifictl); + env::set_var("REDBEAR_DHCPD_CMD", &dhcp_script); + } + + let profile = parse_profile( + "wifi-open-bounded", + "Description='Wi-Fi bounded'\nInterface=wlan0\nConnection=wifi\nSSID='test-ssid'\nSecurity=open\nIP=bounded\n", + ) + .unwrap(); + + apply_profile(&profile, false).unwrap(); + + assert!(!dhcp_log.exists()); + assert_eq!( + fs::read_to_string(netcfg.join("ifaces/wlan0/addr/list")).unwrap(), + "Not configured\n" + ); + assert_eq!( + fs::read_to_string(wifictl.join("ifaces/wlan0/connect")).unwrap(), + "1\n" + ); + } + + #[test] + fn reads_wifi_state_values() { + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let wifictl = temp_root("rbos-wifictl-state"); + fs::create_dir_all(wifictl.join("ifaces/wlan0")).unwrap(); + fs::write(wifictl.join("ifaces/wlan0/status"), "firmware-ready\n").unwrap(); + fs::write( + wifictl.join("ifaces/wlan0/firmware-status"), + "firmware=present family=intel-bz-arrow-lake\n", + ) + .unwrap(); + fs::write( + wifictl.join("ifaces/wlan0/transport-status"), + "transport=pci memory_enabled=yes bus_master=yes bar0_present=yes irq_pin_present=yes\n", + ) + .unwrap(); + fs::write( + wifictl.join("ifaces/wlan0/transport-init-status"), + "transport_init=stub\n", + ) + .unwrap(); + fs::write( + wifictl.join("ifaces/wlan0/activation-status"), + "activation=stub\n", + ) + .unwrap(); + fs::write( + wifictl.join("ifaces/wlan0/connect-result"), + "connect_result=bounded-associated ssid=demo security=wpa2-psk\n", + ) + .unwrap(); + + unsafe { + env::set_var("REDBEAR_WIFICTL_ROOT", &wifictl); + } + + assert_eq!( + read_wifictl_value("wlan0", "status").as_deref(), + Some("firmware-ready") + ); + assert!(read_wifictl_value("wlan0", "firmware-status") + .unwrap() + .contains("intel-bz-arrow-lake")); + assert!(read_wifictl_value("wlan0", "transport-status") + .unwrap() + .contains("memory_enabled=yes")); + assert_eq!( + read_wifictl_value("wlan0", "transport-init-status").as_deref(), + Some("transport_init=stub") + ); + assert_eq!( + read_wifictl_value("wlan0", "activation-status").as_deref(), + Some("activation=stub") + ); + assert_eq!( + read_wifictl_value("wlan0", "connect-result").as_deref(), + Some("connect_result=bounded-associated ssid=demo security=wpa2-psk") + ); + } + + #[test] + fn scan_uses_wifi_profile_or_interface() { + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let profile_dir = temp_root("rbos-netctl-scan-profile"); + let wifictl = temp_root("rbos-netctl-scan-wifictl"); + fs::create_dir_all(&profile_dir).unwrap(); + fs::create_dir_all(wifictl.join("ifaces/wlan0")).unwrap(); + fs::write( + profile_dir.join("wifi-dhcp"), + "Description='Wi-Fi'\nInterface=wlan0\nConnection=wifi\nSSID='test-ssid'\nSecurity=open\nIP=dhcp\n", + ) + .unwrap(); + fs::write(wifictl.join("ifaces/wlan0/status"), "scanning\n").unwrap(); + fs::write( + wifictl.join("ifaces/wlan0/firmware-status"), + "firmware=present family=intel-bz-arrow-lake prepared=yes\n", + ) + .unwrap(); + fs::write( + wifictl.join("ifaces/wlan0/transport-status"), + "transport=pci memory_enabled=yes bus_master=yes\n", + ) + .unwrap(); + fs::write( + wifictl.join("ifaces/wlan0/transport-init-status"), + "transport_init=stub\n", + ) + .unwrap(); + fs::write( + wifictl.join("ifaces/wlan0/activation-status"), + "activation=stub\n", + ) + .unwrap(); + fs::write( + wifictl.join("ifaces/wlan0/scan-results"), + "demo-ssid\ndemo-open\n", + ) + .unwrap(); + + unsafe { + env::set_var("REDBEAR_NETCTL_PROFILE_DIR", &profile_dir); + env::set_var("REDBEAR_WIFICTL_ROOT", &wifictl); + } + + scan_wifi("wifi-dhcp").unwrap(); + assert_eq!( + fs::read_to_string(wifictl.join("ifaces/wlan0/prepare")).unwrap(), + "1\n" + ); + assert_eq!( + fs::read_to_string(wifictl.join("ifaces/wlan0/init-transport")).unwrap(), + "1\n" + ); + assert_eq!( + fs::read_to_string(wifictl.join("ifaces/wlan0/activate-nic")).unwrap(), + "1\n" + ); + assert_eq!( + fs::read_to_string(wifictl.join("ifaces/wlan0/scan")).unwrap(), + "1\n" + ); + } + + #[test] + fn retry_uses_wifi_profile_or_interface() { + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let profile_dir = temp_root("rbos-netctl-retry-profile"); + let wifictl = temp_root("rbos-netctl-retry-wifictl"); + fs::create_dir_all(&profile_dir).unwrap(); + fs::create_dir_all(wifictl.join("ifaces/wlan0")).unwrap(); + fs::write( + profile_dir.join("wifi-dhcp"), + "Description='Wi-Fi'\nInterface=wlan0\nConnection=wifi\nSSID='test-ssid'\nSecurity=open\nIP=dhcp\n", + ) + .unwrap(); + fs::write(wifictl.join("ifaces/wlan0/status"), "device-detected\n").unwrap(); + fs::write(wifictl.join("ifaces/wlan0/link-state"), "link=retrying\n").unwrap(); + + unsafe { + env::set_var("REDBEAR_NETCTL_PROFILE_DIR", &profile_dir); + env::set_var("REDBEAR_WIFICTL_ROOT", &wifictl); + } + + retry_wifi("wifi-dhcp").unwrap(); + assert_eq!( + fs::read_to_string(wifictl.join("ifaces/wlan0/retry")).unwrap(), + "1\n" + ); + } + + #[test] + fn reports_wifi_last_error() { + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let wifictl = temp_root("rbos-wifictl-error"); + fs::create_dir_all(wifictl.join("ifaces/wlan0")).unwrap(); + fs::write( + wifictl.join("ifaces/wlan0/last-error"), + "missing firmware\n", + ) + .unwrap(); + + unsafe { + env::set_var("REDBEAR_WIFICTL_ROOT", &wifictl); + } + + assert_eq!( + read_wifictl_value("wlan0", "last-error").as_deref(), + Some("missing firmware") + ); + } +} + fn parse_scalar(value: &str) -> String { let trimmed = value.trim(); trimmed diff --git a/local/recipes/system/redbear-netctl/source/tests/cli_wifi.rs b/local/recipes/system/redbear-netctl/source/tests/cli_wifi.rs new file mode 100644 index 00000000..dd8f7eb6 --- /dev/null +++ b/local/recipes/system/redbear-netctl/source/tests/cli_wifi.rs @@ -0,0 +1,381 @@ +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use redbear_netctl_console::backend::{ + ConsoleBackend, FsBackend, IpMode, Profile, RuntimePaths, SecurityKind, +}; + +fn temp_root(prefix: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("{prefix}-{stamp}")); + fs::create_dir_all(&path).unwrap(); + path +} + +fn run_netctl( + args: &[&str], + profile_dir: &PathBuf, + wifictl_root: &PathBuf, + netcfg_root: &PathBuf, +) -> String { + let output = Command::new(env!("CARGO_BIN_EXE_redbear-netctl")) + .args(args) + .env("REDBEAR_NETCTL_PROFILE_DIR", profile_dir) + .env("REDBEAR_WIFICTL_ROOT", wifictl_root) + .env("REDBEAR_NETCFG_ROOT", netcfg_root) + .env("REDBEAR_DHCPD_CMD", "/usr/bin/true") + .output() + .unwrap(); + + assert!( + output.status.success(), + "command {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + + String::from_utf8(output.stdout).unwrap() +} + +fn console_runtime_paths( + profile_dir: &PathBuf, + wifictl_root: &PathBuf, + netcfg_root: &PathBuf, + dhcpd_command: &str, +) -> RuntimePaths { + RuntimePaths { + profile_dir: profile_dir.clone(), + active_profile_path: profile_dir.join("active"), + wifictl_root: wifictl_root.clone(), + netcfg_root: netcfg_root.clone(), + dhcpd_command: dhcpd_command.to_string(), + dhcp_wait_timeout: std::time::Duration::from_millis(500), + dhcp_poll_interval: std::time::Duration::from_millis(10), + } +} + +#[test] +fn cli_start_wifi_profile_writes_connect_path() { + let profile_dir = temp_root("rbos-netctl-cli-profiles"); + let wifictl_root = temp_root("rbos-netctl-cli-wifictl"); + let netcfg_root = temp_root("rbos-netctl-cli-netcfg"); + fs::create_dir_all(wifictl_root.join("ifaces/wlan0")).unwrap(); + fs::create_dir_all(netcfg_root.join("ifaces/wlan0/addr")).unwrap(); + fs::write( + netcfg_root.join("ifaces/wlan0/addr/list"), + "Not configured\n", + ) + .unwrap(); + + fs::write( + profile_dir.join("wifi-dhcp"), + "Description='Wi-Fi'\nInterface=wlan0\nConnection=wifi\nSSID='demo'\nSecurity=wpa2-psk\nKey='secret'\nIP=dhcp\n", + ) + .unwrap(); + + fs::write(wifictl_root.join("ifaces/wlan0/status"), "connected\n").unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/link-state"), + "link=connected\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/firmware-status"), + "firmware=present\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/transport-status"), + "transport=active\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/transport-init-status"), + "transport_init=ok\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/activation-status"), + "activation=ok\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/connect-result"), + "connect_result=bounded-associated ssid=demo security=wpa2-psk\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/disconnect-result"), + "disconnect_result=bounded-disconnected\n", + ) + .unwrap(); + + let dhcp_log = profile_dir.join("dhcp.log"); + let dhcp_script = profile_dir.join("fake-dhcpd.sh"); + fs::write( + &dhcp_script, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' \"$1\" > '{}'\nprintf '10.0.0.44/24\\n' > '{}/ifaces/wlan0/addr/list'\n", + dhcp_log.display(), + netcfg_root.display() + ), + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&dhcp_script).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&dhcp_script, perms).unwrap(); + } + + let output = Command::new(env!("CARGO_BIN_EXE_redbear-netctl")) + .args(["start", "wifi-dhcp"]) + .env("REDBEAR_NETCTL_PROFILE_DIR", &profile_dir) + .env("REDBEAR_WIFICTL_ROOT", &wifictl_root) + .env("REDBEAR_NETCFG_ROOT", &netcfg_root) + .env("REDBEAR_DHCPD_CMD", &dhcp_script) + .env("REDBEAR_DHCPD_WAIT_MS", "500") + .env("REDBEAR_DHCPD_POLL_MS", "10") + .output() + .unwrap(); + assert!( + output.status.success(), + "command {:?} failed: {}", + ["start", "wifi-dhcp"], + String::from_utf8_lossy(&output.stderr) + ); + let started = String::from_utf8(output.stdout).unwrap(); + assert!(started.contains("started wifi-dhcp")); + assert_eq!(fs::read_to_string(&dhcp_log).unwrap(), "wlan0\n"); + assert_eq!( + fs::read_to_string(netcfg_root.join("ifaces/wlan0/addr/list")).unwrap(), + "10.0.0.44/24\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/ssid")).unwrap(), + "demo\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/security")).unwrap(), + "wpa2-psk\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/key")).unwrap(), + "secret\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/prepare")).unwrap(), + "1\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/init-transport")).unwrap(), + "1\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/activate-nic")).unwrap(), + "1\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/connect")).unwrap(), + "1\n" + ); + + let status = run_netctl( + &["status", "wifi-dhcp"], + &profile_dir, + &wifictl_root, + &netcfg_root, + ); + assert!(status.contains("address=10.0.0.44/24")); + assert!(status.contains("wifi_status=connected")); + assert!(status.contains("connect_result=bounded-associated ssid=demo security=wpa2-psk")); + assert!(status.contains("disconnect_result=bounded-disconnected")); + + let stopped = run_netctl( + &["stop", "wifi-dhcp"], + &profile_dir, + &wifictl_root, + &netcfg_root, + ); + assert!(stopped.contains("stopped wifi-dhcp")); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/disconnect")).unwrap(), + "1\n" + ); +} + +#[test] +fn cli_status_reports_pending_wifi_link_honestly() { + let profile_dir = temp_root("rbos-netctl-cli-pending-profiles"); + let wifictl_root = temp_root("rbos-netctl-cli-pending-wifictl"); + let netcfg_root = temp_root("rbos-netctl-cli-pending-netcfg"); + fs::create_dir_all(wifictl_root.join("ifaces/wlan0")).unwrap(); + fs::create_dir_all(netcfg_root.join("ifaces/wlan0/addr")).unwrap(); + fs::write( + profile_dir.join("wifi-open-bounded"), + "Description='Wi-Fi bounded'\nInterface=wlan0\nConnection=wifi\nSSID='demo'\nSecurity=wpa2-psk\nKey='secret'\nIP=bounded\n", + ) + .unwrap(); + fs::write(profile_dir.join("active"), "wifi-open-bounded\n").unwrap(); + + fs::write(wifictl_root.join("ifaces/wlan0/status"), "associating\n").unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/link-state"), + "link=associating\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/firmware-status"), + "firmware=present\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/transport-status"), + "transport=active\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/transport-init-status"), + "transport_init=ok\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/activation-status"), + "activation=ok\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/connect-result"), + "connect_result=host-bounded-pending ssid=demo security=wpa2-psk\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/disconnect-result"), + "disconnect_result=bounded-disconnected\n", + ) + .unwrap(); + fs::write( + netcfg_root.join("ifaces/wlan0/addr/list"), + "Not configured\n", + ) + .unwrap(); + + let status = run_netctl( + &["status", "wifi-open-bounded"], + &profile_dir, + &wifictl_root, + &netcfg_root, + ); + assert!(status.contains("wifi_status=associating")); + assert!(status.contains("link_state=link=associating")); + assert!(status.contains("connect_result=host-bounded-pending ssid=demo security=wpa2-psk")); +} + +#[test] +fn cli_start_consumes_console_written_wifi_profile() { + let profile_dir = temp_root("rbos-netctl-cli-console-profile"); + let wifictl_root = temp_root("rbos-netctl-cli-console-wifictl"); + let netcfg_root = temp_root("rbos-netctl-cli-console-netcfg"); + fs::create_dir_all(wifictl_root.join("ifaces/wlan0")).unwrap(); + fs::create_dir_all(netcfg_root.join("ifaces/wlan0/addr")).unwrap(); + fs::write( + netcfg_root.join("ifaces/wlan0/addr/list"), + "Not configured\n", + ) + .unwrap(); + fs::write(wifictl_root.join("ifaces/wlan0/status"), "associating\n").unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/link-state"), + "link=associating\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/transport-init-status"), + "transport_init=ok\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/activation-status"), + "activation=ok\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/connect-result"), + "connect_result=host-bounded-pending ssid=console-demo security=wpa2-psk\n", + ) + .unwrap(); + fs::write( + wifictl_root.join("ifaces/wlan0/disconnect-result"), + "disconnect_result=not-run\n", + ) + .unwrap(); + + let console_backend = FsBackend::new(console_runtime_paths( + &profile_dir, + &wifictl_root, + &netcfg_root, + "/usr/bin/true", + )); + let profile = Profile { + name: "wifi-console-bounded".to_string(), + description: "Console written Wi-Fi profile".to_string(), + interface: "wlan0".to_string(), + ssid: "console-demo".to_string(), + security: SecurityKind::Wpa2Psk, + key: "secret".to_string(), + ip_mode: IpMode::Bounded, + address: String::new(), + gateway: String::new(), + dns: String::new(), + }; + console_backend.save_profile(&profile).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_redbear-netctl")) + .args(["start", "wifi-console-bounded"]) + .env("REDBEAR_NETCTL_PROFILE_DIR", &profile_dir) + .env("REDBEAR_WIFICTL_ROOT", &wifictl_root) + .env("REDBEAR_NETCFG_ROOT", &netcfg_root) + .env("REDBEAR_DHCPD_CMD", "/usr/bin/true") + .output() + .unwrap(); + assert!( + output.status.success(), + "command {:?} failed: {}", + ["start", "wifi-console-bounded"], + String::from_utf8_lossy(&output.stderr) + ); + + let started = String::from_utf8(output.stdout).unwrap(); + assert!(started.contains("started wifi-console-bounded")); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/ssid")).unwrap(), + "console-demo\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/security")).unwrap(), + "wpa2-psk\n" + ); + assert_eq!( + fs::read_to_string(wifictl_root.join("ifaces/wlan0/key")).unwrap(), + "secret\n" + ); + + let status = run_netctl( + &["status", "wifi-console-bounded"], + &profile_dir, + &wifictl_root, + &netcfg_root, + ); + assert!(status.contains("profile=wifi-console-bounded")); + assert!(status.contains("wifi_status=associating")); + assert!(status.contains("link_state=link=associating")); + assert!( + status.contains("connect_result=host-bounded-pending ssid=console-demo security=wpa2-psk") + ); +} diff --git a/recipes/system/redbear-mtr b/recipes/system/redbear-mtr new file mode 120000 index 00000000..f31e29cb --- /dev/null +++ b/recipes/system/redbear-mtr @@ -0,0 +1 @@ +../../local/recipes/system/redbear-mtr \ No newline at end of file diff --git a/recipes/system/redbear-netctl-console b/recipes/system/redbear-netctl-console new file mode 120000 index 00000000..3a53c6ac --- /dev/null +++ b/recipes/system/redbear-netctl-console @@ -0,0 +1 @@ +../../local/recipes/system/redbear-netctl-console \ No newline at end of file diff --git a/recipes/system/redbear-netstat b/recipes/system/redbear-netstat new file mode 120000 index 00000000..2ddf4265 --- /dev/null +++ b/recipes/system/redbear-netstat @@ -0,0 +1 @@ +../../local/recipes/system/redbear-netstat \ No newline at end of file diff --git a/recipes/system/redbear-nmap b/recipes/system/redbear-nmap new file mode 120000 index 00000000..24e04f01 --- /dev/null +++ b/recipes/system/redbear-nmap @@ -0,0 +1 @@ +../../local/recipes/system/redbear-nmap \ No newline at end of file diff --git a/recipes/system/redbear-traceroute b/recipes/system/redbear-traceroute new file mode 120000 index 00000000..5de6abc0 --- /dev/null +++ b/recipes/system/redbear-traceroute @@ -0,0 +1 @@ +../../local/recipes/system/redbear-traceroute \ No newline at end of file