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); } }