From: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
To: "Proxmox Backup Server development discussion"
<pbs-devel@lists.proxmox.com>,
"Fabian Grünbichler" <f.gruenbichler@proxmox.com>
Subject: Re: [pbs-devel] [PATCH proxmox-backup v3 3/4] pbs-config: invalidate token-secret cache on token.shadow changes
Date: Tue, 20 Jan 2026 10:21:57 +0100 [thread overview]
Message-ID: <521b901c-e264-4ba9-8adb-2a5b05e89dcc@proxmox.com> (raw)
In-Reply-To: <1768385989.rl12driouk.astroid@yuna.none>
On 1/14/26 11:44 AM, Fabian Grünbichler wrote:
> On January 2, 2026 5:07 pm, Samuel Rufinatscha wrote:
>> Previously the in-memory token-secret cache was only updated via
>> set_secret() and delete_secret(), so manual edits to token.shadow were
>> not reflected.
>>
>> This patch adds file change detection to the cache. It tracks the mtime
>> and length of token.shadow and clears the in-memory token secret cache
>> whenever these values change.
>>
>> Note, this patch fetches file stats on every request. An TTL-based
>> optimization will be covered in a subsequent patch of the series.
>>
>> This patch is part of the series which fixes bug #7017 [1].
>>
>> [1] https://bugzilla.proxmox.com/show_bug.cgi?id=7017
>>
>> Signed-off-by: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
>> ---
>> Changes from v1 to v2:
>>
>> * Add file metadata tracking (file_mtime, file_len) and
>> FILE_GENERATION.
>> * Store file_gen in CachedSecret and verify it against the current
>> FILE_GENERATION to ensure cached entries belong to the current file
>> state.
>> * Add shadow_mtime_len() helper and convert refresh to best-effort
>> (try_write, returns bool).
>> * Pass a pre-write metadata snapshot into apply_api_mutation and
>> clear/bump generation if the cache metadata indicates missed external
>> edits.
>>
>> Changes from v2 to v3:
>>
>> * Cache now tracks last_checked (epoch seconds).
>> * Simplified refresh_cache_if_file_changed, removed
>> FILE_GENERATION logic
>> * On first load, initializes file metadata and keeps empty cache.
>>
>> pbs-config/src/token_shadow.rs | 122 +++++++++++++++++++++++++++++++--
>> 1 file changed, 118 insertions(+), 4 deletions(-)
>>
>> diff --git a/pbs-config/src/token_shadow.rs b/pbs-config/src/token_shadow.rs
>> index fa84aee5..02fb191b 100644
>> --- a/pbs-config/src/token_shadow.rs
>> +++ b/pbs-config/src/token_shadow.rs
>> @@ -1,5 +1,8 @@
>> use std::collections::HashMap;
>> +use std::fs;
>> +use std::io::ErrorKind;
>> use std::sync::LazyLock;
>> +use std::time::SystemTime;
>>
>> use anyhow::{bail, format_err, Error};
>> use parking_lot::RwLock;
>> @@ -7,6 +10,7 @@ use serde::{Deserialize, Serialize};
>> use serde_json::{from_value, Value};
>>
>> use proxmox_sys::fs::CreateOptions;
>> +use proxmox_time::epoch_i64;
>>
>> use pbs_api_types::Authid;
>> //use crate::auth;
>> @@ -24,6 +28,9 @@ static TOKEN_SECRET_CACHE: LazyLock<RwLock<ApiTokenSecretCache>> = LazyLock::new
>> RwLock::new(ApiTokenSecretCache {
>> secrets: HashMap::new(),
>> shared_gen: 0,
>> + file_mtime: None,
>> + file_len: None,
>> + last_checked: None,
>> })
>> });
>>
>> @@ -62,6 +69,63 @@ fn write_file(data: HashMap<Authid, String>) -> Result<(), Error> {
>> proxmox_sys::fs::replace_file(CONF_FILE, &json, options, true)
>> }
>>
>> +/// Refreshes the in-memory cache if the on-disk token.shadow file changed.
>> +/// Returns true if the cache is valid to use, false if not.
>> +fn refresh_cache_if_file_changed() -> bool {
>> + let now = epoch_i64();
>> +
>> + // Best-effort refresh under write lock.
>> + let Some(mut cache) = TOKEN_SECRET_CACHE.try_write() else {
>> + return false;
>> + };
>> +
>> + let Some(shared_gen_now) = token_shadow_shared_gen() else {
>> + return false;
>> + };
>> +
>> + // If another process bumped the generation, we don't know what changed -> clear cache
>> + if cache.shared_gen != shared_gen_now {
>> + invalidate_cache_state(&mut cache);
>> + cache.shared_gen = shared_gen_now;
>> + }
>> +
>> + // Stat the file to detect manual edits.
>> + let Ok((new_mtime, new_len)) = shadow_mtime_len() else {
>> + return false;
>> + };
>> +
>> + // Initialize file stats if we have no prior state.
>> + if cache.last_checked.is_none() {
>> + cache.secrets.clear(); // ensure cache is empty on first load
>> + cache.file_mtime = new_mtime;
>> + cache.file_len = new_len;
>> + cache.last_checked = Some(now);
>> + return true;
>
> this code here
>
>> + }
>> +
>> + // No change detected.
>> + if cache.file_mtime == new_mtime && cache.file_len == new_len {
>> + cache.last_checked = Some(now);
>> + return true;
>> + }
>> +
>> + // Manual edit detected -> invalidate cache and update stat.
>> + cache.secrets.clear();
>> + cache.file_mtime = new_mtime;
>> + cache.file_len = new_len;
>> + cache.last_checked = Some(now);
>
> and this code here are identical. if this is the first invocation, then
> the change detection check above cannot be true (the cached mtime and
> len will be None).
>
> so we can drop the first if above, and replace the last line in this
> hunk with
>
> let prev_last_checked = cache.last_checked.replace(Some(now));
>
> and then skip bumping the generation if this is_none()
Great idea about the .replace()! Integrating it with the new
ShadowFileInfo :)
>
> OTOH, if we just cleared the cache here, does it make sense to return
> true? the cache is empty, so likely querying it *now* makes no sense?
Agree, we should just return false here
>
>> +
>> + // Best-effort propagation to other processes + update local view.
>> + if let Some(shared_gen_new) = bump_token_shadow_shared_gen() {
>> + cache.shared_gen = shared_gen_new;
>> + } else {
>> + // Do not fail: local cache is already safe as we cleared it above.
>> + // Keep local shared_gen as-is to avoid repeated failed attempts.
>> + }
>> +
>> + true
>> +}
>> +
>> /// Verifies that an entry for given tokenid / API token secret exists
>> pub fn verify_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
>> if !tokenid.is_token() {
>> @@ -69,7 +133,7 @@ pub fn verify_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
>> }
>>
>> // Fast path
>> - if cache_try_secret_matches(tokenid, secret) {
>> + if refresh_cache_if_file_changed() && cache_try_secret_matches(tokenid, secret) {
>> return Ok(());
>> }
>>
>> @@ -109,12 +173,15 @@ fn set_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
>>
>> let _guard = lock_config()?;
>>
>> + // Capture state before we write to detect external edits.
>> + let pre_meta = shadow_mtime_len().unwrap_or((None, None));
>> +
>> let mut data = read_file()?;
>> let hashed_secret = proxmox_sys::crypt::encrypt_pw(secret)?;
>> data.insert(tokenid.clone(), hashed_secret);
>> write_file(data)?;
>>
>> - apply_api_mutation(tokenid, Some(secret));
>> + apply_api_mutation(tokenid, Some(secret), pre_meta);
>>
>> Ok(())
>> }
>> @@ -127,11 +194,14 @@ pub fn delete_secret(tokenid: &Authid) -> Result<(), Error> {
>>
>> let _guard = lock_config()?;
>>
>> + // Capture state before we write to detect external edits.
>> + let pre_meta = shadow_mtime_len().unwrap_or((None, None));
>> +
>> let mut data = read_file()?;
>> data.remove(tokenid);
>> write_file(data)?;
>>
>> - apply_api_mutation(tokenid, None);
>> + apply_api_mutation(tokenid, None, pre_meta);
>>
>> Ok(())
>> }
>> @@ -145,6 +215,12 @@ struct ApiTokenSecretCache {
>> secrets: HashMap<Authid, CachedSecret>,
>> /// Shared generation to detect mutations of the underlying token.shadow file.
>> shared_gen: usize,
>> + // shadow file mtime to detect changes
>> + file_mtime: Option<SystemTime>,
>> + // shadow file length to detect changes
>> + file_len: Option<u64>,
>> + // last time the file metadata was checked
>> + last_checked: Option<i64>,
>
> these three are always set together, so wouldn't it make more sense to
> make them an Option<ShadowFileInfo> ?
>
>> }
>>
>> /// Cached secret.
>> @@ -204,7 +280,13 @@ fn cache_try_secret_matches(tokenid: &Authid, secret: &str) -> bool {
>> eq && gen2 == cache_gen
>> }
>>
>> -fn apply_api_mutation(tokenid: &Authid, new_secret: Option<&str>) {
>> +fn apply_api_mutation(
>> + tokenid: &Authid,
>> + new_secret: Option<&str>,
>> + pre_write_meta: (Option<SystemTime>, Option<u64>),
>> +) {
>> + let now = epoch_i64();
>> +
>> // Signal cache invalidation to other processes (best-effort).
>> let new_shared_gen = bump_token_shadow_shared_gen();
>>
>> @@ -220,6 +302,13 @@ fn apply_api_mutation(tokenid: &Authid, new_secret: Option<&str>) {
>> // Update to the post-mutation generation.
>> cache.shared_gen = gen;
>>
>> + // If our cached file metadata does not match the on-disk state before our write,
>> + // we likely missed an external/manual edit. We can no longer trust any cached secrets.
>> + let (pre_mtime, pre_len) = pre_write_meta;
>> + if cache.file_mtime != pre_mtime || cache.file_len != pre_len {
>> + cache.secrets.clear();
>> + }
>> +
>> // Apply the new mutation.
>> match new_secret {
>> Some(secret) => {
>> @@ -234,6 +323,20 @@ fn apply_api_mutation(tokenid: &Authid, new_secret: Option<&str>) {
>> cache.secrets.remove(tokenid);
>> }
>> }
>> +
>> + // Update our view of the file metadata to the post-write state (best-effort).
>> + // (If this fails, drop local cache so callers fall back to slow path until refreshed.)
>> + match shadow_mtime_len() {
>> + Ok((mtime, len)) => {
>> + cache.file_mtime = mtime;
>> + cache.file_len = len;
>> + cache.last_checked = Some(now);
>> + }
>> + Err(_) => {
>> + // If we cannot validate state, do not trust cache.
>> + invalidate_cache_state(&mut cache);
>> + }
>> + }
>> }
>>
>> /// Get the current shared generation.
>> @@ -253,4 +356,15 @@ fn bump_token_shadow_shared_gen() -> Option<usize> {
>> /// Invalidates the cache state and only keeps the shared generation.
>> fn invalidate_cache_state(cache: &mut ApiTokenSecretCache) {
>> cache.secrets.clear();
>> + cache.file_mtime = None;
>> + cache.file_len = None;
>> + cache.last_checked = None;
>> +}
>> +
>> +fn shadow_mtime_len() -> Result<(Option<SystemTime>, Option<u64>), Error> {
>> + match fs::metadata(CONF_FILE) {
>> + Ok(meta) => Ok((meta.modified().ok(), Some(meta.len()))),
>> + Err(e) if e.kind() == ErrorKind::NotFound => Ok((None, None)),
>> + Err(e) => Err(e.into()),
>> + }
>> }
>> --
>> 2.47.3
>>
>>
>>
>> _______________________________________________
>> pbs-devel mailing list
>> pbs-devel@lists.proxmox.com
>> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel
>>
>>
>>
>
>
> _______________________________________________
> pbs-devel mailing list
> pbs-devel@lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel
>
>
_______________________________________________
pbs-devel mailing list
pbs-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel
next prev parent reply other threads:[~2026-01-20 9:21 UTC|newest]
Thread overview: 28+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-01-02 16:07 [pbs-devel] [PATCH proxmox{-backup, , -datacenter-manager} v3 00/10] token-shadow: reduce api token verification overhead Samuel Rufinatscha
2026-01-02 16:07 ` [pbs-devel] [PATCH proxmox-backup v3 1/4] pbs-config: add token.shadow generation to ConfigVersionCache Samuel Rufinatscha
2026-01-14 10:44 ` Fabian Grünbichler
2026-01-16 13:53 ` Samuel Rufinatscha
2026-01-02 16:07 ` [pbs-devel] [PATCH proxmox-backup v3 2/4] pbs-config: cache verified API token secrets Samuel Rufinatscha
2026-01-14 10:44 ` Fabian Grünbichler
2026-01-16 15:13 ` Samuel Rufinatscha
2026-01-16 15:29 ` Fabian Grünbichler
2026-01-16 15:33 ` Samuel Rufinatscha
2026-01-16 16:00 ` Fabian Grünbichler
2026-01-16 16:56 ` Samuel Rufinatscha
2026-01-02 16:07 ` [pbs-devel] [PATCH proxmox-backup v3 3/4] pbs-config: invalidate token-secret cache on token.shadow changes Samuel Rufinatscha
2026-01-14 10:44 ` Fabian Grünbichler
2026-01-20 9:21 ` Samuel Rufinatscha [this message]
2026-01-02 16:07 ` [pbs-devel] [PATCH proxmox-backup v3 4/4] pbs-config: add TTL window to token secret cache Samuel Rufinatscha
2026-01-02 16:07 ` [pbs-devel] [PATCH proxmox v3 1/4] proxmox-access-control: extend AccessControlConfig for token.shadow invalidation Samuel Rufinatscha
2026-01-02 16:07 ` [pbs-devel] [PATCH proxmox v3 2/4] proxmox-access-control: cache verified API token secrets Samuel Rufinatscha
2026-01-02 16:07 ` [pbs-devel] [PATCH proxmox v3 3/4] proxmox-access-control: invalidate token-secret cache on token.shadow changes Samuel Rufinatscha
2026-01-02 16:07 ` [pbs-devel] [PATCH proxmox v3 4/4] proxmox-access-control: add TTL window to token secret cache Samuel Rufinatscha
2026-01-02 16:07 ` [pbs-devel] [PATCH proxmox-datacenter-manager v3 1/2] pdm-config: implement token.shadow generation Samuel Rufinatscha
2026-01-14 10:45 ` Fabian Grünbichler
2026-01-16 16:28 ` Samuel Rufinatscha
2026-01-16 16:48 ` Shannon Sterz
2026-01-19 7:56 ` Samuel Rufinatscha
2026-01-02 16:07 ` [pbs-devel] [PATCH proxmox-datacenter-manager v3 2/2] docs: document API token-cache TTL effects Samuel Rufinatscha
2026-01-14 10:45 ` Fabian Grünbichler
2026-01-14 11:24 ` Samuel Rufinatscha
2026-01-21 15:15 ` [pbs-devel] superseded: [PATCH proxmox{-backup, , -datacenter-manager} v3 00/10] token-shadow: reduce api token verification overhead Samuel Rufinatscha
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=521b901c-e264-4ba9-8adb-2a5b05e89dcc@proxmox.com \
--to=s.rufinatscha@proxmox.com \
--cc=f.gruenbichler@proxmox.com \
--cc=pbs-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox