Advance netctl and networking tools

Red Bear OS Team
This commit is contained in:
2026-04-16 12:44:35 +01:00
parent 91323523a3
commit 4b76deaa60
18 changed files with 3511 additions and 36 deletions
@@ -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"
@@ -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"
@@ -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<B: ConsoleBackend> {
backend: B,
pub profiles: Vec<String>,
pub selected_profile: usize,
pub draft: Profile,
pub scans: Vec<ScanResult>,
pub selected_scan: usize,
pub selected_field: usize,
pub focus: Focus,
pub editor: Option<EditorState>,
pub active_profile: Option<String>,
pub status: WifiRuntimeState,
pub message: String,
pub dirty: bool,
pub should_quit: bool,
}
impl<B: ConsoleBackend> App<B> {
pub fn new(backend: B) -> Result<Self, String> {
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<Field> {
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() {
"<empty>".to_string()
} else {
"".repeat(secret.chars().count())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ScanResult;
#[derive(Default)]
struct MockBackend {
profiles: Vec<String>,
active_profile: Option<String>,
loaded_profile: Profile,
scans: Vec<ScanResult>,
}
impl ConsoleBackend for MockBackend {
fn list_wifi_profiles(&self) -> Result<Vec<String>, String> {
Ok(self.profiles.clone())
}
fn active_profile_name(&self) -> Result<Option<String>, String> {
Ok(self.active_profile.clone())
}
fn load_profile(&self, _name: &str) -> Result<Profile, String> {
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<Vec<ScanResult>, String> {
Ok(self.scans.clone())
}
fn connect(&self, _profile: &Profile) -> Result<String, String> {
Ok("applied wifi-profile via wlan0".to_string())
}
fn disconnect(
&self,
_profile_name: Option<&str>,
_interface: &str,
) -> Result<String, String> {
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);
}
}
@@ -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<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);
}
}
@@ -0,0 +1,3 @@
pub mod app;
pub mod backend;
pub mod ui;
@@ -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<Key> {
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<FsBackend>) {
// 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("<none>"),
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, " <no saved profiles>");
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, " <none; press r to scan>");
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::<String>()
+ ""
}
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/<profile>\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"
);
}
@@ -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<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>) {
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<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>, 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<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>, 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<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>, 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::<Vec<_>>()
};
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<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>, 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<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>, 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::<Vec<_>>()
};
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<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>, area: Rect) {
let selected = app.selected_field();
let rows = app
.visible_fields()
.into_iter()
.map(|field| render_field_line(app, field, field == selected))
.collect::<Vec<_>>();
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<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>, 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<B: ConsoleBackend>(frame: &mut ratatui::Frame, app: &App<B>) {
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<B: ConsoleBackend>(
app: &App<B>,
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]
}
@@ -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<Mutex<()>> = 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"
);
}
@@ -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)
);
}
@@ -3,3 +3,7 @@ path = "source"
[build] [build]
template = "cargo" template = "cargo"
[package.files]
"/usr/bin/redbear-netctl" = "redbear-netctl"
"/usr/bin/netctl" = "redbear-netctl"
@@ -3,10 +3,9 @@ name = "redbear-netctl"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[[bin]]
name = "netctl"
path = "src/main.rs"
[[bin]] [[bin]]
name = "redbear-netctl" name = "redbear-netctl"
path = "src/main.rs" path = "src/main.rs"
[dev-dependencies]
redbear-netctl-console = { path = "../../redbear-netctl-console/source" }
@@ -2,6 +2,8 @@ use std::env;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{self, Command}; use std::process::{self, Command};
use std::thread;
use std::time::{Duration, Instant};
fn program_name() -> String { fn program_name() -> String {
env::args() env::args()
@@ -16,13 +18,14 @@ fn program_name() -> String {
fn usage() -> String { fn usage() -> String {
format!( format!(
"Usage: {} [--boot|list|status [profile]|start <profile>|stop <profile>|enable <profile>|disable [profile]|is-enabled [profile]]", "Usage: {} [--boot|list|status [profile]|scan <profile|iface>|retry <profile|iface>|start <profile>|stop <profile>|enable <profile>|disable [profile]|is-enabled [profile]]",
program_name() program_name()
) )
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum ProfileIpMode { enum ProfileIpMode {
Bounded,
Dhcp, Dhcp,
Static { Static {
address: String, 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)] #[derive(Clone, Debug)]
struct Profile { struct Profile {
name: String, name: String,
interface: String, interface: String,
connection: String, connection: ConnectionMode,
ip_mode: ProfileIpMode, ip_mode: ProfileIpMode,
} }
@@ -56,6 +77,8 @@ fn run() -> Result<(), String> {
"--boot" => run_boot_profile(), "--boot" => run_boot_profile(),
"list" => list_profiles(), "list" => list_profiles(),
"status" => status(args.next().as_deref()), "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), "start" => start_profile(&required_profile(args.next())?, false),
"stop" => stop_profile(&required_profile(args.next())?), "stop" => stop_profile(&required_profile(args.next())?),
"enable" => enable_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> { fn status(profile: Option<&str>) -> Result<(), String> {
let active = active_profile_name()?; let active = active_profile_name()?;
let selected = profile.map(str::to_string).or(active.clone()); let selected = profile.map(str::to_string).or(active.clone());
let address = current_addr().unwrap_or_else(|| "unconfigured".into());
match selected { match selected {
Some(name) => { Some(name) => {
let loaded = load_profile(&name)?;
let enabled = active.as_deref() == Some(name.as_str()); let enabled = active.as_deref() == Some(name.as_str());
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!( println!(
"profile={} enabled={} address={}", "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, name,
if enabled { "yes" } else { "no" }, 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 address
); );
} }
}
}
None => { 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> { fn start_profile(name: &str, boot: bool) -> Result<(), String> {
ensure_runtime_surfaces()?;
let profile = load_profile(name)?; let profile = load_profile(name)?;
apply_profile(&profile, boot)?; apply_profile(&profile, boot)?;
println!("started {}", profile.name); println!("started {}", profile.name);
@@ -121,6 +192,11 @@ fn start_profile(name: &str, boot: bool) -> Result<(), String> {
} }
fn stop_profile(name: &str) -> 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) { if active_profile_name()?.as_deref() == Some(name) {
let _ = fs::remove_file(active_profile_path()); 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> { fn apply_profile(profile: &Profile, boot: bool) -> Result<(), String> {
if profile.connection != "ethernet" { match &profile.connection {
return Err(format!( ConnectionMode::Ethernet => {}
"unsupported Connection={} (only ethernet is supported)", ConnectionMode::Wifi(wifi) => apply_wifi_profile(&profile.interface, wifi)?,
profile.connection
));
}
if profile.interface != "eth0" {
return Err(format!(
"unsupported Interface={} (only eth0 is supported)",
profile.interface
));
} }
match &profile.ip_mode { match &profile.ip_mode {
ProfileIpMode::Bounded => {}
ProfileIpMode::Dhcp => { ProfileIpMode::Dhcp => {
if boot if boot
|| current_addr().as_deref() == Some("Not configured") || current_addr(&profile.interface).as_deref() == Some("Not configured")
|| current_addr().is_none() || current_addr(&profile.interface).is_none()
{ {
let _child = Command::new("dhcpd") let _child = Command::new(dhcpd_command())
.arg(&profile.interface)
.spawn() .spawn()
.map_err(|err| format!("failed to spawn dhcpd: {err}"))?; .map_err(|err| format!("failed to spawn dhcpd: {err}"))?;
wait_for_address(&profile.interface)?;
} }
} }
ProfileIpMode::Static { ProfileIpMode::Static {
@@ -190,7 +261,7 @@ fn apply_profile(profile: &Profile, boot: bool) -> Result<(), String> {
gateway, gateway,
dns, dns,
} => { } => {
write_netcfg("ifaces/eth0/addr/set", address)?; write_netcfg(&format!("ifaces/{}/addr/set", profile.interface), address)?;
if let Some(gateway) = gateway { if let Some(gateway) = gateway {
write_netcfg("route/add", &format!("default via {gateway}"))?; write_netcfg("route/add", &format!("default via {gateway}"))?;
} }
@@ -209,19 +280,183 @@ fn apply_profile(profile: &Profile, boot: bool) -> Result<(), String> {
Ok(()) Ok(())
} }
fn ensure_runtime_surfaces() -> Result<(), String> { fn ensure_runtime_surfaces_for(interface: &str) -> Result<(), String> {
let addr_path = format!("{}/ifaces/eth0/addr/list", netcfg_root().display()); let addr_path = format!("{}/ifaces/{interface}/addr/list", netcfg_root().display());
fs::read_to_string(&addr_path) fs::read_to_string(&addr_path)
.map(|_| ()) .map(|_| ())
.map_err(|err| format!("failed to access {addr_path}: {err}")) .map_err(|err| format!("failed to access {addr_path}: {err}"))
} }
fn current_addr() -> Option<String> { fn current_addr(interface: &str) -> Option<String> {
fs::read_to_string(format!("{}/ifaces/eth0/addr/list", netcfg_root().display())) fs::read_to_string(format!(
"{}/ifaces/{interface}/addr/list",
netcfg_root().display()
))
.ok() .ok()
.map(|value| value.trim().to_string()) .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<String> {
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> { fn write_netcfg(node: &str, value: &str) -> Result<(), String> {
let path = format!("{}/{node}", netcfg_root().display()); let path = format!("{}/{node}", netcfg_root().display());
fs::write(&path, format!("{}\n", value.trim())) fs::write(&path, format!("{}\n", value.trim()))
@@ -298,6 +533,44 @@ fn netcfg_root() -> PathBuf {
.unwrap_or_else(|| PathBuf::from("/scheme/netcfg")) .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::<u64>().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::<u64>().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<Profile, String> { fn parse_profile(name: &str, content: &str) -> Result<Profile, String> {
let mut interface = None; let mut interface = None;
let mut connection = None; let mut connection = None;
@@ -305,6 +578,9 @@ fn parse_profile(name: &str, content: &str) -> Result<Profile, String> {
let mut address = None; let mut address = None;
let mut gateway = None; let mut gateway = None;
let mut dns = None; let mut dns = None;
let mut ssid = None;
let mut security = None;
let mut wifi_key = None;
for raw_line in content.lines() { for raw_line in content.lines() {
let line = raw_line.trim(); let line = raw_line.trim();
@@ -326,17 +602,43 @@ fn parse_profile(name: &str, content: &str) -> Result<Profile, String> {
"Address" => address = parse_first_array_item(value), "Address" => address = parse_first_array_item(value),
"Gateway" => gateway = Some(parse_scalar(value)), "Gateway" => gateway = Some(parse_scalar(value)),
"DNS" => dns = parse_first_array_item(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 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 let ip_mode = match ip
.ok_or_else(|| format!("profile {name} is missing IP="))? .ok_or_else(|| format!("profile {name} is missing IP="))?
.to_ascii_lowercase() .to_ascii_lowercase()
.as_str() .as_str()
{ {
"bounded" | "none" => ProfileIpMode::Bounded,
"dhcp" => ProfileIpMode::Dhcp, "dhcp" => ProfileIpMode::Dhcp,
"static" => ProfileIpMode::Static { "static" => ProfileIpMode::Static {
address: address.ok_or_else(|| format!("profile {name} is missing Address="))?, address: address.ok_or_else(|| format!("profile {name} is missing Address="))?,
@@ -349,11 +651,379 @@ fn parse_profile(name: &str, content: &str) -> Result<Profile, String> {
Ok(Profile { Ok(Profile {
name: name.to_string(), name: name.to_string(),
interface, interface,
connection: connection.to_ascii_lowercase(), connection,
ip_mode, 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<Mutex<()>> = 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 { fn parse_scalar(value: &str) -> String {
let trimmed = value.trim(); let trimmed = value.trim();
trimmed trimmed
@@ -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")
);
}
+1
View File
@@ -0,0 +1 @@
../../local/recipes/system/redbear-mtr
+1
View File
@@ -0,0 +1 @@
../../local/recipes/system/redbear-netctl-console
+1
View File
@@ -0,0 +1 @@
../../local/recipes/system/redbear-netstat
+1
View File
@@ -0,0 +1 @@
../../local/recipes/system/redbear-nmap
+1
View File
@@ -0,0 +1 @@
../../local/recipes/system/redbear-traceroute