Skip to content

Commit 7ebc4be

Browse files
author
Paul C
committed
v24.55.2: cluster-join secret reconciliation + alert-toggle enforcement + backup folder fixes
Cluster join (closes "added a node but its containers don't show"): - The admin+token-authenticated join-handshake now mints a single-use, TLS-only, 120s bootstrap grant; the master presents it on the secret push, so a node holding its OWN secret can adopt the fleet secret right after joining. Without it the push failed inter-node auth, the node stayed divergent, and its resources never appeared. Grant checked ONLY when normal cluster-auth fails (existing pushes byte-identical), single-use, length- capped, audited; never emitted as null. (Two security reviews; findings fixed.) Alerts (wabil — honor the toggles, end-to-end): - Settings→Alerts event toggles are now ENFORCED: predictive::threshold and predictive::container_memory honour alert_cpu / alert_memory / alert_disk / alert_containers (default-true → existing installs unchanged; unchecking now actually suppresses and existing Inbox findings auto-resolve). - The alerts save handler was silently dropping alert_containers + container_memory_threshold, so unchecking "Container memory alert" reverted to on after Save — now persisted. Backups (wabil): - System-folder backup filenames now include a short hash of the FULL source path, so two folders that differ only by case ("temp" vs "Temp") — or share a basename under different parents — no longer collide and overwrite each other on a case-insensitive backup destination (SMB/exFAT/APFS). - PBS file-level that can't apply to a target (Proxmox-LXC block-storage rootfs, VMs, config) now records an explicit reason in the backup's log + details instead of silently falling back to a tarball. Golden Rule: all new option fields default to prior behavior; the join grant is additive (existing secret pushes unchanged). 1311 tests pass.
1 parent 1c70dfd commit 7ebc4be

8 files changed

Lines changed: 235 additions & 18 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfstack"
3-
version = "24.55.1"
3+
version = "24.55.2"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

src/api/mod.rs

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,12 @@ pub struct AppState {
243243
/// In-memory, 15-minute TTL, single-use. The static per-install
244244
/// `join_token` above still works alongside these for backward compat.
245245
pub one_time_join_tokens: Arc<crate::cluster_join::OneTimeTokens>,
246+
/// Single-use bootstrap grants minted by THIS node's join-handshake on
247+
/// success. The joining master presents one back on /api/cluster/secret/
248+
/// receive, letting a node that currently holds a DIFFERENT secret adopt
249+
/// the fleet secret right after an admin-authenticated join (closes the
250+
/// "added a node but its containers don't show" gap). Consumed on use.
251+
pub bootstrap_grants: Arc<crate::cluster_join::OneTimeTokens>,
246252
pub pbs_restore_progress: std::sync::Mutex<PbsRestoreProgress>,
247253
pub ai_agent: Arc<crate::ai::AiAgent>,
248254
/// Pre-built agent status response, updated every 2s by background task
@@ -3327,12 +3333,26 @@ pub async fn cluster_join_handshake(
33273333
// via `take`, and was reinserted on every failure path above — so a
33283334
// success here means it is correctly single-use with no consume race.
33293335

3330-
HttpResponse::Ok().json(serde_json::json!({
3336+
// Mint a single-use bootstrap grant. The master presents it on the
3337+
// immediately-following /api/cluster/secret/receive push so the fleet
3338+
// secret can be adopted even though we currently hold a DIFFERENT secret
3339+
// (otherwise that push fails inter-node auth and we stay divergent — our
3340+
// resources then never appear to the cluster). Best-effort: if minting
3341+
// fails (CSPRNG), the master falls back to the legacy secret/default auth.
3342+
let bootstrap_grant = state.bootstrap_grants.mint().ok();
3343+
3344+
let mut resp_body = serde_json::json!({
33313345
"valid": true,
33323346
"hostname": hostname::get().map(|h| h.to_string_lossy().to_string()).unwrap_or_default(),
33333347
"cluster_fingerprint": crate::cluster_join::cluster_fingerprint(),
33343348
"cluster_name": our_cluster,
3335-
}))
3349+
});
3350+
// Only include the grant when minting succeeded — never emit a null field
3351+
// (which would needlessly reveal a CSPRNG failure).
3352+
if let Some(g) = bootstrap_grant {
3353+
resp_body["bootstrap_grant"] = serde_json::json!(g);
3354+
}
3355+
HttpResponse::Ok().json(resp_body)
33363356
}
33373357

33383358
/// Append a cluster-join / secret-change entry to BOTH the persistent
@@ -3728,7 +3748,44 @@ fn has_no_other_peers(state: &AppState) -> bool {
37283748
/// configured: a peerless node is fresh (case 1); a node with peers
37293749
/// is active (case 2).
37303750
pub async fn cluster_secret_receive(req: HttpRequest, state: web::Data<AppState>, body: web::Json<serde_json::Value>) -> HttpResponse {
3731-
if let Err(resp) = require_cluster_auth(&req, &state) { return resp; }
3751+
// Auth: the normal inter-node secret, OR a single-use bootstrap grant this
3752+
// node minted in its own join-handshake moments ago. The grant path lets a
3753+
// node that currently holds a DIFFERENT secret adopt the fleet secret right
3754+
// after an admin-authenticated join (without it, this push fails inter-node
3755+
// auth, the node stays divergent, and its resources never appear to the
3756+
// cluster). The grant is checked ONLY when the normal auth fails — so an
3757+
// existing valid push is byte-identical and never consumes a grant — and it
3758+
// is single-use (consumed here).
3759+
let mut authed_via_grant = false;
3760+
match require_cluster_auth(&req, &state) {
3761+
Ok(_) => {}
3762+
Err(resp) => {
3763+
let grant_ok = body.get("bootstrap_grant")
3764+
.and_then(|v| v.as_str())
3765+
// Real grants are 64 hex chars; cap the input so an attacker
3766+
// can't feed enormous strings into the constant-time scan.
3767+
.filter(|g| g.len() <= 128)
3768+
.map(|g| state.bootstrap_grants.take(g).is_some())
3769+
.unwrap_or(false);
3770+
if !grant_ok { return resp; }
3771+
authed_via_grant = true;
3772+
}
3773+
}
3774+
if authed_via_grant {
3775+
// A secret adopted via the grant path = a freshly-joined node taking
3776+
// the fleet secret. Audit it (with source IP) so grant-path use is
3777+
// visible in the trail, distinct from a normal cluster-auth push.
3778+
let peer = req.peer_addr()
3779+
.map(|a| a.ip().to_canonical().to_string())
3780+
.unwrap_or_default();
3781+
cluster_join_audit(
3782+
&state, "cluster-node", "warning",
3783+
"Cluster secret adopted via bootstrap grant",
3784+
&format!("New cluster secret accepted via a single-use bootstrap grant from {}",
3785+
if peer.is_empty() { "unknown source" } else { &peer }),
3786+
&peer,
3787+
);
3788+
}
37323789
let secret = match body.get("secret").and_then(|v| v.as_str()) {
37333790
Some(s) if !s.is_empty() => s,
37343791
_ => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Missing secret" })),
@@ -3980,6 +4037,7 @@ pub async fn add_node(req: HttpRequest, state: web::Data<AppState>, body: web::J
39804037
let mut last_error = String::new();
39814038
let mut verified = false;
39824039
let mut cluster_fingerprint: Option<String> = None;
4040+
let mut bootstrap_grant: Option<String> = None;
39834041
for url in &urls {
39844042
match client.post(url)
39854043
.timeout(std::time::Duration::from_secs(8))
@@ -3997,6 +4055,10 @@ pub async fn add_node(req: HttpRequest, state: web::Data<AppState>, body: web::J
39974055
if data.get("valid").and_then(|v| v.as_bool()) == Some(true) {
39984056
cluster_fingerprint = data.get("cluster_fingerprint")
39994057
.and_then(|v| v.as_str()).map(|s| s.to_string());
4058+
// Grant authorising the secret push below (lets a node
4059+
// with its own secret adopt the fleet secret).
4060+
bootstrap_grant = data.get("bootstrap_grant")
4061+
.and_then(|v| v.as_str()).map(|s| s.to_string());
40004062
verified = true;
40014063
break;
40024064
} else {
@@ -4142,12 +4204,25 @@ pub async fn add_node(req: HttpRequest, state: web::Data<AppState>, body: web::J
41424204
let addr = body.address.clone();
41434205
let secret = active_secret;
41444206
let our_secret = state.cluster_secret.clone();
4207+
// Single-use grant from the handshake — lets the receiver adopt the
4208+
// fleet secret even if it currently holds its own (the X-WolfStack-
4209+
// Secret attempts below would otherwise be rejected, leaving it
4210+
// divergent and its resources invisible to the cluster).
4211+
let grant = bootstrap_grant.clone();
41454212
tokio::spawn(async move {
41464213
let client = &*API_HTTP_CLIENT;
41474214
let urls = build_node_urls(&addr, port, "/api/cluster/secret/receive");
41484215
for auth_value in [our_secret.as_str(), crate::auth::default_cluster_secret()] {
41494216
let mut pushed = false;
41504217
for url in &urls {
4218+
// Only attach the bootstrap grant on the TLS (https) leg —
4219+
// never on the plaintext-http fallback. Capturing the grant
4220+
// then requires breaking TLS, at which point the secret in
4221+
// the same body is already exposed, so the grant adds no
4222+
// marginal on-wire risk. (build_node_urls puts https first;
4223+
// the http legs are the WolfNet-overlay and last-resort
4224+
// plaintext fallbacks.)
4225+
let leg_grant = if url.starts_with("https://") { grant.clone() } else { None };
41514226
// `bootstrap: true` tells the receiving node "this is a
41524227
// first-time join; safe to self-restart so the new
41534228
// secret takes effect in-memory immediately". Old-code
@@ -4159,6 +4234,7 @@ pub async fn add_node(req: HttpRequest, state: web::Data<AppState>, body: web::J
41594234
.json(&serde_json::json!({
41604235
"secret": secret,
41614236
"bootstrap": true,
4237+
"bootstrap_grant": leg_grant,
41624238
}))
41634239
.send()
41644240
.await
@@ -26608,6 +26684,13 @@ pub async fn alerts_config_save(req: HttpRequest, state: web::Data<AppState>, bo
2660826684
if let Some(b) = v.get("alert_cpu").and_then(|v| v.as_bool()) { config.alert_cpu = b; }
2660926685
if let Some(b) = v.get("alert_memory").and_then(|v| v.as_bool()) { config.alert_memory = b; }
2661026686
if let Some(b) = v.get("alert_disk").and_then(|v| v.as_bool()) { config.alert_disk = b; }
26687+
// Container monitoring toggle + threshold — these were MISSING here, so
26688+
// unchecking "Container memory alert" never persisted and reverted to on
26689+
// after Save (wabil 2026-06-21).
26690+
if let Some(b) = v.get("alert_containers").and_then(|v| v.as_bool()) { config.alert_containers = b; }
26691+
if let Some(t) = v.get("container_memory_threshold").and_then(|v| v.as_f64()) {
26692+
config.container_memory_threshold = t as f32;
26693+
}
2661126694
if let Some(i) = v.get("check_interval_secs").and_then(|v| v.as_u64()) {
2661226695
// Clamp to sensible range: 30 seconds to 1 hour
2661326696
config.check_interval_secs = i.max(30).min(3600);

src/backup/mod.rs

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,39 @@ impl BackupStorage {
364364
mod tests {
365365
use super::*;
366366

367+
#[test]
368+
fn path_discriminator_distinguishes_case_and_parent() {
369+
// The whole point: case-only-differing paths must get DIFFERENT
370+
// discriminators so they don't collide on a case-insensitive dest.
371+
assert_ne!(short_path_discriminator("/data/temp"),
372+
short_path_discriminator("/data/Temp"));
373+
// Same basename under different parents must differ too.
374+
assert_ne!(short_path_discriminator("/a/x"),
375+
short_path_discriminator("/b/x"));
376+
// Stable + hex (8 lowercase hex chars).
377+
let d = short_path_discriminator("/data/temp");
378+
assert_eq!(d, short_path_discriminator("/data/temp"));
379+
assert_eq!(d.len(), 8);
380+
assert!(d.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
381+
}
382+
383+
#[test]
384+
fn pbs_file_level_skip_note_only_for_inapplicable_pbs() {
385+
let mut pbs = BackupStorage { storage_type: StorageType::Pbs, pbs_file_level: true, ..Default::default() };
386+
let vm = BackupTarget { target_type: BackupTargetType::Vm, ..Default::default() };
387+
let docker = BackupTarget { target_type: BackupTargetType::Docker, ..Default::default() };
388+
// VM can't do file-level → a note.
389+
assert!(pbs_file_level_skip_note(&vm, &pbs).is_some());
390+
// Docker WILL use pxar → no note.
391+
assert!(pbs_file_level_skip_note(&docker, &pbs).is_none());
392+
// file-level off → never a note, even for VM.
393+
pbs.pbs_file_level = false;
394+
assert!(pbs_file_level_skip_note(&vm, &pbs).is_none());
395+
// non-PBS storage → never a note.
396+
let local = BackupStorage { storage_type: StorageType::Local, pbs_file_level: true, ..Default::default() };
397+
assert!(pbs_file_level_skip_note(&vm, &local).is_none());
398+
}
399+
367400
fn wd(path: &str, sub: &str) -> BackupStorage {
368401
BackupStorage {
369402
storage_type: StorageType::Wolfdisk,
@@ -1288,6 +1321,25 @@ fn sanitize_archive_name(s: &str) -> String {
12881321
.collect()
12891322
}
12901323

1324+
/// Short, case-insensitive-safe discriminator derived from the FULL source
1325+
/// path: the first 8 hex chars of SHA-256(path). Two paths that differ only by
1326+
/// case (or share a basename under different parents) get DIFFERENT
1327+
/// discriminators, so their backup filenames never collide — even on a
1328+
/// case-insensitive destination filesystem. Hex is itself case-insensitive,
1329+
/// so the discriminator can't re-introduce a case collision.
1330+
fn short_path_discriminator(path: &str) -> String {
1331+
use sha2::{Digest, Sha256};
1332+
let mut hasher = Sha256::new();
1333+
hasher.update(path.as_bytes());
1334+
let digest = hasher.finalize();
1335+
let mut out = String::with_capacity(8);
1336+
use std::fmt::Write;
1337+
for b in digest.iter().take(4) {
1338+
let _ = write!(out, "{:02x}", b);
1339+
}
1340+
out
1341+
}
1342+
12911343
/// tar.gz a directory's contents (NOT the directory itself) into the
12921344
/// given archive path. Returns the resulting archive size in bytes.
12931345
fn tar_dir_to_gz(src_dir: &str, archive: &Path) -> Result<u64, String> {
@@ -1996,7 +2048,15 @@ pub fn backup_system_path(label: &str, path: &str, exclude_mounts: &[String]) ->
19962048
let safe_label = sanitize_archive_name(if label.trim().is_empty() {
19972049
Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("folder")
19982050
} else { label.trim() });
1999-
let filename = format!("systempath-{}-{}.tar.gz", safe_label, timestamp);
2051+
// Disambiguator derived from the FULL source path. Without it, two folders
2052+
// whose names differ only by case ("temp" vs "Temp") — or that share a
2053+
// basename under different parents ("/a/x" vs "/b/x") — collide on a
2054+
// case-insensitive backup destination (SMB / exFAT / APFS), so one
2055+
// overwrites the other (wabil 2026-06-21). A short hex hash of the exact
2056+
// path is case-insensitive-safe and unique per source, while the readable
2057+
// label is preserved.
2058+
let path_disc = short_path_discriminator(path);
2059+
let filename = format!("systempath-{}-{}-{}.tar.gz", safe_label, path_disc, timestamp);
20002060
let tar_path = staging.join(&filename);
20012061

20022062
let src = path.trim_end_matches('/');
@@ -2242,7 +2302,11 @@ fn create_backup_entry(target: BackupTarget, storage: &BackupStorage) -> BackupE
22422302
let id = Uuid::new_v4().to_string();
22432303
let now = Utc::now().to_rfc3339();
22442304
let hostname = local_hostname();
2245-
let comments = backup_comments(&target);
2305+
let mut comments = backup_comments(&target);
2306+
// Make a file-level→tarball fallback (e.g. Proxmox LXC → vzdump) visible.
2307+
if let Some(note) = pbs_file_level_skip_note(&target, storage) {
2308+
comments = format!("{} | {}", comments, note);
2309+
}
22462310
let cluster = local_cluster_name();
22472311

22482312
// PBS file-level (pxar) path — see create_backup_with_log for the rationale.
@@ -2970,6 +3034,27 @@ fn pbs_file_level_applies(target: &BackupTarget) -> bool {
29703034
}
29713035
}
29723036

3037+
/// When PBS file-level is ENABLED but a target can't use it, return a plain-
3038+
/// English reason. Makes the tarball/vzdump fallback visibly intentional in the
3039+
/// backup's details + live log instead of looking like "file-level is broken"
3040+
/// (wabil 2026-06-21: "file level not implemented … lxc is tar.zst"). Returns
3041+
/// None when file-level isn't requested or the target WILL use pxar.
3042+
fn pbs_file_level_skip_note(target: &BackupTarget, storage: &BackupStorage) -> Option<String> {
3043+
if storage.storage_type != StorageType::Pbs || !storage.pbs_file_level || pbs_file_level_applies(target) {
3044+
return None;
3045+
}
3046+
let why = match target.target_type {
3047+
BackupTargetType::Lxc =>
3048+
"Proxmox LXC rootfs is on block storage (ZFS/LVM) — pxar file-level isn't possible, using vzdump image",
3049+
BackupTargetType::Vm =>
3050+
"VMs back up as a disk image, not per-file — pxar file-level doesn't apply",
3051+
BackupTargetType::Config =>
3052+
"config backups are a single archive — pxar file-level doesn't apply",
3053+
_ => "pxar file-level isn't available for this target — using image/tarball backup",
3054+
};
3055+
Some(format!("PBS file-level not applicable: {}", why))
3056+
}
3057+
29733058
/// Build the pxar source pairs for a file-level PBS backup of `target`.
29743059
/// Returns (backup_type, backup_id, pairs). For Docker the container's
29753060
/// filesystem is materialised into a staging tree via `docker export`;
@@ -5086,7 +5171,13 @@ pub fn create_backup_with_log(
50865171
let _ = log.send(format!("[{}/{}] Starting {} backup: {}",
50875172
i + 1, total, type_name, display_name));
50885173

5089-
let comments = backup_comments_with_cluster(t, &cluster);
5174+
let mut comments = backup_comments_with_cluster(t, &cluster);
5175+
// Surface why file-level fell back (e.g. Proxmox LXC → vzdump) so it's
5176+
// not mistaken for a broken feature.
5177+
if let Some(note) = pbs_file_level_skip_note(t, &storage) {
5178+
let _ = log.send(format!(" {}", note));
5179+
comments = format!("{} | {}", comments, note);
5180+
}
50905181
let hostname = local_hostname();
50915182

50925183
// PBS file-level (pxar) path — upload the workload's content directory

src/cluster_join.rs

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,21 +64,37 @@ struct PendingToken {
6464
expires: Instant,
6565
}
6666

67-
/// In-memory store of pending one-time join tokens. Kept in-memory only:
68-
/// these are deliberately ephemeral (15-minute TTL) and a process restart
69-
/// invalidating them is the safe failure mode — an operator simply mints a
70-
/// fresh one. Keyed by the token string itself for O(1) verify+consume.
71-
#[derive(Default)]
67+
/// In-memory store of single-use, TTL-bounded tokens. Kept in-memory only:
68+
/// these are deliberately ephemeral and a process restart invalidating them is
69+
/// the safe failure mode — an operator simply mints a fresh one. Keyed by the
70+
/// token string itself for O(1) verify+consume. The TTL is per-store: join
71+
/// tokens use 15 min (`new()`); bootstrap grants use a much shorter window
72+
/// (`with_ttl`) since they're consumed seconds after minting.
7273
pub struct OneTimeTokens {
7374
inner: Mutex<HashMap<String, PendingToken>>,
75+
ttl: Duration,
76+
}
77+
78+
impl Default for OneTimeTokens {
79+
fn default() -> Self {
80+
Self::with_ttl(ONE_TIME_TTL)
81+
}
7482
}
7583

7684
impl OneTimeTokens {
7785
pub fn new() -> Self {
7886
Self::default()
7987
}
8088

81-
/// Mint a new one-time token, store it with a 15-minute expiry, and
89+
/// Store with a custom TTL (e.g. a short window for bootstrap grants).
90+
pub fn with_ttl(ttl: Duration) -> Self {
91+
Self {
92+
inner: Mutex::new(HashMap::new()),
93+
ttl,
94+
}
95+
}
96+
97+
/// Mint a new one-time token, store it with this store's TTL, and
8298
/// return the plaintext (shown to the operator ONCE). Also prunes any
8399
/// already-expired entries so the map can't grow without bound.
84100
/// Returns `Err` if the system CSPRNG is unavailable — we refuse to
@@ -94,7 +110,7 @@ impl OneTimeTokens {
94110
guard.insert(
95111
token.clone(),
96112
PendingToken {
97-
expires: Instant::now() + ONE_TIME_TTL,
113+
expires: Instant::now() + self.ttl,
98114
},
99115
);
100116
Ok(token)

src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,11 @@ async fn main() -> std::io::Result<()> {
696696
cluster_secret: cluster_secret.clone(),
697697
join_token: api::load_join_token(),
698698
one_time_join_tokens: Arc::new(cluster_join::OneTimeTokens::new()),
699+
// Bootstrap grants are consumed seconds after minting (handshake →
700+
// immediate secret push), so a tight 120s TTL minimises the window.
701+
bootstrap_grants: Arc::new(cluster_join::OneTimeTokens::with_ttl(
702+
std::time::Duration::from_secs(120),
703+
)),
699704
pbs_restore_progress: Mutex::new(Default::default()),
700705
ai_agent: ai_agent.clone(),
701706
cached_status: cached_status.clone(),

src/predictive/container_memory.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,13 @@ pub fn analyze(
130130
acks: &AckStore,
131131
proposals: &crate::predictive::proposal::ProposalStore,
132132
) -> Vec<Proposal> {
133+
// Respect the Settings → Alerts "Container memory alert" toggle. Default
134+
// true (existing behaviour unchanged); when off, emit nothing — existing
135+
// findings auto-resolve since covered_scopes still reports their scopes
136+
// (wabil 2026-06-21).
137+
if !crate::alerting::AlertConfig::load().alert_containers {
138+
return Vec::new();
139+
}
133140
let mut out = Vec::new();
134141
for fact in current {
135142
let scope = ProposalScope {

0 commit comments

Comments
 (0)