@@ -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).
37303750pub 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);
0 commit comments