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 4A3111FF0AF for ; Fri, 06 Nov 2026 18:24:35 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 807942172D; Fri, 06 Nov 2026 18:23:39 +0100 (CET) From: Christian Ebner To: pbs-devel@lists.proxmox.com Subject: [PATCH proxmox-backup v2 29/43] datastore/config: parse and enforce maximum retention timespan Date: Fri, 6 Nov 2026 18:22:21 +0100 Message-ID: <20261106172235.890542-30-c.ebner@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261106172235.890542-1-c.ebner@proxmox.com> References: <20261106172235.890542-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: 1793985778775 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.450 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 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: YOJTHZZFUPWRSITBEB7REFJJDGDJYKHL X-Message-ID-Hash: YOJTHZZFUPWRSITBEB7REFJJDGDJYKHL 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 | 34 +++++++++++++++++++++++++++++++++- src/api2/backup/mod.rs | 4 ++++ src/api2/config/datastore.rs | 9 +++++++++ src/server/pull.rs | 13 +++++++++---- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/pbs-datastore/src/datastore.rs b/pbs-datastore/src/datastore.rs index d5608d4da..c8f14c5d0 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, DEFAULT_SNAPSHOT_TIME_SKEW_SECS, DataStoreConfig, DatastoreBackendConfig, DatastoreBackendType, GarbageCollectionCacheStats, GarbageCollectionStatus, MAX_NAMESPACE_DEPTH, MaintenanceMode, - MaintenanceType, Operation, S3Statistics, UPID, + MaintenanceType, Operation, RetentionTimespan, S3Statistics, UPID, }; use pbs_config::s3::S3_CFG_TYPE_ID; use pbs_config::{BackupLockGuard, ConfigVersionCache}; @@ -213,6 +213,7 @@ pub struct DataStoreImpl { request_counters: Option>, thresholds_exceeded_callback: Option, max_snapshot_time_skew: i64, + max_retention_timespan: Option, } impl DataStoreImpl { @@ -232,6 +233,7 @@ impl DataStoreImpl { request_counters: None, thresholds_exceeded_callback: None, max_snapshot_time_skew: i64::try_from(DEFAULT_SNAPSHOT_TIME_SKEW_SECS).unwrap(), + max_retention_timespan: None, }) } } @@ -814,6 +816,7 @@ impl DataStore { request_counters, thresholds_exceeded_callback, max_snapshot_time_skew, + max_retention_timespan: config.max_retention_timespan, }) } @@ -859,6 +862,35 @@ impl DataStore { Ok(()) } + /// Return the configured maximum retention timespan + pub fn max_retention_timespan(&self) -> Option<&RetentionTimespan> { + self.inner.max_retention_timespan.as_ref() + } + + /// Check if the given retention timestamp is contained within the maximum + /// retention timespan relative to current system allowed by the datastore. + /// + /// Fails if there is no maximum retention configured for this datastore. + pub fn check_max_retention(&self, retain_until: i64) -> Result<(), Error> { + if retain_until <= proxmox_time::epoch_i64() { + bail!("retention timestamp must be in the future"); + } + if let Some(max_retention) = self.max_retention_timespan() { + let max_timestamp = max_retention + .to_timestamp_from_systemtime()? + .saturating_add(self.inner.max_snapshot_time_skew); + if retain_until > max_timestamp { + bail!( + "retention timestamp '{}' exceeds datastore limit of '{}'", + proxmox_time::epoch_to_rfc3339_utc(retain_until)?, + proxmox_time::epoch_to_rfc3339_utc(max_timestamp)?, + ); + } + return Ok(()); + } + bail!("no maximum retention timespan configured for datastore"); + } + // 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 7d2236c9e..a057340ca 100644 --- a/src/api2/backup/mod.rs +++ b/src/api2/backup/mod.rs @@ -157,6 +157,10 @@ fn upgrade_to_backup_protocol( "backup" }; + if let Some(retain_until) = retain_until { + datastore.check_max_retention(retain_until)?; + } + // 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 350e48acc..03ff19fc3 100644 --- a/src/api2/config/datastore.rs +++ b/src/api2/config/datastore.rs @@ -442,6 +442,8 @@ pub enum DeletableProperty { CounterResetSchedule, /// Delete the maximum snapshot time skew property. MaxSnapshotTimeSkew, + /// Delete the max-retention-timespan + MaxRetentionTimespan, } #[api( @@ -550,6 +552,9 @@ pub fn update_datastore( DeletableProperty::MaxSnapshotTimeSkew => { data.max_snapshot_time_skew = None; } + DeletableProperty::MaxRetentionTimespan => { + data.max_retention_timespan = None; + } } } } @@ -655,6 +660,10 @@ pub fn update_datastore( data.max_snapshot_time_skew = update.max_snapshot_time_skew; } + 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 c1531ebda..44347c3ad 100644 --- a/src/server/pull.rs +++ b/src/server/pull.rs @@ -139,13 +139,18 @@ 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()) - .transpose()?; + let retain_until = if let Some(retention) = retention_timespan { + let retain_until = retention.to_timestamp_from_systemtime()?; + (*store).check_max_retention(retain_until)?; + Some(retain_until) + } else { + None + }; + + 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()); -- 2.47.3