Harden privileged command operands against injection
All checks were successful
dev release / build (push) Successful in 3m2s
All checks were successful
dev release / build (push) Successful in 3m2s
Validate chpasswd user:password fields, refuse deleting root or the current user, and pipe charge-threshold writes through tee stdin. Allowlist firewall rules, systemd units, AUR names, hostname, and timezone. Keep CalDAV passwords write-only like restic.
This commit is contained in:
parent
77b5a223d8
commit
f7b114f778
10 changed files with 605 additions and 89 deletions
|
|
@ -5,7 +5,6 @@
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
|
||||||
"dialog:default"
|
"dialog:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,10 @@ fn os_pretty_name() -> String {
|
||||||
fs::read_to_string("/etc/os-release")
|
fs::read_to_string("/etc/os-release")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| {
|
.and_then(|s| {
|
||||||
s.lines()
|
s.lines().find_map(|l| {
|
||||||
.find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string()))
|
l.strip_prefix("PRETTY_NAME=")
|
||||||
|
.map(|v| v.trim_matches('"').to_string())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "BOS".to_string())
|
.unwrap_or_else(|| "BOS".to_string())
|
||||||
}
|
}
|
||||||
|
|
@ -49,11 +51,15 @@ fn cpu() -> String {
|
||||||
let model = fs::read_to_string("/proc/cpuinfo")
|
let model = fs::read_to_string("/proc/cpuinfo")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| {
|
.and_then(|s| {
|
||||||
s.lines()
|
s.lines().find_map(|l| {
|
||||||
.find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string()))
|
l.strip_prefix("model name")
|
||||||
|
.map(|v| v.trim_start_matches([':', ' ', '\t']).to_string())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
|
let cores = std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(0);
|
||||||
if cores > 0 {
|
if cores > 0 {
|
||||||
format!("{model} ({cores} threads)")
|
format!("{model} ({cores} threads)")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -62,9 +68,7 @@ fn cpu() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn memory() -> String {
|
fn memory() -> String {
|
||||||
let kb = fs::read_to_string("/proc/meminfo")
|
let kb = fs::read_to_string("/proc/meminfo").ok().and_then(|s| {
|
||||||
.ok()
|
|
||||||
.and_then(|s| {
|
|
||||||
s.lines()
|
s.lines()
|
||||||
.find(|l| l.starts_with("MemTotal:"))
|
.find(|l| l.starts_with("MemTotal:"))
|
||||||
.and_then(|l| l.split_whitespace().nth(1))
|
.and_then(|l| l.split_whitespace().nth(1))
|
||||||
|
|
@ -96,7 +100,11 @@ async fn gpu() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn disk_usage() -> String {
|
async fn disk_usage() -> String {
|
||||||
let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output().await else {
|
let Ok(output) = Command::new("df")
|
||||||
|
.args(["-h", "--output=used,size,pcent", "/"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
return "unknown".to_string();
|
return "unknown".to_string();
|
||||||
};
|
};
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
@ -136,11 +144,35 @@ pub async fn get_system_info() -> SystemInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// RFC 1123 labels (digit start allowed), no leading `-`. Linux static
|
||||||
|
/// hostnames are also capped at `HOST_NAME_MAX` (64).
|
||||||
|
fn valid_hostname(name: &str) -> bool {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() || name.len() > 64 || name.starts_with('-') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if name.contains('\n') || name.contains('\r') || name.contains('\0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
name.split('.').all(valid_dns_label)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_dns_label(label: &str) -> bool {
|
||||||
|
let b = label.as_bytes();
|
||||||
|
if b.is_empty() || b.len() > 63 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !b[0].is_ascii_alphanumeric() || !b[b.len() - 1].is_ascii_alphanumeric() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
b.iter().all(|c| c.is_ascii_alphanumeric() || *c == b'-')
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_hostname(name: String) -> Result<(), String> {
|
pub async fn set_hostname(name: String) -> Result<(), String> {
|
||||||
let name = name.trim();
|
let name = name.trim();
|
||||||
if name.is_empty() {
|
if !valid_hostname(name) {
|
||||||
return Err("Hostname can't be empty".into());
|
return Err("invalid hostname".into());
|
||||||
}
|
}
|
||||||
let output = Command::new("pkexec")
|
let output = Command::new("pkexec")
|
||||||
.args(["hostnamectl", "set-hostname", name])
|
.args(["hostnamectl", "set-hostname", name])
|
||||||
|
|
@ -153,3 +185,24 @@ pub async fn set_hostname(name: String) -> Result<(), String> {
|
||||||
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
|
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostname_rfc1123() {
|
||||||
|
assert!(valid_hostname("bos"));
|
||||||
|
assert!(valid_hostname("bos.local"));
|
||||||
|
assert!(valid_hostname("a1-b"));
|
||||||
|
assert!(valid_hostname("1host"));
|
||||||
|
assert!(!valid_hostname(""));
|
||||||
|
assert!(!valid_hostname("-bos"));
|
||||||
|
assert!(!valid_hostname("bos-"));
|
||||||
|
assert!(!valid_hostname("-foo.bar"));
|
||||||
|
assert!(!valid_hostname("foo_bar"));
|
||||||
|
assert!(!valid_hostname("bos\n-set-hostname evil"));
|
||||||
|
assert!(!valid_hostname("--help"));
|
||||||
|
assert!(!valid_hostname(&"a".repeat(65)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use super::util;
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct AurResult {
|
pub struct AurResult {
|
||||||
name: String,
|
name: String,
|
||||||
|
|
@ -19,7 +21,11 @@ pub struct AurResult {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn search_aur(query: String) -> Vec<AurResult> {
|
pub async fn search_aur(query: String) -> Vec<AurResult> {
|
||||||
let Ok(output) = tokio::process::Command::new("yay").args(["-Ss", "--aur", &query]).output().await else {
|
let Ok(output) = tokio::process::Command::new("yay")
|
||||||
|
.args(["-Ss", "--aur", &query])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
@ -28,12 +34,18 @@ pub async fn search_aur(query: String) -> Vec<AurResult> {
|
||||||
while let Some(header) = lines.next() {
|
while let Some(header) = lines.next() {
|
||||||
// "aur/name version (+votes score) [Orphaned]" — name/version are
|
// "aur/name version (+votes score) [Orphaned]" — name/version are
|
||||||
// always the first two whitespace-separated fields after "aur/".
|
// always the first two whitespace-separated fields after "aur/".
|
||||||
let Some(rest) = header.strip_prefix("aur/") else { continue };
|
let Some(rest) = header.strip_prefix("aur/") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let mut parts = rest.split_whitespace();
|
let mut parts = rest.split_whitespace();
|
||||||
let Some(name) = parts.next() else { continue };
|
let Some(name) = parts.next() else { continue };
|
||||||
let version = parts.next().unwrap_or("").to_string();
|
let version = parts.next().unwrap_or("").to_string();
|
||||||
let description = lines.next().unwrap_or("").trim().to_string();
|
let description = lines.next().unwrap_or("").trim().to_string();
|
||||||
results.push(AurResult { name: name.to_string(), version, description });
|
results.push(AurResult {
|
||||||
|
name: name.to_string(),
|
||||||
|
version,
|
||||||
|
description,
|
||||||
|
});
|
||||||
if results.len() >= 50 {
|
if results.len() >= 50 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -42,6 +54,13 @@ pub async fn search_aur(query: String) -> Vec<AurResult> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn install_aur_package(pkg: String) {
|
pub fn install_aur_package(pkg: String) -> Result<(), String> {
|
||||||
let _ = std::process::Command::new("kitty").args(["-e", "yay", "-S", &pkg]).spawn();
|
if !util::valid_pkg_name(&pkg) {
|
||||||
|
return Err(format!("refusing to install '{pkg}'"));
|
||||||
|
}
|
||||||
|
std::process::Command::new("kitty")
|
||||||
|
.args(["-e", "yay", "-S", &pkg])
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,22 +35,35 @@ pub struct BreadpadConfig {
|
||||||
pub fn get_breadpad_config() -> BreadpadConfig {
|
pub fn get_breadpad_config() -> BreadpadConfig {
|
||||||
let doc = config::load_doc(&config_path());
|
let doc = config::load_doc(&config_path());
|
||||||
BreadpadConfig {
|
BreadpadConfig {
|
||||||
default_type: config::get_str(&doc, &["settings", "default_type"]).unwrap_or_else(|| "note".into()),
|
default_type: config::get_str(&doc, &["settings", "default_type"])
|
||||||
|
.unwrap_or_else(|| "note".into()),
|
||||||
workspace_tag: config::get_bool(&doc, &["settings", "workspace_tag"]).unwrap_or(true),
|
workspace_tag: config::get_bool(&doc, &["settings", "workspace_tag"]).unwrap_or(true),
|
||||||
snooze_options: config::get_str_list(&doc, &["settings", "snooze_options"]),
|
snooze_options: config::get_str_list(&doc, &["settings", "snooze_options"]),
|
||||||
archive_after_days: config::get_i64(&doc, &["settings", "archive_after_days"]).unwrap_or(30),
|
archive_after_days: config::get_i64(&doc, &["settings", "archive_after_days"])
|
||||||
|
.unwrap_or(30),
|
||||||
model_path: config::get_str(&doc, &["model", "path"]).unwrap_or_default(),
|
model_path: config::get_str(&doc, &["model", "path"]).unwrap_or_default(),
|
||||||
tokenizer_path: config::get_str(&doc, &["model", "tokenizer"]).unwrap_or_default(),
|
tokenizer_path: config::get_str(&doc, &["model", "tokenizer"]).unwrap_or_default(),
|
||||||
ollama_enabled: config::get_bool(&doc, &["model", "ollama", "enabled"]).unwrap_or(true),
|
ollama_enabled: config::get_bool(&doc, &["model", "ollama", "enabled"]).unwrap_or(true),
|
||||||
ollama_endpoint: config::get_str(&doc, &["model", "ollama", "endpoint"]).unwrap_or_default(),
|
ollama_endpoint: config::get_str(&doc, &["model", "ollama", "endpoint"])
|
||||||
|
.unwrap_or_default(),
|
||||||
ollama_model: config::get_str(&doc, &["model", "ollama", "model"]).unwrap_or_default(),
|
ollama_model: config::get_str(&doc, &["model", "ollama", "model"]).unwrap_or_default(),
|
||||||
ollama_confidence_threshold: config::get_f64(&doc, &["model", "ollama", "confidence_threshold"]).unwrap_or(0.6),
|
ollama_confidence_threshold: config::get_f64(
|
||||||
reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"]).unwrap_or_else(|| "7:00".into()),
|
&doc,
|
||||||
reminders_missed_grace_minutes: config::get_i64(&doc, &["reminders", "missed_grace_minutes"]).unwrap_or(60),
|
&["model", "ollama", "confidence_threshold"],
|
||||||
|
)
|
||||||
|
.unwrap_or(0.6),
|
||||||
|
reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"])
|
||||||
|
.unwrap_or_else(|| "7:00".into()),
|
||||||
|
reminders_missed_grace_minutes: config::get_i64(
|
||||||
|
&doc,
|
||||||
|
&["reminders", "missed_grace_minutes"],
|
||||||
|
)
|
||||||
|
.unwrap_or(60),
|
||||||
calendar_enabled: config::get_bool(&doc, &["calendar", "enabled"]).unwrap_or(false),
|
calendar_enabled: config::get_bool(&doc, &["calendar", "enabled"]).unwrap_or(false),
|
||||||
calendar_url: config::get_str(&doc, &["calendar", "url"]).unwrap_or_default(),
|
calendar_url: config::get_str(&doc, &["calendar", "url"]).unwrap_or_default(),
|
||||||
calendar_username: config::get_str(&doc, &["calendar", "username"]).unwrap_or_default(),
|
calendar_username: config::get_str(&doc, &["calendar", "username"]).unwrap_or_default(),
|
||||||
calendar_password: config::get_str(&doc, &["calendar", "password"]).unwrap_or_default(),
|
// Write-only to the webview, same as restic — never round-trip the secret.
|
||||||
|
calendar_password: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,19 +73,76 @@ pub fn save_breadpad_config(cfg: BreadpadConfig) -> Result<(), String> {
|
||||||
let mut doc = config::load_doc(&path);
|
let mut doc = config::load_doc(&path);
|
||||||
config::set_str(&mut doc, &["settings", "default_type"], &cfg.default_type);
|
config::set_str(&mut doc, &["settings", "default_type"], &cfg.default_type);
|
||||||
config::set_bool(&mut doc, &["settings", "workspace_tag"], cfg.workspace_tag);
|
config::set_bool(&mut doc, &["settings", "workspace_tag"], cfg.workspace_tag);
|
||||||
config::set_str_list(&mut doc, &["settings", "snooze_options"], &cfg.snooze_options);
|
config::set_str_list(
|
||||||
config::set_i64(&mut doc, &["settings", "archive_after_days"], cfg.archive_after_days);
|
&mut doc,
|
||||||
|
&["settings", "snooze_options"],
|
||||||
|
&cfg.snooze_options,
|
||||||
|
);
|
||||||
|
config::set_i64(
|
||||||
|
&mut doc,
|
||||||
|
&["settings", "archive_after_days"],
|
||||||
|
cfg.archive_after_days,
|
||||||
|
);
|
||||||
config::set_str_or_remove(&mut doc, &["model", "path"], &cfg.model_path);
|
config::set_str_or_remove(&mut doc, &["model", "path"], &cfg.model_path);
|
||||||
config::set_str_or_remove(&mut doc, &["model", "tokenizer"], &cfg.tokenizer_path);
|
config::set_str_or_remove(&mut doc, &["model", "tokenizer"], &cfg.tokenizer_path);
|
||||||
config::set_bool(&mut doc, &["model", "ollama", "enabled"], cfg.ollama_enabled);
|
config::set_bool(
|
||||||
config::set_str_or_remove(&mut doc, &["model", "ollama", "endpoint"], &cfg.ollama_endpoint);
|
&mut doc,
|
||||||
|
&["model", "ollama", "enabled"],
|
||||||
|
cfg.ollama_enabled,
|
||||||
|
);
|
||||||
|
config::set_str_or_remove(
|
||||||
|
&mut doc,
|
||||||
|
&["model", "ollama", "endpoint"],
|
||||||
|
&cfg.ollama_endpoint,
|
||||||
|
);
|
||||||
config::set_str_or_remove(&mut doc, &["model", "ollama", "model"], &cfg.ollama_model);
|
config::set_str_or_remove(&mut doc, &["model", "ollama", "model"], &cfg.ollama_model);
|
||||||
config::set_f64(&mut doc, &["model", "ollama", "confidence_threshold"], cfg.ollama_confidence_threshold);
|
config::set_f64(
|
||||||
config::set_str_or_remove(&mut doc, &["reminders", "default_morning"], &cfg.reminders_default_morning);
|
&mut doc,
|
||||||
config::set_i64(&mut doc, &["reminders", "missed_grace_minutes"], cfg.reminders_missed_grace_minutes);
|
&["model", "ollama", "confidence_threshold"],
|
||||||
|
cfg.ollama_confidence_threshold,
|
||||||
|
);
|
||||||
|
config::set_str_or_remove(
|
||||||
|
&mut doc,
|
||||||
|
&["reminders", "default_morning"],
|
||||||
|
&cfg.reminders_default_morning,
|
||||||
|
);
|
||||||
|
config::set_i64(
|
||||||
|
&mut doc,
|
||||||
|
&["reminders", "missed_grace_minutes"],
|
||||||
|
cfg.reminders_missed_grace_minutes,
|
||||||
|
);
|
||||||
config::set_bool(&mut doc, &["calendar", "enabled"], cfg.calendar_enabled);
|
config::set_bool(&mut doc, &["calendar", "enabled"], cfg.calendar_enabled);
|
||||||
config::set_str_or_remove(&mut doc, &["calendar", "url"], &cfg.calendar_url);
|
config::set_str_or_remove(&mut doc, &["calendar", "url"], &cfg.calendar_url);
|
||||||
config::set_str_or_remove(&mut doc, &["calendar", "username"], &cfg.calendar_username);
|
config::set_str_or_remove(&mut doc, &["calendar", "username"], &cfg.calendar_username);
|
||||||
config::set_str_or_remove(&mut doc, &["calendar", "password"], &cfg.calendar_password);
|
apply_calendar_password(&mut doc, &cfg.calendar_password);
|
||||||
config::save_doc(&path, &doc).map_err(|e| e.to_string())
|
config::save_doc(&path, &doc).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Empty incoming password keeps the existing secret (PasswordField is write-only).
|
||||||
|
fn apply_calendar_password(doc: &mut toml_edit::DocumentMut, incoming: &str) {
|
||||||
|
if incoming.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
config::set_str(doc, &["calendar", "password"], incoming);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_password_keeps_existing_secret() {
|
||||||
|
let mut doc: toml_edit::DocumentMut =
|
||||||
|
"[calendar]\npassword = \"secret\"\n".parse().unwrap();
|
||||||
|
apply_calendar_password(&mut doc, "");
|
||||||
|
assert_eq!(
|
||||||
|
config::get_str(&doc, &["calendar", "password"]).as_deref(),
|
||||||
|
Some("secret")
|
||||||
|
);
|
||||||
|
apply_calendar_password(&mut doc, "newpass");
|
||||||
|
assert_eq!(
|
||||||
|
config::get_str(&doc, &["calendar", "password"]).as_deref(),
|
||||||
|
Some("newpass")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,12 @@ async fn list_timezones() -> Vec<String> {
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect())
|
.map(|o| {
|
||||||
|
String::from_utf8_lossy(&o.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,10 +65,32 @@ pub async fn get_datetime_info() -> DateTimeInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reject flags, path traversal, and newlines before we ever exec. Charset
|
||||||
|
/// matches IANA names (`Area/City`, `UTC`, `Etc/GMT+6`).
|
||||||
|
fn timezone_looks_safe(tz: &str) -> bool {
|
||||||
|
let tz = tz.trim();
|
||||||
|
if tz.is_empty() || tz.len() > 64 || tz.starts_with('-') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if tz.contains('\n') || tz.contains('\r') || tz.contains('\0') || tz.contains("..") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tz.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-'))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_timezone(tz: String) -> Result<(), String> {
|
pub async fn set_timezone(tz: String) -> Result<(), String> {
|
||||||
|
let tz = tz.trim();
|
||||||
|
if !timezone_looks_safe(tz) {
|
||||||
|
return Err("invalid timezone".into());
|
||||||
|
}
|
||||||
|
let listed = list_timezones().await;
|
||||||
|
if !listed.is_empty() && !listed.iter().any(|t| t == tz) {
|
||||||
|
return Err("unknown timezone".into());
|
||||||
|
}
|
||||||
let output = Command::new("pkexec")
|
let output = Command::new("pkexec")
|
||||||
.args(["timedatectl", "set-timezone", &tz])
|
.args(["timedatectl", "set-timezone", tz])
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
@ -77,6 +104,28 @@ pub async fn set_timezone(tz: String) -> Result<(), String> {
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_ntp_enabled(enabled: bool) -> Result<(), String> {
|
pub async fn set_ntp_enabled(enabled: bool) -> Result<(), String> {
|
||||||
let val = if enabled { "true" } else { "false" };
|
let val = if enabled { "true" } else { "false" };
|
||||||
Command::new("pkexec").args(["timedatectl", "set-ntp", val]).status().await.map_err(|e| e.to_string())?;
|
Command::new("pkexec")
|
||||||
|
.args(["timedatectl", "set-ntp", val])
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timezone_rejects_flags_and_traversal() {
|
||||||
|
assert!(timezone_looks_safe("UTC"));
|
||||||
|
assert!(timezone_looks_safe("America/New_York"));
|
||||||
|
assert!(timezone_looks_safe("Etc/GMT+6"));
|
||||||
|
assert!(!timezone_looks_safe(""));
|
||||||
|
assert!(!timezone_looks_safe("-UTC"));
|
||||||
|
assert!(!timezone_looks_safe("--help"));
|
||||||
|
assert!(!timezone_looks_safe("America/../UTC"));
|
||||||
|
assert!(!timezone_looks_safe("UTC\n--adjust"));
|
||||||
|
assert!(!timezone_looks_safe("UTC;reboot"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,9 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
return Err(if stderr.is_empty() {
|
return Err(if stderr.is_empty() {
|
||||||
match output.status.code() {
|
match output.status.code() {
|
||||||
Some(127) => "no polkit authentication agent is available in this session".to_string(),
|
Some(127) => {
|
||||||
|
"no polkit authentication agent is available in this session".to_string()
|
||||||
|
}
|
||||||
Some(code) => format!("pkexec exited with status {code}"),
|
Some(code) => format!("pkexec exited with status {code}"),
|
||||||
None => "pkexec was terminated by a signal".to_string(),
|
None => "pkexec was terminated by a signal".to_string(),
|
||||||
}
|
}
|
||||||
|
|
@ -41,7 +43,10 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
let active = text.lines().next().is_some_and(|l| l.trim() == "Status: active");
|
let active = text
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|l| l.trim() == "Status: active");
|
||||||
let rules = text
|
let rules = text
|
||||||
.lines()
|
.lines()
|
||||||
.filter_map(|l| {
|
.filter_map(|l| {
|
||||||
|
|
@ -51,7 +56,10 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
|
||||||
}
|
}
|
||||||
let (num, rest) = l.split_once(']')?;
|
let (num, rest) = l.split_once(']')?;
|
||||||
let number = num.trim_start_matches('[').trim().to_string();
|
let number = num.trim_start_matches('[').trim().to_string();
|
||||||
Some(FirewallRule { number, text: rest.trim().to_string() })
|
Some(FirewallRule {
|
||||||
|
number,
|
||||||
|
text: rest.trim().to_string(),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(FirewallStatus { active, rules })
|
Ok(FirewallStatus { active, rules })
|
||||||
|
|
@ -60,7 +68,11 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> {
|
pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> {
|
||||||
let verb = if enabled { "enable" } else { "disable" };
|
let verb = if enabled { "enable" } else { "disable" };
|
||||||
let output = Command::new("pkexec").args(["ufw", "--force", verb]).output().await.map_err(|e| e.to_string())?;
|
let output = Command::new("pkexec")
|
||||||
|
.args(["ufw", "--force", verb])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -68,9 +80,54 @@ pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Port, optional `/tcp`/`/udp`, or optional space-separated proto. No
|
||||||
|
/// service names, IPs, or flags — those become extra `ufw allow` operands.
|
||||||
|
fn valid_firewall_rule(rule: &str) -> bool {
|
||||||
|
let rule = rule.trim();
|
||||||
|
if rule.is_empty() || rule.len() > 16 || rule.starts_with('-') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if rule.contains('\n') || rule.contains('\r') || rule.contains('\0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let (port, proto) = if let Some((p, rest)) = rule.split_once('/') {
|
||||||
|
(p, Some(rest))
|
||||||
|
} else if let Some((p, rest)) = rule.split_once(' ') {
|
||||||
|
(p, Some(rest.trim()))
|
||||||
|
} else {
|
||||||
|
(rule, None)
|
||||||
|
};
|
||||||
|
let Ok(n) = port.parse::<u16>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if n == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match proto {
|
||||||
|
None => true,
|
||||||
|
Some(p) => p == "tcp" || p == "udp",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_rule_number(number: &str) -> bool {
|
||||||
|
let t = number.trim();
|
||||||
|
!t.is_empty()
|
||||||
|
&& t.len() <= 8
|
||||||
|
&& t.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
&& t.parse::<u32>().is_ok_and(|n| n > 0)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn add_firewall_rule(rule: String) -> Result<(), String> {
|
pub async fn add_firewall_rule(rule: String) -> Result<(), String> {
|
||||||
let output = Command::new("pkexec").args(["ufw", "allow", rule.trim()]).output().await.map_err(|e| e.to_string())?;
|
let rule = rule.trim();
|
||||||
|
if !valid_firewall_rule(rule) {
|
||||||
|
return Err("invalid firewall rule".into());
|
||||||
|
}
|
||||||
|
let output = Command::new("pkexec")
|
||||||
|
.args(["ufw", "allow", rule])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -80,10 +137,48 @@ pub async fn add_firewall_rule(rule: String) -> Result<(), String> {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn remove_firewall_rule(number: String) -> Result<(), String> {
|
pub async fn remove_firewall_rule(number: String) -> Result<(), String> {
|
||||||
let output = Command::new("pkexec").args(["ufw", "--force", "delete", &number]).output().await.map_err(|e| e.to_string())?;
|
if !valid_rule_number(&number) {
|
||||||
|
return Err("invalid rule number".into());
|
||||||
|
}
|
||||||
|
let output = Command::new("pkexec")
|
||||||
|
.args(["ufw", "--force", "delete", number.trim()])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
|
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn firewall_rule_is_port_and_optional_proto() {
|
||||||
|
assert!(valid_firewall_rule("22"));
|
||||||
|
assert!(valid_firewall_rule("8080/tcp"));
|
||||||
|
assert!(valid_firewall_rule("53/udp"));
|
||||||
|
assert!(valid_firewall_rule("80 tcp"));
|
||||||
|
assert!(!valid_firewall_rule("OpenSSH"));
|
||||||
|
assert!(!valid_firewall_rule("-f"));
|
||||||
|
assert!(!valid_firewall_rule("22;id"));
|
||||||
|
assert!(!valid_firewall_rule("22/tcp\nallow 23"));
|
||||||
|
assert!(!valid_firewall_rule("0"));
|
||||||
|
assert!(!valid_firewall_rule("65536"));
|
||||||
|
assert!(!valid_firewall_rule("22/all"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn firewall_delete_is_positive_int() {
|
||||||
|
assert!(valid_rule_number("1"));
|
||||||
|
assert!(valid_rule_number("12"));
|
||||||
|
assert!(!valid_rule_number("0"));
|
||||||
|
assert!(!valid_rule_number("-1"));
|
||||||
|
assert!(!valid_rule_number("1;2"));
|
||||||
|
assert!(!valid_rule_number("1\n2"));
|
||||||
|
assert!(!valid_rule_number(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,22 @@
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use super::util;
|
||||||
|
|
||||||
async fn upower_device(kind: &str) -> Option<String> {
|
async fn upower_device(kind: &str) -> Option<String> {
|
||||||
let out = Command::new("upower").arg("-e").output().await.ok()?;
|
let out = Command::new("upower").arg("-e").output().await.ok()?;
|
||||||
String::from_utf8_lossy(&out.stdout).lines().find(|l| l.to_lowercase().contains(kind)).map(str::to_string)
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.to_lowercase().contains(kind))
|
||||||
|
.map(str::to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upower_field(device: &str, field: &str) -> Option<String> {
|
async fn upower_field(device: &str, field: &str) -> Option<String> {
|
||||||
let out = Command::new("upower").args(["-i", device]).output().await.ok()?;
|
let out = Command::new("upower")
|
||||||
|
.args(["-i", device])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
let text = String::from_utf8_lossy(&out.stdout);
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
text.lines()
|
text.lines()
|
||||||
.find(|l| l.trim_start().starts_with(field))
|
.find(|l| l.trim_start().starts_with(field))
|
||||||
|
|
@ -39,12 +48,18 @@ async fn battery_summary() -> Vec<(String, String)> {
|
||||||
if let Some(t) = t {
|
if let Some(t) = t {
|
||||||
rows.push(("Time remaining".to_string(), t));
|
rows.push(("Time remaining".to_string(), t));
|
||||||
}
|
}
|
||||||
let full: Option<f64> = upower_field(&bat, "energy-full").await.and_then(|v| v.split_whitespace().next()?.parse().ok());
|
let full: Option<f64> = upower_field(&bat, "energy-full")
|
||||||
let design: Option<f64> =
|
.await
|
||||||
upower_field(&bat, "energy-full-design").await.and_then(|v| v.split_whitespace().next()?.parse().ok());
|
.and_then(|v| v.split_whitespace().next()?.parse().ok());
|
||||||
|
let design: Option<f64> = upower_field(&bat, "energy-full-design")
|
||||||
|
.await
|
||||||
|
.and_then(|v| v.split_whitespace().next()?.parse().ok());
|
||||||
if let (Some(full), Some(design)) = (full, design) {
|
if let (Some(full), Some(design)) = (full, design) {
|
||||||
if design > 0.0 {
|
if design > 0.0 {
|
||||||
rows.push(("Battery health".to_string(), format!("{:.0}% of design capacity", full / design * 100.0)));
|
rows.push((
|
||||||
|
"Battery health".to_string(),
|
||||||
|
format!("{:.0}% of design capacity", full / design * 100.0),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows
|
rows
|
||||||
|
|
@ -64,12 +79,19 @@ async fn power_source() -> String {
|
||||||
async fn tlp_profile() -> Option<String> {
|
async fn tlp_profile() -> Option<String> {
|
||||||
let out = Command::new("tlp-stat").arg("-s").output().await.ok()?;
|
let out = Command::new("tlp-stat").arg("-s").output().await.ok()?;
|
||||||
let text = String::from_utf8_lossy(&out.stdout);
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
text.lines().find(|l| l.trim_start().starts_with("TLP profile")).and_then(|l| l.split('=').nth(1)).map(|v| v.trim().to_string())
|
text.lines()
|
||||||
|
.find(|l| l.trim_start().starts_with("TLP profile"))
|
||||||
|
.and_then(|l| l.split('=').nth(1))
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn brightness_device() -> Option<String> {
|
async fn brightness_device() -> Option<String> {
|
||||||
let out = Command::new("brightnessctl").output().await.ok()?;
|
let out = Command::new("brightnessctl").output().await.ok()?;
|
||||||
String::from_utf8_lossy(&out.stdout).lines().find(|l| l.starts_with("Device")).and_then(|l| l.split('\'').nth(1)).map(str::to_string)
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("Device"))
|
||||||
|
.and_then(|l| l.split('\'').nth(1))
|
||||||
|
.map(str::to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn brightness_pct() -> Option<u32> {
|
async fn brightness_pct() -> Option<u32> {
|
||||||
|
|
@ -98,7 +120,10 @@ fn charge_threshold_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)>
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_threshold(path: &std::path::Path) -> i64 {
|
fn read_threshold(path: &std::path::Path) -> i64 {
|
||||||
std::fs::read_to_string(path).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(100)
|
std::fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.trim().parse().ok())
|
||||||
|
.unwrap_or(100)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -130,16 +155,57 @@ pub async fn set_brightness(percent: i64) -> Result<(), String> {
|
||||||
return Err("No controllable backlight found".into());
|
return Err("No controllable backlight found".into());
|
||||||
};
|
};
|
||||||
let pct = format!("{percent}%");
|
let pct = format!("{percent}%");
|
||||||
Command::new("brightnessctl").args(["--device", &device, "set", &pct]).status().await.map_err(|e| e.to_string())?;
|
Command::new("brightnessctl")
|
||||||
|
.args(["--device", &device, "set", &pct])
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn charge_threshold_write(which: &str, percent: i64) -> Result<(String, i64), String> {
|
||||||
|
if which != "start" && which != "end" {
|
||||||
|
return Err("threshold must be start or end".into());
|
||||||
|
}
|
||||||
|
Ok((which.to_string(), percent.clamp(0, 100)))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_charge_threshold(which: String, percent: i64) -> Result<(), String> {
|
pub async fn set_charge_threshold(which: String, percent: i64) -> Result<(), String> {
|
||||||
|
let (which, percent) = charge_threshold_write(&which, percent)?;
|
||||||
let Some((start, end)) = charge_threshold_paths() else {
|
let Some((start, end)) = charge_threshold_paths() else {
|
||||||
return Err("No charge threshold support on this hardware".into());
|
return Err("No charge threshold support on this hardware".into());
|
||||||
};
|
};
|
||||||
let path = if which == "start" { start } else { end };
|
let path = if which == "start" { start } else { end };
|
||||||
Command::new("pkexec").args(["tee", &path.display().to_string()]).arg(percent.to_string()).output().await.map_err(|e| e.to_string())?;
|
// GNU tee writes stdin to its path operands — the percent must be piped,
|
||||||
|
// not passed as a second path argument.
|
||||||
|
let input = format!("{percent}\n");
|
||||||
|
if util::run_with_stdin(&["pkexec", "tee", &path.display().to_string()], &input).await {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Failed to set charge threshold".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn charge_threshold_clamps_and_restricts_which() {
|
||||||
|
assert_eq!(
|
||||||
|
charge_threshold_write("start", 80).unwrap(),
|
||||||
|
("start".into(), 80)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
charge_threshold_write("end", 150).unwrap(),
|
||||||
|
("end".into(), 100)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
charge_threshold_write("start", -5).unwrap(),
|
||||||
|
("start".into(), 0)
|
||||||
|
);
|
||||||
|
assert!(charge_threshold_write("both", 50).is_err());
|
||||||
|
assert!(charge_threshold_write("-start", 50).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,19 @@ pub enum ServiceAction {
|
||||||
Restart,
|
Restart,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Units the frontend already hardcodes in ServiceControl call sites.
|
||||||
|
const ALLOWED_UNITS: &[&str] = &[
|
||||||
|
"breadd.service",
|
||||||
|
"breadclipd.service",
|
||||||
|
"breadcrumbs.service",
|
||||||
|
"breadmill.service",
|
||||||
|
"breadbox-sync.service",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn allowed_unit(unit: &str) -> bool {
|
||||||
|
ALLOWED_UNITS.contains(&unit)
|
||||||
|
}
|
||||||
|
|
||||||
async fn systemctl_active(unit: &str) -> bool {
|
async fn systemctl_active(unit: &str) -> bool {
|
||||||
Command::new("systemctl")
|
Command::new("systemctl")
|
||||||
.args(["--user", "is-active", "--quiet", unit])
|
.args(["--user", "is-active", "--quiet", unit])
|
||||||
|
|
@ -40,15 +53,21 @@ async fn systemctl_enabled(unit: &str) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_service_status(unit: String) -> ServiceStatus {
|
pub async fn get_service_status(unit: String) -> Result<ServiceStatus, String> {
|
||||||
ServiceStatus {
|
if !allowed_unit(&unit) {
|
||||||
|
return Err("unknown service".into());
|
||||||
|
}
|
||||||
|
Ok(ServiceStatus {
|
||||||
active: systemctl_active(&unit).await,
|
active: systemctl_active(&unit).await,
|
||||||
enabled: systemctl_enabled(&unit).await,
|
enabled: systemctl_enabled(&unit).await,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), String> {
|
pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), String> {
|
||||||
|
if !allowed_unit(&unit) {
|
||||||
|
return Err("unknown service".into());
|
||||||
|
}
|
||||||
let verb = match action {
|
let verb = match action {
|
||||||
ServiceAction::Start => "start",
|
ServiceAction::Start => "start",
|
||||||
ServiceAction::Stop => "stop",
|
ServiceAction::Stop => "stop",
|
||||||
|
|
@ -70,8 +89,31 @@ pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), S
|
||||||
/// panel, no reason to pull an open-ended `journalctl -f` tail into the
|
/// panel, no reason to pull an open-ended `journalctl -f` tail into the
|
||||||
/// webview.
|
/// webview.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn open_logs(unit: String) {
|
pub fn open_logs(unit: String) -> Result<(), String> {
|
||||||
let _ = std::process::Command::new("kitty")
|
if !allowed_unit(&unit) {
|
||||||
.args(["-e", "journalctl", "--user", "-u", &unit, "-f"])
|
return Err("unknown service".into());
|
||||||
.spawn();
|
}
|
||||||
|
std::process::Command::new("kitty")
|
||||||
|
.args(["-e", "journalctl", "--user", "-u", &unit, "-f"])
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn units_match_frontend_hardcoded_list() {
|
||||||
|
assert!(allowed_unit("breadd.service"));
|
||||||
|
assert!(allowed_unit("breadclipd.service"));
|
||||||
|
assert!(allowed_unit("breadcrumbs.service"));
|
||||||
|
assert!(allowed_unit("breadmill.service"));
|
||||||
|
assert!(allowed_unit("breadbox-sync.service"));
|
||||||
|
assert!(!allowed_unit("sshd.service"));
|
||||||
|
assert!(!allowed_unit("breadd.service;reboot"));
|
||||||
|
assert!(!allowed_unit("../sshd.service"));
|
||||||
|
assert!(!allowed_unit("-u sshd"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@
|
||||||
//! `pkexec`.
|
//! `pkexec`.
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use super::util;
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct Account {
|
pub struct Account {
|
||||||
username: String,
|
username: String,
|
||||||
|
|
@ -26,10 +27,16 @@ fn list_accounts() -> Vec<Account> {
|
||||||
let shell = f[6];
|
let shell = f[6];
|
||||||
// Real human accounts: normal UID range, a real login shell
|
// Real human accounts: normal UID range, a real login shell
|
||||||
// (excludes system/service accounts like greeter, avahi, etc).
|
// (excludes system/service accounts like greeter, avahi, etc).
|
||||||
if !(1000..60000).contains(&uid) || shell.ends_with("nologin") || shell.ends_with("/false") {
|
if !(1000..60000).contains(&uid)
|
||||||
|
|| shell.ends_with("nologin")
|
||||||
|
|| shell.ends_with("/false")
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(Account { username: f[0].to_string(), full_name: f[4].split(',').next().unwrap_or("").to_string() })
|
Some(Account {
|
||||||
|
username: f[0].to_string(),
|
||||||
|
full_name: f[4].split(',').next().unwrap_or("").to_string(),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
@ -42,29 +49,69 @@ pub struct UsersInfo {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_users_info() -> UsersInfo {
|
pub fn get_users_info() -> UsersInfo {
|
||||||
UsersInfo { accounts: list_accounts(), current_user: std::env::var("USER").unwrap_or_default() }
|
UsersInfo {
|
||||||
|
accounts: list_accounts(),
|
||||||
|
current_user: std::env::var("USER").unwrap_or_default(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a root command that needs a line of input on stdin (chpasswd's own
|
/// shadow-utils `USER_NAME_MAX` is 32; keep chpasswd/useradd operands inside it.
|
||||||
/// "user:password" format). `pkexec` inherits the spawning process's stdin
|
const USERNAME_MAX: usize = 32;
|
||||||
/// only when explicitly piped, so this pipes it through.
|
|
||||||
async fn run_with_stdin(args: &[&str], input: String) -> bool {
|
/// `[a-z_][a-z0-9_-]*`, length-capped, no leading `-`. Also rejects `:`,
|
||||||
let Ok(mut child) = Command::new(args[0]).args(&args[1..]).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
|
/// newlines, and other chpasswd field/line separators.
|
||||||
else {
|
fn valid_username(name: &str) -> bool {
|
||||||
return false;
|
let bytes = name.as_bytes();
|
||||||
};
|
if bytes.is_empty() || bytes.len() > USERNAME_MAX {
|
||||||
if let Some(mut stdin) = child.stdin.take() {
|
|
||||||
if stdin.write_all(input.as_bytes()).await.is_err() {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
let first = bytes[0];
|
||||||
|
if first != b'_' && !first.is_ascii_lowercase() {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
child.wait().await.map(|s| s.success()).unwrap_or(false)
|
bytes[1..]
|
||||||
|
.iter()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(*b, b'_' | b'-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// chpasswd reads `user:password` lines — a `:`, `\n`, or `\r` in either
|
||||||
|
/// field injects extra passwd entries or shifts columns.
|
||||||
|
fn valid_chpasswd_password(password: &str) -> bool {
|
||||||
|
!password.is_empty()
|
||||||
|
&& password.len() <= 512
|
||||||
|
&& !password.contains('\n')
|
||||||
|
&& !password.contains('\r')
|
||||||
|
&& !password.contains(':')
|
||||||
|
&& !password.contains('\0')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chpasswd_input(username: &str, password: &str) -> Result<String, String> {
|
||||||
|
if !valid_username(username) {
|
||||||
|
return Err("invalid username".into());
|
||||||
|
}
|
||||||
|
if !valid_chpasswd_password(password) {
|
||||||
|
return Err("invalid password".into());
|
||||||
|
}
|
||||||
|
Ok(format!("{username}:{password}\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn may_delete_user(username: &str, current: &str) -> Result<(), String> {
|
||||||
|
if !valid_username(username) {
|
||||||
|
return Err("invalid username".into());
|
||||||
|
}
|
||||||
|
if username == "root" {
|
||||||
|
return Err("refusing to remove root".into());
|
||||||
|
}
|
||||||
|
if !current.is_empty() && username == current {
|
||||||
|
return Err("refusing to remove the current user".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn change_password(username: String, password: String) -> Result<(), String> {
|
pub async fn change_password(username: String, password: String) -> Result<(), String> {
|
||||||
let input = format!("{username}:{password}\n");
|
let input = chpasswd_input(&username, &password)?;
|
||||||
if run_with_stdin(&["pkexec", "chpasswd"], input).await {
|
if util::run_with_stdin(&["pkexec", "chpasswd"], &input).await {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err("Failed to change password".into())
|
Err("Failed to change password".into())
|
||||||
|
|
@ -73,7 +120,13 @@ pub async fn change_password(username: String, password: String) -> Result<(), S
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn remove_user(username: String) -> Result<(), String> {
|
pub async fn remove_user(username: String) -> Result<(), String> {
|
||||||
let output = Command::new("pkexec").args(["userdel", "-r", &username]).output().await.map_err(|e| e.to_string())?;
|
let current = std::env::var("USER").unwrap_or_default();
|
||||||
|
may_delete_user(&username, ¤t)?;
|
||||||
|
let output = Command::new("pkexec")
|
||||||
|
.args(["userdel", "-r", &username])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -83,22 +136,68 @@ pub async fn remove_user(username: String) -> Result<(), String> {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn add_user(username: String, full_name: String, password: String) -> Result<(), String> {
|
pub async fn add_user(username: String, full_name: String, password: String) -> Result<(), String> {
|
||||||
let username = username.trim().to_string();
|
let username = username.trim();
|
||||||
let mut useradd_args = vec!["pkexec".to_string(), "useradd".to_string(), "-m".to_string(), "-s".to_string(), "/bin/bash".to_string()];
|
let input = chpasswd_input(username, &password)?;
|
||||||
|
let mut useradd_args = vec![
|
||||||
|
"pkexec".to_string(),
|
||||||
|
"useradd".to_string(),
|
||||||
|
"-m".to_string(),
|
||||||
|
"-s".to_string(),
|
||||||
|
"/bin/bash".to_string(),
|
||||||
|
];
|
||||||
if !full_name.trim().is_empty() {
|
if !full_name.trim().is_empty() {
|
||||||
useradd_args.push("-c".to_string());
|
useradd_args.push("-c".to_string());
|
||||||
useradd_args.push(full_name.trim().to_string());
|
useradd_args.push(full_name.trim().to_string());
|
||||||
}
|
}
|
||||||
useradd_args.push(username.clone());
|
useradd_args.push(username.to_string());
|
||||||
let args_ref: Vec<&str> = useradd_args.iter().map(String::as_str).collect();
|
let args_ref: Vec<&str> = useradd_args.iter().map(String::as_str).collect();
|
||||||
let output = Command::new(args_ref[0]).args(&args_ref[1..]).output().await.map_err(|e| e.to_string())?;
|
let output = Command::new(args_ref[0])
|
||||||
|
.args(&args_ref[1..])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
|
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
|
||||||
}
|
}
|
||||||
let input = format!("{username}:{password}\n");
|
if util::run_with_stdin(&["pkexec", "chpasswd"], &input).await {
|
||||||
if run_with_stdin(&["pkexec", "chpasswd"], input).await {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err("User created, but setting the password failed.".into())
|
Err("User created, but setting the password failed.".into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chpasswd_rejects_newline_injection() {
|
||||||
|
assert!(chpasswd_input("alice", "pw\nroot:evil").is_err());
|
||||||
|
assert!(chpasswd_input("alice\nroot", "pw").is_err());
|
||||||
|
assert!(chpasswd_input("alice\rroot", "pw").is_err());
|
||||||
|
assert!(chpasswd_input("alice", "pw\rroot:x").is_err());
|
||||||
|
assert!(chpasswd_input("al:ice", "pw").is_err());
|
||||||
|
assert!(chpasswd_input("alice", "p:w").is_err());
|
||||||
|
assert_eq!(chpasswd_input("alice", "secret").unwrap(), "alice:secret\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn username_grammar() {
|
||||||
|
assert!(valid_username("alice"));
|
||||||
|
assert!(valid_username("_svc"));
|
||||||
|
assert!(valid_username("a1-b_c"));
|
||||||
|
assert!(!valid_username(""));
|
||||||
|
assert!(!valid_username("-alice"));
|
||||||
|
assert!(!valid_username("Alice"));
|
||||||
|
assert!(!valid_username("root user"));
|
||||||
|
assert!(!valid_username(&"a".repeat(USERNAME_MAX + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_user_refuses_root_and_self() {
|
||||||
|
assert!(may_delete_user("root", "alice").is_err());
|
||||||
|
assert!(may_delete_user("alice", "alice").is_err());
|
||||||
|
assert!(may_delete_user("root\n", "alice").is_err());
|
||||||
|
assert!(may_delete_user("bob", "alice").is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,30 @@ pub fn bos_settings_dir() -> PathBuf {
|
||||||
config::config_dir().join("bos-settings")
|
config::config_dir().join("bos-settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pipe `input` to a command's stdin (`pkexec` does not inherit a piped
|
||||||
|
/// stdin unless we set it). Used by chpasswd and `pkexec tee`.
|
||||||
|
pub async fn run_with_stdin(args: &[&str], input: &str) -> bool {
|
||||||
|
if args.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(mut child) = tokio::process::Command::new(args[0])
|
||||||
|
.args(&args[1..])
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
if stdin.write_all(input.as_bytes()).await.is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.wait().await.map(|s| s.success()).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Atomic write with mode 0600 set on the new inode before/after replace,
|
/// Atomic write with mode 0600 set on the new inode before/after replace,
|
||||||
/// matching breadcrumbs' `networks.toml` care.
|
/// matching breadcrumbs' `networks.toml` care.
|
||||||
pub fn write_secure(path: &Path, contents: &str) -> Result<(), String> {
|
pub fn write_secure(path: &Path, contents: &str) -> Result<(), String> {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue