From 9b30daaf9cf8bb1f5366d8e1307740e5006f9297 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 10:17:58 -0400 Subject: [PATCH] fix(security): restart a companion whose image was rebuilt underneath it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rebuilt image never reached a running companion. ensure_image_present rebuilds in place under the same tag, so the quadlet body is identical, write_if_changed reports no change, and enable_now is a no-op on a running service — the container keeps the old layers indefinitely. That is precisely how archi-dev-box kept serving the LND, FIPS, Electrs and Guardian screens on 0.0.0.0 after v1.7.123 rebuilt every one of those images to bind loopback: correct images on disk, three-day-old containers still running. Closing those ports needed a manual 'podman rm -f' per container, which no other node would ever get. Compare the running container's image ID against the built one and restart when they diverge. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/container/companion.rs | 49 +++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/core/archipelago/src/container/companion.rs b/core/archipelago/src/container/companion.rs index 92658725..018ee63d 100644 --- a/core/archipelago/src/container/companion.rs +++ b/core/archipelago/src/container/companion.rs @@ -214,10 +214,59 @@ pub async fn install_one(spec: &CompanionSpec) -> Result<()> { } // Start is idempotent — if already running, systemctl returns 0. quadlet::enable_now(&unit.service_name()).await?; + + // A rebuilt image does NOT reach a container that is already running. + // `ensure_image_present` rebuilds in place under the same tag, so the unit + // body is byte-identical, `write_if_changed` reports no change, and + // `enable_now` is a no-op on a running service — the container keeps the + // old layers indefinitely. That is exactly how archi-dev-box kept serving + // the LND, FIPS, Electrs and Guardian screens on 0.0.0.0 after v1.7.123 + // rebuilt every one of those images to bind loopback: the images were + // correct on disk and the running containers were three days old + // (2026-08-05). Compare image IDs and restart when they diverge. + if let Some(running) = container_image_id(spec.name).await { + if let Some(built) = image_id(&image).await { + if running != built { + info!( + companion = spec.name, + "running container uses a stale image; restarting onto the rebuilt one" + ); + quadlet::restart_service(&unit.service_name()).await?; + } + } + } info!(companion = spec.name, "companion started"); Ok(()) } +/// Image ID a container is actually running, or `None` when it does not exist. +async fn container_image_id(name: &str) -> Option { + let out = tokio::process::Command::new("podman") + .args(["inspect", name, "--format", "{{.Image}}"]) + .output() + .await + .ok()?; + if !out.status.success() { + return None; + } + let id = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (!id.is_empty()).then_some(id) +} + +/// Current ID behind an image reference, or `None` when absent. +async fn image_id(image_ref: &str) -> Option { + let out = tokio::process::Command::new("podman") + .args(["image", "inspect", image_ref, "--format", "{{.Id}}"]) + .output() + .await + .ok()?; + if !out.status.success() { + return None; + } + let id = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (!id.is_empty()).then_some(id) +} + /// Build companion image locally if a Dockerfile exists, otherwise /// pull from the lfg2025 registry. Returns the image ref the quadlet /// should reference (`localhost/:latest` for build, registry