4b76deaa60
Red Bear OS Team
1052 lines
36 KiB
Rust
1052 lines
36 KiB
Rust
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()
|
|
.next()
|
|
.and_then(|path| {
|
|
PathBuf::from(path)
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().into_owned())
|
|
})
|
|
.unwrap_or_else(|| "netctl".to_string())
|
|
}
|
|
|
|
fn usage() -> String {
|
|
format!(
|
|
"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,
|
|
gateway: Option<String>,
|
|
dns: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[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: ConnectionMode,
|
|
ip_mode: ProfileIpMode,
|
|
}
|
|
|
|
fn main() {
|
|
if let Err(err) = run() {
|
|
eprintln!("{}: {err}", program_name());
|
|
process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn run() -> Result<(), String> {
|
|
let mut args = env::args().skip(1);
|
|
let Some(command) = args.next() else {
|
|
return Err(usage());
|
|
};
|
|
|
|
match command.as_str() {
|
|
"--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())?),
|
|
"disable" => disable_profile(args.next().as_deref()),
|
|
"is-enabled" => is_enabled(args.next().as_deref()),
|
|
"help" | "--help" | "-h" => {
|
|
println!("{}", usage());
|
|
Ok(())
|
|
}
|
|
_ => Err(usage()),
|
|
}
|
|
}
|
|
|
|
fn required_profile(profile: Option<String>) -> Result<String, String> {
|
|
profile.ok_or_else(usage)
|
|
}
|
|
|
|
fn run_boot_profile() -> Result<(), String> {
|
|
let Some(active) = active_profile_name()? else {
|
|
return Ok(());
|
|
};
|
|
start_profile(&active, true)
|
|
}
|
|
|
|
fn list_profiles() -> Result<(), String> {
|
|
let mut entries = profile_names()?;
|
|
entries.sort();
|
|
for entry in entries {
|
|
println!("{entry}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn status(profile: Option<&str>) -> Result<(), String> {
|
|
let active = active_profile_name()?;
|
|
let selected = profile.map(str::to_string).or(active.clone());
|
|
|
|
match selected {
|
|
Some(name) => {
|
|
let loaded = load_profile(&name)?;
|
|
let enabled = active.as_deref() == Some(name.as_str());
|
|
let address = current_addr(&loaded.interface).unwrap_or_else(|| "unconfigured".into());
|
|
let connection = connection_name(&loaded.connection);
|
|
match &loaded.connection {
|
|
ConnectionMode::Wifi(_) => {
|
|
let wifi_status = read_wifictl_value(&loaded.interface, "status")
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
let link_state = read_wifictl_value(&loaded.interface, "link-state")
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
let firmware_status = read_wifictl_value(&loaded.interface, "firmware-status")
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
let transport_status =
|
|
read_wifictl_value(&loaded.interface, "transport-status")
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
let transport_init_status =
|
|
read_wifictl_value(&loaded.interface, "transport-init-status")
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
let activation_status =
|
|
read_wifictl_value(&loaded.interface, "activation-status")
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
let connect_result = read_wifictl_value(&loaded.interface, "connect-result")
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
let disconnect_result =
|
|
read_wifictl_value(&loaded.interface, "disconnect-result")
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
let last_error = read_wifictl_value(&loaded.interface, "last-error")
|
|
.unwrap_or_else(|| "none".to_string());
|
|
println!(
|
|
"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=unconfigured");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn start_profile(name: &str, boot: bool) -> Result<(), String> {
|
|
let profile = load_profile(name)?;
|
|
apply_profile(&profile, boot)?;
|
|
println!("started {}", profile.name);
|
|
Ok(())
|
|
}
|
|
|
|
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());
|
|
}
|
|
println!("stopped {}", name);
|
|
Ok(())
|
|
}
|
|
|
|
fn enable_profile(name: &str) -> Result<(), String> {
|
|
let profile = load_profile(name)?;
|
|
let active_path = active_profile_path();
|
|
fs::write(&active_path, format!("{}\n", profile.name))
|
|
.map_err(|err| format!("failed to write {}: {err}", active_path.display()))?;
|
|
println!("enabled {}", profile.name);
|
|
Ok(())
|
|
}
|
|
|
|
fn disable_profile(profile: Option<&str>) -> Result<(), String> {
|
|
if let Some(name) = profile {
|
|
if active_profile_name()?.as_deref() != Some(name) {
|
|
println!("disabled {}", name);
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
let _ = fs::remove_file(active_profile_path());
|
|
println!("disabled {}", profile.unwrap_or("active"));
|
|
Ok(())
|
|
}
|
|
|
|
fn is_enabled(profile: Option<&str>) -> Result<(), String> {
|
|
let active = active_profile_name()?;
|
|
let enabled = match profile {
|
|
Some(profile) => active.as_deref() == Some(profile),
|
|
None => active.is_some(),
|
|
};
|
|
println!("{}", if enabled { "yes" } else { "no" });
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_profile(profile: &Profile, boot: bool) -> Result<(), String> {
|
|
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(&profile.interface).as_deref() == Some("Not configured")
|
|
|| current_addr(&profile.interface).is_none()
|
|
{
|
|
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 {
|
|
address,
|
|
gateway,
|
|
dns,
|
|
} => {
|
|
write_netcfg(&format!("ifaces/{}/addr/set", profile.interface), address)?;
|
|
if let Some(gateway) = gateway {
|
|
write_netcfg("route/add", &format!("default via {gateway}"))?;
|
|
}
|
|
if let Some(dns) = dns {
|
|
write_netcfg("resolv/nameserver", dns)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
if !boot && active_profile_name()?.as_deref() == Some(profile.name.as_str()) {
|
|
let active_path = active_profile_path();
|
|
fs::write(&active_path, format!("{}\n", profile.name))
|
|
.map_err(|err| format!("failed to update {}: {err}", active_path.display()))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
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(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> {
|
|
let path = format!("{}/{node}", netcfg_root().display());
|
|
fs::write(&path, format!("{}\n", value.trim()))
|
|
.map_err(|err| format!("failed to write {path}: {err}"))
|
|
}
|
|
|
|
fn active_profile_name() -> Result<Option<String>, String> {
|
|
let active_path = active_profile_path();
|
|
match fs::read_to_string(&active_path) {
|
|
Ok(value) => {
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(value.to_string()))
|
|
}
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
|
Err(err) => Err(format!("failed to read {}: {err}", active_path.display())),
|
|
}
|
|
}
|
|
|
|
fn profile_names() -> Result<Vec<String>, String> {
|
|
let profile_dir = profile_dir();
|
|
let entries = fs::read_dir(&profile_dir)
|
|
.map_err(|err| format!("failed to read {}: {err}", 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(|name| name.to_str()) else {
|
|
continue;
|
|
};
|
|
if name == "active" || name.starts_with('.') {
|
|
continue;
|
|
}
|
|
names.push(name.to_string());
|
|
}
|
|
|
|
Ok(names)
|
|
}
|
|
|
|
fn load_profile(name: &str) -> Result<Profile, String> {
|
|
let path = 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 profile_path(name: &str) -> PathBuf {
|
|
profile_dir().join(name)
|
|
}
|
|
|
|
fn profile_dir() -> PathBuf {
|
|
env::var_os("REDBEAR_NETCTL_PROFILE_DIR")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| PathBuf::from("/etc/netctl"))
|
|
}
|
|
|
|
fn active_profile_path() -> PathBuf {
|
|
env::var_os("REDBEAR_NETCTL_ACTIVE")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| profile_dir().join("active"))
|
|
}
|
|
|
|
fn netcfg_root() -> PathBuf {
|
|
env::var_os("REDBEAR_NETCFG_ROOT")
|
|
.map(PathBuf::from)
|
|
.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;
|
|
let mut ip = None;
|
|
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();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
|
|
let Some((key, value)) = line.split_once('=') else {
|
|
continue;
|
|
};
|
|
let key = key.trim();
|
|
let value = value.trim();
|
|
|
|
match key {
|
|
"Description" => {}
|
|
"Interface" => interface = Some(parse_scalar(value)),
|
|
"Connection" => connection = Some(parse_scalar(value)),
|
|
"IP" => ip = Some(parse_scalar(value)),
|
|
"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 = 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="))?,
|
|
gateway,
|
|
dns,
|
|
},
|
|
other => return Err(format!("unsupported IP={other}")),
|
|
};
|
|
|
|
Ok(Profile {
|
|
name: name.to_string(),
|
|
interface,
|
|
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
|
|
.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(|value| !value.is_empty())
|
|
} else {
|
|
let value = parse_scalar(trimmed);
|
|
(!value.is_empty()).then_some(value)
|
|
}
|
|
}
|