From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 8F7AF1FF39E for ; Mon, 10 Jun 2024 17:41:52 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 1681C1B520; Mon, 10 Jun 2024 17:42:28 +0200 (CEST) From: Shannon Sterz To: pbs-devel@lists.proxmox.com Date: Mon, 10 Jun 2024 17:42:13 +0200 Message-Id: <20240610154214.356689-5-s.sterz@proxmox.com> X-Mailer: git-send-email 2.39.2 In-Reply-To: <20240610154214.356689-1-s.sterz@proxmox.com> References: <20240610154214.356689-1-s.sterz@proxmox.com> MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.053 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record T_SCC_BODY_TEXT_LINE -0.01 - Subject: [pbs-devel] [PATCH proxmox 4/5] access: factor out user config and cache handling X-BeenThere: pbs-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Backup Server development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Backup Server development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pbs-devel-bounces@lists.proxmox.com Sender: "pbs-devel" this commit factors out the user config as well as the shared memory based config version caching mechanism. this makes it necessary that users of this crate provide the product name and a reference to a shared memory location as well. Signed-off-by: Shannon Sterz --- Cargo.toml | 1 + proxmox-access/Cargo.toml | 3 + proxmox-access/src/acl.rs | 4 + proxmox-access/src/cached_user_info.rs | 242 +++++++++++++++++++++ proxmox-access/src/config_version_cache.rs | 113 ++++++++++ proxmox-access/src/init.rs | 61 +++++- proxmox-access/src/lib.rs | 7 + proxmox-access/src/user.rs | 182 ++++++++++++++++ 8 files changed, 612 insertions(+), 1 deletion(-) create mode 100644 proxmox-access/src/cached_user_info.rs create mode 100644 proxmox-access/src/config_version_cache.rs create mode 100644 proxmox-access/src/user.rs diff --git a/Cargo.toml b/Cargo.toml index 6b17dd4d..32d05d20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,6 +127,7 @@ proxmox-router = { version = "2.1.3", path = "proxmox-router" } proxmox-schema = { version = "3.1.1", path = "proxmox-schema" } proxmox-section-config = { version = "2.0.0", path = "proxmox-section-config" } proxmox-serde = { version = "0.1.1", path = "proxmox-serde", features = [ "serde_json" ] } +proxmox-shared-memory = { version = "0.3.0", path = "proxmox-shared-memory" } proxmox-sortable-macro = { version = "0.1.3", path = "proxmox-sortable-macro" } proxmox-sys = { version = "0.5.5", path = "proxmox-sys" } proxmox-tfa = { version = "4.0.4", path = "proxmox-tfa" } diff --git a/proxmox-access/Cargo.toml b/proxmox-access/Cargo.toml index 0949e44c..6d28445b 100644 --- a/proxmox-access/Cargo.toml +++ b/proxmox-access/Cargo.toml @@ -21,7 +21,10 @@ serde_json.workspace = true # proxmox-notify.workspace = true proxmox-auth-api = { workspace = true, features = [ "api-types" ] } +proxmox-router = { workspace = true } proxmox-schema.workspace = true +proxmox-section-config.workspace = true proxmox-product-config.workspace = true +proxmox-shared-memory.workspace = true proxmox-sys = { workspace = true, features = [ "crypt" ] } proxmox-time.workspace = true diff --git a/proxmox-access/src/acl.rs b/proxmox-access/src/acl.rs index 26f639ae..8cfb4fe7 100644 --- a/proxmox-access/src/acl.rs +++ b/proxmox-access/src/acl.rs @@ -665,6 +665,10 @@ mod test { &self.roles } + fn privileges(&self) -> &HashMap<&str, u64> { + unreachable!("acl tests don't need privileges") + } + fn role_no_access(&self) -> Option<&'static str> { Some("NoAccess") } diff --git a/proxmox-access/src/cached_user_info.rs b/proxmox-access/src/cached_user_info.rs new file mode 100644 index 00000000..e647b618 --- /dev/null +++ b/proxmox-access/src/cached_user_info.rs @@ -0,0 +1,242 @@ +//! Cached user info for fast ACL permission checks + +use std::sync::{Arc, OnceLock, RwLock}; + +use anyhow::{bail, Error}; + +use proxmox_auth_api::types::{Authid, Userid}; +use proxmox_router::UserInformation; +use proxmox_section_config::SectionConfigData; +use proxmox_time::epoch_i64; + +use crate::acl::AclTree; +use crate::init::acm_conf; +use crate::types::{ApiToken, User}; +use crate::ConfigVersionCache; + +/// Cache User/Group/Token/Acl configuration data for fast permission tests +pub struct CachedUserInfo { + user_cfg: Arc, + acl_tree: Arc, +} + +struct ConfigCache { + data: Option>, + last_update: i64, + last_user_cache_generation: usize, +} + +impl CachedUserInfo { + /// Returns a cached instance (up to 5 seconds old). + pub fn new() -> Result, Error> { + let now = epoch_i64(); + + let version_cache = ConfigVersionCache::new()?; + let user_cache_generation = version_cache.user_cache_generation(); + + static CACHED_CONFIG: OnceLock> = OnceLock::new(); + let cached_config = CACHED_CONFIG.get_or_init(|| { + RwLock::new(ConfigCache { + data: None, + last_update: 0, + last_user_cache_generation: 0, + }) + }); + + { + // limit scope + let cache = cached_config.read().unwrap(); + if (user_cache_generation == cache.last_user_cache_generation) + && ((now - cache.last_update) < 5) + { + if let Some(ref config) = cache.data { + return Ok(config.clone()); + } + } + } + + let config = Arc::new(CachedUserInfo { + user_cfg: crate::user::cached_config()?, + acl_tree: crate::acl::cached_config()?, + }); + + let mut cache = cached_config.write().unwrap(); + cache.last_update = now; + cache.last_user_cache_generation = user_cache_generation; + cache.data = Some(config.clone()); + + Ok(config) + } + + pub fn is_superuser(&self, auth_id: &Authid) -> bool { + acm_conf().is_superuser(auth_id) + } + + pub fn is_group_member(&self, user_id: &Userid, group: &str) -> bool { + acm_conf().is_group_member(user_id, group) + } + + /// Test if a user_id is enabled and not expired + pub fn is_active_user_id(&self, userid: &Userid) -> bool { + if let Ok(info) = self.user_cfg.lookup::("user", userid.as_str()) { + info.is_active() + } else { + false + } + } + + /// Test if a authentication id is enabled and not expired + pub fn is_active_auth_id(&self, auth_id: &Authid) -> bool { + let userid = auth_id.user(); + + if !self.is_active_user_id(userid) { + return false; + } + + if auth_id.is_token() { + if let Ok(info) = self + .user_cfg + .lookup::("token", &auth_id.to_string()) + { + return info.is_active(); + } else { + return false; + } + } + + true + } + + pub fn check_privs( + &self, + auth_id: &Authid, + path: &[&str], + required_privs: u64, + partial: bool, + ) -> Result<(), Error> { + let privs = self.lookup_privs(auth_id, path); + let allowed = if partial { + (privs & required_privs) != 0 + } else { + (privs & required_privs) == required_privs + }; + if !allowed { + // printing the path doesn't leak any information as long as we + // always check privilege before resource existence + let priv_names = privs_to_priv_names(required_privs); + let priv_names = if partial { + priv_names.join("|") + } else { + priv_names.join("&") + }; + bail!( + "missing permissions '{priv_names}' on '/{}'", + path.join("/") + ); + } + Ok(()) + } + + pub fn lookup_privs(&self, auth_id: &Authid, path: &[&str]) -> u64 { + let (privs, _) = self.lookup_privs_details(auth_id, path); + privs + } + + pub fn lookup_privs_details(&self, auth_id: &Authid, path: &[&str]) -> (u64, u64) { + if self.is_superuser(auth_id) { + let acm_config = acm_conf(); + if let Some(admin) = acm_config.role_admin() { + if let Some(admin) = acm_config.roles().get(admin) { + return (*admin, *admin); + } + } + } + + let roles = self.acl_tree.roles(auth_id, path); + let mut privs: u64 = 0; + let mut propagated_privs: u64 = 0; + for (role, propagate) in roles { + if let Some(role_privs) = acm_conf().roles().get(role.as_str()) { + if propagate { + propagated_privs |= role_privs; + } + privs |= role_privs; + } + } + + if auth_id.is_token() { + // limit privs to that of owning user + let user_auth_id = Authid::from(auth_id.user().clone()); + let (owner_privs, owner_propagated_privs) = + self.lookup_privs_details(&user_auth_id, path); + privs &= owner_privs; + propagated_privs &= owner_propagated_privs; + } + + (privs, propagated_privs) + } + + /// Checks whether the `auth_id` has any of the privilegs `privs` on any object below `path`. + pub fn any_privs_below( + &self, + auth_id: &Authid, + path: &[&str], + privs: u64, + ) -> Result { + // if the anchor path itself has matching propagated privs, we skip checking children + let (_privs, propagated_privs) = self.lookup_privs_details(auth_id, path); + if propagated_privs & privs != 0 { + return Ok(true); + } + + // get all sub-paths with roles defined for `auth_id` + let paths = self.acl_tree.get_child_paths(auth_id, path)?; + + for path in paths.iter() { + // early return if any sub-path has any of the privs we are looking for + if privs & self.lookup_privs(auth_id, &[path.as_str()]) != 0 { + return Ok(true); + } + } + + // no paths or no matching paths + Ok(false) + } +} + +impl UserInformation for CachedUserInfo { + fn is_superuser(&self, userid: &str) -> bool { + if let Ok(authid) = userid.parse() { + return self.is_superuser(&authid); + } + + false + } + + fn is_group_member(&self, userid: &str, group: &str) -> bool { + if let Ok(userid) = userid.parse() { + return self.is_group_member(&userid, group); + } + + false + } + + fn lookup_privs(&self, auth_id: &str, path: &[&str]) -> u64 { + match auth_id.parse::() { + Ok(auth_id) => Self::lookup_privs(self, &auth_id, path), + Err(_) => 0, + } + } +} + +pub fn privs_to_priv_names(privs: u64) -> Vec<&'static str> { + acm_conf() + .privileges() + .iter() + .fold(Vec::new(), |mut priv_names, (name, value)| { + if value & privs != 0 { + priv_names.push(name); + } + priv_names + }) +} diff --git a/proxmox-access/src/config_version_cache.rs b/proxmox-access/src/config_version_cache.rs new file mode 100644 index 00000000..62effab7 --- /dev/null +++ b/proxmox-access/src/config_version_cache.rs @@ -0,0 +1,113 @@ +use std::mem::{ManuallyDrop, MaybeUninit}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, OnceLock}; + +use anyhow::{bail, Error}; +use nix::sys::stat::Mode; + +use proxmox_product_config::default_create_options; +use proxmox_sys::fs::create_path; + +use proxmox_shared_memory::*; + +use crate::init::{shmem_file, shmem_magic}; + +#[derive(Debug)] +#[repr(C)] +struct ConfigVersionCacheDataInner { + magic: [u8; 8], + // User (user.cfg) cache generation/version. + user_cache_generation: AtomicUsize, +} + +#[repr(C)] +union ConfigVersionCacheData { + data: ManuallyDrop, + _padding: [u8; 4096], +} + +#[test] +fn assert_cache_size() { + assert_eq!(std::mem::size_of::(), 4096); +} + +impl std::ops::Deref for ConfigVersionCacheData { + type Target = ConfigVersionCacheDataInner; + + #[inline] + fn deref(&self) -> &ConfigVersionCacheDataInner { + unsafe { &self.data } + } +} + +impl std::ops::DerefMut for ConfigVersionCacheData { + #[inline] + fn deref_mut(&mut self) -> &mut ConfigVersionCacheDataInner { + unsafe { &mut self.data } + } +} + +impl Init for ConfigVersionCacheData { + fn initialize(this: &mut MaybeUninit) { + unsafe { + let me = &mut *this.as_mut_ptr(); + me.magic = *shmem_magic(); + } + } + + fn check_type_magic(this: &MaybeUninit) -> Result<(), Error> { + unsafe { + let me = &*this.as_ptr(); + if me.magic != *shmem_magic() { + bail!("ConfigVersionCache: wrong magic number"); + } + Ok(()) + } + } +} + +pub struct ConfigVersionCache { + shmem: SharedMemory, +} + +static INSTANCE: OnceLock> = OnceLock::new(); + +impl ConfigVersionCache { + /// Open the memory based communication channel singleton. + pub fn new() -> Result, Error> { + Ok(INSTANCE + .get_or_init(|| Self::open().expect("could not open config version cache!")) + .clone()) + } + + // Actual work of `new`: + fn open() -> Result, Error> { + let dir_opts = default_create_options().perm(Mode::from_bits_truncate(0o770)); + + let file_path = shmem_file(); + let dir_path = file_path.parent().unwrap(); + + create_path(dir_path, Some(dir_opts.clone()), Some(dir_opts))?; + + let file_opts = default_create_options().perm(Mode::from_bits_truncate(0o660)); + let shmem: SharedMemory = SharedMemory::open(file_path, file_opts)?; + + Ok(Arc::new(Self { shmem })) + } + + /// Returns the user cache generation number. + pub fn user_cache_generation(&self) -> usize { + self.shmem + .data() + .user_cache_generation + .load(Ordering::Acquire) + } + + /// Increase the user cache generation number. + pub fn increase_user_cache_generation(&self) { + self.shmem + .data() + .user_cache_generation + .fetch_add(1, Ordering::AcqRel); + } +} diff --git a/proxmox-access/src/init.rs b/proxmox-access/src/init.rs index 4bcfbd87..ad8584ae 100644 --- a/proxmox-access/src/init.rs +++ b/proxmox-access/src/init.rs @@ -1,4 +1,5 @@ use anyhow::{format_err, Error}; +use proxmox_auth_api::types::{Authid, Userid}; use std::{ collections::HashMap, path::{Path, PathBuf}, @@ -7,13 +8,32 @@ use std::{ static ACM_CONF: OnceLock<&'static dyn AcmConfig> = OnceLock::new(); static ACM_CONF_DIR: OnceLock = OnceLock::new(); +static SHMEM_FILE: OnceLock = OnceLock::new(); +static PROXMOX_CONFIG_VERSION_CACHE_MAGIC: OnceLock<[u8; 8]> = OnceLock::new(); /// This trait specifies the functions a product needs to implement to get ACL tree based access /// control management from this plugin. pub trait AcmConfig: Send + Sync { + /// Returns a mapping of all recognized privileges and their corresponding `u64` value. + fn privileges(&self) -> &HashMap<&str, u64>; + /// Returns a mapping of all recognized roles and their corresponding `u64` value. fn roles(&self) -> &HashMap<&str, u64>; + /// Checks whether an `Authid` has super user privileges or not. + /// + /// Default: Always returns `false`. + fn is_superuser(&self, _auth_id: &Authid) -> bool { + false + } + + /// Checks whether a user is part of a group. + /// + /// Default: Always returns `false`. + fn is_group_member(&self, _user_id: &Userid, _group: &str) -> bool { + false + } + /// Optionally returns a role that has no access to any resource. /// /// Default: Returns `None`. @@ -32,9 +52,12 @@ pub trait AcmConfig: Send + Sync { pub fn init>( acm_config: &'static dyn AcmConfig, config_dir: P, + shmem_path: P, + product_name: &str, ) -> Result<(), Error> { init_acm_config(acm_config)?; - init_acm_config_dir(config_dir) + init_acm_config_dir(config_dir)?; + init_shmem_cache(shmem_path, product_name) } pub fn init_acm_config_dir>(config_dir: P) -> Result<(), Error> { @@ -49,6 +72,23 @@ pub(crate) fn init_acm_config(config: &'static dyn AcmConfig) -> Result<(), Erro .map_err(|_e| format_err!("cannot initialize acl tree config twice!")) } +fn init_shmem_cache>(shmem_path: P, product_name: &str) -> Result<(), Error> { + let magic = openssl::sha::sha256(product_name.as_bytes()); + + PROXMOX_CONFIG_VERSION_CACHE_MAGIC + .set( + magic[0..8] + .try_into() + // this will never fail because sha256() always returns a [u8, 32] from which we + // can always get a [u8, 8]. + .unwrap(), + ) + .map_err(|_e| format_err!("shared memory cache cannot be initialized twice!"))?; + + SHMEM_FILE + .set(shmem_path.as_ref().to_owned()) + .map_err(|_e| format_err!("shared memory cache cannot be initialized twice!")) +} pub(crate) fn acm_conf() -> &'static dyn AcmConfig { *ACM_CONF @@ -56,6 +96,17 @@ pub(crate) fn acm_conf() -> &'static dyn AcmConfig { .expect("please initialize the acm config before using it!") } +pub(crate) fn shmem_magic() -> &'static [u8; 8] { + PROXMOX_CONFIG_VERSION_CACHE_MAGIC + .get() + .expect("shared memory cache magic wasn't initialized before using it!") +} + +pub(crate) fn shmem_file() -> &'static PathBuf { + SHMEM_FILE + .get() + .expect("shared memory cache magic wasn't initialized before using it!") +} fn conf_dir() -> &'static PathBuf { ACM_CONF_DIR @@ -71,6 +122,14 @@ pub(crate) fn acl_config_lock() -> PathBuf { conf_dir().with_file_name(".acl.lck") } +pub(crate) fn user_config() -> PathBuf { + conf_dir().with_file_name("user.cfg") +} + +pub(crate) fn user_config_lock() -> PathBuf { + conf_dir().with_file_name(".user.lck") +} + pub(crate) fn token_shadow() -> PathBuf { conf_dir().with_file_name("token.shadow") } diff --git a/proxmox-access/src/lib.rs b/proxmox-access/src/lib.rs index 524b0e60..7d3af46c 100644 --- a/proxmox-access/src/lib.rs +++ b/proxmox-access/src/lib.rs @@ -2,3 +2,10 @@ pub mod acl; pub mod init; pub mod token_shadow; pub mod types; +pub mod user; + +mod cached_user_info; +pub use cached_user_info::CachedUserInfo; + +mod config_version_cache; +pub use config_version_cache::ConfigVersionCache; diff --git a/proxmox-access/src/user.rs b/proxmox-access/src/user.rs new file mode 100644 index 00000000..c2263c9e --- /dev/null +++ b/proxmox-access/src/user.rs @@ -0,0 +1,182 @@ +use std::collections::HashMap; +use std::sync::{Arc, OnceLock, RwLock}; + +use anyhow::{bail, Error}; + +use proxmox_auth_api::types::Authid; +use proxmox_product_config::{open_api_lockfile, replace_privileged_config, ApiLockGuard}; +use proxmox_schema::*; +use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin}; + +use crate::init::{user_config, user_config_lock}; +use crate::types::{ApiToken, User}; + +fn get_or_init_config() -> &'static SectionConfig { + static CONFIG: OnceLock = OnceLock::new(); + CONFIG.get_or_init(|| { + let mut config = SectionConfig::new(&Authid::API_SCHEMA); + + let user_schema = match User::API_SCHEMA { + Schema::Object(ref user_schema) => user_schema, + _ => unreachable!(), + }; + let user_plugin = + SectionConfigPlugin::new("user".to_string(), Some("userid".to_string()), user_schema); + config.register_plugin(user_plugin); + + let token_schema = match ApiToken::API_SCHEMA { + Schema::Object(ref token_schema) => token_schema, + _ => unreachable!(), + }; + let token_plugin = SectionConfigPlugin::new( + "token".to_string(), + Some("tokenid".to_string()), + token_schema, + ); + config.register_plugin(token_plugin); + + config + }) +} + +/// Get exclusive lock +pub fn lock_config() -> Result { + open_api_lockfile(user_config_lock(), None, true) +} + +pub fn config() -> Result<(SectionConfigData, [u8; 32]), Error> { + let content = proxmox_sys::fs::file_read_optional_string(user_config())?.unwrap_or_default(); + + let digest = openssl::sha::sha256(content.as_bytes()); + let data = get_or_init_config().parse(user_config(), &content)?; + + Ok((data, digest)) +} + +pub fn cached_config() -> Result, Error> { + struct ConfigCache { + data: Option>, + last_mtime: i64, + last_mtime_nsec: i64, + } + + static CACHED_CONFIG: OnceLock> = OnceLock::new(); + let cached_config = CACHED_CONFIG.get_or_init(|| { + RwLock::new(ConfigCache { + data: None, + last_mtime: 0, + last_mtime_nsec: 0, + }) + }); + + let stat = match nix::sys::stat::stat(&user_config()) { + Ok(stat) => Some(stat), + Err(nix::errno::Errno::ENOENT) => None, + Err(err) => bail!("unable to stat '{}' - {err}", user_config().display()), + }; + + { + // limit scope + let cache = cached_config.read().unwrap(); + if let Some(ref config) = cache.data { + if let Some(stat) = stat { + if stat.st_mtime == cache.last_mtime && stat.st_mtime_nsec == cache.last_mtime_nsec + { + return Ok(config.clone()); + } + } else if cache.last_mtime == 0 && cache.last_mtime_nsec == 0 { + return Ok(config.clone()); + } + } + } + + let (config, _digest) = config()?; + let config = Arc::new(config); + + let mut cache = cached_config.write().unwrap(); + if let Some(stat) = stat { + cache.last_mtime = stat.st_mtime; + cache.last_mtime_nsec = stat.st_mtime_nsec; + } + cache.data = Some(config.clone()); + + Ok(config) +} + +pub fn save_config(config: &SectionConfigData) -> Result<(), Error> { + let config_file = user_config(); + let raw = get_or_init_config().write(&config_file, config)?; + replace_privileged_config(config_file, raw.as_bytes())?; + + // increase user version + // We use this in CachedUserInfo + let version_cache = crate::ConfigVersionCache::new()?; + version_cache.increase_user_cache_generation(); + + Ok(()) +} + +/// Only exposed for testing +#[doc(hidden)] +pub fn test_cfg_from_str(raw: &str) -> Result<(SectionConfigData, [u8; 32]), Error> { + let cfg = get_or_init_config(); + let parsed = cfg.parse("test_user_cfg", raw)?; + + Ok((parsed, [0; 32])) +} + +// shell completion helper +pub fn complete_userid(_arg: &str, _param: &HashMap) -> Vec { + match config() { + Ok((data, _digest)) => data + .sections + .iter() + .filter_map(|(id, (section_type, _))| { + if section_type == "user" { + Some(id.to_string()) + } else { + None + } + }) + .collect(), + Err(_) => Vec::new(), + } +} + +// shell completion helper +pub fn complete_authid(_arg: &str, _param: &HashMap) -> Vec { + match config() { + Ok((data, _digest)) => data.sections.keys().map(|id| id.to_string()).collect(), + Err(_) => vec![], + } +} + +// shell completion helper +pub fn complete_token_name(_arg: &str, param: &HashMap) -> Vec { + let data = match config() { + Ok((data, _digest)) => data, + Err(_) => return Vec::new(), + }; + + match param.get("userid") { + Some(userid) => { + let user = data.lookup::("user", userid); + let tokens = data.convert_to_typed_array("token"); + match (user, tokens) { + (Ok(_), Ok(tokens)) => tokens + .into_iter() + .filter_map(|token: ApiToken| { + let tokenid = token.tokenid; + if tokenid.is_token() && tokenid.user() == userid { + Some(tokenid.tokenname().unwrap().as_str().to_string()) + } else { + None + } + }) + .collect(), + _ => vec![], + } + } + None => vec![], + } +} -- 2.39.2 _______________________________________________ pbs-devel mailing list pbs-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel