Files
RedBear-OS/local/recipes/system/redbear-netctl-console/source/src/backend.rs
T
2026-04-16 12:44:35 +01:00

826 lines
27 KiB
Rust

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<SecurityKind>,
}
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<Vec<String>, String>;
fn active_profile_name(&self) -> Result<Option<String>, String>;
fn load_profile(&self, name: &str) -> Result<Profile, String>;
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<Vec<ScanResult>, String>;
fn connect(&self, profile: &Profile) -> Result<String, String>;
fn disconnect(&self, profile_name: Option<&str>, interface: &str) -> Result<String, String>;
}
#[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::<u64>().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::<u64>().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<String> {
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<String> {
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<Vec<String>, 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<Option<String>, 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<Profile, String> {
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<Vec<ScanResult>, 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::<Vec<_>>();
Ok(results)
}
fn connect(&self, profile: &Profile) -> Result<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<Profile, String> {
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<String> {
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);
}
}