From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id CC3621FF0E7 for ; Thu, 13 Aug 2026 19:11:42 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 7665D21B8C; Thu, 13 Aug 2026 19:11:15 +0200 (CEST) From: Christian Ebner To: pbs-devel@lists.proxmox.com Subject: [PATCH proxmox-backup 25/28] datastore/config: parse and enforce maximum retention timespan Date: Thu, 13 Aug 2026 19:09:59 +0200 Message-ID: <20260813171002.809441-26-c.ebner@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260813171002.809441-1-c.ebner@proxmox.com> References: <20260813171002.809441-1-c.ebner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1786641018545 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.193 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust RDNS_NONE 1.274 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: QUGQYLDWGCEJZ3IOMD6F3Y4AFT6RETFN X-Message-ID-Hash: QUGQYLDWGCEJZ3IOMD6F3Y4AFT6RETFN X-MailFrom: c.ebner@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Backup Server development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Allows to limit the maximum retention timespan which can be set on snapshots, so users cannot block contents for unintended extended periods of time. Signed-off-by: Christian Ebner --- pbs-datastore/src/datastore.rs | 10 +++++++++- src/api2/backup/mod.rs | 9 +++++++++ src/api2/config/datastore.rs | 9 +++++++++ src/server/pull.rs | 13 +++++++++++-- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/pbs-datastore/src/datastore.rs b/pbs-datastore/src/datastore.rs index c3db52269..cf68efe9a 100644 --- a/pbs-datastore/src/datastore.rs +++ b/pbs-datastore/src/datastore.rs @@ -34,7 +34,7 @@ use pbs_api_types::{ ArchiveType, Authid, BackupGroupDeleteStats, BackupNamespace, BackupType, ChunkOrder, DataStoreConfig, DatastoreBackendConfig, DatastoreBackendType, GarbageCollectionCacheStats, GarbageCollectionStatus, MAX_NAMESPACE_DEPTH, MaintenanceMode, MaintenanceType, Operation, - S3Statistics, UPID, + RetentionTimespan, S3Statistics, UPID, }; use pbs_config::s3::S3_CFG_TYPE_ID; use pbs_config::{BackupLockGuard, ConfigVersionCache}; @@ -212,6 +212,7 @@ pub struct DataStoreImpl { config_generation: Option, request_counters: Option>, thresholds_exceeded_callback: Option, + max_retention_timespan: Option, } impl DataStoreImpl { @@ -230,6 +231,7 @@ impl DataStoreImpl { config_generation: None, request_counters: None, thresholds_exceeded_callback: None, + max_retention_timespan: None, }) } } @@ -804,6 +806,7 @@ impl DataStore { config_generation: generation, request_counters, thresholds_exceeded_callback, + max_retention_timespan: config.max_retention_timespan, }) } @@ -849,6 +852,11 @@ impl DataStore { Ok(()) } + /// Return the configured maximum retenition timespan + pub fn max_retention_timespan(&self) -> Option<&RetentionTimespan> { + self.inner.max_retention_timespan.as_ref() + } + // Requires obtaining a shared chunk store lock beforehand pub fn create_fixed_writer>( &self, diff --git a/src/api2/backup/mod.rs b/src/api2/backup/mod.rs index cfce13943..a6c06f8ff 100644 --- a/src/api2/backup/mod.rs +++ b/src/api2/backup/mod.rs @@ -156,6 +156,15 @@ fn upgrade_to_backup_protocol( "backup" }; + if let Some(retain_until) = &retain_until { + if let Some(max_timespan) = datastore.max_retention_timespan() { + let max_timestamp = max_timespan.to_timestamp_from_systemtime()?; + if *retain_until > max_timestamp { + bail!("requested retention timestamp exceeds datastore limit") + } + } + } + // lock backup group to only allow one backup per group at a time let (owner, group_guard) = datastore.create_locked_backup_group( backup_group.backup_ns(), diff --git a/src/api2/config/datastore.rs b/src/api2/config/datastore.rs index e7028480c..347d0b9b6 100644 --- a/src/api2/config/datastore.rs +++ b/src/api2/config/datastore.rs @@ -440,6 +440,8 @@ pub enum DeletableProperty { NotificationThresholds, /// Delete the counter reset schedule. CounterResetSchedule, + /// Delete the max-retention-timespan + MaxRetentionTimespan, } #[api( @@ -545,6 +547,9 @@ pub fn update_datastore( DeletableProperty::CounterResetSchedule => { data.counter_reset_schedule = None; } + DeletableProperty::MaxRetentionTimespan => { + data.max_retention_timespan = None; + } } } } @@ -646,6 +651,10 @@ pub fn update_datastore( data.counter_reset_schedule = update.counter_reset_schedule; } + if update.max_retention_timespan.is_some() { + data.max_retention_timespan = update.max_retention_timespan; + } + config.set_data(&name, "datastore", &data)?; pbs_config::datastore::save_config(&config)?; diff --git a/src/server/pull.rs b/src/server/pull.rs index 893b642d4..bafb1fe21 100644 --- a/src/server/pull.rs +++ b/src/server/pull.rs @@ -140,14 +140,23 @@ impl PullParameters { let lookup = crate::tools::lookup_with(store, Operation::Write); let store = DataStore::lookup_datastore(lookup)?; let backend = store.backend()?; - let target = PullTarget { store, ns, backend }; let group_filter = group_filter.unwrap_or_default(); let retain_until = retention_timespan - .map(|timespan| timespan.to_timestamp_from_systemtime()) + .map(|timespan| { + if let Some(max_timespan) = (*store).max_retention_timespan() { + // sub-second precision not permitted by api type, fine to ignore + if timespan.as_timespan().as_secs() > max_timespan.as_timespan().as_secs() { + bail!("provided timespan '{timespan}' exceeds datastore limit of '{max_timespan}'"); + } + } + timespan.to_timestamp_from_systemtime() + }) .transpose()?; + let target = PullTarget { store, ns, backend }; + let crypt_configs = if let Some(key_ids) = &decryption_keys { let mut crypt_configs = Vec::with_capacity(key_ids.len()); for key_id in key_ids { -- 2.47.3