Advance netctl and networking tools
Red Bear OS Team
This commit is contained in:
@@ -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]
|
||||
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"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "netctl"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "redbear-netctl"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dev-dependencies]
|
||||
redbear-netctl-console = { path = "../../redbear-netctl-console/source" }
|
||||
|
||||
@@ -2,6 +2,8 @@ use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{self, Command};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn program_name() -> String {
|
||||
env::args()
|
||||
@@ -16,13 +18,14 @@ fn program_name() -> String {
|
||||
|
||||
fn usage() -> String {
|
||||
format!(
|
||||
"Usage: {} [--boot|list|status [profile]|start <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()
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum ProfileIpMode {
|
||||
Bounded,
|
||||
Dhcp,
|
||||
Static {
|
||||
address: String,
|
||||
@@ -31,11 +34,29 @@ enum ProfileIpMode {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum WifiSecurity {
|
||||
Open,
|
||||
Wpa2Psk { key: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct WifiSettings {
|
||||
ssid: String,
|
||||
security: WifiSecurity,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum ConnectionMode {
|
||||
Ethernet,
|
||||
Wifi(WifiSettings),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Profile {
|
||||
name: String,
|
||||
interface: String,
|
||||
connection: String,
|
||||
connection: ConnectionMode,
|
||||
ip_mode: ProfileIpMode,
|
||||
}
|
||||
|
||||
@@ -56,6 +77,8 @@ fn run() -> Result<(), String> {
|
||||
"--boot" => run_boot_profile(),
|
||||
"list" => list_profiles(),
|
||||
"status" => status(args.next().as_deref()),
|
||||
"scan" => scan_wifi(&required_profile(args.next())?),
|
||||
"retry" => retry_wifi(&required_profile(args.next())?),
|
||||
"start" => start_profile(&required_profile(args.next())?, false),
|
||||
"stop" => stop_profile(&required_profile(args.next())?),
|
||||
"enable" => enable_profile(&required_profile(args.next())?),
|
||||
@@ -92,20 +115,69 @@ fn list_profiles() -> Result<(), String> {
|
||||
fn status(profile: Option<&str>) -> Result<(), String> {
|
||||
let active = active_profile_name()?;
|
||||
let selected = profile.map(str::to_string).or(active.clone());
|
||||
let address = current_addr().unwrap_or_else(|| "unconfigured".into());
|
||||
|
||||
match selected {
|
||||
Some(name) => {
|
||||
let loaded = load_profile(&name)?;
|
||||
let enabled = active.as_deref() == Some(name.as_str());
|
||||
println!(
|
||||
"profile={} enabled={} address={}",
|
||||
name,
|
||||
if enabled { "yes" } else { "no" },
|
||||
address
|
||||
);
|
||||
let address = current_addr(&loaded.interface).unwrap_or_else(|| "unconfigured".into());
|
||||
let connection = connection_name(&loaded.connection);
|
||||
match &loaded.connection {
|
||||
ConnectionMode::Wifi(_) => {
|
||||
let wifi_status = read_wifictl_value(&loaded.interface, "status")
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let link_state = read_wifictl_value(&loaded.interface, "link-state")
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let firmware_status = read_wifictl_value(&loaded.interface, "firmware-status")
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let transport_status =
|
||||
read_wifictl_value(&loaded.interface, "transport-status")
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let transport_init_status =
|
||||
read_wifictl_value(&loaded.interface, "transport-init-status")
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let activation_status =
|
||||
read_wifictl_value(&loaded.interface, "activation-status")
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let connect_result = read_wifictl_value(&loaded.interface, "connect-result")
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let disconnect_result =
|
||||
read_wifictl_value(&loaded.interface, "disconnect-result")
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let last_error = read_wifictl_value(&loaded.interface, "last-error")
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
println!(
|
||||
"profile={} enabled={} connection={} interface={} address={} wifi_status={} link_state={} firmware_status={} transport_status={} transport_init_status={} activation_status={} connect_result={} disconnect_result={} last_error={}",
|
||||
name,
|
||||
if enabled { "yes" } else { "no" },
|
||||
connection,
|
||||
loaded.interface,
|
||||
address,
|
||||
wifi_status,
|
||||
link_state,
|
||||
firmware_status,
|
||||
transport_status,
|
||||
transport_init_status,
|
||||
activation_status,
|
||||
connect_result,
|
||||
disconnect_result,
|
||||
last_error
|
||||
);
|
||||
}
|
||||
ConnectionMode::Ethernet => {
|
||||
println!(
|
||||
"profile={} enabled={} connection={} interface={} address={}",
|
||||
name,
|
||||
if enabled { "yes" } else { "no" },
|
||||
connection,
|
||||
loaded.interface,
|
||||
address
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!("profile=none enabled=no address={address}");
|
||||
println!("profile=none enabled=no address=unconfigured");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +185,6 @@ fn status(profile: Option<&str>) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn start_profile(name: &str, boot: bool) -> Result<(), String> {
|
||||
ensure_runtime_surfaces()?;
|
||||
let profile = load_profile(name)?;
|
||||
apply_profile(&profile, boot)?;
|
||||
println!("started {}", profile.name);
|
||||
@@ -121,6 +192,11 @@ fn start_profile(name: &str, boot: bool) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn stop_profile(name: &str) -> Result<(), String> {
|
||||
if let Ok(profile) = load_profile(name) {
|
||||
if let ConnectionMode::Wifi(_) = profile.connection {
|
||||
write_wifictl(&profile.interface, "disconnect", "1")?;
|
||||
}
|
||||
}
|
||||
if active_profile_name()?.as_deref() == Some(name) {
|
||||
let _ = fs::remove_file(active_profile_path());
|
||||
}
|
||||
@@ -161,28 +237,23 @@ fn is_enabled(profile: Option<&str>) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn apply_profile(profile: &Profile, boot: bool) -> Result<(), String> {
|
||||
if profile.connection != "ethernet" {
|
||||
return Err(format!(
|
||||
"unsupported Connection={} (only ethernet is supported)",
|
||||
profile.connection
|
||||
));
|
||||
}
|
||||
if profile.interface != "eth0" {
|
||||
return Err(format!(
|
||||
"unsupported Interface={} (only eth0 is supported)",
|
||||
profile.interface
|
||||
));
|
||||
match &profile.connection {
|
||||
ConnectionMode::Ethernet => {}
|
||||
ConnectionMode::Wifi(wifi) => apply_wifi_profile(&profile.interface, wifi)?,
|
||||
}
|
||||
|
||||
match &profile.ip_mode {
|
||||
ProfileIpMode::Bounded => {}
|
||||
ProfileIpMode::Dhcp => {
|
||||
if boot
|
||||
|| current_addr().as_deref() == Some("Not configured")
|
||||
|| current_addr().is_none()
|
||||
|| current_addr(&profile.interface).as_deref() == Some("Not configured")
|
||||
|| current_addr(&profile.interface).is_none()
|
||||
{
|
||||
let _child = Command::new("dhcpd")
|
||||
let _child = Command::new(dhcpd_command())
|
||||
.arg(&profile.interface)
|
||||
.spawn()
|
||||
.map_err(|err| format!("failed to spawn dhcpd: {err}"))?;
|
||||
wait_for_address(&profile.interface)?;
|
||||
}
|
||||
}
|
||||
ProfileIpMode::Static {
|
||||
@@ -190,7 +261,7 @@ fn apply_profile(profile: &Profile, boot: bool) -> Result<(), String> {
|
||||
gateway,
|
||||
dns,
|
||||
} => {
|
||||
write_netcfg("ifaces/eth0/addr/set", address)?;
|
||||
write_netcfg(&format!("ifaces/{}/addr/set", profile.interface), address)?;
|
||||
if let Some(gateway) = gateway {
|
||||
write_netcfg("route/add", &format!("default via {gateway}"))?;
|
||||
}
|
||||
@@ -209,17 +280,181 @@ fn apply_profile(profile: &Profile, boot: bool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_runtime_surfaces() -> Result<(), String> {
|
||||
let addr_path = format!("{}/ifaces/eth0/addr/list", netcfg_root().display());
|
||||
fn ensure_runtime_surfaces_for(interface: &str) -> Result<(), String> {
|
||||
let addr_path = format!("{}/ifaces/{interface}/addr/list", netcfg_root().display());
|
||||
fs::read_to_string(&addr_path)
|
||||
.map(|_| ())
|
||||
.map_err(|err| format!("failed to access {addr_path}: {err}"))
|
||||
}
|
||||
|
||||
fn current_addr() -> Option<String> {
|
||||
fs::read_to_string(format!("{}/ifaces/eth0/addr/list", netcfg_root().display()))
|
||||
fn current_addr(interface: &str) -> Option<String> {
|
||||
fs::read_to_string(format!(
|
||||
"{}/ifaces/{interface}/addr/list",
|
||||
netcfg_root().display()
|
||||
))
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
}
|
||||
|
||||
fn connection_name(connection: &ConnectionMode) -> &'static str {
|
||||
match connection {
|
||||
ConnectionMode::Ethernet => "ethernet",
|
||||
ConnectionMode::Wifi(_) => "wifi",
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_wifi(target: &str) -> Result<(), String> {
|
||||
let interface = match load_profile(target) {
|
||||
Ok(profile) => match profile.connection {
|
||||
ConnectionMode::Wifi(_) => profile.interface,
|
||||
ConnectionMode::Ethernet => {
|
||||
return Err(format!("profile {target} is not a Wi-Fi profile"));
|
||||
}
|
||||
},
|
||||
Err(_) => target.to_string(),
|
||||
};
|
||||
|
||||
write_wifictl(&interface, "prepare", "1")?;
|
||||
if read_wifictl_value(&interface, "status").as_deref() == Some("failed") {
|
||||
let last_error = read_wifictl_value(&interface, "last-error")
|
||||
.unwrap_or_else(|| "prepare failed".to_string());
|
||||
return Err(format!("wifictl prepare failed: {last_error}"));
|
||||
}
|
||||
write_wifictl(&interface, "init-transport", "1")?;
|
||||
if read_wifictl_value(&interface, "transport-init-status").as_deref()
|
||||
== Some("transport_init=failed")
|
||||
|| read_wifictl_value(&interface, "status").as_deref() == Some("failed")
|
||||
{
|
||||
let last_error = read_wifictl_value(&interface, "last-error")
|
||||
.unwrap_or_else(|| "transport init failed".to_string());
|
||||
return Err(format!("wifictl init-transport failed: {last_error}"));
|
||||
}
|
||||
write_wifictl(&interface, "activate-nic", "1")?;
|
||||
if read_wifictl_value(&interface, "activation-status").as_deref() == Some("activation=failed")
|
||||
|| read_wifictl_value(&interface, "status").as_deref() == Some("failed")
|
||||
{
|
||||
let last_error = read_wifictl_value(&interface, "last-error")
|
||||
.unwrap_or_else(|| "activation failed".to_string());
|
||||
return Err(format!("wifictl activate-nic failed: {last_error}"));
|
||||
}
|
||||
write_wifictl(&interface, "scan", "1")?;
|
||||
let results = read_wifictl_value(&interface, "scan-results").unwrap_or_default();
|
||||
let status = read_wifictl_value(&interface, "status").unwrap_or_else(|| "unknown".to_string());
|
||||
let firmware_status =
|
||||
read_wifictl_value(&interface, "firmware-status").unwrap_or_else(|| "unknown".to_string());
|
||||
let transport_status =
|
||||
read_wifictl_value(&interface, "transport-status").unwrap_or_else(|| "unknown".to_string());
|
||||
let transport_init_status = read_wifictl_value(&interface, "transport-init-status")
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let activation_status = read_wifictl_value(&interface, "activation-status")
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
println!(
|
||||
"interface={} status={} firmware_status={} transport_status={} transport_init_status={} activation_status={} scan_results={}",
|
||||
interface,
|
||||
status,
|
||||
firmware_status,
|
||||
transport_status,
|
||||
transport_init_status,
|
||||
activation_status,
|
||||
if results.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
results
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retry_wifi(target: &str) -> Result<(), String> {
|
||||
let interface = match load_profile(target) {
|
||||
Ok(profile) => match profile.connection {
|
||||
ConnectionMode::Wifi(_) => profile.interface,
|
||||
ConnectionMode::Ethernet => {
|
||||
return Err(format!("profile {target} is not a Wi-Fi profile"));
|
||||
}
|
||||
},
|
||||
Err(_) => target.to_string(),
|
||||
};
|
||||
|
||||
write_wifictl(&interface, "retry", "1")?;
|
||||
let status = read_wifictl_value(&interface, "status").unwrap_or_else(|| "unknown".to_string());
|
||||
let link_state =
|
||||
read_wifictl_value(&interface, "link-state").unwrap_or_else(|| "unknown".to_string());
|
||||
let last_error =
|
||||
read_wifictl_value(&interface, "last-error").unwrap_or_else(|| "none".to_string());
|
||||
println!(
|
||||
"interface={} status={} link_state={} last_error={}",
|
||||
interface, status, link_state, last_error
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_wifi_profile(interface: &str, wifi: &WifiSettings) -> Result<(), String> {
|
||||
let root = wifictl_root();
|
||||
let iface_root = root.join("ifaces").join(interface);
|
||||
fs::create_dir_all(&iface_root)
|
||||
.map_err(|err| format!("failed to prepare {}: {err}", iface_root.display()))?;
|
||||
|
||||
write_wifictl(interface, "ssid", &wifi.ssid)?;
|
||||
match &wifi.security {
|
||||
WifiSecurity::Open => {
|
||||
write_wifictl(interface, "security", "open")?;
|
||||
}
|
||||
WifiSecurity::Wpa2Psk { key } => {
|
||||
write_wifictl(interface, "security", "wpa2-psk")?;
|
||||
write_wifictl(interface, "key", key)?;
|
||||
}
|
||||
}
|
||||
write_wifictl(interface, "prepare", "1")?;
|
||||
if read_wifictl_value(interface, "status").as_deref() == Some("failed") {
|
||||
let last_error = read_wifictl_value(interface, "last-error")
|
||||
.unwrap_or_else(|| "prepare failed".to_string());
|
||||
return Err(format!("wifictl prepare failed: {last_error}"));
|
||||
}
|
||||
write_wifictl(interface, "init-transport", "1")?;
|
||||
if read_wifictl_value(interface, "transport-init-status").as_deref()
|
||||
== Some("transport_init=failed")
|
||||
|| read_wifictl_value(interface, "status").as_deref() == Some("failed")
|
||||
{
|
||||
let last_error = read_wifictl_value(interface, "last-error")
|
||||
.unwrap_or_else(|| "transport init failed".to_string());
|
||||
return Err(format!("wifictl init-transport failed: {last_error}"));
|
||||
}
|
||||
write_wifictl(interface, "activate-nic", "1")?;
|
||||
if read_wifictl_value(interface, "activation-status").as_deref() == Some("activation=failed")
|
||||
|| read_wifictl_value(interface, "status").as_deref() == Some("failed")
|
||||
{
|
||||
let last_error = read_wifictl_value(interface, "last-error")
|
||||
.unwrap_or_else(|| "activation failed".to_string());
|
||||
return Err(format!("wifictl activate-nic failed: {last_error}"));
|
||||
}
|
||||
write_wifictl(interface, "connect", "1")?;
|
||||
if read_wifictl_value(interface, "status").as_deref() == Some("failed") {
|
||||
let last_error = read_wifictl_value(interface, "last-error")
|
||||
.unwrap_or_else(|| "connect failed".to_string());
|
||||
return Err(format!("wifictl connect failed: {last_error}"));
|
||||
}
|
||||
ensure_runtime_surfaces_for(interface)
|
||||
}
|
||||
|
||||
fn write_wifictl(interface: &str, node: &str, value: &str) -> Result<(), String> {
|
||||
let path = wifictl_root().join("ifaces").join(interface).join(node);
|
||||
fs::write(&path, format!("{}\n", value.trim()))
|
||||
.map_err(|err| format!("failed to write {}: {err}", path.display()))
|
||||
}
|
||||
|
||||
fn wifictl_root() -> PathBuf {
|
||||
env::var_os("REDBEAR_WIFICTL_ROOT")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/scheme/wifictl"))
|
||||
}
|
||||
|
||||
fn read_wifictl_value(interface: &str, node: &str) -> Option<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> {
|
||||
@@ -298,6 +533,44 @@ fn netcfg_root() -> PathBuf {
|
||||
.unwrap_or_else(|| PathBuf::from("/scheme/netcfg"))
|
||||
}
|
||||
|
||||
fn dhcpd_command() -> String {
|
||||
env::var("REDBEAR_DHCPD_CMD").unwrap_or_else(|_| "dhcpd".to_string())
|
||||
}
|
||||
|
||||
fn dhcp_wait_timeout() -> Duration {
|
||||
env::var("REDBEAR_DHCPD_WAIT_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<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> {
|
||||
let mut interface = 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 gateway = None;
|
||||
let mut dns = None;
|
||||
let mut ssid = None;
|
||||
let mut security = None;
|
||||
let mut wifi_key = None;
|
||||
|
||||
for raw_line in content.lines() {
|
||||
let line = raw_line.trim();
|
||||
@@ -326,17 +602,43 @@ fn parse_profile(name: &str, content: &str) -> Result<Profile, String> {
|
||||
"Address" => address = parse_first_array_item(value),
|
||||
"Gateway" => gateway = Some(parse_scalar(value)),
|
||||
"DNS" => dns = parse_first_array_item(value),
|
||||
"SSID" => ssid = Some(parse_scalar(value)),
|
||||
"Security" => security = Some(parse_scalar(value)),
|
||||
"Key" | "Passphrase" => wifi_key = Some(parse_scalar(value)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let interface = interface.ok_or_else(|| format!("profile {name} is missing Interface="))?;
|
||||
let connection = connection.ok_or_else(|| format!("profile {name} is missing Connection="))?;
|
||||
let connection = match connection
|
||||
.ok_or_else(|| format!("profile {name} is missing Connection="))?
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"ethernet" => ConnectionMode::Ethernet,
|
||||
"wifi" => {
|
||||
let ssid = ssid.ok_or_else(|| format!("profile {name} is missing SSID="))?;
|
||||
let security = match security
|
||||
.ok_or_else(|| format!("profile {name} is missing Security="))?
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"open" => WifiSecurity::Open,
|
||||
"wpa2-psk" => WifiSecurity::Wpa2Psk {
|
||||
key: wifi_key.ok_or_else(|| format!("profile {name} is missing Key="))?,
|
||||
},
|
||||
other => return Err(format!("unsupported Security={other}")),
|
||||
};
|
||||
ConnectionMode::Wifi(WifiSettings { ssid, security })
|
||||
}
|
||||
other => return Err(format!("unsupported Connection={other}")),
|
||||
};
|
||||
let ip_mode = match ip
|
||||
.ok_or_else(|| format!("profile {name} is missing IP="))?
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"bounded" | "none" => ProfileIpMode::Bounded,
|
||||
"dhcp" => ProfileIpMode::Dhcp,
|
||||
"static" => ProfileIpMode::Static {
|
||||
address: address.ok_or_else(|| format!("profile {name} is missing Address="))?,
|
||||
@@ -349,11 +651,379 @@ fn parse_profile(name: &str, content: &str) -> Result<Profile, String> {
|
||||
Ok(Profile {
|
||||
name: name.to_string(),
|
||||
interface,
|
||||
connection: connection.to_ascii_lowercase(),
|
||||
connection,
|
||||
ip_mode,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<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 {
|
||||
let trimmed = value.trim();
|
||||
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")
|
||||
);
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../local/recipes/system/redbear-mtr
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../local/recipes/system/redbear-netctl-console
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../local/recipes/system/redbear-netstat
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../local/recipes/system/redbear-nmap
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../local/recipes/system/redbear-traceroute
|
||||
Reference in New Issue
Block a user