From 25d4418aa11437227a578333e40efb12dd177d26 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 25 Jun 2026 21:15:49 +0200 Subject: [PATCH 01/11] Add UI for sensors management --- Cargo.lock | 1 + Cargo.toml | 1 + src/ui/alert.rs | 2 +- src/ui/mod.rs | 305 +++++++++++++++++++++++++++++++++++++----- src/ui/repomanager.rs | 244 ++++++++++++++++++++++++++++++++- src/ui/statusbar.rs | 8 +- 6 files changed, 521 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ce0f43e..8fdaa193 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8598,6 +8598,7 @@ dependencies = [ "libeventreg", "libmodcore", "libmodpak", + "libsensors", "libsetup", "libsysinspect", "libsysproto", diff --git a/Cargo.toml b/Cargo.toml index 1e85437c..d3b0be1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ libmodcore = { path = "./libmodcore" } libcommon = { path = "./libcommon" } libsysproto = { path = "./libsysproto" } libsetup = { path = "./libsetup" } +libsensors = { path = "./libsensors" } log = "0.4.29" sysinfo = { version = "0.33.1", features = ["linux-tmpfs"] } tokio = { version = "1.52.3", features = ["full"] } diff --git a/src/ui/alert.rs b/src/ui/alert.rs index 1a02fbbf..c207ae6f 100644 --- a/src/ui/alert.rs +++ b/src/ui/alert.rs @@ -201,7 +201,7 @@ impl SysInspectUX { Self::help_line("Ctrl+R", "Open the registration form to add a new machine."), Line::from(""), Self::help_line("Ctrl+A", "Open the Artefacts Manager: modules, libraries,"), - Self::help_line("", "models, profiles, platform builds."), + Self::help_line("", "models, sensors, profiles, platform builds."), Line::from(""), Self::help_line("Ctrl+U", "Run a cluster upgrade across your machines."), Line::from(""), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 77bcff88..1def4175 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -13,6 +13,7 @@ use libeventreg::{ }; use libmodcore::modinit::ModInterface; use libmodpak::{SysInspectModPak, mpk::ModPakMetadata}; +use libsensors::sspec::SensorConf; use libsysinspect::{ cfg::mmconf::{ConsoleConfig, MasterConfig, MinionConfig}, console::{ConsoleMinionInfoRow, ConsoleModelRow, ConsoleModuleRow, ConsoleOnlineMinionRow, ConsolePayload}, @@ -40,7 +41,7 @@ use ratatui_glamour::widgets::spinner; use std::{ cell::{Cell, RefCell}, io::{self}, - path::PathBuf, + path::{Path, PathBuf}, sync::Arc, time::{Duration, Instant}, }; @@ -993,7 +994,8 @@ impl SysInspectUX { 0 => self.process_module_add(&path), 1 => self.process_library_add(&path), 2 => self.process_model_add(&path), - 4 => self.process_platform_add(&path), + 3 => self.process_sensor_add(&path), + 5 => self.process_platform_add(&path), _ => {} } } @@ -1006,8 +1008,9 @@ impl SysInspectUX { self.repo_manager.needs_reload = false; let _ = self.load_module_index(); let _ = self.load_model_list(); + let _ = self.load_sensor_list(); let _ = self.load_library_index(); - if self.repo_manager.active_tab == 4 { + if self.repo_manager.active_tab == 5 { let _ = self.load_platforms(); } self.repo_manager.profiles.has_global_modules.set(self.repo_manager.module_groups.values().any(|v| !v.is_empty())); @@ -2711,7 +2714,7 @@ impl SysInspectUX { return self.repo_manager.handle_info_key(e); } if self.repo_manager.model_delete_visible { - let handled = self.repo_manager.handle_model_delete_key(e); + let handled = self.repo_manager.handle_model_delete_key(e.code); if !handled && e.code == KeyCode::Enter { if self.repo_manager.model_delete_focus == repomanager::ModelDeleteFocus::YesBtn { let model_id = self.repo_manager.model_delete_id.clone(); @@ -2768,8 +2771,8 @@ impl SysInspectUX { } return true; } - // Profile-specific overlays (tab 3) - if self.repo_manager.active_tab == 3 { + // Profile-specific overlays (tab 4) + if self.repo_manager.active_tab == 4 { if self.repo_manager.profiles.delete_visible { let handled = self.repo_manager.profiles.handle_delete_key(e.code); if !handled && e.code == KeyCode::Enter { @@ -2893,8 +2896,25 @@ impl SysInspectUX { return true; } } - // Platform delete overlay (tab 4) - if self.repo_manager.active_tab == 4 && self.repo_manager.platforms.delete_visible { + if self.repo_manager.active_tab == 3 && self.repo_manager.sensor_delete_visible { + let handled = self.repo_manager.handle_sensor_delete_key(e.code); + if !handled && e.code == KeyCode::Enter { + if self.repo_manager.sensor_delete_focus == repomanager::SensorDeleteFocus::YesBtn { + let sensor_id = self.repo_manager.sensor_delete_id.clone(); + match self.delete_sensor(&sensor_id) { + Ok(()) => self.start_cluster_sync(), + Err(err) => { + self.error_alert_visible = true; + self.error_alert_message = err; + } + } + } + self.repo_manager.sensor_delete_visible = false; + } + return true; + } + // Platform delete overlay (tab 5) + if self.repo_manager.active_tab == 5 && self.repo_manager.platforms.delete_visible { let handled = self.repo_manager.platforms.handle_delete_key(e.code); if !handled && e.code == KeyCode::Enter { if self.repo_manager.platforms.delete_focus == platforms::DeleteFocus::YesBtn { @@ -2905,10 +2925,12 @@ impl SysInspectUX { } return true; } - let total_count = if self.repo_manager.active_tab == 4 { + let total_count = if self.repo_manager.active_tab == 5 { self.repo_manager.platforms.filtered_count(self.repo_manager.filter.value()) - } else if self.repo_manager.active_tab == 3 { + } else if self.repo_manager.active_tab == 4 { self.repo_manager.profiles.filtered_count(self.repo_manager.filter.value()) + } else if self.repo_manager.active_tab == 3 { + self.repo_filtered_sensor_count() } else if self.repo_manager.active_tab == 2 { self.repo_filtered_model_count() } else if self.repo_manager.active_tab == 1 { @@ -2924,10 +2946,12 @@ impl SysInspectUX { self.repo_filtered_count() }; let max_cursor = total_count.saturating_sub(1); - let cursor_ref: &mut usize = if self.repo_manager.active_tab == 4 { + let cursor_ref: &mut usize = if self.repo_manager.active_tab == 5 { &mut self.repo_manager.platforms.cursor - } else if self.repo_manager.active_tab == 3 { + } else if self.repo_manager.active_tab == 4 { &mut self.repo_manager.profiles.cursor + } else if self.repo_manager.active_tab == 3 { + &mut self.repo_manager.sensor_cursor } else if self.repo_manager.active_tab == 2 { &mut self.repo_manager.model_cursor } else if self.repo_manager.active_tab == 1 { @@ -2961,6 +2985,7 @@ impl SysInspectUX { self.repo_manager.group_cursor_row = 0; self.repo_manager.lib_cursor = 0; self.repo_manager.model_cursor = 0; + self.repo_manager.sensor_cursor = 0; self.repo_manager.profiles.cursor = 0; self.repo_manager.platforms.cursor = 0; if self.repo_manager.active_tab == 1 { @@ -2970,18 +2995,22 @@ impl SysInspectUX { let _ = self.load_model_list(); } if self.repo_manager.active_tab == 3 { - let _ = self.load_profile_list(); + let _ = self.load_sensor_list(); } if self.repo_manager.active_tab == 4 { + let _ = self.load_profile_list(); + } + if self.repo_manager.active_tab == 5 { let _ = self.load_platforms(); } } KeyCode::Right => { - self.repo_manager.active_tab = (self.repo_manager.active_tab + 1).min(4); + self.repo_manager.active_tab = (self.repo_manager.active_tab + 1).min(5); self.repo_manager.group_cursor = 0; self.repo_manager.group_cursor_row = 0; self.repo_manager.lib_cursor = 0; self.repo_manager.model_cursor = 0; + self.repo_manager.sensor_cursor = 0; self.repo_manager.profiles.cursor = 0; self.repo_manager.platforms.cursor = 0; if self.repo_manager.active_tab == 1 { @@ -2991,19 +3020,22 @@ impl SysInspectUX { let _ = self.load_model_list(); } if self.repo_manager.active_tab == 3 { - let _ = self.load_profile_list(); + let _ = self.load_sensor_list(); } if self.repo_manager.active_tab == 4 { + let _ = self.load_profile_list(); + } + if self.repo_manager.active_tab == 5 { let _ = self.load_platforms(); } } KeyCode::Up => { if self.repo_manager.active_tab == 0 { self.move_module_up(); - } else if self.repo_manager.active_tab == 3 { + } else if self.repo_manager.active_tab == 4 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); - } else if self.repo_manager.active_tab == 4 { + } else if self.repo_manager.active_tab == 5 { self.repo_manager.platforms.handle_list_key(e.code); } else { *cursor_ref = cursor_ref.saturating_sub(1); @@ -3012,10 +3044,10 @@ impl SysInspectUX { KeyCode::Down => { if self.repo_manager.active_tab == 0 { self.move_module_down(); - } else if self.repo_manager.active_tab == 3 { + } else if self.repo_manager.active_tab == 4 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); - } else if self.repo_manager.active_tab == 4 { + } else if self.repo_manager.active_tab == 5 { self.repo_manager.platforms.handle_list_key(e.code); } else { *cursor_ref = (*cursor_ref + 1).min(max_cursor); @@ -3033,6 +3065,19 @@ impl SysInspectUX { } } } + KeyCode::Char(' ') if self.repo_manager.active_tab == 3 && !self.repo_manager.sensor_rows.is_empty() => { + if let Some(sensor) = self.repo_manager.sensor_rows.get(self.repo_manager.sensor_cursor) { + let sensor_id = sensor.id.clone(); + let enabled = !sensor.enabled; + if let Err(err) = self.set_sensor_enabled(&sensor_id, enabled) { + self.error_alert_visible = true; + self.error_alert_message = err; + } else { + let _ = self.load_sensor_list(); + self.start_cluster_sync(); + } + } + } KeyCode::PageUp => { if self.repo_manager.active_tab == 0 { let n = self.repo_manager.group_order.len(); @@ -3040,10 +3085,10 @@ impl SysInspectUX { self.repo_manager.group_cursor = (self.repo_manager.group_cursor + n - 1) % n; self.repo_manager.group_cursor_row = 0; } - } else if self.repo_manager.active_tab == 3 { + } else if self.repo_manager.active_tab == 4 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); - } else if self.repo_manager.active_tab == 4 { + } else if self.repo_manager.active_tab == 5 { self.repo_manager.platforms.handle_list_key(e.code); } else { *cursor_ref = cursor_ref.saturating_sub(page); @@ -3056,19 +3101,19 @@ impl SysInspectUX { self.repo_manager.group_cursor = (self.repo_manager.group_cursor + 1) % n; self.repo_manager.group_cursor_row = 0; } - } else if self.repo_manager.active_tab == 3 { + } else if self.repo_manager.active_tab == 4 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); - } else if self.repo_manager.active_tab == 4 { + } else if self.repo_manager.active_tab == 5 { self.repo_manager.platforms.handle_list_key(e.code); } else { *cursor_ref = (*cursor_ref + page).min(max_cursor); } } KeyCode::Enter => { - if self.repo_manager.active_tab == 4 { + if self.repo_manager.active_tab == 5 { // Platforms have no detail view - } else if self.repo_manager.active_tab == 3 { + } else if self.repo_manager.active_tab == 4 { let name = match self.repo_manager.profiles.selected_profile_name() { Some(n) => n.to_string(), None => return true, @@ -3115,15 +3160,19 @@ impl SysInspectUX { } } KeyCode::Delete => { - if self.repo_manager.active_tab == 4 { + if self.repo_manager.active_tab == 5 { if let Some(name) = self.repo_manager.platforms.selected_name() { self.repo_manager.platforms.open_delete(name); } - } else if self.repo_manager.active_tab == 3 { + } else if self.repo_manager.active_tab == 4 { if let Some(name) = self.repo_manager.profiles.selected_profile_name() { self.repo_manager.profiles.open_delete(name.to_string()); self.status_at_profiles(); } + } else if self.repo_manager.active_tab == 3 { + if let Some(sensor) = self.repo_manager.sensor_rows.get(self.repo_manager.sensor_cursor) { + self.repo_manager.open_sensor_delete(sensor.id.clone()); + } } else if self.repo_manager.active_tab == 2 { if let Some(model) = self.repo_manager.model_rows.get(self.repo_manager.model_cursor) { self.repo_manager.open_model_delete(model.id.clone()); @@ -3180,11 +3229,14 @@ impl SysInspectUX { } } KeyCode::Insert | KeyCode::Char('i') if !e.modifiers.contains(KeyModifiers::CONTROL) => { - if self.repo_manager.active_tab == 4 { + if self.repo_manager.active_tab == 5 { self.file_picker.open(&std::env::current_dir().unwrap_or_default(), filepicker::PickerMode::MinionBuild); - } else if self.repo_manager.active_tab == 3 { + } else if self.repo_manager.active_tab == 4 { self.repo_manager.profiles.open_create(); self.status_at_profiles(); + } else if self.repo_manager.active_tab == 3 { + let start_dir = std::env::current_dir().unwrap_or_default(); + self.file_picker.open(&start_dir, filepicker::PickerMode::DirectoryPicker); } else if self.repo_manager.active_tab == 2 { let start_dir = std::env::current_dir().unwrap_or_default(); self.file_picker.open(&start_dir, filepicker::PickerMode::DirectoryPicker); @@ -3194,7 +3246,7 @@ impl SysInspectUX { } } KeyCode::Char('l') if !e.modifiers.contains(KeyModifiers::CONTROL) => { - if self.repo_manager.active_tab == 3 { + if self.repo_manager.active_tab == 4 { self.repo_manager.profiles.open_create(); self.status_at_profiles(); } else { @@ -3274,6 +3326,15 @@ impl SysInspectUX { self.repo_manager.model_rows.iter().filter(|r| f.is_empty() || r.name.to_lowercase().contains(&f) || r.id.to_lowercase().contains(&f)).count() } + fn repo_filtered_sensor_count(&self) -> usize { + let f = self.repo_manager.filter.value().to_lowercase(); + self.repo_manager + .sensor_rows + .iter() + .filter(|r| f.is_empty() || r.id.to_lowercase().contains(&f) || r.description.to_lowercase().contains(&f)) + .count() + } + fn call_profile_rpc(&self, context: &str) -> Result { let ctx = context.to_string(); let resp = tokio::task::block_in_place(|| { @@ -3519,12 +3580,23 @@ impl SysInspectUX { } } + fn load_sensor_list(&mut self) -> Result<(), String> { + let enabled = if self.repo_manager.sensor_rows.is_empty() { self.cfg.fileserver_sensors() } else { self.enabled_sensor_ids() }; + self.refresh_local_sensor_rows(&enabled) + } + fn model_dropin_path(&self) -> PathBuf { let cfg_path = self.cfg.config_path(); let stem = cfg_path.file_stem().unwrap_or_default().to_string_lossy().to_string(); cfg_path.with_file_name(format!("{stem}.d")).join("99-models.conf") } + fn sensor_dropin_path(&self) -> PathBuf { + let cfg_path = self.cfg.config_path(); + let stem = cfg_path.file_stem().unwrap_or_default().to_string_lossy().to_string(); + cfg_path.with_file_name(format!("{stem}.d")).join("99-sensors.conf") + } + fn system_top_dropin_path(&self) -> PathBuf { let cfg_path = self.cfg.config_path(); let stem = cfg_path.file_stem().unwrap_or_default().to_string_lossy().to_string(); @@ -3582,6 +3654,108 @@ impl SysInspectUX { std::fs::write(&dropin, body).map_err(|e| format!("Unable to write models drop-in: {e}")) } + fn enabled_sensor_ids(&self) -> Vec { + let mut ids: Vec = self.repo_manager.sensor_rows.iter().filter(|row| row.enabled).map(|row| row.id.clone()).collect(); + ids.sort(); + ids.dedup(); + ids + } + + fn enabled_sensor_ids_with(&self, sensor_id: &str, enabled: bool) -> Vec { + let mut ids = self.enabled_sensor_ids(); + if enabled { + if !ids.iter().any(|id| id == sensor_id) { + ids.push(sensor_id.to_string()); + } + } else { + ids.retain(|id| id != sensor_id); + } + ids.sort(); + ids.dedup(); + ids + } + + fn write_enabled_sensors_dropin(&self, mut ids: Vec) -> Result<(), String> { + ids.sort(); + ids.dedup(); + let dropin = self.sensor_dropin_path(); + if let Some(parent) = dropin.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("Unable to create drop-in directory: {e}"))?; + } + let mut body = String::from("config:\n master:\n fileserver.sensors:"); + if ids.is_empty() { + body.push_str(" []\n"); + } else { + body.push('\n'); + for id in ids { + body.push_str(&format!(" - {id}\n")); + } + } + std::fs::write(&dropin, body).map_err(|e| format!("Unable to write sensors drop-in: {e}")) + } + + fn discover_sensor_scope_dirs(root: &Path) -> Result, String> { + fn walk(dir: &Path, out: &mut Vec) -> Result<(), String> { + if dir.join("sensors.cfg").exists() { + out.push(dir.to_path_buf()); + return Ok(()); + } + let entries = std::fs::read_dir(dir).map_err(|e| format!("Unable to read {}: {e}", dir.display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("Unable to read directory entry in {}: {e}", dir.display()))?; + let path = entry.path(); + if path.is_dir() { + walk(&path, out)?; + } + } + Ok(()) + } + + let mut out = Vec::new(); + if root.exists() { + walk(root, &mut out)?; + } + out.sort(); + Ok(out) + } + + fn describe_sensor_scope(path: &Path) -> Result<(String, usize), String> { + let spec = libsensors::load(path).map_err(|e| format!("Failed to load sensor scope {}: {e}", path.display()))?; + let items: Vec<(&String, &SensorConf)> = spec.items_raw().iter().collect(); + let sensor_count = items.len(); + let description = items + .iter() + .find_map(|(_, cfg)| cfg.description().map(str::trim).filter(|s| !s.is_empty()).map(ToOwned::to_owned)) + .unwrap_or_else(|| { + if sensor_count == 0 { + "No sensors defined".to_string() + } else if sensor_count == 1 { + items[0].1.listener().to_string() + } else { + format!("{} sensors", sensor_count) + } + }); + Ok((description, sensor_count)) + } + + fn refresh_local_sensor_rows(&mut self, enabled_ids: &[String]) -> Result<(), String> { + let enabled: std::collections::BTreeSet = enabled_ids.iter().cloned().collect(); + let mut rows = Vec::new(); + for scope in Self::discover_sensor_scope_dirs(&self.cfg.fileserver_sensors_root())? { + let id = match scope.file_name().and_then(|s| s.to_str()) { + Some(id) if !id.is_empty() => id.to_string(), + _ => continue, + }; + let (description, sensor_count) = Self::describe_sensor_scope(&scope)?; + rows.push(repomanager::SensorScopeRow { id: id.clone(), enabled: enabled.contains(&id), description, sensor_count }); + } + rows.sort_by(|a, b| a.id.cmp(&b.id)); + let cursor = self.repo_manager.sensor_cursor.min(rows.len().saturating_sub(1)); + self.repo_manager.sensor_rows = rows; + self.repo_manager.sensor_cursor = cursor; + Ok(()) + } + fn refresh_local_model_rows(&mut self, enabled_ids: &[String]) -> Result<(), String> { let mut minion_cfg = MinionConfig::default(); let root = self.cfg.fileserver_root().to_str().unwrap_or("/etc/sysinspect").to_string(); @@ -3697,6 +3871,14 @@ impl SysInspectUX { Ok(()) } + fn set_sensor_enabled(&mut self, sensor_id: &str, enabled: bool) -> Result<(), String> { + let ids = self.enabled_sensor_ids_with(sensor_id, enabled); + self.write_enabled_sensors_dropin(ids)?; + self.reload_master_config()?; + self.refresh_local_sensor_rows(&self.enabled_sensor_ids_with(sensor_id, enabled))?; + Ok(()) + } + fn enabled_model_ids_with(&self, model_id: &str, enabled: bool) -> Vec { let mut ids = self.enabled_model_ids(); if enabled { @@ -3756,6 +3938,47 @@ impl SysInspectUX { } } + fn process_sensor_add(&mut self, path: &std::path::Path) { + if !path.is_dir() { + self.error_alert_visible = true; + self.error_alert_message = "Select a sensor scope directory".to_string(); + return; + } + if !path.join("sensors.cfg").exists() { + self.error_alert_visible = true; + self.error_alert_message = "Selected directory does not contain sensors.cfg".to_string(); + return; + } + let sensor_id = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + if sensor_id.is_empty() { + self.error_alert_visible = true; + self.error_alert_message = "Unable to determine sensor scope id from directory name".to_string(); + return; + } + let dst_root = self.cfg.fileserver_sensors_root(); + let dst = dst_root.join(&sensor_id); + if dst.exists() { + self.error_alert_visible = true; + self.error_alert_message = format!("Sensor scope already exists: {sensor_id}"); + return; + } + let enabled_ids = self.enabled_sensor_ids_with(&sensor_id, true); + match Self::copy_dir_recursive(path, &dst) + .and_then(|_| self.write_enabled_sensors_dropin(enabled_ids.clone())) + .and_then(|_| self.reload_master_config()) + .and_then(|_| self.refresh_local_sensor_rows(&enabled_ids)) + { + Ok(()) => { + self.start_cluster_sync(); + } + Err(err) => { + let _ = std::fs::remove_dir_all(&dst); + self.error_alert_visible = true; + self.error_alert_message = err; + } + } + } + fn delete_model(&mut self, model_id: &str) -> Result<(), String> { let path = self.cfg.fileserver_models_root(false).join(model_id); if !path.exists() { @@ -3769,6 +3992,19 @@ impl SysInspectUX { Ok(()) } + fn delete_sensor(&mut self, sensor_id: &str) -> Result<(), String> { + let path = self.cfg.fileserver_sensors_root().join(sensor_id); + if !path.exists() { + return Err(format!("Sensor scope does not exist: {sensor_id}")); + } + std::fs::remove_dir_all(&path).map_err(|e| format!("Unable to remove sensor scope {sensor_id}: {e}"))?; + let enabled_ids = self.enabled_sensor_ids_with(sensor_id, false); + self.write_enabled_sensors_dropin(enabled_ids.clone())?; + self.reload_master_config()?; + self.refresh_local_sensor_rows(&enabled_ids)?; + Ok(()) + } + fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> { std::fs::create_dir_all(dst).map_err(|e| format!("Unable to create destination {}: {e}", dst.display()))?; let entries = std::fs::read_dir(src).map_err(|e| format!("Unable to read {}: {e}", src.display()))?; @@ -4277,6 +4513,9 @@ impl SysInspectUX { if let Err(err) = self.load_module_index() { self.error_alert_visible = true; self.error_alert_message = err; + } else if let Err(err) = self.load_sensor_list() { + self.error_alert_visible = true; + self.error_alert_message = err; } else { self.repo_manager.visible = true; self.status_at_repo_manager(); @@ -4346,6 +4585,9 @@ impl SysInspectUX { if let Err(err) = self.load_module_index() { self.error_alert_visible = true; self.error_alert_message = err; + } else if let Err(err) = self.load_sensor_list() { + self.error_alert_visible = true; + self.error_alert_message = err; } else { self.repo_manager.visible = true; self.status_at_repo_manager(); @@ -5253,6 +5495,9 @@ impl SysInspectUX { if let Err(err) = self.load_module_index() { self.error_alert_visible = true; self.error_alert_message = err; + } else if let Err(err) = self.load_sensor_list() { + self.error_alert_visible = true; + self.error_alert_message = err; } else { self.repo_manager.visible = true; self.status_at_repo_manager(); diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index a7058530..87488280 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -20,6 +20,14 @@ use std::{ }; use unicode_width::UnicodeWidthStr; +#[derive(Debug, Clone)] +pub struct SensorScopeRow { + pub id: String, + pub enabled: bool, + pub description: String, + pub sensor_count: usize, +} + #[derive(Debug, Clone)] pub struct StagedModule { pub name: String, @@ -56,6 +64,12 @@ pub enum ModelDeleteFocus { NoBtn, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SensorDeleteFocus { + YesBtn, + NoBtn, +} + #[derive(Debug)] pub struct RepoManager { pub visible: bool, @@ -111,6 +125,14 @@ pub struct RepoManager { pub model_delete_id: String, pub model_delete_focus: ModelDeleteFocus, + // Sensors + pub sensor_rows: Vec, + pub sensor_cursor: usize, + pub sensor_scroll: Cell, + pub sensor_delete_visible: bool, + pub sensor_delete_id: String, + pub sensor_delete_focus: SensorDeleteFocus, + // Profiles pub profiles: profiles::ProfilesManager, @@ -159,6 +181,12 @@ impl Default for RepoManager { model_delete_visible: false, model_delete_id: String::new(), model_delete_focus: ModelDeleteFocus::NoBtn, + sensor_rows: Vec::new(), + sensor_cursor: 0, + sensor_scroll: Cell::new(0), + sensor_delete_visible: false, + sensor_delete_id: String::new(), + sensor_delete_focus: SensorDeleteFocus::NoBtn, profiles: profiles::ProfilesManager::default(), platforms: platforms::PlatformsManager::default(), } @@ -374,6 +402,9 @@ impl RepoManager { if self.model_delete_visible { self.render_model_delete(parent, buf); } + if self.sensor_delete_visible { + self.render_sensor_delete(parent, buf); + } if self.staging { self.render_staging(parent, buf); } @@ -421,7 +452,7 @@ impl RepoManager { let inner = block.inner(canvas); block.render(canvas, buf); - let tab_names = ["Modules", "Libraries", "Models", "Profiles", "Platforms"]; + let tab_names = ["Modules", "Libraries", "Models", "Sensors", "Profiles", "Platforms"]; let section_name = tab_names[self.active_tab as usize]; let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); @@ -465,8 +496,9 @@ impl RepoManager { 0 => self.render_modules(body, buf), 1 => self.render_libraries(body, buf), 2 => self.render_models(body, buf), - 3 => self.profiles.render_list(body, buf, self.filter_focus, &self.filter), - 4 => self.platforms.render_list(body, buf, self.filter_focus, &self.filter), + 3 => self.render_sensors(body, buf), + 4 => self.profiles.render_list(body, buf, self.filter_focus, &self.filter), + 5 => self.platforms.render_list(body, buf, self.filter_focus, &self.filter), _ => {} } Self::draw_shadow(buf, canvas, dlg_w, dlg_h); @@ -1065,6 +1097,118 @@ impl RepoManager { } } + fn render_sensors(&self, inner: Rect, buf: &mut Buffer) { + if inner.height < 2 { + return; + } + let [filter_area, list_area] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + Self::render_filter_row(filter_area, buf, self.filter_focus, &self.filter); + if self.sensor_rows.is_empty() { + let msg = "(no sensor scopes found)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + let flt = self.filter.value().to_lowercase(); + let filtered: Vec<(usize, &SensorScopeRow)> = self + .sensor_rows + .iter() + .enumerate() + .filter(|(_, r)| flt.is_empty() || r.id.to_lowercase().contains(&flt) || r.description.to_lowercase().contains(&flt)) + .collect(); + let view_h = list_area.height as usize; + let total = filtered.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.sensor_scroll.get(); + let cursor = self.sensor_cursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + self.sensor_scroll.set(s); + if total == 0 { + let msg = "(no matches)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); + let state_w: u16 = 4; + let name_w: u16 = 18u16.min(list_area.width.saturating_sub(24)); + for i in 0..view_h.min(total.saturating_sub(s)) { + let fi = s + i; + let (_oi, row) = filtered[fi]; + let ry = list_area.y + i as u16; + let sel = !self.filter_focus && fi == cursor; + let row_style = if sel { + hl + } else if row.enabled { + Style::default().fg(palette::FG) + } else { + Style::default().fg(palette::MUTED) + }; + if sel { + for cx in 0..list_area.width { + if let Some(cell) = buf.cell_mut(Position::new(list_area.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + let check_style = if sel { + row_style + } else if row.enabled { + Style::default().fg(palette::SUCCESS_PEAK) + } else { + Style::default().fg(palette::MUTED) + }; + buf.set_string(list_area.x + 1, ry, if row.enabled { "▣" } else { "□" }, check_style); + let name_style = if sel { + row_style + } else if row.enabled { + Style::default().fg(palette::PROCESSING) + } else { + Style::default().fg(palette::MUTED) + }; + buf.set_string(list_area.x + 1 + state_w + 1, ry, truncate_str(&row.id, name_w as usize), name_style); + let descr_style = if sel { + row_style + } else if row.enabled { + Style::default().fg(palette::GRAY_1) + } else { + Style::default().fg(palette::MUTED) + }; + let descr_x = list_area.x + 1 + state_w + 1 + name_w + 1; + let descr_w = list_area.width.saturating_sub(1 + state_w + 1 + name_w + 2); + let suffix = if row.sensor_count > 1 { format!(" ({})", row.sensor_count) } else { String::new() }; + let descr = format!("{}{}", row.description, suffix); + buf.set_string(descr_x, ry, truncate_str(&descr, descr_w as usize), descr_style); + } + if total > view_h { + let bh = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let by = ((s as f64 / total as f64) * (view_h - bh) as f64) as usize; + for i in 0..view_h { + let sx = list_area.right().saturating_sub(1); + let sy = list_area.y + i as u16; + if i >= by && i < by + bh { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + } + fn render_filter_row(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { let label_style = if focused { Style::default().fg(palette::FORM_LABEL).add_modifier(Modifier::BOLD) } else { Style::default().fg(palette::FORM_LABEL) }; @@ -1141,8 +1285,14 @@ impl RepoManager { self.model_delete_visible = true; } - pub fn handle_model_delete_key(&mut self, key: crossterm::event::KeyEvent) -> bool { - match key.code { + pub fn open_sensor_delete(&mut self, sensor_id: String) { + self.sensor_delete_id = sensor_id; + self.sensor_delete_focus = SensorDeleteFocus::NoBtn; + self.sensor_delete_visible = true; + } + + pub fn handle_model_delete_key(&mut self, code: crossterm::event::KeyCode) -> bool { + match code { crossterm::event::KeyCode::Esc => { self.model_delete_visible = false; } @@ -1161,6 +1311,26 @@ impl RepoManager { true } + pub fn handle_sensor_delete_key(&mut self, code: crossterm::event::KeyCode) -> bool { + match code { + crossterm::event::KeyCode::Esc => { + self.sensor_delete_visible = false; + } + crossterm::event::KeyCode::Tab + | crossterm::event::KeyCode::BackTab + | crossterm::event::KeyCode::Left + | crossterm::event::KeyCode::Right => { + self.sensor_delete_focus = match self.sensor_delete_focus { + SensorDeleteFocus::YesBtn => SensorDeleteFocus::NoBtn, + SensorDeleteFocus::NoBtn => SensorDeleteFocus::YesBtn, + }; + } + crossterm::event::KeyCode::Enter => return false, + _ => {} + } + true + } + fn render_info(&self, parent: Rect, buf: &mut Buffer) { match self.info_active_tab { 0 => self.render_module_info(parent, buf), @@ -1506,6 +1676,70 @@ impl RepoManager { Self::draw_shadow(buf, canvas, w, h); } + fn render_sensor_delete(&self, parent: Rect, buf: &mut Buffer) { + let w = (parent.width / 2).clamp(42, 64); + let h: u16 = 6; + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_0] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { + text: format!(" Delete {} ", self.sensor_delete_id), + bg: palette::ERROR_BASE, + fg: palette::FG, + modifier: Modifier::empty(), + }], + ); + + let msg = format!("Delete sensor scope \"{}\"?", self.sensor_delete_id); + let msg_x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; + buf.set_string(msg_x, inner.y + 1, &msg, Style::default().fg(palette::FG)); + + let btn_y = inner.y + 3; + let yes_lbl = "[ Yes ]"; + let no_lbl = "[ No ]"; + let yes_w: u16 = 10; + let no_w: u16 = 10; + let gap: u16 = 3; + let total_btn_w = yes_w + gap + no_w; + let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; + + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + + let yes_style = if self.sensor_delete_focus == SensorDeleteFocus::YesBtn { sel_btn } else { unsel_btn }; + let no_style = if self.sensor_delete_focus == SensorDeleteFocus::NoBtn { sel_btn } else { unsel_btn }; + buf.set_string(btn_x, btn_y, yes_lbl, yes_style); + buf.set_string(btn_x + yes_w + gap, btn_y, no_lbl, no_style); + + Self::draw_shadow(buf, canvas, w, h); + } + fn render_info_text(&self, area: Rect, buf: &mut Buffer, text: &str) { let w = (area.width.saturating_sub(3)) as usize; let lines: Vec = text.split('\n').flat_map(|l| dslbrowser::wrap_text(l, w)).collect(); diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index cfd6e8a3..fa6c47c5 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -207,14 +207,14 @@ impl SysInspectUX { self.status_text = Line::from(vec![ key("\u{2191}\u{2193} "), desc("navigate "), - key("Enter "), - desc("info "), + key("Space "), + desc("toggle "), key("Del "), desc("remove "), key("Ins/i "), desc("add "), - key("L "), - desc("libraries "), + key("\u{2190}/\u{2192} "), + desc("tabs "), key("Esc "), desc("close"), ]); From 71d435f8b61a3f2f7d6c3c9d81dd9a4efce3a9b5 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 26 Jun 2026 00:54:00 +0200 Subject: [PATCH 02/11] Add keyring example --- examples/demos/keypair/model.cfg | 11 +- examples/demos/keyring/README.txt | 61 ++++++++++ examples/demos/keyring/model.cfg | 175 +++++++++++++++++++++++++++++ examples/demos/keyring/sensors.cfg | 64 +++++++++++ 4 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 examples/demos/keyring/README.txt create mode 100644 examples/demos/keyring/model.cfg create mode 100644 examples/demos/keyring/sensors.cfg diff --git a/examples/demos/keypair/model.cfg b/examples/demos/keypair/model.cfg index 6363e0f8..fb264919 100644 --- a/examples/demos/keypair/model.cfg +++ b/examples/demos/keypair/model.cfg @@ -1,5 +1,5 @@ name: Keypair Demo -version: "0.1" +version: "0.1.1" description: | Demo model for self-healing host keypairs in /tmp/keypairdemo and syncing per-host public keys to Master datastore. @@ -56,6 +56,9 @@ actions: args: src: /keypairdemo/{{ traits['system']['hostname']['fqdn'] | default(value='unknown-host') }}.pub file: /tmp/keypairdemo/{{ traits['system']['hostname']['fqdn'] | default(value='unknown-host') }}.pub + tls: true + tls-accept-insecure: true + port: 4202 regen-priv: opts: @@ -63,6 +66,9 @@ actions: args: src: /keypairdemo/{{ traits['system']['hostname']['fqdn'] | default(value='unknown-host') }}.pub file: /tmp/keypairdemo/{{ traits['system']['hostname']['fqdn'] | default(value='unknown-host') }}.pub + tls: true + tls-accept-insecure: true + port: 4202 keypair-repair-pub: descr: Pull host pubkey from Master datastore when deleted @@ -77,6 +83,9 @@ actions: src: /keypairdemo/{{ traits['system']['hostname']['fqdn'] | default(value='unknown-host') }}.pub file: /tmp/keypairdemo/{{ traits['system']['hostname']['fqdn'] | default(value='unknown-host') }}.pub mode: "0644" + tls: true + tls-accept-insecure: true + port: 4202 keypair-regen-priv: descr: Regenerate keypair when private key is missing diff --git a/examples/demos/keyring/README.txt b/examples/demos/keyring/README.txt new file mode 100644 index 00000000..16cdcba3 --- /dev/null +++ b/examples/demos/keyring/README.txt @@ -0,0 +1,61 @@ +To setup this demo, do the following: + + +Master +====== + +1. Copy model.cfg to the master's models root into subdirectory "keyring", + so you end up with "$MASTER/data/models/keyring/model.cfg" + +2. Copy sensors.cfg to the master's sensors root into subdirectory "keyring", + so you end up with "$MASTER/data/sensors/keyring/sensors.cfg" + +3. Export both scopes in master config: + + config: + master: + fileserver.models: + - keyring + fileserver.sensors: + - keyring + +4. Ensure these modules exist on the master repository: + + sysinspect module -L + + You should have "cfg.resource" and "sys.run" available. + + +Minion +====== + +Nothing special. Let it sync and start normally. + + +To demo, bootstrap the cluster first: + + sysinspect keyring/keyring-files/bootstrap '*' + + +Expected result: + +- each host creates its own local private/public pair under /tmp/keyringdemo +- each host publishes its own public key to the Master datastore +- each host syncs all cluster public keys into: + + /tmp/keyringdemo/pubring/ + + +Useful watchdogs: + + watch -n 1 'find /tmp/keyringdemo -maxdepth 2 -type f | sort' + + +Try deleting: + +- /tmp/keyringdemo/.pub +- /tmp/keyringdemo/.priv +- /tmp/keyringdemo/pubring/.pub + +and watch how the local host repairs its own key files and resyncs the shared +pubring. diff --git a/examples/demos/keyring/model.cfg b/examples/demos/keyring/model.cfg new file mode 100644 index 00000000..3214c66e --- /dev/null +++ b/examples/demos/keyring/model.cfg @@ -0,0 +1,175 @@ +name: Keyring Demo +version: "0.1.0" +description: | + Demo model for exchanging per-host public keys through the Master datastore + and materialising a shared local pubring under /tmp/keyringdemo/pubring. +maintainer: Sysinspect Demo + +checkbook: + keyring: + - keyring-demo + +relations: + keyring-demo: + $: + requires: + - keyring-files + +entities: + keyring-files: + descr: Host keypair files and shared pubring in /tmp/keyringdemo + +actions: + keyring-bootstrap: + descr: Ensure demo dir, pubring dir, and host keypair exist + module: sys.run + bind: + - keyring-files + state: + bootstrap: + args: + cmd: >- + /bin/sh -lc 'set -eu; + DEMO_DIR="/tmp/keyringdemo"; + PUBRING_DIR="${DEMO_DIR}/pubring"; + HOST="{{ traits['system']['hostname']['fqdn'] | default(value='unknown-host') }}"; + PRIV="${DEMO_DIR}/${HOST}.priv"; + PUB="${DEMO_DIR}/${HOST}.pub"; + mkdir -p "${DEMO_DIR}" "${PUBRING_DIR}"; + if [ ! -s "${PRIV}" ]; then + ssh-keygen -q -t ed25519 -N "" -f "${PRIV}"; + chmod 600 "${PRIV}"; + fi; + if [ ! -s "${PUB}" ]; then + ssh-keygen -y -f "${PRIV}" > "${PUB}"; + chmod 644 "${PUB}"; + fi' + + keyring-publish-pub: + descr: Push local host pubkey to Master datastore + module: cfg.resource + bind: + - keyring-files + state: + bootstrap: + opts: + - push + args: + src: /keyringdemo/{{ traits['system']['hostname']['fqdn'] | default(value='unknown-host') }}.pub + file: /tmp/keyringdemo/{{ traits['system']['hostname']['fqdn'] | default(value='unknown-host') }}.pub + tls: true + tls-accept-insecure: true + port: 4202 + + regen-priv: + opts: + - push + args: + src: /keyringdemo/{{ traits['system']['hostname']['fqdn'] | default(value='unknown-host') }}.pub + file: /tmp/keyringdemo/{{ traits['system']['hostname']['fqdn'] | default(value='unknown-host') }}.pub + tls: true + tls-accept-insecure: true + port: 4202 + + keyring-repair-pub: + descr: Pull host pubkey from Master datastore when deleted + module: cfg.resource + bind: + - keyring-files + state: + repair-pub: + opts: + - pull + args: + src: /keyringdemo/{{ traits['system']['hostname']['fqdn'] | default(value='unknown-host') }}.pub + file: /tmp/keyringdemo/{{ traits['system']['hostname']['fqdn'] | default(value='unknown-host') }}.pub + mode: "0644" + tls: true + tls-accept-insecure: true + port: 4202 + + pubring: + descr: Sync all published cluster pubkeys into local pubring + module: cfg.resource + bind: + - keyring-files + state: + bootstrap: + opts: + - sync-dir + args: + src: /keyringdemo/ + dst: /tmp/keyringdemo/pubring + mode: "0644" + tls: true + tls-accept-insecure: true + port: 4202 + + regen-priv: + opts: + - sync-dir + args: + src: /keyringdemo/ + dst: /tmp/keyringdemo/pubring + mode: "0644" + tls: true + tls-accept-insecure: true + port: 4202 + + repair-pub: + opts: + - sync-dir + args: + src: /keyringdemo/ + dst: /tmp/keyringdemo/pubring + mode: "0644" + tls: true + tls-accept-insecure: true + port: 4202 + + pubring: + opts: + - sync-dir + args: + src: /keyringdemo/ + dst: /tmp/keyringdemo/pubring + mode: "0644" + tls: true + tls-accept-insecure: true + port: 4202 + + keyring-regen-priv: + descr: Regenerate keypair when private key is missing + module: sys.run + bind: + - keyring-files + state: + regen-priv: + args: + cmd: >- + /bin/sh -lc 'set -eu; + DEMO_DIR="/tmp/keyringdemo"; + PUBRING_DIR="${DEMO_DIR}/pubring"; + HOST="{{ traits['system']['hostname']['fqdn'] | default(value='unknown-host') }}"; + PRIV="${DEMO_DIR}/${HOST}.priv"; + PUB="${DEMO_DIR}/${HOST}.pub"; + mkdir -p "${DEMO_DIR}" "${PUBRING_DIR}"; + rm -f "${PRIV}" "${PUB}" "${PRIV}.pub"; + ssh-keygen -q -t ed25519 -N "" -f "${PRIV}"; + if [ -f "${PRIV}.pub" ]; then mv -f "${PRIV}.pub" "${PUB}"; fi; + if [ ! -s "${PUB}" ]; then ssh-keygen -y -f "${PRIV}" > "${PUB}"; fi; + chmod 600 "${PRIV}"; + chmod 644 "${PUB}"' + +events: + $|$|$|$: + handlers: + - console-logger + - outcome-logger + + console-logger: + concise: false + prefix: Keyring Demo + + outcome-logger: + prefix: Keyring Demo diff --git a/examples/demos/keyring/sensors.cfg b/examples/demos/keyring/sensors.cfg new file mode 100644 index 00000000..d0dec424 --- /dev/null +++ b/examples/demos/keyring/sensors.cfg @@ -0,0 +1,64 @@ +sensors: + interval: + min: 500 + max: 1000 + unit: milliseconds + + keyring-dir-watch: + description: Watch /tmp/keyringdemo for host key and pubring file deletions + listener: sys.filesystem + opts: + - deleted + args: + path: /tmp/keyringdemo + +events: + keyring-dir-watch|sys.filesystem|deleted@/tmp/keyringdemo/$.pub|0: + handlers: + - pipeline + - console-logger + + pipeline: + calls: + - query: keyring/keyring-files/repair-pub + - query: keyring/keyring-files/pubring + verbose: true + + console-logger: + concise: false + prefix: Keyring Demo + + keyring-dir-watch|sys.filesystem|deleted@/tmp/keyringdemo/$.priv|0: + handlers: + - pipeline + - console-logger + + pipeline: + calls: + - query: keyring/keyring-files/regen-priv + verbose: true + + console-logger: + concise: false + prefix: Keyring Demo + + keyring-dir-watch|sys.filesystem|deleted@/tmp/keyringdemo/pubring/$.pub|0: + handlers: + - pipeline + - console-logger + + pipeline: + calls: + - query: keyring/keyring-files/pubring + verbose: true + + console-logger: + concise: false + prefix: Keyring Demo + + $|$|$|$: + handlers: + - outcome-logger + + outcome-logger: + prefix: Keyring Demo From 048626b7742bed2011e35e3a9c3e1622e6a7ae3c Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 26 Jun 2026 00:54:12 +0200 Subject: [PATCH 03/11] Add internal auth for modules --- libwebapi/Cargo.toml | 1 + libwebapi/src/api/v1/mod.rs | 15 +++-- libwebapi/src/api/v1/store.rs | 116 +++++++++++++++++++++++++++++++++- 3 files changed, 125 insertions(+), 7 deletions(-) diff --git a/libwebapi/Cargo.toml b/libwebapi/Cargo.toml index a752dc04..4e51dab1 100644 --- a/libwebapi/Cargo.toml +++ b/libwebapi/Cargo.toml @@ -37,3 +37,4 @@ hostname = "0.4.2" rustls = "0.23.40" rustls-pemfile = "2.2.0" x509-parser = "0.16.0" +base64 = "0.22.1" diff --git a/libwebapi/src/api/v1/mod.rs b/libwebapi/src/api/v1/mod.rs index 2687c525..b308b171 100644 --- a/libwebapi/src/api/v1/mod.rs +++ b/libwebapi/src/api/v1/mod.rs @@ -3,8 +3,8 @@ use crate::api::v1::{ minions::{QueryError, QueryRequest, QueryResponse, query_handler}, model::{ModelNameResponse, model_descr_handler, model_names_handler}, store::{ - StoreListQuery, StoreMetaResponse, StoreResolveQuery, store_blob_handler, store_list_handler, store_meta_handler, store_resolve_handler, - store_upload_handler, + StoreListQuery, StoreMetaResponse, StoreMinionAuthResponse, StoreResolveQuery, store_blob_handler, store_list_handler, + store_meta_handler, store_minion_auth_handler, store_resolve_handler, store_upload_handler, }, system::{AuthRequest, AuthResponse, HealthInfo, HealthResponse, authenticate_handler}, }; @@ -63,6 +63,7 @@ impl V1 { .service(authenticate_handler) .service(model_names_handler) .service(model_descr_handler) + .service(store_minion_auth_handler) .service(store_resolve_handler) .service(store_list_handler) .service(store_meta_handler) @@ -108,12 +109,13 @@ impl super::ApiVersion for V1 { crate::api::v1::store::store_meta_handler, crate::api::v1::store::store_blob_handler, crate::api::v1::store::store_upload_handler, + crate::api::v1::store::store_minion_auth_handler, crate::api::v1::store::store_resolve_handler, crate::api::v1::store::store_list_handler, ), components(schemas(QueryRequest, QueryResponse, QueryError, - HealthInfo, HealthResponse, AuthRequest, AuthResponse, - ModelNameResponse, StoreMetaResponse, StoreResolveQuery, StoreListQuery)), + HealthInfo, HealthResponse, AuthRequest, AuthResponse, + ModelNameResponse, StoreMetaResponse, StoreMinionAuthResponse, StoreResolveQuery, StoreListQuery)), modifiers(&SecurityAddon), info(title = "SysInspect API", version = API_VERSION, description = API_DOC_DESCRIPTION))] pub struct ApiDoc; @@ -128,12 +130,13 @@ pub struct ApiDoc; crate::api::v1::store::store_meta_handler, crate::api::v1::store::store_blob_handler, crate::api::v1::store::store_upload_handler, + crate::api::v1::store::store_minion_auth_handler, crate::api::v1::store::store_resolve_handler, crate::api::v1::store::store_list_handler, ), components(schemas(QueryRequest, QueryResponse, QueryError, - HealthInfo, HealthResponse, AuthRequest, AuthResponse, - ModelNameResponse, StoreMetaResponse, StoreResolveQuery, StoreListQuery)), + HealthInfo, HealthResponse, AuthRequest, AuthResponse, + ModelNameResponse, StoreMetaResponse, StoreMinionAuthResponse, StoreResolveQuery, StoreListQuery)), modifiers(&SecurityAddon), info(title = "SysInspect API", version = API_VERSION, description = API_DOC_DEV_DESCRIPTION))] pub struct ApiDocDev; diff --git a/libwebapi/src/api/v1/store.rs b/libwebapi/src/api/v1/store.rs index 2140064a..ebd09c8e 100644 --- a/libwebapi/src/api/v1/store.rs +++ b/libwebapi/src/api/v1/store.rs @@ -1,15 +1,20 @@ use std::path::{Path, PathBuf}; -use crate::{MasterInterfaceType, api::v1::minions::authorise_request}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use crate::{MasterInterfaceType, api::v1::minions::authorise_request, sessions::get_session_store}; use actix_files::NamedFile; use actix_web::Result as ActixResult; use actix_web::{HttpRequest, HttpResponse, Responder, get, post, web}; use futures_util::StreamExt; use libdatastore::resources::DataItemMeta; +use libsysinspect::rsa::keys::{RsaKey, key_from_file, verify_sign}; use serde::{Deserialize, Serialize}; use tokio::io::AsyncWriteExt; use tokio::task; use utoipa::ToSchema; + +const MINION_AUTH_SKEW_SECS: u64 = 300; + #[derive(Debug, Serialize, ToSchema)] pub struct StoreMetaResponse { pub sha256: String, @@ -35,11 +40,120 @@ pub struct StoreErrorResponse { pub error: String, } +#[derive(Debug, Serialize, ToSchema)] +pub struct StoreMinionAuthResponse { + pub status: String, + pub access_token: String, + pub token_type: String, + pub error: String, +} + fn unauthorised_store_error(err: libcommon::SysinspectError) -> actix_web::Error { let msg = err.to_string(); actix_web::error::InternalError::from_response(err, HttpResponse::Unauthorized().json(StoreErrorResponse { error: msg })).into() } +fn minion_auth_material(method: &str, path: &str, query: &str, timestamp: &str, body_sha256: &str) -> String { + format!("{}\n{}\n{}\n{}\n{}", method, path, query, timestamp, body_sha256) +} + +async fn verify_minion_bootstrap(req: &HttpRequest, master: &web::Data) -> Result { + let reject = |msg: String| { + log::warn!( + "Datastore minion-auth bootstrap rejected for {} {} from {:?}: {}", + req.method(), + req.uri(), + req.peer_addr(), + msg + ); + libcommon::SysinspectError::WebAPIError(msg) + }; + + let minion_id = req + .headers() + .get("X-Sysinspect-Minion-Id") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or_else(|| reject("Missing X-Sysinspect-Minion-Id header".to_string()))?; + let timestamp = req + .headers() + .get("X-Sysinspect-Timestamp") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or_else(|| reject("Missing X-Sysinspect-Timestamp header".to_string()))?; + let signature_b64 = req + .headers() + .get("X-Sysinspect-Signature") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or_else(|| reject("Missing X-Sysinspect-Signature header".to_string()))?; + let body_sha256 = req.headers().get("X-Sysinspect-Body-Sha256").and_then(|v| v.to_str().ok()).map(str::trim).unwrap_or(""); + + let now = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .map_err(|e| reject(format!("Unable to read system time: {e}")))? + .as_secs(); + let ts = timestamp + .parse::() + .map_err(|_| reject("Invalid X-Sysinspect-Timestamp header".to_string()))?; + if now.abs_diff(ts) > MINION_AUTH_SKEW_SECS { + return Err(reject("Expired minion-auth timestamp".to_string())); + } + + let cfg = { + let master = master.lock().await; + master.cfg().await.clone() + }; + let key_path = cfg.minion_keys_root().join(format!("{minion_id}.rsa.pub")); + let public_key = match key_from_file(key_path.to_str().unwrap_or_default()) + .map_err(|e| reject(format!("Unable to load minion public key: {e}")))? + { + Some(RsaKey::Public(pbk)) => pbk, + _ => { + return Err(reject(format!("Unknown minion or invalid public key for {}", minion_id))) + } + }; + let signature = STANDARD + .decode(signature_b64) + .map_err(|e| reject(format!("Invalid minion-auth signature encoding: {e}")))?; + let material = minion_auth_material(req.method().as_str(), req.path(), req.query_string(), timestamp, body_sha256); + let verified = verify_sign(&public_key, material.as_bytes(), signature) + .map_err(|e| reject(format!("Minion-auth verification failed: {e}")))?; + if !verified { + return Err(reject("Invalid minion-auth signature".to_string())); + } + + Ok(minion_id.to_string()) +} + +#[utoipa::path( + post, + path = "/store/auth/minion", + tag = "Datastore", + responses( + (status = 200, description = "Datastore minion authentication successful", body = StoreMinionAuthResponse), + (status = 401, description = "Unauthorized", body = StoreErrorResponse) + ) +)] +#[post("/store/auth/minion")] +pub async fn store_minion_auth_handler(req: HttpRequest, master: web::Data) -> impl Responder { + match verify_minion_bootstrap(&req, &master).await { + Ok(minion_id) => match get_session_store().lock().await.open(format!("minion:{minion_id}")) { + Ok(token) => HttpResponse::Ok().json(StoreMinionAuthResponse { + status: "authenticated".to_string(), + access_token: token, + token_type: "Bearer".to_string(), + error: String::new(), + }), + Err(err) => HttpResponse::InternalServerError().json(StoreErrorResponse { error: err.to_string() }), + }, + Err(err) => HttpResponse::Unauthorized().json(StoreErrorResponse { error: err.to_string() }), + } +} + /// Get a list of all meta files within the datastore. fn get_meta_files(root: &Path, out: &mut Vec) -> std::io::Result<()> { if !root.exists() { From 6620df5bf4e5ce889c8c10a50acc8a7e80a2acb7 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 26 Jun 2026 00:54:27 +0200 Subject: [PATCH 04/11] Fix resource module --- modules/cfg/resource/Cargo.toml | 4 +- modules/cfg/resource/src/main.rs | 246 ++++++++++++++++++++++++-- modules/cfg/resource/src/mod_doc.yaml | 22 ++- 3 files changed, 254 insertions(+), 18 deletions(-) diff --git a/modules/cfg/resource/Cargo.toml b/modules/cfg/resource/Cargo.toml index e4b90772..2f965401 100644 --- a/modules/cfg/resource/Cargo.toml +++ b/modules/cfg/resource/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "resource" -version = "0.1.0" +version = "0.2.1" edition = "2024" [dependencies] @@ -14,3 +14,5 @@ clap = { version = "4.6.1", features = ["derive"] } log = "0.4.29" reqwest = { version = "0.12.28", default-features = false, features = ["blocking", "json", "rustls-tls"] } sha2 = "0.10.9" +base64 = "0.22.1" +rsa = "0.9.10" diff --git a/modules/cfg/resource/src/main.rs b/modules/cfg/resource/src/main.rs index 0122c825..a17bd40e 100644 --- a/modules/cfg/resource/src/main.rs +++ b/modules/cfg/resource/src/main.rs @@ -7,6 +7,7 @@ use std::{ time::Duration, }; +use base64::{Engine, engine::general_purpose::STANDARD}; use clap::Parser; use libmodcore::{ init_mod_doc, @@ -17,8 +18,12 @@ use libmodcore::{ rtspec::RuntimeSpec, runtime::{ModRequest, get_call_args, send_call_response}, }; -use libsysinspect::cfg::mmconf::DEFAULT_API_PORT; +use libsysinspect::{ + cfg::mmconf::{CFG_MINION_RSA_PRV, DEFAULT_API_PORT, DEFAULT_MINION_MACHINE_ID, DEFAULT_MINION_MACHINE_ID_REL, DEFAULT_SYSINSPECT_ROOT}, + rsa::keys::{RsaKey, key_from_file, sign_data}, +}; use reqwest::StatusCode; +use rsa::RsaPrivateKey; use serde::Deserialize; use serde_json::json; use sha2::{Digest, Sha256}; @@ -36,11 +41,22 @@ struct StoreMetaResponse { fname: Option, } +#[derive(Debug, Deserialize, Clone)] +struct StoreListEntry { + sha256: String, + size_bytes: u64, + fmode: u32, + created_unix: u64, + expires_unix: Option, + fname: Option, +} + type JsonMap = HashMap; struct Ctx { cl: reqwest::blocking::Client, b: String, + t: String, s: String, f: Option, d: Option, @@ -48,6 +64,16 @@ struct Ctx { force: bool, } +struct MinionAuth { + minion_id: String, + private_key: RsaPrivateKey, +} + +#[derive(Debug, Deserialize, Clone)] +struct StoreMinionAuthResponse { + access_token: String, +} + fn cfg_str(rq: &ModRequest, key: &str) -> Option { rq.config().get(key).and_then(|v| v.as_string()).filter(|v| !v.is_empty()) } @@ -56,6 +82,10 @@ fn arg_str(rq: &ModRequest, key: &str) -> Option { rq.args().get(key).and_then(|v| v.as_string()).filter(|v| !v.is_empty()) } +fn arg_bool(rq: &ModRequest, key: &str) -> Option { + rq.args().get(key).and_then(|v| v.as_bool()) +} + fn parse_mode(mode: &str) -> Option { let raw = mode.trim(); if raw.is_empty() { @@ -81,14 +111,114 @@ fn file_sha256(path: &Path) -> io::Result { Ok(format!("{:x}", hasher.finalize())) } +fn bytes_sha256_hex(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + format!("{:x}", hasher.finalize()) +} + +fn timestamp_now() -> String { + std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string() +} + +fn canonical_auth_material(method: &str, path: &str, query: &str, timestamp: &str, body_sha256: &str) -> String { + format!("{method}\n{path}\n{query}\n{timestamp}\n{body_sha256}") +} + +fn cfg_root(rq: &ModRequest) -> PathBuf { + cfg_str(rq, "path.root").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(DEFAULT_SYSINSPECT_ROOT)) +} + +fn machine_id_path(rq: &ModRequest) -> PathBuf { + if let Some(mid) = cfg_str(rq, "path.id") { + if mid == "relative" { + return cfg_root(rq).join(DEFAULT_MINION_MACHINE_ID_REL); + } + return PathBuf::from(mid); + } + let default = PathBuf::from(DEFAULT_MINION_MACHINE_ID); + if default.exists() { + default + } else { + cfg_root(rq).join(DEFAULT_MINION_MACHINE_ID_REL) + } +} + +fn load_minion_auth(rq: &ModRequest) -> Result { + let minion_id = fs::read_to_string(machine_id_path(rq)) + .map_err(|e| format!("Unable to read minion identity: {e}"))? + .trim() + .to_string(); + if minion_id.is_empty() { + return Err("Minion identity is empty".to_string()); + } + + let key_path = cfg_root(rq).join(CFG_MINION_RSA_PRV); + let private_key = match key_from_file(key_path.to_str().unwrap_or_default()).map_err(|e| format!("Unable to load minion private key: {e}"))? { + Some(RsaKey::Private(prk)) => prk, + _ => return Err(format!("Minion private key not found at {}", key_path.display())), + }; + + Ok(MinionAuth { minion_id, private_key }) +} + +fn authed_request( + client: &reqwest::blocking::Client, auth: &MinionAuth, method: reqwest::Method, url: &str, query: &[(&str, &str)], body_sha256: &str, +) -> Result { + let mut parsed = reqwest::Url::parse(url).map_err(|e| format!("Invalid request URL '{url}': {e}"))?; + if !query.is_empty() { + let mut qp = parsed.query_pairs_mut(); + for (k, v) in query { + qp.append_pair(k, v); + } + } + let timestamp = timestamp_now(); + let material = canonical_auth_material(method.as_str(), parsed.path(), parsed.query().unwrap_or(""), ×tamp, body_sha256); + let signature = sign_data(auth.private_key.clone(), material.as_bytes()).map_err(|e| format!("Unable to sign datastore request: {e}"))?; + + Ok(client + .request(method, parsed) + .header("X-Sysinspect-Minion-Id", auth.minion_id.clone()) + .header("X-Sysinspect-Timestamp", timestamp) + .header("X-Sysinspect-Signature", STANDARD.encode(signature)) + .header("X-Sysinspect-Body-Sha256", body_sha256.to_string())) +} + +fn bearer_request(client: &reqwest::blocking::Client, token: &str, method: reqwest::Method, url: &str) -> reqwest::blocking::RequestBuilder { + client.request(method, url).bearer_auth(token) +} + fn api_base(rq: &ModRequest) -> String { let ip = cfg_str(rq, "master.ip").unwrap_or_else(|| "127.0.0.1".to_string()); - format!("http://{ip}:{}", DEFAULT_API_PORT) + let port = arg_str(rq, "port") + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_API_PORT); + let scheme = if arg_bool(rq, "tls").unwrap_or(true) { "https" } else { "http" }; + format!("{scheme}://{ip}:{port}") } -fn resolve_meta(client: &reqwest::blocking::Client, base: &str, src: &str) -> Result, String> { +fn bootstrap_datastore_token(client: &reqwest::blocking::Client, auth: &MinionAuth, base: &str) -> Result { + let url = format!("{base}/store/auth/minion"); + let rsp = authed_request(client, auth, reqwest::Method::POST, &url, &[], "")? + .send() + .map_err(|e| format!("store auth request failed: {e}"))?; + if !rsp.status().is_success() { + return Err(format!("store auth request failed: HTTP {}", rsp.status())); + } + let auth = rsp.json::().map_err(|e| format!("unable to parse store auth response: {e}"))?; + if auth.access_token.trim().is_empty() { + return Err("store auth response did not contain an access token".to_string()); + } + Ok(auth.access_token) +} + +fn resolve_meta(client: &reqwest::blocking::Client, token: &str, base: &str, src: &str) -> Result, String> { let url = format!("{base}/store/resolve"); - let req = client.get(url).query(&[("fname", src)]); + let req = bearer_request(client, token, reqwest::Method::GET, &url).query(&[("fname", src)]); let rsp = req.send().map_err(|e| format!("resolve request failed: {e}"))?; if rsp.status() == StatusCode::NOT_FOUND { @@ -101,12 +231,12 @@ fn resolve_meta(client: &reqwest::blocking::Client, base: &str, src: &str) -> Re rsp.json::().map(Some).map_err(|e| format!("unable to parse resolve metadata: {e}")) } -fn download_atomic(client: &reqwest::blocking::Client, url: &str, dst: &Path) -> Result<(), String> { +fn download_atomic(client: &reqwest::blocking::Client, token: &str, url: &str, dst: &Path) -> Result<(), String> { if let Some(parent) = dst.parent() { fs::create_dir_all(parent).map_err(|e| format!("failed to create destination parent '{}': {e}", parent.display()))?; } - let req = client.get(url); + let req = bearer_request(client, token, reqwest::Method::GET, url); let mut rsp = req.send().map_err(|e| format!("download request failed: {e}"))?; if !rsp.status().is_success() { return Err(format!("download request failed: HTTP {}", rsp.status())); @@ -122,6 +252,16 @@ fn download_atomic(client: &reqwest::blocking::Client, url: &str, dst: &Path) -> Ok(()) } +fn list_meta(client: &reqwest::blocking::Client, token: &str, base: &str, prefix: &str) -> Result, String> { + let url = format!("{base}/store/list"); + let req = bearer_request(client, token, reqwest::Method::GET, &url).query(&[("prefix", prefix)]); + let rsp = req.send().map_err(|e| format!("list request failed: {e}"))?; + if !rsp.status().is_success() { + return Err(format!("list request failed: HTTP {}", rsp.status())); + } + rsp.json::>().map_err(|e| format!("unable to parse store list response: {e}")) +} + fn finish(resp: &mut ModResponse, data: &mut JsonMap) { data.insert(RuntimeSpec::LogsSectionField.to_string(), json!(take_logs())); _ = resp.set_data(data.clone()); @@ -135,10 +275,24 @@ fn mk_ctx(rq: &ModRequest) -> Result { let cl = reqwest::blocking::Client::builder() .connect_timeout(Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECS)) .timeout(Duration::from_secs(HTTP_REQUEST_TIMEOUT_SECS)) + .danger_accept_invalid_certs(arg_bool(rq, "tls-accept-insecure").unwrap_or(false)) .build() .map_err(|e| format!("Unable to initialize HTTP client: {e}"))?; - Ok(Ctx { cl, b: api_base(rq), s, f: arg_str(rq, "file"), d: arg_str(rq, "dst"), m: arg_str(rq, "mode"), force: rq.has_option("force") }) + let auth = load_minion_auth(rq)?; + let base = api_base(rq); + let token = bootstrap_datastore_token(&cl, &auth, &base)?; + + Ok(Ctx { + cl, + b: base, + t: token, + s, + f: arg_str(rq, "file"), + d: arg_str(rq, "dst"), + m: arg_str(rq, "mode"), + force: rq.has_option("force"), + }) } fn local_p(c: &Ctx) -> PathBuf { @@ -154,7 +308,7 @@ fn do_push(c: &Ctx) -> Result<(bool, String, JsonMap), String> { let hs = file_sha256(&p).map_err(|e| format!("Unable to checksum local file '{}': {e}", p.display()))?; if !c.force { - match resolve_meta(&c.cl, &c.b, &c.s)? { + match resolve_meta(&c.cl, &c.t, &c.b, &c.s)? { Some(m) if m.sha256 == hs => { d.insert("src".to_string(), json!(c.s)); d.insert("sha256".to_string(), json!(m.sha256)); @@ -166,7 +320,12 @@ fn do_push(c: &Ctx) -> Result<(bool, String, JsonMap), String> { } let b = fs::read(&p).map_err(|e| format!("Unable to read local file '{}': {e}", p.display()))?; - let req = c.cl.post(format!("{}/store", c.b)).header("Content-Type", "application/octet-stream").header("X-Filename", c.s.clone()).body(b); + let body_sha256 = bytes_sha256_hex(&b); + let req = bearer_request(&c.cl, &c.t, reqwest::Method::POST, &format!("{}/store", c.b)) + .header("Content-Type", "application/octet-stream") + .header("X-Filename", c.s.clone()) + .header("X-Sysinspect-Body-Sha256", body_sha256) + .body(b); let rsp = req.send().map_err(|e| format!("Push request failed: {e}"))?; if !rsp.status().is_success() { let st = rsp.status(); @@ -187,7 +346,7 @@ fn do_push(c: &Ctx) -> Result<(bool, String, JsonMap), String> { fn do_pull(c: &Ctx) -> Result<(bool, String, JsonMap), String> { let mut d = JsonMap::new(); let p = local_p(c); - let m = match resolve_meta(&c.cl, &c.b, &c.s)? { + let m = match resolve_meta(&c.cl, &c.t, &c.b, &c.s)? { Some(m) => m, None => return Err(format!("Resource '{}' was not found in datastore", c.s)), }; @@ -206,7 +365,7 @@ fn do_pull(c: &Ctx) -> Result<(bool, String, JsonMap), String> { } let url = format!("{}/store/{}/blob", c.b, m.sha256); - download_atomic(&c.cl, &url, &p)?; + download_atomic(&c.cl, &c.t, &url, &p)?; let md = c.m.as_deref().and_then(parse_mode).unwrap_or(m.fmode & 0o7777); fs::set_permissions(&p, fs::Permissions::from_mode(md)) .map_err(|e| format!("Downloaded resource but failed to set mode on '{}': {e}", p.display()))?; @@ -222,17 +381,66 @@ fn do_pull(c: &Ctx) -> Result<(bool, String, JsonMap), String> { Ok((true, "Resource downloaded from datastore".to_string(), d)) } +fn do_sync_dir(c: &Ctx) -> Result<(bool, String, JsonMap), String> { + let mut d = JsonMap::new(); + let dst_dir = c + .f + .clone() + .or_else(|| c.d.clone()) + .map(PathBuf::from) + .ok_or_else(|| "Argument \"file\" or \"dst\" is required for sync-dir".to_string())?; + fs::create_dir_all(&dst_dir).map_err(|e| format!("Unable to create destination directory '{}': {e}", dst_dir.display()))?; + + let metas = list_meta(&c.cl, &c.t, &c.b, &c.s)?; + let mut changed = false; + let mut synced = 0usize; + for meta in metas { + let Some(fname) = meta.fname.as_deref() else { + continue; + }; + let Some(name) = Path::new(fname).file_name().and_then(|s| s.to_str()) else { + continue; + }; + let dst = dst_dir.join(name); + if dst.exists() && !c.force { + if let Ok(h) = file_sha256(&dst) + && h == meta.sha256 + { + synced += 1; + continue; + } + } + + let url = format!("{}/store/{}/blob", c.b, meta.sha256); + download_atomic(&c.cl, &c.t, &url, &dst)?; + let md = c.m.as_deref().and_then(parse_mode).unwrap_or(meta.fmode & 0o7777); + fs::set_permissions(&dst, fs::Permissions::from_mode(md)) + .map_err(|e| format!("Downloaded resource but failed to set mode on '{}': {e}", dst.display()))?; + changed = true; + synced += 1; + } + + d.insert("src".to_string(), json!(c.s)); + d.insert("dst".to_string(), json!(dst_dir)); + d.insert("synced".to_string(), json!(synced)); + Ok((changed, format!("Pubring synced ({} files)", synced), d)) +} + fn run(_cli: &ModuleCli, rq: &ModRequest) -> ModResponse { let mut resp = ModResponse::new_cm(); let mut data = JsonMap::new(); - if rq.has_option("push") && rq.has_option("pull") { - resp.set_message("Configuration error: cannot have both push and pull options"); + let push = rq.has_option("push"); + let pull = rq.has_option("pull"); + let sync_dir = rq.has_option("sync-dir"); + let selected = push as u8 + pull as u8 + sync_dir as u8; + if selected > 1 { + resp.set_message("Configuration error: cannot combine push, pull, and sync-dir options"); finish(&mut resp, &mut data); return resp; } - if !rq.has_option("push") && !rq.has_option("pull") { - resp.set_message("Configuration error: must have either push or pull option"); + if selected == 0 { + resp.set_message("Configuration error: must have one of push, pull, or sync-dir option"); finish(&mut resp, &mut data); return resp; } @@ -246,7 +454,13 @@ fn run(_cli: &ModuleCli, rq: &ModRequest) -> ModResponse { } }; - let out = if rq.has_option("push") { do_push(&c) } else { do_pull(&c) }; + let out = if push { + do_push(&c) + } else if pull { + do_pull(&c) + } else { + do_sync_dir(&c) + }; match out { Ok((ch, msg, mut d)) => { resp.set_retcode(0); diff --git a/modules/cfg/resource/src/mod_doc.yaml b/modules/cfg/resource/src/mod_doc.yaml index 5317f674..b8964e9a 100644 --- a/modules/cfg/resource/src/mod_doc.yaml +++ b/modules/cfg/resource/src/mod_doc.yaml @@ -1,5 +1,5 @@ name: "cfg.resource" -version: "0.1.0" +version: "0.2.1" author: "Bo Maryniuk" description: | Plugin for resource management and availability. @@ -21,6 +21,11 @@ options: required: false description: "Overwrite the resource if it is already installed (default: false)" + - name: sync-dir + type: bool + required: false + description: "Synchronize all datastore items matching the logical prefix in [Y::]src[N] into a local directory." + # Keyword arguments arguments: - name: src @@ -38,6 +43,21 @@ arguments: required: false description: "Alias for [Y::]file[N]. If neither [Y::]file[N] nor [Y::]dst[N] is set, [Y::]src[N] is used as local path." + - name: tls + type: bool + required: false + description: "Use HTTPS when talking to the Master Web API (default: true)." + + - name: tls-accept-insecure + type: bool + required: false + description: "Allow self-signed or otherwise invalid TLS certificates (default: false)." + + - name: port + type: int + required: false + description: "Master Web API port (default: 4202)." + - name: mode type: string required: false From 1ef5afaa9b14ad79bbfc60b33749e4a96deeb717 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 26 Jun 2026 00:54:49 +0200 Subject: [PATCH 05/11] Add TLS autosetup (self-signed) --- src/ui/setup.rs | 174 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 164 insertions(+), 10 deletions(-) diff --git a/src/ui/setup.rs b/src/ui/setup.rs index fc097354..d7d59c53 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -4,6 +4,15 @@ use super::{ }; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use libsysinspect::cfg::mmconf::{MasterConfig, SysInspectConfig}; +use openssl::{ + asn1::Asn1Time, + bn::{BigNum, MsbOption}, + hash::MessageDigest, + nid::Nid, + pkey::PKey, + rsa::Rsa, + x509::{X509, X509NameBuilder}, +}; use ratatui::{ layout::Position, prelude::{Buffer, Rect}, @@ -17,6 +26,32 @@ use ratatui_glamour::rule::dashed_title; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +fn resolve_user_path(raw: &str) -> PathBuf { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return PathBuf::new(); + } + + if trimmed == "~" + && let Ok(home) = std::env::var("HOME") + { + return PathBuf::from(home); + } + + if let Some(rest) = trimmed.strip_prefix("~/") + && let Ok(home) = std::env::var("HOME") + { + return PathBuf::from(home).join(rest); + } + + let path = PathBuf::from(trimmed); + if path.is_absolute() { + path + } else { + std::env::current_dir().unwrap_or_default().join(path) + } +} + #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum InstallationMode { SystemWide, @@ -33,12 +68,13 @@ pub enum SetupFocus { BindPort, FsPort, ApiCheck, + ApiSelfSignedCheck, Ok, Cancel, } impl SetupFocus { - fn next(self, mode: InstallationMode) -> Self { + fn next(self, mode: InstallationMode, api_enabled: bool) -> Self { use SetupFocus::*; match self { @@ -55,13 +91,20 @@ impl SetupFocus { BindAddr => BindPort, BindPort => FsPort, FsPort => ApiCheck, - ApiCheck => Ok, + ApiCheck => { + if api_enabled { + ApiSelfSignedCheck + } else { + Ok + } + } + ApiSelfSignedCheck => Ok, Ok => Cancel, Cancel => SysMasterPath, } } - fn prev(self, mode: InstallationMode) -> Self { + fn prev(self, mode: InstallationMode, api_enabled: bool) -> Self { use SetupFocus::*; match self { @@ -79,7 +122,14 @@ impl SetupFocus { BindPort => BindAddr, FsPort => BindPort, ApiCheck => FsPort, - Ok => ApiCheck, + ApiSelfSignedCheck => ApiCheck, + Ok => { + if api_enabled { + ApiSelfSignedCheck + } else { + ApiCheck + } + } Cancel => Ok, } } @@ -97,6 +147,7 @@ pub struct MasterSetupWizard { pub bind_port: InputState, pub fs_port: InputState, pub api_enabled: bool, + pub api_self_signed_tls: bool, pub focus: SetupFocus, pub ok_pressed: bool, pub quit_requested: bool, @@ -138,6 +189,7 @@ pub struct SetupRequest { bind_port: String, fs_port: String, api_enabled: bool, + api_self_signed_tls: bool, } #[derive(Debug, Clone)] @@ -182,6 +234,7 @@ impl Default for MasterSetupWizard { bind_port, fs_port, api_enabled: true, + api_self_signed_tls: false, focus: SetupFocus::SysMasterPath, ok_pressed: false, quit_requested: false, @@ -200,6 +253,7 @@ impl MasterSetupWizard { bind_port: self.bind_port.value().to_string(), fs_port: self.fs_port.value().to_string(), api_enabled: self.api_enabled, + api_self_signed_tls: self.api_self_signed_tls, } } @@ -220,6 +274,7 @@ impl MasterSetupWizard { w.fs_port.set_value(fs.split(':').nth(1).unwrap_or("4201").to_string()); w.api_enabled = cfg.api_enabled(); + w.api_self_signed_tls = false; if !is_system { w.custom_destination.set_value(root.to_string_lossy().to_string()); @@ -244,13 +299,13 @@ impl MasterSetupWizard { match key.code { KeyCode::Tab => { self.focus = if key.modifiers.contains(KeyModifiers::SHIFT) { - self.focus.prev(self.installation_mode) + self.focus.prev(self.installation_mode, self.api_enabled) } else { - self.focus.next(self.installation_mode) + self.focus.next(self.installation_mode, self.api_enabled) }; } KeyCode::BackTab => { - self.focus = self.focus.prev(self.installation_mode); + self.focus = self.focus.prev(self.installation_mode, self.api_enabled); } KeyCode::Enter => match self.focus { SetupFocus::SysMasterPath => { @@ -274,6 +329,11 @@ impl MasterSetupWizard { SetupFocus::ApiCheck => { self.api_enabled = !self.api_enabled; } + SetupFocus::ApiSelfSignedCheck => { + if self.api_enabled { + self.api_self_signed_tls = !self.api_self_signed_tls; + } + } _ => {} // input fields — Enter does nothing (text is handled by char keys) }, KeyCode::Esc => { @@ -282,6 +342,8 @@ impl MasterSetupWizard { KeyCode::Char(' ') => { if self.focus == SetupFocus::ApiCheck { self.api_enabled = !self.api_enabled; + } else if self.focus == SetupFocus::ApiSelfSignedCheck && self.api_enabled { + self.api_self_signed_tls = !self.api_self_signed_tls; } } KeyCode::Backspace => { @@ -344,7 +406,7 @@ impl MasterSetupWizard { return; } let dlg_w = (parent.width * 3 / 4).clamp(60, 72); - let dlg_h = if self.installation_mode == InstallationMode::Custom { 15u16 } else { 14u16 }; + let dlg_h = if self.installation_mode == InstallationMode::Custom { 16u16 } else { 15u16 }; let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; @@ -490,6 +552,21 @@ impl MasterSetupWizard { buf.set_string(inner.x + 1, row_y, api_chk, api_style); row_y += 1; + let self_signed_chk = if self.api_self_signed_tls { + " ▣ Setup with self-signed TLS certificate" + } else { + " □ Setup with self-signed TLS certificate" + }; + let self_signed_style = if !self.api_enabled { + Style::default().fg(palette::MUTED) + } else if self.is_focused(SetupFocus::ApiSelfSignedCheck) { + focus_style + } else { + muted + }; + buf.set_string(inner.x + 1, row_y, self_signed_chk, self_signed_style); + row_y += 1; + // spacing row_y += 1; @@ -653,6 +730,80 @@ pub fn render_progress(progress: &SetupProgress, parent: Rect, buf: &mut Buffer) } impl SetupRequest { + fn config_path(&self) -> PathBuf { + let root = self.root_dir(); + if matches!(self.installation_mode, InstallationMode::SystemWide) { + root.join("sysinspect.conf") + } else { + root.join("etc/sysinspect.conf") + } + } + + fn config_dropin_dir(&self) -> PathBuf { + let config_path = self.config_path(); + let stem = config_path.file_stem().unwrap_or_default().to_string_lossy().to_string(); + config_path.with_file_name(format!("{stem}.d")) + } + + fn self_signed_tls_paths(&self) -> (PathBuf, PathBuf) { + let etc_dir = self.root_dir().join("etc"); + (etc_dir.join("api.crt"), etc_dir.join("api.key")) + } + + fn write_webapi_tls_dropin(&self, cert_path: &Path, key_path: &Path) -> Result<(), String> { + let dropin_dir = self.config_dropin_dir(); + std::fs::create_dir_all(&dropin_dir).map_err(|e| format!("Cannot create {}: {e}", dropin_dir.display()))?; + let dropin = dropin_dir.join("99-webapi-tls.conf"); + let body = format!( + "config:\n master:\n api.enabled: true\n api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n api.tls.allow-insecure: true\n", + cert_path.display(), + key_path.display() + ); + std::fs::write(&dropin, body).map_err(|e| format!("Cannot write {}: {e}", dropin.display())) + } + + fn generate_self_signed_webapi_cert(&self) -> Result<(), String> { + let (cert_path, key_path) = self.self_signed_tls_paths(); + if let Some(parent) = cert_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("Cannot create {}: {e}", parent.display()))?; + } + + let rsa = Rsa::generate(2048).map_err(|e| format!("Cannot generate RSA key: {e}"))?; + let pkey = PKey::from_rsa(rsa).map_err(|e| format!("Cannot convert RSA key: {e}"))?; + + let mut name = X509NameBuilder::new().map_err(|e| format!("Cannot create certificate subject: {e}"))?; + name.append_entry_by_nid(Nid::COMMONNAME, "sysinspect-webapi") + .map_err(|e| format!("Cannot set certificate subject: {e}"))?; + let name = name.build(); + + let mut builder = X509::builder().map_err(|e| format!("Cannot build certificate: {e}"))?; + let mut serial = BigNum::new().map_err(|e| format!("Cannot allocate serial number: {e}"))?; + serial.rand(128, MsbOption::MAYBE_ZERO, false).map_err(|e| format!("Cannot generate serial number: {e}"))?; + let serial = serial.to_asn1_integer().map_err(|e| format!("Cannot encode serial number: {e}"))?; + builder.set_version(2).map_err(|e| format!("Cannot set certificate version: {e}"))?; + builder.set_serial_number(&serial).map_err(|e| format!("Cannot set serial number: {e}"))?; + builder.set_subject_name(&name).map_err(|e| format!("Cannot set subject: {e}"))?; + builder.set_issuer_name(&name).map_err(|e| format!("Cannot set issuer: {e}"))?; + builder.set_pubkey(&pkey).map_err(|e| format!("Cannot set public key: {e}"))?; + builder + .set_not_before(Asn1Time::days_from_now(0).map_err(|e| format!("Cannot set not-before time: {e}"))?.as_ref()) + .map_err(|e| format!("Cannot apply not-before time: {e}"))?; + builder + .set_not_after(Asn1Time::days_from_now(3650).map_err(|e| format!("Cannot set not-after time: {e}"))?.as_ref()) + .map_err(|e| format!("Cannot apply not-after time: {e}"))?; + builder.sign(&pkey, MessageDigest::sha256()).map_err(|e| format!("Cannot sign certificate: {e}"))?; + + std::fs::write(&cert_path, builder.build().to_pem().map_err(|e| format!("Cannot export certificate: {e}"))?) + .map_err(|e| format!("Cannot write {}: {e}", cert_path.display()))?; + std::fs::write( + &key_path, + pkey.private_key_to_pem_pkcs8().map_err(|e| format!("Cannot export private key: {e}"))?, + ) + .map_err(|e| format!("Cannot write {}: {e}", key_path.display()))?; + + self.write_webapi_tls_dropin(&cert_path, &key_path) + } + fn total_progress_steps(entry_count: usize) -> usize { entry_count.saturating_add(1) } @@ -788,7 +939,7 @@ impl SetupRequest { fn root_dir(&self) -> PathBuf { match self.installation_mode { InstallationMode::SystemWide => std::path::PathBuf::from("/etc/sysinspect"), - InstallationMode::Custom => std::path::PathBuf::from(&self.custom_destination), + InstallationMode::Custom => resolve_user_path(&self.custom_destination), } } @@ -895,6 +1046,9 @@ impl SetupRequest { let master_cfg: MasterConfig = serde_yaml::from_str(&partial).map_err(|e| format!("Cannot construct config: {e}"))?; let yaml = SysInspectConfig::default().set_master_config(master_cfg).to_yaml(); std::fs::write(&config_path, yaml).map_err(|e| format!("Cannot write config: {e}"))?; + if self.api_enabled && self.api_self_signed_tls { + self.generate_self_signed_webapi_cert()?; + } if let Some(progress) = progress.as_ref() && let Ok(mut state) = progress.lock() { @@ -912,7 +1066,7 @@ impl MasterSetupWizard { fn root_dir_for_write_config(&self) -> PathBuf { match self.installation_mode { InstallationMode::SystemWide => PathBuf::from("/etc/sysinspect"), - InstallationMode::Custom => PathBuf::from(self.custom_destination.value()), + InstallationMode::Custom => resolve_user_path(self.custom_destination.value()), } } } From b7a7e42161cd256aa1e36ccc7fe80497f6d6450a Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 26 Jun 2026 00:54:59 +0200 Subject: [PATCH 06/11] Dependencies --- Cargo.lock | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 8fdaa193..3cbbfcfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4470,6 +4470,7 @@ dependencies = [ "actix-files", "actix-web", "async-trait", + "base64", "colored", "futures-util", "hostname", @@ -7023,14 +7024,16 @@ dependencies = [ [[package]] name = "resource" -version = "0.1.0" +version = "0.2.1" dependencies = [ + "base64", "clap", "libdatastore", "libmodcore", "libsysinspect", "log", "reqwest 0.12.28", + "rsa", "serde", "serde_json", "serde_yaml", From 71018136bec7792280c7188c51d2095969562d0b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 26 Jun 2026 01:10:57 +0200 Subject: [PATCH 07/11] update docs --- docs/apidoc/overview.rst | 13 +++- docs/global_config.rst | 9 ++- docs/moddescr/cfg_resource.rst | 111 +++++++++++++++++++++++++++++++-- 3 files changed, 125 insertions(+), 8 deletions(-) diff --git a/docs/apidoc/overview.rst b/docs/apidoc/overview.rst index ac6814e4..f2813141 100644 --- a/docs/apidoc/overview.rst +++ b/docs/apidoc/overview.rst @@ -31,7 +31,7 @@ Authentication And Requests The Web API uses: - HTTPS/TLS for transport protection -- bearer tokens for authentication +- bearer tokens for operator and external client authentication - plain JSON request and response bodies Typical flow: @@ -40,6 +40,17 @@ Typical flow: 2. receive ``access_token`` 3. call later endpoints with ``Authorization: Bearer `` +Datastore endpoints used by internal minion-side resource workflows are a small +exception to the operator flow above: + +- a minion can bootstrap datastore access through ``POST /store/auth/minion`` +- the bootstrap request is signed with the minion's registered RSA identity +- the API returns a short-lived datastore bearer token +- later datastore calls use that token as normal bearer auth + +This keeps external API access operator-authenticated while letting internal +``cfg.resource`` model actions access the datastore transparently. + Swagger UI itself is served over the same HTTPS listener. Operators typically: 1. open ``https://:4202/doc/`` diff --git a/docs/global_config.rst b/docs/global_config.rst index 6f4dc2d8..e061f02f 100644 --- a/docs/global_config.rst +++ b/docs/global_config.rst @@ -497,11 +497,18 @@ Below are directives for the configuration of the File Server service: Type: **string** - Authentication method to be used for the embedded Web API. This is a string and can be one of the following: + Authentication method to be used for the embedded Web API. This applies to + normal operator or external API clients authenticating through + ``POST /api/v1/authenticate``. This is a string and can be one of the + following: - ``pam`` - ``ldap`` `(planned, not implemented yet)` + Datastore endpoints used by internal minion-originated ``cfg.resource`` + calls also support transparent minion-auth bootstrap and are not intended + to require operator PAM credentials inside model execution. + ``api.devmode`` ################ diff --git a/docs/moddescr/cfg_resource.rst b/docs/moddescr/cfg_resource.rst index bb0f4060..c9614f1e 100644 --- a/docs/moddescr/cfg_resource.rst +++ b/docs/moddescr/cfg_resource.rst @@ -17,12 +17,59 @@ It can: The module is designed for resource workflows where models only describe **what** to place and **where**. -At the moment, transport is resolved internally as: +Transport is resolved internally against ``master.ip`` from Minion +configuration and can be controlled explicitly with module args: -- ``http://:4202`` +- ``tls: true`` => ``https://:`` +- ``tls: false`` => ``http://:`` -where ``master.ip`` comes from Minion configuration, and ``4202`` is the current default API port. -Authentication headers are not used yet in this module version. +Authentication Model +-------------------- + +There are two different authentication contexts around ``cfg.resource``: + +- **Operator / external Web API access** + The embedded Web API uses normal HTTPS transport plus bearer-token + authentication for human or external API clients. + +- **Minion-internal resource access** + ``cfg.resource`` is intended to be used from minion-side model actions. + In that flow the minion is already trusted by the Master over the normal + Sysinspect transport, so datastore access should be transparent and must not + require PAM login prompts or operator bearer-token handling inside models. + +The current design for ``cfg.resource`` is therefore: + +- keep bearer-token auth for normal external Web API users +- allow datastore endpoints to accept **minion-auth** for minion-originated + requests +- keep the module itself stateless + +Current minion-auth bootstrap shape: + +- ``X-Sysinspect-Minion-Id`` +- ``X-Sysinspect-Timestamp`` +- ``X-Sysinspect-Signature`` +- ``X-Sysinspect-Body-Sha256`` + +The bootstrap signature covers a small canonical request string: + +- HTTP method +- request path +- query string +- timestamp +- body hash + +The module first authenticates on: + +- ``POST /store/auth/minion`` + +and receives a short-lived datastore bearer token. Subsequent datastore calls +for that module run then use normal ``Authorization: Bearer`` headers, but the +token was bootstrapped transparently from the minion's registered RSA identity. + +This keeps datastore access transparent for internal model execution while +still preventing unauthenticated external callers from using the same API. In simple words for day-to-day usage: @@ -43,6 +90,10 @@ The following options are available: ``force`` Disable checksum skip logic. Always push/pull even if data already matches. + ``sync-dir`` + Resolve all datastore items whose logical id matches the prefix from ``src`` + and materialise them into a local directory. + The following keyword arguments are available: @@ -62,10 +113,24 @@ The following keyword arguments are available: ``dst`` (type: string, optional) Alias for ``file``. + For ``sync-dir`` it should point to a destination directory. + ``mode`` (type: string, optional) File mode for pull result (octal, for example ``0644``). If omitted, mode from datastore metadata is applied. + ``tls`` (type: bool, optional) + Whether to use HTTPS for the datastore API transport. + Default: ``true``. + + ``tls-accept-insecure`` (type: bool, optional) + Allow self-signed or otherwise invalid TLS certificates. + Default: ``false``. + + ``port`` (type: integer, optional) + Web API port on the master. + Default: ``4202``. + Behavior Notes -------------- @@ -73,8 +138,13 @@ Behavior Notes - If ``file`` and ``dst`` are both absent, module uses ``src`` as local path. - ``pull`` checks local checksum first and skips download when already up to date (unless ``force``). - ``push`` checks datastore checksum first and skips upload when already up to date (unless ``force``). -- Module transport/auth details are not passed from model arguments. - They are taken from runtime Minion config injected automatically during module call. +- Only ``master.ip`` comes from runtime Minion config. +- Protocol and API port can be overridden explicitly through ``tls``, + ``tls-accept-insecure``, and ``port``. +- Operator bearer-token auth is not a suitable fit for internal model calls. +- ``cfg.resource`` therefore bootstraps datastore access from the minion's + registered RSA identity and then reuses a short-lived datastore bearer token + automatically. State Semantics @@ -110,6 +180,9 @@ Pull resource to local file: src: /somehost/etc/ssh/authorized_keys file: /etc/ssh/authorized_keys mode: "0600" + tls: true + tls-accept-insecure: true + port: 4202 Push local file to datastore: @@ -125,6 +198,9 @@ Push local file to datastore: args: src: /somehost/etc/ssh/authorized_keys file: /etc/ssh/authorized_keys + tls: true + tls-accept-insecure: true + port: 4202 Force pull even if checksum matches: @@ -140,6 +216,28 @@ Force pull even if checksum matches: args: src: /somehost/etc/ssh/authorized_keys dst: /etc/ssh/authorized_keys + tls: true + tls-accept-insecure: true + port: 4202 + +Sync all public keys from one logical prefix into a local directory: + +.. code-block:: yaml + + actions: + sync-pubring: + module: cfg.resource + bind: [shared-keyring] + state: + $: + opts: [sync-dir] + args: + src: /keyringdemo/ + dst: /tmp/keyringdemo/pubring + mode: "0644" + tls: true + tls-accept-insecure: true + port: 4202 Returning Data @@ -158,6 +256,7 @@ Typical extra fields in ``data``: - ``sha256`` resolved artifact checksum - ``size_bytes`` artifact size - ``mode`` resulting local mode (for pull) +- ``synced`` number of materialised files (for ``sync-dir``) The examples below are **module runtime protocol payloads** and are exchanged between Sysinspect runtime and module process over STDIN/STDOUT, therefore From 6c3fa7d1d15864f9d64c4dda93a3a3135bbca154 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 26 Jun 2026 01:11:30 +0200 Subject: [PATCH 08/11] Add bearer auth for internal modules --- libwebapi/src/api/v1/mod.rs | 4 ++-- libwebapi/src/api/v1/store.rs | 34 ++++++++++------------------------ 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/libwebapi/src/api/v1/mod.rs b/libwebapi/src/api/v1/mod.rs index b308b171..6cf17116 100644 --- a/libwebapi/src/api/v1/mod.rs +++ b/libwebapi/src/api/v1/mod.rs @@ -3,8 +3,8 @@ use crate::api::v1::{ minions::{QueryError, QueryRequest, QueryResponse, query_handler}, model::{ModelNameResponse, model_descr_handler, model_names_handler}, store::{ - StoreListQuery, StoreMetaResponse, StoreMinionAuthResponse, StoreResolveQuery, store_blob_handler, store_list_handler, - store_meta_handler, store_minion_auth_handler, store_resolve_handler, store_upload_handler, + StoreListQuery, StoreMetaResponse, StoreMinionAuthResponse, StoreResolveQuery, store_blob_handler, store_list_handler, store_meta_handler, + store_minion_auth_handler, store_resolve_handler, store_upload_handler, }, system::{AuthRequest, AuthResponse, HealthInfo, HealthResponse, authenticate_handler}, }; diff --git a/libwebapi/src/api/v1/store.rs b/libwebapi/src/api/v1/store.rs index ebd09c8e..9b33df54 100644 --- a/libwebapi/src/api/v1/store.rs +++ b/libwebapi/src/api/v1/store.rs @@ -1,10 +1,10 @@ use std::path::{Path, PathBuf}; -use base64::{Engine, engine::general_purpose::STANDARD}; use crate::{MasterInterfaceType, api::v1::minions::authorise_request, sessions::get_session_store}; use actix_files::NamedFile; use actix_web::Result as ActixResult; use actix_web::{HttpRequest, HttpResponse, Responder, get, post, web}; +use base64::{Engine, engine::general_purpose::STANDARD}; use futures_util::StreamExt; use libdatastore::resources::DataItemMeta; use libsysinspect::rsa::keys::{RsaKey, key_from_file, verify_sign}; @@ -59,13 +59,7 @@ fn minion_auth_material(method: &str, path: &str, query: &str, timestamp: &str, async fn verify_minion_bootstrap(req: &HttpRequest, master: &web::Data) -> Result { let reject = |msg: String| { - log::warn!( - "Datastore minion-auth bootstrap rejected for {} {} from {:?}: {}", - req.method(), - req.uri(), - req.peer_addr(), - msg - ); + log::warn!("Datastore minion-auth bootstrap rejected for {} {} from {:?}: {}", req.method(), req.uri(), req.peer_addr(), msg); libcommon::SysinspectError::WebAPIError(msg) }; @@ -96,9 +90,7 @@ async fn verify_minion_bootstrap(req: &HttpRequest, master: &web::Data() - .map_err(|_| reject("Invalid X-Sysinspect-Timestamp header".to_string()))?; + let ts = timestamp.parse::().map_err(|_| reject("Invalid X-Sysinspect-Timestamp header".to_string()))?; if now.abs_diff(ts) > MINION_AUTH_SKEW_SECS { return Err(reject("Expired minion-auth timestamp".to_string())); } @@ -108,20 +100,14 @@ async fn verify_minion_bootstrap(req: &HttpRequest, master: &web::Data pbk, - _ => { - return Err(reject(format!("Unknown minion or invalid public key for {}", minion_id))) - } - }; - let signature = STANDARD - .decode(signature_b64) - .map_err(|e| reject(format!("Invalid minion-auth signature encoding: {e}")))?; + let public_key = + match key_from_file(key_path.to_str().unwrap_or_default()).map_err(|e| reject(format!("Unable to load minion public key: {e}")))? { + Some(RsaKey::Public(pbk)) => pbk, + _ => return Err(reject(format!("Unknown minion or invalid public key for {}", minion_id))), + }; + let signature = STANDARD.decode(signature_b64).map_err(|e| reject(format!("Invalid minion-auth signature encoding: {e}")))?; let material = minion_auth_material(req.method().as_str(), req.path(), req.query_string(), timestamp, body_sha256); - let verified = verify_sign(&public_key, material.as_bytes(), signature) - .map_err(|e| reject(format!("Minion-auth verification failed: {e}")))?; + let verified = verify_sign(&public_key, material.as_bytes(), signature).map_err(|e| reject(format!("Minion-auth verification failed: {e}")))?; if !verified { return Err(reject("Invalid minion-auth signature".to_string())); } From d22eee897435c49bcd783878e256c81e3f18931a Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 26 Jun 2026 01:12:07 +0200 Subject: [PATCH 09/11] support signatures for internal auth --- modules/cfg/resource/src/main.rs | 58 +++++++++----------------------- 1 file changed, 15 insertions(+), 43 deletions(-) diff --git a/modules/cfg/resource/src/main.rs b/modules/cfg/resource/src/main.rs index a17bd40e..fb12536a 100644 --- a/modules/cfg/resource/src/main.rs +++ b/modules/cfg/resource/src/main.rs @@ -118,11 +118,7 @@ fn bytes_sha256_hex(data: &[u8]) -> String { } fn timestamp_now() -> String { - std::time::SystemTime::now() - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - .to_string() + std::time::SystemTime::now().duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap_or_default().as_secs().to_string() } fn canonical_auth_material(method: &str, path: &str, query: &str, timestamp: &str, body_sha256: &str) -> String { @@ -141,18 +137,11 @@ fn machine_id_path(rq: &ModRequest) -> PathBuf { return PathBuf::from(mid); } let default = PathBuf::from(DEFAULT_MINION_MACHINE_ID); - if default.exists() { - default - } else { - cfg_root(rq).join(DEFAULT_MINION_MACHINE_ID_REL) - } + if default.exists() { default } else { cfg_root(rq).join(DEFAULT_MINION_MACHINE_ID_REL) } } fn load_minion_auth(rq: &ModRequest) -> Result { - let minion_id = fs::read_to_string(machine_id_path(rq)) - .map_err(|e| format!("Unable to read minion identity: {e}"))? - .trim() - .to_string(); + let minion_id = fs::read_to_string(machine_id_path(rq)).map_err(|e| format!("Unable to read minion identity: {e}"))?.trim().to_string(); if minion_id.is_empty() { return Err("Minion identity is empty".to_string()); } @@ -194,18 +183,14 @@ fn bearer_request(client: &reqwest::blocking::Client, token: &str, method: reqwe fn api_base(rq: &ModRequest) -> String { let ip = cfg_str(rq, "master.ip").unwrap_or_else(|| "127.0.0.1".to_string()); - let port = arg_str(rq, "port") - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_API_PORT); + let port = arg_str(rq, "port").and_then(|v| v.parse::().ok()).unwrap_or(DEFAULT_API_PORT); let scheme = if arg_bool(rq, "tls").unwrap_or(true) { "https" } else { "http" }; format!("{scheme}://{ip}:{port}") } fn bootstrap_datastore_token(client: &reqwest::blocking::Client, auth: &MinionAuth, base: &str) -> Result { let url = format!("{base}/store/auth/minion"); - let rsp = authed_request(client, auth, reqwest::Method::POST, &url, &[], "")? - .send() - .map_err(|e| format!("store auth request failed: {e}"))?; + let rsp = authed_request(client, auth, reqwest::Method::POST, &url, &[], "")?.send().map_err(|e| format!("store auth request failed: {e}"))?; if !rsp.status().is_success() { return Err(format!("store auth request failed: HTTP {}", rsp.status())); } @@ -283,16 +268,7 @@ fn mk_ctx(rq: &ModRequest) -> Result { let base = api_base(rq); let token = bootstrap_datastore_token(&cl, &auth, &base)?; - Ok(Ctx { - cl, - b: base, - t: token, - s, - f: arg_str(rq, "file"), - d: arg_str(rq, "dst"), - m: arg_str(rq, "mode"), - force: rq.has_option("force"), - }) + Ok(Ctx { cl, b: base, t: token, s, f: arg_str(rq, "file"), d: arg_str(rq, "dst"), m: arg_str(rq, "mode"), force: rq.has_option("force") }) } fn local_p(c: &Ctx) -> PathBuf { @@ -383,12 +359,8 @@ fn do_pull(c: &Ctx) -> Result<(bool, String, JsonMap), String> { fn do_sync_dir(c: &Ctx) -> Result<(bool, String, JsonMap), String> { let mut d = JsonMap::new(); - let dst_dir = c - .f - .clone() - .or_else(|| c.d.clone()) - .map(PathBuf::from) - .ok_or_else(|| "Argument \"file\" or \"dst\" is required for sync-dir".to_string())?; + let dst_dir = + c.f.clone().or_else(|| c.d.clone()).map(PathBuf::from).ok_or_else(|| "Argument \"file\" or \"dst\" is required for sync-dir".to_string())?; fs::create_dir_all(&dst_dir).map_err(|e| format!("Unable to create destination directory '{}': {e}", dst_dir.display()))?; let metas = list_meta(&c.cl, &c.t, &c.b, &c.s)?; @@ -402,13 +374,13 @@ fn do_sync_dir(c: &Ctx) -> Result<(bool, String, JsonMap), String> { continue; }; let dst = dst_dir.join(name); - if dst.exists() && !c.force { - if let Ok(h) = file_sha256(&dst) - && h == meta.sha256 - { - synced += 1; - continue; - } + if dst.exists() + && !c.force + && let Ok(h) = file_sha256(&dst) + && h == meta.sha256 + { + synced += 1; + continue; } let url = format!("{}/store/{}/blob", c.b, meta.sha256); From d8f198b3996f6b6109ac7cb3b8eb5f2544fc1ba9 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 26 Jun 2026 01:12:25 +0200 Subject: [PATCH 10/11] Fix artifacts resync --- src/ui/mod.rs | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1def4175..2d408e39 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2629,6 +2629,29 @@ impl SysInspectUX { } } + fn flush_repo_manager_reload(&mut self) { + if !self.repo_manager.needs_reload { + return; + } + self.repo_manager.needs_reload = false; + let _ = self.load_module_index(); + let _ = self.load_model_list(); + let _ = self.load_sensor_list(); + let _ = self.load_library_index(); + if self.repo_manager.active_tab == 5 { + let _ = self.load_platforms(); + } + self.repo_manager.profiles.has_global_modules.set(self.repo_manager.module_groups.values().any(|v| !v.is_empty())); + self.repo_manager.profiles.has_global_models.set(!self.repo_manager.model_rows.is_empty()); + if self.minions_rows.is_empty() + && let Ok(rows) = self.fetch_minions() + { + self.minions_rows = rows; + } + self.repo_manager.profiles.has_connected_minions.set(!self.minions_rows.is_empty()); + self.mark_repo_sync_pending(); + } + fn on_repo_manager(&mut self, e: event::KeyEvent) -> bool { if !self.repo_manager.visible { return false; @@ -2970,6 +2993,7 @@ impl SysInspectUX { } self.repo_manager.models_dirty = false; } + self.flush_repo_manager_reload(); let start_repo_sync = self.repo_manager.pending_cluster_upgrade; self.repo_manager.pending_cluster_upgrade = false; self.repo_manager.exit_staging(); @@ -3234,10 +3258,7 @@ impl SysInspectUX { } else if self.repo_manager.active_tab == 4 { self.repo_manager.profiles.open_create(); self.status_at_profiles(); - } else if self.repo_manager.active_tab == 3 { - let start_dir = std::env::current_dir().unwrap_or_default(); - self.file_picker.open(&start_dir, filepicker::PickerMode::DirectoryPicker); - } else if self.repo_manager.active_tab == 2 { + } else if self.repo_manager.active_tab == 3 || self.repo_manager.active_tab == 2 { let start_dir = std::env::current_dir().unwrap_or_default(); self.file_picker.open(&start_dir, filepicker::PickerMode::DirectoryPicker); } else { From 7babca70fb1bb610d7a313d5df36b4e4b4759695 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 26 Jun 2026 01:12:46 +0200 Subject: [PATCH 11/11] Update setup workflow --- src/ui/setup.rs | 35 +++++++++-------------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/src/ui/setup.rs b/src/ui/setup.rs index d7d59c53..f9a86d44 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -45,11 +45,7 @@ fn resolve_user_path(raw: &str) -> PathBuf { } let path = PathBuf::from(trimmed); - if path.is_absolute() { - path - } else { - std::env::current_dir().unwrap_or_default().join(path) - } + if path.is_absolute() { path } else { std::env::current_dir().unwrap_or_default().join(path) } } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -329,10 +325,8 @@ impl MasterSetupWizard { SetupFocus::ApiCheck => { self.api_enabled = !self.api_enabled; } - SetupFocus::ApiSelfSignedCheck => { - if self.api_enabled { - self.api_self_signed_tls = !self.api_self_signed_tls; - } + SetupFocus::ApiSelfSignedCheck if self.api_enabled => { + self.api_self_signed_tls = !self.api_self_signed_tls; } _ => {} // input fields — Enter does nothing (text is handled by char keys) }, @@ -552,11 +546,8 @@ impl MasterSetupWizard { buf.set_string(inner.x + 1, row_y, api_chk, api_style); row_y += 1; - let self_signed_chk = if self.api_self_signed_tls { - " ▣ Setup with self-signed TLS certificate" - } else { - " □ Setup with self-signed TLS certificate" - }; + let self_signed_chk = + if self.api_self_signed_tls { " ▣ Setup with self-signed TLS certificate" } else { " □ Setup with self-signed TLS certificate" }; let self_signed_style = if !self.api_enabled { Style::default().fg(palette::MUTED) } else if self.is_focused(SetupFocus::ApiSelfSignedCheck) { @@ -732,11 +723,7 @@ pub fn render_progress(progress: &SetupProgress, parent: Rect, buf: &mut Buffer) impl SetupRequest { fn config_path(&self) -> PathBuf { let root = self.root_dir(); - if matches!(self.installation_mode, InstallationMode::SystemWide) { - root.join("sysinspect.conf") - } else { - root.join("etc/sysinspect.conf") - } + if matches!(self.installation_mode, InstallationMode::SystemWide) { root.join("sysinspect.conf") } else { root.join("etc/sysinspect.conf") } } fn config_dropin_dir(&self) -> PathBuf { @@ -772,8 +759,7 @@ impl SetupRequest { let pkey = PKey::from_rsa(rsa).map_err(|e| format!("Cannot convert RSA key: {e}"))?; let mut name = X509NameBuilder::new().map_err(|e| format!("Cannot create certificate subject: {e}"))?; - name.append_entry_by_nid(Nid::COMMONNAME, "sysinspect-webapi") - .map_err(|e| format!("Cannot set certificate subject: {e}"))?; + name.append_entry_by_nid(Nid::COMMONNAME, "sysinspect-webapi").map_err(|e| format!("Cannot set certificate subject: {e}"))?; let name = name.build(); let mut builder = X509::builder().map_err(|e| format!("Cannot build certificate: {e}"))?; @@ -795,11 +781,8 @@ impl SetupRequest { std::fs::write(&cert_path, builder.build().to_pem().map_err(|e| format!("Cannot export certificate: {e}"))?) .map_err(|e| format!("Cannot write {}: {e}", cert_path.display()))?; - std::fs::write( - &key_path, - pkey.private_key_to_pem_pkcs8().map_err(|e| format!("Cannot export private key: {e}"))?, - ) - .map_err(|e| format!("Cannot write {}: {e}", key_path.display()))?; + std::fs::write(&key_path, pkey.private_key_to_pem_pkcs8().map_err(|e| format!("Cannot export private key: {e}"))?) + .map_err(|e| format!("Cannot write {}: {e}", key_path.display()))?; self.write_webapi_tls_dropin(&cert_path, &key_path) }