From df32db14a2771c3745f1744298860f2a5f60e4c7 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 13 Mar 2026 21:20:35 +0100 Subject: [PATCH 01/54] Add traits sync command and CLI --- libsysinspect/src/context/mod.rs | 5 +++ libsysproto/src/query.rs | 3 ++ src/clidef.rs | 10 +++++ src/main.rs | 65 +++++++++++++++++++++++++++++++- sysminion/src/minion.rs | 11 ++++-- 5 files changed, 89 insertions(+), 5 deletions(-) diff --git a/libsysinspect/src/context/mod.rs b/libsysinspect/src/context/mod.rs index d2e63876..11501560 100644 --- a/libsysinspect/src/context/mod.rs +++ b/libsysinspect/src/context/mod.rs @@ -67,3 +67,8 @@ pub fn get_context(c: &str) -> Option> { Some(c) } + +/// Parse comma-separated keys from a string. +pub fn get_context_keys(c: &str) -> Vec { + c.trim().split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect() +} diff --git a/libsysproto/src/query.rs b/libsysproto/src/query.rs index 2975db67..69232da9 100644 --- a/libsysproto/src/query.rs +++ b/libsysproto/src/query.rs @@ -26,6 +26,9 @@ pub mod commands { // Get online minions pub const CLUSTER_ONLINE_MINIONS: &str = "cluster/minion/online"; + + // Update master-managed static traits on minions + pub const CLUSTER_TRAITS_UPDATE: &str = "cluster/traits/update"; } /// diff --git a/src/clidef.rs b/src/clidef.rs index e4338db3..001db64a 100644 --- a/src/clidef.rs +++ b/src/clidef.rs @@ -31,6 +31,16 @@ pub fn cli(version: &'static str) -> Command { .arg(Arg::new("arch").short('a').long("arch").help("Specify the module architecture (x86, x64, arm, arm64, noarch)").default_value("noarch")) .arg(Arg::new("help").short('h').long("help").action(ArgAction::SetTrue).help("Display help for this command")) ) + .subcommand(Command::new("traits").about("Sync or update minion traits").styles(styles.clone()).disable_help_flag(true) + .arg(Arg::new("set").long("set").help("Set traits as comma-separated key:value pairs").conflicts_with_all(["unset", "reset"])) + .arg(Arg::new("unset").long("unset").help("Unset traits as comma-separated keys").conflicts_with_all(["set", "reset"])) + .arg(Arg::new("reset").long("reset").action(ArgAction::SetTrue).help("Reset all master-managed traits on targeted minions").conflicts_with_all(["set", "unset"])) + .arg(Arg::new("id").long("id").help("Target a specific minion by its system id").conflicts_with_all(["query", "query-pos"])) + .arg(Arg::new("query").long("query").help("Target minions by hostname glob or query").conflicts_with("query-pos")) + .arg(Arg::new("select-traits").long("traits").help("Target minions by traits query")) + .arg(Arg::new("query-pos").help("Target minions by hostname glob or query").required(false).index(1)) + .arg(Arg::new("help").short('h').long("help").action(ArgAction::SetTrue).help("Display help for this command")) + ) // Sysinspect .next_help_heading("Main") diff --git a/src/main.rs b/src/main.rs index 4f6e06a2..19745306 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,14 +7,18 @@ use libsysinspect::{ mmconf::{MasterConfig, MinionConfig}, select_config_path, }, + context, inspector::SysInspectRunner, logger::{self, MemoryLogger, STDOUTLogger}, reactor::handlers, traits::get_minion_traits, }; use libsysproto::query::SCHEME_COMMAND; -use libsysproto::query::commands::{CLUSTER_ONLINE_MINIONS, CLUSTER_REMOVE_MINION, CLUSTER_SHUTDOWN, CLUSTER_SYNC}; +use libsysproto::query::commands::{ + CLUSTER_ONLINE_MINIONS, CLUSTER_REMOVE_MINION, CLUSTER_SHUTDOWN, CLUSTER_SYNC, CLUSTER_TRAITS_UPDATE, +}; use log::LevelFilter; +use serde_json::json; use std::{ env, fs::OpenOptions, @@ -53,6 +57,32 @@ fn call_master_fifo( Ok(()) } +fn call_master_command(model: &str, query: &str, traits: Option<&String>, mid: Option<&str>, fifo: &str, context: Option) -> Result<(), SysinspectError> { + call_master_fifo(model, query, traits, mid, fifo, context.as_ref()) +} + +fn traits_update_context(am: &ArgMatches) -> Result, SysinspectError> { + if let Some(setv) = am.get_one::("set") { + let traits = context::get_context(setv) + .ok_or_else(|| SysinspectError::InvalidQuery("Trait values must be in key:value format".to_string()))?; + return Ok(Some(json!({"op": "set", "traits": traits}).to_string())); + } + + if let Some(keys) = am.get_one::("unset") { + return Ok(Some(json!({ + "op": "unset", + "traits": context::get_context_keys(keys).into_iter().map(|key| (key, serde_json::Value::Null)).collect::>() + }) + .to_string())); + } + + if am.get_flag("reset") { + return Ok(Some(json!({"op": "reset", "traits": {}}).to_string())); + } + + Err(SysinspectError::InvalidQuery("Specify one of --set, --unset, or --reset".to_string())) +} + /// Set logger fn set_logger(p: &ArgMatches) { let log: &'static dyn log::Log = if *p.get_one::("ui").unwrap_or(&false) { @@ -88,6 +118,15 @@ fn help(cli: &mut Command, params: &ArgMatches) -> bool { } return false; } + if let Some(sub) = params.subcommand_matches("traits") + && sub.get_flag("help") + { + if let Some(s_cli) = cli.find_subcommand_mut("traits") { + _ = s_cli.print_help(); + return true; + } + return false; + } if params.get_flag("help") { _ = &cli.print_long_help(); return true; @@ -218,6 +257,30 @@ async fn main() { exit(0) } + if let Some(sub) = params.subcommand_matches("traits") { + let target_id = sub.get_one::("id").map(String::as_str); + let target_query = sub + .get_one::("query") + .or_else(|| sub.get_one::("query-pos")) + .map(String::as_str) + .unwrap_or("*"); + let target_traits = sub.get_one::("select-traits"); + let scheme = format!("{SCHEME_COMMAND}{CLUSTER_TRAITS_UPDATE}"); + + let context = match traits_update_context(sub) { + Ok(ctx) => ctx, + Err(err) => { + log::error!("{err}"); + exit(1); + } + }; + + if let Err(err) = call_master_command(&scheme, target_query, target_traits, target_id, &cfg.socket(), context) { + log::error!("Cannot reach master: {err}"); + } + exit(0); + } + if *params.get_one::("list-handlers").unwrap_or(&false) { print_event_handlers(); return; diff --git a/sysminion/src/minion.rs b/sysminion/src/minion.rs index eb05a501..7e3def43 100644 --- a/sysminion/src/minion.rs +++ b/sysminion/src/minion.rs @@ -43,7 +43,7 @@ use libsysproto::{ payload::{ModStatePayload, PayloadType}, query::{ MinionQuery, SCHEME_COMMAND, - commands::{CLUSTER_REBOOT, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_SHUTDOWN, CLUSTER_SYNC}, + commands::{CLUSTER_REBOOT, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_SHUTDOWN, CLUSTER_SYNC, CLUSTER_TRAITS_UPDATE}, }, rqtypes::{ProtoValue, RequestType}, }; @@ -487,7 +487,7 @@ impl SysMinion { let scheme = msg.target().scheme().to_string(); if scheme.starts_with(SCHEME_COMMAND) { - this.as_ptr().call_internal_command(&scheme).await; + this.as_ptr().call_internal_command(&scheme, msg.target().context()).await; continue; } @@ -823,7 +823,7 @@ impl SysMinion { } /// Calls internal command - async fn call_internal_command(self: Arc, cmd: &str) { + async fn call_internal_command(self: Arc, cmd: &str, context: &str) { let cmd = cmd.strip_prefix(SCHEME_COMMAND).unwrap_or_default(); match cmd { CLUSTER_SHUTDOWN => { @@ -848,6 +848,9 @@ impl SysMinion { } let _ = self.as_ptr().send_sensors_sync().await; } + CLUSTER_TRAITS_UPDATE => { + log::error!("Received traits update payload: {}", context); + } _ => { log::warn!("Unknown command: {cmd}"); } @@ -917,7 +920,7 @@ impl SysMinion { match PayloadType::try_from(cmd.payload().clone()) { Ok(PayloadType::ModelOrStatement(pld)) => { if cmd.target().scheme().starts_with(SCHEME_COMMAND) { - self.as_ptr().call_internal_command(cmd.target().scheme()).await; + self.as_ptr().call_internal_command(cmd.target().scheme(), cmd.target().context()).await; } else { self.as_ptr().launch_sysinspect(cmd.cycle(), cmd.target().scheme(), &pld, cmd.target().context()).await; log::debug!("Command dispatched"); From 4f5415863e501a4270095e0e9b14185e14eef2d1 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 13 Mar 2026 22:34:07 +0100 Subject: [PATCH 02/54] Write traits to the minion remotely --- libsysinspect/src/traits/mod.rs | 75 +++++++++++++++++++++++++++ libsysinspect/src/traits/systraits.rs | 60 ++++++++++++--------- sysminion/src/minion.rs | 34 +++++++++++- 3 files changed, 142 insertions(+), 27 deletions(-) diff --git a/libsysinspect/src/traits/mod.rs b/libsysinspect/src/traits/mod.rs index cca66947..5a886388 100644 --- a/libsysinspect/src/traits/mod.rs +++ b/libsysinspect/src/traits/mod.rs @@ -6,9 +6,14 @@ use libcommon::SysinspectError; use once_cell::sync::OnceCell; use pest::Parser; use pest_derive::Parser; +use serde::Deserialize; use serde_json::Value; +use std::fs; use systraits::SystemTraits; +#[cfg(test)] +mod traits_ut; + /// Standard Traits pub static SYS_ID: &str = "system.id"; pub static SYS_OS_KERNEL: &str = "system.kernel"; @@ -26,6 +31,8 @@ pub static HW_CPU_BRAND: &str = "hardware.cpu.brand"; pub static HW_CPU_FREQ: &str = "hardware.cpu.frequency"; pub static HW_CPU_VENDOR: &str = "hardware.cpu.vendor"; pub static HW_CPU_CORES: &str = "hardware.cpu.cores"; +pub static MASTER_TRAITS_FILE: &str = "master.cfg"; +static MASTER_TRAITS_FILE_HEADER: &str = "# THIS FILE IS AUTOGENERATED BY SYSINSPECT MASTER.\n# DO NOT EDIT THIS FILE MANUALLY.\n# LOCAL CUSTOM TRAITS BELONG IN SEPARATE *.cfg FILES.\n"; #[derive(Parser)] #[grammar = "traits/traits_query.pest"] @@ -122,3 +129,71 @@ fn __get_minion_traits(cfg: Option<&MinionConfig>, q: bool) -> SystemTraits { _TRAITS.get_or_init(|| SystemTraits::new(MinionConfig::default(), q)).to_owned() } + +/// Ensure the reserved master-managed traits file exists under the traits directory. +pub fn ensure_master_traits_file(cfg: &MinionConfig) -> Result<(), SysinspectError> { + if !cfg.traits_dir().exists() { + fs::create_dir_all(cfg.traits_dir())?; + } + + let master_traits = cfg.traits_dir().join(MASTER_TRAITS_FILE); + if !master_traits.exists() { + fs::write(master_traits, MASTER_TRAITS_FILE_HEADER)?; + } + + Ok(()) +} + +fn load_master_traits(cfg: &MinionConfig) -> Result, SysinspectError> { + ensure_master_traits_file(cfg)?; + let content = fs::read_to_string(cfg.traits_dir().join(MASTER_TRAITS_FILE))?; + Ok(serde_yaml::from_str::>>(&content)?.unwrap_or_default()) +} + +fn store_master_traits(cfg: &MinionConfig, traits: &IndexMap) -> Result<(), SysinspectError> { + ensure_master_traits_file(cfg)?; + let body = if traits.is_empty() { String::new() } else { serde_yaml::to_string(traits)? }; + fs::write(cfg.traits_dir().join(MASTER_TRAITS_FILE), format!("{MASTER_TRAITS_FILE_HEADER}{body}"))?; + Ok(()) +} + +#[derive(Debug, Deserialize)] +pub struct TraitUpdateRequest { + op: String, + #[serde(default)] + traits: IndexMap, +} + +impl TraitUpdateRequest { + pub fn from_context(context: &str) -> Result { + Ok(serde_json::from_str(context)?) + } + + pub fn apply(&self, cfg: &MinionConfig) -> Result, SysinspectError> { + let mut current = load_master_traits(cfg)?; + match self.op.as_str() { + "set" => { + for (key, value) in &self.traits { + current.insert(key.to_string(), value.clone()); + } + } + "unset" => { + for key in self.traits.keys() { + current.shift_remove(key); + } + } + "reset" => current.clear(), + _ => return Err(SysinspectError::InvalidQuery(format!("Unknown trait update operation: {}", self.op))), + } + store_master_traits(cfg, ¤t)?; + Ok(current) + } + + pub fn op(&self) -> &str { + &self.op + } + + pub fn traits(&self) -> &IndexMap { + &self.traits + } +} diff --git a/libsysinspect/src/traits/systraits.rs b/libsysinspect/src/traits/systraits.rs index dcfbc00a..0bdf14f8 100644 --- a/libsysinspect/src/traits/systraits.rs +++ b/libsysinspect/src/traits/systraits.rs @@ -1,8 +1,8 @@ use crate::{ cfg::mmconf::MinionConfig, traits::{ - HW_CPU_BRAND, HW_CPU_CORES, HW_CPU_FREQ, HW_CPU_TOTAL, HW_CPU_VENDOR, HW_MEM, HW_SWAP, SYS_ID, SYS_NET_HOSTNAME, SYS_NET_HOSTNAME_FQDN, - SYS_NET_HOSTNAME_IP, SYS_OS_DISTRO, SYS_OS_KERNEL, SYS_OS_NAME, SYS_OS_VERSION, + HW_CPU_BRAND, HW_CPU_CORES, HW_CPU_FREQ, HW_CPU_TOTAL, HW_CPU_VENDOR, HW_MEM, HW_SWAP, MASTER_TRAITS_FILE, SYS_ID, SYS_NET_HOSTNAME, + SYS_NET_HOSTNAME_FQDN, SYS_NET_HOSTNAME_IP, SYS_OS_DISTRO, SYS_OS_KERNEL, SYS_OS_NAME, SYS_OS_VERSION, }, util::sys::to_fqdn_ip, }; @@ -196,38 +196,48 @@ impl SystemTraits { log::info!("Loading defined/custom traits"); } - for f in fs::read_dir(self.cfg.traits_dir())?.flatten() { + let mut files = fs::read_dir(self.cfg.traits_dir())? + .flatten() + .filter(|f| f.file_name().to_str().unwrap_or_default().ends_with(".cfg")) + .collect::>(); + files.sort_by_key(|f| { + let name = f.file_name().to_str().unwrap_or_default().to_string(); + (name == MASTER_TRAITS_FILE, name) + }); + + for f in files { let fname = f.file_name(); let fname = fname.to_str().unwrap_or_default(); - if fname.ends_with(".cfg") { - let content = Self::proxy_log_error(fs::read_to_string(f.path()), format!("Unable to read custom trait file at {fname}").as_str()) - .unwrap_or_default(); + let content = Self::proxy_log_error(fs::read_to_string(f.path()), format!("Unable to read custom trait file at {fname}").as_str()) + .unwrap_or_default(); - if content.is_empty() { - continue; - } + if content.is_empty() { + continue; + } - let content: Option = Self::proxy_log_error(serde_yaml::from_str(&content), "Custom trait file has broken YAML"); + let content: Option = Self::proxy_log_error(serde_yaml::from_str(&content), "Custom trait file has broken YAML"); - let content: Option = - content.as_ref().and_then(|v| Self::proxy_log_error(serde_json::to_value(v), "Unable to convert existing YAML to JSON format")); + let content: Option = + content.as_ref().and_then(|v| Self::proxy_log_error(serde_json::to_value(v), "Unable to convert existing YAML to JSON format")); - if content.is_none() { - log::error!("Unable to load custom traits from {}", f.file_name().to_str().unwrap_or_default()); - continue; - } + if content.is_none() { + log::error!("Unable to load custom traits from {}", f.file_name().to_str().unwrap_or_default()); + continue; + } - let content = content.as_ref().and_then(|v| { - Self::proxy_log_error(serde_json::from_value::>(v.clone()), "Unable to parse JSON") - }); + if fname == MASTER_TRAITS_FILE && content.as_ref().is_some_and(serde_json::Value::is_null) { + continue; + } - if let Some(content) = content { - for (k, v) in content { - self.put(k, json!(v)); - } - } else { - log::error!("Custom traits data is empty or in a wrong format"); + let content = + content.as_ref().and_then(|v| Self::proxy_log_error(serde_json::from_value::>(v.clone()), "Unable to parse JSON")); + + if let Some(content) = content { + for (k, v) in content { + self.put(k, json!(v)); } + } else { + log::error!("Custom traits data is empty or in a wrong format"); } } Ok(()) diff --git a/sysminion/src/minion.rs b/sysminion/src/minion.rs index 7e3def43..dda923fe 100644 --- a/sysminion/src/minion.rs +++ b/sysminion/src/minion.rs @@ -34,7 +34,7 @@ use libsysinspect::{ fmt::{formatter::StringFormatter, kvfmt::KeyValueFormatter}, }, rsa, - traits::{self}, + traits::{self, TraitUpdateRequest, ensure_master_traits_file}, util::{self, dataconv}, }; use libsysproto::{ @@ -171,6 +171,7 @@ impl SysMinion { log::debug!("Creating directory for the drop-in traits at {}", self.cfg.traits_dir().as_os_str().to_str().unwrap_or_default()); fs::create_dir_all(self.cfg.traits_dir())?; } + ensure_master_traits_file(&self.cfg)?; // Place for trait functions if !self.cfg.functions_dir().exists() { @@ -843,13 +844,42 @@ impl SysMinion { } CLUSTER_SYNC => { log::info!("Syncing the minion with the master"); + if let Err(e) = ensure_master_traits_file(&self.cfg) { + log::error!("Failed to ensure master-managed traits file: {e}"); + } if let Err(e) = SysInspectModPakMinion::new(self.cfg.clone()).sync().await { log::error!("Failed to sync minion with master: {e}"); } let _ = self.as_ptr().send_sensors_sync().await; } CLUSTER_TRAITS_UPDATE => { - log::error!("Received traits update payload: {}", context); + match TraitUpdateRequest::from_context(context) { + Ok(update) => match update.apply(&self.cfg) { + Ok(_) => { + let summary = if update.op() == "reset" { + "all master-managed traits".bright_yellow().to_string() + } else if update.op() == "unset" { + update.traits().keys().map(|key| key.yellow().to_string()).collect::>().join(", ") + } else { + update + .traits() + .iter() + .map(|(key, value)| format!("{}: {}", key.yellow(), dataconv::to_string(Some(value.clone())).unwrap_or_default().bright_yellow())) + .collect::>() + .join(", ") + }; + let label = match update.op() { + "set" => "Set traits", + "unset" => "Unset traits", + "reset" => "Reset traits", + _ => "Updated traits", + }; + log::info!("{}: {}", label, summary); + } + Err(err) => log::error!("Failed to apply traits update: {err}"), + }, + Err(err) => log::error!("Failed to parse traits update payload: {err}"), + } } _ => { log::warn!("Unknown command: {cmd}"); From 5d3859a921de469121004f16fe58d9664d593a96 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 13 Mar 2026 22:34:16 +0100 Subject: [PATCH 03/54] Add unit tests --- libsysinspect/src/traits/traits_ut.rs | 88 +++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 libsysinspect/src/traits/traits_ut.rs diff --git a/libsysinspect/src/traits/traits_ut.rs b/libsysinspect/src/traits/traits_ut.rs new file mode 100644 index 00000000..7dad190b --- /dev/null +++ b/libsysinspect/src/traits/traits_ut.rs @@ -0,0 +1,88 @@ +use crate::cfg::mmconf::MinionConfig; +use crate::traits::{MASTER_TRAITS_FILE, TraitUpdateRequest, ensure_master_traits_file}; +use crate::traits::systraits::SystemTraits; +use std::fs; + +#[test] +fn ensure_master_traits_file_creates_reserved_master_cfg() { + let root = tempfile::tempdir().unwrap_or_else(|err| panic!("failed to create tempdir: {err}")); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().unwrap_or_default()); + + ensure_master_traits_file(&cfg).unwrap_or_else(|err| panic!("failed to ensure master traits file: {err}")); + + let pth = cfg.traits_dir().join(MASTER_TRAITS_FILE); + assert!(pth.exists(), "master-managed traits file should exist"); + + let content = fs::read_to_string(pth).unwrap_or_else(|err| panic!("failed to read master traits file: {err}")); + assert!(content.contains("AUTOGENERATED"), "master-managed traits header should be present"); +} + +#[test] +fn trait_update_request_set_writes_master_managed_traits() { + let root = tempfile::tempdir().unwrap_or_else(|err| panic!("failed to create tempdir: {err}")); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().unwrap_or_default()); + + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"foo":"bar","count":3}}"#) + .unwrap_or_else(|err| panic!("failed to parse traits update: {err}")) + .apply(&cfg) + .unwrap_or_else(|err| panic!("failed to apply traits update: {err}")); + + let content = + fs::read_to_string(cfg.traits_dir().join(MASTER_TRAITS_FILE)).unwrap_or_else(|err| panic!("failed to read master traits file: {err}")); + assert!(content.contains("foo: bar")); + assert!(content.contains("count: 3")); +} + +#[test] +fn trait_update_request_unset_removes_keys_from_master_managed_traits() { + let root = tempfile::tempdir().unwrap_or_else(|err| panic!("failed to create tempdir: {err}")); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().unwrap_or_default()); + + let set = TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"foo":"bar","keep":"yes"}}"#) + .unwrap_or_else(|err| panic!("failed to parse set request: {err}")); + set.apply(&cfg).unwrap_or_else(|err| panic!("failed to apply set request: {err}")); + + let unset = TraitUpdateRequest::from_context(r#"{"op":"unset","traits":{"foo":null}}"#) + .unwrap_or_else(|err| panic!("failed to parse unset request: {err}")); + unset.apply(&cfg).unwrap_or_else(|err| panic!("failed to apply unset request: {err}")); + + let content = + fs::read_to_string(cfg.traits_dir().join(MASTER_TRAITS_FILE)).unwrap_or_else(|err| panic!("failed to read master traits file: {err}")); + assert!(!content.contains("foo:")); + assert!(content.contains("keep: yes")); +} + +#[test] +fn trait_update_request_reset_clears_master_managed_traits_file_body() { + let root = tempfile::tempdir().unwrap_or_else(|err| panic!("failed to create tempdir: {err}")); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().unwrap_or_default()); + + let set = TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"foo":"bar"}}"#) + .unwrap_or_else(|err| panic!("failed to parse set request: {err}")); + set.apply(&cfg).unwrap_or_else(|err| panic!("failed to apply set request: {err}")); + + let reset = TraitUpdateRequest::from_context(r#"{"op":"reset","traits":{}}"#) + .unwrap_or_else(|err| panic!("failed to parse reset request: {err}")); + reset.apply(&cfg).unwrap_or_else(|err| panic!("failed to apply reset request: {err}")); + + let content = + fs::read_to_string(cfg.traits_dir().join(MASTER_TRAITS_FILE)).unwrap_or_else(|err| panic!("failed to read master traits file: {err}")); + assert!(content.contains("AUTOGENERATED")); + assert!(!content.contains("foo:")); +} + +#[test] +fn empty_master_traits_file_is_accepted_during_trait_load() { + let root = tempfile::tempdir().unwrap_or_else(|err| panic!("failed to create tempdir: {err}")); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().unwrap_or_default()); + + ensure_master_traits_file(&cfg).unwrap_or_else(|err| panic!("failed to ensure master traits file: {err}")); + + let traits = SystemTraits::new(cfg, true); + assert!(!traits.has("foo"), "header-only master.cfg should not inject traits"); +} From acbb2f97440d04866cafe73ca7880ba2e79c8d39 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 13 Mar 2026 22:39:09 +0100 Subject: [PATCH 04/54] Reload master traits on --sync --- sysminion/src/minion.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/sysminion/src/minion.rs b/sysminion/src/minion.rs index dda923fe..400d59e1 100644 --- a/sysminion/src/minion.rs +++ b/sysminion/src/minion.rs @@ -34,7 +34,7 @@ use libsysinspect::{ fmt::{formatter::StringFormatter, kvfmt::KeyValueFormatter}, }, rsa, - traits::{self, TraitUpdateRequest, ensure_master_traits_file}, + traits::{self, TraitUpdateRequest, ensure_master_traits_file, systraits::SystemTraits}, util::{self, dataconv}, }; use libsysproto::{ @@ -622,7 +622,8 @@ impl SysMinion { } pub async fn send_traits(self: Arc) -> Result<(), SysinspectError> { - let mut r = MinionMessage::new(self.get_minion_id().to_string(), RequestType::Traits, traits::get_minion_traits(None).to_json_value()?); + let fresh_traits = SystemTraits::new(self.cfg.clone(), false); + let mut r = MinionMessage::new(self.get_minion_id().to_string(), RequestType::Traits, fresh_traits.to_json_value()?); r.set_sid(MINION_SID.to_string()); self.request(r.sendable().map_err(|e| { log::error!("Error preparing traits message: {e}"); @@ -634,10 +635,11 @@ impl SysMinion { /// Send ehlo pub async fn send_ehlo(self: Arc) -> Result<(), SysinspectError> { + let fresh_traits = SystemTraits::new(self.cfg.clone(), false); let mut r = MinionMessage::new( - dataconv::as_str(traits::get_minion_traits(None).get(traits::SYS_ID)), + dataconv::as_str(fresh_traits.get(traits::SYS_ID)), RequestType::Ehlo, - traits::get_minion_traits(None).to_json_value()?, + fresh_traits.to_json_value()?, ); r.set_sid(MINION_SID.to_string()); @@ -850,6 +852,9 @@ impl SysMinion { if let Err(e) = SysInspectModPakMinion::new(self.cfg.clone()).sync().await { log::error!("Failed to sync minion with master: {e}"); } + if let Err(e) = self.as_ptr().send_traits().await { + log::error!("Failed to sync traits with master: {e}"); + } let _ = self.as_ptr().send_sensors_sync().await; } CLUSTER_TRAITS_UPDATE => { @@ -875,6 +880,9 @@ impl SysMinion { _ => "Updated traits", }; log::info!("{}: {}", label, summary); + if let Err(err) = self.as_ptr().send_traits().await { + log::error!("Failed to sync traits with master: {err}"); + } } Err(err) => log::error!("Failed to apply traits update: {err}"), }, From ec51bc4cfda271a31cf0eda44f2d6a436dc7a0c2 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 13 Mar 2026 22:51:00 +0100 Subject: [PATCH 05/54] Align documentation on sysinspect CLI tool --- docs/genusage/cli.rst | 148 ++++++++++++++++++++++++++++++++---- docs/genusage/systraits.rst | 137 +++++++++++---------------------- 2 files changed, 176 insertions(+), 109 deletions(-) diff --git a/docs/genusage/cli.rst b/docs/genusage/cli.rst index 0f123a5d..1dec9afb 100644 --- a/docs/genusage/cli.rst +++ b/docs/genusage/cli.rst @@ -10,9 +10,136 @@ Overview Sysinspect consists of three main executables: -1. ``sysinspect`` — a command to send remote commands to the cluster or run models locally. -2. ``sysmaster`` — is a controller server for all the minion clients -3. ``sysminion`` — a minion client, running as ``root`` on the target +1. ``sysinspect`` — the operator-facing command-line tool +2. ``sysmaster`` — the controller for connected minions +3. ``sysminion`` — the agent running on the target host + +The rest of this page focuses on ``sysinspect`` itself. + +Running Models Remotely +----------------------- + +The most common use of ``sysinspect`` is sending a model query to the +master: + +.. code-block:: bash + + sysinspect "my_model" + sysinspect "my_model/my_entity" + sysinspect "my_model/my_entity/my_state" + +The optional second positional argument targets minions: + +.. code-block:: bash + + sysinspect "my_model" "*" + sysinspect "my_model" "web*" + sysinspect "my_model" "db01,db02" + +Use ``--traits`` to further narrow the target set: + +.. code-block:: bash + + sysinspect "my_model" "*" --traits "system.os.name:Ubuntu" + +Use ``--context`` to pass comma-separated key/value data into the model call: + +.. code-block:: bash + + sysinspect "my_model" "*" --context "foo:123,name:Fred" + +Running Models Locally +---------------------- + +``sysinspect`` can also execute a model locally without going through the +master. Use ``--model`` and optionally limit the selection by entities, +labels, and state: + +.. code-block:: bash + + sysinspect --model ./my_model + sysinspect --model ./my_model --entities foo,bar + sysinspect --model ./my_model --labels os-check + sysinspect --model ./my_model --state online + +Cluster Commands +---------------- + +The following commands talk to the local master instance and affect the +cluster: + +.. code-block:: bash + + sysinspect --sync + sysinspect --online + sysinspect --shutdown + sysinspect --unregister 30006546535e428aba0a0caa6712e225 + +``--sync`` instructs minions to refresh cluster artefacts and then report +their current traits back to the master. + +``--online`` currently prints the result into the master's log, because the +local control channel still has no response stream. + +Traits Management +----------------- + +Master-managed static traits can be updated from the command line: + +.. code-block:: bash + + sysinspect traits --set "foo:bar" + sysinspect traits --set "foo:bar,baz:qux" "web*" + sysinspect traits --set "foo:bar" --id 30006546535e428aba0a0caa6712e225 + sysinspect traits --unset "foo,baz" "web*" + sysinspect traits --reset --id 30006546535e428aba0a0caa6712e225 + +The ``traits`` subcommand supports: + +* ``--set`` — comma-separated ``key:value`` pairs +* ``--unset`` — comma-separated keys +* ``--reset`` — clear only master-managed traits +* ``--id`` — target one minion by System Id +* ``--query`` or trailing positional query — target minions by hostname glob +* ``--traits`` — further narrow targeted minions by traits query + +Module Repository Management +---------------------------- + +The ``module`` subcommand manages the master's module repository: + +.. code-block:: bash + + sysinspect module -A --name runtime.lua --path ./target/debug/runtime/lua + sysinspect module -A --path ./lib -l + sysinspect module -L + sysinspect module -Ll + sysinspect module -R --name runtime.lua + sysinspect module -R --name runtime/lua/reader.lua -l + sysinspect module -i --name runtime.lua + +Supported operations are: + +* ``-A`` / ``--add`` +* ``-R`` / ``--remove`` +* ``-L`` / ``--list`` +* ``-i`` / ``--info`` + +Use ``-l`` / ``--lib`` when operating on library payloads instead of runnable +modules. + +TUI and Utility Commands +------------------------ + +``sysinspect`` also exposes a few utility entrypoints: + +.. code-block:: bash + + sysinspect --ui + sysinspect --list-handlers + +The terminal user interface is documented separately in +:doc:`../uix/ui`. Starting a Master ----------------- @@ -88,21 +215,10 @@ If connection was established successfully, then the last message should be "Ehl To start/stop a Minion in daemon mode, use ``--daemon`` and ``--stop`` respectively. -Minion can be also stopped remotely. However, to start it back, one needs to take care of the -process themselves (either via ``systemd``, manually via SSH or any other means). To stop a minion -remotely, use its System Id: - -.. code-block:: text - - sysinspect --stop 30006546535e428aba0a0caa6712e225 - -In this case a minion with the System Id above will be stopped, while the rest of the cluster will -continue working. - Removing a Minion ----------------- -To remove a Minion (unregister) use the following command, similar to stopping it by its System Id: +To remove a Minion (unregister) use the following command by its System Id: .. code-block:: text @@ -110,4 +226,4 @@ To remove a Minion (unregister) use the following command, similar to stopping i In this case the Minion will be unregistered, its RSA public key will be removed, connection terminated and the Master will be forgotten. In order to start this minion again, please refer to the Minion -registration. \ No newline at end of file +registration. diff --git a/docs/genusage/systraits.rst b/docs/genusage/systraits.rst index 34355185..1b920168 100644 --- a/docs/genusage/systraits.rst +++ b/docs/genusage/systraits.rst @@ -25,8 +25,8 @@ System Traits Definition and description of system traits and their purpose. -Traits are essentially static attributes of a minion. They can be a literally anything -in a form of key/value. There are different kinds of traits: +Traits are essentially static attributes of a minion. They can be almost anything +in a key/value form. There are different kinds of traits: **Common** @@ -36,29 +36,17 @@ in a form of key/value. There are different kinds of traits: **Custom** - Custom traits are static data that set explicity onto a minion. Any data in + Custom traits are static data that are set explicitly onto a minion. Any data in key/value form. They are usually various labels, rack number, physical floor, Asset Tag, serial number etc. **Dynamic** - Dynamic traits are custom functions, where data obtained by relevant modules. - essentially, they are just like normal modules, except the resulting data is stored as + Dynamic traits are custom functions where data is obtained by relevant modules. + Essentially, they are just like normal modules, except the resulting data is stored as a criterion by which a specific minion is targeted. For example, *"memory less than X"*, or *"runs process Y"* etc. -Listing Traits --------------- - -To list minion's traits, is enough to target a minion by its Id or hostname: - -.. code-block:: bash - - $ sysinspect --minions - ... - - $ sysinspect --info - Using Traits in a Model ----------------------- @@ -68,86 +56,68 @@ Static Minion Traits -------------------- Traits can be also custom static data, which is placed in a minion configuration. Traits are just -YAML files with key/value format, placed in ``$SYSINSPECT/traits`` directory of a minion. The naming -of those files is not important, they will be anyway merged into one tree. Important is to ensure -that trait keys do not repeat, so they do not overwrite each other. The ``$SYSINSPECT`` directory +YAML files with key/value format, placed in ``$SYSINSPECT/traits`` directory of a minion. Files +ending in ``.cfg`` are loaded and merged into one tree. The ``$SYSINSPECT`` directory is ``/etc/sysinspect`` by default or is defined in the minion configuration. +Load order is: + +1. discovered built-in traits +2. local ``*.cfg`` files in alphabetical order, except ``master.cfg`` +3. trait functions from ``$SYSINSPECT/functions`` +4. ``master.cfg`` last, overriding all previous values + Example of a trait file: .. code-block:: yaml - :caption: File: ``/etc/sysinspect/traits/example.trait`` + :caption: File: ``/etc/sysinspect/traits/example.cfg`` - traits: - name: Fred + name: Fred + rack: A3 -From now on, the minion can be targeded by the trait ``name``: +From now on, the minion can be targeted by the trait ``name``: .. code-block:: bash :caption: Targeting a minion by a custom trait - sysinspect "my_model/my_entity name:Fred" - -.. code-block:: + sysinspect "my_model/my_entity" "*" --traits "name:Fred" Populating Static Traits ------------------------ -Populating traits is done in two steps: - -1. Writing a specific static trait in a trait description -2. Populating the trait description to all targeted minions +Local static traits are simply written into separate ``*.cfg`` files by the +operator or provisioning system. -Synopsis of a trait description as follows: +Master-managed static traits use a reserved file: .. code-block:: text - :caption: Synopsis - - : - [machine-id]: - - [list] - [hostname]: - - [list] - [traits]: - [key]: [value] - : - [key]: [value] - # Only for dynamic traits (functions) - [functions]: - - [list] + $SYSINSPECT/traits/master.cfg -For example, to make an alias for all Ubuntu running machines, the following valid trait description: +This file is created automatically by the minion and is reserved for updates +coming from the master. It should not be edited manually. -.. code-block:: yaml - :caption: An alias to a system trait - - # This is to select what minions should have - # the following traits assigned - query: - traits: - - system.os.kernel.version: 6.* - - # Actual traits to be assigned - traits: - kernel: six - -Now it is possible to call all minions with any kernel of major version 6 like so: +The ``sysinspect traits`` command updates only this file: .. code-block:: bash - :caption: Target minions by own alias - sysinspect "my_model/my_entity kernel:six" + sysinspect traits --set "foo:bar" "web*" + sysinspect traits --unset "foo,baz" "web*" + sysinspect traits --reset --id 30006546535e428aba0a0caa6712e225 -The section ``functions`` is used for the dynamic traits, described below. +After such update the minion immediately sends refreshed traits back to the +master. Global ``sysinspect --sync`` also refreshes traits. Dynamic Traits -------------- -Dynamic traits are functions that are doing something on the machine. Since those functions -are standalone executables, they do not accept any parameters. Functions are the same modules -like any other modules and using the same protocol with the JSON format. The difference is that -the module should return key/value structure. For example: +Function-based traits are standalone executables placed into +``$SYSINSPECT/functions``. Since those functions are standalone executables, +they do not accept any parameters. They use the same general JSON return +shape as other modules, except that the output is merged into the minion's +trait tree. + +The module should return a key/value structure. For example: .. code-block:: json @@ -155,7 +125,7 @@ the module should return key/value structure. For example: "key": "value", } -Example of using a custom module: +Example of using a custom function: .. code-block:: bash :caption: File: ``my_trait.sh`` @@ -178,30 +148,11 @@ function module is actionable or not on a target system. I.e. user must ensure t system where the particular minion is running, should be equipped with Bash in ``/usr/bin`` directory. -Any modules that return non-zero return like system error more than ``1`` is simply ignored -and error is logged. - -Populating Dynamic Traits -------------------------- - -To populate dynamic trait there are three steps for this: - -1. Writing a specific trait in a Trait Description -2. Placing the trait module to the file server so the minions can download it -3. Populating the Trait Description to all targeted minions - -To write a specific trait in a Trait Description, the ``functions`` section must be specified. -Example: - -.. code-block:: yaml - - functions: - # Specify a relative path on the fileserver - - /functions/my_trait.sh +Any function that returns a non-zero result greater than ``1`` is ignored and +an error is logged. -The script ``my_trait.sh`` will be copied to ``$SYSINSPECT/functions``. When the minion starts, -it will execute each function in alphabetical oder, read the JSON output and merge the result -into the common traits tree. Then the traits tree will be synchronised with the Master. +The script ``my_trait.sh`` will be executed when traits are loaded. The minion +reads its JSON output and merges the result into the common traits tree. .. important:: @@ -212,4 +163,4 @@ This means if the attribute will be different at every minion startup, it might to target a minion by such attribute, unless it is matching to some regular expression. There might be a rare use cases, such as *"select minion or not, depending on its mood"* (because the function returns every time a different value), but generally this sort of dynamism is nearly -outside of the scope of traits system. \ No newline at end of file +outside of the scope of traits system. From 179de1970414178adccecc3731469b22ceecc20f Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 13 Mar 2026 23:04:48 +0100 Subject: [PATCH 06/54] Add libsodium --- Cargo.lock | 1 + libsysinspect/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 8428a4fb..bc054b3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3958,6 +3958,7 @@ dependencies = [ "sha2", "shlex", "sled", + "sodiumoxide", "strum 0.27.2", "strum_macros 0.27.2", "sysinfo 0.34.2", diff --git a/libsysinspect/Cargo.toml b/libsysinspect/Cargo.toml index 02975cbe..8e191faf 100644 --- a/libsysinspect/Cargo.toml +++ b/libsysinspect/Cargo.toml @@ -20,6 +20,7 @@ prettytable-rs = "0.10.0" rand = "0.8.5" regex = "1.12.2" rsa = { version = "0.9.10", features = ["pkcs5", "sha1", "sha2"] } +sodiumoxide = "0.2.7" libcommon = { path = "../libcommon" } libsysproto = { path = "../libsysproto" } From b256552b9f84379f762efab463c07c83c45f78ac Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 13 Mar 2026 23:05:18 +0100 Subject: [PATCH 07/54] Add console key bootstrap and its configuration --- libsysinspect/src/cfg/mmconf.rs | 34 ++++++++++ libsysinspect/src/console/mod.rs | 103 +++++++++++++++++++++++++++++++ libsysinspect/src/lib.rs | 1 + 3 files changed, 138 insertions(+) create mode 100644 libsysinspect/src/console/mod.rs diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index 56e4832b..14b08996 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -21,6 +21,9 @@ pub static DEFAULT_FILESERVER_PORT: u32 = 4201; /// Default API port for the web API pub static DEFAULT_API_PORT: u32 = 4202; +/// Default port for the local console/control endpoint on the master +pub static DEFAULT_CONSOLE_PORT: u32 = 4203; + // Default directories // -------------------- @@ -103,6 +106,9 @@ pub static CFG_SENSORS_ROOT: &str = "sensors"; // --------- pub static CFG_MASTER_KEY_PUB: &str = "master.rsa.pub"; pub static CFG_MASTER_KEY_PRI: &str = "master.rsa"; +pub static CFG_CONSOLE_KEY_PUB: &str = "console.rsa.pub"; +pub static CFG_CONSOLE_KEY_PRI: &str = "console.rsa"; +pub static CFG_CONSOLE_KEYS: &str = "console-keys"; pub static CFG_MINION_RSA_PUB: &str = "minion.rsa.pub"; pub static CFG_MINION_RSA_PRV: &str = "minion.rsa"; @@ -747,6 +753,12 @@ pub struct MasterConfig { // Path to FIFO socket. Default: /var/run/sysinspect-master.socket socket: Option, + #[serde(rename = "console.bind.ip")] + console_ip: Option, + + #[serde(rename = "console.bind.port")] + console_port: Option, + #[serde(rename = "fileserver.bind.ip")] fsr_ip: Option, @@ -913,6 +925,16 @@ impl MasterConfig { self.socket.to_owned().unwrap_or(DEFAULT_SOCKET.to_string()) } + /// Return local console/control bind address + pub fn console_bind_addr(&self) -> String { + format!( + "{}:{}", + self.console_ip.to_owned().unwrap_or("127.0.0.1".to_string()), + self.console_port.unwrap_or(DEFAULT_CONSOLE_PORT) + ) + } + + /// Get API enabled status (default: true) pub fn api_enabled(&self) -> bool { self.api_enabled.unwrap_or(true) @@ -1013,6 +1035,18 @@ impl MasterConfig { self.root_dir().join(CFG_API_KEYS) } + pub fn console_keys_root(&self) -> PathBuf { + self.root_dir().join(CFG_CONSOLE_KEYS) + } + + pub fn console_privkey(&self) -> PathBuf { + self.root_dir().join(CFG_CONSOLE_KEY_PRI) + } + + pub fn console_pubkey(&self) -> PathBuf { + self.root_dir().join(CFG_CONSOLE_KEY_PUB) + } + /// Return a pidfile. Either from config or default. /// The default pidfile conforms to POSIX at /run/user//.... pub fn pidfile(&self) -> PathBuf { diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs new file mode 100644 index 00000000..9751c3a6 --- /dev/null +++ b/libsysinspect/src/console/mod.rs @@ -0,0 +1,103 @@ +use base64::{Engine, engine::general_purpose::STANDARD}; +use libcommon::SysinspectError; +use rsa::RsaPublicKey; +use serde::{Deserialize, Serialize}; +use sodiumoxide::crypto::secretbox::{self, Key}; +use std::fs; + +use crate::{ + cfg::mmconf::MasterConfig, + rsa::keys::{ + RsaKey::{Private, Public}, + decrypt, encrypt, key_from_file, key_to_file, keygen, sign_data, to_pem, verify_sign, + }, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsoleBootstrap { + pub client_pubkey: String, + pub symkey_cipher: String, + pub symkey_sign: String, +} + +impl ConsoleBootstrap { + pub fn new(cfg: &MasterConfig) -> Result { + let (client_prk, client_pbk) = ensure_console_keypair(cfg)?; + let master_pbk = load_master_public_key(cfg)?; + let symkey = secretbox::gen_key(); + let symkey_cipher = encrypt(master_pbk, symkey.0.to_vec()) + .map_err(|_| SysinspectError::RSAError("Failed to encrypt console session key".to_string()))?; + let symkey_sign = sign_data(client_prk, &symkey.0) + .map_err(|_| SysinspectError::RSAError("Failed to sign console session key".to_string()))?; + + Ok(Self { + client_pubkey: to_pem(None, Some(&client_pbk)) + .map_err(|e| SysinspectError::RSAError(e.to_string()))? + .1 + .unwrap_or_default(), + symkey_cipher: STANDARD.encode(symkey_cipher), + symkey_sign: STANDARD.encode(symkey_sign), + }) + } + + pub fn session_key(&self, cfg: &MasterConfig) -> Result { + let master_prk = match key_from_file(cfg.root_dir().join(crate::cfg::mmconf::CFG_MASTER_KEY_PRI).to_str().unwrap_or_default())? { + Some(Private(prk)) => prk, + Some(_) => return Err(SysinspectError::RSAError("Expected master private key".to_string())), + None => return Err(SysinspectError::RSAError("Master private key not found".to_string())), + }; + let client_pbk = match crate::rsa::keys::from_pem(None, Some(&self.client_pubkey)) + .map_err(|e| SysinspectError::RSAError(e.to_string()))? + .1 + { + Some(pbk) => pbk, + None => return Err(SysinspectError::RSAError("Client public key not found in bootstrap".to_string())), + }; + let symkey = decrypt( + master_prk, + STANDARD + .decode(&self.symkey_cipher) + .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode console session key: {e}")))?, + ) + .map_err(|_| SysinspectError::RSAError("Failed to decrypt console session key".to_string()))?; + let symkey_sign = STANDARD + .decode(&self.symkey_sign) + .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode console session signature: {e}")))?; + + if !verify_sign(&client_pbk, &symkey, symkey_sign).map_err(|e| SysinspectError::RSAError(e.to_string()))? { + return Err(SysinspectError::RSAError("Console session signature verification failed".to_string())); + } + + Key::from_slice(&symkey).ok_or_else(|| SysinspectError::RSAError("Console session key has invalid size".to_string())) + } +} + +pub fn ensure_console_keypair(cfg: &MasterConfig) -> Result<(rsa::RsaPrivateKey, RsaPublicKey), SysinspectError> { + if cfg.console_privkey().exists() && cfg.console_pubkey().exists() { + let prk = match key_from_file(cfg.console_privkey().to_str().unwrap_or_default())? { + Some(Private(prk)) => prk, + Some(_) => return Err(SysinspectError::RSAError("Expected console private key".to_string())), + None => return Err(SysinspectError::RSAError("Console private key not found".to_string())), + }; + let pbk = match key_from_file(cfg.console_pubkey().to_str().unwrap_or_default())? { + Some(Public(pbk)) => pbk, + Some(_) => return Err(SysinspectError::RSAError("Expected console public key".to_string())), + None => return Err(SysinspectError::RSAError("Console public key not found".to_string())), + }; + return Ok((prk, pbk)); + } + + fs::create_dir_all(cfg.root_dir()).map_err(SysinspectError::IoErr)?; + let (prk, pbk) = keygen(crate::rsa::keys::DEFAULT_KEY_SIZE).map_err(|e| SysinspectError::RSAError(e.to_string()))?; + key_to_file(&Private(prk.clone()), cfg.root_dir().to_str().unwrap_or_default(), crate::cfg::mmconf::CFG_CONSOLE_KEY_PRI)?; + key_to_file(&Public(pbk.clone()), cfg.root_dir().to_str().unwrap_or_default(), crate::cfg::mmconf::CFG_CONSOLE_KEY_PUB)?; + Ok((prk, pbk)) +} + +pub fn load_master_public_key(cfg: &MasterConfig) -> Result { + match key_from_file(cfg.root_dir().join(crate::cfg::mmconf::CFG_MASTER_KEY_PUB).to_str().unwrap_or_default())? { + Some(Public(pbk)) => Ok(pbk), + Some(_) => Err(SysinspectError::RSAError("Expected master public key".to_string())), + None => Err(SysinspectError::RSAError("Master public key not found".to_string())), + } +} diff --git a/libsysinspect/src/lib.rs b/libsysinspect/src/lib.rs index 043cb87b..44ee13a8 100644 --- a/libsysinspect/src/lib.rs +++ b/libsysinspect/src/lib.rs @@ -1,4 +1,5 @@ pub mod cfg; +pub mod console; pub mod context; pub mod inspector; pub mod intp; From 2c0513b08429e0263a79bd0d3ac57335e71b5dff Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 13 Mar 2026 23:05:38 +0100 Subject: [PATCH 08/54] Add unit tests for the console configuration --- libsysinspect/src/cfg/mmconf_ut.rs | 31 ++++++++++++++++++++++++++++++ libsysinspect/src/cfg/mod.rs | 2 ++ 2 files changed, 33 insertions(+) create mode 100644 libsysinspect/src/cfg/mmconf_ut.rs diff --git a/libsysinspect/src/cfg/mmconf_ut.rs b/libsysinspect/src/cfg/mmconf_ut.rs new file mode 100644 index 00000000..47b1fe77 --- /dev/null +++ b/libsysinspect/src/cfg/mmconf_ut.rs @@ -0,0 +1,31 @@ +use super::mmconf::{DEFAULT_CONSOLE_PORT, MasterConfig}; +use std::{fs, time::{SystemTime, UNIX_EPOCH}}; + +fn write_master_cfg(contents: &str) -> std::path::PathBuf { + let base = std::env::temp_dir().join(format!( + "sysinspect-mmconf-ut-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() + )); + fs::create_dir_all(&base).unwrap(); + let path = base.join("sysinspect.conf"); + fs::write(&path, contents).unwrap(); + path +} + +#[test] +fn master_console_defaults_are_used_when_not_configured() { + let cfg = MasterConfig::new(write_master_cfg("config:\n master:\n fileserver.models: []\n")).unwrap(); + + assert_eq!(cfg.console_bind_addr(), format!("127.0.0.1:{DEFAULT_CONSOLE_PORT}")); +} + +#[test] +fn master_console_config_overrides_defaults() { + let cfg = MasterConfig::new(write_master_cfg( + "config:\n master:\n fileserver.models: []\n console.bind.ip: 127.0.0.1\n console.bind.port: 5511\n", + )) + .unwrap(); + + assert_eq!(cfg.console_bind_addr(), "127.0.0.1:5511"); +} diff --git a/libsysinspect/src/cfg/mod.rs b/libsysinspect/src/cfg/mod.rs index 0da06578..6b5735b7 100644 --- a/libsysinspect/src/cfg/mod.rs +++ b/libsysinspect/src/cfg/mod.rs @@ -3,6 +3,8 @@ Config reader */ pub mod mmconf; +#[cfg(test)] +mod mmconf_ut; use libcommon::SysinspectError; use mmconf::MinionConfig; From c5d922c4d466dbaea97e3c39cf0e90d9e6ed0019 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 13 Mar 2026 23:05:51 +0100 Subject: [PATCH 09/54] Update configuration docs --- docs/global_config.rst | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/global_config.rst b/docs/global_config.rst index 528420ac..4b7f43d1 100644 --- a/docs/global_config.rst +++ b/docs/global_config.rst @@ -134,10 +134,34 @@ and contains the following directives: Type: **string** - Path for a FIFO socket to communicate with the ``sysinspect`` command, - which is issuing commands over the network. + Path for the current local FIFO socket used by the ``sysinspect`` command + to communicate with the local ``sysmaster`` instance. - Default value is ``/var/run/sysinspect-master.socket``. + If omitted, the default value is ``/var/run/sysinspect-master.socket``. + + .. note:: + + This is the currently active local console transport. The newer + ``console.*`` settings below are already part of the configuration + surface, but the FIFO socket is still what ``sysinspect`` uses today. + +``console.bind.ip`` +################### + + Type: **string** + + IPv4 address for the master's local console endpoint. + + If omitted, the default value is ``127.0.0.1``. + +``console.bind.port`` +##################### + + Type: **integer** + + TCP port for the master's local console endpoint. + + If omitted, the default value is ``4203``. ``bind.ip`` ########### @@ -435,6 +459,8 @@ Example configuration for the Sysinspect Master: config: master: socket: /tmp/sysinspect-master.socket + console.bind.ip: 127.0.0.1 + console.bind.port: 4203 bind.ip: 0.0.0.0 bind.port: 4200 From ab71397c6115a8396c2379cdd49f9bf8f04728cf Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 13 Mar 2026 23:13:42 +0100 Subject: [PATCH 10/54] Drop FIFO/unixsocket console connection and replace with the TCP one --- libsysinspect/src/console/mod.rs | 229 +++++++++++++++++++------- src/main.rs | 53 +++--- sysmaster/src/master.rs | 267 ++++++++++++++++--------------- 3 files changed, 348 insertions(+), 201 deletions(-) diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index 9751c3a6..0ba4c6ba 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -1,18 +1,27 @@ use base64::{Engine, engine::general_purpose::STANDARD}; use libcommon::SysinspectError; -use rsa::RsaPublicKey; -use serde::{Deserialize, Serialize}; -use sodiumoxide::crypto::secretbox::{self, Key}; -use std::fs; +use rsa::{RsaPrivateKey, RsaPublicKey}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use sodiumoxide::crypto::secretbox::{self, Key, Nonce}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::OnceLock, +}; use crate::{ - cfg::mmconf::MasterConfig, + cfg::mmconf::{CFG_CONSOLE_KEY_PRI, CFG_CONSOLE_KEY_PUB, MasterConfig}, rsa::keys::{ RsaKey::{Private, Public}, decrypt, encrypt, key_from_file, key_to_file, keygen, sign_data, to_pem, verify_sign, }, }; +#[cfg(test)] +mod console_ut; + +static SODIUM_INIT: OnceLock<()> = OnceLock::new(); + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConsoleBootstrap { pub client_pubkey: String, @@ -20,84 +29,194 @@ pub struct ConsoleBootstrap { pub symkey_sign: String, } -impl ConsoleBootstrap { - pub fn new(cfg: &MasterConfig) -> Result { - let (client_prk, client_pbk) = ensure_console_keypair(cfg)?; - let master_pbk = load_master_public_key(cfg)?; - let symkey = secretbox::gen_key(); - let symkey_cipher = encrypt(master_pbk, symkey.0.to_vec()) - .map_err(|_| SysinspectError::RSAError("Failed to encrypt console session key".to_string()))?; - let symkey_sign = sign_data(client_prk, &symkey.0) - .map_err(|_| SysinspectError::RSAError("Failed to sign console session key".to_string()))?; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsoleSealed { + pub nonce: String, + pub payload: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsoleEnvelope { + pub bootstrap: ConsoleBootstrap, + pub sealed: ConsoleSealed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsoleQuery { + pub model: String, + pub query: String, + pub traits: String, + pub mid: String, + pub context: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsoleResponse { + pub ok: bool, + pub message: String, +} +fn sodium_ready() -> Result<(), SysinspectError> { + if SODIUM_INIT.get().is_some() { + return Ok(()); + } + if sodiumoxide::init().is_err() { + return Err(SysinspectError::ConfigError("Failed to initialise libsodium".to_string())); + } + let _ = SODIUM_INIT.set(()); + Ok(()) +} + +fn console_keypair(root: &Path) -> (PathBuf, PathBuf) { + (root.join(CFG_CONSOLE_KEY_PRI), root.join(CFG_CONSOLE_KEY_PUB)) +} + +pub fn ensure_console_keypair(root: &Path) -> Result<(RsaPrivateKey, RsaPublicKey), SysinspectError> { + let (prk_path, pbk_path) = console_keypair(root); + if prk_path.exists() && pbk_path.exists() { + return Ok((load_private_key(&prk_path)?, load_public_key(&pbk_path)?)); + } + + fs::create_dir_all(root).map_err(SysinspectError::IoErr)?; + let (prk, pbk) = keygen(crate::rsa::keys::DEFAULT_KEY_SIZE).map_err(|e| SysinspectError::RSAError(e.to_string()))?; + key_to_file(&Private(prk.clone()), root.to_str().unwrap_or_default(), CFG_CONSOLE_KEY_PRI)?; + key_to_file(&Public(pbk.clone()), root.to_str().unwrap_or_default(), CFG_CONSOLE_KEY_PUB)?; + Ok((prk, pbk)) +} + +pub fn load_master_public_key(cfg: &MasterConfig) -> Result { + load_public_key(&cfg.root_dir().join(crate::cfg::mmconf::CFG_MASTER_KEY_PUB)) +} + +pub fn load_master_private_key(cfg: &MasterConfig) -> Result { + load_private_key(&cfg.root_dir().join(crate::cfg::mmconf::CFG_MASTER_KEY_PRI)) +} + +fn load_private_key(path: &Path) -> Result { + match key_from_file(path.to_str().unwrap_or_default())? { + Some(Private(prk)) => Ok(prk), + Some(_) => Err(SysinspectError::RSAError(format!("Expected private key at {}", path.display()))), + None => Err(SysinspectError::RSAError(format!("Private key not found at {}", path.display()))), + } +} + +fn load_public_key(path: &Path) -> Result { + match key_from_file(path.to_str().unwrap_or_default())? { + Some(Public(pbk)) => Ok(pbk), + Some(_) => Err(SysinspectError::RSAError(format!("Expected public key at {}", path.display()))), + None => Err(SysinspectError::RSAError(format!("Public key not found at {}", path.display()))), + } +} + +impl ConsoleBootstrap { + pub fn new(client_prk: &RsaPrivateKey, client_pbk: &RsaPublicKey, master_pbk: &RsaPublicKey, symkey: &Key) -> Result { Ok(Self { - client_pubkey: to_pem(None, Some(&client_pbk)) + client_pubkey: to_pem(None, Some(client_pbk)) .map_err(|e| SysinspectError::RSAError(e.to_string()))? .1 .unwrap_or_default(), - symkey_cipher: STANDARD.encode(symkey_cipher), - symkey_sign: STANDARD.encode(symkey_sign), + symkey_cipher: STANDARD.encode( + encrypt(master_pbk.clone(), symkey.0.to_vec()) + .map_err(|_| SysinspectError::RSAError("Failed to encrypt console session key".to_string()))?, + ), + symkey_sign: STANDARD.encode( + sign_data(client_prk.clone(), &symkey.0) + .map_err(|_| SysinspectError::RSAError("Failed to sign console session key".to_string()))?, + ), }) } - pub fn session_key(&self, cfg: &MasterConfig) -> Result { - let master_prk = match key_from_file(cfg.root_dir().join(crate::cfg::mmconf::CFG_MASTER_KEY_PRI).to_str().unwrap_or_default())? { - Some(Private(prk)) => prk, - Some(_) => return Err(SysinspectError::RSAError("Expected master private key".to_string())), - None => return Err(SysinspectError::RSAError("Master private key not found".to_string())), - }; - let client_pbk = match crate::rsa::keys::from_pem(None, Some(&self.client_pubkey)) + pub fn session_key(&self, master_prk: &RsaPrivateKey) -> Result<(Key, RsaPublicKey), SysinspectError> { + let client_pbk = crate::rsa::keys::from_pem(None, Some(&self.client_pubkey)) .map_err(|e| SysinspectError::RSAError(e.to_string()))? .1 - { - Some(pbk) => pbk, - None => return Err(SysinspectError::RSAError("Client public key not found in bootstrap".to_string())), - }; + .ok_or_else(|| SysinspectError::RSAError("Client public key missing from console bootstrap".to_string()))?; let symkey = decrypt( - master_prk, + master_prk.clone(), STANDARD .decode(&self.symkey_cipher) .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode console session key: {e}")))?, ) .map_err(|_| SysinspectError::RSAError("Failed to decrypt console session key".to_string()))?; - let symkey_sign = STANDARD + let signature = STANDARD .decode(&self.symkey_sign) .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode console session signature: {e}")))?; - if !verify_sign(&client_pbk, &symkey, symkey_sign).map_err(|e| SysinspectError::RSAError(e.to_string()))? { + if !verify_sign(&client_pbk, &symkey, signature).map_err(|e| SysinspectError::RSAError(e.to_string()))? { return Err(SysinspectError::RSAError("Console session signature verification failed".to_string())); } - Key::from_slice(&symkey).ok_or_else(|| SysinspectError::RSAError("Console session key has invalid size".to_string())) + Ok(( + Key::from_slice(&symkey).ok_or_else(|| SysinspectError::RSAError("Console session key has invalid size".to_string()))?, + client_pbk, + )) } } -pub fn ensure_console_keypair(cfg: &MasterConfig) -> Result<(rsa::RsaPrivateKey, RsaPublicKey), SysinspectError> { - if cfg.console_privkey().exists() && cfg.console_pubkey().exists() { - let prk = match key_from_file(cfg.console_privkey().to_str().unwrap_or_default())? { - Some(Private(prk)) => prk, - Some(_) => return Err(SysinspectError::RSAError("Expected console private key".to_string())), - None => return Err(SysinspectError::RSAError("Console private key not found".to_string())), - }; - let pbk = match key_from_file(cfg.console_pubkey().to_str().unwrap_or_default())? { - Some(Public(pbk)) => pbk, - Some(_) => return Err(SysinspectError::RSAError("Expected console public key".to_string())), - None => return Err(SysinspectError::RSAError("Console public key not found".to_string())), - }; - return Ok((prk, pbk)); +impl ConsoleSealed { + pub fn seal(payload: &T, key: &Key) -> Result { + sodium_ready()?; + let nonce = secretbox::gen_nonce(); + Ok(Self { + nonce: STANDARD.encode(nonce.0), + payload: STANDARD.encode(secretbox::seal( + &serde_json::to_vec(payload).map_err(|e| SysinspectError::SerializationError(e.to_string()))?, + &nonce, + key, + )), + }) } - fs::create_dir_all(cfg.root_dir()).map_err(SysinspectError::IoErr)?; - let (prk, pbk) = keygen(crate::rsa::keys::DEFAULT_KEY_SIZE).map_err(|e| SysinspectError::RSAError(e.to_string()))?; - key_to_file(&Private(prk.clone()), cfg.root_dir().to_str().unwrap_or_default(), crate::cfg::mmconf::CFG_CONSOLE_KEY_PRI)?; - key_to_file(&Public(pbk.clone()), cfg.root_dir().to_str().unwrap_or_default(), crate::cfg::mmconf::CFG_CONSOLE_KEY_PUB)?; - Ok((prk, pbk)) + pub fn open(&self, key: &Key) -> Result { + sodium_ready()?; + let nonce = Nonce::from_slice( + &STANDARD + .decode(&self.nonce) + .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode console nonce: {e}")))?, + ) + .ok_or_else(|| SysinspectError::SerializationError("Console nonce has invalid size".to_string()))?; + let payload = secretbox::open( + &STANDARD + .decode(&self.payload) + .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode console payload: {e}")))?, + &nonce, + key, + ) + .map_err(|_| SysinspectError::RSAError("Failed to decrypt console payload".to_string()))?; + serde_json::from_slice(&payload).map_err(|e| SysinspectError::DeserializationError(e.to_string())) + } } -pub fn load_master_public_key(cfg: &MasterConfig) -> Result { - match key_from_file(cfg.root_dir().join(crate::cfg::mmconf::CFG_MASTER_KEY_PUB).to_str().unwrap_or_default())? { - Some(Public(pbk)) => Ok(pbk), - Some(_) => Err(SysinspectError::RSAError("Expected master public key".to_string())), - None => Err(SysinspectError::RSAError("Master public key not found".to_string())), +pub fn authorised_console_client(cfg: &MasterConfig, client_pem: &str) -> Result { + if cfg.console_pubkey().exists() && fs::read_to_string(cfg.console_pubkey()).map_err(SysinspectError::IoErr)? == client_pem { + return Ok(true); + } + + let root = cfg.console_keys_root(); + if !root.exists() { + return Ok(false); + } + + for entry in fs::read_dir(root).map_err(SysinspectError::IoErr)? { + let path = entry.map_err(SysinspectError::IoErr)?.path(); + if path.is_file() && fs::read_to_string(&path).map_err(SysinspectError::IoErr)? == client_pem { + return Ok(true); + } } + + Ok(false) +} + +pub fn build_console_query(root: &Path, cfg: &MasterConfig, query: &ConsoleQuery) -> Result<(ConsoleEnvelope, Key), SysinspectError> { + sodium_ready()?; + let (client_prk, client_pbk) = ensure_console_keypair(root)?; + let master_pbk = load_master_public_key(cfg)?; + let key = secretbox::gen_key(); + Ok(( + ConsoleEnvelope { + bootstrap: ConsoleBootstrap::new(&client_prk, &client_pbk, &master_pbk, &key)?, + sealed: ConsoleSealed::seal(query, &key)?, + }, + key, + )) } diff --git a/src/main.rs b/src/main.rs index 19745306..2504ac16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use libsysinspect::{ mmconf::{MasterConfig, MinionConfig}, select_config_path, }, + console::{ConsoleQuery, ConsoleResponse, ConsoleSealed, build_console_query}, context, inspector::SysInspectRunner, logger::{self, MemoryLogger, STDOUTLogger}, @@ -21,12 +22,15 @@ use log::LevelFilter; use serde_json::json; use std::{ env, - fs::OpenOptions, - io::{ErrorKind, Write}, + io::ErrorKind, path::PathBuf, process::exit, sync::{Mutex, OnceLock}, }; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::TcpStream, +}; mod clidef; mod ui; @@ -45,22 +49,33 @@ fn print_event_handlers() { println!(); } -/// Call master via FIFO -fn call_master_fifo( - model: &str, query: &str, traits: Option<&String>, mid: Option<&str>, fifo: &str, context: Option<&String>, +async fn call_master_console( + cfg: &MasterConfig, model: &str, query: &str, traits: Option<&String>, mid: Option<&str>, context: Option<&String>, ) -> Result<(), SysinspectError> { - let payload = - format!("{model};{query};{};{};{}\n", traits.unwrap_or(&"".to_string()), mid.unwrap_or_default(), context.unwrap_or(&"".to_string())); - OpenOptions::new().write(true).open(fifo)?.write_all(payload.as_bytes())?; + let request = ConsoleQuery { + model: model.to_string(), + query: query.to_string(), + traits: traits.cloned().unwrap_or_default(), + mid: mid.unwrap_or_default().to_string(), + context: context.cloned().unwrap_or_default(), + }; + let (envelope, key) = build_console_query(&cfg.root_dir(), cfg, &request)?; + let mut stream = TcpStream::connect(cfg.console_bind_addr()).await?; + stream.write_all(format!("{}\n", serde_json::to_string(&envelope)?).as_bytes()).await?; - log::debug!("Message sent to the master via FIFO: {payload:?}"); + let mut reader = BufReader::new(stream); + let mut reply = String::new(); + reader.read_line(&mut reply).await?; + let response: ConsoleResponse = match serde_json::from_str::(reply.trim()) { + Ok(sealed) => sealed.open(&key)?, + Err(_) => serde_json::from_str(reply.trim())?, + }; + if !response.ok { + return Err(SysinspectError::MasterGeneralError(response.message)); + } Ok(()) } -fn call_master_command(model: &str, query: &str, traits: Option<&String>, mid: Option<&str>, fifo: &str, context: Option) -> Result<(), SysinspectError> { - call_master_fifo(model, query, traits, mid, fifo, context.as_ref()) -} - fn traits_update_context(am: &ArgMatches) -> Result, SysinspectError> { if let Some(setv) = am.get_one::("set") { let traits = context::get_context(setv) @@ -275,7 +290,7 @@ async fn main() { } }; - if let Err(err) = call_master_command(&scheme, target_query, target_traits, target_id, &cfg.socket(), context) { + if let Err(err) = call_master_console(&cfg, &scheme, target_query, target_traits, target_id, context.as_ref()).await { log::error!("Cannot reach master: {err}"); } exit(0); @@ -304,23 +319,23 @@ async fn main() { let query = params.get_one::("query"); let traits = params.get_one::("traits"); let context = params.get_one::("context"); - if let Err(err) = call_master_fifo(model, query.unwrap_or(&"".to_string()), traits, None, &cfg.socket(), context) { + if let Err(err) = call_master_console(&cfg, model, query.unwrap_or(&"".to_string()), traits, None, context).await { log::error!("Cannot reach master: {err}"); } } else if params.get_flag("shutdown") { - if let Err(err) = call_master_fifo(&format!("{SCHEME_COMMAND}{CLUSTER_SHUTDOWN}"), "*", None, None, &cfg.socket(), None) { + if let Err(err) = call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_SHUTDOWN}"), "*", None, None, None).await { log::error!("Cannot reach master: {err}"); } } else if params.get_flag("sync") { - if let Err(err) = call_master_fifo(&format!("{SCHEME_COMMAND}{CLUSTER_SYNC}"), "*", None, None, &cfg.socket(), None) { + if let Err(err) = call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_SYNC}"), "*", None, None, None).await { log::error!("Cannot reach master: {err}"); } } else if let Some(mid) = params.get_one::("unregister") { - if let Err(err) = call_master_fifo(&format!("{SCHEME_COMMAND}{CLUSTER_REMOVE_MINION}"), "", None, Some(mid), &cfg.socket(), None) { + if let Err(err) = call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_REMOVE_MINION}"), "", None, Some(mid), None).await { log::error!("Cannot reach master: {err}"); } } else if params.get_flag("online") { - if let Err(err) = call_master_fifo(&format!("{SCHEME_COMMAND}{CLUSTER_ONLINE_MINIONS}"), "", None, None, &cfg.socket(), None) { + if let Err(err) = call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_ONLINE_MINIONS}"), "", None, None, None).await { log::error!("Cannot reach master: {err}"); } else { println!("Check the master's logs for online minions information. 😀"); diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index 3e08767d..0521f0b5 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -19,6 +19,7 @@ use libeventreg::{ }; use libsysinspect::{ cfg::mmconf::{CFG_MODELS_ROOT, MasterConfig}, + console::{ConsoleEnvelope, ConsoleQuery, ConsoleResponse, ConsoleSealed, authorised_console_client, ensure_console_keypair, load_master_private_key}, mdescr::{mspec::MODEL_FILE_EXT, mspecdef::ModelSpec, telemetry::DataExportType}, util::{self, iofs::scan_files_sha256}, }; @@ -37,15 +38,14 @@ use serde_json::json; use std::time::Duration as StdDuration; use std::{ collections::{HashMap, HashSet}, - path::{Path, PathBuf}, + path::PathBuf, sync::{Arc, Weak}, vec, }; use tokio::net::TcpListener; -use tokio::select; use tokio::sync::{broadcast, mpsc}; use tokio::time::{Duration, sleep}; -use tokio::{fs::OpenOptions, sync::Mutex}; +use tokio::sync::Mutex; use tokio::{ io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader as TokioBufReader}, time, @@ -105,17 +105,6 @@ impl SysMaster { }) } - /// Open FIFO socket for command-line communication - fn open_socket(&self, path: &str) -> Result<(), SysinspectError> { - if !Path::new(path).exists() { - if unsafe { libc::mkfifo(std::ffi::CString::new(path)?.as_ptr(), 0o600) } != 0 { - return Err(SysinspectError::ConfigError(format!("{}", std::io::Error::last_os_error()))); - } - log::info!("Socket opened at {path}"); - } - Ok(()) - } - /// Parse minion request fn to_request(&self, data: &str) -> Option { match serde_json::from_str::(data) { @@ -133,7 +122,8 @@ impl SysMaster { /// Start sysmaster pub async fn init(&mut self) -> Result<(), SysinspectError> { log::info!("Starting master at {}", self.cfg.bind_addr()); - self.open_socket(&self.cfg.socket())?; + ensure_console_keypair(&self.cfg.root_dir())?; + std::fs::create_dir_all(self.cfg.console_keys_root()).map_err(SysinspectError::IoErr)?; self.vmcluster.init().await?; Ok(()) } @@ -241,84 +231,88 @@ impl SysMaster { pub(crate) async fn msg_query(&mut self, payload: &str) -> Option { let query = payload.split(";").map(|s| s.to_string()).collect::>(); if let [querypath, query, traits, mid, context] = query.as_slice() { - let is_virtual = query.to_lowercase().starts_with("v:"); - let query = query.to_lowercase().replace("v:", ""); - - log::debug!("Context: {context}"); - - let hostnames: Vec = query.split(',').map(|h| h.to_string()).collect(); - let mut tgt = MinionTarget::new(mid, ""); - tgt.set_scheme(querypath); - tgt.set_context_query(context); + return self.msg_query_data(querypath, query, traits, mid, context).await; + } - log::debug!( - "Querying minions for: {}, traits: {}, is virtual: {}", - query.bright_yellow(), - traits.bright_yellow(), - if is_virtual { "yes".bright_green() } else { "no".bright_red() } - ); + None + } - let mut targeted = false; - if is_virtual && let Some(decided) = self.vmcluster.decide(&query, traits).await { - for hostname in decided.iter() { - log::debug!("Virtual minion requested. Decided to run on a physical: {}", hostname.bright_yellow()); - tgt.add_hostname(hostname); - if !targeted { - targeted = true; - } + async fn msg_query_data(&mut self, querypath: &str, query: &str, traits: &str, mid: &str, context: &str) -> Option { + let is_virtual = query.to_lowercase().starts_with("v:"); + let query = query.to_lowercase().replace("v:", ""); + + log::debug!("Context: {context}"); + + let hostnames: Vec = query.split(',').map(|h| h.to_string()).collect(); + let mut tgt = MinionTarget::new(mid, ""); + tgt.set_scheme(querypath); + tgt.set_context_query(context); + + log::debug!( + "Querying minions for: {}, traits: {}, is virtual: {}", + query.bright_yellow(), + traits.bright_yellow(), + if is_virtual { "yes".bright_green() } else { "no".bright_red() } + ); + + let mut targeted = false; + if is_virtual && let Some(decided) = self.vmcluster.decide(&query, traits).await { + for hostname in decided.iter() { + log::debug!("Virtual minion requested. Decided to run on a physical: {}", hostname.bright_yellow()); + tgt.add_hostname(hostname); + if !targeted { + targeted = true; } - } else if !is_virtual { - for hostname in hostnames.iter() { - tgt.add_hostname(hostname); - if !targeted { - targeted = true; - } - } - tgt.set_traits_query(traits); - } - - if !targeted { - log::warn!( - "No suitable {}minion found for the query: {}, traits query: {}, context: {}", - if is_virtual { "virtual " } else { "" }, - if query.is_empty() { "".red() } else { query.bright_yellow() }, - if traits.is_empty() { "".red() } else { traits.bright_yellow() }, - if context.is_empty() { "".red() } else { context.bright_yellow() } - ); - return None; } - log::debug!("Target: {:#?}", tgt); - - let mut out: IndexMap = IndexMap::default(); - for em in self.cfg.fileserver_models() { - for (n, cs) in scan_files_sha256(self.cfg.fileserver_models_root(false).join(em), Some(MODEL_FILE_EXT)) { - out.insert(format!("/{}/{em}/{n}", self.cfg.fileserver_models_root(false).file_name().unwrap().to_str().unwrap()), cs); + } else if !is_virtual { + for hostname in hostnames.iter() { + tgt.add_hostname(hostname); + if !targeted { + targeted = true; } } + tgt.set_traits_query(traits); + } - let mut payload = String::from(""); - if tgt.scheme().eq(SCHEME_COMMAND) { - payload = query.to_owned(); - } - - let mut msg = MasterMessage::new( - RequestType::Command, - json!( - ModStatePayload::new(payload) - .set_uri(querypath.to_string()) - .add_files(out) - .set_models_root(self.cfg.fileserver_models_root(true).to_str().unwrap_or_default()) - ), // TODO: SID part + if !targeted { + log::warn!( + "No suitable {}minion found for the query: {}, traits query: {}, context: {}", + if is_virtual { "virtual " } else { "" }, + if query.is_empty() { "".red() } else { query.bright_yellow() }, + if traits.is_empty() { "".red() } else { traits.bright_yellow() }, + if context.is_empty() { "".red() } else { context.bright_yellow() } ); - msg.set_target(tgt); - msg.set_retcode(ProtoErrorCode::Success); + return None; + } + log::debug!("Target: {:#?}", tgt); - log::debug!("Constructed message: {:#?}", msg); + let mut out: IndexMap = IndexMap::default(); + for em in self.cfg.fileserver_models() { + for (n, cs) in scan_files_sha256(self.cfg.fileserver_models_root(false).join(em), Some(MODEL_FILE_EXT)) { + out.insert(format!("/{}/{em}/{n}", self.cfg.fileserver_models_root(false).file_name().unwrap().to_str().unwrap()), cs); + } + } - return Some(msg); + let mut payload = String::new(); + if tgt.scheme().eq(SCHEME_COMMAND) { + payload = query.to_owned(); } - None + let mut msg = MasterMessage::new( + RequestType::Command, + json!( + ModStatePayload::new(payload) + .set_uri(querypath.to_string()) + .add_files(out) + .set_models_root(self.cfg.fileserver_models_root(true).to_str().unwrap_or_default()) + ), + ); + msg.set_target(tgt); + msg.set_retcode(ProtoErrorCode::Success); + + log::debug!("Constructed message: {:#?}", msg); + + Some(msg) } fn msg_sensors_files(&mut self) -> MasterMessage { @@ -735,58 +729,78 @@ impl SysMaster { } } - pub async fn do_fifo(master: Arc>) { - log::trace!("Init local command channel"); + pub async fn do_console(master: Arc>) { + log::trace!("Init local console channel"); tokio::spawn({ - let master = Arc::clone(&master); // Don't move master into the closure multiple times. + let master = Arc::clone(&master); async move { - // Only lock to get broadcast and cfg, then drop immediately. let (cfg, bcast) = { - let master = master.lock().await; - (master.cfg(), master.broadcast().clone()) + let guard = master.lock().await; + (guard.cfg(), guard.broadcast().clone()) + }; + let listener = match TcpListener::bind(cfg.console_bind_addr()).await { + Ok(listener) => listener, + Err(err) => { + log::error!("Failed to bind console listener: {err}"); + return; + } }; loop { - match OpenOptions::new().read(true).open(cfg.socket()).await { - Ok(file) => { - let reader = TokioBufReader::new(file); - let mut lines = reader.lines(); - - loop { - select! { - line = lines.next_line() => { - match line { - Ok(Some(payload)) => { - log::debug!("Querying minions: {payload}"); - let msg = { - let mut guard = master.lock().await; - guard.msg_query(&payload).await + match listener.accept().await { + Ok((stream, peer)) => { + let master = Arc::clone(&master); + let cfg = cfg.clone(); + let bcast = bcast.clone(); + tokio::spawn(async move { + let (read_half, mut write_half) = stream.into_split(); + let mut reader = TokioBufReader::new(read_half); + let mut line = String::new(); + let reply = match reader.read_line(&mut line).await { + Ok(0) => serde_json::to_string(&ConsoleResponse { ok: false, message: "Empty console request".to_string() }).ok(), + Ok(_) => match serde_json::from_str::(line.trim()) { + Ok(envelope) => match load_master_private_key(&cfg).and_then(|prk| envelope.bootstrap.session_key(&prk)) { + Ok((key, _client_pkey)) => { + let response = if !authorised_console_client(&cfg, &envelope.bootstrap.client_pubkey).unwrap_or(false) { + ConsoleResponse { ok: false, message: "Console client key is not authorised".to_string() } + } else { + match envelope.sealed.open::(&key) { + Ok(query) => { + let msg = { + let mut guard = master.lock().await; + guard.msg_query_data(&query.model, &query.query, &query.traits, &query.mid, &query.context).await + }; + if let Some(msg) = msg { + SysMaster::bcast_master_msg(&bcast, cfg.telemetry_enabled(), Arc::clone(&master), Some(msg.clone())).await; + let guard = master.lock().await; + let ids = guard.mreg.lock().await.get_targeted_minions(msg.target(), false).await; + guard.taskreg.lock().await.register(msg.cycle(), ids); + ConsoleResponse { ok: true, message: format!("Accepted console command from {peer}") } + } else { + ConsoleResponse { ok: false, message: "No message constructed for the console query".to_string() } + } + } + Err(err) => ConsoleResponse { ok: false, message: format!("Failed to open console query: {err}") }, + } }; - - if msg.is_none() { - log::warn!("No message constructed for the query: {}", payload.bright_yellow()); - continue; - } - - SysMaster::bcast_master_msg(&bcast, cfg.telemetry_enabled(), Arc::clone(&master), msg.clone()).await; - { - let guard = master.lock().await; - let ids = guard.mreg.lock().await.get_targeted_minions(msg.as_ref().unwrap().target(), false).await; - guard.taskreg.lock().await.register(msg.as_ref().unwrap().cycle(), ids); - } - } - Ok(None) => break, // End of file, re-open the FIFO - Err(e) => { - log::error!("Error reading from FIFO: {e}"); - break; + ConsoleSealed::seal(&response, &key).ok().and_then(|sealed| serde_json::to_string(&sealed).ok()) } - } - } + Err(err) => serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Console bootstrap failed: {err}") }).ok(), + }, + Err(err) => serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Failed to parse console request: {err}") }).ok(), + }, + Err(err) => serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Failed to read console request: {err}") }).ok(), + }; + + if let Some(reply) = reply + && let Err(err) = write_half.write_all(format!("{reply}\n").as_bytes()).await + { + log::error!("Failed to write console response: {err}"); } - } + }); } - Err(e) => { - log::error!("Failed to open FIFO: {e}"); - sleep(Duration::from_secs(1)).await; // Retry after a sec + Err(err) => { + log::error!("Console listener accept error: {err}"); + sleep(Duration::from_secs(1)).await; } } } @@ -1006,9 +1020,8 @@ pub(crate) async fn master(cfg: MasterConfig) -> Result<(), SysinspectError> { let scheduler = SysMaster::do_scheduler_service(Arc::clone(&master)).await; libtelemetry::init_otel_collector(cfg).await?; - // Task to read from the FIFO and broadcast messages to clients - SysMaster::do_fifo(Arc::clone(&master)).await; - log::info!("Local command channel initialized"); + SysMaster::do_console(Arc::clone(&master)).await; + log::info!("Local console channel initialized"); // Handle incoming messages from minions SysMaster::do_incoming(Arc::clone(&master), client_rx).await; From f98f9e2a691295b9a78390c4a4be434212506164 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 13 Mar 2026 23:13:52 +0100 Subject: [PATCH 11/54] Add RSA unit test --- libsysinspect/src/console/console_ut.rs | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 libsysinspect/src/console/console_ut.rs diff --git a/libsysinspect/src/console/console_ut.rs b/libsysinspect/src/console/console_ut.rs new file mode 100644 index 00000000..8b9cc16d --- /dev/null +++ b/libsysinspect/src/console/console_ut.rs @@ -0,0 +1,40 @@ +use super::{ConsoleBootstrap, ConsoleQuery, ConsoleSealed, ensure_console_keypair}; +use crate::{ + cfg::mmconf::{CFG_MASTER_KEY_PRI, CFG_MASTER_KEY_PUB}, + rsa::keys::{RsaKey::{Private, Public}, key_to_file, keygen}, +}; +use sodiumoxide::crypto::secretbox; +use tempfile::tempdir; + +#[test] +fn console_bootstrap_roundtrips_session_key() { + let root = tempdir().unwrap(); + let (master_prk, master_pbk) = keygen(crate::rsa::keys::DEFAULT_KEY_SIZE).unwrap(); + key_to_file(&Private(master_prk.clone()), root.path().to_str().unwrap_or_default(), CFG_MASTER_KEY_PRI).unwrap(); + key_to_file(&Public(master_pbk.clone()), root.path().to_str().unwrap_or_default(), CFG_MASTER_KEY_PUB).unwrap(); + + let (client_prk, client_pbk) = ensure_console_keypair(root.path()).unwrap(); + let symkey = secretbox::gen_key(); + let bootstrap = ConsoleBootstrap::new(&client_prk, &client_pbk, &master_pbk, &symkey).unwrap(); + let (opened, _) = bootstrap.session_key(&master_prk).unwrap(); + + assert_eq!(opened.0.to_vec(), symkey.0.to_vec()); +} + +#[test] +fn console_sealed_roundtrips_payload() { + let payload = ConsoleQuery { + model: "cmd://cluster/sync".to_string(), + query: "*".to_string(), + traits: "".to_string(), + mid: "".to_string(), + context: "{\"op\":\"reset\",\"traits\":{}}".to_string(), + }; + let key = secretbox::gen_key(); + let sealed = ConsoleSealed::seal(&payload, &key).unwrap(); + let opened: ConsoleQuery = sealed.open(&key).unwrap(); + + assert_eq!(opened.model, payload.model); + assert_eq!(opened.query, payload.query); + assert_eq!(opened.context, payload.context); +} From 49d0d56109af34a84f6091104b34c86e9c61715d Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 13 Mar 2026 23:28:16 +0100 Subject: [PATCH 12/54] Display online minions from the sysinspect console to the STDOUT instead of master logs --- libmodpak/src/lib.rs | 30 ++++++---------- libsysinspect/src/util/mod.rs | 9 +++++ src/main.rs | 12 +++---- sysmaster/src/master.rs | 67 ++++++++++++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 26 deletions(-) diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index f4699212..60d63080 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -6,11 +6,10 @@ use indexmap::IndexMap; use libcommon::SysinspectError; use libsysinspect::cfg::mmconf::DEFAULT_MODULES_DIR; use libsysinspect::cfg::mmconf::{CFG_AUTOSYNC_FAST, CFG_AUTOSYNC_SHALLOW, DEFAULT_MODULES_LIB_DIR, MinionConfig}; -use libsysinspect::util::iofs::get_file_sha256; +use libsysinspect::util::{iofs::get_file_sha256, pad_visible}; use mpk::{ModAttrs, ModPakMetadata, ModPakRepoIndex}; use once_cell::sync::Lazy; use prettytable::{Cell, Row, Table, format}; -use regex::Regex; use std::os::unix::fs::PermissionsExt; use std::sync::Arc; use std::{collections::HashMap, fs, path::PathBuf}; @@ -31,8 +30,6 @@ their dependencies, and their architecture. static REPO_MOD_INDEX: &str = "mod.index"; static REPO_MOD_SHA256_EXT: &str = "checksum.sha256"; -static ANSI_ESCAPE_RE: Lazy = Lazy::new(|| Regex::new(r"\x1b\[[0-9;]*m").expect("ansi regex should compile")); - pub struct ModPakSyncState { state: Arc>, } @@ -354,11 +351,6 @@ impl SysInspectModPak { format!("{}{}", prefix.bright_white().bold(), file) } - fn pad_visible(text: &str, width: usize) -> String { - let visible = ANSI_ESCAPE_RE.replace_all(text, "").chars().count(); - if visible >= width { text.to_string() } else { format!("{text}{}", " ".repeat(width - visible)) } - } - /// Creates a new ModPakRepo with the given root path. pub fn new(root: PathBuf) -> Result { if !root.exists() { @@ -585,11 +577,11 @@ impl SysInspectModPak { println!( "{} {} {} {} {}", - Self::pad_visible(&"Type".bright_yellow().to_string(), type_width), - Self::pad_visible(&"Name".bright_yellow().to_string(), name_width), - Self::pad_visible(&"OS".bright_yellow().to_string(), os_width), - Self::pad_visible(&"Arch".bright_yellow().to_string(), arch_width), - Self::pad_visible(&"SHA256".bright_yellow().to_string(), sha_width), + pad_visible(&"Type".bright_yellow().to_string(), type_width), + pad_visible(&"Name".bright_yellow().to_string(), name_width), + pad_visible(&"OS".bright_yellow().to_string(), os_width), + pad_visible(&"Arch".bright_yellow().to_string(), arch_width), + pad_visible(&"SHA256".bright_yellow().to_string(), sha_width), ); println!( "{} {} {} {} {}", @@ -603,11 +595,11 @@ impl SysInspectModPak { for (kind, name, os_name, arch, sha) in rows { println!( "{} {} {} {} {}", - Self::pad_visible(&kind.bright_green().to_string(), type_width), - Self::pad_visible(&Self::format_library_name(&name), name_width), - Self::pad_visible(&os_name.bright_green().to_string(), os_width), - Self::pad_visible(&arch.bright_green().to_string(), arch_width), - Self::pad_visible(&sha.green().to_string(), sha_width), + pad_visible(&kind.bright_green().to_string(), type_width), + pad_visible(&Self::format_library_name(&name), name_width), + pad_visible(&os_name.bright_green().to_string(), os_width), + pad_visible(&arch.bright_green().to_string(), arch_width), + pad_visible(&sha.green().to_string(), sha_width), ); } diff --git a/libsysinspect/src/util/mod.rs b/libsysinspect/src/util/mod.rs index edd2eb7a..79723d03 100644 --- a/libsysinspect/src/util/mod.rs +++ b/libsysinspect/src/util/mod.rs @@ -4,9 +4,13 @@ pub mod sys; pub mod tty; use libcommon::SysinspectError; +use once_cell::sync::Lazy; +use regex::Regex; use std::{fs, io, path::PathBuf}; use uuid::Uuid; +static ANSI_ESCAPE_RE: Lazy = Lazy::new(|| Regex::new(r"\x1b\[[0-9;]*m").expect("ansi regex should compile")); + /// The `/etc/machine-id` is not always present, especially /// on the custom embedded systems. However, this file is used /// to identify a minion. @@ -27,3 +31,8 @@ pub fn write_machine_id(p: Option) -> Result<(), SysinspectError> { Ok(()) } + +pub fn pad_visible(text: &str, width: usize) -> String { + let visible = ANSI_ESCAPE_RE.replace_all(text, "").chars().count(); + if visible >= width { text.to_string() } else { format!("{text}{}", " ".repeat(width - visible)) } +} diff --git a/src/main.rs b/src/main.rs index 2504ac16..7d98691f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,7 +51,7 @@ fn print_event_handlers() { async fn call_master_console( cfg: &MasterConfig, model: &str, query: &str, traits: Option<&String>, mid: Option<&str>, context: Option<&String>, -) -> Result<(), SysinspectError> { +) -> Result { let request = ConsoleQuery { model: model.to_string(), query: query.to_string(), @@ -73,7 +73,7 @@ async fn call_master_console( if !response.ok { return Err(SysinspectError::MasterGeneralError(response.message)); } - Ok(()) + Ok(response) } fn traits_update_context(am: &ArgMatches) -> Result, SysinspectError> { @@ -335,10 +335,10 @@ async fn main() { log::error!("Cannot reach master: {err}"); } } else if params.get_flag("online") { - if let Err(err) = call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_ONLINE_MINIONS}"), "", None, None, None).await { - log::error!("Cannot reach master: {err}"); - } else { - println!("Check the master's logs for online minions information. 😀"); + match call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_ONLINE_MINIONS}"), "", None, None, None).await { + Ok(response) if !response.message.is_empty() => println!("{}", response.message), + Ok(_) => {} + Err(err) => log::error!("Cannot reach master: {err}"), } } else if let Some(mpath) = params.get_one::("model") { let mut sr = SysInspectRunner::new(&MinionConfig::default()); diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index 0521f0b5..d0c5ff10 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -21,7 +21,7 @@ use libsysinspect::{ cfg::mmconf::{CFG_MODELS_ROOT, MasterConfig}, console::{ConsoleEnvelope, ConsoleQuery, ConsoleResponse, ConsoleSealed, authorised_console_client, ensure_console_keypair, load_master_private_key}, mdescr::{mspec::MODEL_FILE_EXT, mspecdef::ModelSpec, telemetry::DataExportType}, - util::{self, iofs::scan_files_sha256}, + util::{self, iofs::scan_files_sha256, pad_visible}, }; use libsysproto::{ self, MasterMessage, MinionMessage, MinionTarget, ProtoConversion, @@ -729,6 +729,64 @@ impl SysMaster { } } + async fn online_minions_summary(&mut self) -> Result { + let mreg = self.mreg.lock().await; + let mut session = self.session.lock().await; + let ids = mreg.get_registered_ids()?; + let mut rows: Vec<(String, String, String, String, String, String)> = vec![]; + + for mid in &ids { + let alive = session.alive(mid); + let traits = match mreg.get(mid) { + Ok(Some(mrec)) => mrec.get_traits().to_owned(), + _ => HashMap::new(), + }; + let mut h = traits.get("system.hostname.fqdn").and_then(|v| v.as_str()).unwrap_or("unknown"); + if h.is_empty() { + h = traits.get("system.hostname").and_then(|v| v.as_str()).unwrap_or("unknown"); + } + let ip = traits.get("system.hostname.ip").and_then(|v| v.as_str()).unwrap_or("unknown"); + let mid_short = if mid.chars().count() > 8 { + format!("{}...{}", &mid[..4], &mid[mid.len() - 4..]) + } else { + mid.to_string() + }; + rows.push(( + h.to_string(), + if alive { h.bright_green().to_string() } else { h.red().to_string() }, + ip.to_string(), + if alive { ip.bright_blue().to_string() } else { ip.blue().to_string() }, + mid_short.clone(), + if alive { mid_short.bright_green().to_string() } else { mid_short.green().to_string() }, + )); + } + + let host_width = rows.iter().map(|r| r.0.chars().count()).max().unwrap_or(4).max("HOST".chars().count()); + let ip_width = rows.iter().map(|r| r.2.chars().count()).max().unwrap_or(2).max("IP".chars().count()); + let id_width = rows.iter().map(|r| r.4.chars().count()).max().unwrap_or(2).max("ID".chars().count()); + + let mut out = vec![ + format!( + "{} {} {}", + pad_visible(&"HOST".bright_yellow().to_string(), host_width), + pad_visible(&"IP".bright_yellow().to_string(), ip_width), + pad_visible(&"ID".bright_yellow().to_string(), id_width), + ), + format!("{} {} {}", "─".repeat(host_width), "─".repeat(ip_width), "─".repeat(id_width)), + ]; + + for (_, host, _, ip, _, mid) in rows { + out.push(format!( + "{} {} {}", + pad_visible(&host, host_width), + pad_visible(&ip, ip_width), + pad_visible(&mid, id_width), + )); + } + + Ok(out.join("\n")) + } + pub async fn do_console(master: Arc>) { log::trace!("Init local console channel"); tokio::spawn({ @@ -765,6 +823,12 @@ impl SysMaster { } else { match envelope.sealed.open::(&key) { Ok(query) => { + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_ONLINE_MINIONS}")) { + match master.lock().await.online_minions_summary().await { + Ok(summary) => ConsoleResponse { ok: true, message: summary }, + Err(err) => ConsoleResponse { ok: false, message: format!("Unable to get online minions: {err}") }, + } + } else { let msg = { let mut guard = master.lock().await; guard.msg_query_data(&query.model, &query.query, &query.traits, &query.mid, &query.context).await @@ -778,6 +842,7 @@ impl SysMaster { } else { ConsoleResponse { ok: false, message: "No message constructed for the console query".to_string() } } + } } Err(err) => ConsoleResponse { ok: false, message: format!("Failed to open console query: {err}") }, } From b6cd8c70e50508cc3878a4b1f2bbbeaf4f2aa0a5 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 14 Mar 2026 00:19:13 +0100 Subject: [PATCH 13/54] Update documentation --- docs/genusage/cli.rst | 86 ++++++++++++++++++++++++++++++++++++- docs/genusage/systraits.rst | 13 ++++++ docs/global_config.rst | 36 ++++++++-------- 3 files changed, 114 insertions(+), 21 deletions(-) diff --git a/docs/genusage/cli.rst b/docs/genusage/cli.rst index 1dec9afb..170ad459 100644 --- a/docs/genusage/cli.rst +++ b/docs/genusage/cli.rst @@ -78,8 +78,7 @@ cluster: ``--sync`` instructs minions to refresh cluster artefacts and then report their current traits back to the master. -``--online`` currently prints the result into the master's log, because the -local control channel still has no response stream. +``--online`` prints the current online-minion summary directly to stdout. Traits Management ----------------- @@ -103,6 +102,89 @@ The ``traits`` subcommand supports: * ``--query`` or trailing positional query — target minions by hostname glob * ``--traits`` — further narrow targeted minions by traits query +Deployment Profiles +------------------- + +Deployment profiles describe which modules and libraries a minion is allowed +to sync. Profiles are assigned to minions through the ``minion.profile`` +static trait. + +Profile definitions: + +.. code-block:: bash + + sysinspect profile --new --name Toto + sysinspect profile --delete --name Toto + sysinspect profile --list + sysinspect profile --list --name 'T*' + +Assign selectors to a profile: + +.. code-block:: bash + + sysinspect profile -A --name Toto --match 'runtime.lua,net.*' + sysinspect profile -A --lib --name Toto --match 'runtime/lua/*.lua' + sysinspect profile -R --name Toto --match 'net.*' + +Assign or remove profiles on minions: + +.. code-block:: bash + + sysinspect profile --tag 'Toto,Foo' --query 'web*' + sysinspect profile --tag 'Toto' --id 30006546535e428aba0a0caa6712e225 + sysinspect profile --untag 'Foo' --traits 'system.hostname.fqdn:db01.example.net' + +Notes: + +* ``--name`` is an exact profile name for ``--new``, ``--delete``, ``-A``, and ``-R`` +* ``--name`` is a glob pattern for ``--list`` +* ``--match`` accepts comma-separated exact names or glob patterns +* ``-l`` / ``--lib`` switches selector operations and listing to library selectors +* ``--tag`` and ``--untag`` update ``minion.profile`` on the targeted minions +* profile names are case-sensitive Unix-like names +* each profile file carries its own canonical ``name`` field; the filename is only storage + +Profile Data Model +------------------ + +The master publishes a dedicated ``profiles.index`` next to ``mod.index``. +Each profile entry points to one profile file plus its checksum: + +.. code-block:: yaml + + profiles: + Toto: + file: totobullshit.profile + checksum: deadbeef + +Each profile file carries the actual profile identity and the allowed artefact +selectors: + +.. code-block:: yaml + + name: Toto + modules: + - runtime.lua + - net.* + libraries: + - lib/runtime/lua/*.lua + +The filename is only storage. The canonical profile identity is the +case-sensitive ``name`` field inside the file. + +Sync Behavior +------------- + +During minion sync: + +1. ``mod.index`` is downloaded from the fileserver +2. ``profiles.index`` is downloaded from the fileserver +3. the minion resolves its effective profiles from ``minion.profile`` +4. the selected profile files are refreshed into ``$SYSINSPECT/profiles`` +5. profile selectors are merged by union + dedup +6. module and library sync is filtered by that merged selector set +7. integrity cleanup removes now-forbidden artefacts + Module Repository Management ---------------------------- diff --git a/docs/genusage/systraits.rst b/docs/genusage/systraits.rst index 1b920168..a8261e2b 100644 --- a/docs/genusage/systraits.rst +++ b/docs/genusage/systraits.rst @@ -108,6 +108,19 @@ The ``sysinspect traits`` command updates only this file: After such update the minion immediately sends refreshed traits back to the master. Global ``sysinspect --sync`` also refreshes traits. +Deployment profile assignment also uses this same mechanism. For example: + +.. code-block:: bash + + sysinspect profile --tag "tiny-lua" --query "pi*" + sysinspect profile --untag "tiny-lua" --id 30006546535e428aba0a0caa6712e225 + +This updates the master-managed ``minion.profile`` trait on the targeted +minions. + +If ``minion.profile`` is not set, the minion falls back to the +``default`` profile during sync. + Dynamic Traits -------------- diff --git a/docs/global_config.rst b/docs/global_config.rst index 4b7f43d1..eb0ba203 100644 --- a/docs/global_config.rst +++ b/docs/global_config.rst @@ -129,28 +129,14 @@ Master Sysinspect Master configuration is located under earlier mentioned ``master`` section, and contains the following directives: -``socket`` -########## - - Type: **string** - - Path for the current local FIFO socket used by the ``sysinspect`` command - to communicate with the local ``sysmaster`` instance. - - If omitted, the default value is ``/var/run/sysinspect-master.socket``. - - .. note:: - - This is the currently active local console transport. The newer - ``console.*`` settings below are already part of the configuration - surface, but the FIFO socket is still what ``sysinspect`` uses today. - ``console.bind.ip`` ################### Type: **string** - IPv4 address for the master's local console endpoint. + IPv4 address for the master's console endpoint used by ``sysinspect``. + This is the active command transport between ``sysinspect`` and + ``sysmaster``. If omitted, the default value is ``127.0.0.1``. @@ -159,10 +145,23 @@ and contains the following directives: Type: **integer** - TCP port for the master's local console endpoint. + TCP port for the master's console endpoint used by ``sysinspect``. If omitted, the default value is ``4203``. +``console`` key material +######################## + + The console endpoint uses the master's RSA material under the master + root directory: + + * ``console.rsa`` — console private key + * ``console.rsa.pub`` — console public key + * ``console-keys/`` — authorised client public keys + + These are filesystem conventions under the master root, not YAML + configuration directives. + ``bind.ip`` ########### @@ -458,7 +457,6 @@ Example configuration for the Sysinspect Master: config: master: - socket: /tmp/sysinspect-master.socket console.bind.ip: 127.0.0.1 console.bind.port: 4203 bind.ip: 0.0.0.0 From f3d3ea24630be04aeddfcb95fe1ffd6e627ca71c Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 14 Mar 2026 00:19:23 +0100 Subject: [PATCH 14/54] Update manpage --- man/sysinspect.8.md | 143 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/man/sysinspect.8.md b/man/sysinspect.8.md index 0552f768..b26f8286 100644 --- a/man/sysinspect.8.md +++ b/man/sysinspect.8.md @@ -10,6 +10,11 @@ SYNOPSIS ======== | **sysinspect** \[**OPTIONS**]... +| **sysinspect** *model* \[*query*] \[**--traits** *traits-query*] \[**--context** *k:v,...*] +| **sysinspect** **--model** *path* \[**--entities** *list* | **--labels** *list*] \[**--state** *name*] +| **sysinspect** **traits** \[**--set** *k:v,...* | **--unset** *k,...* | **--reset**] \[**--id** *id* | **--query** *glob* | *glob*] \[**--traits** *query*] +| **sysinspect** **profile** \[**--new** | **--delete** | **--list** | **-A** | **-R** | **--tag** | **--untag**] ... +| **sysinspect** **module** \[**-A** | **-R** | **-L** | **-i**] ... DESCRIPTION =========== @@ -20,6 +25,144 @@ system for the following means: - Root Cause Analysis - Anomaly Detection +The command-line tool talks to the local or configured master instance, +submits model requests, manages the module repository, updates +master-managed static traits on minions, and manages deployment profiles. + +RUNNING MODELS REMOTELY +======================= + +The most common use of **sysinspect** is sending a model request to the +master. + +Examples: + +| **sysinspect** "my_model" +| **sysinspect** "my_model/my_entity" +| **sysinspect** "my_model/my_entity/my_state" +| **sysinspect** "my_model" "*" +| **sysinspect** "my_model" "web*" +| **sysinspect** "my_model" "db01,db02" +| **sysinspect** "my_model" "*" **--traits** "system.os.name:Ubuntu" +| **sysinspect** "my_model" "*" **--context** "foo:123,name:Fred" + +The optional second positional argument targets minions by hostname glob +or comma-separated host list. The **--traits** option further narrows the +target set. The **--context** option passes comma-separated key/value +data into the model call. + +RUNNING MODELS LOCALLY +====================== + +**sysinspect** can also execute a model locally without going through the +master. + +Examples: + +| **sysinspect** **--model** ./my_model +| **sysinspect** **--model** ./my_model **--entities** foo,bar +| **sysinspect** **--model** ./my_model **--labels** os-check +| **sysinspect** **--model** ./my_model **--state** online + +The local selector options are: + +- **--entities** limit execution to specific entities +- **--labels** limit execution to specific labels +- **--state** choose the state to process + +CLUSTER COMMANDS +================ + +- **--sync** refreshes cluster artefacts and triggers minions to report + fresh traits back to the master +- **--online** prints the current online-minion summary to standard output +- **--shutdown** asks the master to stop +- **--unregister** *id* unregisters a minion by System Id + +TRAITS +====== + +The **traits** subcommand updates only the master-managed static trait +overlay stored on minions. + +Examples: + +| **sysinspect** **traits** **--set** "foo:bar" +| **sysinspect** **traits** **--set** "foo:bar,baz:qux" "web*" +| **sysinspect** **traits** **--set** "foo:bar" **--id** 30006546535e428aba0a0caa6712e225 +| **sysinspect** **traits** **--unset** "foo,baz" "web*" +| **sysinspect** **traits** **--reset** **--id** 30006546535e428aba0a0caa6712e225 + +Supported selectors: + +- **--id** target one minion by System Id +- **--query** or trailing positional query target minions by hostname glob +- **--traits** further narrow the target set by traits query + +PROFILES +======== + +Deployment profiles define which modules and libraries a minion is +allowed to sync. + +Examples: + +| **sysinspect** **profile** **--new** **--name** Toto +| **sysinspect** **profile** **--delete** **--name** Toto +| **sysinspect** **profile** **--list** +| **sysinspect** **profile** **--list** **--name** 'T*' +| **sysinspect** **profile** **-A** **--name** Toto **--match** 'runtime.lua,net.*' +| **sysinspect** **profile** **-A** **--lib** **--name** Toto **--match** 'runtime/lua/*.lua' +| **sysinspect** **profile** **-R** **--name** Toto **--match** 'net.*' +| **sysinspect** **profile** **--tag** 'Toto,Foo' **--query** 'web*' +| **sysinspect** **profile** **--untag** 'Foo' **--traits** 'system.hostname.fqdn:db01.example.net' + +Notes: + +- **--name** is an exact profile name for **--new**, **--delete**, + **-A**, and **-R** +- **--name** is a glob pattern for **--list** +- **--match** accepts comma-separated exact names or glob patterns +- **-l** or **--lib** switches selector operations and listing to + library selectors +- **--tag** and **--untag** update the **minion.profile** static trait +- a profile file carries its own canonical **name** field; the filename + is only storage + +MODULE REPOSITORY +================= + +The **module** subcommand manages the master's module repository. + +Examples: + +| **sysinspect** **module** **-A** **--name** runtime.lua **--path** ./target/debug/runtime/lua +| **sysinspect** **module** **-A** **--path** ./lib **-l** +| **sysinspect** **module** **-L** +| **sysinspect** **module** **-Ll** +| **sysinspect** **module** **-R** **--name** runtime.lua +| **sysinspect** **module** **-R** **--name** runtime/lua/reader.lua **-l** +| **sysinspect** **module** **-i** **--name** runtime.lua + +UTILITY COMMANDS +================ + +Additional operator entrypoints: + +| **sysinspect** **--ui** +| **sysinspect** **--list-handlers** + +**--ui** starts the terminal user interface. **--list-handlers** prints +the registered event handler identifiers. + +COMMON OPTIONS +============== + +- **-c**, **--config** *path* use an alternative configuration file +- **-d**, **--debug** increase log verbosity; repeat for more verbosity +- **-h**, **--help** display help +- **-v**, **--version** display version + DETAILED DOCUMENTATION ====================== From af6b6cb935398df21b58e8a0fda211fe42c9bc6c Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 14 Mar 2026 00:19:36 +0100 Subject: [PATCH 15/54] Add profiles examples --- examples/profiles/README.md | 28 ++++++++++++++++++++++++++ examples/profiles/runtime-full.profile | 9 +++++++++ examples/profiles/tiny-lua.profile | 6 ++++++ 3 files changed, 43 insertions(+) create mode 100644 examples/profiles/README.md create mode 100644 examples/profiles/runtime-full.profile create mode 100644 examples/profiles/tiny-lua.profile diff --git a/examples/profiles/README.md b/examples/profiles/README.md new file mode 100644 index 00000000..3409828f --- /dev/null +++ b/examples/profiles/README.md @@ -0,0 +1,28 @@ +Deployment Profile Examples +=========================== + +This directory contains example deployment profile files for the master-side +`profiles.index` / `.profile` mechanism. + +Files: + +- `tiny-lua.profile` + - a narrow profile that allows only the Lua runtime and Lua-side libraries +- `runtime-full.profile` + - a fuller runtime profile that allows Lua, Py3, and Wasm runtimes together + +Profile file format: + +```yaml +name: tiny-lua +modules: + - runtime.lua +libraries: + - lib/runtime/lua/*.lua +``` + +Notes: + +- profile identity comes from `name`, not the filename +- selectors support exact names and glob patterns +- effective minion selection is driven by the `minion.profile` trait diff --git a/examples/profiles/runtime-full.profile b/examples/profiles/runtime-full.profile new file mode 100644 index 00000000..30227662 --- /dev/null +++ b/examples/profiles/runtime-full.profile @@ -0,0 +1,9 @@ +name: runtime-full +modules: + - runtime.lua + - runtime.py3 + - runtime.wasm +libraries: + - lib/runtime/lua/* + - lib/runtime/python3/* + - lib/runtime/wasm/* diff --git a/examples/profiles/tiny-lua.profile b/examples/profiles/tiny-lua.profile new file mode 100644 index 00000000..0d6d4e1b --- /dev/null +++ b/examples/profiles/tiny-lua.profile @@ -0,0 +1,6 @@ +name: tiny-lua +modules: + - runtime.lua +libraries: + - lib/runtime/lua/*.lua + - lib/sensors/lua/*.lua From 3b289015a9ed7646d19e861c01cb2469ef5d5b3b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 14 Mar 2026 00:24:14 +0100 Subject: [PATCH 16/54] Add minion profiles management --- libmodpak/src/lib.rs | 195 ++++++++++++++++++++++++++++++- libmodpak/src/mpk.rs | 183 ++++++++++++++++++++++++++++- libsysinspect/src/cfg/mmconf.rs | 16 +++ libsysinspect/src/console/mod.rs | 31 +++++ libsysinspect/src/context/mod.rs | 55 ++++++++- libsysinspect/src/traits/mod.rs | 49 +++++++- libsysproto/src/query.rs | 3 + src/clidef.rs | 17 +++ src/main.rs | 103 +++++++++++++++- sysmaster/src/dataserv/fls.rs | 8 +- sysmaster/src/master.rs | 156 ++++++++++++++++++++++++- sysminion/src/minion.rs | 13 ++- 12 files changed, 817 insertions(+), 12 deletions(-) diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index 60d63080..6c7a18ae 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -2,12 +2,13 @@ use colored::Colorize; use cruet::Inflector; use fs_extra::dir::CopyOptions; use goblin::Object; -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use libcommon::SysinspectError; use libsysinspect::cfg::mmconf::DEFAULT_MODULES_DIR; -use libsysinspect::cfg::mmconf::{CFG_AUTOSYNC_FAST, CFG_AUTOSYNC_SHALLOW, DEFAULT_MODULES_LIB_DIR, MinionConfig}; +use libsysinspect::cfg::mmconf::{CFG_AUTOSYNC_FAST, CFG_AUTOSYNC_SHALLOW, CFG_PROFILES_ROOT, DEFAULT_MODULES_LIB_DIR, MinionConfig}; +use libsysinspect::traits::effective_profiles; use libsysinspect::util::{iofs::get_file_sha256, pad_visible}; -use mpk::{ModAttrs, ModPakMetadata, ModPakRepoIndex}; +use mpk::{ModAttrs, ModPakMetadata, ModPakProfile, ModPakProfilesIndex, ModPakRepoIndex}; use once_cell::sync::Lazy; use prettytable::{Cell, Row, Table, format}; use std::os::unix::fs::PermissionsExt; @@ -29,6 +30,7 @@ their dependencies, and their architecture. */ static REPO_MOD_INDEX: &str = "mod.index"; +static REPO_PROFILES_INDEX: &str = "profiles.index"; static REPO_MOD_SHA256_EXT: &str = "checksum.sha256"; pub struct ModPakSyncState { state: Arc>, @@ -84,6 +86,71 @@ impl SysInspectModPakMinion { Ok(idx) } + async fn get_profiles_idx(&self) -> Result { + let resp = reqwest::Client::new() + .get(format!("http://{}/{}", self.cfg.fileserver(), REPO_PROFILES_INDEX)) + .send() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Request failed: {e}")))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(ModPakProfilesIndex::new()); + } + if resp.status() != reqwest::StatusCode::OK { + return Err(SysinspectError::MasterGeneralError(format!("Failed to get profiles index: {}", resp.status()))); + } + + ModPakProfilesIndex::from_yaml(&String::from_utf8_lossy(&resp.bytes().await.unwrap())) + } + + async fn sync_profiles(&self, profiles: &ModPakProfilesIndex, names: &[String]) -> Result<(), SysinspectError> { + if !self.cfg.profiles_dir().exists() { + fs::create_dir_all(self.cfg.profiles_dir())?; + } + + for name in names { + if let Some(profile) = profiles.get(name) { + let dst = self.cfg.profiles_dir().join(profile.file()); + let checksum = if dst.exists() { get_file_sha256(dst.clone()).ok() } else { None }; + if checksum.as_deref() == Some(profile.checksum()) { + continue; + } + + let resp = reqwest::Client::new() + .get(format!("http://{}/{}/{}", self.cfg.fileserver(), CFG_PROFILES_ROOT, profile.file().display())) + .send() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Request failed: {e}")))?; + if resp.status() != reqwest::StatusCode::OK { + return Err(SysinspectError::MasterGeneralError(format!("Failed to get profile {}: {}", name, resp.status()))); + } + if let Some(parent) = dst.parent() + && !parent.exists() + { + fs::create_dir_all(parent)?; + } + fs::write(&dst, resp.bytes().await.map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read response: {e}")))? )?; + } + } + + Ok(()) + } + + fn filtered_repo_index(&self, ridx: ModPakRepoIndex, profiles: &ModPakProfilesIndex, names: &[String]) -> Result { + if profiles.profiles().is_empty() { + return Ok(ridx); + } + + let mut modules = IndexSet::new(); + let mut libraries = IndexSet::new(); + for name in names { + if let Some(profile) = profiles.get(name) { + ModPakProfile::from_yaml(&fs::read_to_string(self.cfg.profiles_dir().join(profile.file()))?)?.merge_into(&mut modules, &mut libraries); + } + } + + Ok(ridx.retain_profiles(&modules, &libraries)) + } + /// Verifies an artefact by its subpath and checksum. async fn verify_artefact_by_subpath(&self, section: &str, subpath: &str, checksum: &str) -> Result<(bool, Option), SysinspectError> { let path = self.cfg.sharelib_dir().join(section).join(subpath); @@ -119,7 +186,10 @@ impl SysInspectModPakMinion { } MODPAK_SYNC_STATE.set_syncing(true).await; - let ridx = self.get_modpak_idx().await?; + let profiles = self.get_profiles_idx().await?; + let names = effective_profiles(&self.cfg); + self.sync_profiles(&profiles, &names).await?; + let ridx = self.filtered_repo_index(self.get_modpak_idx().await?, &profiles, &names)?; self.sync_integrity(&ridx)?; // blocking self.sync_modules(&ridx).await?; @@ -160,6 +230,9 @@ impl SysInspectModPakMinion { let mut shared = IndexMap::new(); for sp in [DEFAULT_MODULES_DIR, DEFAULT_MODULES_LIB_DIR].iter() { let root = self.cfg.sharelib_dir().join(sp); + if !root.exists() { + continue; + } collect_files(&root, &root, &mut shared)?; } @@ -328,6 +401,57 @@ pub struct SysInspectModPak { } impl SysInspectModPak { + fn get_profiles_index(&self) -> Result { + ModPakProfilesIndex::from_yaml(&fs::read_to_string(self.root.parent().unwrap_or(&self.root).join(REPO_PROFILES_INDEX))?) + } + + fn set_profiles_index(&self, index: &ModPakProfilesIndex) -> Result<(), SysinspectError> { + fs::write(self.root.parent().unwrap_or(&self.root).join(REPO_PROFILES_INDEX), index.to_yaml()?)?; + Ok(()) + } + + fn get_profile(&self, name: &str) -> Result { + let index = self.get_profiles_index()?; + let entry = index + .get(name) + .ok_or_else(|| SysinspectError::MasterGeneralError(format!("Profile {} was not found", name.bright_yellow())))?; + { + let profile = + ModPakProfile::from_yaml(&fs::read_to_string(self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).join(entry.file()))?)?; + if profile.name() != name { + return Err(SysinspectError::MasterGeneralError(format!( + "Profile {} does not match the file content name {}", + name.bright_yellow(), + profile.name().bright_yellow() + ))); + } + Ok(profile) + } + } + + fn set_profile(&self, name: &str, profile: &ModPakProfile) -> Result<(), SysinspectError> { + let mut index = self.get_profiles_index()?; + let file = PathBuf::from(format!("{name}.profile")); + let path = self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).join(&file); + if !self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).exists() { + fs::create_dir_all(self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT))?; + } + fs::write(&path, profile.to_yaml()?)?; + index.insert(name, file, &get_file_sha256(path)?); + self.set_profiles_index(&index) + } + + fn remove_profile_entry(&self, name: &str) -> Result<(), SysinspectError> { + let mut index = self.get_profiles_index()?; + if let Some(entry) = index.profiles().get(name) + && self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).join(entry.file()).exists() + { + fs::remove_file(self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).join(entry.file()))?; + } + index.remove(name); + self.set_profiles_index(&index) + } + /// Format a library path for `module -Ll` with runtime-aware filename colors. pub(crate) fn format_library_name(name: &str) -> String { for marker in ["site-lua/", "site-packages/"] { @@ -357,6 +481,8 @@ impl SysInspectModPak { log::info!("Creating module repository at {}", root.display()); std::fs::create_dir_all(&root)?; fs::write(root.join(REPO_MOD_INDEX), ModPakRepoIndex::new().to_yaml()?)?; // XXX: needs flock + fs::create_dir_all(root.parent().unwrap_or(&root).join(CFG_PROFILES_ROOT))?; + fs::write(root.parent().unwrap_or(&root).join(REPO_PROFILES_INDEX), ModPakProfilesIndex::new().to_yaml()?)?; } let ridx = root.join(REPO_MOD_INDEX); @@ -365,6 +491,15 @@ impl SysInspectModPak { fs::write(&ridx, ModPakRepoIndex::new().to_yaml()?)?; } + if !root.parent().unwrap_or(&root).join(CFG_PROFILES_ROOT).exists() { + fs::create_dir_all(root.parent().unwrap_or(&root).join(CFG_PROFILES_ROOT))?; + } + + let pidx = root.parent().unwrap_or(&root).join(REPO_PROFILES_INDEX); + if !pidx.exists() { + fs::write(&pidx, ModPakProfilesIndex::new().to_yaml()?)?; + } + Ok(Self { root: root.clone(), idx: ModPakRepoIndex::from_yaml(&fs::read_to_string(ridx)?)? }) } @@ -624,6 +759,58 @@ impl SysInspectModPak { Ok(()) } + pub fn list_profiles(&self, expr: Option<&str>) -> Result, SysinspectError> { + let expr = glob::Pattern::new(expr.unwrap_or("*")).map_err(|e| SysinspectError::MasterGeneralError(format!("Invalid pattern: {e}")))?; + let mut profiles = self.get_profiles_index()?.profiles().keys().filter(|name| expr.matches(name)).map(|name| name.to_string()).collect::>(); + profiles.sort(); + Ok(profiles) + } + + pub fn new_profile(&self, name: &str) -> Result<(), SysinspectError> { + if self.get_profiles_index()?.get(name).is_some() { + return Err(SysinspectError::MasterGeneralError(format!("Profile {} already exists", name.bright_yellow()))); + } + self.set_profile(name, &ModPakProfile::new(name)) + } + + pub fn delete_profile(&self, name: &str) -> Result<(), SysinspectError> { + if self.get_profiles_index()?.get(name).is_none() { + return Err(SysinspectError::MasterGeneralError(format!("Profile {} was not found", name.bright_yellow()))); + } + self.remove_profile_entry(name) + } + + pub fn add_profile_matches(&self, name: &str, matches: Vec, library: bool) -> Result<(), SysinspectError> { + let mut profile = self.get_profile(name)?; + if library { + profile.add_libraries(matches); + } else { + profile.add_modules(matches); + } + self.set_profile(name, &profile) + } + + pub fn remove_profile_matches(&self, name: &str, matches: Vec, library: bool) -> Result<(), SysinspectError> { + let mut profile = self.get_profile(name)?; + if library { + profile.remove_libraries(matches); + } else { + profile.remove_modules(matches); + } + self.set_profile(name, &profile) + } + + pub fn list_profile_matches(&self, expr: Option<&str>, library: bool) -> Result, SysinspectError> { + let mut out = Vec::new(); + for profile in self.list_profiles(expr)? { + let data = self.get_profile(&profile)?; + for entry in if library { data.libraries() } else { data.modules() } { + out.push(format!("{profile}: {entry}")); + } + } + Ok(out) + } + /// Lists all modules in the repository. pub fn list_modules(&self) -> Result<(), SysinspectError> { let osn = HashMap::from([ diff --git a/libmodpak/src/mpk.rs b/libmodpak/src/mpk.rs index 8ae63592..8b564703 100644 --- a/libmodpak/src/mpk.rs +++ b/libmodpak/src/mpk.rs @@ -1,6 +1,8 @@ +//! Module repository index, profile index, and package metadata types. + use anyhow::Context; use colored::Colorize; -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use libcommon::SysinspectError; use libmodcore::modinit::{ModArgument, ModInterface, ModOption}; use once_cell::sync::Lazy; @@ -106,6 +108,156 @@ fn default_library_kind() -> String { "script".to_string() } +#[derive(Debug, Serialize, Deserialize, Clone)] +/// Indexed profile reference stored in `profiles.index`. +pub struct ModPakProfileRef { + file: PathBuf, + checksum: String, +} + +impl ModPakProfileRef { + /// Create a new indexed profile reference. + pub fn new(file: PathBuf, checksum: &str) -> Self { + Self { file, checksum: checksum.to_string() } + } + + /// Return the stored profile filename. + pub fn file(&self) -> &PathBuf { + &self.file + } + + /// Return the stored profile checksum. + pub fn checksum(&self) -> &str { + &self.checksum + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +/// One deployment profile file with its canonical name and selector lists. +pub struct ModPakProfile { + name: String, + #[serde(default)] + modules: Vec, + #[serde(default)] + libraries: Vec, +} + +impl ModPakProfile { + /// Create a new empty profile with the given canonical name. + pub fn new(name: &str) -> Self { + Self { name: name.to_string(), ..Default::default() } + } + + /// Return the canonical profile name from the profile file. + pub fn name(&self) -> &str { + &self.name + } + + /// Return the module selector list. + pub fn modules(&self) -> &[String] { + &self.modules + } + + /// Return the library selector list. + pub fn libraries(&self) -> &[String] { + &self.libraries + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +/// Top-level `profiles.index` structure published by the master. +pub struct ModPakProfilesIndex { + #[serde(default)] + profiles: IndexMap, +} + +impl ModPakProfilesIndex { + /// Create an empty profiles index. + pub fn new() -> Self { + Self::default() + } + + /// Return all indexed profiles. + pub fn profiles(&self) -> &IndexMap { + &self.profiles + } + + /// Return one indexed profile reference by canonical name. + pub fn get(&self, name: &str) -> Option<&ModPakProfileRef> { + self.profiles.get(name) + } + + /// Insert or replace one indexed profile reference. + pub fn insert(&mut self, name: &str, file: PathBuf, checksum: &str) { + self.profiles.insert(name.to_string(), ModPakProfileRef::new(file, checksum)); + } + + /// Remove one indexed profile reference by canonical name. + pub fn remove(&mut self, name: &str) { + self.profiles.shift_remove(name); + } + + /// Serialize the profiles index to YAML. + pub fn to_yaml(&self) -> Result { + Ok(serde_yaml::to_string(self)?) + } + + /// Deserialize the profiles index from YAML. + pub fn from_yaml(yaml: &str) -> Result { + Ok(serde_yaml::from_str(yaml)?) + } +} + +impl ModPakProfile { + /// Deserialize one profile file from YAML. + pub fn from_yaml(yaml: &str) -> Result { + Ok(serde_yaml::from_str(yaml)?) + } + + /// Merge this profile's selectors into the effective deduplicated selector sets. + pub fn merge_into(&self, modules: &mut IndexSet, libraries: &mut IndexSet) { + for module in &self.modules { + modules.insert(module.to_string()); + } + for library in &self.libraries { + libraries.insert(library.to_string()); + } + } + + /// Add module selectors, keeping insertion order and skipping duplicates. + pub fn add_modules(&mut self, modules: Vec) { + for module in modules { + if !self.modules.contains(&module) { + self.modules.push(module); + } + } + } + + /// Add library selectors, keeping insertion order and skipping duplicates. + pub fn add_libraries(&mut self, libraries: Vec) { + for library in libraries { + if !self.libraries.contains(&library) { + self.libraries.push(library); + } + } + } + + /// Remove matching module selectors. + pub fn remove_modules(&mut self, modules: Vec) { + self.modules.retain(|module| !modules.contains(module)); + } + + /// Remove matching library selectors. + pub fn remove_libraries(&mut self, libraries: Vec) { + self.libraries.retain(|library| !libraries.contains(library)); + } + + /// Serialize one profile file to YAML. + pub fn to_yaml(&self) -> Result { + Ok(serde_yaml::to_string(self)?) + } +} + #[allow(clippy::type_complexity)] #[derive(Debug, Serialize, Deserialize)] pub struct ModPakRepoIndex { @@ -258,6 +410,32 @@ impl ModPakRepoIndex { modules } + /// Return a filtered repository view that keeps only artefacts matched by the given profile selectors. + pub fn retain_profiles(&self, modules: &IndexSet, libraries: &IndexSet) -> Self { + let mut index = Self::new(); + for (platform, archset) in &self.platform { + for (arch, entries) in archset { + for (name, attrs) in entries.iter().filter(|(name, _)| modules.iter().any(|expr| glob::Pattern::new(expr).is_ok_and(|pattern| pattern.matches(name)))) { + index + .platform + .entry(platform.to_string()) + .or_default() + .entry(arch.to_string()) + .or_default() + .insert(name.to_string(), attrs.clone()); + } + } + } + + for (name, entry) in &self.library { + if libraries.iter().any(|expr| glob::Pattern::new(expr).is_ok_and(|pattern| pattern.matches(name))) { + index.library.insert(name.to_string(), entry.clone()); + } + } + + index + } + /// Returns the modules in the index. Optionally filtered by architecture and names. pub(crate) fn all_modules(&self, arch: Option<&str>, names: Option>) -> IndexMap>> { if let Some(arch) = arch { @@ -401,6 +579,7 @@ impl ModPakMetadata { Ok(()) } + /// Set the module architecture label. pub fn set_arch(&mut self, arch: &str) { self.arch = arch.to_string(); } @@ -425,6 +604,7 @@ impl ModPakMetadata { &self.options } + /// Return the repository subpath derived from the module namespace. pub fn get_subpath(&self) -> PathBuf { let p = self.get_name().trim_start_matches('.').trim_end_matches('.').to_string().replace('.', "/"); if self.arch.eq("noarch") { PathBuf::from(format!("{p}.py")) } else { PathBuf::from(p) } @@ -456,6 +636,7 @@ impl ModPakMetadata { } } + /// Build module metadata from the `sysinspect module -A` CLI arguments and optional sidecar spec. pub fn from_cli_matches(matches: &clap::ArgMatches) -> Result { let mut mpm = ModPakMetadata::default(); diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index 14b08996..7441da84 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -101,6 +101,7 @@ pub static CFG_TRAIT_FUNCTIONS_ROOT: &str = "functions"; /// Directory within the `DEFAULT_MODULES_SHARELIB` for sensors pub static CFG_SENSORS_ROOT: &str = "sensors"; +pub static CFG_PROFILES_ROOT: &str = "profiles"; // Key names // --------- @@ -465,6 +466,11 @@ impl MinionConfig { self.root = Some(dir.to_string()); } + /// Set master fileserver port + pub fn set_master_fileserver_port(&mut self, port: u32) { + self.master_fileserver_port = Some(port); + } + /// Set sharelib path pub fn set_sharelib_path(&mut self, p: &str) { self.sharelib_path = Some(p.to_string()); @@ -514,6 +520,11 @@ impl MinionConfig { self.root_dir().join(CFG_SENSORS_ROOT) } + /// Get root directory for synced deployment profiles + pub fn profiles_dir(&self) -> PathBuf { + self.root_dir().join(CFG_PROFILES_ROOT) + } + /// Return machine Id path pub fn machine_id_path(&self) -> PathBuf { if let Some(mid) = self.machine_id.clone() { @@ -1016,6 +1027,11 @@ impl MasterConfig { } } + /// Get deployment profiles root on the fileserver + pub fn fileserver_profiles_root(&self) -> PathBuf { + self.fileserver_root().join(CFG_PROFILES_ROOT) + } + /// Get default sysinspect root. For master it is always /etc/sysinspect pub fn root_dir(&self) -> PathBuf { PathBuf::from(DEFAULT_SYSINSPECT_ROOT.to_string()) diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index 0ba4c6ba..32ab31ea 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -1,3 +1,5 @@ +//! Encrypted console transport primitives shared by `sysinspect` and `sysmaster`. + use base64::{Engine, engine::general_purpose::STANDARD}; use libcommon::SysinspectError; use rsa::{RsaPrivateKey, RsaPublicKey}; @@ -22,40 +24,60 @@ mod console_ut; static SODIUM_INIT: OnceLock<()> = OnceLock::new(); +/// RSA-bootstrapped session bootstrap data sent before opening the sealed console payload. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConsoleBootstrap { + /// Client console public key in PEM format. pub client_pubkey: String, + /// Session key encrypted to the master's RSA public key. pub symkey_cipher: String, + /// Signature over the raw session key bytes using the client RSA private key. pub symkey_sign: String, } +/// Symmetrically encrypted console frame payload. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConsoleSealed { + /// Base64-encoded libsodium nonce. pub nonce: String, + /// Base64-encoded libsodium `secretbox` payload. pub payload: String, } +/// Full console request envelope containing the RSA bootstrap and sealed request. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConsoleEnvelope { + /// Bootstrap data used to derive the symmetric session key. pub bootstrap: ConsoleBootstrap, + /// Encrypted request payload. pub sealed: ConsoleSealed, } +/// Structured console request sent from `sysinspect` to `sysmaster`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConsoleQuery { + /// Requested model or command URI. pub model: String, + /// Target query string or hostname glob. pub query: String, + /// Optional traits selector expression. pub traits: String, + /// Optional direct minion System Id target. pub mid: String, + /// Optional JSON-encoded context payload. pub context: String, } +/// Structured console response returned by `sysmaster`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConsoleResponse { + /// Response success flag. pub ok: bool, + /// Human-readable response message or payload. pub message: String, } +/// Ensure the local libsodium state is initialised once for console sealing operations. fn sodium_ready() -> Result<(), SysinspectError> { if SODIUM_INIT.get().is_some() { return Ok(()); @@ -71,6 +93,7 @@ fn console_keypair(root: &Path) -> (PathBuf, PathBuf) { (root.join(CFG_CONSOLE_KEY_PRI), root.join(CFG_CONSOLE_KEY_PUB)) } +/// Ensure a console RSA keypair exists under the given root and return it. pub fn ensure_console_keypair(root: &Path) -> Result<(RsaPrivateKey, RsaPublicKey), SysinspectError> { let (prk_path, pbk_path) = console_keypair(root); if prk_path.exists() && pbk_path.exists() { @@ -84,10 +107,12 @@ pub fn ensure_console_keypair(root: &Path) -> Result<(RsaPrivateKey, RsaPublicKe Ok((prk, pbk)) } +/// Load the master's public RSA key used for console session bootstrap. pub fn load_master_public_key(cfg: &MasterConfig) -> Result { load_public_key(&cfg.root_dir().join(crate::cfg::mmconf::CFG_MASTER_KEY_PUB)) } +/// Load the master's private RSA key used for console session bootstrap. pub fn load_master_private_key(cfg: &MasterConfig) -> Result { load_private_key(&cfg.root_dir().join(crate::cfg::mmconf::CFG_MASTER_KEY_PRI)) } @@ -109,6 +134,7 @@ fn load_public_key(path: &Path) -> Result { } impl ConsoleBootstrap { + /// Build bootstrap material for a new console session. pub fn new(client_prk: &RsaPrivateKey, client_pbk: &RsaPublicKey, master_pbk: &RsaPublicKey, symkey: &Key) -> Result { Ok(Self { client_pubkey: to_pem(None, Some(client_pbk)) @@ -126,6 +152,7 @@ impl ConsoleBootstrap { }) } + /// Recover and verify the console session key from the bootstrap payload. pub fn session_key(&self, master_prk: &RsaPrivateKey) -> Result<(Key, RsaPublicKey), SysinspectError> { let client_pbk = crate::rsa::keys::from_pem(None, Some(&self.client_pubkey)) .map_err(|e| SysinspectError::RSAError(e.to_string()))? @@ -154,6 +181,7 @@ impl ConsoleBootstrap { } impl ConsoleSealed { + /// Seal a serializable console payload with the given symmetric session key. pub fn seal(payload: &T, key: &Key) -> Result { sodium_ready()?; let nonce = secretbox::gen_nonce(); @@ -167,6 +195,7 @@ impl ConsoleSealed { }) } + /// Open a sealed console payload with the given symmetric session key. pub fn open(&self, key: &Key) -> Result { sodium_ready()?; let nonce = Nonce::from_slice( @@ -187,6 +216,7 @@ impl ConsoleSealed { } } +/// Check whether the provided client console public key is authorised by the master. pub fn authorised_console_client(cfg: &MasterConfig, client_pem: &str) -> Result { if cfg.console_pubkey().exists() && fs::read_to_string(cfg.console_pubkey()).map_err(SysinspectError::IoErr)? == client_pem { return Ok(true); @@ -207,6 +237,7 @@ pub fn authorised_console_client(cfg: &MasterConfig, client_pem: &str) -> Result Ok(false) } +/// Build a fully bootstrapped encrypted console request envelope for the given query. pub fn build_console_query(root: &Path, cfg: &MasterConfig, query: &ConsoleQuery) -> Result<(ConsoleEnvelope, Key), SysinspectError> { sodium_ready()?; let (client_prk, client_pbk) = ensure_console_keypair(root)?; diff --git a/libsysinspect/src/context/mod.rs b/libsysinspect/src/context/mod.rs index 11501560..ddade778 100644 --- a/libsysinspect/src/context/mod.rs +++ b/libsysinspect/src/context/mod.rs @@ -1,9 +1,12 @@ +//! Shared context parsing and small request payload types used by CLI and console paths. + pub mod host; #[cfg(test)] mod host_ut; use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; use serde_json::{Number, Value}; /// Parse a string into a serde_json::Value @@ -43,7 +46,11 @@ fn get_json_value(s: &str) -> Value { Value::String(s.to_string()) } -/// Get context data from a string +/// Parse comma-separated `key:value` pairs into a typed JSON map. +/// +/// Values are interpreted as JSON-like scalars where possible: +/// `null`, booleans, integers, floats, and quoted strings. Everything +/// else is kept as a plain string. pub fn get_context(c: &str) -> Option> { let c = c.trim(); if c.is_empty() { @@ -72,3 +79,49 @@ pub fn get_context(c: &str) -> Option> { pub fn get_context_keys(c: &str) -> Vec { c.trim().split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect() } + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +/// Console request payload used for profile management operations. +pub struct ProfileConsoleRequest { + op: String, + #[serde(default)] + name: String, + #[serde(default)] + matches: Vec, + #[serde(default)] + library: bool, + #[serde(default)] + profiles: Vec, +} + +impl ProfileConsoleRequest { + /// Parse a profile console request from the JSON context payload. + pub fn from_context(context: &str) -> Result { + serde_json::from_str(context) + } + + /// Return the requested profile operation name. + pub fn op(&self) -> &str { + &self.op + } + + /// Return the target profile name, if present. + pub fn name(&self) -> &str { + &self.name + } + + /// Return the module or library selector list carried by the request. + pub fn matches(&self) -> &[String] { + &self.matches + } + + /// Return whether the request targets library selectors instead of module selectors. + pub fn library(&self) -> bool { + self.library + } + + /// Return the profile names carried by tag or untag requests. + pub fn profiles(&self) -> &[String] { + &self.profiles + } +} diff --git a/libsysinspect/src/traits/mod.rs b/libsysinspect/src/traits/mod.rs index 5a886388..23d695a4 100644 --- a/libsysinspect/src/traits/mod.rs +++ b/libsysinspect/src/traits/mod.rs @@ -1,7 +1,9 @@ +//! Trait parsing, loading, and master-managed static trait helpers. + pub mod systraits; use crate::cfg::mmconf::MinionConfig; -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use libcommon::SysinspectError; use once_cell::sync::OnceCell; use pest::Parser; @@ -15,22 +17,38 @@ use systraits::SystemTraits; mod traits_ut; /// Standard Traits +/// Stable System Id trait key. pub static SYS_ID: &str = "system.id"; +/// Operating system kernel trait key. pub static SYS_OS_KERNEL: &str = "system.kernel"; +/// Operating system version trait key. pub static SYS_OS_VERSION: &str = "system.os.version"; +/// Operating system name trait key. pub static SYS_OS_NAME: &str = "system.os.name"; +/// Operating system distribution trait key. pub static SYS_OS_DISTRO: &str = "system.os.distribution"; +/// Hostname trait key. pub static SYS_NET_HOSTNAME: &str = "system.hostname"; +/// FQDN hostname trait key. pub static SYS_NET_HOSTNAME_FQDN: &str = "system.hostname.fqdn"; +/// Primary hostname IP trait key. pub static SYS_NET_HOSTNAME_IP: &str = "system.hostname.ip"; +/// Memory trait key. pub static HW_MEM: &str = "hardware.memory"; +/// Swap trait key. pub static HW_SWAP: &str = "hardware.swap"; +/// CPU count trait key. pub static HW_CPU_TOTAL: &str = "hardware.cpu.total"; +/// CPU brand trait key. pub static HW_CPU_BRAND: &str = "hardware.cpu.brand"; +/// CPU frequency trait key. pub static HW_CPU_FREQ: &str = "hardware.cpu.frequency"; +/// CPU vendor trait key. pub static HW_CPU_VENDOR: &str = "hardware.cpu.vendor"; +/// CPU core count trait key. pub static HW_CPU_CORES: &str = "hardware.cpu.cores"; +/// Reserved master-managed traits overlay filename. pub static MASTER_TRAITS_FILE: &str = "master.cfg"; static MASTER_TRAITS_FILE_HEADER: &str = "# THIS FILE IS AUTOGENERATED BY SYSINSPECT MASTER.\n# DO NOT EDIT THIS FILE MANUALLY.\n# LOCAL CUSTOM TRAITS BELONG IN SEPARATE *.cfg FILES.\n"; @@ -121,6 +139,30 @@ pub fn get_minion_traits_nolog(cfg: Option<&MinionConfig>) -> SystemTraits { __get_minion_traits(cfg, true) } +/// Resolve the effective deployment profile names for a minion. +/// +/// This reads the merged trait view fresh and interprets `minion.profile` +/// as either a single string or an array of strings. Missing or empty values +/// fall back to `default`. Duplicate profile names are removed while keeping +/// the first-seen order. +pub fn effective_profiles(cfg: &MinionConfig) -> Vec { + match SystemTraits::new(cfg.clone(), true).get("minion.profile") { + Some(serde_json::Value::String(name)) if !name.trim().is_empty() => vec![name], + Some(serde_json::Value::Array(items)) => { + let mut names = IndexSet::new(); + for item in items { + if let Some(name) = item.as_str() + && !name.trim().is_empty() + { + names.insert(name.to_string()); + } + } + if names.is_empty() { vec!["default".to_string()] } else { names.into_iter().collect() } + } + _ => vec!["default".to_string()], + } +} + /// Get or initialise system traits fn __get_minion_traits(cfg: Option<&MinionConfig>, q: bool) -> SystemTraits { if let Some(cfg) = cfg { @@ -158,6 +200,7 @@ fn store_master_traits(cfg: &MinionConfig, traits: &IndexMap) -> } #[derive(Debug, Deserialize)] +/// Generic master-to-minion trait update payload. pub struct TraitUpdateRequest { op: String, #[serde(default)] @@ -165,10 +208,12 @@ pub struct TraitUpdateRequest { } impl TraitUpdateRequest { + /// Parse a trait update request from the console command JSON context. pub fn from_context(context: &str) -> Result { Ok(serde_json::from_str(context)?) } + /// Apply the requested update to the reserved master-managed traits file. pub fn apply(&self, cfg: &MinionConfig) -> Result, SysinspectError> { let mut current = load_master_traits(cfg)?; match self.op.as_str() { @@ -189,10 +234,12 @@ impl TraitUpdateRequest { Ok(current) } + /// Return the requested update operation name. pub fn op(&self) -> &str { &self.op } + /// Return the raw trait payload carried by the request. pub fn traits(&self) -> &IndexMap { &self.traits } diff --git a/libsysproto/src/query.rs b/libsysproto/src/query.rs index 69232da9..5b886493 100644 --- a/libsysproto/src/query.rs +++ b/libsysproto/src/query.rs @@ -29,6 +29,9 @@ pub mod commands { // Update master-managed static traits on minions pub const CLUSTER_TRAITS_UPDATE: &str = "cluster/traits/update"; + + // Manage deployment profiles on the master + pub const CLUSTER_PROFILE: &str = "cluster/profile"; } /// diff --git a/src/clidef.rs b/src/clidef.rs index 001db64a..a776c4c4 100644 --- a/src/clidef.rs +++ b/src/clidef.rs @@ -41,6 +41,23 @@ pub fn cli(version: &'static str) -> Command { .arg(Arg::new("query-pos").help("Target minions by hostname glob or query").required(false).index(1)) .arg(Arg::new("help").short('h').long("help").action(ArgAction::SetTrue).help("Display help for this command")) ) + .subcommand(Command::new("profile").about("Manage deployment profiles").styles(styles.clone()).disable_help_flag(true) + .arg(Arg::new("new").long("new").action(ArgAction::SetTrue).help("Create a deployment profile").conflicts_with_all(["delete", "list", "add", "remove", "tag", "untag"])) + .arg(Arg::new("delete").long("delete").action(ArgAction::SetTrue).help("Delete a deployment profile").conflicts_with_all(["new", "list", "add", "remove", "tag", "untag"])) + .arg(Arg::new("list").long("list").action(ArgAction::SetTrue).help("List deployment profiles or their assigned selectors").conflicts_with_all(["new", "delete", "add", "remove", "tag", "untag"])) + .arg(Arg::new("add").short('A').long("add").action(ArgAction::SetTrue).help("Add selectors to a deployment profile").conflicts_with_all(["new", "delete", "list", "remove", "tag", "untag"])) + .arg(Arg::new("remove").short('R').long("remove").action(ArgAction::SetTrue).help("Remove selectors from a deployment profile").conflicts_with_all(["new", "delete", "list", "add", "tag", "untag"])) + .arg(Arg::new("tag").long("tag").help("Assign one or more profiles to targeted minions").conflicts_with_all(["new", "delete", "list", "add", "remove", "untag"])) + .arg(Arg::new("untag").long("untag").help("Unassign one or more profiles from targeted minions").conflicts_with_all(["new", "delete", "list", "add", "remove", "tag"])) + .arg(Arg::new("name").short('n').long("name").help("Profile name or profile glob pattern")) + .arg(Arg::new("match").short('m').long("match").help("Comma-separated module or library selectors")) + .arg(Arg::new("lib").short('l').long("lib").action(ArgAction::SetTrue).help("Operate on library selectors instead of module selectors")) + .arg(Arg::new("id").long("id").help("Target a specific minion by its system id").conflicts_with_all(["query", "query-pos"])) + .arg(Arg::new("query").long("query").help("Target minions by hostname glob or query").conflicts_with("query-pos")) + .arg(Arg::new("select-traits").long("traits").help("Target minions by traits query")) + .arg(Arg::new("query-pos").help("Target minions by hostname glob or query").required(false).index(1)) + .arg(Arg::new("help").short('h').long("help").action(ArgAction::SetTrue).help("Display help for this command")) + ) // Sysinspect .next_help_heading("Main") diff --git a/src/main.rs b/src/main.rs index 7d98691f..7a6ba6f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,7 @@ use libsysinspect::{ }; use libsysproto::query::SCHEME_COMMAND; use libsysproto::query::commands::{ - CLUSTER_ONLINE_MINIONS, CLUSTER_REMOVE_MINION, CLUSTER_SHUTDOWN, CLUSTER_SYNC, CLUSTER_TRAITS_UPDATE, + CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, CLUSTER_SHUTDOWN, CLUSTER_SYNC, CLUSTER_TRAITS_UPDATE, }; use log::LevelFilter; use serde_json::json; @@ -98,6 +98,71 @@ fn traits_update_context(am: &ArgMatches) -> Result, SysinspectEr Err(SysinspectError::InvalidQuery("Specify one of --set, --unset, or --reset".to_string())) } +fn profile_update_context(am: &ArgMatches) -> Result, SysinspectError> { + let invalid_name = |name: &str| name.chars().any(|c| ['*', '?', '[', ']'].contains(&c)); + if am.get_flag("new") { + if am.get_one::("name").is_none() { + return Err(SysinspectError::InvalidQuery("Specify --name for --new".to_string())); + } + if invalid_name(am.get_one::("name").unwrap()) { + return Err(SysinspectError::InvalidQuery("Profile names for --new must be exact names, not glob patterns".to_string())); + } + return Ok(Some(json!({"op": "new", "name": am.get_one::("name").cloned().unwrap_or_default()}).to_string())); + } + + if am.get_flag("delete") { + if am.get_one::("name").is_none() { + return Err(SysinspectError::InvalidQuery("Specify --name for --delete".to_string())); + } + if invalid_name(am.get_one::("name").unwrap()) { + return Err(SysinspectError::InvalidQuery("Profile names for --delete must be exact names, not glob patterns".to_string())); + } + return Ok(Some(json!({"op": "delete", "name": am.get_one::("name").cloned().unwrap_or_default()}).to_string())); + } + + if am.get_flag("list") { + return Ok(Some( + json!({"op": "list", "name": am.get_one::("name").cloned().unwrap_or_default(), "library": am.get_flag("lib")}).to_string(), + )); + } + + if am.get_flag("add") || am.get_flag("remove") { + if am.get_one::("name").is_none() || am.get_one::("match").is_none() { + return Err(SysinspectError::InvalidQuery("Specify both --name and --match for profile selector updates".to_string())); + } + if invalid_name(am.get_one::("name").unwrap()) { + return Err(SysinspectError::InvalidQuery("Profile names for selector updates must be exact names, not glob patterns".to_string())); + } + if clidef::split_by(am, "match", None).is_empty() { + return Err(SysinspectError::InvalidQuery("At least one selector is required in --match".to_string())); + } + return Ok(Some( + json!({ + "op": if am.get_flag("add") { "add" } else { "remove" }, + "name": am.get_one::("name").cloned().unwrap_or_default(), + "matches": clidef::split_by(am, "match", None), + "library": am.get_flag("lib"), + }) + .to_string(), + )); + } + + if am.get_one::("tag").is_some() || am.get_one::("untag").is_some() { + if clidef::split_by(am, if am.get_one::("tag").is_some() { "tag" } else { "untag" }, None).is_empty() { + return Err(SysinspectError::InvalidQuery("Specify at least one profile name for --tag or --untag".to_string())); + } + return Ok(Some( + json!({ + "op": if am.get_one::("tag").is_some() { "tag" } else { "untag" }, + "profiles": clidef::split_by(am, if am.get_one::("tag").is_some() { "tag" } else { "untag" }, None), + }) + .to_string(), + )); + } + + Err(SysinspectError::InvalidQuery("Specify one profile operation".to_string())) +} + /// Set logger fn set_logger(p: &ArgMatches) { let log: &'static dyn log::Log = if *p.get_one::("ui").unwrap_or(&false) { @@ -142,6 +207,15 @@ fn help(cli: &mut Command, params: &ArgMatches) -> bool { } return false; } + if let Some(sub) = params.subcommand_matches("profile") + && sub.get_flag("help") + { + if let Some(s_cli) = cli.find_subcommand_mut("profile") { + _ = s_cli.print_help(); + return true; + } + return false; + } if params.get_flag("help") { _ = &cli.print_long_help(); return true; @@ -296,6 +370,33 @@ async fn main() { exit(0); } + if let Some(sub) = params.subcommand_matches("profile") { + let target_id = sub.get_one::("id").map(String::as_str); + let target_query = sub + .get_one::("query") + .or_else(|| sub.get_one::("query-pos")) + .map(String::as_str) + .unwrap_or("*"); + let target_traits = sub.get_one::("select-traits"); + let context = match profile_update_context(sub) { + Ok(ctx) => ctx, + Err(err) => { + log::error!("{err}"); + exit(1); + } + }; + + match call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_PROFILE}"), target_query, target_traits, target_id, context.as_ref()).await { + Ok(resp) => { + if !resp.message.is_empty() { + println!("{}", resp.message); + } + } + Err(err) => log::error!("Cannot reach master: {err}"), + } + exit(0); + } + if *params.get_one::("list-handlers").unwrap_or(&false) { print_event_handlers(); return; diff --git a/sysmaster/src/dataserv/fls.rs b/sysmaster/src/dataserv/fls.rs index 2a61fc45..4e336cea 100644 --- a/sysmaster/src/dataserv/fls.rs +++ b/sysmaster/src/dataserv/fls.rs @@ -2,7 +2,7 @@ use actix_web::{App, HttpResponse, HttpServer, Responder, rt::System, web}; use colored::Colorize; use libcommon::SysinspectError; use libsysinspect::cfg::mmconf::{ - CFG_FILESERVER_ROOT, CFG_MODELS_ROOT, CFG_MODREPO_ROOT, CFG_SENSORS_ROOT, CFG_TRAIT_FUNCTIONS_ROOT, CFG_TRAITS_ROOT, MasterConfig, + CFG_FILESERVER_ROOT, CFG_MODELS_ROOT, CFG_MODREPO_ROOT, CFG_PROFILES_ROOT, CFG_SENSORS_ROOT, CFG_TRAIT_FUNCTIONS_ROOT, CFG_TRAITS_ROOT, MasterConfig, }; use std::{fs, path::PathBuf, thread}; @@ -14,7 +14,7 @@ fn init_fs_env(cfg: &MasterConfig) -> Result<(), SysinspectError> { fs::create_dir_all(&root)?; } - for sub in [CFG_TRAIT_FUNCTIONS_ROOT, CFG_MODELS_ROOT, CFG_MODREPO_ROOT, CFG_TRAITS_ROOT, CFG_SENSORS_ROOT] { + for sub in [CFG_TRAIT_FUNCTIONS_ROOT, CFG_MODELS_ROOT, CFG_MODREPO_ROOT, CFG_PROFILES_ROOT, CFG_TRAITS_ROOT, CFG_SENSORS_ROOT] { let subdir = root.join(sub); if !subdir.exists() { log::info!("Created file server subdirectory at {}", subdir.display().to_string().bright_yellow()); @@ -22,6 +22,10 @@ fn init_fs_env(cfg: &MasterConfig) -> Result<(), SysinspectError> { } } + if !root.join("profiles.index").exists() { + fs::write(root.join("profiles.index"), "profiles: {}\n")?; + } + Ok(()) } diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index d0c5ff10..e4d20009 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -17,9 +17,11 @@ use libeventreg::{ ipcs::DbIPCService, kvdb::{EventMinion, EventsRegistry}, }; +use libmodpak::SysInspectModPak; use libsysinspect::{ cfg::mmconf::{CFG_MODELS_ROOT, MasterConfig}, console::{ConsoleEnvelope, ConsoleQuery, ConsoleResponse, ConsoleSealed, authorised_console_client, ensure_console_keypair, load_master_private_key}, + context::{ProfileConsoleRequest, get_context}, mdescr::{mspec::MODEL_FILE_EXT, mspecdef::ModelSpec, telemetry::DataExportType}, util::{self, iofs::scan_files_sha256, pad_visible}, }; @@ -29,7 +31,7 @@ use libsysproto::{ payload::{ModStatePayload, PingData}, query::{ SCHEME_COMMAND, - commands::{CLUSTER_ONLINE_MINIONS, CLUSTER_REMOVE_MINION}, + commands::{CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, CLUSTER_TRAITS_UPDATE}, }, rqtypes::{ProtoKey, ProtoValue, RequestType}, }; @@ -729,6 +731,7 @@ impl SysMaster { } } + /// Build the formatted online-minion summary returned by the console `--online` command. async fn online_minions_summary(&mut self) -> Result { let mreg = self.mreg.lock().await; let mut session = self.session.lock().await; @@ -787,6 +790,136 @@ impl SysMaster { Ok(out.join("\n")) } + /// Resolve target minions for console profile operations from id, traits query, or hostname query. + async fn selected_minions(&mut self, query: &str, traits: &str, mid: &str) -> Result, SysinspectError> { + let mut records = if !mid.is_empty() { + self.mreg.lock().await.get(mid)?.into_iter().collect::>() + } else if !traits.trim().is_empty() { + self.mreg + .lock() + .await + .get_by_traits(get_context(traits).unwrap_or_default().into_iter().collect::>())? + } else { + self.mreg.lock().await.get_by_query(if query.trim().is_empty() { "*" } else { query })? + }; + records.sort_by(|a, b| a.id().cmp(b.id())); + Ok(records) + } + + /// Execute one profile console request and return its console response plus any outbound master messages to broadcast. + async fn profile_console_response( + &mut self, request: &ProfileConsoleRequest, query: &str, traits: &str, mid: &str, + ) -> Result<(ConsoleResponse, Vec), SysinspectError> { + let repo = SysInspectModPak::new(self.cfg.get_mod_repo_root())?; + + match request.op() { + "new" => Ok(( + { + repo.new_profile(request.name())?; + ConsoleResponse { ok: true, message: format!("Created profile {}", request.name().bright_yellow()) } + }, + vec![], + )), + "delete" => Ok(( + { + repo.delete_profile(request.name())?; + ConsoleResponse { ok: true, message: format!("Deleted profile {}", request.name().bright_yellow()) } + }, + vec![], + )), + "list" => Ok(( + ConsoleResponse { + ok: true, + message: if request.name().is_empty() { + repo.list_profiles(None)?.join("\n") + } else { + repo.list_profile_matches(Some(request.name()), request.library())?.join("\n") + }, + }, + vec![], + )), + "add" => Ok(( + { + repo.add_profile_matches(request.name(), request.matches().to_vec(), request.library())?; + ConsoleResponse { ok: true, message: format!("Updated profile {}", request.name().bright_yellow()) } + }, + vec![], + )), + "remove" => Ok(( + { + repo.remove_profile_matches(request.name(), request.matches().to_vec(), request.library())?; + ConsoleResponse { ok: true, message: format!("Updated profile {}", request.name().bright_yellow()) } + }, + vec![], + )), + "tag" | "untag" => { + let known_profiles = repo.list_profiles(None)?; + let missing = request.profiles().iter().filter(|name| !known_profiles.contains(name)).cloned().collect::>(); + if !missing.is_empty() { + return Ok(( + ConsoleResponse { + ok: false, + message: format!("Unknown profile{}: {}", if missing.len() == 1 { "" } else { "s" }, missing.join(", ").bright_yellow()), + }, + vec![], + )); + } + + let mut msgs = Vec::new(); + for minion in self.selected_minions(query, traits, mid).await? { + let mut profiles = match minion.get_traits().get("minion.profile") { + Some(serde_json::Value::String(name)) if !name.trim().is_empty() => vec![name.to_string()], + Some(serde_json::Value::Array(names)) => names.iter().filter_map(|name| name.as_str().map(str::to_string)).collect::>(), + _ => vec!["default".to_string()], + }; + if request.op() == "tag" { + for profile in request.profiles() { + if !profiles.contains(profile) { + profiles.push(profile.to_string()); + } + } + } else { + profiles.retain(|profile| !request.profiles().contains(profile)); + } + if profiles.is_empty() { + profiles.push("default".to_string()); + } + if let Some(msg) = self + .msg_query_data( + &format!("{SCHEME_COMMAND}{CLUSTER_TRAITS_UPDATE}"), + "", + "", + minion.id(), + &json!({"op": "set", "traits": {"minion.profile": profiles}}).to_string(), + ) + .await + { + msgs.push(msg); + } + } + + Ok(( + ConsoleResponse { + ok: true, + message: format!( + "{} {} on {} minion{}", + if request.op() == "tag" { "Applied profiles" } else { "Removed profiles" }, + request.profiles().join(", ").bright_yellow(), + msgs.len(), + if msgs.len() == 1 { "" } else { "s" } + ), + }, + msgs, + )) + } + _ => Ok(( + ConsoleResponse { ok: false, message: format!("Unsupported profile operation {}", request.op().bright_yellow()) }, + vec![], + )), + } + } + + /// Start the encrypted TCP console listener used by `sysinspect` to talk to the master. pub async fn do_console(master: Arc>) { log::trace!("Init local console channel"); tokio::spawn({ @@ -828,6 +961,27 @@ impl SysMaster { Ok(summary) => ConsoleResponse { ok: true, message: summary }, Err(err) => ConsoleResponse { ok: false, message: format!("Unable to get online minions: {err}") }, } + } else if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_PROFILE}")) { + let (response, msgs) = match ProfileConsoleRequest::from_context(&query.context) { + Ok(request) => { + let mut guard = master.lock().await; + match guard.profile_console_response(&request, &query.query, &query.traits, &query.mid).await { + Ok(data) => data, + Err(err) => (ConsoleResponse { ok: false, message: err.to_string() }, vec![]), + } + } + Err(err) => ( + ConsoleResponse { ok: false, message: format!("Failed to parse profile request: {err}") }, + vec![], + ), + }; + for msg in msgs { + SysMaster::bcast_master_msg(&bcast, cfg.telemetry_enabled(), Arc::clone(&master), Some(msg.clone())).await; + let guard = master.lock().await; + let ids = guard.mreg.lock().await.get_targeted_minions(msg.target(), false).await; + guard.taskreg.lock().await.register(msg.cycle(), ids); + } + response } else { let msg = { let mut guard = master.lock().await; diff --git a/sysminion/src/minion.rs b/sysminion/src/minion.rs index 400d59e1..484eb288 100644 --- a/sysminion/src/minion.rs +++ b/sysminion/src/minion.rs @@ -34,7 +34,7 @@ use libsysinspect::{ fmt::{formatter::StringFormatter, kvfmt::KeyValueFormatter}, }, rsa, - traits::{self, TraitUpdateRequest, ensure_master_traits_file, systraits::SystemTraits}, + traits::{self, TraitUpdateRequest, effective_profiles, ensure_master_traits_file, systraits::SystemTraits}, util::{self, dataconv}, }; use libsysproto::{ @@ -185,11 +185,22 @@ impl SysMinion { fs::create_dir_all(self.cfg.sensors_dir())?; } + if !self.cfg.profiles_dir().exists() { + log::debug!("Creating directory for the synced profiles at {}", self.cfg.profiles_dir().as_os_str().to_str().unwrap_or_default()); + fs::create_dir_all(self.cfg.profiles_dir())?; + } + let mut out: Vec = vec![]; for t in traits::get_minion_traits(Some(&self.cfg)).trait_keys() { out.push(format!("{}: {}", t.to_owned(), dataconv::to_string(traits::get_minion_traits(None).get(&t)).unwrap_or_default())); } log::debug!("Minion traits:\n{}", out.join("\n")); + let profiles = effective_profiles(&self.cfg); + log::info!( + "{} {}", + if profiles.len() == 1 { "Activating profile" } else { "Activating profiles" }, + profiles.iter().map(|name| name.bright_yellow().to_string()).collect::>().join(", ") + ); Ok(()) } From ce9c22f3134932a4b60fe129018277188497c383 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 14 Mar 2026 00:24:32 +0100 Subject: [PATCH 17/54] Add UT for minion profiles management --- libmodpak/src/lib_ut.rs | 62 ++++++++++ libmodpak/src/mpk_ut.rs | 55 ++++++++- libmodpak/tests/profile_sync.rs | 160 ++++++++++++++++++++++++++ libsysinspect/src/traits/traits_ut.rs | 16 ++- 4 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 libmodpak/tests/profile_sync.rs diff --git a/libmodpak/src/lib_ut.rs b/libmodpak/src/lib_ut.rs index 661a66c7..77107691 100644 --- a/libmodpak/src/lib_ut.rs +++ b/libmodpak/src/lib_ut.rs @@ -1,6 +1,8 @@ #[cfg(test)] mod tests { use crate::{SysInspectModPak, mpk::ModPakMetadata}; + use libsysinspect::{cfg::mmconf::MinionConfig, traits::effective_profiles}; + use std::collections::HashSet; use colored::control; use std::{fs, path::Path}; @@ -223,4 +225,64 @@ mod tests { } assert!(found, "runtime.lua should be indexed"); } + + #[test] + fn profile_crud_updates_index_and_profile_file() { + let root = tempfile::tempdir().expect("repo tempdir should be created"); + let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); + + repo.new_profile("toto").expect("profile should be created"); + repo.add_profile_matches("toto", vec!["runtime.lua".to_string(), "net.*".to_string()], false) + .expect("module selectors should be added"); + repo.add_profile_matches("toto", vec!["runtime/lua/*.lua".to_string()], true).expect("library selectors should be added"); + + assert_eq!(repo.list_profiles(None).expect("profiles should list"), vec!["toto".to_string()]); + assert!(repo + .list_profile_matches(Some("toto"), false) + .expect("profile modules should list") + .contains(&"toto: runtime.lua".to_string())); + assert!(repo + .list_profile_matches(Some("toto"), false) + .expect("profile modules should list") + .contains(&"toto: net.*".to_string())); + assert!(repo + .list_profile_matches(Some("toto"), true) + .expect("profile libraries should list") + .contains(&"toto: runtime/lua/*.lua".to_string())); + + repo.remove_profile_matches("toto", vec!["net.*".to_string()], false).expect("module selector should be removed"); + assert!(!repo + .list_profile_matches(Some("toto"), false) + .expect("profile modules should list") + .contains(&"toto: net.*".to_string())); + + repo.delete_profile("toto").expect("profile should be deleted"); + assert!(repo.list_profiles(None).expect("profiles should list").is_empty()); + } + + #[test] + fn profile_create_and_delete_validate_existence() { + let root = tempfile::tempdir().expect("repo tempdir should be created"); + let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); + + assert!(repo.delete_profile("missing").is_err()); + repo.new_profile("toto").expect("profile should be created"); + assert!(repo.new_profile("toto").is_err()); + } + + #[test] + fn effective_profile_names_fallback_to_default_and_accept_array() { + let root = tempfile::tempdir().expect("root tempdir should be created"); + let share = tempfile::tempdir().expect("share tempdir should be created"); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().expect("root path should be valid")); + cfg.set_sharelib_path(share.path().to_str().expect("share path should be valid")); + fs::create_dir_all(cfg.traits_dir()).expect("traits dir should be created"); + + assert_eq!(effective_profiles(&cfg), vec!["default".to_string()]); + + fs::write(cfg.traits_dir().join("master.cfg"), "minion.profile:\n - Toto\n - Foo\n - Toto\n").expect("master traits should be written"); + let names = effective_profiles(&cfg).into_iter().collect::>(); + assert_eq!(names, HashSet::from(["Toto".to_string(), "Foo".to_string()])); + } } diff --git a/libmodpak/src/mpk_ut.rs b/libmodpak/src/mpk_ut.rs index a175de20..6c6011f8 100644 --- a/libmodpak/src/mpk_ut.rs +++ b/libmodpak/src/mpk_ut.rs @@ -1,4 +1,5 @@ -use crate::mpk::ModPakMetadata; +use crate::mpk::{ModPakMetadata, ModPakProfile, ModPakProfilesIndex, ModPakRepoIndex}; +use indexmap::IndexSet; use std::path::PathBuf; #[test] @@ -12,3 +13,55 @@ fn runtime_dispatcher_names_are_reserved() { let meta = ModPakMetadata::new_for_test(PathBuf::from("/tmp/custom-module"), "lua.reader"); assert!(meta.validate_namespace().is_err()); } + +#[test] +fn profiles_index_and_profile_roundtrip() { + let mut index = ModPakProfilesIndex::new(); + index.insert("default", PathBuf::from("default.profile"), "deadbeef"); + let index = ModPakProfilesIndex::from_yaml(&index.to_yaml().expect("profiles index should serialize")).expect("profiles index should deserialize"); + let profile = + ModPakProfile::from_yaml("name: default\nmodules:\n - runtime.lua\nlibraries:\n - runtime/lua/reader.lua\n").expect("profile should deserialize"); + + assert_eq!(index.get("default").expect("default profile should exist").file(), &PathBuf::from("default.profile")); + assert_eq!(profile.name(), "default"); + assert_eq!(profile.modules(), &["runtime.lua".to_string()]); + assert_eq!(profile.libraries(), &["runtime/lua/reader.lua".to_string()]); +} + +#[test] +fn profile_merge_and_repo_filter_dedup_exact_matches() { + let mut modules = IndexSet::new(); + let mut libraries = IndexSet::new(); + ModPakProfile::from_yaml("name: default\nmodules:\n - runtime.lua\n - runtime.lua\nlibraries:\n - runtime/lua/reader.lua\n") + .expect("profile should deserialize") + .merge_into(&mut modules, &mut libraries); + + let mut repo = ModPakRepoIndex::from_yaml( + r#" +platform: {} +library: + runtime/lua/reader.lua: + file: runtime/lua/reader.lua + checksum: beadfeed + kind: lua + runtime/py3/reader.py: + file: runtime/py3/reader.py + checksum: facefeed + kind: python +"#, + ) + .expect("repo index should deserialize"); + repo.index_module("runtime.lua", "runtime/lua", "any", "noarch", "lua runtime", false, "deadbeef", None, None) + .expect("runtime module should index"); + repo.index_module("net.ping", "net/ping", "any", "noarch", "ping module", false, "cafebabe", None, None) + .expect("ping module should index"); + + let filtered = repo.retain_profiles(&modules, &libraries); + let modules = filtered.modules(); + let libraries = filtered.library(); + + assert!(modules.contains_key("runtime.lua")); + assert!(!modules.contains_key("net.ping")); + assert!(libraries.contains_key("runtime/lua/reader.lua")); + assert!(!libraries.contains_key("runtime/py3/reader.py")); +} diff --git a/libmodpak/tests/profile_sync.rs b/libmodpak/tests/profile_sync.rs new file mode 100644 index 00000000..3f003a32 --- /dev/null +++ b/libmodpak/tests/profile_sync.rs @@ -0,0 +1,160 @@ +use libmodpak::{SysInspectModPak, SysInspectModPakMinion, mpk::ModPakRepoIndex}; +use libsysinspect::{ + cfg::mmconf::MinionConfig, + traits::{TraitUpdateRequest, ensure_master_traits_file}, +}; +use std::{fs, path::{Path, PathBuf}}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; + +fn write_file(path: &Path, data: &[u8]) { + fs::create_dir_all(path.parent().expect("parent should exist")).expect("parent dir should be created"); + fs::write(path, data).expect("file should be written"); +} + +fn add_script_module(root: &Path, name: &str, body: &str) { + let subpath = name.replace('.', "/"); + write_file(&root.join("script/any/noarch").join(&subpath), body.as_bytes()); +} + +fn set_script_modules(root: &Path, modules: &[&str]) { + let mut index = if root.join("mod.index").exists() { + ModPakRepoIndex::from_yaml(&fs::read_to_string(root.join("mod.index")).expect("mod.index should read")).expect("mod.index should deserialize") + } else { + ModPakRepoIndex::new() + }; + for module in modules { + index + .index_module(module, &module.replace('.', "/"), "any", "noarch", "demo module", false, "deadbeef", None, None) + .expect("module should index"); + } + fs::write(root.join("mod.index"), index.to_yaml().expect("mod.index should serialize")).expect("mod.index should write"); +} + +fn add_library_tree(repo: &mut SysInspectModPak, root: &Path, rel: &str) { + let file = root.join("lib").join(rel); + write_file(&file, rel.as_bytes()); + repo.add_library(root.to_path_buf()).expect("library should be added"); +} + +async fn start_fileserver(root: PathBuf) -> (u16, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener should bind"); + let port = listener.local_addr().expect("listener addr should exist").port(); + let handle = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { break }; + let root = root.clone(); + tokio::spawn(async move { + let mut buf = [0_u8; 4096]; + let Ok(n) = stream.read(&mut buf).await else { return }; + let req = String::from_utf8_lossy(&buf[..n]); + let path = req + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/"); + let file = root.join(path.trim_start_matches('/')); + let response = match fs::read(&file) { + Ok(body) => format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).into_bytes(), + Err(_) => b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec(), + }; + let body = fs::read(&file).unwrap_or_default(); + let _ = stream.write_all(&response).await; + if !body.is_empty() { + let _ = stream.write_all(&body).await; + } + }); + } + }); + (port, handle) +} + +fn configured_minion(root: &Path, share: &Path, port: u16) -> MinionConfig { + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.to_str().expect("root path should be valid")); + cfg.set_sharelib_path(share.to_str().expect("share path should be valid")); + cfg.set_master_ip("127.0.0.1"); + cfg.set_master_fileserver_port(port.into()); + cfg +} + +#[tokio::test] +async fn narrow_profile_syncs_only_allowed_artifacts_and_removes_old_ones() { + let master = tempfile::tempdir().expect("master tempdir should be created"); + let mut repo = SysInspectModPak::new(master.path().join("data/repo")).expect("repo should be created"); + let libs = tempfile::tempdir().expect("library tempdir should be created"); + add_library_tree(&mut repo, libs.path(), "runtime/lua/alpha.lua"); + add_library_tree(&mut repo, libs.path(), "runtime/lua/beta.lua"); + add_script_module(&master.path().join("data/repo"), "alpha.demo", "# alpha"); + add_script_module(&master.path().join("data/repo"), "beta.demo", "# beta"); + set_script_modules(&master.path().join("data/repo"), &["alpha.demo", "beta.demo"]); + repo.new_profile("Alpha").expect("Alpha should be created"); + repo.new_profile("Beta").expect("Beta should be created"); + repo.add_profile_matches("Alpha", vec!["alpha.demo".to_string()], false).expect("Alpha module selector should be added"); + repo.add_profile_matches("Alpha", vec!["lib/runtime/lua/alpha.lua".to_string()], true).expect("Alpha library selector should be added"); + repo.add_profile_matches("Beta", vec!["beta.demo".to_string()], false).expect("Beta module selector should be added"); + repo.add_profile_matches("Beta", vec!["lib/runtime/lua/beta.lua".to_string()], true).expect("Beta library selector should be added"); + + let (port, server) = start_fileserver(master.path().join("data")).await; + let minion = tempfile::tempdir().expect("minion tempdir should be created"); + let share = tempfile::tempdir().expect("share tempdir should be created"); + let cfg = configured_minion(minion.path(), share.path(), port); + fs::create_dir_all(cfg.traits_dir()).expect("traits dir should be created"); + ensure_master_traits_file(&cfg).expect("master traits file should exist"); + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"minion.profile":["Alpha"]}}"#) + .expect("set request should parse") + .apply(&cfg) + .expect("set request should apply"); + + SysInspectModPakMinion::new(cfg.clone()).sync().await.expect("first sync should work"); + assert!(share.path().join("modules/alpha/demo").exists()); + assert!(!share.path().join("modules/beta/demo").exists()); + assert!(share.path().join("lib/lib/runtime/lua/alpha.lua").exists()); + assert!(!share.path().join("lib/lib/runtime/lua/beta.lua").exists()); + + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"minion.profile":["Beta"]}}"#) + .expect("set request should parse") + .apply(&cfg) + .expect("set request should apply"); + SysInspectModPakMinion::new(cfg).sync().await.expect("second sync should work"); + assert!(!share.path().join("modules/alpha/demo").exists()); + assert!(share.path().join("modules/beta/demo").exists()); + assert!(!share.path().join("lib/lib/runtime/lua/alpha.lua").exists()); + assert!(share.path().join("lib/lib/runtime/lua/beta.lua").exists()); + + server.abort(); +} + +#[tokio::test] +async fn overlapping_multi_profile_sync_merges_by_union_and_dedup() { + let master = tempfile::tempdir().expect("master tempdir should be created"); + let repo = SysInspectModPak::new(master.path().join("data/repo")).expect("repo should be created"); + add_script_module(&master.path().join("data/repo"), "alpha.demo", "# alpha"); + add_script_module(&master.path().join("data/repo"), "beta.demo", "# beta"); + add_script_module(&master.path().join("data/repo"), "gamma.demo", "# gamma"); + set_script_modules(&master.path().join("data/repo"), &["alpha.demo", "beta.demo", "gamma.demo"]); + repo.new_profile("One").expect("One should be created"); + repo.new_profile("Two").expect("Two should be created"); + repo.add_profile_matches("One", vec!["alpha.demo".to_string(), "beta.demo".to_string()], false).expect("One selectors should be added"); + repo.add_profile_matches("Two", vec!["beta.demo".to_string(), "gamma.demo".to_string()], false).expect("Two selectors should be added"); + + let (port, server) = start_fileserver(master.path().join("data")).await; + let minion = tempfile::tempdir().expect("minion tempdir should be created"); + let share = tempfile::tempdir().expect("share tempdir should be created"); + let cfg = configured_minion(minion.path(), share.path(), port); + fs::create_dir_all(cfg.traits_dir()).expect("traits dir should be created"); + ensure_master_traits_file(&cfg).expect("master traits file should exist"); + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"minion.profile":["One","Two","One"]}}"#) + .expect("set request should parse") + .apply(&cfg) + .expect("set request should apply"); + + SysInspectModPakMinion::new(cfg).sync().await.expect("sync should work"); + assert!(share.path().join("modules/alpha/demo").exists()); + assert!(share.path().join("modules/beta/demo").exists()); + assert!(share.path().join("modules/gamma/demo").exists()); + + server.abort(); +} diff --git a/libsysinspect/src/traits/traits_ut.rs b/libsysinspect/src/traits/traits_ut.rs index 7dad190b..7e74aa1e 100644 --- a/libsysinspect/src/traits/traits_ut.rs +++ b/libsysinspect/src/traits/traits_ut.rs @@ -1,5 +1,5 @@ use crate::cfg::mmconf::MinionConfig; -use crate::traits::{MASTER_TRAITS_FILE, TraitUpdateRequest, ensure_master_traits_file}; +use crate::traits::{MASTER_TRAITS_FILE, TraitUpdateRequest, effective_profiles, ensure_master_traits_file}; use crate::traits::systraits::SystemTraits; use std::fs; @@ -86,3 +86,17 @@ fn empty_master_traits_file_is_accepted_during_trait_load() { let traits = SystemTraits::new(cfg, true); assert!(!traits.has("foo"), "header-only master.cfg should not inject traits"); } + +#[test] +fn effective_profiles_fallback_to_default_and_dedup_array_values() { + let root = tempfile::tempdir().unwrap_or_else(|err| panic!("failed to create tempdir: {err}")); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().unwrap_or_default()); + + ensure_master_traits_file(&cfg).unwrap_or_else(|err| panic!("failed to ensure master traits file: {err}")); + assert_eq!(effective_profiles(&cfg), vec!["default".to_string()]); + + fs::write(cfg.traits_dir().join(MASTER_TRAITS_FILE), "minion.profile:\n - Foo\n - Bar\n - Foo\n") + .unwrap_or_else(|err| panic!("failed to write master traits file: {err}")); + assert_eq!(effective_profiles(&cfg), vec!["Foo".to_string(), "Bar".to_string()]); +} From 08f8e4e5a0a39818836361f16e622e68f575e37b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 14 Mar 2026 00:30:18 +0100 Subject: [PATCH 18/54] Add tutorial on profiles --- docs/index.rst | 1 + docs/tutorial/profiles_tutor.rst | 276 +++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 docs/tutorial/profiles_tutor.rst diff --git a/docs/index.rst b/docs/index.rst index 1b247384..40c505f6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -47,6 +47,7 @@ welcome—see the section on contributing for how to get involved. tutorial/checkbook_tutor tutorial/action_chain_tutor tutorial/module_management + tutorial/profiles_tutor tutorial/wasm_modules_tutor tutorial/lua_modules_tutor tutorial/menotify_tutor diff --git a/docs/tutorial/profiles_tutor.rst b/docs/tutorial/profiles_tutor.rst new file mode 100644 index 00000000..6526961f --- /dev/null +++ b/docs/tutorial/profiles_tutor.rst @@ -0,0 +1,276 @@ +.. _profiles_tutorial: + +Deployment Profiles Tutorial +============================ + +.. note:: + + This tutorial walks through the full deployment profile flow on the + Master side: create profiles, add selectors, assign profiles to + minions, sync, and verify the result. + +Overview +-------- + +Deployment profiles define which modules and libraries a minion is allowed +to sync. + +Profiles are: + +* defined on the Master +* stored one profile per file +* assigned to minions through the ``minion.profile`` static trait +* enforced during minion sync + +The effective result is: + +1. a minion resolves its assigned profile names +2. it downloads the corresponding profile files +3. it merges selectors by union + dedup +4. it syncs only the allowed modules and libraries + +What We Will Build +------------------ + +In this tutorial we will: + +1. create a narrow profile named ``tiny-lua`` +2. allow only the Lua runtime and Lua-side libraries +3. assign that profile to one or more minions +4. sync the cluster +5. verify that only the allowed artefacts are present + +Prerequisites +------------- + +This tutorial assumes: + +* the Master is running +* one or more minions are already registered +* the runtime modules and libraries are already published in the module repository + +If you need the repository basics first, see :ref:`mod_mgmt_tutorial`. + +Creating a Profile +------------------ + +Create a new profile on the Master: + +.. code-block:: bash + + sysinspect profile --new --name tiny-lua + +This creates a profile entry in ``profiles.index`` and a profile file under +the Master's profiles directory. + +List available profiles: + +.. code-block:: bash + + sysinspect profile --list + +Expected output should include: + +.. code-block:: text + + tiny-lua + +Adding Module Selectors +----------------------- + +Now add the module selectors allowed by this profile: + +.. code-block:: bash + + sysinspect profile -A --name tiny-lua --match "runtime.lua" + +List the module selectors: + +.. code-block:: bash + + sysinspect profile --list --name tiny-lua + +Expected output: + +.. code-block:: text + + tiny-lua: runtime.lua + +Adding Library Selectors +------------------------ + +Add the library selectors allowed by the same profile: + +.. code-block:: bash + + sysinspect profile -A --lib --name tiny-lua --match "lib/runtime/lua/*.lua,lib/sensors/lua/*.lua" + +List the library selectors: + +.. code-block:: bash + + sysinspect profile --list --name tiny-lua --lib + +Expected output: + +.. code-block:: text + + tiny-lua: lib/runtime/lua/*.lua + tiny-lua: lib/sensors/lua/*.lua + +Editing a Profile +----------------- + +If you added a selector by mistake, remove it with ``-R``: + +.. code-block:: bash + + sysinspect profile -R --name tiny-lua --match "lib/sensors/lua/*.lua" --lib + +You can then add the correct selector again: + +.. code-block:: bash + + sysinspect profile -A --lib --name tiny-lua --match "lib/sensors/lua/*.lua" + +Profile files have a canonical ``name`` inside the file. The filename is +only storage. + +For example, a profile file looks like this: + +.. code-block:: yaml + + name: tiny-lua + modules: + - runtime.lua + libraries: + - lib/runtime/lua/*.lua + - lib/sensors/lua/*.lua + +Assigning a Profile to Minions +------------------------------ + +Assign the profile to minions by query: + +.. code-block:: bash + + sysinspect profile --tag "tiny-lua" --query "pi*" + +Assign the profile to one exact minion by System Id: + +.. code-block:: bash + + sysinspect profile --tag "tiny-lua" --id 30006546535e428aba0a0caa6712e225 + +You can also combine profile assignment with trait-based minion targeting: + +.. code-block:: bash + + sysinspect profile --tag "tiny-lua" --traits "system.os.name:NetBSD" + +This updates the master-managed ``minion.profile`` static trait on the +targeted minions. + +Removing a Profile Assignment +----------------------------- + +To remove one assigned profile from targeted minions: + +.. code-block:: bash + + sysinspect profile --untag "tiny-lua" --query "pi*" + +If all assigned profiles are removed, the minion falls back to the +``default`` profile during sync. + +Synchronising the Cluster +------------------------- + +After creating or changing profiles, refresh the cluster: + +.. code-block:: bash + + sysinspect --sync + +During sync: + +1. minions download ``mod.index`` +2. minions download ``profiles.index`` +3. minions resolve ``minion.profile`` +4. minions download the selected profile files +5. minions merge all selectors by union + dedup +6. minions sync only the allowed artefacts + +On minion startup, Sysinspect also logs the effective profile names being +activated. + +Verifying the Result +-------------------- + +There are a few practical ways to verify the setup. + +Check the assigned profile on the Master: + +.. code-block:: bash + + sysinspect --ui + +The TUI can be used to inspect online minions and their traits, including +``minion.profile``. + +Check the profile definition on the Master: + +.. code-block:: bash + + sysinspect profile --list --name tiny-lua + sysinspect profile --list --name tiny-lua --lib + +Check the minion logs: + +.. code-block:: text + + INFO: Activating profile tiny-lua + +or, for multiple profiles: + +.. code-block:: text + + INFO: Activating profiles tiny-lua, runtime-full + +Using the Shipped Examples +-------------------------- + +Sysinspect ships example profile files under: + +* ``examples/profiles/tiny-lua.profile`` +* ``examples/profiles/runtime-full.profile`` + +They are examples of the exact profile file format accepted by the Master. + +Deleting a Profile +------------------ + +When a profile is no longer needed: + +.. code-block:: bash + + sysinspect profile --delete --name tiny-lua + +This removes both: + +* the ``profiles.index`` entry +* the stored profile file on the Master + +Summary +------- + +The profile workflow is: + +1. create a profile +2. add module selectors +3. add library selectors +4. assign the profile to minions +5. sync the cluster +6. verify the result + +That is the complete deployment profile loop in Sysinspect. From 39df6879d2cd49dc7f57962865d0643aa55f44f5 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 14 Mar 2026 00:30:28 +0100 Subject: [PATCH 19/54] Add missing docstrings --- libmodpak/src/lib.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index 6c7a18ae..7c8af720 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -401,15 +401,18 @@ pub struct SysInspectModPak { } impl SysInspectModPak { + /// Load the on-disk profiles index published next to the module repository. fn get_profiles_index(&self) -> Result { ModPakProfilesIndex::from_yaml(&fs::read_to_string(self.root.parent().unwrap_or(&self.root).join(REPO_PROFILES_INDEX))?) } + /// Persist the profiles index next to the module repository. fn set_profiles_index(&self, index: &ModPakProfilesIndex) -> Result<(), SysinspectError> { fs::write(self.root.parent().unwrap_or(&self.root).join(REPO_PROFILES_INDEX), index.to_yaml()?)?; Ok(()) } + /// Load one profile by canonical name and verify the file content name matches the index entry. fn get_profile(&self, name: &str) -> Result { let index = self.get_profiles_index()?; let entry = index @@ -429,6 +432,7 @@ impl SysInspectModPak { } } + /// Persist one profile file and refresh its `profiles.index` checksum entry. fn set_profile(&self, name: &str, profile: &ModPakProfile) -> Result<(), SysinspectError> { let mut index = self.get_profiles_index()?; let file = PathBuf::from(format!("{name}.profile")); @@ -441,6 +445,7 @@ impl SysInspectModPak { self.set_profiles_index(&index) } + /// Remove one profile file and its `profiles.index` entry. fn remove_profile_entry(&self, name: &str) -> Result<(), SysinspectError> { let mut index = self.get_profiles_index()?; if let Some(entry) = index.profiles().get(name) @@ -759,6 +764,7 @@ impl SysInspectModPak { Ok(()) } + /// List profile names filtered by an optional glob expression. pub fn list_profiles(&self, expr: Option<&str>) -> Result, SysinspectError> { let expr = glob::Pattern::new(expr.unwrap_or("*")).map_err(|e| SysinspectError::MasterGeneralError(format!("Invalid pattern: {e}")))?; let mut profiles = self.get_profiles_index()?.profiles().keys().filter(|name| expr.matches(name)).map(|name| name.to_string()).collect::>(); @@ -766,6 +772,7 @@ impl SysInspectModPak { Ok(profiles) } + /// Create a new empty profile with the given canonical name. pub fn new_profile(&self, name: &str) -> Result<(), SysinspectError> { if self.get_profiles_index()?.get(name).is_some() { return Err(SysinspectError::MasterGeneralError(format!("Profile {} already exists", name.bright_yellow()))); @@ -773,6 +780,7 @@ impl SysInspectModPak { self.set_profile(name, &ModPakProfile::new(name)) } + /// Delete one profile by canonical name. pub fn delete_profile(&self, name: &str) -> Result<(), SysinspectError> { if self.get_profiles_index()?.get(name).is_none() { return Err(SysinspectError::MasterGeneralError(format!("Profile {} was not found", name.bright_yellow()))); @@ -780,6 +788,7 @@ impl SysInspectModPak { self.remove_profile_entry(name) } + /// Add module or library selectors to the named profile. pub fn add_profile_matches(&self, name: &str, matches: Vec, library: bool) -> Result<(), SysinspectError> { let mut profile = self.get_profile(name)?; if library { @@ -790,6 +799,7 @@ impl SysInspectModPak { self.set_profile(name, &profile) } + /// Remove module or library selectors from the named profile. pub fn remove_profile_matches(&self, name: &str, matches: Vec, library: bool) -> Result<(), SysinspectError> { let mut profile = self.get_profile(name)?; if library { @@ -800,6 +810,7 @@ impl SysInspectModPak { self.set_profile(name, &profile) } + /// List module or library selectors for profiles matching the optional glob expression. pub fn list_profile_matches(&self, expr: Option<&str>, library: bool) -> Result, SysinspectError> { let mut out = Vec::new(); for profile in self.list_profiles(expr)? { From 1d16c9f348dabd4c3b42a73ab1d7d36a5a4ea95f Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 14 Mar 2026 13:48:38 +0100 Subject: [PATCH 20/54] Do not buffer an unbounded console line --- sysmaster/src/master.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index e4d20009..f620099d 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -56,6 +56,7 @@ use tokio::{ // Session singleton pub static SHARED_SESSION: Lazy>> = Lazy::new(|| Arc::new(Mutex::new(SessionKeeper::new(30)))); static MODEL_CACHE: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(HashMap::new()))); +static MAX_CONSOLE_FRAME_SIZE: usize = 64 * 1024; #[derive(Debug)] pub struct SysMaster { @@ -945,10 +946,18 @@ impl SysMaster { tokio::spawn(async move { let (read_half, mut write_half) = stream.into_split(); let mut reader = TokioBufReader::new(read_half); - let mut line = String::new(); - let reply = match reader.read_line(&mut line).await { + let mut frame = Vec::new(); + let reply = match reader.take((MAX_CONSOLE_FRAME_SIZE + 1) as u64).read_until(b'\n', &mut frame).await { Ok(0) => serde_json::to_string(&ConsoleResponse { ok: false, message: "Empty console request".to_string() }).ok(), - Ok(_) => match serde_json::from_str::(line.trim()) { + Ok(_) if frame.len() > MAX_CONSOLE_FRAME_SIZE || !frame.ends_with(b"\n") => { + serde_json::to_string(&ConsoleResponse { + ok: false, + message: format!("Console request exceeds {} bytes", MAX_CONSOLE_FRAME_SIZE), + }) + .ok() + } + Ok(_) => match String::from_utf8(frame).map(|line| line.trim().to_string()) { + Ok(line) => match serde_json::from_str::(&line) { Ok(envelope) => match load_master_private_key(&cfg).and_then(|prk| envelope.bootstrap.session_key(&prk)) { Ok((key, _client_pkey)) => { let response = if !authorised_console_client(&cfg, &envelope.bootstrap.client_pubkey).unwrap_or(false) { @@ -1006,6 +1015,10 @@ impl SysMaster { Err(err) => serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Console bootstrap failed: {err}") }).ok(), }, Err(err) => serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Failed to parse console request: {err}") }).ok(), + }, + Err(err) => { + serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Console request is not valid UTF-8: {err}") }).ok() + } }, Err(err) => serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Failed to read console request: {err}") }).ok(), }; From 44f6124252c848c63600eeddb71b54de25feb3fe Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 14 Mar 2026 13:50:05 +0100 Subject: [PATCH 21/54] Seal console failure responses --- sysmaster/src/master.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index f620099d..99a3ae65 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -1010,7 +1010,19 @@ impl SysMaster { Err(err) => ConsoleResponse { ok: false, message: format!("Failed to open console query: {err}") }, } }; - ConsoleSealed::seal(&response, &key).ok().and_then(|sealed| serde_json::to_string(&sealed).ok()) + match ConsoleSealed::seal(&response, &key).and_then(|sealed| { + serde_json::to_string(&sealed).map_err(|e| SysinspectError::SerializationError(e.to_string())) + }) { + Ok(reply) => Some(reply), + Err(err) => { + log::error!("Failed to seal console response: {err}"); + serde_json::to_string(&ConsoleResponse { + ok: false, + message: format!("Failed to seal console response: {err}"), + }) + .ok() + } + } } Err(err) => serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Console bootstrap failed: {err}") }).ok(), }, From ae6eb3f59b47d6df078e368919175fc84e50bb79 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 14 Mar 2026 13:51:00 +0100 Subject: [PATCH 22/54] Remove unwraps --- libmodpak/src/lib.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index 7c8af720..1b63c729 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -81,7 +81,10 @@ impl SysInspectModPakMinion { return Err(SysinspectError::MasterGeneralError(format!("Failed to get modpak index: {}", resp.status()))); } - let buff = resp.bytes().await.unwrap(); + let buff = resp + .bytes() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read modpak index response: {e}")))?; let idx = ModPakRepoIndex::from_yaml(&String::from_utf8_lossy(&buff))?; Ok(idx) } @@ -99,7 +102,11 @@ impl SysInspectModPakMinion { return Err(SysinspectError::MasterGeneralError(format!("Failed to get profiles index: {}", resp.status()))); } - ModPakProfilesIndex::from_yaml(&String::from_utf8_lossy(&resp.bytes().await.unwrap())) + let buff = resp + .bytes() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read profiles index response: {e}")))?; + ModPakProfilesIndex::from_yaml(&String::from_utf8_lossy(&buff)) } async fn sync_profiles(&self, profiles: &ModPakProfilesIndex, names: &[String]) -> Result<(), SysinspectError> { From 28c6668b313a32659003cb1577aa7ad63423fa73 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 14 Mar 2026 13:53:08 +0100 Subject: [PATCH 23/54] Corrently handle partial state --- libsysinspect/src/console/mod.rs | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index 32ab31ea..6d7e6224 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -96,15 +96,28 @@ fn console_keypair(root: &Path) -> (PathBuf, PathBuf) { /// Ensure a console RSA keypair exists under the given root and return it. pub fn ensure_console_keypair(root: &Path) -> Result<(RsaPrivateKey, RsaPublicKey), SysinspectError> { let (prk_path, pbk_path) = console_keypair(root); - if prk_path.exists() && pbk_path.exists() { - return Ok((load_private_key(&prk_path)?, load_public_key(&pbk_path)?)); + match (prk_path.exists(), pbk_path.exists()) { + (true, true) => Ok((load_private_key(&prk_path)?, load_public_key(&pbk_path)?)), + (true, false) => { + fs::create_dir_all(root).map_err(SysinspectError::IoErr)?; + let prk = load_private_key(&prk_path)?; + let pbk = RsaPublicKey::from(&prk); + key_to_file(&Public(pbk.clone()), root.to_str().unwrap_or_default(), CFG_CONSOLE_KEY_PUB)?; + Ok((prk, pbk)) + } + (false, true) => Err(SysinspectError::ConfigError(format!( + "Console public key exists at {} but private key is missing at {}. Remove the stale public key so a new console keypair can be generated.", + pbk_path.display(), + prk_path.display() + ))), + (false, false) => { + fs::create_dir_all(root).map_err(SysinspectError::IoErr)?; + let (prk, pbk) = keygen(crate::rsa::keys::DEFAULT_KEY_SIZE).map_err(|e| SysinspectError::RSAError(e.to_string()))?; + key_to_file(&Private(prk.clone()), root.to_str().unwrap_or_default(), CFG_CONSOLE_KEY_PRI)?; + key_to_file(&Public(pbk.clone()), root.to_str().unwrap_or_default(), CFG_CONSOLE_KEY_PUB)?; + Ok((prk, pbk)) + } } - - fs::create_dir_all(root).map_err(SysinspectError::IoErr)?; - let (prk, pbk) = keygen(crate::rsa::keys::DEFAULT_KEY_SIZE).map_err(|e| SysinspectError::RSAError(e.to_string()))?; - key_to_file(&Private(prk.clone()), root.to_str().unwrap_or_default(), CFG_CONSOLE_KEY_PRI)?; - key_to_file(&Public(pbk.clone()), root.to_str().unwrap_or_default(), CFG_CONSOLE_KEY_PUB)?; - Ok((prk, pbk)) } /// Load the master's public RSA key used for console session bootstrap. From 0fc33ef1db262e9f361663c3cfd4e18bd3cbcf8b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 14 Mar 2026 13:53:16 +0100 Subject: [PATCH 24/54] Add UT for partial state --- libsysinspect/src/console/console_ut.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/libsysinspect/src/console/console_ut.rs b/libsysinspect/src/console/console_ut.rs index 8b9cc16d..3aa4b40c 100644 --- a/libsysinspect/src/console/console_ut.rs +++ b/libsysinspect/src/console/console_ut.rs @@ -3,6 +3,7 @@ use crate::{ cfg::mmconf::{CFG_MASTER_KEY_PRI, CFG_MASTER_KEY_PUB}, rsa::keys::{RsaKey::{Private, Public}, key_to_file, keygen}, }; +use rsa::traits::PublicKeyParts; use sodiumoxide::crypto::secretbox; use tempfile::tempdir; @@ -38,3 +39,16 @@ fn console_sealed_roundtrips_payload() { assert_eq!(opened.query, payload.query); assert_eq!(opened.context, payload.context); } + +#[test] +fn ensure_console_keypair_recovers_missing_public_key_from_private_key() { + let root = tempdir().unwrap(); + let (client_prk, _) = keygen(crate::rsa::keys::DEFAULT_KEY_SIZE).unwrap(); + key_to_file(&Private(client_prk.clone()), root.path().to_str().unwrap_or_default(), crate::cfg::mmconf::CFG_CONSOLE_KEY_PRI).unwrap(); + + let (loaded_prk, loaded_pbk) = ensure_console_keypair(root.path()).unwrap(); + + assert_eq!(loaded_prk.n().to_bytes_be(), client_prk.n().to_bytes_be()); + assert!(root.path().join(crate::cfg::mmconf::CFG_CONSOLE_KEY_PUB).exists()); + assert_eq!(loaded_pbk.n().to_bytes_be(), client_prk.n().to_bytes_be()); +} From eb9088aa60f602305cad3d3435e9c83f0fe30237 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 14 Mar 2026 13:53:56 +0100 Subject: [PATCH 25/54] Return a proper InvalidQuery error on invalid traits selector --- sysmaster/src/master.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index 99a3ae65..5fac5893 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -796,10 +796,14 @@ impl SysMaster { let mut records = if !mid.is_empty() { self.mreg.lock().await.get(mid)?.into_iter().collect::>() } else if !traits.trim().is_empty() { + let traits = get_context(traits) + .ok_or_else(|| SysinspectError::InvalidQuery("Traits selector must be in key:value format".to_string()))? + .into_iter() + .collect::>(); self.mreg .lock() .await - .get_by_traits(get_context(traits).unwrap_or_default().into_iter().collect::>())? + .get_by_traits(traits)? } else { self.mreg.lock().await.get_by_query(if query.trim().is_empty() { "*" } else { query })? }; From 13b319fe2080ccb5c600e7f95872e29499f3a4dc Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 14 Mar 2026 13:54:34 +0100 Subject: [PATCH 26/54] Lintfix --- sysmaster/src/master.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index 5fac5893..488793d5 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -949,7 +949,7 @@ impl SysMaster { let bcast = bcast.clone(); tokio::spawn(async move { let (read_half, mut write_half) = stream.into_split(); - let mut reader = TokioBufReader::new(read_half); + let reader = TokioBufReader::new(read_half); let mut frame = Vec::new(); let reply = match reader.take((MAX_CONSOLE_FRAME_SIZE + 1) as u64).read_until(b'\n', &mut frame).await { Ok(0) => serde_json::to_string(&ConsoleResponse { ok: false, message: "Empty console request".to_string() }).ok(), From 0761462e1cbe70aba74b98e8549bbcfe67f0c43a Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 20:56:58 +0100 Subject: [PATCH 27/54] Adjust doc --- docs/global_config.rst | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/global_config.rst b/docs/global_config.rst index eb0ba203..4bd38684 100644 --- a/docs/global_config.rst +++ b/docs/global_config.rst @@ -134,9 +134,12 @@ and contains the following directives: Type: **string** - IPv4 address for the master's console endpoint used by ``sysinspect``. - This is the active command transport between ``sysinspect`` and - ``sysmaster``. + IPv4 address on which ``sysmaster`` listens for console connections from + ``sysinspect``. This is the active command transport between + ``sysinspect`` and ``sysmaster``. + + When this value is ``0.0.0.0``, the local ``sysinspect`` client still + connects through ``127.0.0.1``. If omitted, the default value is ``127.0.0.1``. From f73db5a424e9ac60f07cd0433c77b64e02c238c7 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 20:57:34 +0100 Subject: [PATCH 28/54] Refactoring & lintfixes --- libmodpak/src/lib.rs | 54 ++++++++++++++++----------- libsysinspect/src/cfg/mmconf.rs | 16 +++++++- libsysinspect/src/traits/systraits.rs | 8 ++-- src/main.rs | 2 +- sysmaster/src/master.rs | 12 +++++- 5 files changed, 62 insertions(+), 30 deletions(-) diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index 1b63c729..e8055046 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -115,28 +115,29 @@ impl SysInspectModPakMinion { } for name in names { - if let Some(profile) = profiles.get(name) { - let dst = self.cfg.profiles_dir().join(profile.file()); - let checksum = if dst.exists() { get_file_sha256(dst.clone()).ok() } else { None }; - if checksum.as_deref() == Some(profile.checksum()) { - continue; - } + let profile = profiles + .get(name) + .ok_or_else(|| SysinspectError::MasterGeneralError(format!("Profile {} is missing from profiles.index", name.bright_yellow())))?; + let dst = self.cfg.profiles_dir().join(profile.file()); + let checksum = if dst.exists() { get_file_sha256(dst.clone()).ok() } else { None }; + if checksum.as_deref() == Some(profile.checksum()) { + continue; + } - let resp = reqwest::Client::new() - .get(format!("http://{}/{}/{}", self.cfg.fileserver(), CFG_PROFILES_ROOT, profile.file().display())) - .send() - .await - .map_err(|e| SysinspectError::MasterGeneralError(format!("Request failed: {e}")))?; - if resp.status() != reqwest::StatusCode::OK { - return Err(SysinspectError::MasterGeneralError(format!("Failed to get profile {}: {}", name, resp.status()))); - } - if let Some(parent) = dst.parent() - && !parent.exists() - { - fs::create_dir_all(parent)?; - } - fs::write(&dst, resp.bytes().await.map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read response: {e}")))? )?; + let resp = reqwest::Client::new() + .get(format!("http://{}/{}/{}", self.cfg.fileserver(), CFG_PROFILES_ROOT, profile.file().display())) + .send() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Request failed: {e}")))?; + if resp.status() != reqwest::StatusCode::OK { + return Err(SysinspectError::MasterGeneralError(format!("Failed to get profile {}: {}", name, resp.status()))); } + if let Some(parent) = dst.parent() + && !parent.exists() + { + fs::create_dir_all(parent)?; + } + fs::write(&dst, resp.bytes().await.map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read response: {e}")))? )?; } Ok(()) @@ -147,10 +148,19 @@ impl SysInspectModPakMinion { return Ok(ridx); } + let found = names.iter().filter(|name| profiles.get(name).is_some()).cloned().collect::>(); + if found.is_empty() { + return Err(SysinspectError::MasterGeneralError(format!( + "None of the requested profile{} exist in profiles.index: {}", + if names.len() == 1 { "" } else { "s" }, + names.join(", ").bright_yellow() + ))); + } + let mut modules = IndexSet::new(); let mut libraries = IndexSet::new(); - for name in names { - if let Some(profile) = profiles.get(name) { + for name in found { + if let Some(profile) = profiles.get(&name) { ModPakProfile::from_yaml(&fs::read_to_string(self.cfg.profiles_dir().join(profile.file()))?)?.merge_into(&mut modules, &mut libraries); } } diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index 7441da84..4ba39c30 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -936,8 +936,8 @@ impl MasterConfig { self.socket.to_owned().unwrap_or(DEFAULT_SOCKET.to_string()) } - /// Return local console/control bind address - pub fn console_bind_addr(&self) -> String { + /// Return console listener address for `sysmaster`. + pub fn console_listen_addr(&self) -> String { format!( "{}:{}", self.console_ip.to_owned().unwrap_or("127.0.0.1".to_string()), @@ -945,6 +945,18 @@ impl MasterConfig { ) } + /// Return console connect address for `sysinspect`. + /// + /// If the console listener is configured as `0.0.0.0`, clients still + /// connect through loopback. + pub fn console_connect_addr(&self) -> String { + format!( + "{}:{}", + if self.console_ip.as_deref() == Some("0.0.0.0") { "127.0.0.1".to_string() } else { self.console_ip.to_owned().unwrap_or("127.0.0.1".to_string()) }, + self.console_port.unwrap_or(DEFAULT_CONSOLE_PORT) + ) + } + /// Get API enabled status (default: true) pub fn api_enabled(&self) -> bool { diff --git a/libsysinspect/src/traits/systraits.rs b/libsysinspect/src/traits/systraits.rs index 0bdf14f8..ea2ccc8c 100644 --- a/libsysinspect/src/traits/systraits.rs +++ b/libsysinspect/src/traits/systraits.rs @@ -220,12 +220,14 @@ impl SystemTraits { let content: Option = content.as_ref().and_then(|v| Self::proxy_log_error(serde_json::to_value(v), "Unable to convert existing YAML to JSON format")); - if content.is_none() { - log::error!("Unable to load custom traits from {}", f.file_name().to_str().unwrap_or_default()); + if fname == MASTER_TRAITS_FILE + && content.as_ref().is_none_or(serde_json::Value::is_null) + { continue; } - if fname == MASTER_TRAITS_FILE && content.as_ref().is_some_and(serde_json::Value::is_null) { + if content.is_none() { + log::error!("Unable to load custom traits from {}", f.file_name().to_str().unwrap_or_default()); continue; } diff --git a/src/main.rs b/src/main.rs index 7a6ba6f0..c1e4846a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,7 +60,7 @@ async fn call_master_console( context: context.cloned().unwrap_or_default(), }; let (envelope, key) = build_console_query(&cfg.root_dir(), cfg, &request)?; - let mut stream = TcpStream::connect(cfg.console_bind_addr()).await?; + let mut stream = TcpStream::connect(cfg.console_connect_addr()).await?; stream.write_all(format!("{}\n", serde_json::to_string(&envelope)?).as_bytes()).await?; let mut reader = BufReader::new(stream); diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index 488793d5..d65d0cbf 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -934,7 +934,14 @@ impl SysMaster { let guard = master.lock().await; (guard.cfg(), guard.broadcast().clone()) }; - let listener = match TcpListener::bind(cfg.console_bind_addr()).await { + let master_prk = match load_master_private_key(&cfg) { + Ok(prk) => prk, + Err(err) => { + log::error!("Failed to load console private key: {err}"); + return; + } + }; + let listener = match TcpListener::bind(cfg.console_listen_addr()).await { Ok(listener) => listener, Err(err) => { log::error!("Failed to bind console listener: {err}"); @@ -947,6 +954,7 @@ impl SysMaster { let master = Arc::clone(&master); let cfg = cfg.clone(); let bcast = bcast.clone(); + let master_prk = master_prk.clone(); tokio::spawn(async move { let (read_half, mut write_half) = stream.into_split(); let reader = TokioBufReader::new(read_half); @@ -962,7 +970,7 @@ impl SysMaster { } Ok(_) => match String::from_utf8(frame).map(|line| line.trim().to_string()) { Ok(line) => match serde_json::from_str::(&line) { - Ok(envelope) => match load_master_private_key(&cfg).and_then(|prk| envelope.bootstrap.session_key(&prk)) { + Ok(envelope) => match envelope.bootstrap.session_key(&master_prk) { Ok((key, _client_pkey)) => { let response = if !authorised_console_client(&cfg, &envelope.bootstrap.client_pubkey).unwrap_or(false) { ConsoleResponse { ok: false, message: "Console client key is not authorised".to_string() } From 4c9822e1f7c29df52dbbf5e386fab150b8aa23ee Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 20:57:43 +0100 Subject: [PATCH 29/54] Add more tests --- libmodpak/tests/profile_sync.rs | 26 ++++++++++++++++++++++++++ libsysinspect/src/cfg/mmconf_ut.rs | 17 +++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/libmodpak/tests/profile_sync.rs b/libmodpak/tests/profile_sync.rs index 3f003a32..9ed66b44 100644 --- a/libmodpak/tests/profile_sync.rs +++ b/libmodpak/tests/profile_sync.rs @@ -158,3 +158,29 @@ async fn overlapping_multi_profile_sync_merges_by_union_and_dedup() { server.abort(); } + +#[tokio::test] +async fn sync_fails_if_effective_profiles_are_missing_from_profiles_index() { + let master = tempfile::tempdir().expect("master tempdir should be created"); + let repo = SysInspectModPak::new(master.path().join("data/repo")).expect("repo should be created"); + add_script_module(&master.path().join("data/repo"), "alpha.demo", "# alpha"); + set_script_modules(&master.path().join("data/repo"), &["alpha.demo"]); + repo.new_profile("Existing").expect("Existing should be created"); + repo.add_profile_matches("Existing", vec!["alpha.demo".to_string()], false).expect("Existing selector should be added"); + + let (port, server) = start_fileserver(master.path().join("data")).await; + let minion = tempfile::tempdir().expect("minion tempdir should be created"); + let share = tempfile::tempdir().expect("share tempdir should be created"); + let cfg = configured_minion(minion.path(), share.path(), port); + fs::create_dir_all(cfg.traits_dir()).expect("traits dir should be created"); + ensure_master_traits_file(&cfg).expect("master traits file should exist"); + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"minion.profile":["Missing"]}}"#) + .expect("set request should parse") + .apply(&cfg) + .expect("set request should apply"); + + let err = SysInspectModPakMinion::new(cfg).sync().await.expect_err("sync should fail when effective profiles are missing"); + assert!(err.to_string().contains("Missing")); + + server.abort(); +} diff --git a/libsysinspect/src/cfg/mmconf_ut.rs b/libsysinspect/src/cfg/mmconf_ut.rs index 47b1fe77..29219ff5 100644 --- a/libsysinspect/src/cfg/mmconf_ut.rs +++ b/libsysinspect/src/cfg/mmconf_ut.rs @@ -17,7 +17,8 @@ fn write_master_cfg(contents: &str) -> std::path::PathBuf { fn master_console_defaults_are_used_when_not_configured() { let cfg = MasterConfig::new(write_master_cfg("config:\n master:\n fileserver.models: []\n")).unwrap(); - assert_eq!(cfg.console_bind_addr(), format!("127.0.0.1:{DEFAULT_CONSOLE_PORT}")); + assert_eq!(cfg.console_listen_addr(), format!("127.0.0.1:{DEFAULT_CONSOLE_PORT}")); + assert_eq!(cfg.console_connect_addr(), format!("127.0.0.1:{DEFAULT_CONSOLE_PORT}")); } #[test] @@ -27,5 +28,17 @@ fn master_console_config_overrides_defaults() { )) .unwrap(); - assert_eq!(cfg.console_bind_addr(), "127.0.0.1:5511"); + assert_eq!(cfg.console_listen_addr(), "127.0.0.1:5511"); + assert_eq!(cfg.console_connect_addr(), "127.0.0.1:5511"); +} + +#[test] +fn master_console_connect_addr_rewrites_wildcard_bind_to_loopback() { + let cfg = MasterConfig::new(write_master_cfg( + "config:\n master:\n fileserver.models: []\n console.bind.ip: 0.0.0.0\n console.bind.port: 5511\n", + )) + .unwrap(); + + assert_eq!(cfg.console_listen_addr(), "0.0.0.0:5511"); + assert_eq!(cfg.console_connect_addr(), "127.0.0.1:5511"); } From 79245a7a6a3ab32a2db9a10d42369f2f6d812f2a Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 21:39:36 +0100 Subject: [PATCH 30/54] Update docs --- docs/genusage/cli.rst | 3 ++- docs/tutorial/profiles_tutor.rst | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/genusage/cli.rst b/docs/genusage/cli.rst index 170ad459..1f314635 100644 --- a/docs/genusage/cli.rst +++ b/docs/genusage/cli.rst @@ -117,6 +117,7 @@ Profile definitions: sysinspect profile --delete --name Toto sysinspect profile --list sysinspect profile --list --name 'T*' + sysinspect profile --show --name Toto Assign selectors to a profile: @@ -136,7 +137,7 @@ Assign or remove profiles on minions: Notes: -* ``--name`` is an exact profile name for ``--new``, ``--delete``, ``-A``, and ``-R`` +* ``--name`` is an exact profile name for ``--new``, ``--delete``, ``--show``, ``-A``, and ``-R`` * ``--name`` is a glob pattern for ``--list`` * ``--match`` accepts comma-separated exact names or glob patterns * ``-l`` / ``--lib`` switches selector operations and listing to library selectors diff --git a/docs/tutorial/profiles_tutor.rst b/docs/tutorial/profiles_tutor.rst index 6526961f..18dfbb90 100644 --- a/docs/tutorial/profiles_tutor.rst +++ b/docs/tutorial/profiles_tutor.rst @@ -96,6 +96,12 @@ Expected output: tiny-lua: runtime.lua +Show the fully expanded profile content as a mixed modules/libraries table: + +.. code-block:: bash + + sysinspect profile --show --name tiny-lua + Adding Library Selectors ------------------------ @@ -224,6 +230,7 @@ Check the profile definition on the Master: sysinspect profile --list --name tiny-lua sysinspect profile --list --name tiny-lua --lib + sysinspect profile --show --name tiny-lua Check the minion logs: From 7e321884628781f186c65875fd9acfeb4e0a1576 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 21:40:08 +0100 Subject: [PATCH 31/54] Add profile contents listing --- libmodpak/src/lib.rs | 172 ++++++++++++++++++++++---------- libmodpak/src/mpk.rs | 62 ++++++++++++ libsysinspect/src/traits/mod.rs | 50 +++++++--- src/clidef.rs | 15 +-- src/main.rs | 10 ++ sysmaster/src/master.rs | 14 ++- 6 files changed, 246 insertions(+), 77 deletions(-) diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index e8055046..848a3756 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -32,6 +32,16 @@ their dependencies, and their architecture. static REPO_MOD_INDEX: &str = "mod.index"; static REPO_PROFILES_INDEX: &str = "profiles.index"; static REPO_MOD_SHA256_EXT: &str = "checksum.sha256"; + +struct ArtefactRow { + kind: String, + name: String, + display_name: String, + os: String, + arch: String, + sha256: String, +} + pub struct ModPakSyncState { state: Arc>, } @@ -60,6 +70,20 @@ impl ModPakSyncState { pub static MODPAK_SYNC_STATE: Lazy = Lazy::new(ModPakSyncState::new); +fn os_label(os: &str) -> &str { + HashMap::from([ + ("sysv", "Linux"), + ("any", "Any"), + ("linux", "Linux"), + ("netbsd", "NetBSD"), + ("freebsd", "FreeBSD"), + ("openbsd", "OpenBSD"), + ]) + .get(os) + .copied() + .unwrap_or(os) +} + pub struct SysInspectModPakMinion { cfg: MinionConfig, } @@ -418,6 +442,45 @@ pub struct SysInspectModPak { } impl SysInspectModPak { + fn render_artefact_table(rows: Vec) -> String { + let type_width = rows.iter().map(|row| row.kind.chars().count()).max().unwrap_or(4).max("Type".chars().count()); + let name_width = rows.iter().map(|row| row.name.chars().count()).max().unwrap_or(4).max("Name".chars().count()); + let os_width = rows.iter().map(|row| row.os.chars().count()).max().unwrap_or(2).max("OS".chars().count()); + let arch_width = rows.iter().map(|row| row.arch.chars().count()).max().unwrap_or(4).max("Arch".chars().count()); + let sha_width = rows.iter().map(|row| row.sha256.chars().count()).max().unwrap_or(6).max("SHA256".chars().count()); + let mut out = vec![ + format!( + "{} {} {} {} {}", + pad_visible(&"Type".bright_yellow().to_string(), type_width), + pad_visible(&"Name".bright_yellow().to_string(), name_width), + pad_visible(&"OS".bright_yellow().to_string(), os_width), + pad_visible(&"Arch".bright_yellow().to_string(), arch_width), + pad_visible(&"SHA256".bright_yellow().to_string(), sha_width), + ), + format!( + "{} {} {} {} {}", + "─".repeat(type_width), + "─".repeat(name_width), + "─".repeat(os_width), + "─".repeat(arch_width), + "─".repeat(sha_width), + ), + ]; + + for row in rows { + out.push(format!( + "{} {} {} {} {}", + pad_visible(&row.kind.bright_green().to_string(), type_width), + pad_visible(&row.display_name, name_width), + pad_visible(&row.os.bright_green().to_string(), os_width), + pad_visible(&row.arch.bright_green().to_string(), arch_width), + pad_visible(&row.sha256.green().to_string(), sha_width), + )); + } + + out.join("\n") + } + /// Load the on-disk profiles index published next to the module repository. fn get_profiles_index(&self) -> Result { ModPakProfilesIndex::from_yaml(&fs::read_to_string(self.root.parent().unwrap_or(&self.root).join(REPO_PROFILES_INDEX))?) @@ -682,7 +745,7 @@ impl SysInspectModPak { /// Lists all libraries in the repository. pub fn list_libraries(&self, expr: Option<&str>) -> Result<(), SysinspectError> { let expr = glob::Pattern::new(expr.unwrap_or("*")).map_err(|e| SysinspectError::MasterGeneralError(format!("Invalid pattern: {e}")))?; - let mut rows = Vec::<(String, String, String, String, String)>::new(); + let mut rows = Vec::::new(); for (_, mpklf) in self.idx.library() { if !expr.matches(&mpklf.file().display().to_string()) { continue; @@ -709,16 +772,17 @@ impl SysInspectModPak { (mpklf.kind().to_string(), "noarch".to_string(), "any".to_string()) }; - rows.push(( - match kind.as_str() { + rows.push(ArtefactRow { + kind: match kind.as_str() { "wasm" | "binary" => "binary".to_string(), _ => "script".to_string(), }, - mpklf.file().display().to_string(), - p.to_title_case(), + name: mpklf.file().display().to_string(), + display_name: Self::format_library_name(&mpklf.file().display().to_string()), + os: p.to_title_case(), arch, - format!("{}...{}", &mpklf.checksum()[..4], &mpklf.checksum()[mpklf.checksum().len() - 4..]), - )); + sha256: format!("{}...{}", &mpklf.checksum()[..4], &mpklf.checksum()[mpklf.checksum().len() - 4..]), + }); } if rows.is_empty() { @@ -726,47 +790,62 @@ impl SysInspectModPak { return Ok(()); } - let type_width = rows.iter().map(|r| r.0.chars().count()).max().unwrap_or(4).max("Type".chars().count()); - let name_width = rows.iter().map(|r| r.1.chars().count()).max().unwrap_or(4).max("Name".chars().count()); - let os_width = rows.iter().map(|r| r.2.chars().count()).max().unwrap_or(2).max("OS".chars().count()); - let arch_width = rows.iter().map(|r| r.3.chars().count()).max().unwrap_or(4).max("Arch".chars().count()); - let sha_width = rows.iter().map(|r| r.4.chars().count()).max().unwrap_or(6).max("SHA256".chars().count()); - - println!( - "{} {} {} {} {}", - pad_visible(&"Type".bright_yellow().to_string(), type_width), - pad_visible(&"Name".bright_yellow().to_string(), name_width), - pad_visible(&"OS".bright_yellow().to_string(), os_width), - pad_visible(&"Arch".bright_yellow().to_string(), arch_width), - pad_visible(&"SHA256".bright_yellow().to_string(), sha_width), - ); - println!( - "{} {} {} {} {}", - "─".repeat(type_width), - "─".repeat(name_width), - "─".repeat(os_width), - "─".repeat(arch_width), - "─".repeat(sha_width), - ); - - for (kind, name, os_name, arch, sha) in rows { - println!( - "{} {} {} {} {}", - pad_visible(&kind.bright_green().to_string(), type_width), - pad_visible(&Self::format_library_name(&name), name_width), - pad_visible(&os_name.bright_green().to_string(), os_width), - pad_visible(&arch.bright_green().to_string(), arch_width), - pad_visible(&sha.green().to_string(), sha_width), - ); - } + println!("{}", Self::render_artefact_table(rows)); Ok(()) } + /// Render the expanded artefact content of one profile as a mixed modules/libraries table. + pub fn show_profile(&self, name: &str) -> Result { + let profile = self.get_profile(name)?; + let mut modules = self.idx.match_modules(profile.modules()); + modules.sort_by(|a, b| a.name().cmp(b.name())); + let mut libraries = self + .idx + .library() + .into_iter() + .filter(|(name, _)| profile.libraries().iter().any(|expr| glob::Pattern::new(expr).is_ok_and(|pattern| pattern.matches(name)))) + .collect::>(); + libraries.sort_by(|(a, _), (b, _)| a.cmp(b)); + + if modules.is_empty() && libraries.is_empty() { + return Err(SysinspectError::MasterGeneralError(format!("Profile {} does not match any modules or libraries", name.bright_yellow()))); + } + + let mut rows = modules + .into_iter() + .map(|module| ArtefactRow { + kind: "module".to_string(), + name: module.name().to_string(), + display_name: module.name().bright_cyan().bold().to_string(), + os: module.os().iter().map(|os| os_label(os).to_string()).collect::>().join(", "), + arch: module.arch().join(", "), + sha256: module + .checksums() + .iter() + .map(|checksum| format!("{}...{}", &checksum[..4], &checksum[checksum.len() - 4..])) + .collect::>() + .join(", "), + }) + .collect::>(); + rows.extend(libraries.into_iter().map(|(name, file)| ArtefactRow { + kind: match file.kind() { + "wasm" | "binary" => "binary".to_string(), + _ => "script".to_string(), + }, + name: name.clone(), + display_name: Self::format_library_name(&name), + os: "Any".to_string(), + arch: "noarch".to_string(), + sha256: format!("{}...{}", &file.checksum()[..4], &file.checksum()[file.checksum().len() - 4..]), + })); + Ok(Self::render_artefact_table(rows)) + } + pub fn module_info(&self, name: &str) -> Result<(), SysinspectError> { let mut found = false; for (p, archset) in self.idx.all_modules(None, Some(vec![name])) { - let p = if p.eq("sysv") { "Linux" } else { p.as_str() }; + let p = os_label(&p); for (arch, modules) in archset { println!("{} ({}): ", p, arch.bright_green()); Self::print_table(&modules, true); @@ -841,22 +920,13 @@ impl SysInspectModPak { /// Lists all modules in the repository. pub fn list_modules(&self) -> Result<(), SysinspectError> { - let osn = HashMap::from([ - ("sysv", "Linux"), - ("any", "Any"), - ("linux", "Linux"), - ("netbsd", "NetBSD"), - ("freebsd", "FreeBSD"), - ("openbsd", "OpenBSD"), - ]); - let allmods = self.idx.all_modules(None, None); let mut platforms = allmods.iter().map(|(p, _)| p.to_string()).collect::>(); platforms.sort(); for p in platforms { let archset = allmods.get(&p).unwrap(); // safe: iter above - let p = if osn.contains_key(p.as_str()) { osn.get(p.as_str()).unwrap() } else { p.as_str() }; + let p = os_label(&p); for (arch, modules) in archset { println!("{} ({}): ", p, arch.bright_green()); Self::print_table(modules, false); diff --git a/libmodpak/src/mpk.rs b/libmodpak/src/mpk.rs index 8b564703..968bf341 100644 --- a/libmodpak/src/mpk.rs +++ b/libmodpak/src/mpk.rs @@ -208,6 +208,55 @@ impl ModPakProfilesIndex { } } +#[derive(Debug, Clone, Default)] +/// Aggregated repository view of one module across all matching platforms and architectures. +pub struct ModPakRepoModuleView { + name: String, + os: Vec, + arch: Vec, + checksums: Vec, +} + +impl ModPakRepoModuleView { + /// Create an empty aggregated module view. + pub fn new(name: &str) -> Self { + Self { name: name.to_string(), ..Default::default() } + } + + /// Return the module namespace name. + pub fn name(&self) -> &str { + &self.name + } + + /// Return the supported operating systems. + pub fn os(&self) -> &[String] { + &self.os + } + + /// Return the supported architectures. + pub fn arch(&self) -> &[String] { + &self.arch + } + + /// Return the checksums for the matched module variants. + pub fn checksums(&self) -> &[String] { + &self.checksums + } + + /// Merge one platform, architecture, and checksum tuple into the aggregated view. + pub fn merge_variant(&mut self, os: &str, arch: &str, checksum: &str) { + if !self.os.contains(&os.to_string()) { + self.os.push(os.to_string()); + } + if !self.arch.contains(&arch.to_string()) { + self.arch.push(arch.to_string()); + } + if !self.checksums.contains(&checksum.to_string()) { + self.checksums.push(checksum.to_string()); + } + } +} + impl ModPakProfile { /// Deserialize one profile file from YAML. pub fn from_yaml(yaml: &str) -> Result { @@ -436,6 +485,19 @@ impl ModPakRepoIndex { index } + /// Return aggregated module views matched by the given selector patterns. + pub fn match_modules(&self, patterns: &[String]) -> Vec { + let mut views = IndexMap::::new(); + for (platform, archset) in &self.platform { + for (arch, entries) in archset { + for (name, attrs) in entries.iter().filter(|(name, _)| patterns.iter().any(|expr| glob::Pattern::new(expr).is_ok_and(|pattern| pattern.matches(name)))) { + views.entry(name.to_string()).or_insert_with(|| ModPakRepoModuleView::new(name)).merge_variant(platform, arch, attrs.checksum()); + } + } + } + views.into_values().collect() + } + /// Returns the modules in the index. Optionally filtered by architecture and names. pub(crate) fn all_modules(&self, arch: Option<&str>, names: Option>) -> IndexMap>> { if let Some(arch) = arch { diff --git a/libsysinspect/src/traits/mod.rs b/libsysinspect/src/traits/mod.rs index 23d695a4..604a0c29 100644 --- a/libsysinspect/src/traits/mod.rs +++ b/libsysinspect/src/traits/mod.rs @@ -139,17 +139,13 @@ pub fn get_minion_traits_nolog(cfg: Option<&MinionConfig>) -> SystemTraits { __get_minion_traits(cfg, true) } -/// Resolve the effective deployment profile names for a minion. -/// -/// This reads the merged trait view fresh and interprets `minion.profile` -/// as either a single string or an array of strings. Missing or empty values -/// fall back to `default`. Duplicate profile names are removed while keeping -/// the first-seen order. -pub fn effective_profiles(cfg: &MinionConfig) -> Vec { - match SystemTraits::new(cfg.clone(), true).get("minion.profile") { - Some(serde_json::Value::String(name)) if !name.trim().is_empty() => vec![name], - Some(serde_json::Value::Array(items)) => { - let mut names = IndexSet::new(); +fn normalized_profiles(value: &Value) -> Vec { + let mut names = IndexSet::new(); + match value { + Value::String(name) if !name.trim().is_empty() => { + names.insert(name.to_string()); + } + Value::Array(items) => { for item in items { if let Some(name) = item.as_str() && !name.trim().is_empty() @@ -157,10 +153,27 @@ pub fn effective_profiles(cfg: &MinionConfig) -> Vec { names.insert(name.to_string()); } } - if names.is_empty() { vec!["default".to_string()] } else { names.into_iter().collect() } } - _ => vec!["default".to_string()], + _ => {} } + if names.len() > 1 { + names.shift_remove("default"); + } + names.into_iter().collect() +} + +/// Resolve the effective deployment profile names for a minion. +/// +/// This reads the merged trait view fresh and interprets `minion.profile` +/// as either a single string or an array of strings. Missing or empty values +/// fall back to `default`. Duplicate profile names are removed while keeping +/// the first-seen order. +pub fn effective_profiles(cfg: &MinionConfig) -> Vec { + let names = match SystemTraits::new(cfg.clone(), true).get("minion.profile") { + Some(value) => normalized_profiles(&value), + _ => vec!["default".to_string()], + }; + if names.is_empty() { vec!["default".to_string()] } else { names } } /// Get or initialise system traits @@ -219,7 +232,16 @@ impl TraitUpdateRequest { match self.op.as_str() { "set" => { for (key, value) in &self.traits { - current.insert(key.to_string(), value.clone()); + if key == "minion.profile" { + let profiles = normalized_profiles(value); + if profiles.is_empty() { + current.shift_remove(key); + } else { + current.insert(key.to_string(), serde_json::to_value(profiles)?); + } + } else { + current.insert(key.to_string(), value.clone()); + } } } "unset" => { diff --git a/src/clidef.rs b/src/clidef.rs index a776c4c4..a7b05cb7 100644 --- a/src/clidef.rs +++ b/src/clidef.rs @@ -42,13 +42,14 @@ pub fn cli(version: &'static str) -> Command { .arg(Arg::new("help").short('h').long("help").action(ArgAction::SetTrue).help("Display help for this command")) ) .subcommand(Command::new("profile").about("Manage deployment profiles").styles(styles.clone()).disable_help_flag(true) - .arg(Arg::new("new").long("new").action(ArgAction::SetTrue).help("Create a deployment profile").conflicts_with_all(["delete", "list", "add", "remove", "tag", "untag"])) - .arg(Arg::new("delete").long("delete").action(ArgAction::SetTrue).help("Delete a deployment profile").conflicts_with_all(["new", "list", "add", "remove", "tag", "untag"])) - .arg(Arg::new("list").long("list").action(ArgAction::SetTrue).help("List deployment profiles or their assigned selectors").conflicts_with_all(["new", "delete", "add", "remove", "tag", "untag"])) - .arg(Arg::new("add").short('A').long("add").action(ArgAction::SetTrue).help("Add selectors to a deployment profile").conflicts_with_all(["new", "delete", "list", "remove", "tag", "untag"])) - .arg(Arg::new("remove").short('R').long("remove").action(ArgAction::SetTrue).help("Remove selectors from a deployment profile").conflicts_with_all(["new", "delete", "list", "add", "tag", "untag"])) - .arg(Arg::new("tag").long("tag").help("Assign one or more profiles to targeted minions").conflicts_with_all(["new", "delete", "list", "add", "remove", "untag"])) - .arg(Arg::new("untag").long("untag").help("Unassign one or more profiles from targeted minions").conflicts_with_all(["new", "delete", "list", "add", "remove", "tag"])) + .arg(Arg::new("new").long("new").action(ArgAction::SetTrue).help("Create a deployment profile").conflicts_with_all(["delete", "list", "show", "add", "remove", "tag", "untag"])) + .arg(Arg::new("delete").long("delete").action(ArgAction::SetTrue).help("Delete a deployment profile").conflicts_with_all(["new", "list", "show", "add", "remove", "tag", "untag"])) + .arg(Arg::new("list").long("list").action(ArgAction::SetTrue).help("List deployment profiles or their assigned selectors").conflicts_with_all(["new", "delete", "show", "add", "remove", "tag", "untag"])) + .arg(Arg::new("show").long("show").action(ArgAction::SetTrue).help("Show the expanded artefact content of one deployment profile").conflicts_with_all(["new", "delete", "list", "add", "remove", "tag", "untag"])) + .arg(Arg::new("add").short('A').long("add").action(ArgAction::SetTrue).help("Add selectors to a deployment profile").conflicts_with_all(["new", "delete", "list", "show", "remove", "tag", "untag"])) + .arg(Arg::new("remove").short('R').long("remove").action(ArgAction::SetTrue).help("Remove selectors from a deployment profile").conflicts_with_all(["new", "delete", "list", "show", "add", "tag", "untag"])) + .arg(Arg::new("tag").long("tag").help("Assign one or more profiles to targeted minions").conflicts_with_all(["new", "delete", "list", "show", "add", "remove", "untag"])) + .arg(Arg::new("untag").long("untag").help("Unassign one or more profiles from targeted minions").conflicts_with_all(["new", "delete", "list", "show", "add", "remove", "tag"])) .arg(Arg::new("name").short('n').long("name").help("Profile name or profile glob pattern")) .arg(Arg::new("match").short('m').long("match").help("Comma-separated module or library selectors")) .arg(Arg::new("lib").short('l').long("lib").action(ArgAction::SetTrue).help("Operate on library selectors instead of module selectors")) diff --git a/src/main.rs b/src/main.rs index c1e4846a..6dca0b32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,6 +126,16 @@ fn profile_update_context(am: &ArgMatches) -> Result, SysinspectE )); } + if am.get_flag("show") { + if am.get_one::("name").is_none() { + return Err(SysinspectError::InvalidQuery("Specify --name for --show".to_string())); + } + if invalid_name(am.get_one::("name").unwrap()) { + return Err(SysinspectError::InvalidQuery("Profile names for --show must be exact names, not glob patterns".to_string())); + } + return Ok(Some(json!({"op": "show", "name": am.get_one::("name").cloned().unwrap_or_default()}).to_string())); + } + if am.get_flag("add") || am.get_flag("remove") { if am.get_one::("name").is_none() || am.get_one::("match").is_none() { return Err(SysinspectError::InvalidQuery("Specify both --name and --match for profile selector updates".to_string())); diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index d65d0cbf..3374a339 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -843,6 +843,7 @@ impl SysMaster { }, vec![], )), + "show" => Ok((ConsoleResponse { ok: true, message: repo.show_profile(request.name())? }, vec![])), "add" => Ok(( { repo.add_profile_matches(request.name(), request.matches().to_vec(), request.library())?; @@ -875,8 +876,9 @@ impl SysMaster { let mut profiles = match minion.get_traits().get("minion.profile") { Some(serde_json::Value::String(name)) if !name.trim().is_empty() => vec![name.to_string()], Some(serde_json::Value::Array(names)) => names.iter().filter_map(|name| name.as_str().map(str::to_string)).collect::>(), - _ => vec!["default".to_string()], + _ => vec![], }; + profiles.retain(|profile| profile != "default"); if request.op() == "tag" { for profile in request.profiles() { if !profiles.contains(profile) { @@ -886,16 +888,18 @@ impl SysMaster { } else { profiles.retain(|profile| !request.profiles().contains(profile)); } - if profiles.is_empty() { - profiles.push("default".to_string()); - } if let Some(msg) = self .msg_query_data( &format!("{SCHEME_COMMAND}{CLUSTER_TRAITS_UPDATE}"), "", "", minion.id(), - &json!({"op": "set", "traits": {"minion.profile": profiles}}).to_string(), + &if profiles.is_empty() { + json!({"op": "unset", "traits": {"minion.profile": null}}) + } else { + json!({"op": "set", "traits": {"minion.profile": profiles}}) + } + .to_string(), ) .await { From cb515e4817ac353c7c27d983cffd44d724b33677 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 21:40:17 +0100 Subject: [PATCH 32/54] Add tests --- libmodpak/src/lib_ut.rs | 25 +++++++++++++++++++++++++ libsysinspect/src/traits/traits_ut.rs | 12 ++++++++++++ 2 files changed, 37 insertions(+) diff --git a/libmodpak/src/lib_ut.rs b/libmodpak/src/lib_ut.rs index 77107691..0958f598 100644 --- a/libmodpak/src/lib_ut.rs +++ b/libmodpak/src/lib_ut.rs @@ -270,6 +270,31 @@ mod tests { assert!(repo.new_profile("toto").is_err()); } + #[test] + fn show_profile_renders_modules_first_and_libraries_after() { + control::set_override(true); + + let root = tempfile::tempdir().expect("repo tempdir should be created"); + let src = tempfile::tempdir().expect("src tempdir should be created"); + let repo_root = root.path().join("repo"); + let mut repo = SysInspectModPak::new(repo_root.clone()).expect("repo should be created"); + write_library(src.path(), "runtime/lua/reader.lua"); + repo.add_library(src.path().to_path_buf()).expect("library tree should be indexed"); + write_module(&mut repo, "linux", "x86_64", "runtime.lua", "runtime/lua"); + write_module(&mut repo, "netbsd", "noarch", "runtime.lua", "runtime/lua"); + repo.new_profile("toto").expect("profile should be created"); + repo.add_profile_matches("toto", vec!["runtime.lua".to_string()], false).expect("module selector should be added"); + repo.add_profile_matches("toto", vec!["lib/runtime/lua/*.lua".to_string()], true).expect("library selector should be added"); + + let rendered = repo.show_profile("toto").expect("profile should render"); + let module_pos = rendered.find("runtime.lua").expect("module row should exist"); + let library_pos = rendered.find("reader.lua").expect("library row should exist"); + + assert!(rendered.contains("Linux, NetBSD") || rendered.contains("NetBSD, Linux")); + assert!(rendered.contains("x86_64, noarch") || rendered.contains("noarch, x86_64")); + assert!(module_pos < library_pos, "modules should be rendered before libraries"); + } + #[test] fn effective_profile_names_fallback_to_default_and_accept_array() { let root = tempfile::tempdir().expect("root tempdir should be created"); diff --git a/libsysinspect/src/traits/traits_ut.rs b/libsysinspect/src/traits/traits_ut.rs index 7e74aa1e..b2f8d110 100644 --- a/libsysinspect/src/traits/traits_ut.rs +++ b/libsysinspect/src/traits/traits_ut.rs @@ -100,3 +100,15 @@ fn effective_profiles_fallback_to_default_and_dedup_array_values() { .unwrap_or_else(|err| panic!("failed to write master traits file: {err}")); assert_eq!(effective_profiles(&cfg), vec!["Foo".to_string(), "Bar".to_string()]); } + +#[test] +fn effective_profiles_drops_default_when_real_profiles_are_present() { + let root = tempfile::tempdir().unwrap_or_else(|err| panic!("failed to create tempdir: {err}")); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().unwrap_or_default()); + + ensure_master_traits_file(&cfg).unwrap_or_else(|err| panic!("failed to ensure master traits file: {err}")); + fs::write(cfg.traits_dir().join(MASTER_TRAITS_FILE), "minion.profile:\n - default\n - Runtimes\n") + .unwrap_or_else(|err| panic!("failed to write master traits file: {err}")); + assert_eq!(effective_profiles(&cfg), vec!["Runtimes".to_string()]); +} From c7fb295f09fd9945776be9ba59dcfa12e860ace5 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 00:39:19 +0100 Subject: [PATCH 33/54] Make profile name always lowercase, if generated --- libmodpak/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index 848a3756..58988138 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -515,7 +515,7 @@ impl SysInspectModPak { /// Persist one profile file and refresh its `profiles.index` checksum entry. fn set_profile(&self, name: &str, profile: &ModPakProfile) -> Result<(), SysinspectError> { let mut index = self.get_profiles_index()?; - let file = PathBuf::from(format!("{name}.profile")); + let file = index.get(name).map(|entry| entry.file().to_path_buf()).unwrap_or_else(|| PathBuf::from(format!("{}.profile", name.to_lowercase()))); let path = self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).join(&file); if !self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).exists() { fs::create_dir_all(self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT))?; From eb6dc56f5fa2f7fae64c68bb0cec87df835d56c3 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 00:39:29 +0100 Subject: [PATCH 34/54] Add unit test for profile names --- libmodpak/src/lib_ut.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/libmodpak/src/lib_ut.rs b/libmodpak/src/lib_ut.rs index 0958f598..0cbb84eb 100644 --- a/libmodpak/src/lib_ut.rs +++ b/libmodpak/src/lib_ut.rs @@ -5,6 +5,7 @@ mod tests { use std::collections::HashSet; use colored::control; use std::{fs, path::Path}; + use libsysinspect::cfg::mmconf::CFG_PROFILES_ROOT; /// Creates a minimal library tree under `src/lib`. /// @@ -270,6 +271,45 @@ mod tests { assert!(repo.new_profile("toto").is_err()); } + #[test] + fn new_profiles_use_lowercase_filenames_without_changing_profile_name() { + let root = tempfile::tempdir().expect("repo tempdir should be created"); + let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); + + repo.new_profile("Toto").expect("profile should be created"); + + let idx = repo.get_profiles_index().expect("profiles index should load"); + let profile = repo.get_profile("Toto").expect("profile should load"); + assert_eq!(idx.get("Toto").expect("profile ref should exist").file(), &std::path::PathBuf::from("toto.profile")); + assert_eq!(profile.name(), "Toto"); + } + + #[test] + fn existing_profile_keeps_arbitrary_indexed_filename() { + let root = tempfile::tempdir().expect("repo tempdir should be created"); + let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); + let profiles_root = root.path().join(CFG_PROFILES_ROOT); + fs::write( + profiles_root.join("totobullshit.profile"), + "name: Toto\nmodules:\n - runtime.lua\n", + ) + .expect("profile file should be written"); + fs::write( + root.path().join("profiles.index"), + "profiles:\n Toto:\n file: totobullshit.profile\n checksum: deadbeef\n", + ) + .expect("profiles index should be written"); + + repo.add_profile_matches("Toto", vec!["net.*".to_string()], false).expect("profile should be updated"); + + let idx = repo.get_profiles_index().expect("profiles index should load"); + let profile = repo.get_profile("Toto").expect("profile should load"); + assert_eq!(idx.get("Toto").expect("profile ref should exist").file(), &std::path::PathBuf::from("totobullshit.profile")); + assert_eq!(profile.name(), "Toto"); + assert!(profile.modules().contains(&"runtime.lua".to_string())); + assert!(profile.modules().contains(&"net.*".to_string())); + } + #[test] fn show_profile_renders_modules_first_and_libraries_after() { control::set_override(true); From 4b1e4d960af7fad1458bcbf1503ad64a49726b89 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 00:53:05 +0100 Subject: [PATCH 35/54] Fix docs, add missing bits --- docs/genusage/cli.rst | 5 ++++- docs/genusage/systraits.rst | 3 +++ docs/tutorial/profiles_tutor.rst | 7 +++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/genusage/cli.rst b/docs/genusage/cli.rst index 1f314635..9004740c 100644 --- a/docs/genusage/cli.rst +++ b/docs/genusage/cli.rst @@ -144,6 +144,7 @@ Notes: * ``--tag`` and ``--untag`` update ``minion.profile`` on the targeted minions * profile names are case-sensitive Unix-like names * each profile file carries its own canonical ``name`` field; the filename is only storage +* new profile files are written with lowercase filenames, but existing indexed filenames remain valid even if they are mixed-case or arbitrary Profile Data Model ------------------ @@ -171,7 +172,9 @@ selectors: - lib/runtime/lua/*.lua The filename is only storage. The canonical profile identity is the -case-sensitive ``name`` field inside the file. +case-sensitive ``name`` field inside the file. Newly created profile files +are written with lowercase filenames, but already indexed filenames are +still honored as-is. Sync Behavior ------------- diff --git a/docs/genusage/systraits.rst b/docs/genusage/systraits.rst index a8261e2b..1cf5e0c0 100644 --- a/docs/genusage/systraits.rst +++ b/docs/genusage/systraits.rst @@ -121,6 +121,9 @@ minions. If ``minion.profile`` is not set, the minion falls back to the ``default`` profile during sync. +``default`` is fallback-only. Once one or more real profiles are assigned, +``default`` is not kept alongside them as a stored assignment. + Dynamic Traits -------------- diff --git a/docs/tutorial/profiles_tutor.rst b/docs/tutorial/profiles_tutor.rst index 18dfbb90..277c3519 100644 --- a/docs/tutorial/profiles_tutor.rst +++ b/docs/tutorial/profiles_tutor.rst @@ -142,6 +142,10 @@ You can then add the correct selector again: Profile files have a canonical ``name`` inside the file. The filename is only storage. +New profiles are written with lowercase filenames by default, but the index +can still point at any existing filename and that filename will continue to +work unchanged. + For example, a profile file looks like this: .. code-block:: yaml @@ -189,6 +193,9 @@ To remove one assigned profile from targeted minions: If all assigned profiles are removed, the minion falls back to the ``default`` profile during sync. +``default`` is fallback-only. It is not stored together with real assigned +profiles. + Synchronising the Cluster ------------------------- From 18cbbb4d2d2e4640474c7406bb441fb2110dfd74 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 00:53:17 +0100 Subject: [PATCH 36/54] Update examples readme --- examples/profiles/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/profiles/README.md b/examples/profiles/README.md index 3409828f..e3ce73ff 100644 --- a/examples/profiles/README.md +++ b/examples/profiles/README.md @@ -24,5 +24,7 @@ libraries: Notes: - profile identity comes from `name`, not the filename +- new profiles are normally written with lowercase filenames +- already indexed arbitrary or mixed-case filenames remain valid - selectors support exact names and glob patterns - effective minion selection is driven by the `minion.profile` trait From a5ab1dda61d6854dfa9fdd2afd0d8f4659b197e0 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 00:53:42 +0100 Subject: [PATCH 37/54] Split Android from Linux --- libmodpak/src/lib.rs | 26 ++++++-------------------- libsysinspect/src/cfg/mmconf.rs | 2 +- libsysinspect/src/traits/mod.rs | 18 ++++++++++++++++++ libsysinspect/src/traits/systraits.rs | 7 ++----- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index 58988138..628c516c 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -6,14 +6,14 @@ use indexmap::{IndexMap, IndexSet}; use libcommon::SysinspectError; use libsysinspect::cfg::mmconf::DEFAULT_MODULES_DIR; use libsysinspect::cfg::mmconf::{CFG_AUTOSYNC_FAST, CFG_AUTOSYNC_SHALLOW, CFG_PROFILES_ROOT, DEFAULT_MODULES_LIB_DIR, MinionConfig}; -use libsysinspect::traits::effective_profiles; +use libsysinspect::traits::{current_os_type, effective_profiles, os_display_name}; use libsysinspect::util::{iofs::get_file_sha256, pad_visible}; use mpk::{ModAttrs, ModPakMetadata, ModPakProfile, ModPakProfilesIndex, ModPakRepoIndex}; use once_cell::sync::Lazy; use prettytable::{Cell, Row, Table, format}; use std::os::unix::fs::PermissionsExt; use std::sync::Arc; -use std::{collections::HashMap, fs, path::PathBuf}; +use std::{fs, path::PathBuf}; use textwrap::{Options, wrap}; use tokio::sync::Mutex; @@ -70,20 +70,6 @@ impl ModPakSyncState { pub static MODPAK_SYNC_STATE: Lazy = Lazy::new(ModPakSyncState::new); -fn os_label(os: &str) -> &str { - HashMap::from([ - ("sysv", "Linux"), - ("any", "Any"), - ("linux", "Linux"), - ("netbsd", "NetBSD"), - ("freebsd", "FreeBSD"), - ("openbsd", "OpenBSD"), - ]) - .get(os) - .copied() - .unwrap_or(os) -} - pub struct SysInspectModPakMinion { cfg: MinionConfig, } @@ -364,7 +350,7 @@ impl SysInspectModPakMinion { /// Syncs modules from the fileserver. async fn sync_modules(&self, ridx: &ModPakRepoIndex) -> Result<(), SysinspectError> { - let ostype = env!("THIS_OS"); + let ostype = current_os_type(); let osarch = env!("THIS_ARCH"); let modt = ridx.modules().len(); @@ -818,7 +804,7 @@ impl SysInspectModPak { kind: "module".to_string(), name: module.name().to_string(), display_name: module.name().bright_cyan().bold().to_string(), - os: module.os().iter().map(|os| os_label(os).to_string()).collect::>().join(", "), + os: module.os().iter().map(|os| os_display_name(os).to_string()).collect::>().join(", "), arch: module.arch().join(", "), sha256: module .checksums() @@ -845,7 +831,7 @@ impl SysInspectModPak { pub fn module_info(&self, name: &str) -> Result<(), SysinspectError> { let mut found = false; for (p, archset) in self.idx.all_modules(None, Some(vec![name])) { - let p = os_label(&p); + let p = os_display_name(&p); for (arch, modules) in archset { println!("{} ({}): ", p, arch.bright_green()); Self::print_table(&modules, true); @@ -926,7 +912,7 @@ impl SysInspectModPak { for p in platforms { let archset = allmods.get(&p).unwrap(); // safe: iter above - let p = os_label(&p); + let p = os_display_name(&p); for (arch, modules) in archset { println!("{} ({}): ", p, arch.bright_green()); Self::print_table(modules, false); diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index 4ba39c30..c3ae5c80 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -254,7 +254,7 @@ impl TelemetryConfig { ("service.namespace", CFG_OTLP_SERVICE_NAME), ("service.version", CFG_OTLP_SERVICE_VERSION), ("host.name", sysinfo::System::host_name().unwrap_or_default().as_str()), - ("os.type", sysinfo::System::distribution_id().as_str()), + ("os.type", crate::traits::current_os_type()), ("deployment.environment", "production"), ("os.version", sysinfo::System::kernel_version().unwrap_or_default().as_str()), ] { diff --git a/libsysinspect/src/traits/mod.rs b/libsysinspect/src/traits/mod.rs index 604a0c29..6fd411b1 100644 --- a/libsysinspect/src/traits/mod.rs +++ b/libsysinspect/src/traits/mod.rs @@ -139,6 +139,24 @@ pub fn get_minion_traits_nolog(cfg: Option<&MinionConfig>) -> SystemTraits { __get_minion_traits(cfg, true) } +/// Return the canonical lowercase OS type for the current build target. +pub fn current_os_type() -> &'static str { + std::env::consts::OS +} + +/// Return a stable display label for a canonical lowercase OS type. +pub fn os_display_name(os: &str) -> &str { + match os { + "android" => "Android", + "linux" | "sysv" => "Linux", + "any" => "Any", + "netbsd" => "NetBSD", + "freebsd" => "FreeBSD", + "openbsd" => "OpenBSD", + _ => os, + } +} + fn normalized_profiles(value: &Value) -> Vec { let mut names = IndexSet::new(); match value { diff --git a/libsysinspect/src/traits/systraits.rs b/libsysinspect/src/traits/systraits.rs index ea2ccc8c..589336bd 100644 --- a/libsysinspect/src/traits/systraits.rs +++ b/libsysinspect/src/traits/systraits.rs @@ -148,11 +148,8 @@ impl SystemTraits { self.put(SYS_OS_VERSION.to_string(), json!(v)); } - if let Some(v) = sysinfo::System::name() { - self.put(SYS_OS_NAME.to_string(), json!(v)); - } - - self.put(SYS_OS_DISTRO.to_string(), json!(sysinfo::System::distribution_id())); + self.put(SYS_OS_NAME.to_string(), json!(super::os_display_name(super::current_os_type()))); + self.put(SYS_OS_DISTRO.to_string(), json!(super::current_os_type())); // Machine Id (not always there) let mut mid = String::default(); From 092861566e120098704d0b2ae2c8f14e52e27a57 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 00:53:58 +0100 Subject: [PATCH 38/54] Add unit test for os display name --- libsysinspect/src/traits/traits_ut.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/libsysinspect/src/traits/traits_ut.rs b/libsysinspect/src/traits/traits_ut.rs index b2f8d110..324f83ac 100644 --- a/libsysinspect/src/traits/traits_ut.rs +++ b/libsysinspect/src/traits/traits_ut.rs @@ -1,5 +1,5 @@ use crate::cfg::mmconf::MinionConfig; -use crate::traits::{MASTER_TRAITS_FILE, TraitUpdateRequest, effective_profiles, ensure_master_traits_file}; +use crate::traits::{MASTER_TRAITS_FILE, TraitUpdateRequest, current_os_type, effective_profiles, ensure_master_traits_file, os_display_name}; use crate::traits::systraits::SystemTraits; use std::fs; @@ -112,3 +112,10 @@ fn effective_profiles_drops_default_when_real_profiles_are_present() { .unwrap_or_else(|err| panic!("failed to write master traits file: {err}")); assert_eq!(effective_profiles(&cfg), vec!["Runtimes".to_string()]); } + +#[test] +fn os_display_name_keeps_android_distinct_from_linux() { + assert_eq!(os_display_name("android"), "Android"); + assert_eq!(os_display_name("linux"), "Linux"); + assert!(!current_os_type().is_empty(), "current os type should be available"); +} From d2d321be7052abf031c2281169eadd7930d62dbf Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 00:54:09 +0100 Subject: [PATCH 39/54] Update manpage --- man/sysinspect.8.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/man/sysinspect.8.md b/man/sysinspect.8.md index b26f8286..9a294735 100644 --- a/man/sysinspect.8.md +++ b/man/sysinspect.8.md @@ -111,6 +111,7 @@ Examples: | **sysinspect** **profile** **--delete** **--name** Toto | **sysinspect** **profile** **--list** | **sysinspect** **profile** **--list** **--name** 'T*' +| **sysinspect** **profile** **--show** **--name** Toto | **sysinspect** **profile** **-A** **--name** Toto **--match** 'runtime.lua,net.*' | **sysinspect** **profile** **-A** **--lib** **--name** Toto **--match** 'runtime/lua/*.lua' | **sysinspect** **profile** **-R** **--name** Toto **--match** 'net.*' @@ -120,7 +121,7 @@ Examples: Notes: - **--name** is an exact profile name for **--new**, **--delete**, - **-A**, and **-R** + **--show**, **-A**, and **-R** - **--name** is a glob pattern for **--list** - **--match** accepts comma-separated exact names or glob patterns - **-l** or **--lib** switches selector operations and listing to @@ -128,6 +129,8 @@ Notes: - **--tag** and **--untag** update the **minion.profile** static trait - a profile file carries its own canonical **name** field; the filename is only storage +- new profile files are written with lowercase filenames, but existing + indexed filenames remain valid as-is MODULE REPOSITORY ================= From c89e224f1ee718553214317010e8f70a5d4e60ce Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 01:30:24 +0100 Subject: [PATCH 40/54] Reject traversing profile paths --- libmodpak/src/lib.rs | 30 +++++++++++++++++++++++++++--- libmodpak/src/lib_ut.rs | 13 +++++++++++++ libmodpak/tests/profile_sync.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index 628c516c..1569c36e 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -13,7 +13,10 @@ use once_cell::sync::Lazy; use prettytable::{Cell, Row, Table, format}; use std::os::unix::fs::PermissionsExt; use std::sync::Arc; -use std::{fs, path::PathBuf}; +use std::{ + fs, + path::{Component, Path, PathBuf}, +}; use textwrap::{Options, wrap}; use tokio::sync::Mutex; @@ -116,7 +119,7 @@ impl SysInspectModPakMinion { .bytes() .await .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read profiles index response: {e}")))?; - ModPakProfilesIndex::from_yaml(&String::from_utf8_lossy(&buff)) + Self::validate_profiles_index(ModPakProfilesIndex::from_yaml(&String::from_utf8_lossy(&buff))?) } async fn sync_profiles(&self, profiles: &ModPakProfilesIndex, names: &[String]) -> Result<(), SysinspectError> { @@ -153,6 +156,25 @@ impl SysInspectModPakMinion { Ok(()) } + fn validate_profile_path(path: &Path) -> Result<(), SysinspectError> { + if path.components().all(|component| matches!(component, Component::Normal(_))) { + return Ok(()); + } + + Err(SysinspectError::MasterGeneralError(format!( + "Invalid profile path in profiles.index: {}", + path.display() + ))) + } + + fn validate_profiles_index(index: ModPakProfilesIndex) -> Result { + for profile in index.profiles().values() { + Self::validate_profile_path(profile.file())?; + } + + Ok(index) + } + fn filtered_repo_index(&self, ridx: ModPakRepoIndex, profiles: &ModPakProfilesIndex, names: &[String]) -> Result { if profiles.profiles().is_empty() { return Ok(ridx); @@ -469,7 +491,9 @@ impl SysInspectModPak { /// Load the on-disk profiles index published next to the module repository. fn get_profiles_index(&self) -> Result { - ModPakProfilesIndex::from_yaml(&fs::read_to_string(self.root.parent().unwrap_or(&self.root).join(REPO_PROFILES_INDEX))?) + SysInspectModPakMinion::validate_profiles_index(ModPakProfilesIndex::from_yaml( + &fs::read_to_string(self.root.parent().unwrap_or(&self.root).join(REPO_PROFILES_INDEX))?, + )?) } /// Persist the profiles index next to the module repository. diff --git a/libmodpak/src/lib_ut.rs b/libmodpak/src/lib_ut.rs index 0cbb84eb..7befbf51 100644 --- a/libmodpak/src/lib_ut.rs +++ b/libmodpak/src/lib_ut.rs @@ -310,6 +310,19 @@ mod tests { assert!(profile.modules().contains(&"net.*".to_string())); } + #[test] + fn profiles_index_rejects_parent_dir_traversal() { + let root = tempfile::tempdir().expect("repo tempdir should be created"); + let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); + fs::write( + root.path().join("profiles.index"), + "profiles:\n Toto:\n file: ../escape.profile\n checksum: deadbeef\n", + ) + .expect("profiles index should be written"); + + assert!(repo.get_profiles_index().is_err()); + } + #[test] fn show_profile_renders_modules_first_and_libraries_after() { control::set_override(true); diff --git a/libmodpak/tests/profile_sync.rs b/libmodpak/tests/profile_sync.rs index 9ed66b44..75f062b5 100644 --- a/libmodpak/tests/profile_sync.rs +++ b/libmodpak/tests/profile_sync.rs @@ -184,3 +184,30 @@ async fn sync_fails_if_effective_profiles_are_missing_from_profiles_index() { server.abort(); } + +#[tokio::test] +async fn sync_rejects_profile_paths_with_traversal_components() { + let master = tempfile::tempdir().expect("master tempdir should be created"); + fs::create_dir_all(master.path().join("data")).expect("data dir should be created"); + fs::write( + master.path().join("data/profiles.index"), + "profiles:\n Escape:\n file: ../escape.profile\n checksum: deadbeef\n", + ) + .expect("profiles index should be written"); + + let (port, server) = start_fileserver(master.path().join("data")).await; + let minion = tempfile::tempdir().expect("minion tempdir should be created"); + let share = tempfile::tempdir().expect("share tempdir should be created"); + let cfg = configured_minion(minion.path(), share.path(), port); + fs::create_dir_all(cfg.traits_dir()).expect("traits dir should be created"); + ensure_master_traits_file(&cfg).expect("master traits file should exist"); + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"minion.profile":["Escape"]}}"#) + .expect("set request should parse") + .apply(&cfg) + .expect("set request should apply"); + + let err = SysInspectModPakMinion::new(cfg).sync().await.expect_err("sync should fail on path traversal"); + assert!(err.to_string().contains("Invalid profile path")); + + server.abort(); +} From 56b56fbd3d6edfa08a27f6eb71993607b5db3b61 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 01:30:30 +0100 Subject: [PATCH 41/54] Match profile libraries with lib prefix --- libmodpak/src/mpk.rs | 6 +++++- libmodpak/src/mpk_ut.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/libmodpak/src/mpk.rs b/libmodpak/src/mpk.rs index 968bf341..d375a973 100644 --- a/libmodpak/src/mpk.rs +++ b/libmodpak/src/mpk.rs @@ -357,6 +357,10 @@ impl ModPakRepoIndex { } } + fn library_selector_matches(pattern: &glob::Pattern, name: &str) -> bool { + pattern.matches(name) || pattern.matches(&format!("lib/{name}")) || name.strip_prefix("lib/").is_some_and(|rel| pattern.matches(rel)) + } + pub fn index_library(&mut self, p: &Path) -> Result<(), SysinspectError> { for (fname, cs) in libsysinspect::util::iofs::scan_files_sha256(p.to_path_buf(), None) { log::debug!("Adding library file: {fname} with checksum: {cs}"); @@ -477,7 +481,7 @@ impl ModPakRepoIndex { } for (name, entry) in &self.library { - if libraries.iter().any(|expr| glob::Pattern::new(expr).is_ok_and(|pattern| pattern.matches(name))) { + if libraries.iter().any(|expr| glob::Pattern::new(expr).is_ok_and(|pattern| Self::library_selector_matches(&pattern, name))) { index.library.insert(name.to_string(), entry.clone()); } } diff --git a/libmodpak/src/mpk_ut.rs b/libmodpak/src/mpk_ut.rs index 6c6011f8..68e556cb 100644 --- a/libmodpak/src/mpk_ut.rs +++ b/libmodpak/src/mpk_ut.rs @@ -65,3 +65,33 @@ library: assert!(libraries.contains_key("runtime/lua/reader.lua")); assert!(!libraries.contains_key("runtime/py3/reader.py")); } + +#[test] +fn profile_library_filter_accepts_optional_lib_prefix() { + let mut modules = IndexSet::new(); + let mut libraries = IndexSet::new(); + ModPakProfile::from_yaml("name: default\nlibraries:\n - lib/runtime/lua/reader.lua\n") + .expect("profile should deserialize") + .merge_into(&mut modules, &mut libraries); + + let filtered = ModPakRepoIndex::from_yaml( + r#" +platform: {} +library: + runtime/lua/reader.lua: + file: runtime/lua/reader.lua + checksum: beadfeed + kind: lua + runtime/py3/reader.py: + file: runtime/py3/reader.py + checksum: facefeed + kind: python +"#, + ) + .expect("repo index should deserialize") + .retain_profiles(&modules, &libraries) + .library(); + + assert!(filtered.contains_key("runtime/lua/reader.lua")); + assert!(!filtered.contains_key("runtime/py3/reader.py")); +} From d5706cc00f8a5270d8294b367d5d40bbd686130e Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 01:30:47 +0100 Subject: [PATCH 42/54] Reject empty profile names --- sysmaster/src/master.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index 3374a339..de83e640 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -815,11 +815,20 @@ impl SysMaster { async fn profile_console_response( &mut self, request: &ProfileConsoleRequest, query: &str, traits: &str, mid: &str, ) -> Result<(ConsoleResponse, Vec), SysinspectError> { + fn require_profile_name(request: &ProfileConsoleRequest) -> Result<(), SysinspectError> { + if !request.name().trim().is_empty() { + return Ok(()); + } + + Err(SysinspectError::InvalidQuery("Profile name cannot be empty".to_string())) + } + let repo = SysInspectModPak::new(self.cfg.get_mod_repo_root())?; match request.op() { "new" => Ok(( { + require_profile_name(request)?; repo.new_profile(request.name())?; ConsoleResponse { ok: true, message: format!("Created profile {}", request.name().bright_yellow()) } }, From ec8785d0cb04637d98f5a57c90c4a3d19f88e5f1 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 01:31:07 +0100 Subject: [PATCH 43/54] Validate profile names in more console ops --- sysmaster/src/master.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index de83e640..59145080 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -836,6 +836,7 @@ impl SysMaster { )), "delete" => Ok(( { + require_profile_name(request)?; repo.delete_profile(request.name())?; ConsoleResponse { ok: true, message: format!("Deleted profile {}", request.name().bright_yellow()) } }, @@ -852,9 +853,16 @@ impl SysMaster { }, vec![], )), - "show" => Ok((ConsoleResponse { ok: true, message: repo.show_profile(request.name())? }, vec![])), + "show" => Ok(( + { + require_profile_name(request)?; + ConsoleResponse { ok: true, message: repo.show_profile(request.name())? } + }, + vec![], + )), "add" => Ok(( { + require_profile_name(request)?; repo.add_profile_matches(request.name(), request.matches().to_vec(), request.library())?; ConsoleResponse { ok: true, message: format!("Updated profile {}", request.name().bright_yellow()) } }, @@ -862,6 +870,7 @@ impl SysMaster { )), "remove" => Ok(( { + require_profile_name(request)?; repo.remove_profile_matches(request.name(), request.matches().to_vec(), request.library())?; ConsoleResponse { ok: true, message: format!("Updated profile {}", request.name().bright_yellow()) } }, From 2532eb263e3bdbe586608917928a8b7184456995 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 01:31:21 +0100 Subject: [PATCH 44/54] Add console read timeout --- sysmaster/src/master.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index 59145080..90ffd629 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -57,6 +57,7 @@ use tokio::{ pub static SHARED_SESSION: Lazy>> = Lazy::new(|| Arc::new(Mutex::new(SessionKeeper::new(30)))); static MODEL_CACHE: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(HashMap::new()))); static MAX_CONSOLE_FRAME_SIZE: usize = 64 * 1024; +const CONSOLE_READ_TIMEOUT: StdDuration = StdDuration::from_secs(5); #[derive(Debug)] pub struct SysMaster { @@ -981,16 +982,24 @@ impl SysMaster { let (read_half, mut write_half) = stream.into_split(); let reader = TokioBufReader::new(read_half); let mut frame = Vec::new(); - let reply = match reader.take((MAX_CONSOLE_FRAME_SIZE + 1) as u64).read_until(b'\n', &mut frame).await { - Ok(0) => serde_json::to_string(&ConsoleResponse { ok: false, message: "Empty console request".to_string() }).ok(), - Ok(_) if frame.len() > MAX_CONSOLE_FRAME_SIZE || !frame.ends_with(b"\n") => { + let mut reader = reader.take((MAX_CONSOLE_FRAME_SIZE + 1) as u64); + let reply = match time::timeout(CONSOLE_READ_TIMEOUT, reader.read_until(b'\n', &mut frame)).await { + Err(_) => serde_json::to_string(&ConsoleResponse { + ok: false, + message: format!("Console request timed out after {} seconds", CONSOLE_READ_TIMEOUT.as_secs()), + }) + .ok(), + Ok(Ok(0)) => { + serde_json::to_string(&ConsoleResponse { ok: false, message: "Empty console request".to_string() }).ok() + } + Ok(Ok(_)) if frame.len() > MAX_CONSOLE_FRAME_SIZE || !frame.ends_with(b"\n") => { serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Console request exceeds {} bytes", MAX_CONSOLE_FRAME_SIZE), }) .ok() } - Ok(_) => match String::from_utf8(frame).map(|line| line.trim().to_string()) { + Ok(Ok(_)) => match String::from_utf8(frame).map(|line| line.trim().to_string()) { Ok(line) => match serde_json::from_str::(&line) { Ok(envelope) => match envelope.bootstrap.session_key(&master_prk) { Ok((key, _client_pkey)) => { @@ -1066,7 +1075,7 @@ impl SysMaster { serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Console request is not valid UTF-8: {err}") }).ok() } }, - Err(err) => serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Failed to read console request: {err}") }).ok(), + Ok(Err(err)) => serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Failed to read console request: {err}") }).ok(), }; if let Some(reply) = reply From 214f99a324635153384c604c0eb06364ab3d9ecd Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 01:31:26 +0100 Subject: [PATCH 45/54] Trim console PEM comparisons --- libsysinspect/src/console/mod.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index 6d7e6224..afd5d03e 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -231,7 +231,10 @@ impl ConsoleSealed { /// Check whether the provided client console public key is authorised by the master. pub fn authorised_console_client(cfg: &MasterConfig, client_pem: &str) -> Result { - if cfg.console_pubkey().exists() && fs::read_to_string(cfg.console_pubkey()).map_err(SysinspectError::IoErr)? == client_pem { + let client_pem = client_pem.trim(); + if cfg.console_pubkey().exists() + && fs::read_to_string(cfg.console_pubkey()).map_err(SysinspectError::IoErr)?.trim() == client_pem + { return Ok(true); } @@ -242,7 +245,9 @@ pub fn authorised_console_client(cfg: &MasterConfig, client_pem: &str) -> Result for entry in fs::read_dir(root).map_err(SysinspectError::IoErr)? { let path = entry.map_err(SysinspectError::IoErr)?.path(); - if path.is_file() && fs::read_to_string(&path).map_err(SysinspectError::IoErr)? == client_pem { + if path.is_file() + && fs::read_to_string(&path).map_err(SysinspectError::IoErr)?.trim() == client_pem + { return Ok(true); } } From e9b7fc5d924768824e42eb62eb7d7e9efeccdfee Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 01:33:46 +0100 Subject: [PATCH 46/54] Update cargo versions --- Cargo.lock | 272 ++++++++++++++++++++++++++--------------------------- 1 file changed, 131 insertions(+), 141 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc054b3b..0df96ed8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,9 +341,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -794,6 +794,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitflagset" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b6ee310aa7af14142c8c9121775774ff601ae055ed98ba7fac96098bcde1b9" +dependencies = [ + "num-integer", + "num-traits", + "radium 1.1.1", + "ref-cast", +] + [[package]] name = "bitvec" version = "1.0.1" @@ -1107,9 +1119,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -1276,9 +1288,9 @@ checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" @@ -1714,38 +1726,14 @@ dependencies = [ "libc", ] -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", + "darling_core", + "darling_macro", ] [[package]] @@ -1761,24 +1749,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn 2.0.117", ] @@ -1862,9 +1839,9 @@ dependencies = [ [[package]] name = "derive-where" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" dependencies = [ "proc-macro2", "quote", @@ -1979,9 +1956,9 @@ dependencies = [ [[package]] name = "doctest-file" -version = "1.0.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" +checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" [[package]] name = "dotenv" @@ -3239,11 +3216,11 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling 0.23.0", + "darling", "indoc", "proc-macro2", "quote", @@ -4167,9 +4144,9 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab6473172471198271ff72e9379150e9dfd70d8e533e0752a27e515b48dd375e" +checksum = "98c23545df7ecf1b16c303910a69b079e8e251d60f7dd2cc9b4177f2afaf1746" dependencies = [ "twox-hash", ] @@ -4663,9 +4640,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -4673,9 +4650,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", @@ -5446,9 +5423,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] @@ -5546,7 +5523,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.4+spec-1.1.0", + "toml_edit 0.25.5+spec-1.1.0", ] [[package]] @@ -5767,9 +5744,9 @@ dependencies = [ [[package]] name = "pymath" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfb6723b732fc7f0b29a0ee7150c7f70f947bf467b8c3e82530b13589a78b4c" +checksum = "bc10e50b7a1f2cc3887e983721cb51fc7574be0066c84bff3ef9e5c096e8d6d5" dependencies = [ "libc", "libm", @@ -6387,7 +6364,7 @@ dependencies = [ [[package]] name = "ruff_python_ast" version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?rev=5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be#5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be" +source = "git+https://github.com/astral-sh/ruff.git?rev=e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675#e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675" dependencies = [ "aho-corasick", "bitflags 2.11.0", @@ -6405,7 +6382,7 @@ dependencies = [ [[package]] name = "ruff_python_parser" version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?rev=5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be#5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be" +source = "git+https://github.com/astral-sh/ruff.git?rev=e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675#e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675" dependencies = [ "bitflags 2.11.0", "bstr", @@ -6425,7 +6402,7 @@ dependencies = [ [[package]] name = "ruff_python_trivia" version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?rev=5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be#5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be" +source = "git+https://github.com/astral-sh/ruff.git?rev=e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675#e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675" dependencies = [ "itertools 0.14.0", "ruff_source_file", @@ -6436,7 +6413,7 @@ dependencies = [ [[package]] name = "ruff_source_file" version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?rev=5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be#5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be" +source = "git+https://github.com/astral-sh/ruff.git?rev=e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675#e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675" dependencies = [ "memchr", "ruff_text_size", @@ -6445,7 +6422,7 @@ dependencies = [ [[package]] name = "ruff_text_size" version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?rev=5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be#5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be" +source = "git+https://github.com/astral-sh/ruff.git?rev=e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675#e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675" dependencies = [ "get-size2", ] @@ -6686,8 +6663,8 @@ dependencies = [ [[package]] name = "rustpython" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "cfg-if", "dirs-next", @@ -6706,8 +6683,8 @@ dependencies = [ [[package]] name = "rustpython-codegen" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "ahash 0.8.12", "bitflags 2.11.0", @@ -6729,8 +6706,8 @@ dependencies = [ [[package]] name = "rustpython-common" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "ascii", "bitflags 2.11.0", @@ -6757,8 +6734,8 @@ dependencies = [ [[package]] name = "rustpython-compiler" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "ruff_python_ast", "ruff_python_parser", @@ -6771,10 +6748,11 @@ dependencies = [ [[package]] name = "rustpython-compiler-core" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "bitflags 2.11.0", + "bitflagset", "itertools 0.14.0", "lz4_flex", "malachite-bigint", @@ -6785,8 +6763,8 @@ dependencies = [ [[package]] name = "rustpython-derive" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "rustpython-compiler", "rustpython-derive-impl", @@ -6795,8 +6773,8 @@ dependencies = [ [[package]] name = "rustpython-derive-impl" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "itertools 0.14.0", "maplit", @@ -6811,16 +6789,16 @@ dependencies = [ [[package]] name = "rustpython-doc" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "phf 0.13.1", ] [[package]] name = "rustpython-literal" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "hexf-parse", "is-macro", @@ -6832,8 +6810,8 @@ dependencies = [ [[package]] name = "rustpython-pylib" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "glob", "rustpython-compiler-core", @@ -6842,8 +6820,8 @@ dependencies = [ [[package]] name = "rustpython-sre_engine" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "bitflags 2.11.0", "num_enum", @@ -6853,8 +6831,8 @@ dependencies = [ [[package]] name = "rustpython-stdlib" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "adler32", "ahash 0.8.12", @@ -6944,8 +6922,8 @@ dependencies = [ [[package]] name = "rustpython-vm" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "ahash 0.8.12", "ascii", @@ -6999,7 +6977,7 @@ dependencies = [ "scopeguard", "static_assertions", "strum 0.27.2", - "strum_macros 0.27.2", + "strum_macros 0.28.0", "thiserror 2.0.18", "thread_local", "timsort", @@ -7016,8 +6994,8 @@ dependencies = [ [[package]] name = "rustpython-wtf8" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "ascii", "bstr", @@ -7298,9 +7276,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.17.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ "base64", "chrono", @@ -7317,11 +7295,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.17.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling 0.21.3", + "darling", "proc-macro2", "quote", "syn 2.0.117", @@ -7675,6 +7653,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -8142,9 +8132,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -8283,17 +8273,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" dependencies = [ "indexmap 2.13.0", "serde_core", "serde_spanned 1.0.4", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.0", ] [[package]] @@ -8307,18 +8297,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" dependencies = [ "serde_core", ] @@ -8334,28 +8315,28 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" dependencies = [ "indexmap 2.13.0", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] @@ -8366,9 +8347,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" [[package]] name = "tonic" @@ -8527,9 +8508,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "nu-ansi-term", "sharded-slab", @@ -8577,9 +8558,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uds_windows" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", @@ -9636,9 +9617,9 @@ dependencies = [ [[package]] name = "wide" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac11b009ebeae802ed758530b6496784ebfee7a87b9abfbcaf3bbe25b814eb25" +checksum = "198f6abc41fab83526d10880fa5c17e2b4ee44e763949b4bb34e2fd1e8ca48e4" dependencies = [ "bytemuck", "safe_arch", @@ -10169,13 +10150,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + [[package]] name = "winresource" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e287ced0f21cd11f4035fe946fd3af145f068d1acb708afd248100f89ec7432d" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" dependencies = [ - "toml 0.9.12+spec-1.1.0", + "toml 1.0.7+spec-1.1.0", "version_check", ] @@ -10425,7 +10415,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -10453,7 +10443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "winnow", + "winnow 0.7.15", "zvariant", ] @@ -10632,7 +10622,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] @@ -10660,5 +10650,5 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "winnow", + "winnow 0.7.15", ] From 22e9bc555d6c4eebe6456f0b1b9be216002176e8 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 11:42:48 +0100 Subject: [PATCH 47/54] Reject console clients before bootstrap --- sysmaster/src/master.rs | 155 +++++++++++++++++++++++++--------------- 1 file changed, 98 insertions(+), 57 deletions(-) diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index 90ffd629..2f4cbdf7 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -1001,75 +1001,116 @@ impl SysMaster { } Ok(Ok(_)) => match String::from_utf8(frame).map(|line| line.trim().to_string()) { Ok(line) => match serde_json::from_str::(&line) { - Ok(envelope) => match envelope.bootstrap.session_key(&master_prk) { - Ok((key, _client_pkey)) => { - let response = if !authorised_console_client(&cfg, &envelope.bootstrap.client_pubkey).unwrap_or(false) { - ConsoleResponse { ok: false, message: "Console client key is not authorised".to_string() } + Ok(envelope) => { + if !authorised_console_client(&cfg, &envelope.bootstrap.client_pubkey).unwrap_or(false) { + serde_json::to_string(&ConsoleResponse { + ok: false, + message: "Console client key is not authorised".to_string(), + }) + .ok() } else { - match envelope.sealed.open::(&key) { - Ok(query) => { - if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_ONLINE_MINIONS}")) { - match master.lock().await.online_minions_summary().await { - Ok(summary) => ConsoleResponse { ok: true, message: summary }, - Err(err) => ConsoleResponse { ok: false, message: format!("Unable to get online minions: {err}") }, - } - } else if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_PROFILE}")) { - let (response, msgs) = match ProfileConsoleRequest::from_context(&query.context) { - Ok(request) => { - let mut guard = master.lock().await; - match guard.profile_console_response(&request, &query.query, &query.traits, &query.mid).await { - Ok(data) => data, - Err(err) => (ConsoleResponse { ok: false, message: err.to_string() }, vec![]), + match envelope.bootstrap.session_key(&master_prk) { + Ok((key, _client_pkey)) => { + let response = match envelope.sealed.open::(&key) { + Ok(query) => { + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_ONLINE_MINIONS}")) { + match master.lock().await.online_minions_summary().await { + Ok(summary) => ConsoleResponse { ok: true, message: summary }, + Err(err) => ConsoleResponse { + ok: false, + message: format!("Unable to get online minions: {err}"), + }, + } + } else if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_PROFILE}")) { + let (response, msgs) = match ProfileConsoleRequest::from_context(&query.context) { + Ok(request) => { + let mut guard = master.lock().await; + match guard.profile_console_response(&request, &query.query, &query.traits, &query.mid).await { + Ok(data) => data, + Err(err) => (ConsoleResponse { ok: false, message: err.to_string() }, vec![]), + } + } + Err(err) => ( + ConsoleResponse { + ok: false, + message: format!("Failed to parse profile request: {err}"), + }, + vec![], + ), + }; + for msg in msgs { + SysMaster::bcast_master_msg( + &bcast, + cfg.telemetry_enabled(), + Arc::clone(&master), + Some(msg.clone()), + ) + .await; + let guard = master.lock().await; + let ids = guard.mreg.lock().await.get_targeted_minions(msg.target(), false).await; + guard.taskreg.lock().await.register(msg.cycle(), ids); + } + response + } else { + let msg = { + let mut guard = master.lock().await; + guard.msg_query_data(&query.model, &query.query, &query.traits, &query.mid, &query.context).await + }; + if let Some(msg) = msg { + SysMaster::bcast_master_msg( + &bcast, + cfg.telemetry_enabled(), + Arc::clone(&master), + Some(msg.clone()), + ) + .await; + let guard = master.lock().await; + let ids = guard.mreg.lock().await.get_targeted_minions(msg.target(), false).await; + guard.taskreg.lock().await.register(msg.cycle(), ids); + ConsoleResponse { ok: true, message: format!("Accepted console command from {peer}") } + } else { + ConsoleResponse { + ok: false, + message: "No message constructed for the console query".to_string(), + } } } - Err(err) => ( - ConsoleResponse { ok: false, message: format!("Failed to parse profile request: {err}") }, - vec![], - ), - }; - for msg in msgs { - SysMaster::bcast_master_msg(&bcast, cfg.telemetry_enabled(), Arc::clone(&master), Some(msg.clone())).await; - let guard = master.lock().await; - let ids = guard.mreg.lock().await.get_targeted_minions(msg.target(), false).await; - guard.taskreg.lock().await.register(msg.cycle(), ids); } - response - } else { - let msg = { - let mut guard = master.lock().await; - guard.msg_query_data(&query.model, &query.query, &query.traits, &query.mid, &query.context).await + Err(err) => ConsoleResponse { + ok: false, + message: format!("Failed to open console query: {err}"), + }, }; - if let Some(msg) = msg { - SysMaster::bcast_master_msg(&bcast, cfg.telemetry_enabled(), Arc::clone(&master), Some(msg.clone())).await; - let guard = master.lock().await; - let ids = guard.mreg.lock().await.get_targeted_minions(msg.target(), false).await; - guard.taskreg.lock().await.register(msg.cycle(), ids); - ConsoleResponse { ok: true, message: format!("Accepted console command from {peer}") } - } else { - ConsoleResponse { ok: false, message: "No message constructed for the console query".to_string() } - } + match ConsoleSealed::seal(&response, &key).and_then(|sealed| { + serde_json::to_string(&sealed) + .map_err(|e| SysinspectError::SerializationError(e.to_string())) + }) { + Ok(reply) => Some(reply), + Err(err) => { + log::error!("Failed to seal console response: {err}"); + serde_json::to_string(&ConsoleResponse { + ok: false, + message: format!("Failed to seal console response: {err}"), + }) + .ok() + } } } - Err(err) => ConsoleResponse { ok: false, message: format!("Failed to open console query: {err}") }, - } - }; - match ConsoleSealed::seal(&response, &key).and_then(|sealed| { - serde_json::to_string(&sealed).map_err(|e| SysinspectError::SerializationError(e.to_string())) - }) { - Ok(reply) => Some(reply), - Err(err) => { - log::error!("Failed to seal console response: {err}"); - serde_json::to_string(&ConsoleResponse { + Err(err) => serde_json::to_string(&ConsoleResponse { ok: false, - message: format!("Failed to seal console response: {err}"), + message: format!("Console bootstrap failed: {err}"), }) - .ok() + .ok(), } } } - Err(err) => serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Console bootstrap failed: {err}") }).ok(), - }, - Err(err) => serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Failed to parse console request: {err}") }).ok(), + Err(err) => { + serde_json::to_string(&ConsoleResponse { + ok: false, + message: format!("Failed to parse console request: {err}"), + }) + .ok() + } }, Err(err) => { serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Console request is not valid UTF-8: {err}") }).ok() From a0cb6c44080e8226c56ca03c040151edca648f5c Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 11:43:39 +0100 Subject: [PATCH 48/54] Harden console key permissions --- libsysinspect/src/console/console_ut.rs | 19 +++++++++++++++ libsysinspect/src/console/mod.rs | 31 +++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/libsysinspect/src/console/console_ut.rs b/libsysinspect/src/console/console_ut.rs index 3aa4b40c..9b2700c9 100644 --- a/libsysinspect/src/console/console_ut.rs +++ b/libsysinspect/src/console/console_ut.rs @@ -52,3 +52,22 @@ fn ensure_console_keypair_recovers_missing_public_key_from_private_key() { assert!(root.path().join(crate::cfg::mmconf::CFG_CONSOLE_KEY_PUB).exists()); assert_eq!(loaded_pbk.n().to_bytes_be(), client_prk.n().to_bytes_be()); } + +#[cfg(unix)] +#[test] +fn ensure_console_keypair_sets_restrictive_permissions() { + use std::os::unix::fs::PermissionsExt; + + let root = tempdir().unwrap(); + let _ = ensure_console_keypair(root.path()).unwrap(); + + let dir_mode = std::fs::metadata(root.path()).unwrap().permissions().mode() & 0o777; + let key_mode = std::fs::metadata(root.path().join(crate::cfg::mmconf::CFG_CONSOLE_KEY_PRI)) + .unwrap() + .permissions() + .mode() + & 0o777; + + assert_eq!(dir_mode, 0o700); + assert_eq!(key_mode, 0o600); +} diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index afd5d03e..0993a8a3 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -23,6 +23,7 @@ use crate::{ mod console_ut; static SODIUM_INIT: OnceLock<()> = OnceLock::new(); +const CONSOLE_KEY_SIZE: usize = 2048; /// RSA-bootstrapped session bootstrap data sent before opening the sealed console payload. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -93,16 +94,41 @@ fn console_keypair(root: &Path) -> (PathBuf, PathBuf) { (root.join(CFG_CONSOLE_KEY_PRI), root.join(CFG_CONSOLE_KEY_PUB)) } +fn ensure_console_permissions(root: &Path, prk_path: &Path) -> Result<(), SysinspectError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut root_perms = fs::metadata(root).map_err(SysinspectError::IoErr)?.permissions(); + root_perms.set_mode(0o700); + fs::set_permissions(root, root_perms).map_err(SysinspectError::IoErr)?; + + if prk_path.exists() { + let mut prk_perms = fs::metadata(prk_path).map_err(SysinspectError::IoErr)?.permissions(); + prk_perms.set_mode(0o600); + fs::set_permissions(prk_path, prk_perms).map_err(SysinspectError::IoErr)?; + } + } + + Ok(()) +} + /// Ensure a console RSA keypair exists under the given root and return it. pub fn ensure_console_keypair(root: &Path) -> Result<(RsaPrivateKey, RsaPublicKey), SysinspectError> { let (prk_path, pbk_path) = console_keypair(root); match (prk_path.exists(), pbk_path.exists()) { - (true, true) => Ok((load_private_key(&prk_path)?, load_public_key(&pbk_path)?)), + (true, true) => { + let prk = load_private_key(&prk_path)?; + let pbk = load_public_key(&pbk_path)?; + ensure_console_permissions(root, &prk_path)?; + Ok((prk, pbk)) + } (true, false) => { fs::create_dir_all(root).map_err(SysinspectError::IoErr)?; let prk = load_private_key(&prk_path)?; let pbk = RsaPublicKey::from(&prk); key_to_file(&Public(pbk.clone()), root.to_str().unwrap_or_default(), CFG_CONSOLE_KEY_PUB)?; + ensure_console_permissions(root, &prk_path)?; Ok((prk, pbk)) } (false, true) => Err(SysinspectError::ConfigError(format!( @@ -112,9 +138,10 @@ pub fn ensure_console_keypair(root: &Path) -> Result<(RsaPrivateKey, RsaPublicKe ))), (false, false) => { fs::create_dir_all(root).map_err(SysinspectError::IoErr)?; - let (prk, pbk) = keygen(crate::rsa::keys::DEFAULT_KEY_SIZE).map_err(|e| SysinspectError::RSAError(e.to_string()))?; + let (prk, pbk) = keygen(CONSOLE_KEY_SIZE).map_err(|e| SysinspectError::RSAError(e.to_string()))?; key_to_file(&Private(prk.clone()), root.to_str().unwrap_or_default(), CFG_CONSOLE_KEY_PRI)?; key_to_file(&Public(pbk.clone()), root.to_str().unwrap_or_default(), CFG_CONSOLE_KEY_PUB)?; + ensure_console_permissions(root, &prk_path)?; Ok((prk, pbk)) } } From 42401224afc32bfd2bb41f47faf69458ba34c71f Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 11:44:14 +0100 Subject: [PATCH 49/54] Verify synced profile checksums --- libmodpak/src/lib.rs | 9 +++++++-- libmodpak/tests/profile_sync.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index 1569c36e..9ed2208e 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -132,8 +132,7 @@ impl SysInspectModPakMinion { .get(name) .ok_or_else(|| SysinspectError::MasterGeneralError(format!("Profile {} is missing from profiles.index", name.bright_yellow())))?; let dst = self.cfg.profiles_dir().join(profile.file()); - let checksum = if dst.exists() { get_file_sha256(dst.clone()).ok() } else { None }; - if checksum.as_deref() == Some(profile.checksum()) { + if dst.exists() && get_file_sha256(dst.clone())?.eq(profile.checksum()) { continue; } @@ -151,6 +150,12 @@ impl SysInspectModPakMinion { fs::create_dir_all(parent)?; } fs::write(&dst, resp.bytes().await.map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read response: {e}")))? )?; + if !get_file_sha256(dst.clone())?.eq(profile.checksum()) { + return Err(SysinspectError::MasterGeneralError(format!( + "Checksum mismatch for profile {}", + name.bright_yellow() + ))); + } } Ok(()) diff --git a/libmodpak/tests/profile_sync.rs b/libmodpak/tests/profile_sync.rs index 75f062b5..69f16915 100644 --- a/libmodpak/tests/profile_sync.rs +++ b/libmodpak/tests/profile_sync.rs @@ -211,3 +211,35 @@ async fn sync_rejects_profile_paths_with_traversal_components() { server.abort(); } + +#[tokio::test] +async fn sync_fails_if_downloaded_profile_checksum_does_not_match_index() { + let master = tempfile::tempdir().expect("master tempdir should be created"); + let repo = SysInspectModPak::new(master.path().join("data/repo")).expect("repo should be created"); + add_script_module(&master.path().join("data/repo"), "alpha.demo", "# alpha"); + set_script_modules(&master.path().join("data/repo"), &["alpha.demo"]); + repo.new_profile("Broken").expect("Broken should be created"); + repo.add_profile_matches("Broken", vec!["alpha.demo".to_string()], false) + .expect("Broken selector should be added"); + fs::write( + master.path().join("data/profiles.index"), + "profiles:\n Broken:\n file: broken.profile\n checksum: deadbeef\n", + ) + .expect("profiles index should be overwritten"); + + let (port, server) = start_fileserver(master.path().join("data")).await; + let minion = tempfile::tempdir().expect("minion tempdir should be created"); + let share = tempfile::tempdir().expect("share tempdir should be created"); + let cfg = configured_minion(minion.path(), share.path(), port); + fs::create_dir_all(cfg.traits_dir()).expect("traits dir should be created"); + ensure_master_traits_file(&cfg).expect("master traits file should exist"); + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"minion.profile":["Broken"]}}"#) + .expect("set request should parse") + .apply(&cfg) + .expect("set request should apply"); + + let err = SysInspectModPakMinion::new(cfg).sync().await.expect_err("sync should fail on profile checksum mismatch"); + assert!(err.to_string().contains("Checksum mismatch for profile")); + + server.abort(); +} From 0a34ec7f49d35519a7fa57da9e1138f2cd490359 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 11:44:19 +0100 Subject: [PATCH 50/54] Validate profile CRUD paths --- libmodpak/src/lib.rs | 31 +++++++++++++++++++++++++++---- libmodpak/src/lib_ut.rs | 8 ++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index 9ed2208e..c9bcd234 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -507,12 +507,32 @@ impl SysInspectModPak { Ok(()) } + fn validate_profile_name_and_file(name: &str, file: &Path) -> Result<(), SysinspectError> { + if name.contains('/') || name.contains('\\') || name.contains("..") { + return Err(SysinspectError::MasterGeneralError(format!( + "Invalid profile name {}", + name.bright_yellow() + ))); + } + + if file.components().all(|component| matches!(component, Component::Normal(_))) { + return Ok(()); + } + + Err(SysinspectError::MasterGeneralError(format!( + "Invalid profile file path for {}: {}", + name.bright_yellow(), + file.display() + ))) + } + /// Load one profile by canonical name and verify the file content name matches the index entry. fn get_profile(&self, name: &str) -> Result { let index = self.get_profiles_index()?; let entry = index .get(name) .ok_or_else(|| SysinspectError::MasterGeneralError(format!("Profile {} was not found", name.bright_yellow())))?; + Self::validate_profile_name_and_file(name, entry.file())?; { let profile = ModPakProfile::from_yaml(&fs::read_to_string(self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).join(entry.file()))?)?; @@ -531,6 +551,7 @@ impl SysInspectModPak { fn set_profile(&self, name: &str, profile: &ModPakProfile) -> Result<(), SysinspectError> { let mut index = self.get_profiles_index()?; let file = index.get(name).map(|entry| entry.file().to_path_buf()).unwrap_or_else(|| PathBuf::from(format!("{}.profile", name.to_lowercase()))); + Self::validate_profile_name_and_file(name, &file)?; let path = self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).join(&file); if !self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).exists() { fs::create_dir_all(self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT))?; @@ -543,10 +564,12 @@ impl SysInspectModPak { /// Remove one profile file and its `profiles.index` entry. fn remove_profile_entry(&self, name: &str) -> Result<(), SysinspectError> { let mut index = self.get_profiles_index()?; - if let Some(entry) = index.profiles().get(name) - && self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).join(entry.file()).exists() - { - fs::remove_file(self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).join(entry.file()))?; + if let Some(entry) = index.profiles().get(name) { + Self::validate_profile_name_and_file(name, entry.file())?; + let path = self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).join(entry.file()); + if path.exists() { + fs::remove_file(path)?; + } } index.remove(name); self.set_profiles_index(&index) diff --git a/libmodpak/src/lib_ut.rs b/libmodpak/src/lib_ut.rs index 7befbf51..27806d7d 100644 --- a/libmodpak/src/lib_ut.rs +++ b/libmodpak/src/lib_ut.rs @@ -323,6 +323,14 @@ mod tests { assert!(repo.get_profiles_index().is_err()); } + #[test] + fn new_profile_rejects_traversing_name() { + let root = tempfile::tempdir().expect("repo tempdir should be created"); + let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); + + assert!(repo.new_profile("../escape").is_err()); + } + #[test] fn show_profile_renders_modules_first_and_libraries_after() { control::set_override(true); From 5849d27133ec9b1b24ab61a41637c94608794f5f Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 11:44:53 +0100 Subject: [PATCH 51/54] Bound console client response reads --- src/main.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 6dca0b32..b8c0c989 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,10 +26,12 @@ use std::{ path::PathBuf, process::exit, sync::{Mutex, OnceLock}, + time::Duration, }; use tokio::{ - io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, net::TcpStream, + time::timeout, }; mod clidef; @@ -38,6 +40,9 @@ mod ui; static VERSION: &str = "0.4.0"; static LOGGER: OnceLock = OnceLock::new(); static MEM_LOGGER: MemoryLogger = MemoryLogger { messages: Mutex::new(Vec::new()) }; +const CONSOLE_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const CONSOLE_READ_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_CONSOLE_RESPONSE_SIZE: u64 = 64 * 1024; /// Display event handlers fn print_event_handlers() { From 241603c66383d80ebd4a97e77e584e9c1546aba6 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 11:45:20 +0100 Subject: [PATCH 52/54] Validate profile names in CLI --- src/main.rs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index b8c0c989..db6f801a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,13 +104,20 @@ fn traits_update_context(am: &ArgMatches) -> Result, SysinspectEr } fn profile_update_context(am: &ArgMatches) -> Result, SysinspectError> { - let invalid_name = |name: &str| name.chars().any(|c| ['*', '?', '[', ']'].contains(&c)); + let invalid_name = |name: &str| { + let name = name.trim(); + name.is_empty() + || matches!(name, "." | "..") + || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + }; if am.get_flag("new") { if am.get_one::("name").is_none() { return Err(SysinspectError::InvalidQuery("Specify --name for --new".to_string())); } if invalid_name(am.get_one::("name").unwrap()) { - return Err(SysinspectError::InvalidQuery("Profile names for --new must be exact names, not glob patterns".to_string())); + return Err(SysinspectError::InvalidQuery( + "Profile names for --new must be exact names and may only contain letters, digits, '.', '_', or '-'".to_string(), + )); } return Ok(Some(json!({"op": "new", "name": am.get_one::("name").cloned().unwrap_or_default()}).to_string())); } @@ -120,7 +127,9 @@ fn profile_update_context(am: &ArgMatches) -> Result, SysinspectE return Err(SysinspectError::InvalidQuery("Specify --name for --delete".to_string())); } if invalid_name(am.get_one::("name").unwrap()) { - return Err(SysinspectError::InvalidQuery("Profile names for --delete must be exact names, not glob patterns".to_string())); + return Err(SysinspectError::InvalidQuery( + "Profile names for --delete must be exact names and may only contain letters, digits, '.', '_', or '-'".to_string(), + )); } return Ok(Some(json!({"op": "delete", "name": am.get_one::("name").cloned().unwrap_or_default()}).to_string())); } @@ -136,7 +145,9 @@ fn profile_update_context(am: &ArgMatches) -> Result, SysinspectE return Err(SysinspectError::InvalidQuery("Specify --name for --show".to_string())); } if invalid_name(am.get_one::("name").unwrap()) { - return Err(SysinspectError::InvalidQuery("Profile names for --show must be exact names, not glob patterns".to_string())); + return Err(SysinspectError::InvalidQuery( + "Profile names for --show must be exact names and may only contain letters, digits, '.', '_', or '-'".to_string(), + )); } return Ok(Some(json!({"op": "show", "name": am.get_one::("name").cloned().unwrap_or_default()}).to_string())); } @@ -146,7 +157,9 @@ fn profile_update_context(am: &ArgMatches) -> Result, SysinspectE return Err(SysinspectError::InvalidQuery("Specify both --name and --match for profile selector updates".to_string())); } if invalid_name(am.get_one::("name").unwrap()) { - return Err(SysinspectError::InvalidQuery("Profile names for selector updates must be exact names, not glob patterns".to_string())); + return Err(SysinspectError::InvalidQuery( + "Profile names for selector updates must be exact names and may only contain letters, digits, '.', '_', or '-'".to_string(), + )); } if clidef::split_by(am, "match", None).is_empty() { return Err(SysinspectError::InvalidQuery("At least one selector is required in --match".to_string())); From 7d760f276b507d15b7b4159485bee199b44520cb Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 11:45:33 +0100 Subject: [PATCH 53/54] Add console client timeouts --- src/main.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index db6f801a..b89acc42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,12 +65,22 @@ async fn call_master_console( context: context.cloned().unwrap_or_default(), }; let (envelope, key) = build_console_query(&cfg.root_dir(), cfg, &request)?; - let mut stream = TcpStream::connect(cfg.console_connect_addr()).await?; + let mut stream = timeout(CONSOLE_CONNECT_TIMEOUT, TcpStream::connect(cfg.console_connect_addr())) + .await + .map_err(|_| std::io::Error::new(ErrorKind::TimedOut, "timeout while connecting to master console"))??; stream.write_all(format!("{}\n", serde_json::to_string(&envelope)?).as_bytes()).await?; - let mut reader = BufReader::new(stream); + let mut reader = BufReader::new(stream).take(MAX_CONSOLE_RESPONSE_SIZE + 1); let mut reply = String::new(); - reader.read_line(&mut reply).await?; + timeout(CONSOLE_READ_TIMEOUT, reader.read_line(&mut reply)) + .await + .map_err(|_| std::io::Error::new(ErrorKind::TimedOut, "timeout while reading response from master console"))??; + if reply.len() as u64 > MAX_CONSOLE_RESPONSE_SIZE || !reply.ends_with('\n') { + return Err(SysinspectError::SerializationError(format!( + "Console response exceeds {} bytes", + MAX_CONSOLE_RESPONSE_SIZE + ))); + } let response: ConsoleResponse = match serde_json::from_str::(reply.trim()) { Ok(sealed) => sealed.open(&key)?, Err(_) => serde_json::from_str(reply.trim())?, From 9e2c6e6a471f007b9c75fc15bb8edf08ddb8d5ba Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 18 Mar 2026 11:45:40 +0100 Subject: [PATCH 54/54] Trim tagged profile names --- src/main.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index b89acc42..ecdf7fd6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -186,13 +186,19 @@ fn profile_update_context(am: &ArgMatches) -> Result, SysinspectE } if am.get_one::("tag").is_some() || am.get_one::("untag").is_some() { - if clidef::split_by(am, if am.get_one::("tag").is_some() { "tag" } else { "untag" }, None).is_empty() { + let arg_name = if am.get_one::("tag").is_some() { "tag" } else { "untag" }; + let profiles = clidef::split_by(am, arg_name, None) + .into_iter() + .map(|profile| profile.trim().to_string()) + .filter(|profile| !profile.is_empty()) + .collect::>(); + if profiles.is_empty() { return Err(SysinspectError::InvalidQuery("Specify at least one profile name for --tag or --untag".to_string())); } return Ok(Some( json!({ - "op": if am.get_one::("tag").is_some() { "tag" } else { "untag" }, - "profiles": clidef::split_by(am, if am.get_one::("tag").is_some() { "tag" } else { "untag" }, None), + "op": arg_name, + "profiles": profiles, }) .to_string(), ));