Compare commits

..
45 changed files with 1478 additions and 1286 deletions
+1 -23
View File
@@ -162,33 +162,11 @@ impl ApiHandler {
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
),
)),
Ok(content_server::ServeResult::Unavailable) => Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(
r#"{"error":"The seller's node can't read this file right now. This request did not redeem an ecash payment."}"#,
),
)),
Ok(content_server::ServeResult::RangeNotSatisfiable(total)) => Ok(Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header("Content-Range", format!("bytes */{total}"))
.body(hyper::Body::empty())
.unwrap()),
Ok(content_server::ServeResult::NotFound) => Ok(build_response(
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Content not found"),
)),
// Not a 404: a paid request may already have been charged by the
// time this fails, and "not found" hid the real error entirely.
Err(e) => {
tracing::error!("Serving content {content_id} failed: {e:#}");
Ok(build_response(
StatusCode::INTERNAL_SERVER_ERROR,
"text/plain",
hyper::Body::from("Failed to serve content"),
))
}
}
}
+8 -35
View File
@@ -43,25 +43,6 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
}
}
/// Only pass through the peer's bounded, printable explanation; refund status
/// is always determined locally and must never come from the peer's wording.
fn seller_error_message(status: reqwest::StatusCode, body: &str) -> String {
let reason = serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_owned));
match reason {
Some(reason) if !reason.trim().is_empty() => {
let clean: String = reason
.chars()
.filter(|c| !c.is_control())
.take(240)
.collect();
format!("Seller response ({status}): {clean}")
}
_ => format!("Peer returned an error ({status})."),
}
}
/// Keep first purchases and cached repeats compatible with both existing clients.
fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json::Value {
use base64::Engine;
@@ -642,14 +623,13 @@ impl RpcHandler {
let path = format!("/content/{}", content_id);
// Surface a real reason instead of the generic sanitized error (#30):
// A bearer token must not be replayed after an ambiguous delivery.
// A transport error can mean the seller received it without replying.
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
// here means the peer is genuinely unreachable on both transports.
let (response, transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str.clone())
.single_delivery()
.timeout(std::time::Duration::from_secs(900))
.send_get()
.await
@@ -662,7 +642,7 @@ impl RpcHandler {
let refund =
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("The purchase could not be completed. {refund}")
"error": format!("Could not reach the peer over mesh or Tor. {refund}")
}));
}
};
@@ -699,7 +679,7 @@ impl RpcHandler {
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("{} {refund}", seller_error_message(status, &body))
"error": format!("Peer returned an error ({status}). {refund}")
}));
}
@@ -713,17 +693,10 @@ impl RpcHandler {
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "application/octet-stream".to_string());
let bytes = match response.bytes().await {
Ok(bytes) => bytes,
Err(error) => {
tracing::warn!("paid download: response body failed: {error}");
let refund =
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("The file transfer was interrupted after payment was sent. {refund}")
}));
}
};
let bytes = response
.bytes()
.await
.context("Failed to read response body")?;
// Persist the purchase so it "stays unlocked" for this buyer: cache the
// bytes + metadata keyed by (onion, content_id). The gallery then renders
@@ -162,20 +162,3 @@ async fn files_copy_fails_without_overwriting_or_claiming_success_on_errors() {
assert_eq!(api.seen.lock().unwrap().len(), expected);
}
}
#[test]
fn seller_errors_are_bounded_printable_and_identified_as_peer_text() {
let status = reqwest::StatusCode::SERVICE_UNAVAILABLE;
let message = seller_error_message(status, r#"{"error":"Cannot read file\n\u0000"}"#);
assert!(message.starts_with("Seller response (503"));
assert!(message.ends_with("Cannot read file"));
assert!(!message.contains('\n') && !message.contains('\0'));
let body = serde_json::json!({"error": "é".repeat(1000)}).to_string();
assert!(seller_error_message(status, &body).chars().count() < 300);
for body in ["not JSON", r#"{"error": 7}"#, r#"{"error":" "}"#] {
assert_eq!(
seller_error_message(status, body),
"Peer returned an error (503 Service Unavailable)."
);
}
}
@@ -89,6 +89,15 @@ impl RpcHandler {
match handler.handle_package_install(params).await {
Ok(_) => {
info!("package.install {}: complete", package_id_spawn);
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
crate::crash_recovery::clear_user_uninstalled(&handler.config.data_dir, id)
.await;
}
crate::crash_recovery::mark_installed(
&handler.config.data_dir,
&package_id_spawn,
)
.await;
// The install pipeline has verified the container is up
// and healthy (see install.rs post-start exit check).
// Kick the scanner first so the fresh manifest (with
@@ -184,17 +193,20 @@ impl RpcHandler {
// phase is cleared (None) so no stale InstallPhase
// lingers on the card.
let err_msg = format!("Install failed: {:#}", e);
let (mut data, _) = handler.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
entry.state = PackageState::Stopped;
entry.install_progress = Some(crate::data_model::InstallProgress {
size: 0,
downloaded: 0,
phase: None,
message: Some(err_msg),
});
handler.state_manager.update_data(data).await;
}
handler
.state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
entry.state = PackageState::Stopped;
entry.install_progress = Some(crate::data_model::InstallProgress {
size: 0,
downloaded: 0,
phase: None,
message: Some(err_msg),
});
}
})
.await;
}
}
});
@@ -252,6 +264,11 @@ impl RpcHandler {
match handler.handle_package_uninstall(params).await {
Ok(_) => {
info!("package.uninstall {}: complete", package_id_spawn);
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
crate::crash_recovery::mark_user_uninstalled(&handler.config.data_dir, id)
.await;
crate::crash_recovery::clear_installed(&handler.config.data_dir, id).await;
}
// Inner handler already removed the package entry on
// success. Nothing more to do here.
}
@@ -382,52 +399,56 @@ impl RpcHandler {
/// call, but fires before the spawn so the UI sees it immediately.
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
let (mut data, _) = state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| PackageDataEntry {
state: PackageState::Installing,
health: None,
exit_code: None,
static_files: StaticFiles {
license: String::new(),
instructions: String::new(),
// Leave icon empty during the transient Installing window:
// hardcoding `<id>.png` is wrong for ~half our apps (many use
// `.svg` / `.webp`), producing a broken-image flicker until
// the scanner refreshes the entry. The frontend's `icon`
// computed falls through to `curatedMap.get(id)?.icon` which
// has the correct extensions for known apps.
icon: String::new(),
},
manifest: Manifest {
id: package_id.to_string(),
title: package_id.to_string(),
version: String::new(),
description: Description {
short: "Installing...".to_string(),
long: String::new(),
},
release_notes: String::new(),
license: String::new(),
wrapper_repo: String::new(),
upstream_repo: String::new(),
support_site: String::new(),
marketing_site: String::new(),
donation_url: None,
author: None,
website: None,
interfaces: None,
tier: None,
},
installed: None,
install_progress: None,
uninstall_stage: None,
available_update: None,
});
entry.state = PackageState::Installing;
state_manager.update_data(data).await;
state_manager
.mutate_data(|data| {
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| PackageDataEntry {
ui_ready: None,
state: PackageState::Installing,
health: None,
exit_code: None,
static_files: StaticFiles {
license: String::new(),
instructions: String::new(),
// Leave icon empty during the transient Installing window:
// hardcoding `<id>.png` is wrong for ~half our apps (many use
// `.svg` / `.webp`), producing a broken-image flicker until
// the scanner refreshes the entry. The frontend's `icon`
// computed falls through to `curatedMap.get(id)?.icon` which
// has the correct extensions for known apps.
icon: String::new(),
},
manifest: Manifest {
id: package_id.to_string(),
title: package_id.to_string(),
version: String::new(),
description: Description {
short: "Installing...".to_string(),
long: String::new(),
},
release_notes: String::new(),
license: String::new(),
wrapper_repo: String::new(),
upstream_repo: String::new(),
support_site: String::new(),
marketing_site: String::new(),
donation_url: None,
author: None,
website: None,
interfaces: None,
tier: None,
},
installed: None,
install_progress: None,
uninstall_stage: None,
available_update: None,
});
entry.ui_ready = Some(false);
entry.state = PackageState::Installing;
})
.await;
}
/// True when the failed install still has a real footprint: any container
@@ -485,20 +506,23 @@ async fn remove_entry_with_notification(
id_prefix: &str,
message: &str,
) {
let (mut data, _) = handler.state_manager.get_snapshot().await;
data.package_data.remove(package_id);
data.notifications.push(crate::data_model::Notification {
id: format!("{id_prefix}-{package_id}"),
level: crate::data_model::NotificationLevel::Error,
title: format!("Could not install {package_id}"),
message: message.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: Some(package_id.to_string()),
});
while data.notifications.len() > 20 {
data.notifications.remove(0);
}
handler.state_manager.update_data(data).await;
handler
.state_manager
.mutate_data(|data| {
data.package_data.remove(package_id);
data.notifications.push(crate::data_model::Notification {
id: format!("{id_prefix}-{package_id}"),
level: crate::data_model::NotificationLevel::Error,
title: format!("Could not install {package_id}"),
message: message.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: Some(package_id.to_string()),
});
while data.notifications.len() > 20 {
data.notifications.remove(0);
}
})
.await;
}
/// Flip an existing entry's state and return the pre-flip value (or None if
@@ -508,18 +532,23 @@ async fn flip_package_state(
package_id: &str,
new_state: PackageState,
) -> Option<PackageState> {
let (mut data, _) = state_manager.get_snapshot().await;
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = new_state;
state_manager.update_data(data).await;
} else {
warn!(
"flip_package_state: no entry for {} — cannot flip",
package_id
);
}
prev
state_manager
.mutate_data(|data| {
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(package_id) {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
} else {
warn!(
"flip_package_state: no entry for {} — cannot flip",
package_id
);
}
prev
})
.await
}
/// Set state unconditionally (no-op if entry no longer exists).
@@ -528,13 +557,18 @@ async fn set_package_state(
package_id: &str,
new_state: PackageState,
) {
let (mut data, _) = state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
if entry.state != new_state {
entry.state = new_state;
state_manager.update_data(data).await;
}
}
state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
if entry.state != new_state {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
}
}
})
.await
}
/// Set state and clear the uninstall_stage label. Used when an uninstall
@@ -545,12 +579,17 @@ async fn set_package_state_and_clear_uninstall_stage(
package_id: &str,
new_state: PackageState,
) {
let (mut data, _) = state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = new_state;
entry.uninstall_stage = None;
state_manager.update_data(data).await;
}
state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
entry.uninstall_stage = None;
}
})
.await
}
/// Kick the container scanner to run immediately and wait for it to finish
+7 -106
View File
@@ -545,7 +545,7 @@ impl RpcHandler {
// Keep legacy install flow as default while migration is in progress.
if orchestrator_managed {
let orchestrator_app_id = orchestrator_install_app_id(package_id);
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
self.set_install_phase(package_id, InstallPhase::PreparingApp)
.await;
install_log(&format!(
"INSTALL ORCH: {} — attempting orchestrator install as {}",
@@ -2053,25 +2053,8 @@ fn parse_setup_token(lines: &[&str]) -> Option<String> {
}
async fn cleanup_stale_package_ports(package_id: &str) {
match package_id {
"grafana" => cleanup_stale_pasta_port("3000").await,
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"searxng" => cleanup_stale_pasta_port("8888").await,
"uptime-kuma" => cleanup_stale_pasta_port("3002").await,
"gitea" => {
cleanup_stale_pasta_port("3001").await;
cleanup_stale_pasta_port("2222").await;
cleanup_stale_pasta_port("3000").await;
}
"nginx-proxy-manager" => {
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}
"nextcloud" => cleanup_stale_pasta_port("8085").await,
"portainer" => cleanup_stale_pasta_port("9000").await,
_ => {}
}
// Never kill by port: another app or the management gate may own it.
crate::container::ghost_reaper::reap_for_app(package_id).await;
}
fn install_command_tail(
@@ -2196,93 +2179,11 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
return true;
}
match package_id {
"grafana"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("3000").await;
true
}
"homeassistant" | "home-assistant"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8123").await;
true
}
"searxng"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8888").await;
true
}
"uptime-kuma"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("3002").await;
true
}
"gitea" if stderr.contains("pasta failed") || stderr.contains("address already in use") => {
cleanup_stale_pasta_port("3001").await;
cleanup_stale_pasta_port("2222").await;
cleanup_stale_pasta_port("3000").await;
true
}
"nginx-proxy-manager"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
true
}
"nextcloud"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8085").await;
true
}
"portainer"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("9000").await;
true
}
_ => false,
if stderr.contains("pasta failed") || stderr.contains("address already in use") {
crate::container::ghost_reaper::reap_for_app(package_id).await;
return true;
}
}
async fn cleanup_stale_pasta_port(port: &str) {
// NEVER kill our own process. The daemon holds catalog app ports over
// IPv6 (the mesh app-port relay), so a blunt `fuser -k <port>/tcp` would
// terminate archipelago itself mid-install — installs failed and apps
// vanished on a test node 2026-07-27. Kill every listener on the port
// EXCEPT our PID (and our process group), leaving the relay/daemon alive.
let self_pid = std::process::id();
let kill_listener = format!(
"ss -ltnp 'sport = :{port}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | \
while read p; do [ \"$p\" = \"{self_pid}\" ] || kill \"$p\" 2>/dev/null; done || true",
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
// sudo fuser -k, but exclude our own PID: fuser prints the PIDs holding
// the port; kill each except self. (`fuser -k` has no exclusion flag.)
let fuser_kill = format!(
"for p in $(sudo fuser {port}/tcp 2>/dev/null); do [ \"$p\" = \"{self_pid}\" ] || sudo kill \"$p\" 2>/dev/null; done || true",
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &fuser_kill])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
false
}
async fn repair_nextcloud_permissions() {
@@ -14,20 +14,23 @@ impl RpcHandler {
/// the rare case where the pull stream actually parses, but podman
/// almost never emits parseable progress on a piped stderr.
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
entry.state = PackageState::Installing;
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase: existing_phase,
message: None,
});
self.state_manager.update_data(data).await;
self.state_manager
.mutate_data(|data| {
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
entry.ui_ready = Some(false);
entry.state = PackageState::Installing;
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase: existing_phase,
message: None,
});
})
.await;
}
/// Set the install pipeline phase and broadcast. This is the
@@ -35,76 +38,86 @@ impl RpcHandler {
/// percentage and a user-facing label. Byte counters are retained
/// for the rare case podman emits parseable progress.
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
// Preparing / PullingImage / CreatingContainer / StartingContainer /
// WaitingHealthy / PostInstall all map to the Installing state.
// Updates use Updating state — the wrapper has already flipped
// state to Updating, so don't clobber it.
if entry.state != PackageState::Updating {
entry.state = PackageState::Installing;
}
let (size, downloaded) = entry
.install_progress
.as_ref()
.map(|p| (p.size, p.downloaded))
.unwrap_or((0, 0));
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase: Some(phase),
message: None,
});
self.state_manager.update_data(data).await;
self.state_manager
.mutate_data(|data| {
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
// Preparing / PullingImage / CreatingContainer / StartingContainer /
// WaitingHealthy / PostInstall all map to the Installing state.
// Updates use Updating state — the wrapper has already flipped
// state to Updating, so don't clobber it.
if entry.state != PackageState::Updating {
entry.ui_ready = Some(false);
entry.state = PackageState::Installing;
}
let (size, downloaded) = entry
.install_progress
.as_ref()
.map(|p| (p.size, p.downloaded))
.unwrap_or((0, 0));
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase: Some(phase),
message: None,
});
})
.await;
}
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin
/// to start…") without disturbing the current phase/byte counters.
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
if entry.state != PackageState::Updating {
entry.state = PackageState::Installing;
}
let (size, downloaded, phase) = entry
.install_progress
.as_ref()
.map(|p| (p.size, p.downloaded, p.phase))
.unwrap_or((0, 0, None));
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase,
message: Some(message.to_string()),
});
self.state_manager.update_data(data).await;
self.state_manager
.mutate_data(|data| {
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
if entry.state != PackageState::Updating {
entry.ui_ready = Some(false);
entry.state = PackageState::Installing;
}
let (size, downloaded, phase) = entry
.install_progress
.as_ref()
.map(|p| (p.size, p.downloaded, p.phase))
.unwrap_or((0, 0, None));
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase,
message: Some(message.to_string()),
});
})
.await;
}
/// Clear install progress after pull completes or fails.
pub(super) async fn clear_install_progress(&self, package_id: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.install_progress = None;
}
self.state_manager.update_data(data).await;
self.state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.install_progress = None;
}
})
.await;
}
/// Set the uninstall stage label so the UI can show what's happening
/// instead of a generic spinner. Each call broadcasts a state change
/// — call sparingly (one per pipeline phase, not per container).
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.uninstall_stage = Some(stage.to_string());
entry.state = crate::data_model::PackageState::Removing;
}
self.state_manager.update_data(data).await;
self.state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.uninstall_stage = Some(stage.to_string());
entry.state = crate::data_model::PackageState::Removing;
}
})
.await;
}
/// Update install progress (static method for use in async closures).
@@ -114,25 +127,28 @@ impl RpcHandler {
downloaded: u64,
total: u64,
) {
let (mut data, _rev) = state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size: total,
downloaded,
phase: existing_phase,
message: None,
});
state_manager.update_data(data).await;
state_manager
.mutate_data(|data| {
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size: total,
downloaded,
phase: existing_phase,
message: None,
});
})
.await;
}
}
/// Create a minimal PackageDataEntry for a package being installed.
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
PackageDataEntry {
ui_ready: None,
state: PackageState::Installing,
health: None,
exit_code: None,
+46 -64
View File
@@ -1431,10 +1431,9 @@ async fn repair_before_package_start(container_name: &str) {
// published port and the data-dir file locks, so the replacement either
// fails to bind (`address already in use`) or starts and dies on the
// lock — and `Restart=always` loops it there forever. Ordered before
// the port cleanup below: killing the owner is what actually frees the
// port, and the port sweep alone cannot tell a ghost from a live app.
// starting the replacement. A port sweep cannot distinguish a ghost
// from the dashboard gate or another live app and must never kill it.
crate::container::ghost_reaper::reap_for_app(container_name).await;
cleanup_runtime_host_ports(container_name).await;
}
async fn wait_before_package_start(container_name: &str) {
@@ -1579,7 +1578,6 @@ async fn repair_netbird_network() {
async fn repair_nginx_proxy_manager_container() {
repair_nginx_proxy_manager_dirs().await;
if !nginx_proxy_manager_has_legacy_admin_port().await {
cleanup_nginx_proxy_manager_ports().await;
return;
}
@@ -1588,7 +1586,7 @@ async fn repair_nginx_proxy_manager_container() {
)
.await;
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
cleanup_nginx_proxy_manager_ports().await;
crate::container::ghost_reaper::reap_for_app("nginx-proxy-manager").await;
if let Err(err) = recreate_nginx_proxy_manager_container().await {
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
}
@@ -1812,6 +1810,9 @@ fn manifest_host_ports(container_name: &str) -> Vec<u16> {
pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
let mut dirs = Vec::new();
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
dirs.push(root.into());
}
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(Path::new(&manifest_dir).join("../../apps"));
}
@@ -2032,51 +2033,10 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
return;
}
let ports = runtime_host_ports(container_name);
if !ports.is_empty() {
cleanup_ports(&ports).await;
return;
}
}
async fn cleanup_runtime_host_ports(container_name: &str) {
let ports = runtime_host_ports(container_name);
if !ports.is_empty() {
cleanup_ports(&ports).await;
}
}
async fn cleanup_nginx_proxy_manager_ports() {
cleanup_ports(&[8081, 8084, 8444]).await;
}
async fn cleanup_ports(ports: &[u16]) {
for port in ports {
cleanup_stale_pasta_port(&port.to_string()).await;
}
}
async fn cleanup_stale_pasta_port(port: &str) {
let kill_listener = format!(
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
port
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
let pattern = format!("rootlessport.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// Only reap processes proven to belong to an absent container. The app
// gate shares the app's port on other addresses and lives in this daemon;
// killing port owners (or matching argv with pkill) kills the dashboard.
crate::container::ghost_reaper::reap_for_app(container_name).await;
}
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
@@ -2095,13 +2055,16 @@ async fn flip_package_state(
package_id: &str,
transitional: PackageState,
) -> Option<PackageState> {
let (mut data, _) = state_manager.get_snapshot().await;
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = transitional;
state_manager.update_data(data).await;
}
prev
state_manager
.mutate_data(|data| {
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.ui_ready = Some(false);
entry.state = transitional;
}
prev
})
.await
}
/// Write the package entry's final state. No-op if the entry has since
@@ -2111,13 +2074,18 @@ async fn set_package_state(
package_id: &str,
new_state: PackageState,
) {
let (mut data, _) = state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
if entry.state != new_state {
entry.state = new_state;
state_manager.update_data(data).await;
}
}
state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
if entry.state != new_state {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
}
}
})
.await
}
pub(super) async fn reconcile_companions_for(package_id: &str) {
@@ -2185,6 +2153,20 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
mod tests {
use super::*;
#[tokio::test]
async fn port_conflict_cleanup_preserves_live_host_listener() {
// The previous ss|kill sweep terminated the daemon's app gate on a
// restart. Keep a real listening socket owned by this test process.
let listener = tokio::net::TcpListener::bind("127.0.0.2:2342")
.await
.unwrap();
let addr = listener.local_addr().unwrap();
cleanup_start_conflict("photoprism", "address already in use").await;
let client = tokio::net::TcpStream::connect(addr).await.unwrap();
let _connection = listener.accept().await.unwrap();
drop(client);
}
#[test]
fn missing_container_classifier_covers_podman5_phrasings() {
// Regression (.228 gate 2026-07-08): podman 5.x `inspect` on a missing
+22 -14
View File
@@ -150,23 +150,31 @@ async fn flip_to_transitional(
app_id: &str,
transitional: PackageState,
) -> Option<PackageState> {
let (mut data, _) = state_manager.get_snapshot().await;
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(app_id) {
entry.state = transitional;
state_manager.update_data(data).await;
}
prev
state_manager
.mutate_data(|data| {
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(app_id) {
entry.ui_ready = Some(false);
entry.state = transitional;
}
prev
})
.await
}
/// Set the entry's state to `new_state`. No-ops if the entry has since been
/// removed (e.g. uninstall ran concurrently).
async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) {
let (mut data, _) = state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(app_id) {
if entry.state != new_state {
entry.state = new_state;
state_manager.update_data(data).await;
}
}
state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(app_id) {
if entry.state != new_state {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
}
}
})
.await
}
+3
View File
@@ -114,6 +114,9 @@ impl PortMap {
/// there.
fn apps_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
dirs.push(root.into());
}
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
}
+48 -3
View File
@@ -144,6 +144,34 @@ pub fn shared_status() -> Arc<RwLock<GateStatus>> {
.clone()
}
static REFRESH_KICK: std::sync::LazyLock<tokio::sync::Notify> =
std::sync::LazyLock::new(tokio::sync::Notify::new);
static REFRESH_REV: std::sync::LazyLock<tokio::sync::watch::Sender<u64>> =
std::sync::LazyLock::new(|| tokio::sync::watch::channel(0).0);
/// Installation must not wait for the minute sweep before becoming reachable.
/// Wait for a completed sweep, bounded if shutdown/startup prevents one.
pub async fn refresh_now() {
let mut completed = REFRESH_REV.subscribe();
REFRESH_KICK.notify_one();
let _ = tokio::time::timeout(std::time::Duration::from_secs(3), completed.changed()).await;
}
pub fn port_claimed(status: &GateStatus, port: u16) -> bool {
let mut external = false;
let mut tor = false;
for (claimed_port, address) in &status.claimed {
if *claimed_port != port {
continue;
}
if let Ok(ip) = address.parse::<IpAddr>() {
tor |= ip == GATE_TOR_UPSTREAM;
external |= !ip.is_loopback();
}
}
external && tor
}
/// Run the gate. Returns only on shutdown.
pub async fn run(
gate: Arc<AppGate>,
@@ -162,11 +190,12 @@ pub async fn run(
loop {
tokio::select! {
_ = interval.tick() => {
sweep(&gate, &status, &mut held, &shutdown_rx).await;
}
_ = interval.tick() => {}
_ = REFRESH_KICK.notified() => {}
_ = shutdown_rx.changed() => return,
}
sweep(&gate, &status, &mut held, &shutdown_rx).await;
REFRESH_REV.send_modify(|revision| *revision = revision.wrapping_add(1));
}
}
@@ -461,3 +490,19 @@ mod tests {
assert!(!status.is_fully_enforced());
}
}
#[cfg(test)]
mod readiness_tests {
use super::*;
#[test]
fn readiness_requires_external_and_tor_claims_for_the_same_port() {
let mut status = GateStatus::default();
assert!(!port_claimed(&status, 3001));
status.claimed.push((3001, "127.0.0.2".into()));
assert!(!port_claimed(&status, 3001));
status.claimed.push((3002, "192.0.2.10".into()));
assert!(!port_claimed(&status, 3001));
status.claimed.push((3001, "192.0.2.10".into()));
assert!(port_claimed(&status, 3001));
}
}
+1
View File
@@ -322,6 +322,7 @@ async fn eval_rpc_handler() -> (Arc<RpcHandler>, tempfile::TempDir) {
fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry {
use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles};
PackageDataEntry {
ui_ready: None,
state: PackageState::Running,
health: None,
exit_code: None,
+1
View File
@@ -1069,6 +1069,7 @@ mod tests {
Description, Manifest, PackageDataEntry, PackageState, StaticFiles,
};
PackageDataEntry {
ui_ready: None,
state: PackageState::Running,
health: None,
exit_code: None,
@@ -3,8 +3,9 @@
use anyhow::Result;
use archipelago_container::{
ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient,
ContainerRuntime as ContainerRuntimeTrait, ContainerState, ContainerStatus, PodmanClient,
};
use futures_util::StreamExt;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{debug, info};
@@ -25,8 +26,15 @@ impl DockerPackageScanner {
}
/// Scan Docker containers and convert to package data
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
let containers = self.runtime.list_containers().await?;
pub async fn scan_containers(
&self,
data_dir: &std::path::Path,
cached: &HashMap<String, PackageDataEntry>,
) -> Result<HashMap<String, PackageDataEntry>> {
let mut containers = self.runtime.list_containers().await?;
let installed = crate::crash_recovery::load_installed_apps(data_dir).await;
let uninstalled = crate::crash_recovery::load_user_uninstalled(data_dir).await;
restore_absent_installed(&mut containers, &installed, &uninstalled);
debug!("Found {} containers", containers.len());
@@ -139,6 +147,18 @@ impl DockerPackageScanner {
continue;
}
if container.id.is_empty() {
if let Some(previous) = cached.get(&app_id) {
let mut held = previous.clone();
held.state = PackageState::Stopped;
held.ui_ready = Some(false);
held.health = None;
held.exit_code = None;
packages.insert(app_id.clone(), held);
continue;
}
}
// Get metadata for this app
let metadata = get_app_metadata(&app_id);
// Manifest-owned metadata (icon) wins over the static table: the
@@ -179,14 +199,22 @@ impl DockerPackageScanner {
let tor_address = read_tor_address(&app_id).await;
// Extract actual version from container image tag
let running_version = image_versions::extract_version_from_image(&container.image);
let running_version = if container.id.is_empty() {
String::new() // Absence cannot establish the installed image version.
} else {
image_versions::extract_version_from_image(&container.image)
};
// Decoupled from the binary OTA: prefer the remote app catalog,
// falling back to the image-versions.sh pin when uncovered/offline.
let available_update =
crate::container::app_catalog::available_update_for_app(&app_id, &container.image);
let available_update = if container.id.is_empty() {
None
} else {
crate::container::app_catalog::available_update_for_app(&app_id, &container.image)
};
let package = PackageDataEntry {
ui_ready: Some(false),
state: package_state.clone(),
health: container.health.clone(),
exit_code: if package_state == PackageState::Exited {
@@ -283,10 +311,215 @@ impl DockerPackageScanner {
);
}
let probes: Vec<_> = packages
.iter()
.filter_map(|(id, pkg)| {
if pkg.state != PackageState::Running {
return None;
}
let url = pkg
.installed
.as_ref()?
.interface_addresses
.get("main")?
.lan_address
.clone()?;
Some((id.clone(), url))
})
.collect();
let mut results = futures_util::stream::iter(
probes
.into_iter()
.map(|(id, url)| async move { (id, launch_http_ready(&url).await) }),
)
.buffer_unordered(8);
while let Some((id, ready)) = results.next().await {
if let Some(pkg) = packages.get_mut(&id) {
pkg.ui_ready = Some(ready);
}
}
// HTTP on loopback can precede the LAN/Tor listener after install.
let port_map = crate::appgate::identity::build_port_map();
let gated: Vec<_> = packages
.iter()
.filter_map(|(id, pkg)| {
if pkg.ui_ready != Some(true) {
return None;
}
let url = pkg
.installed
.as_ref()?
.interface_addresses
.get("main")?
.lan_address
.as_deref()?;
let port = launch_url_port(url)?;
port_map
.gated(port)
.filter(|gate| gate.declared)
.map(|_| (id.clone(), port))
})
.collect();
if !gated.is_empty() {
use crate::appgate::listener::{port_claimed, refresh_now, shared_status};
let status = shared_status();
let needs_refresh = {
let current = status.read().await;
gated.iter().any(|(_, port)| !port_claimed(&current, *port))
};
if needs_refresh {
refresh_now().await;
}
let current = status.read().await;
for (id, port) in gated {
if !port_claimed(&current, port) {
packages.get_mut(&id).unwrap().ui_ready = Some(false);
}
}
}
Ok(packages)
}
}
/// Quadlet removes containers during ordinary stops/restarts. Rebuild installed
/// entries even on the daemon's first scan; a runtime absence is not uninstall.
fn restore_absent_installed(
containers: &mut Vec<ContainerStatus>,
installed: &std::collections::HashSet<String>,
uninstalled: &std::collections::HashSet<String>,
) {
fn canonical(name: &str) -> &str {
let name = name.strip_prefix("archy-").unwrap_or(name);
match name {
"immich_server" => "immich",
_ => name,
}
}
let mut present: std::collections::HashSet<String> = containers
.iter()
.map(|c| canonical(&c.name).to_owned())
.collect();
let removed: std::collections::HashSet<_> =
uninstalled.iter().map(|id| canonical(id)).collect();
for name in installed {
let id = canonical(name);
if removed.contains(id) || !present.insert(id.to_owned()) {
continue;
}
containers.push(ContainerStatus {
id: String::new(),
name: id.to_owned(),
state: ContainerState::Stopped,
health: None,
exit_code: None,
started_at: None,
image: String::new(),
created: String::new(),
ports: Vec::new(),
lan_address: None,
});
}
}
/// Probe the actual loopback upstream, not the app gate's login page. A bound
/// TCP socket alone can still reset requests or serve a startup 503.
async fn launch_http_ready(candidate: &str) -> bool {
let Ok(mut url) = reqwest::Url::parse(candidate) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") {
return false;
}
if url.set_host(Some("127.0.0.1")).is_err() {
return false;
}
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
let client = CLIENT.get_or_init(|| {
reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(2))
.redirect(reqwest::redirect::Policy::none())
// Self-signed local app certificates are normal. This client only
// contacts loopback and never sends credentials or follows redirects.
.danger_accept_invalid_certs(true)
.build()
.expect("local readiness client")
});
match client.get(url).send().await {
Ok(response) => matches!(response.status().as_u16(), 200..=399 | 401 | 403),
Err(_) => false,
}
}
#[cfg(test)]
mod lifecycle_regression_tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test]
fn registry_survives_empty_runtime_and_deduplicates_aliases() {
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
.into_iter()
.map(str::to_owned)
.collect();
let removed = ["removed".to_owned()].into_iter().collect();
let mut containers = Vec::new();
restore_absent_installed(&mut containers, &installed, &removed);
assert_eq!(containers.len(), 2);
assert!(containers
.iter()
.all(|c| c.state == ContainerState::Stopped));
containers[0].state = ContainerState::Running;
restore_absent_installed(&mut containers, &installed, &removed);
assert_eq!(containers.len(), 2);
assert_eq!(containers[0].state, ContainerState::Running);
}
#[tokio::test]
async fn readiness_rejects_startup_errors_and_accepts_auth_and_redirects() {
for (status, expected) in [
(200, true),
(302, true),
(401, true),
(403, true),
(404, false),
(500, false),
(502, false),
(503, false),
] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0; 2048];
let n = stream.read(&mut buf).await.unwrap();
assert!(String::from_utf8_lossy(&buf[..n]).starts_with("GET /start HTTP/1.1"));
stream.write_all(format!("HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").as_bytes()).await.unwrap();
});
assert_eq!(
launch_http_ready(&format!("http://localhost:{port}/start")).await,
expected,
"status {status}"
);
task.await.unwrap();
}
}
#[tokio::test]
async fn readiness_rejects_tcp_accept_without_http() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
drop(stream);
});
assert!(!launch_http_ready(&format!("http://localhost:{port}/")).await);
task.await.unwrap();
assert!(!launch_http_ready(&format!("http://localhost:{port}/")).await);
assert!(!launch_http_ready("file:///tmp/test").await);
}
}
struct AppMetadata {
title: String,
description: String,
@@ -3483,11 +3483,9 @@ impl ProdContainerOrchestrator {
}
async fn cleanup_stale_grafana_port(&self) {
let _ = tokio::process::Command::new("pkill")
.args(["-f", "pasta.*3001"])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// Port 3001 can belong to Gitea or the daemon's gate. Reap only a
// Grafana container proven absent from Podman's inventory.
crate::container::ghost_reaper::reap_for_app("grafana").await;
}
async fn detect_host_facts(&self) -> HostFacts {
+40 -471
View File
@@ -238,11 +238,6 @@ pub enum ServeResult {
Forbidden,
/// Content not found.
NotFound,
/// The catalog entry and file exist but this node can't read the file.
/// Returned before any payment is taken.
Unavailable,
/// Requested byte range cannot be served; no payment was taken.
RangeNotSatisfiable(u64),
}
/// Serve a content item by ID with access control and optional range request.
@@ -257,39 +252,6 @@ pub async fn serve_content(
range: Option<ByteRange>,
owner_session: bool,
) -> Result<ServeResult> {
serve_content_with(
data_dir,
id,
payment_token,
invoice_hash,
peer_did,
range,
owner_session,
|path, range, mime| prepare_content(data_dir, path, range, mime),
|token, amount| async move { verify_payment_token(data_dir, &token, amount).await },
)
.await
}
// Inject only the read and payment boundaries, so tests can prove ordering
// without mint access, file-permission assumptions or privileged commands.
async fn serve_content_with<R, RF, V, VF>(
data_dir: &Path,
id: &str,
payment_token: Option<&str>,
invoice_hash: Option<&str>,
peer_did: Option<&str>,
range: Option<ByteRange>,
owner_session: bool,
read: R,
verify: V,
) -> Result<ServeResult>
where
R: FnOnce(PathBuf, Option<ByteRange>, String) -> RF,
RF: std::future::Future<Output = Result<ServeResult>>,
V: FnOnce(String, u64) -> VF,
VF: std::future::Future<Output = bool>,
{
let catalog = load_catalog(data_dir).await?;
let item = match catalog.items.iter().find(|i| i.id == id) {
Some(i) => i,
@@ -352,29 +314,6 @@ where
return Ok(ServeResult::NotFound);
}
// Refuse unauthorized viewers before opening or reading any bytes.
if !owner_session && matches!(item.access, AccessControl::PeersOnly) && !is_known_peer {
return Ok(ServeResult::Forbidden);
}
if !owner_session {
if let AccessControl::Paid { price_sats, .. } = &item.access {
if payment_token.is_none() && invoice_hash.is_none() {
return Ok(ServeResult::PaymentRequired(*price_sats));
}
}
}
// Finish all file I/O before consuming bearer payment. Merely opening then
// reopening after charging still lost payments on read errors or deletion.
let prepared = match read(file_path, range, item.mime_type.clone()).await {
Ok(result @ (ServeResult::Ok(..) | ServeResult::Partial { .. })) => result,
Ok(other) => return Ok(other),
Err(error) => {
warn!(content_id = %id, "Cannot prepare shared content: {error:#}");
return Ok(ServeResult::Unavailable);
}
};
// Check access control
if !owner_session {
match &item.access {
@@ -392,7 +331,7 @@ where
"fedimint"
};
if method_accepted(&item.access, method)
&& verify(token.to_owned(), *price_sats).await
&& verify_payment_token(data_dir, token, *price_sats).await
{
authorized = true;
}
@@ -419,127 +358,55 @@ where
}
}
Ok(prepared)
}
async fn prepare_content(
data_dir: &Path,
path: PathBuf,
range: Option<ByteRange>,
mime: String,
) -> Result<ServeResult> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut file = match fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(&path)
let metadata = fs::metadata(&file_path)
.await
{
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
let bytes = read_filebrowser_via_userns(data_dir, &path).await?;
return slice_prepared_content(bytes, range, mime);
}
Err(error) => return Err(error).context("Opening shared content"),
};
let metadata = file.metadata().await?;
anyhow::ensure!(metadata.is_file(), "Shared content is not a regular file");
let total = metadata.len();
.context("Failed to read file metadata")?;
let total_size = metadata.len();
// Handle range request for streaming
if let Some(range) = range {
let Some((start, end)) = checked_range(&range, total) else {
return Ok(ServeResult::RangeNotSatisfiable(total));
};
file.seek(std::io::SeekFrom::Start(start)).await?;
let len = usize::try_from(end - start + 1).context("Content range is too large")?;
let mut bytes = vec![0; len];
file.read_exact(&mut bytes)
let start = range.start.min(total_size.saturating_sub(1));
let end = range
.end
.map(|e| e.min(total_size - 1))
.unwrap_or(total_size - 1);
if start > end || start >= total_size {
return Ok(ServeResult::NotFound);
}
let len = (end - start + 1) as usize;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut file = tokio::fs::File::open(&file_path)
.await
.context("Reading shared content range")?;
.context("Failed to open content file")?;
file.seek(std::io::SeekFrom::Start(start))
.await
.context("Failed to seek")?;
let mut buf = vec![0u8; len];
file.read_exact(&mut buf)
.await
.context("Failed to read range")?;
debug!(
"Serving content '{}' range {}-{}/{} ({} bytes)",
id, start, end, total_size, len
);
return Ok(ServeResult::Partial {
bytes,
mime_type: mime,
bytes: buf,
mime_type: item.mime_type.clone(),
start,
end,
total,
total: total_size,
});
}
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
let bytes = fs::read(&file_path)
.await
.context("Reading shared content")?;
Ok(ServeResult::Ok(bytes, mime))
}
.context("Failed to read content file")?;
fn checked_range(range: &ByteRange, total: u64) -> Option<(u64, u64)> {
let last = total.checked_sub(1)?;
let end = range.end.unwrap_or(last).min(last);
(range.start <= end && range.start < total).then_some((range.start, end))
}
fn slice_prepared_content(
bytes: Vec<u8>,
range: Option<ByteRange>,
mime: String,
) -> Result<ServeResult> {
let total = bytes.len() as u64;
match range {
None => Ok(ServeResult::Ok(bytes, mime)),
Some(range) => match checked_range(&range, total) {
Some((start, end)) => Ok(ServeResult::Partial {
bytes: bytes[start as usize..=end as usize].to_vec(),
mime_type: mime,
start,
end,
total,
}),
None => Ok(ServeResult::RangeNotSatisfiable(total)),
},
}
}
/// Read only an explicitly shared, regular file within FileBrowser storage.
/// Do not change its mode or grant world-readable access to paid/private data.
async fn filebrowser_read_path(data_dir: &Path, path: &Path) -> Result<PathBuf> {
let root = fs::canonicalize(data_dir.join("filebrowser")).await?;
let target = fs::canonicalize(path).await?;
anyhow::ensure!(
target.starts_with(&root) && target != root,
"Shared file is outside Files storage"
);
anyhow::ensure!(
fs::metadata(&target).await?.is_file(),
"Shared content is not a regular file"
);
Ok(target)
}
async fn read_filebrowser_via_userns(data_dir: &Path, path: &Path) -> Result<Vec<u8>> {
let path = filebrowser_read_path(data_dir, path).await?;
// Tests exercise the boundary explicitly; they never launch the host Podman.
#[cfg(test)]
{
let _ = path;
anyhow::bail!("Files namespace read disabled in unit tests")
}
#[cfg(not(test))]
{
let output = tokio::time::timeout(
std::time::Duration::from_secs(900),
tokio::process::Command::new("podman")
.args(["unshare", "cat", "--"])
.arg(path)
.kill_on_drop(true)
.output(),
)
.await
.context("Files namespace read timed out")??;
anyhow::ensure!(
output.status.success(),
"Files namespace read failed: {}",
output.status
);
Ok(output.stdout)
}
debug!("Serving content '{}' ({} bytes)", id, bytes.len());
Ok(ServeResult::Ok(bytes, item.mime_type.clone()))
}
/// Result of attempting to serve a preview.
@@ -862,301 +729,3 @@ mod prune_missing_content_tests {
assert_eq!(reloaded.items[0].id, "present-item");
}
}
#[cfg(test)]
mod paid_read_order_tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
async fn fixture(bytes: &[u8]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("content/files"))
.await
.unwrap();
fs::write(dir.path().join("content/files/test.bin"), bytes)
.await
.unwrap();
save_catalog(
dir.path(),
&ContentCatalog {
items: vec![ContentItem {
id: "paid".into(),
filename: "test.bin".into(),
mime_type: "application/octet-stream".into(),
size_bytes: bytes.len() as u64,
description: String::new(),
access: AccessControl::Paid {
price_sats: 10,
accepted: vec!["ecash".into()],
},
availability: Availability::AllPeers,
added_at: "2026-09-30".into(),
}],
},
)
.await
.unwrap();
dir
}
#[tokio::test]
async fn all_read_failures_precede_redemption_even_as_root() {
for kind in [
std::io::ErrorKind::PermissionDenied,
std::io::ErrorKind::UnexpectedEof,
std::io::ErrorKind::NotFound,
std::io::ErrorKind::Other,
] {
let dir = fixture(b"abc").await;
let charged = AtomicUsize::new(0);
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
None,
false,
|_, _, _| async move { Err(std::io::Error::from(kind).into()) },
|_, _| async {
charged.fetch_add(1, Ordering::SeqCst);
true
},
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Unavailable));
assert_eq!(charged.load(Ordering::SeqCst), 0);
assert_eq!(load_catalog(dir.path()).await.unwrap().items.len(), 1);
}
}
#[tokio::test]
async fn deletion_during_payment_cannot_lose_prepared_bytes() {
let dir = fixture(b"original").await;
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
None,
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, amount| {
assert_eq!(amount, 10);
async {
fs::remove_file(dir.path().join("content/files/test.bin"))
.await
.unwrap();
true
}
},
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"original"));
}
#[tokio::test]
async fn empty_out_of_bounds_and_reversed_ranges_never_charge() {
for (bytes, start, end) in [
(b"".as_slice(), 0, None),
(b"abc".as_slice(), 3, None),
(b"abc".as_slice(), 2, Some(1)),
] {
let dir = fixture(bytes).await;
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
Some(ByteRange { start, end }),
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async { panic!("invalid range reached payment") },
)
.await
.unwrap();
assert!(
matches!(result, ServeResult::RangeNotSatisfiable(n) if n == bytes.len() as u64)
);
}
}
#[tokio::test]
async fn prepared_range_survives_file_change_while_payment_is_verified() {
let dir = fixture(b"abcdef").await;
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
Some(ByteRange {
start: 2,
end: Some(999),
}),
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async {
fs::write(dir.path().join("content/files/test.bin"), b"x")
.await
.unwrap();
true
},
)
.await
.unwrap();
assert!(
matches!(result, ServeResult::Partial { bytes, start: 2, end: 5, total: 6, .. } if bytes == b"cdef")
);
}
#[tokio::test]
async fn payment_denial_never_returns_prepared_content() {
let dir = fixture(b"secret").await;
let charged = AtomicUsize::new(0);
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
None,
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async {
charged.fetch_add(1, Ordering::SeqCst);
false
},
)
.await
.unwrap();
assert!(matches!(result, ServeResult::PaymentRequired(10)));
assert_eq!(charged.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn missing_payment_and_peer_restrictions_precede_file_reads() {
let dir = fixture(b"secret").await;
let result = serve_content_with(
dir.path(),
"paid",
None,
None,
None,
None,
false,
|_, _, _| async { panic!("unauthorized file read") },
|_, _| async { panic!("unexpected payment") },
)
.await
.unwrap();
assert!(matches!(result, ServeResult::PaymentRequired(10)));
let mut catalog = load_catalog(dir.path()).await.unwrap();
catalog.items[0].access = AccessControl::PeersOnly;
save_catalog(dir.path(), &catalog).await.unwrap();
let result = serve_content_with(
dir.path(),
"paid",
None,
None,
None,
None,
false,
|_, _, _| async { panic!("unauthorized file read") },
|_, _| async { panic!("unexpected payment") },
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Forbidden));
}
#[tokio::test]
async fn owner_reads_paid_content_without_redemption() {
let dir = fixture(b"own file").await;
let result = serve_content_with(
dir.path(),
"paid",
None,
None,
None,
None,
true,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async { panic!("owner charged") },
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"own file"));
}
#[tokio::test]
async fn directory_in_place_of_file_does_not_charge() {
let dir = fixture(b"abc").await;
let path = dir.path().join("content/files/test.bin");
fs::remove_file(&path).await.unwrap();
fs::create_dir(&path).await.unwrap();
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
None,
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async { panic!("directory charged") },
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Unavailable));
}
#[tokio::test]
async fn files_namespace_read_is_scoped_to_regular_files_and_keeps_mode() {
use std::os::unix::fs::{symlink, PermissionsExt};
let dir = fixture(b"outside").await;
let root = dir.path().join("filebrowser");
fs::create_dir(&root).await.unwrap();
let inside = root.join("song");
fs::write(&inside, b"song").await.unwrap();
fs::set_permissions(&inside, std::fs::Permissions::from_mode(0o640))
.await
.unwrap();
assert_eq!(
filebrowser_read_path(dir.path(), &inside).await.unwrap(),
inside
);
assert_eq!(
fs::metadata(&inside).await.unwrap().permissions().mode() & 0o777,
0o640
);
let outside = dir.path().join("content/files/test.bin");
symlink(&outside, root.join("escape")).unwrap();
for path in [outside, root.join("escape"), root.clone()] {
assert!(filebrowser_read_path(dir.path(), &path).await.is_err());
}
}
#[test]
fn user_namespace_bytes_use_the_same_range_rules() {
assert!(matches!(
slice_prepared_content(
vec![],
Some(ByteRange {
start: 0,
end: None
}),
"x".into()
)
.unwrap(),
ServeResult::RangeNotSatisfiable(0)
));
assert!(
matches!(slice_prepared_content(b"abc".to_vec(), Some(ByteRange { start: 1, end: None }), "x".into()).unwrap(), ServeResult::Partial { bytes, start: 1, end: 2, total: 3, .. } if bytes == b"bc")
);
}
}
+38 -1
View File
@@ -194,6 +194,7 @@ pub async fn clear_user_stopped(data_dir: &Path, name: &str) {
// Installation is a decision, not a runtime observation, so it gets a record
// of its own that no amount of downtime erodes.
const INSTALLED_APPS_FILE: &str = "installed-apps.json";
static INSTALLED_APPS_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// Load the durable set of installed app ids / container names.
pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<String> {
@@ -220,12 +221,23 @@ pub async fn load_installed_apps_if_recorded(
async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) {
let path = data_dir.join(INSTALLED_APPS_FILE);
if let Ok(json) = serde_json::to_string_pretty(installed) {
let _ = fs::write(&path, json).await;
let tmp = path.with_extension("json.tmp");
let result = async {
fs::write(&tmp, json).await?;
fs::File::open(&tmp).await?.sync_all().await?;
fs::rename(&tmp, &path).await?;
fs::File::open(data_dir).await?.sync_all().await
}
.await;
if let Err(error) = result {
warn!(%error, "could not persist installed apps");
}
}
}
/// Record that an app is installed. Called when an install succeeds.
pub async fn mark_installed(data_dir: &Path, name: &str) {
let _guard = INSTALLED_APPS_LOCK.lock().await;
let mut installed = load_installed_apps(data_dir).await;
if installed.insert(name.to_string()) {
save_installed_apps(data_dir, &installed).await;
@@ -235,6 +247,7 @@ pub async fn mark_installed(data_dir: &Path, name: &str) {
/// Forget an app. Called on uninstall, beside `mark_user_uninstalled` — the
/// two must move together or a reinstall-after-uninstall leaves a stale claim.
pub async fn clear_installed(data_dir: &Path, name: &str) {
let _guard = INSTALLED_APPS_LOCK.lock().await;
let mut installed = load_installed_apps(data_dir).await;
if installed.remove(name) {
save_installed_apps(data_dir, &installed).await;
@@ -252,6 +265,7 @@ pub async fn clear_installed(data_dir: &Path, name: &str) {
/// need it. Runs on every boot, so an app installed before the upgrade is
/// still picked up whenever it is next seen alive.
pub async fn backfill_installed_apps(data_dir: &Path, present_container_names: &[String]) {
let _guard = INSTALLED_APPS_LOCK.lock().await;
if present_container_names.is_empty() {
return;
}
@@ -1497,3 +1511,26 @@ mod tests {
);
}
}
#[cfg(test)]
mod installed_concurrency_tests {
use super::*;
#[tokio::test]
async fn concurrent_install_records_are_not_lost() {
let dir = tempfile::tempdir().unwrap();
let mut tasks = Vec::new();
for i in 0..24 {
let path = dir.path().to_owned();
tasks.push(tokio::spawn(async move {
mark_installed(&path, &format!("app-{i}")).await;
}));
}
for task in tasks {
task.await.unwrap();
}
assert_eq!(load_installed_apps(dir.path()).await.len(), 24);
clear_installed(dir.path(), "app-3").await;
assert_eq!(load_installed_apps(dir.path()).await.len(), 23);
assert!(!dir.path().join("installed-apps.json.tmp").exists());
}
}
+6
View File
@@ -146,6 +146,10 @@ pub enum PackageState {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PackageDataEntry {
/// Whether the app's HTTP upstream answered this scan (independent of
/// container health and blockchain sync). Missing on older nodes.
#[serde(rename = "ui-ready", default, skip_serializing_if = "Option::is_none")]
pub ui_ready: Option<bool>,
pub state: PackageState,
/// Container health: "healthy", "unhealthy", "starting", or null
#[serde(skip_serializing_if = "Option::is_none")]
@@ -297,6 +301,8 @@ pub enum InstallPhase {
/// `podman pull` in progress (the longest phase — up to several
/// minutes for large images on slow networks).
PullingImage,
/// Orchestrator owns download/build and startup as one operation.
PreparingApp,
/// Creating data directories, writing app-specific configs
/// (bitcoin.conf, lnd.conf, searxng settings.yml, chown).
CreatingContainer,
+7 -268
View File
@@ -46,25 +46,6 @@ fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
}
/// Is this FIPS answer the final one, or should the request go again over
/// Tor? A single-delivery request already reached the peer, so any answer
/// is final: a Tor replay would carry the same (possibly spent) payload.
fn fips_answer_is_final(
pref: crate::settings::transport::TransportPref,
single_delivery: bool,
status: reqwest::StatusCode,
) -> bool {
pref == crate::settings::transport::TransportPref::Fips
|| single_delivery
|| !fips_should_fall_back(status)
}
/// May a failed FIPS attempt be sent again? Only a failed connect proves the
/// peer never saw it; a timeout can land after the request was delivered.
fn fips_retryable(single_delivery: bool, e: &reqwest::Error) -> bool {
e.is_connect() || (!single_delivery && e.is_timeout())
}
/// DNS suffix appended to a peer's bech32 npub.
pub const FIPS_DNS_SUFFIX: &str = "fips";
@@ -132,21 +113,7 @@ pub fn client() -> reqwest::Client {
/// before the Tor fallback ever gets a chance. The generous `connect_timeout`
/// is preserved so a cold hole-punched path still gets time to establish.
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
client_with_delivery_policy(timeout, false)
}
fn delivery_redirect_policy(single: bool) -> reqwest::redirect::Policy {
if single {
reqwest::redirect::Policy::none()
} else {
reqwest::redirect::Policy::default()
}
}
fn client_with_delivery_policy(timeout: Duration, single: bool) -> reqwest::Client {
reqwest::Client::builder()
.no_proxy()
.redirect(delivery_redirect_policy(single))
.timeout(timeout)
.connect_timeout(Duration::from_secs(8))
.user_agent("archipelago-fips/1")
@@ -163,18 +130,10 @@ fn client_with_delivery_policy(timeout: Duration, single: bool) -> reqwest::Clie
/// robust". Only connect/timeout errors are retried (a real HTTP response,
/// including 4xx/5xx, is returned as-is for the caller to interpret).
async fn send_with_retry(rb: reqwest::RequestBuilder) -> Result<reqwest::Response, reqwest::Error> {
send_with_retry_if(rb, |e| e.is_connect() || e.is_timeout()).await
}
/// [`send_with_retry`], retrying only on errors `retryable` accepts.
async fn send_with_retry_if(
rb: reqwest::RequestBuilder,
retryable: impl Fn(&reqwest::Error) -> bool,
) -> Result<reqwest::Response, reqwest::Error> {
let retry = rb.try_clone();
match rb.send().await {
Ok(resp) => Ok(resp),
Err(e) if retryable(&e) && retry.is_some() => {
Err(e) if (e.is_connect() || e.is_timeout()) && retry.is_some() => {
// Brief pause so the hole-punch packets from the first attempt can
// traverse before we re-dial onto the warmed path.
tokio::time::sleep(Duration::from_millis(600)).await;
@@ -391,9 +350,6 @@ pub struct PeerRequest<'a> {
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
/// every caller has a data dir in scope.
pub record_data_dir: Option<std::path::PathBuf>,
/// The request carries something that must reach the peer at most once
/// (a bearer ecash token). See [`PeerRequest::single_delivery`].
pub single_delivery: bool,
}
impl<'a> PeerRequest<'a> {
@@ -407,25 +363,9 @@ impl<'a> PeerRequest<'a> {
fips_timeout: None,
service: None,
record_data_dir: None,
single_delivery: false,
}
}
/// Never send this request twice. A paid download carries a bearer ecash
/// token that the seller redeems on first sight; replaying it over Tor
/// after FIPS already delivered it hands the seller a spent token, so the
/// buyer is charged and gets a 402 instead of the file (2026-09-29: FIPS
/// answered 404 after the seller redeemed, the Tor retry got 402).
///
/// With this set, whatever FIPS answers is final, the FIPS retry fires
/// only when the first attempt never connected, and Tor is used only when
/// FIPS could not have delivered the request. An attempt that may have
/// been delivered but timed out is an error, not a fallback.
pub fn single_delivery(mut self) -> Self {
self.single_delivery = true;
self
}
/// Record the transport that serves this request into federation storage
/// (matched by this request's onion host). Best-effort, off the hot path.
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
@@ -502,7 +442,7 @@ impl<'a> PeerRequest<'a> {
// Use the FIPS reply unless it's one a Tor retry could
// fix (404 path-not-served / 5xx) and we're allowed to
// fall back. FIPS-only never falls back.
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips));
@@ -541,7 +481,7 @@ impl<'a> PeerRequest<'a> {
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
match self.try_fips_get().await? {
Some(resp) => {
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips));
@@ -611,21 +551,13 @@ impl<'a> PeerRequest<'a> {
} else {
budget
};
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
let c = client_with_timeout(per_attempt);
let mut rb = c.post(&url).json(body);
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
let single = self.single_delivery;
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
match tokio::time::timeout(budget, attempt).await {
match tokio::time::timeout(budget, send_with_retry(rb)).await {
Ok(Ok(r)) => Ok(Some(r)),
Ok(Err(e)) if single && !e.is_connect() => Err(anyhow::anyhow!(
"FIPS POST failed after possible delivery; not replaying: {e}"
)),
Err(_) if single => Err(anyhow::anyhow!(
"FIPS POST exceeded its budget after possible delivery; not replaying"
)),
Ok(Err(e)) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
@@ -680,28 +612,13 @@ impl<'a> PeerRequest<'a> {
} else {
budget
};
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
let c = client_with_timeout(per_attempt);
let mut rb = c.get(&url);
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
let single = self.single_delivery;
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
match tokio::time::timeout(budget, attempt).await {
match tokio::time::timeout(budget, send_with_retry(rb)).await {
Ok(Ok(r)) => Ok(Some(r)),
// Anything but a failed connect may have reached the peer.
Ok(Err(e)) if single && !e.is_connect() => Err(anyhow::anyhow!(
"FIPS GET {} failed after the request may have been delivered \
(not retrying over Tor): {}",
self.path,
e
)),
Err(_) if single => Err(anyhow::anyhow!(
"FIPS GET {} exceeded its {:?} budget after the request may have \
been delivered (not retrying over Tor)",
self.path,
budget
)),
Ok(Err(e)) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
@@ -759,7 +676,6 @@ impl<'a> PeerRequest<'a> {
.context("Invalid Tor SOCKS proxy URL")?;
reqwest::Client::builder()
.proxy(proxy)
.redirect(delivery_redirect_policy(self.single_delivery))
.timeout(self.timeout)
.build()
.context("Build Tor HTTP client")
@@ -843,181 +759,4 @@ mod tests {
let err = decode_response(0xAABB, &r, "x").unwrap_err();
assert!(err.to_string().contains("no AAAA"));
}
#[test]
fn a_single_delivery_answer_is_final_whatever_its_status() {
use crate::settings::transport::TransportPref;
use reqwest::StatusCode;
// Regression (2026-09-29): the seller redeemed a paid download's
// token, answered 404, and the Tor fallback replayed the spent token.
for status in [
StatusCode::NOT_FOUND,
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::SERVICE_UNAVAILABLE,
StatusCode::OK,
] {
assert!(fips_answer_is_final(TransportPref::Auto, true, status));
}
// Everything else keeps the existing fallback rules.
assert!(!fips_answer_is_final(
TransportPref::Auto,
false,
StatusCode::NOT_FOUND
));
assert!(!fips_answer_is_final(
TransportPref::Auto,
false,
StatusCode::BAD_GATEWAY
));
assert!(fips_answer_is_final(
TransportPref::Auto,
false,
StatusCode::PAYMENT_REQUIRED
));
assert!(fips_answer_is_final(
TransportPref::Fips,
false,
StatusCode::NOT_FOUND
));
}
/// A listener that accepts connections and never answers, counting them.
async fn silent_peer() -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counter = seen.clone();
tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((stream, _)) = listener.accept().await {
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
held.push(stream); // keep it open, never reply
}
});
(format!("http://{addr}/content/x"), seen)
}
#[tokio::test]
async fn a_single_delivery_request_is_not_resent_after_a_timeout() {
let (url, seen) = silent_peer().await;
let c = client_with_timeout(Duration::from_millis(300));
let err = send_with_retry_if(c.get(&url), |e| fips_retryable(true, e))
.await
.expect_err("peer never answers");
assert!(err.is_timeout());
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn an_ordinary_request_is_still_retried_once_after_a_timeout() {
let (url, seen) = silent_peer().await;
let c = client_with_timeout(Duration::from_millis(300));
let _ = send_with_retry_if(c.get(&url), |e| fips_retryable(false, e)).await;
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[tokio::test]
async fn a_single_delivery_request_still_retries_a_refused_connect() {
// Nothing listening: the peer provably never saw the request.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let c = client_with_timeout(Duration::from_millis(500));
let err = send_with_retry_if(c.get(format!("http://{addr}/")), |e| {
fips_retryable(true, e)
})
.await
.expect_err("nothing listening");
assert!(err.is_connect());
assert!(fips_retryable(true, &err));
}
}
#[cfg(test)]
mod delivery_redirect_tests {
use super::*;
use hyper::{
service::{make_service_fn, service_fn},
Body, Response, Server,
};
use std::{
convert::Infallible,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
};
#[tokio::test]
async fn paid_bearer_request_does_not_follow_redirects_but_normal_get_does() {
let seen = Arc::new(AtomicUsize::new(0));
let counter = seen.clone();
let server = Server::bind(&([127, 0, 0, 1], 0).into());
let address = server.local_addr();
let service = make_service_fn(move |_| {
let counter = counter.clone();
async move {
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
let counter = counter.clone();
async move {
counter.fetch_add(1, Ordering::SeqCst);
let response = if request.uri().path() == "/first" {
Response::builder()
.status(302)
.header("Location", "/replay")
.body(Body::empty())
.unwrap()
} else {
Response::new(Body::from("replayed"))
};
Ok::<_, Infallible>(response)
}
}))
}
});
let task = tokio::spawn(server.serve(service));
let url = format!("http://{address}/first");
let response = client_with_delivery_policy(Duration::from_secs(2), true)
.get(&url)
.header("X-Payment-Token", "dummy-test-token")
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::FOUND);
assert_eq!(seen.load(Ordering::SeqCst), 1);
let response = client_with_delivery_policy(Duration::from_secs(2), false)
.get(url)
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
assert_eq!(seen.load(Ordering::SeqCst), 3);
task.abort();
}
#[tokio::test]
async fn paid_request_is_not_resent_when_peer_disconnects_after_reading_it() {
use tokio::io::AsyncReadExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let seen = Arc::new(AtomicUsize::new(0));
let counter = seen.clone();
let task = tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let mut buf = [0; 4096];
let _ = stream.read(&mut buf).await;
counter.fetch_add(1, Ordering::SeqCst);
drop(stream);
}
});
let c = client_with_delivery_policy(Duration::from_secs(2), true);
let error = send_with_retry_if(c.get(format!("http://{address}/")), |e| {
fips_retryable(true, e)
})
.await
.unwrap_err();
assert!(!error.is_connect());
assert_eq!(seen.load(Ordering::SeqCst), 1);
task.abort();
}
}
+86 -18
View File
@@ -1765,12 +1765,17 @@ fn merge_preserving_transitional(
};
crate::data_model::PackageDataEntry {
state,
state: state.clone(),
// install_progress and uninstall_stage are also owned by the
// initiating op (same reason as state) — keep them.
install_progress: existing.install_progress.clone(),
uninstall_stage: existing.uninstall_stage.clone(),
// Everything else comes from the fresh scan.
ui_ready: if state == crate::data_model::PackageState::Running {
fresh.ui_ready
} else {
Some(false)
},
health: fresh.health.clone(),
exit_code: fresh.exit_code,
static_files: fresh.static_files.clone(),
@@ -1809,7 +1814,10 @@ async fn scan_and_update_packages(
absence_tracker: &mut HashMap<String, u32>,
transitional_since: &mut HashMap<String, Instant>,
) -> Result<()> {
let mut packages = scanner.scan_containers().await?;
let (before_scan, _) = state.get_snapshot().await;
let mut packages = scanner
.scan_containers(data_dir, &before_scan.package_data)
.await?;
let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await;
for (id, pkg) in packages.iter_mut() {
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
@@ -1870,11 +1878,14 @@ async fn scan_and_update_packages(
// once at load ~2). Better to keep saying "scanning…" than to say "empty".
if packages.is_empty() && (!first_scan || !installed_registry.is_empty()) {
if tor_changed || update_changed {
let mut data = current_data;
data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.updated = update_available;
state.update_data(data).await;
state
.mutate_data(|data| {
data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address =
tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.updated = update_available;
})
.await;
}
return Ok(());
}
@@ -1899,6 +1910,13 @@ async fn scan_and_update_packages(
// died without cleanup and let the scan override it.
let now = Instant::now();
for (id, pkg) in &packages {
if user_uninstalled.contains(id)
|| user_uninstalled.contains(&format!("archy-{id}"))
|| (before_scan.package_data.contains_key(id)
&& !current_data.package_data.contains_key(id))
{
continue;
}
absence_tracker.remove(id);
let existing = merged.get(id);
let overwrite = match existing {
@@ -2054,22 +2072,40 @@ async fn scan_and_update_packages(
}
if changed || tor_changed || first_scan || update_changed {
let mut data = current_data;
data.package_data = merged;
data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.containers_scanned = true;
data.server_info.status_info.updated = update_available;
state.update_data(data).await;
debug!(
"📦 State changed (packages={}, tor={}, first_scan={}, update={}), broadcasting update",
changed, tor_changed, first_scan, update_changed
);
state
.mutate_data(|data| {
// A lifecycle operation may have started/finished while this scan
// awaited probes or disk I/O. Never overwrite that newer entry or
// resurrect one that an uninstall removed in the meantime.
apply_scanned_packages(&mut data.package_data, &current_data.package_data, &merged);
data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.containers_scanned = true;
data.server_info.status_info.updated = update_available;
})
.await;
}
Ok(())
}
fn apply_scanned_packages(
latest: &mut HashMap<String, crate::data_model::PackageDataEntry>,
base: &HashMap<String, crate::data_model::PackageDataEntry>,
scanned: &HashMap<String, crate::data_model::PackageDataEntry>,
) {
for (id, fresh) in scanned {
if latest.get(id) == base.get(id) {
latest.insert(id.clone(), fresh.clone());
}
}
for id in base.keys() {
if !scanned.contains_key(id) && latest.get(id) == base.get(id) {
latest.remove(id);
}
}
}
async fn normalize_reachable_package_health(
packages: &mut HashMap<String, crate::data_model::PackageDataEntry>,
) {
@@ -2268,6 +2304,7 @@ mod merge_tests {
fn make_entry(state: PackageState, health: Option<&str>) -> PackageDataEntry {
PackageDataEntry {
ui_ready: None,
state,
health: health.map(|s| s.to_string()),
exit_code: None,
@@ -2280,6 +2317,37 @@ mod merge_tests {
}
}
#[test]
fn stale_scan_cannot_remove_new_installs_or_overwrite_lifecycle_changes() {
let running = make_entry(PackageState::Running, Some("healthy"));
let restarting = make_entry(PackageState::Restarting, None);
let base = [
("restart".into(), running.clone()),
("uninstalled".into(), running.clone()),
]
.into_iter()
.collect();
let mut latest = [
("restart".into(), restarting.clone()),
("new".into(), running.clone()),
]
.into_iter()
.collect();
let scanned = [
("restart".into(), running.clone()),
("uninstalled".into(), running.clone()),
]
.into_iter()
.collect();
apply_scanned_packages(&mut latest, &base, &scanned);
assert_eq!(latest.get("restart"), Some(&restarting));
assert_eq!(latest.get("new"), Some(&running));
assert!(!latest.contains_key("uninstalled"));
apply_scanned_packages(&mut latest, &base, &HashMap::new());
assert_eq!(latest.get("restart"), Some(&restarting));
assert!(latest.contains_key("new"));
}
#[test]
fn peer_path_filter_allows_content_catalog_and_items() {
// Regression: the content *catalog* is exactly "/content" (no trailing
+41
View File
@@ -54,6 +54,21 @@ impl StateManager {
let _ = self.broadcast_tx.send(message);
}
/// Apply a small state change while holding the write lock. A lifecycle
/// task must not replace the entire model from an earlier snapshot.
pub async fn mutate_data<T>(&self, change: impl FnOnce(&mut DataModel) -> T) -> T {
let mut data = self.data.write().await;
let result = change(&mut data);
let mut rev = self.revision.write().await;
*rev += 1;
let _ = self.broadcast_tx.send(WebSocketMessage {
rev: *rev,
data: Some(data.clone()),
patch: None,
});
result
}
/// Get a WebSocket message with the current state
pub async fn get_initial_message(&self) -> WebSocketMessage {
let (data, rev) = self.get_snapshot().await;
@@ -190,3 +205,29 @@ mod tests {
assert_eq!(rev, 1);
}
}
#[cfg(test)]
mod atomic_mutation_tests {
use super::*;
#[tokio::test]
async fn concurrent_updates_preserve_independent_entries() {
let state = Arc::new(StateManager::new());
let mut tasks = Vec::new();
for i in 0..24 {
let state = state.clone();
tasks.push(tokio::spawn(async move {
state
.mutate_data(|data| {
data.peer_health.insert(format!("peer-{i}"), true);
})
.await;
}));
}
for task in tasks {
task.await.unwrap();
}
let (data, revision) = state.get_snapshot().await;
assert_eq!(data.peer_health.len(), 24);
assert_eq!(revision, 24);
}
}
+5 -3
View File
@@ -16,9 +16,11 @@ lookup relays from the defaults. It does not replace GitWorkshop's NIP-34,
GRASP, repository browser, issue, pull-request, or review interfaces.
The separate dependency patch refreshes the npm lockfile and moves `fflate` to
0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. The resulting clean
install reports zero npm advisories; its type-check, 152 unit tests, and
Archipelago subpath production build pass. Keeping this mechanical security
0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. On 2026-09-30 the lockfile was refreshed again for `brace-expansion`
1.1.21/5.0.12, `fast-uri` 3.1.8 and `ip-address` 10.7.2 after fresh node
installs failed the retained dependency audit. The resulting clean install
reports zero npm advisories; its type-check, 152 unit tests, and Archipelago
subpath production build pass. The complete image also builds on the X250. Keeping this mechanical security
update separate makes both the upstream integration and future dependency
refreshes auditable.
@@ -1,5 +1,5 @@
diff --git a/package-lock.json b/package-lock.json
index 20631bb..0933917 100644
index 20631bb..86b6f86 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -63,7 +63,7 @@
@@ -495,9 +495,9 @@ index 20631bb..0933917 100644
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -678,9 +678,9 @@ index 20631bb..0933917 100644
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -833,9 +833,9 @@ index 20631bb..0933917 100644
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
- "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [
{
"type": "github",
@@ -859,9 +859,9 @@ index 20631bb..0933917 100644
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -893,9 +893,9 @@ index 20631bb..0933917 100644
- "version": "10.2.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
- "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "version": "10.7.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
+ "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
+ "version": "10.7.2",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
+ "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -1445,4 +1445,3 @@ index bd7190c..6aa5a6f 100644
import { vi } from "vitest";
// Mock window.matchMedia
+60 -21
View File
@@ -14,34 +14,73 @@ doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
## Next release after 1.8.21 — reported 2026-09-30
- [ ] **ThinkPad X250 kiosk: Bitcoin installation version selector is unreadable
and appears underneath the pruning information.** Operator reports white
styling with invisible text on the actual kiosk; the same flow works in remote
Brave. Reproduce on the X250's kiosk engine and record its version, display
scale and resolution. Inspect the native `<select>` in
`neode-ui/src/components/InstallVersionModal.vue`, its option colors, and the
scroll/stacking behavior in `BaseModal.vue`; these are investigation leads,
not a confirmed cause. Fix contrast and popup visibility without changing
version selection or pruning behavior. Validate Core and Knots, open/closed
and scrolled dropdowns, keyboard/touch selection, and pruning on/off on the
actual kiosk, with remote Brave and mobile regression checks. Browser mocks
alone do not establish that the kiosk rendering is fixed. Track for the next
release; the signed 1.8.21 artifacts remain unchanged.
- [ ] **Release blocker: Gitea → Portainer repository integration.** Diagnose
smart-HTTP reachability from Portainer's actual request namespace, then provide
one declarative topology and idempotent migration for fresh installs and
existing nodes. Preserve gate/auth boundaries, operator configuration,
repository/key/database mounts and Portainer stacks. Cover install order,
lifecycle/reboot/update convergence, clone/push and source-branch/Compose-file
acceptance with a disposable integration setup. Ship in both OTA and ISO;
a healthy Gitea root page is insufficient. Operator supplied a private handover;
deployment addresses and credentials must not be committed.
## Current repair and release tasks — 2026-09-29
- [ ] **New X250: GitWorkshop failed at 70%; slow Nginx installation.** Missing
ISO build contexts restored on-node; package staging/smoke checks added.
GitWorkshop dependency audit refreshed and build/HTTP recovery verified;
Nginx was a slow successful image pull. Aggregate progress label corrected.
Include the validated repair in the next OTA/ISO. See lifecycle evidence.
Release is blocked until these pass; see [execution record](repair-release-20260929.md).
- [ ] **Angor indexer service in the app store**, requested after the other
current repair/review work (2026-09-30). Follow the repository's app-development
and packaging documentation; treat it as a headless service unless upstream
documentation establishes a UI. Verify Bitcoin/Mempool requirements, decide
whether an existing first-class relay meets Angor's requirements or a relay
must be packaged with the indexer, and use the Angor logo from angor.io for its
service icon. The mentioned setup-documentation link was not included; asked
the operator for it. Include this service in the next-release scope.
- [ ] Fix Cashu paid-file redemption between dev and Shorty; test keyset IDs,
- [ ] **App lifecycle: keep installed apps visible through restart and hard
refresh; gate embedded/browser launches on actual web and listener readiness.**
Source repair and scoped live acceptance passed; full release gate pending.
Includes durable inventory reconstruction,
concurrent inventory writes, stale scan/lifecycle updates, delayed HTTP startup,
and the app gate's post-install listener delay. See
[app lifecycle repair evidence](app-lifecycle-repair-20260930.md).
- [x] Review and repair open paid-download PRs #161 and #162, refresh both
branches from main, run independent and combined isolated suites, and verify
rootless file permissions in disposable scratch storage. Combined result:
1,585 passed, zero failed, four existing tests ignored. See the
[review evidence and remaining acceptance work](pr-review-20260930.md).
- [ ] Integrate the reviewed PR branches into the next release and run funded
candidate acceptance, including Tor-only transport and payments with change.
Operator authorized completing the normal merge/closure workflow on
2026-09-30. Both PRs are now merged and closed through Gitea; integrate
local repair commits and sync git/ngit before release. The reviewed code has not yet been deployed to live wallets.
- [ ] Design durable recovery for an accepted payment whose response is lost.
Preserve the truthful unconfirmed-refund warning and prevent automatic
duplicate payment while that recovery work is outstanding.
- [x] **ThinkPad X250 kiosk: Bitcoin version choices readable above pruning.**
Replaced the native popup with inline radio choices. Actual Chromium 152 kiosk
assertions and screenshot verify white-on-dark choices, selection changes and
layout above pruning controls. Focused component tests pass. Included in the
next-release source; published 1.8.21 artifacts remain unchanged.
## 1.8.21 repair and release tasks — completed 2026-09-30
See the [execution record](repair-release-20260929.md) for evidence and limits.
- [x] Fix Cashu paid-file redemption between dev and Shorty; test keyset IDs,
mint errors, fees, and refund reporting before live validation.
- [ ] Complete the remaining Framework incident verification and evidence.
- [ ] Replace the unavailable tx1138.com explorer default with mempool.space;
- [x] Record Framework verification and the operator's acceptance of the
remaining display check before release.
- [x] Replace the unavailable tx1138.com explorer default with mempool.space;
migrate the old default with fresh consent and preserve custom/local explorers.
- [ ] Offer pruning in the Bitcoin installation version modal, using the same
- [x] Offer pruning in the Bitcoin installation version modal, using the same
pruning settings as automatic pruning even on large disks.
- [ ] Explain Bitcoin warmup without raw RPC errors; gate LND unlock on Bitcoin
- [x] Explain Bitcoin warmup without raw RPC errors; gate LND unlock on Bitcoin
RPC readiness and show install/start/sync waiting states with automatic recovery.
- [ ] Test the completed changes on this development box, then publish a new
- [x] Test the completed changes on this development box, then publish a new
signed OTA and raw ISO release. Record any remaining verification gaps.
## Dev & build process (priority)
+111
View File
@@ -0,0 +1,111 @@
# App lifecycle repair — 2026-09-30
Status: source repairs, optimized build, new-node recovery and scoped live
lifecycle acceptance verified. Full release gate remains pending.
These are next-release changes. Published 1.8.21 artifacts remain unchanged.
## Report
The operator reports that restarting an app can make it disappear, and a hard
refresh offers installation again. Newly installed apps sometimes fail to
connect in both embedded views and browser tabs. The new X250 additionally reproduced GitWorkshop disappearing during install
and Nginx Proxy Manager spending approximately 14 minutes at 70%. A disposable
app on the dev box exposed a separate restart failure.
## Findings and repairs
- Quadlet removes containers during stop/restart. The scanner protected existing
in-memory entries but did not reconstruct an absent app on a fresh daemon.
It now synthesizes stopped entries from the durable installed set, respecting
uninstall records, normalizing container prefixes, and preserving cached
metadata. Absence does not establish an image version or available update.
- Concurrent read/modify/write operations could lose installed-app records;
in-place writes could expose truncated JSON to readers. Serialize writers,
publish by atomic rename, and sync the file and parent directory. Legacy
package install/uninstall success paths update the durable record too.
- Scans and lifecycle/progress operations could replace a newer model from an
older snapshot. Use locked mutations for lifecycle/progress, and merge scan
results only into entries unchanged since the scan's merge snapshot.
- Container running state and TCP accept alone did not establish HTTP readiness.
Add explicit `ui-ready` based on bounded HTTP probes of the loopback upstream;
reject connection failures and server errors, accept normal redirects and
authentication challenges, and do not follow redirects or send credentials.
Self-signed HTTPS apps are probed locally without certificate validation.
- The app gate swept new listeners only every 60 seconds. Wake that sweep
immediately for a ready upstream whose declared gate port is not yet claimed,
and withhold readiness until external and Tor listener claims exist.
- Fixed launch URLs could bypass suppressed runtime URLs. Enforce readiness in
app cards, details, centralized embedded/browser launchers, and session frames.
Starting/restarting clears readiness immediately. A waiting frame does not
load an iframe and resumes when the backend reports readiness.
### New X250 findings
- The published ISO copied only `bitcoin-ui`, `lnd-ui` and `electrs-ui` build
directories. GitWorkshop failed because `/opt/archipelago/docker/archipelago-source`
was missing. Copy the complete docker source tree for bundled and unbundled
ISOs, matching OTA packaging. Validate every manifest build context and
Dockerfile in OTA staging, ISO staging and the mounted ISO smoke test.
- After restoring the omitted contexts, GitWorkshop's retained npm audit rejected
newly reported brace-expansion, fast-uri and ip-address vulnerabilities.
Refresh the existing pinned dependency patch, keeping the audit enabled.
Clean install/audit (zero advisories), type-check, 152 upstream tests and
subpath production build pass. The image builds on the X250 and `/healthz`
returns 200. No wallet or Bitcoin container restart was needed.
- Nginx was receiving data, not frozen: over 1 GB read during the pull. It
completed at 12:40:46 UTC after starting at 12:26:27; its web endpoint returns
200. The orchestrated path previously labelled the entire download/build/start
operation "Creating container" at 70%. Give that aggregate operation its own
truthful label and earlier phase; no byte-level download estimate is claimed.
- Restore install progress immediately from an already-loaded server snapshot,
so a new store created after hard refresh does not wait for another mutation.
- Replace the install modal's native version popup with inline radio choices.
On this actual X250's Chromium 152 kiosk renderer, selection changes work,
options have white text on dark backgrounds, and remain above pruning controls.
Screenshot and browser assertions captured; no install confirmation was clicked.
### Restart safety
The disposable fixture restart at 12:38:05 UTC stopped its container, then
`ss | kill` in runtime port cleanup sent SIGTERM to the management daemon at
12:38:35. The daemon owned the gate listener on the same port at other addresses.
Systemd restarted management; Bitcoin and LND container IDs/start times were
unchanged. Remove port-owner kills and broad `pkill` patterns from restart,
install recovery and Grafana preparation. Recovery now uses the existing
container-ID-aware ghost reaper: absent container ownership must be established
before a process is terminated. A real listening-socket regression checks that
conflict cleanup preserves the host listener. App-gate manifest lookup now honors
`ARCHIPELAGO_APPS_DIR`, matching the orchestrator's configured manifest root.
## Validation
- Full frontend suite: 139 files, 1,126 tests passed; final focused kiosk/store
checks: nine passed. Production frontend build passed.
- Final isolated backend suite: 1,567 passed, zero failed, four existing ignored
tests. Optimized backend build passed and was deployed to the development node.
- Tests cover empty runtime inventory, alias deduplication, uninstall exclusion,
concurrent durable writes, concurrent state changes, stale scan publication,
TCP-without-HTTP, HTTP statuses including 502/503, and gate listener claims.
- Live disposable Node fixture delayed HTTP startup by 25 seconds. Desktop and
mobile retained the waiting screen through hard refresh without mounting an
iframe, then opened the exact fixture page automatically when ready.
- Restart retained the app in both state APIs throughout and returned to ready;
the management PID did not change. Stopping removed the Quadlet container;
restarting management reconstructed its installed/stopped entry without a
false update offer. Starting it again succeeded. Desktop and mobile continued
to show the installed app after hard refresh.
- LAN access required node authentication and returned exact fixture bytes after
authentication. The fixture was uninstalled through the package lifecycle API;
its temporary manifest root and service override were removed.
- Bitcoin and LND container IDs and start times stayed unchanged through all
scoped checks and management restarts. No wallet data was used by the fixture.
- X250 kiosk checks also opened the repaired GitWorkshop and Nginx Proxy Manager
pages successfully, with no failed local resource loads.
## Limits
This prevents the identified lifecycle/readiness failures; it cannot guarantee
that an app or network never fails after a successful readiness check. Actual
application failures must remain visible rather than being labelled successful.
The full lifecycle/reboot release gate and funded acceptance of the reviewed
paid-download PRs remain pending. The X250 kiosk fix has live rendering evidence.
+127
View File
@@ -0,0 +1,127 @@
# Paid-download PR review — 2026-09-30
## Scope and result
Reviewed both open PRs from the repository pull-request list: [#161](https://source.archipelago-foundation.org/lfg2025/archy/pulls/161)
and [#162](https://source.archipelago-foundation.org/lfg2025/archy/pulls/162).
Both branches were updated from main, repaired and tested independently and
together. Their existing remote branches were advanced without rewriting the
contributors' history. They remain open for integration into the release after
1.8.21; no reviewed code was merged into main or deployed to a live wallet.
The signed 1.8.21 artifacts are unchanged.
| Candidate | Tested commit | Isolated backend result |
| --- | --- | --- |
| PR #161 | `971d4777` | 1,576 passed, 0 failed, 4 existing tests ignored |
| PR #162 | `0677924a` | 1,568 passed, 0 failed, 4 existing tests ignored |
| Both together | `4bf4bf1a` | 1,585 passed, 0 failed, 4 existing tests ignored |
Both individual branches also passed production `cargo check`, with the
repository's existing 16 warnings. The combined merge required no conflict
resolution. Backend tests ran through `scripts/test-backend-isolated.sh` so they
could not access host wallets, native services or production container storage.
## Findings and repairs
### #161 — payment delivery and file readability
- The branch conflicted with newer mint-fee, keyset-ID and truthful refund
reporting fixes. Preserve those implementations from main; do not reintroduce
its older unconditional “refunded” messages or duplicate keyset resolution.
- Opening a file before charging, then reopening/reading it afterward, still
permits a read failure after payment. Prepare the complete requested bytes
before redemption, including ranged reads. Tests delete or alter the backing
file during payment verification and still receive the prepared original data.
- Empty/out-of-bounds/reversed ranges could fail after redemption, and empty
files could underflow the range calculation. Validate ranges before charging
and return HTTP 416 when unsatisfiable.
- `chmod a+r` unnecessarily changed the permissions of shared paid/private
files. Read restricted FileBrowser files through the rootless namespace while
retaining their mode. Scope the fallback to regular files canonically inside
FileBrowser storage, and reject unauthorized peers before reading.
- A single-delivery flag must also prevent redirects and ambiguous transport
retries. Payment-bearing GET and POST requests now retain the first HTTP
response and do not retry after timeouts or disconnects that might follow
delivery. Refused connections and normal nonpayment browsing retain the
appropriate retry behavior.
- Interrupted response bodies now report the outcome using the actual local
refund result. Seller explanations are bounded, stripped of control
characters and explicitly identified as peer text.
- Original permission tests silently returned when run as root. Replacement
tests inject read/payment boundary failures, exercise them under the isolated
runner, and assert that read failures never invoke redemption.
### #162 — saving purchases in Files
- Its host-permission repair overlapped 1.8.21's authenticated Files API path.
Review of [FileBrowser v2.63.23's resource handler](https://github.com/filebrowser/filebrowser/blob/v2.63.23/http/resource.go)
showed that `override=false` checks for existence separately from opening
with truncation. It does not guarantee no overwrites under concurrent saves.
- The proposed direct path exposed the final filename before the write
completed. Both direct and namespace paths now finish a private temporary
file and publish it through a no-clobber hard link, retrying numbered names.
- Plain `ln` could place a temporary file inside an existing directory instead
of treating the destination as a collision. Use `ln -T`; existing directories
and dangling symlinks are conflicts, never replacement targets.
- Add filename and destination checks, unique temporary names, bounded name
retries, synchronization before publication, and exact input-length checks.
Truncated pipe input cannot become a completed purchased file.
- Files storage remains optional. An unavailable copy destination does not
undo the purchase or create a fake FileBrowser installation; the durable
purchased-content cache remains primary.
## Additional verification on the development node
Used disposable scratch directories only, then removed them:
- Reproduced a FileBrowser-style rootless-owned 0640 upload. The host backend
UID could not read it. `podman unshare cat` returned identical bytes without
changing its 0640 mode.
- Ran the exact namespace writer script with four concurrent writers against
a directory owned by the container UID range. Every file had unique naming,
exact bytes, the expected owner and mode, and no remaining temporary file.
- Sent truncated input to the namespace writer and verified refusal, no final
file and temporary-file cleanup.
The isolated tests additionally exercised 24 simultaneous direct writes,
existing-file preservation, symlink/directory conflicts, collision exhaustion,
root-independent permission failures, read-before-redemption ordering,
authorization, redirects and peer disconnects.
Logs on the development box:
`/tmp/archy-pr161-tests.log`, `/tmp/archy-pr162-tests.log`,
`/tmp/archy-pr-integration-tests.log`, `/tmp/archy-pr161-check.log`,
`/tmp/archy-pr162-check.log`, `/tmp/archy-pr-userns-scratch-test.log`.
## Next-release acceptance and limits
- Integrate the reviewed branches and repeat the release gates against the
final release commit if additional code changes land.
- Perform funded peer-to-peer acceptance on the candidate build, including a
Tor-only purchase and a purchase requiring change, before the next release.
The new review branches were not deployed to funded live wallets here.
- These PRs do not implement durable payment receipts. If a seller redeems a
payment and the connection subsequently loses the response, the buyer may
receive an unconfirmed-refund warning. Do not represent that warning as proof
of a refund or automatically charge the buyer again. Receipt-based recovery
remains separate follow-up work.
- Abrupt process termination can leave a hidden namespace temporary file;
ordinary write failures and truncated input are tested to clean up. The final
filename is published only after complete input, and existing files remain
protected.
- The separately reported X250 kiosk version-selector rendering issue remains
open in `TODO.md` and requires validation on the actual kiosk.
## Authorized merge — 2026-09-30
The operator explicitly requested normal merged/closed PR status after review.
Re-read both PRs and verified their heads still exactly matched the reviewed
commits. Changes from the integration-test base to main were documentation only.
Gitea normal merges completed and read-back confirmed `merged=true`, `state=closed`:
- #161: `3daea6623be3e2c7222101b8e6ac411423c7e16c`.
- #162: `b02ba4100d922dd1b75c6a78121ef446c2159a54`.
Local next-release lifecycle work will be integrated with this main before the
next release. Funded release acceptance and the documented delivery-receipt
limitation remain as recorded above; merging does not claim a new release.
+39
View File
@@ -356,3 +356,42 @@ Bitcoin and LND IDs/start times remained unchanged, with generated stop settings
still verified. No temporary graceful-stop overrides remain. Catalog signature
verifies against the pinned release root; final 1.8.21 artifact validator passes.
The candidate is ready for the user's local OTA signing ceremony.
### 1.8.21 publication completed — 2026-09-30
The operator signed the OTA manifest and subsequently the ISO checksum JSON.
Both signatures verified against the pinned release root. The signed OTA was
published on git/ngit, and the operator confirmed that Framework could see the
update. Source main and the annotated `v1.8.21-alpha` tag were published.
Raw ISO:
`archipelago-installer-1.8.21-alpha-unbundled-x86_64_RC1.iso`
- Size: 2,682,419,200 bytes.
- SHA256: `8667b5522c476a40e29abba19df4180086191527a194a88765aa70ed527f9406`.
- Build and ISO smoke checks passed. The mounted backend matched the staged
OTA backend hash, and the full dashboard/AIUI tree matched the fresh build.
- An isolated UEFI QEMU guest, with no network or host disks attached, booted to
the installer prompt. The VM was stopped and the ISO unmounted afterward.
This was an installer boot check, not a full installation onto hardware.
- Uploaded the raw ISO, plain SHA256 sidecar and signed checksum JSON to the
[1.8.21 release](https://source.archipelago-foundation.org/lfg2025/archy/releases/tag/v1.8.21-alpha).
The stored server file hashes matched, the public ISO headers and first/last
byte samples matched, and both public checksum files matched byte-for-byte.
- The ngit downloader's full-ISO acquisition exceeded its fixed 30-minute
deadline on the available connection. Published Nostr asset records using
the already verified hashes, sizes and public URLs with the existing ngit
signer; both repository relays acknowledged them. Ngit then accepted those
records and final readback resolved all five release assets with the expected
hashes and sizes. No new release-root signing was performed by the assistant.
Final publication evidence: `/tmp/archy-1821-finish-events.log`,
`/tmp/archy-ngit-1821-complete-view.json`, and
`/tmp/archy-1821-verified-asset-events.log` on the development box.
Subsequent review of PRs #161/#162 found additional delivery and concurrent
file-save edge cases. Their repaired, tested branches are recorded in
[the next-release review](pr-review-20260930.md); those changes are not in the
signed 1.8.21 artifacts. The X250 kiosk selector report is also tracked for the
next release. No claim of exhaustive hardware or network-failure coverage is
made for this release.
@@ -2607,21 +2607,14 @@ if [ -f "$SCRIPT_DIR/../../scripts/image-versions.sh" ]; then
echo " ✅ Bundled image-versions.sh"
fi
# Bundle docker UI source files for building custom UIs on first boot
# Always bundle — these are tiny HTML/CSS files, not container images
if true; then
DOCKER_UI_DIR="$SCRIPT_DIR/../../docker"
if [ -d "$DOCKER_UI_DIR" ]; then
echo " Bundling docker UI source files..."
mkdir -p "$ARCH_DIR/docker"
for ui_dir in bitcoin-ui lnd-ui electrs-ui; do
if [ -d "$DOCKER_UI_DIR/$ui_dir" ]; then
cp -r "$DOCKER_UI_DIR/$ui_dir" "$ARCH_DIR/docker/"
echo " ✅ Bundled $ui_dir source"
fi
done
fi
fi
# Build-source apps need their complete contexts even on unbundled ISOs.
# Keep this identical to the OTA runtime payload; a per-app allowlist silently
# omitted GitWorkshop, FIPS and Cuprate and made fresh installs fail at 70%.
DOCKER_UI_DIR="$SCRIPT_DIR/../../docker"
[ -d "$DOCKER_UI_DIR" ] || { echo "Missing docker build sources" >&2; exit 1; }
mkdir -p "$ARCH_DIR/docker"
cp -a "$DOCKER_UI_DIR/." "$ARCH_DIR/docker/"
python3 "$SCRIPT_DIR/../../scripts/check-app-build-contexts.py" "$ARCH_DIR"
if [ "$UNBUNDLED" = "1" ]; then
echo " ✅ Unbundled build ready (Tor setup included, no container images)"
@@ -25,13 +25,22 @@
<div v-if="loading" class="py-6 text-center text-white/60 text-sm">{{ t('common.loading') }}</div>
<div v-else class="space-y-2">
<label class="block text-white/60 text-sm">{{ t('appDetails.selectVersion') }}</label>
<select
v-model="selected"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-blue-400/60"
>
<option v-for="v in versions" :key="v.version" :value="v.version">{{ optionLabel(v) }}</option>
</select>
<fieldset class="space-y-2">
<legend class="text-white/60 text-sm mb-2">{{ t('appDetails.selectVersion') }}</legend>
<!-- Inline options avoid native popup rendering in the kiosk WebView.
They stay in document flow above the pruning explanation. -->
<div class="max-h-40 overflow-y-auto space-y-2 rounded-lg">
<label
v-for="v in versions"
:key="v.version"
class="flex items-center gap-3 rounded-lg border px-3 py-2.5 text-sm text-white cursor-pointer"
:class="selected === v.version ? 'border-blue-400/60 bg-slate-800' : 'border-white/10 bg-slate-900'"
>
<input v-model="selected" type="radio" :name="`install-version-${appId}`" :value="v.version" class="shrink-0 accent-blue-400" />
<span>{{ optionLabel(v) }}</span>
</label>
</div>
</fieldset>
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
</div>
@@ -113,6 +122,7 @@ async function load() {
} catch (err) {
if (import.meta.env.DEV) console.warn('[InstallVersionModal] getPackageVersions failed:', err)
// Fall back to the floating "latest" so the install can still proceed.
versions.value = [{ version: 'latest' } as CatalogVersionInfo]
selected.value = 'latest'
} finally {
loading.value = false
@@ -16,12 +16,14 @@ describe('Bitcoin install storage choice', () => {
versions.mockResolvedValue({ bitcoinPrune: false, default: 'latest', versions: [{ version: 'latest' }, { version: '28.4' }] })
const wrapper = modal(id)
await flushPromises()
await wrapper.get('select').setValue('28.4')
await wrapper.get('input[type=radio][value="28.4"]').setValue(true)
await wrapper.get('input[type=checkbox]').setValue(true)
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('confirm')).toEqual([['28.4', true]])
expect(wrapper.text()).toContain('automatic pruning')
expect(wrapper.text()).toContain('Mempool')
expect(wrapper.find('select').exists()).toBe(false)
expect(wrapper.findAll('input[type=radio]')).toHaveLength(2)
})
it('keeps automatic disk selection by default and resets on reopening', async () => {
versions.mockResolvedValue({ bitcoinPrune: false, versions: [{ version: 'latest' }] })
@@ -29,17 +31,17 @@ describe('Bitcoin install storage choice', () => {
await flushPromises()
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('confirm')).toEqual([['latest', false]])
await wrapper.get('input').setValue(true)
await wrapper.get('input[type=checkbox]').setValue(true)
await wrapper.setProps({ show: false })
await wrapper.setProps({ show: true })
await flushPromises()
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(false)
expect((wrapper.get('input[type=checkbox]').element as HTMLInputElement).checked).toBe(false)
})
it('still allows choosing pruning when version lookup fails', async () => {
versions.mockRejectedValue(new Error('offline'))
const wrapper = modal()
await flushPromises()
await wrapper.get('input').setValue(true)
await wrapper.get('input[type=checkbox]').setValue(true)
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('confirm')).toEqual([['latest', true]])
})
@@ -47,7 +49,7 @@ describe('Bitcoin install storage choice', () => {
versions.mockResolvedValue({ bitcoinPrune: true, versions: [{ version: 'latest' }] })
const wrapper = modal('bitcoin-knots')
await flushPromises()
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true)
expect((wrapper.get('input[type=checkbox]').element as HTMLInputElement).checked).toBe(true)
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('confirm')).toEqual([['latest', true]])
})
@@ -62,6 +64,6 @@ describe('Bitcoin install storage choice', () => {
versions.mockResolvedValue({ bitcoinPrune: false, versions: [{ version: 'latest' }] })
const wrapper = modal('other')
await flushPromises()
expect(wrapper.find('input').exists()).toBe(false)
expect(wrapper.find('input[type=checkbox]').exists()).toBe(false)
})
})
@@ -34,6 +34,7 @@ vi.mock('@/api/rpc-client', () => ({
vi.stubGlobal('open', mockWindowOpen)
import { useAppLauncherStore, senderMatchesApp } from '../appLauncher'
import { useAppStore } from '../app'
describe('useAppLauncherStore', () => {
beforeEach(() => {
@@ -54,6 +55,25 @@ describe('useAppLauncherStore', () => {
})
})
it('blocks both browser and embedded launch while HTTP is unready', () => {
const app = useAppStore()
app.data = { 'package-data': { gitea: { state: 'running', 'ui-ready': false, health: 'healthy', manifest: { id: 'gitea', title: 'Gitea' } } } } as never
const launcher = useAppLauncherStore()
launcher.openSession('gitea')
expect(launcher.panelAppId).toBeNull()
launcher.open({ url: 'http://192.0.2.10:3001/', title: 'Gitea', openInNewTab: true })
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(launcher.isOpen).toBe(false)
})
it('also gates a dynamic app resolved through its runtime URL', () => {
useAppStore().data = { 'package-data': { custom: { state: 'running', 'ui-ready': false, manifest: { id: 'custom', title: 'Custom' }, installed: { 'interface-addresses': { main: { 'lan-address': 'http://localhost:18993/' } } } } } } as never
const launcher = useAppLauncherStore()
launcher.open({ url: 'http://192.0.2.10:18993/', title: 'Custom', openInNewTab: true })
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(launcher.isOpen).toBe(false)
})
it('starts closed with empty state', () => {
const store = useAppLauncherStore()
expect(store.isOpen).toBe(false)
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { reactive, nextTick } from 'vue'
const fake = reactive<{ packages: Record<string, unknown> }>({ packages: {} })
vi.mock('../sync', () => ({ useSyncStore: () => fake }))
vi.mock('../../api/rpc-client', () => ({ rpcClient: {} }))
import { useServerStore } from '../server'
function installing(phase = 'preparing-app') {
return { state: 'installing', manifest: { title: 'Git Workshop' }, 'install-progress': { phase, size: 0, downloaded: 0 } }
}
describe('installation state after hard refresh', () => {
beforeEach(() => {
setActivePinia(createPinia())
fake.packages = {}
})
it('restores an in-flight install from an already-loaded server snapshot', () => {
fake.packages = { 'archipelago-source': installing() }
const store = useServerStore()
expect(store.isInstalling('archipelago-source')).toBe(true)
expect(store.installingApps.get('archipelago-source')).toMatchObject({
progress: 20,
message: 'Downloading, building and starting app…',
})
})
it('keeps a long download visible and clears it on terminal success', async () => {
const store = useServerStore()
fake.packages = { 'nginx-proxy-manager': installing() }
await nextTick()
expect(store.isInstalling('nginx-proxy-manager')).toBe(true)
fake.packages = { 'nginx-proxy-manager': installing() }
await nextTick()
expect(store.installingApps.get('nginx-proxy-manager')?.progress).toBe(20)
fake.packages = { 'nginx-proxy-manager': { state: 'running' } }
await nextTick()
expect(store.isInstalling('nginx-proxy-manager')).toBe(false)
})
})
+23 -1
View File
@@ -239,6 +239,11 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const panelPath = ref<string | null>(null)
function openSessionNow(appId: string, opts: LaunchOptions = {}) {
const pkg = useAppStore().data?.['package-data']?.[appId]
if (pkg?.['ui-ready'] === false) {
useToast().info(`${pkg.manifest?.title || appId} is not ready to open yet`)
return
}
recordAppLaunch(appId)
const mobile = isMobileViewport()
@@ -295,7 +300,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// Apply the same readiness gate here so a container that has just entered
// `running` cannot race nginx and show a transient 502 to the user.
const pkg = useAppStore().data?.['package-data']?.[appId]
if (pkg && pkg.state === 'running' && !isAppReadyForLaunch(pkg)) {
if (pkg && (pkg['ui-ready'] === false || (pkg.state === 'running' && !isAppReadyForLaunch(pkg)))) {
useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`)
return
}
@@ -394,6 +399,12 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
const pkg = resolvedId ? useAppStore().data?.['package-data']?.[resolvedId] : undefined
if (pkg?.['ui-ready'] === false) {
useToast().info(`${pkg.manifest?.title || resolvedId} is not ready to open yet`)
return
}
// Scheme discipline for everything launched on this host. Ports fronted
// by the node's app gate (manifest auth gated/open) serve TLS on the same
// port — on an HTTPS connection those must open over https. Ports that
@@ -472,6 +483,17 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// Check /app/{id}/ path-style routes first (HTTPS proxy mode)
const m = u.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i)
if (m?.[1]) return m[1].toLowerCase()
// Dynamic/sideloaded apps have no entry in the static port map.
if (u.hostname === window.location.hostname && u.port) {
for (const [id, pkg] of Object.entries(useAppStore().data?.['package-data'] || {})) {
const address = pkg.installed?.['interface-addresses']?.main?.['lan-address']
if (!address) continue
try {
const runtime = new URL(address)
if (runtime.port === u.port && ['localhost', '127.0.0.1', window.location.hostname].includes(runtime.hostname)) return id
} catch { /* malformed runtime address is not a launch target */ }
}
}
// Check port-based apps
const appId = PORT_TO_APP_ID[u.port]
if (appId) return appId
+2 -1
View File
@@ -25,6 +25,7 @@ import type { InstallPhase } from '../types/api'
const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; status: InstallProgress['status'] }> = {
'preparing': { progress: 5, message: 'Preparing…', status: 'downloading' },
'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' },
'preparing-app': { progress: 20, message: 'Downloading, building and starting app…', status: 'downloading' },
'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' },
'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' },
'waiting-healthy': { progress: 88, message: 'Finalizing first start…', status: 'starting' },
@@ -149,7 +150,7 @@ export const useServerStore = defineStore('server', () => {
uninstallingApps.value.delete(appId)
}
}
}, { deep: true })
}, { deep: true, immediate: true })
function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) {
const existing = installingApps.value.get(appId)
+2
View File
@@ -88,6 +88,7 @@ export const PackageState = {
export type PackageState = typeof PackageState[keyof typeof PackageState]
export interface PackageDataEntry {
'ui-ready'?: boolean // HTTP upstream readiness, separate from container health
state: PackageState
health?: string | null // "healthy", "unhealthy", "starting", or null
'exit-code'?: number | null // container exit code: 0 = clean stop, non-zero = crash
@@ -180,6 +181,7 @@ export type ServiceStatus = typeof ServiceStatus[keyof typeof ServiceStatus]
export type InstallPhase =
| 'preparing'
| 'pulling-image'
| 'preparing-app'
| 'creating-container'
| 'starting-container'
| 'waiting-healthy'
+1 -1
View File
@@ -259,7 +259,7 @@ const canLaunch = computed(() => {
const hasRuntimeAddress = !!pkg.value.installed?.['interface-addresses']?.main?.['lan-address']
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.value.manifest.id)
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl)
return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
return hasUI && pkg.value['ui-ready'] !== false && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
})
const features = computed(() => {
+18
View File
@@ -39,6 +39,7 @@
:must-open-new-tab="mustOpenNewTab"
:auto-retry-count="autoRetryCount"
:refresh-key="refreshKey"
:ui-ready-blocked="packageEntry?.['ui-ready'] === false"
:blocked-reason="blockedReason"
:blocked-title="blockedTitle"
:warming-up="warmingUp"
@@ -375,6 +376,20 @@ const panelClasses = computed(() => {
return `${base} app-session-overlay`
})
// A cold/restarting upstream is held outside the iframe. Start one fresh
// load when the scanner observes HTTP readiness; no manual refresh required.
watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => {
if (ready === false) {
if (loadTimeoutId) clearTimeout(loadTimeoutId)
if (autoRetryId) clearTimeout(autoRetryId)
if (iframeCheckId) clearTimeout(iframeCheckId)
loading.value = false
} else if (previous === false && ready === true) {
autoRetryCount.value = 0
refresh()
}
})
// --- Lifecycle handlers ---
function onLoad() {
@@ -433,6 +448,7 @@ function refresh() {
function startLoadTimeout() {
if (loadTimeoutId) clearTimeout(loadTimeoutId)
if (packageEntry.value?.['ui-ready'] === false) return
loadTimeoutId = setTimeout(() => {
if (loading.value) {
loading.value = false
@@ -442,11 +458,13 @@ function startLoadTimeout() {
}
function openNewTabAndBack() {
if (packageEntry.value?.['ui-ready'] === false) return
if (appUrl.value) openExternalUrl(appUrl.value)
closeSession()
}
function openNewTab() {
if (packageEntry.value?.['ui-ready'] === false) return
if (appUrl.value) openExternalUrl(appUrl.value)
}
@@ -6,7 +6,7 @@
first, then sync status arrives), and the sync screen is strictly
more informative, so it takes precedence instead of the two
rendering on top of each other. -->
<AppLoadingScreen v-if="loading && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
<AppLoadingScreen v-if="loading && !uiReadyBlocked && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
</Transition>
<!-- ElectrumX sync screen — shown before the real UI while the on-chain
@@ -43,7 +43,7 @@
</Transition>
<div
v-if="appUrl && !iframeBlocked && (!electrsSync || electrsSync.stale)"
v-if="appUrl && !iframeBlocked && !uiReadyBlocked && (!electrsSync || electrsSync.stale)"
class="absolute inset-0 app-session-frame-scroll-host"
tabindex="-1"
@pointerdown="focusIframe"
@@ -66,7 +66,7 @@
reachable yet, so the "App not reachable / retry" overlay would just
paint over the sync progress and read as a hard error. -->
<Transition name="content-fade">
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div v-if="(iframeBlocked || uiReadyBlocked) && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8">
<!-- Warm-up uses the app's own icon, pulsing, rather than the padlock:
the padlock reads as "blocked/denied" and this state is neither. -->
@@ -78,7 +78,8 @@
</div>
<h3 class="text-lg font-semibold text-white mb-2">{{ warmingUp ? `${appTitle} is starting…` : blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
<p class="text-white/50 text-sm mb-6">
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
<template v-if="uiReadyBlocked">{{ blockedReason }} This screen opens automatically when it is ready.</template>
<template v-else-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
<template v-else-if="warmingUp">The container is running but hasn't finished warming up yet.<br>This screen opens on its own as soon as it answers.<span v-if="autoRetryCount > 0" class="block text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
@@ -95,6 +96,7 @@
Retry now
</button>
<button
v-if="!uiReadyBlocked"
@click="$emit('openNewTabAndBack')"
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
>
@@ -108,7 +110,7 @@
</div>
</Transition>
<div v-if="!appUrl" class="absolute inset-0 flex items-center justify-center">
<div v-if="!appUrl && !uiReadyBlocked" class="absolute inset-0 flex items-center justify-center">
<div class="text-center px-8">
<h3 class="text-lg font-semibold text-white mb-2">App not configured</h3>
<p class="text-white/50 text-sm">No URL found for {{ appId }}</p>
@@ -133,6 +135,7 @@ const props = defineProps<{
mustOpenNewTab: boolean
autoRetryCount: number
refreshKey: number
uiReadyBlocked?: boolean
blockedReason?: string
blockedTitle?: string
// True while the container is up but its probe hasn't answered yet and the
@@ -66,3 +66,19 @@ describe('AppSessionFrame warm-up state', () => {
expect(text).toContain('This app opens in a new tab')
})
})
describe('HTTP readiness gate', () => {
it('does not show a missing-configuration error during initial installation', () => {
const frame = mountFrame({ appUrl: '', uiReadyBlocked: true, blockedReason: 'Waiting for the app to be ready…' })
expect(frame.text()).not.toContain('App not configured')
expect(frame.find('iframe').exists()).toBe(false)
})
it('does not mount an iframe before readiness, then opens automatically', async () => {
const frame = mountFrame({ iframeBlocked: false, uiReadyBlocked: true, blockedReason: 'Waiting for the app to be ready…', blockedTitle: 'App not ready' })
expect(frame.find('iframe').exists()).toBe(false)
expect(frame.text()).toContain('opens automatically')
expect(frame.text()).not.toContain('Open in new tab')
await frame.setProps({ uiReadyBlocked: false, blockedReason: '' })
expect(frame.find('iframe').exists()).toBe(true)
})
})
@@ -184,3 +184,24 @@ describe('appsConfig service filtering', () => {
expect(canLaunch(pkg)).toBe(true)
})
})
describe('HTTP readiness independent of container health', () => {
it('blocks fixed launch URLs while the HTTP upstream is unavailable', () => {
for (const id of ['gitea', 'filebrowser', 'fedimint', 'lnd']) {
const pkg = makePkg(id, id, 'other')
pkg['ui-ready'] = false
pkg.health = 'healthy'
expect(canLaunch(pkg)).toBe(false)
expect(isAppReadyForLaunch(pkg)).toBe(false)
expect(launchBlockedReason(id, pkg)).toContain('Waiting')
pkg['ui-ready'] = true
expect(isAppReadyForLaunch(pkg)).toBe(true)
}
})
it('allows a ready companion while its backend is syncing', () => {
const pkg = makePkg('lnd', 'Lightning', 'bitcoin')
pkg.health = 'starting'
pkg['ui-ready'] = true
expect(isAppReadyForLaunch(pkg)).toBe(true)
})
})
+6
View File
@@ -244,6 +244,7 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
export function canLaunch(pkg: PackageDataEntry): boolean {
if (isWebOnlyApp(pkg.manifest.id)) return true
if (pkg['ui-ready'] === false) return false
// Headless backends never get a Launch button, even with a published port.
if (isServicePackage(pkg.manifest.id, pkg)) return false
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
@@ -277,6 +278,7 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
* health check retain the legacy state/port behaviour.
*/
export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
if (pkg['ui-ready'] !== undefined) return pkg['ui-ready']
const manifest = pkg.manifest as unknown as Record<string, unknown>
const hasHealthCheck = Boolean(manifest.health_check || manifest['health-check'])
if (!hasHealthCheck) return pkg.health !== 'unhealthy'
@@ -285,6 +287,10 @@ export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
const appId = pkg?.manifest?.id || id
if (pkg?.['ui-ready'] === false && !isServicePackage(appId, pkg)) {
if (pkg.state === PackageState.Stopped || pkg.state === PackageState.Exited) return 'App is stopped. Start it to open it.'
return 'Waiting for the app to be ready…'
}
if (
(appId === 'fedimint' || appId === 'fedimintd') &&
(pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting'))
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Validate build-source apps against an OTA/ISO runtime payload before shipping."""
import sys
from pathlib import Path
import yaml
def check(root: Path) -> int:
root = root.resolve()
manifests = sorted((root / 'apps').glob('*/manifest.y*ml'))
if not manifests:
raise ValueError(f'No app manifests in {root / "apps"}')
count = 0
for manifest in manifests:
app = yaml.safe_load(manifest.read_text())['app']
build = app.get('container', {}).get('build')
if not build:
continue
context = Path(build['context'])
if context.is_absolute():
context = root / context.relative_to('/opt/archipelago')
else:
context = manifest.parent / context
context = context.resolve()
if not context.is_relative_to(root) or not context.is_dir():
raise ValueError(f'{app["id"]}: missing or out-of-payload build context: {context}')
dockerfile = (context / build.get('dockerfile', 'Dockerfile')).resolve()
if not dockerfile.is_relative_to(context) or not dockerfile.is_file():
raise ValueError(f'{app["id"]}: missing or out-of-context Dockerfile: {dockerfile}')
count += 1
return count
if __name__ == '__main__':
try:
count = check(Path(sys.argv[1] if len(sys.argv) > 1 else '.'))
except (ValueError, KeyError, OSError, yaml.YAMLError) as error:
sys.exit(f'Invalid app build payload: {error}')
print(f'Validated {count} app build contexts and Dockerfiles.')
+1
View File
@@ -101,6 +101,7 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
cp -r "$PROJECT_ROOT/$runtime_path" "$RUNTIME_DIR/$runtime_path"
fi
done
python3 "$PROJECT_ROOT/scripts/check-app-build-contexts.py" "$RUNTIME_DIR"
# KEEP IN SYNC with the `for unit in [...]` array in
# core/archipelago/src/bootstrap.rs (run_runtime_assets). A unit that
# bootstrap installs but this list does not ship simply never reaches a
+7
View File
@@ -64,6 +64,13 @@ for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
fi
done
# Verify the mounted artifact carries every manifest-declared build source.
if python3 "$REPO/scripts/check-app-build-contexts.py" "$MNT/archipelago"; then
ok "app build contexts and Dockerfiles"
else
bad "incomplete app build payload"
fi
# ── GRUB must boot the live system ───────────────────────────────────
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
ok "grub.cfg has boot=live"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Exercise release payload checks with complete, incomplete and escaping contexts."""
import importlib.util
import shutil
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location('contexts', REPO / 'scripts/check-app-build-contexts.py')
contexts = importlib.util.module_from_spec(spec)
spec.loader.exec_module(contexts)
class BuildPayloadTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
shutil.copytree(REPO / 'apps', self.root / 'apps')
shutil.copytree(REPO / 'docker', self.root / 'docker')
def test_complete_payload(self):
self.assertGreaterEqual(contexts.check(self.root), 6)
def test_iso_old_allowlist_rejected(self):
shutil.rmtree(self.root / 'docker/archipelago-source')
with self.assertRaisesRegex(ValueError, 'archipelago-source.*missing'):
contexts.check(self.root)
def test_missing_dockerfile_rejected(self):
(self.root / 'docker/archipelago-source/Dockerfile').unlink()
with self.assertRaisesRegex(ValueError, 'archipelago-source.*Dockerfile'):
contexts.check(self.root)
def test_context_symlink_cannot_escape_payload(self):
target = self.root / 'docker/archipelago-source'
shutil.rmtree(target)
target.symlink_to(REPO / 'docker/archipelago-source', target_is_directory=True)
with self.assertRaisesRegex(ValueError, 'out-of-payload'):
contexts.check(self.root)
def test_empty_payload_rejected(self):
shutil.rmtree(self.root / 'apps')
with self.assertRaisesRegex(ValueError, 'No app manifests'):
contexts.check(self.root)
if __name__ == '__main__':
unittest.main()
+1
View File
@@ -71,6 +71,7 @@ summary() {
# ── Stage 1: static ──────────────────────────────────────────────────
stage "git-diff-check" git diff --check
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
stage "app-build-contexts" python3 tests/regression/app-build-contexts.py
stage "manifest-shell" python3 scripts/check-manifest-shell.py
stage "doctor-ports" bash tests/regression/container-doctor-ports.sh
stage "bitcoin-pruning" python3 tests/regression/bitcoin-prune-entrypoint.py