fix(01-16): recreate the gateway when its credential was rotated (FED-07)
The checkpoint on archi-dev-box proved rotation alone doesn't close FED-07: the credential file went unique while the RUNNING container kept serving the compromised one, because the Quadlet path rewrites a unit without restarting it and fedimint-gateway is classified restart-sensitive, so drift was detected and deliberately ignored on every tick. Rotation now records the app id, and the drift check consumes that flag to recreate even a restart-sensitive app, with a WARN naming the reason. This mirrors the published-port carve-out a few lines above, which already makes the same trade for the same reason: a container that is already broken (there) or already compromised (here) is not protected by leaving it running. Restart-sensitivity protects working services. A gateway answering to a credential published in this repository is not working, it is compromised, and gateway admin can drain Lightning liquidity — indefinite exposure loses to a few seconds of restart. Rotating-but-only-alerting was rejected: the monitoring system fires on metric thresholds only, so it would have needed new event-alert plumbing to deliver something strictly weaker. Re-verified on the same node, same scenario: rotation at 06:39:23, recreate at 06:39:27, PID 3923125 -> 148426, running credential now matches the file, container healthy with the same name and ports, gatewayd.db intact at 18 files with IDENTITY present, 32 containers untouched, no repeat rotation. 3 new tests. Also lands the missing 01-19 and 01-20 SUMMARYs: both had code committed 2026-07-31 but no summary and no roadmap tick, so they read as unstarted. Phase 1 is 11/20. FED-09 carries 15h of Tor uptime and 0 permission-fixes across 542 doctor runs on archi-dev-box. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5cf44c9a58
commit
06e0e6954e
@@ -1254,6 +1254,13 @@ pub struct ProdContainerOrchestrator {
|
||||
/// secret calls; a rotation (hash change) falls through and
|
||||
/// re-registers.
|
||||
env_secret_cache: Mutex<HashMap<String, String>>,
|
||||
/// App ids whose credential this process rotated off a publicly known
|
||||
/// default (FED-07). A rotation leaves the RUNNING container holding the
|
||||
/// compromised value, so its env drift must be acted on even when the app
|
||||
/// is restart-sensitive — leaving it untouched perpetuates the compromise,
|
||||
/// exactly as leaving published-port drift untouched perpetuates a broken
|
||||
/// container. Consumed (and cleared) by the drift check that recreates it.
|
||||
credential_rotated: Mutex<HashSet<String>>,
|
||||
#[cfg(test)]
|
||||
test_disk_gb: Option<u64>,
|
||||
#[cfg(test)]
|
||||
@@ -1317,6 +1324,7 @@ impl ProdContainerOrchestrator {
|
||||
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
|
||||
use_quadlet_backends: config.use_quadlet_backends,
|
||||
env_secret_cache: Mutex::new(HashMap::new()),
|
||||
credential_rotated: Mutex::new(HashSet::new()),
|
||||
#[cfg(test)]
|
||||
test_disk_gb: None,
|
||||
#[cfg(test)]
|
||||
@@ -1339,6 +1347,7 @@ impl ProdContainerOrchestrator {
|
||||
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
|
||||
use_quadlet_backends: false,
|
||||
env_secret_cache: Mutex::new(HashMap::new()),
|
||||
credential_rotated: Mutex::new(HashSet::new()),
|
||||
test_disk_gb: None,
|
||||
test_bitcoin_host: None,
|
||||
}
|
||||
@@ -2040,7 +2049,24 @@ impl ProdContainerOrchestrator {
|
||||
return Ok(ReconcileAction::Installed);
|
||||
}
|
||||
if self.container_env_drifted(&name, &resolved_manifest).await {
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
// FED-07: a credential we just rotated off a publicly
|
||||
// known default leaves the RUNNING container holding
|
||||
// the compromised value. Restart-sensitivity protects
|
||||
// working services; this service is compromised, and
|
||||
// skipping it perpetuates the exposure — the same
|
||||
// reasoning the published-port carve-out above uses.
|
||||
let rotated = self.credential_rotated.lock().await.remove(&app_id);
|
||||
if rotated {
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
container = %name,
|
||||
"recreating restart-sensitive app: its admin credential was \
|
||||
rotated off a publicly known default and the running \
|
||||
container still holds the compromised one (FED-07)"
|
||||
);
|
||||
}
|
||||
if !rotated
|
||||
&& mode == ReconcileMode::ExistingOnly
|
||||
&& is_restart_sensitive_app(&app_id)
|
||||
{
|
||||
tracing::info!(
|
||||
@@ -3262,6 +3288,14 @@ impl ProdContainerOrchestrator {
|
||||
if manifest.app.id == "fedimint-gateway"
|
||||
&& crate::container::secrets::rotate_compromised_gateway_credential(&self.secrets_dir)?
|
||||
{
|
||||
// Mark the app so the drift check below recreates it even though
|
||||
// it is restart-sensitive. Without this the unit is rewritten but
|
||||
// never restarted, and the gateway keeps serving the compromised
|
||||
// credential indefinitely (observed on archi-dev-box 2026-08-01).
|
||||
self.credential_rotated
|
||||
.lock()
|
||||
.await
|
||||
.insert(manifest.app.id.clone());
|
||||
// Names a path, never a value — this line crosses into the node's
|
||||
// logs, which are a wider audience than the 0600 secrets dir.
|
||||
tracing::info!(
|
||||
@@ -5125,6 +5159,116 @@ app:
|
||||
}
|
||||
}
|
||||
|
||||
/// A fedimint-gateway manifest shaped like the real one: a bcrypt
|
||||
/// generated secret plus a secret_env that reads it, which is what makes
|
||||
/// the credential participate in secret_env_hash.
|
||||
fn gateway_manifest_yaml() -> &'static str {
|
||||
"app:\n id: fedimint-gateway\n name: Fedimint Gateway\n version: 0.10.0\n container:\n image: x:1\n generated_secrets:\n - name: fedimint-gateway-hash\n kind: bcrypt\n secret_env:\n - key: FEDI_HASH\n secret_file: fedimint-gateway-hash\n"
|
||||
}
|
||||
|
||||
/// FED-07. Rotating a compromised credential leaves the RUNNING container
|
||||
/// holding the old value, so the rotation must flag the app for recreate.
|
||||
/// Without the flag the drift check skips it as restart-sensitive and the
|
||||
/// gateway keeps serving the published default forever — observed on
|
||||
/// archi-dev-box 2026-08-01 before this was wired up.
|
||||
#[tokio::test]
|
||||
async fn rotating_a_compromised_credential_flags_the_app_for_recreate() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let secrets = tmp.path().join("secrets");
|
||||
std::fs::create_dir_all(&secrets).unwrap();
|
||||
// An install carrying the shipped default, with its .pw sibling
|
||||
// present so ensure_one's fast path no-ops and rotation is what acts.
|
||||
std::fs::write(
|
||||
secrets.join("fedimint-gateway-hash"),
|
||||
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(secrets.join("fedimint-gateway-hash.pw"), "stale-plaintext").unwrap();
|
||||
orch.set_secrets_dir(secrets.clone());
|
||||
|
||||
let mut manifest = AppManifest::parse(gateway_manifest_yaml()).unwrap();
|
||||
orch.resolve_dynamic_env(&mut manifest).await.unwrap();
|
||||
|
||||
assert!(
|
||||
orch.credential_rotated
|
||||
.lock()
|
||||
.await
|
||||
.contains("fedimint-gateway"),
|
||||
"a rotated credential must flag its app so the drift check recreates it"
|
||||
);
|
||||
let after = std::fs::read_to_string(secrets.join("fedimint-gateway-hash")).unwrap();
|
||||
assert!(
|
||||
!after.contains("t9YjjxkiktrlYvjajB"),
|
||||
"the compromised value must be gone from the file"
|
||||
);
|
||||
}
|
||||
|
||||
/// The adjacency edge: an app whose credential was NOT rotated must not be
|
||||
/// flagged, or every reconcile tick would recreate restart-sensitive apps.
|
||||
#[tokio::test]
|
||||
async fn a_unique_credential_does_not_flag_the_app() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let secrets = tmp.path().join("secrets");
|
||||
std::fs::create_dir_all(&secrets).unwrap();
|
||||
crate::container::secrets::ensure_gateway_credential(&secrets).unwrap();
|
||||
let before = std::fs::read_to_string(secrets.join("fedimint-gateway-hash")).unwrap();
|
||||
orch.set_secrets_dir(secrets.clone());
|
||||
|
||||
let mut manifest = AppManifest::parse(gateway_manifest_yaml()).unwrap();
|
||||
orch.resolve_dynamic_env(&mut manifest).await.unwrap();
|
||||
|
||||
assert!(
|
||||
orch.credential_rotated.lock().await.is_empty(),
|
||||
"an app with a unique credential must never be flagged for recreate"
|
||||
);
|
||||
assert_eq!(
|
||||
before,
|
||||
std::fs::read_to_string(secrets.join("fedimint-gateway-hash")).unwrap(),
|
||||
"a unique credential must be left byte-identical"
|
||||
);
|
||||
}
|
||||
|
||||
/// Idempotence at the flag level: the second pass finds a value that is no
|
||||
/// longer on the denylist, so it neither rotates nor re-flags. This is what
|
||||
/// stops a recreate loop on every reconcile tick (T-01-73).
|
||||
#[tokio::test]
|
||||
async fn a_second_pass_does_not_re_flag_the_app() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let secrets = tmp.path().join("secrets");
|
||||
std::fs::create_dir_all(&secrets).unwrap();
|
||||
std::fs::write(
|
||||
secrets.join("fedimint-gateway-hash"),
|
||||
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(secrets.join("fedimint-gateway-hash.pw"), "stale-plaintext").unwrap();
|
||||
orch.set_secrets_dir(secrets.clone());
|
||||
|
||||
let mut m1 = AppManifest::parse(gateway_manifest_yaml()).unwrap();
|
||||
orch.resolve_dynamic_env(&mut m1).await.unwrap();
|
||||
// The drift check consumes the flag when it recreates.
|
||||
orch.credential_rotated
|
||||
.lock()
|
||||
.await
|
||||
.remove("fedimint-gateway");
|
||||
|
||||
let mut m2 = AppManifest::parse(gateway_manifest_yaml()).unwrap();
|
||||
orch.resolve_dynamic_env(&mut m2).await.unwrap();
|
||||
assert!(
|
||||
orch.credential_rotated.lock().await.is_empty(),
|
||||
"the second pass must not re-flag — the rotated value is not denylisted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_fresh_build_when_image_absent() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
|
||||
Reference in New Issue
Block a user