Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
971d477795 | ||
|
|
f12042f194 | ||
|
|
e7cf336665 | ||
|
|
8ca20de82e | ||
|
|
1fa654cb6a | ||
|
|
c993d9dd0d | ||
|
|
33d2b3ce60 | ||
|
|
c7ce35bd43 | ||
|
|
03e38d1ca3 | ||
|
|
e5fc99d66c | ||
|
|
8b74803290 |
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## v1.8.21-alpha (2026-09-30)
|
||||||
|
|
||||||
|
- Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.
|
||||||
|
- Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.
|
||||||
|
- Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.
|
||||||
|
- Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.
|
||||||
|
|
||||||
## v1.8.20-alpha (2026-09-29)
|
## v1.8.20-alpha (2026-09-29)
|
||||||
|
|
||||||
- Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.
|
- Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.
|
||||||
|
|||||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.20-alpha"
|
version = "1.8.21-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.20-alpha"
|
version = "1.8.21-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
|
|||||||
@@ -162,11 +162,33 @@ 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."}"#,
|
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::NotFound) | Err(_) => Ok(build_response(
|
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(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"text/plain",
|
"text/plain",
|
||||||
hyper::Body::from("Content not found"),
|
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"),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,25 @@ 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.
|
/// 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 {
|
fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json::Value {
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
@@ -623,13 +642,14 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let path = format!("/content/{}", content_id);
|
let path = format!("/content/{}", content_id);
|
||||||
// Surface a real reason instead of the generic sanitized error (#30):
|
// Surface a real reason instead of the generic sanitized error (#30):
|
||||||
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
// A bearer token must not be replayed after an ambiguous delivery.
|
||||||
// here means the peer is genuinely unreachable on both transports.
|
// A transport error can mean the seller received it without replying.
|
||||||
let (response, transport) =
|
let (response, transport) =
|
||||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.header("X-Federation-DID", local_did)
|
.header("X-Federation-DID", local_did)
|
||||||
.header("X-Payment-Token", token_str.clone())
|
.header("X-Payment-Token", token_str.clone())
|
||||||
|
.single_delivery()
|
||||||
.timeout(std::time::Duration::from_secs(900))
|
.timeout(std::time::Duration::from_secs(900))
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
@@ -642,7 +662,7 @@ impl RpcHandler {
|
|||||||
let refund =
|
let refund =
|
||||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": format!("Could not reach the peer over mesh or Tor. {refund}")
|
"error": format!("The purchase could not be completed. {refund}")
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -679,7 +699,7 @@ impl RpcHandler {
|
|||||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||||
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": format!("Peer returned an error ({status}). {refund}")
|
"error": format!("{} {refund}", seller_error_message(status, &body))
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,10 +713,17 @@ impl RpcHandler {
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
|
|
||||||
let bytes = response
|
let bytes = match response.bytes().await {
|
||||||
.bytes()
|
Ok(bytes) => bytes,
|
||||||
.await
|
Err(error) => {
|
||||||
.context("Failed to read response body")?;
|
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}")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Persist the purchase so it "stays unlocked" for this buyer: cache the
|
// Persist the purchase so it "stays unlocked" for this buyer: cache the
|
||||||
// bytes + metadata keyed by (onion, content_id). The gallery then renders
|
// bytes + metadata keyed by (onion, content_id). The gallery then renders
|
||||||
|
|||||||
@@ -162,3 +162,20 @@ async fn files_copy_fails_without_overwriting_or_claiming_success_on_errors() {
|
|||||||
assert_eq!(api.seen.lock().unwrap().len(), expected);
|
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)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1174,15 +1174,21 @@ impl ReconcileReport {
|
|||||||
fn cascade_pairs_for_report<'r>(
|
fn cascade_pairs_for_report<'r>(
|
||||||
report: &'r ReconcileReport,
|
report: &'r ReconcileReport,
|
||||||
user_stopped: &std::collections::HashSet<String>,
|
user_stopped: &std::collections::HashSet<String>,
|
||||||
|
changed_backends: &HashSet<String>,
|
||||||
) -> Vec<(&'r str, &'static str)> {
|
) -> Vec<(&'r str, &'static str)> {
|
||||||
let mut pairs = Vec::new();
|
let mut pairs = Vec::new();
|
||||||
for (backend, action) in &report.actions {
|
for (backend, action) in &report.actions {
|
||||||
if !matches!(
|
if !matches!(
|
||||||
action,
|
action,
|
||||||
ReconcileAction::Installed | ReconcileAction::Started
|
ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// A successful systemctl start can be a no-op after a transient
|
||||||
|
// Podman inspect failure. Require a witnessed lifecycle change.
|
||||||
|
if !changed_backends.contains(backend) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for dep in crate::app_ops::address_caching_dependents(backend) {
|
for dep in crate::app_ops::address_caching_dependents(backend) {
|
||||||
let dep_untouched = report
|
let dep_untouched = report
|
||||||
.actions
|
.actions
|
||||||
@@ -1196,6 +1202,25 @@ fn cascade_pairs_for_report<'r>(
|
|||||||
pairs
|
pairs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Only positive runtime evidence permits disrupting an address-caching wallet.
|
||||||
|
/// A known absent/stopped backend becoming running, a new container ID, or a
|
||||||
|
/// changed start timestamp qualifies. A failed observation never does.
|
||||||
|
fn backend_instance_changed(before: Option<&ContainerStatus>, after: &ContainerStatus) -> bool {
|
||||||
|
if after.state != ContainerState::Running || after.id.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(before) = before else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if before.id.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if before.id != after.id || before.state != ContainerState::Running {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
matches!((&before.started_at, &after.started_at), (Some(a), Some(b)) if !a.is_empty() && !b.is_empty() && a != b)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct AdoptionReport {
|
pub struct AdoptionReport {
|
||||||
pub adopted: Vec<String>,
|
pub adopted: Vec<String>,
|
||||||
@@ -1909,12 +1934,33 @@ impl ProdContainerOrchestrator {
|
|||||||
_ => 2,
|
_ => 2,
|
||||||
});
|
});
|
||||||
// Live container names (any state), for the same recovery check.
|
// Live container names (any state), for the same recovery check.
|
||||||
let present_containers: std::collections::HashSet<String> = self
|
let listed_containers = self.runtime.list_containers().await.ok();
|
||||||
.runtime
|
let present_containers: HashSet<String> = listed_containers
|
||||||
.list_containers()
|
.as_ref()
|
||||||
.await
|
.map(|cs| cs.iter().map(|c| c.name.clone()).collect())
|
||||||
.map(|cs| cs.into_iter().map(|c| c.name).collect())
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
// Keep unknown distinct from confirmed absence. Runtime queries can
|
||||||
|
// fail under load while systemd still has a healthy running backend.
|
||||||
|
let mut backend_before: HashMap<String, Option<ContainerStatus>> = HashMap::new();
|
||||||
|
for lm in &manifests {
|
||||||
|
let id = &lm.manifest.app.id;
|
||||||
|
if crate::app_ops::address_caching_dependents(id).is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = compute_container_name(&lm.manifest);
|
||||||
|
match self.runtime.get_container_status(&name).await {
|
||||||
|
Ok(status) => {
|
||||||
|
backend_before.insert(id.clone(), Some(status));
|
||||||
|
}
|
||||||
|
Err(_) if listed_containers.is_some() && !present_containers.contains(&name) => {
|
||||||
|
backend_before.insert(id.clone(), None);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(backend = %id, error = %err,
|
||||||
|
"cannot observe backend before reconcile; will not infer a dependency restart from an action report");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut report = ReconcileReport::default();
|
let mut report = ReconcileReport::default();
|
||||||
let disk_gb = self.disk_gb().await;
|
let disk_gb = self.disk_gb().await;
|
||||||
let bitcoin_pruned = disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
let bitcoin_pruned = disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
||||||
@@ -2096,7 +2142,20 @@ impl ProdContainerOrchestrator {
|
|||||||
// state recovery, repair recreate, boot InstallMissing) moves the
|
// state recovery, repair recreate, boot InstallMissing) moves the
|
||||||
// address behind a running dependent's back — §C "restart lnd after
|
// address behind a running dependent's back — §C "restart lnd after
|
||||||
// ANY bitcoin recreate".
|
// ANY bitcoin recreate".
|
||||||
for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped) {
|
let mut changed_backends = HashSet::new();
|
||||||
|
for (backend, before) in &backend_before {
|
||||||
|
let Some(name) = container_name_by_app_id.get(backend) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Ok(after) = self.runtime.get_container_status(name).await {
|
||||||
|
if backend_instance_changed(before.as_ref(), &after) {
|
||||||
|
changed_backends.insert(backend.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A user stop during a slow reconcile pass still takes precedence.
|
||||||
|
let user_stopped = crate::crash_recovery::load_user_stopped(&self.data_dir).await;
|
||||||
|
for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped, &changed_backends) {
|
||||||
// Same rule as the RPC cascade: hold the dependent's op lock
|
// Same rule as the RPC cascade: hold the dependent's op lock
|
||||||
// across the restart; skip when a worker is mid-sequence.
|
// across the restart; skip when a worker is mid-sequence.
|
||||||
let lock = crate::app_ops::op_lock(dep);
|
let lock = crate::app_ops::op_lock(dep);
|
||||||
@@ -6409,6 +6468,67 @@ app:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_cascade_requires_observed_instance_change() {
|
||||||
|
let running = ContainerStatus {
|
||||||
|
id: "container-1".into(),
|
||||||
|
name: "bitcoin-core".into(),
|
||||||
|
state: ContainerState::Running,
|
||||||
|
started_at: Some("start-1".into()),
|
||||||
|
health: None,
|
||||||
|
exit_code: None,
|
||||||
|
image: "bitcoin:1".into(),
|
||||||
|
created: "created-1".into(),
|
||||||
|
ports: vec![],
|
||||||
|
lan_address: None,
|
||||||
|
};
|
||||||
|
assert!(!backend_instance_changed(Some(&running), &running));
|
||||||
|
assert!(backend_instance_changed(None, &running));
|
||||||
|
let mut before = running.clone();
|
||||||
|
before.state = ContainerState::Exited;
|
||||||
|
assert!(backend_instance_changed(Some(&before), &running));
|
||||||
|
before = running.clone();
|
||||||
|
before.id = "old-container".into();
|
||||||
|
assert!(backend_instance_changed(Some(&before), &running));
|
||||||
|
before = running.clone();
|
||||||
|
before.started_at = Some("earlier-start".into());
|
||||||
|
assert!(backend_instance_changed(Some(&before), &running));
|
||||||
|
before.started_at = None;
|
||||||
|
assert!(!backend_instance_changed(Some(&before), &running));
|
||||||
|
before.id.clear();
|
||||||
|
assert!(!backend_instance_changed(Some(&before), &running));
|
||||||
|
let mut after = running.clone();
|
||||||
|
after.state = ContainerState::Exited;
|
||||||
|
assert!(!backend_instance_changed(None, &after));
|
||||||
|
after = running.clone();
|
||||||
|
after.id.clear();
|
||||||
|
assert!(!backend_instance_changed(None, &after));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cascade_ignores_false_started_report_but_detects_real_exec_drift() {
|
||||||
|
let none = HashSet::new();
|
||||||
|
let mut report = ReconcileReport {
|
||||||
|
actions: vec![
|
||||||
|
("bitcoin-core".into(), ReconcileAction::Started),
|
||||||
|
("lnd".into(), ReconcileAction::NoOp),
|
||||||
|
],
|
||||||
|
failures: vec![],
|
||||||
|
};
|
||||||
|
// systemctl start of an already active unit does not move its address.
|
||||||
|
assert!(cascade_pairs_for_report(&report, &none, &none).is_empty());
|
||||||
|
// A unit exec rewrite can restart Bitcoin while the outer reconcile
|
||||||
|
// action remains NoOp. Runtime evidence still requires LND to reconnect.
|
||||||
|
let changed = ["bitcoin-core".into()].into();
|
||||||
|
report.actions[0].1 = ReconcileAction::NoOp;
|
||||||
|
assert_eq!(
|
||||||
|
cascade_pairs_for_report(&report, &none, &changed),
|
||||||
|
vec![("bitcoin-core", "lnd")]
|
||||||
|
);
|
||||||
|
report.actions[0].1 = ReconcileAction::Left("lifecycle-op-in-flight".into());
|
||||||
|
assert!(cascade_pairs_for_report(&report, &none, &changed).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cascade_pairs_cover_backend_recreate_with_running_dependent() {
|
fn cascade_pairs_cover_backend_recreate_with_running_dependent() {
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@@ -6420,6 +6540,7 @@ app:
|
|||||||
failures: vec![],
|
failures: vec![],
|
||||||
};
|
};
|
||||||
let none = HashSet::new();
|
let none = HashSet::new();
|
||||||
|
let changed: HashSet<String> = ["bitcoin-core".into(), "bitcoin-knots".into()].into();
|
||||||
|
|
||||||
// Backend recreated while lnd sat running (NoOp) → cascade.
|
// Backend recreated while lnd sat running (NoOp) → cascade.
|
||||||
let r = report(vec![
|
let r = report(vec![
|
||||||
@@ -6427,7 +6548,7 @@ app:
|
|||||||
("lnd", ReconcileAction::NoOp),
|
("lnd", ReconcileAction::NoOp),
|
||||||
]);
|
]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cascade_pairs_for_report(&r, &none),
|
cascade_pairs_for_report(&r, &none, &changed),
|
||||||
vec![("bitcoin-knots", "lnd")]
|
vec![("bitcoin-knots", "lnd")]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -6437,7 +6558,7 @@ app:
|
|||||||
("lnd", ReconcileAction::NoOp),
|
("lnd", ReconcileAction::NoOp),
|
||||||
]);
|
]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cascade_pairs_for_report(&r, &none),
|
cascade_pairs_for_report(&r, &none, &changed),
|
||||||
vec![("bitcoin-core", "lnd")]
|
vec![("bitcoin-core", "lnd")]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -6446,7 +6567,7 @@ app:
|
|||||||
("bitcoin-knots", ReconcileAction::NoOp),
|
("bitcoin-knots", ReconcileAction::NoOp),
|
||||||
("lnd", ReconcileAction::NoOp),
|
("lnd", ReconcileAction::NoOp),
|
||||||
]);
|
]);
|
||||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
assert!(cascade_pairs_for_report(&r, &none, &none).is_empty());
|
||||||
|
|
||||||
// Dependent itself (re)started this pass → it already resolved the
|
// Dependent itself (re)started this pass → it already resolved the
|
||||||
// fresh address; no cascade.
|
// fresh address; no cascade.
|
||||||
@@ -6454,7 +6575,7 @@ app:
|
|||||||
("bitcoin-knots", ReconcileAction::Installed),
|
("bitcoin-knots", ReconcileAction::Installed),
|
||||||
("lnd", ReconcileAction::Started),
|
("lnd", ReconcileAction::Started),
|
||||||
]);
|
]);
|
||||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty());
|
||||||
|
|
||||||
// User-stopped dependent is never bounced.
|
// User-stopped dependent is never bounced.
|
||||||
let r = report(vec![
|
let r = report(vec![
|
||||||
@@ -6462,14 +6583,14 @@ app:
|
|||||||
("lnd", ReconcileAction::NoOp),
|
("lnd", ReconcileAction::NoOp),
|
||||||
]);
|
]);
|
||||||
let stopped: HashSet<String> = ["lnd".to_string()].into();
|
let stopped: HashSet<String> = ["lnd".to_string()].into();
|
||||||
assert!(cascade_pairs_for_report(&r, &stopped).is_empty());
|
assert!(cascade_pairs_for_report(&r, &stopped, &changed).is_empty());
|
||||||
|
|
||||||
// Non-backend recreates don't cascade anything.
|
// Non-backend recreates don't cascade anything.
|
||||||
let r = report(vec![
|
let r = report(vec![
|
||||||
("grafana", ReconcileAction::Installed),
|
("grafana", ReconcileAction::Installed),
|
||||||
("lnd", ReconcileAction::NoOp),
|
("lnd", ReconcileAction::NoOp),
|
||||||
]);
|
]);
|
||||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ pub struct QuadletUnit {
|
|||||||
pub no_new_privileges: bool,
|
pub no_new_privileges: bool,
|
||||||
pub cpu_quota: Option<u32>,
|
pub cpu_quota: Option<u32>,
|
||||||
pub restart_policy: RestartPolicy,
|
pub restart_policy: RestartPolicy,
|
||||||
|
pub stop_grace_secs: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QuadletUnit {
|
impl QuadletUnit {
|
||||||
@@ -216,6 +217,10 @@ impl QuadletUnit {
|
|||||||
let _ = writeln!(s, "[Container]");
|
let _ = writeln!(s, "[Container]");
|
||||||
let _ = writeln!(s, "ContainerName={}", self.name);
|
let _ = writeln!(s, "ContainerName={}", self.name);
|
||||||
let _ = writeln!(s, "Image={}", self.image);
|
let _ = writeln!(s, "Image={}", self.image);
|
||||||
|
let grace = self
|
||||||
|
.stop_grace_secs
|
||||||
|
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(&self.name));
|
||||||
|
let _ = writeln!(s, "StopTimeout={grace}");
|
||||||
// Pull=never: companions are pre-pulled or built. A missing image
|
// Pull=never: companions are pre-pulled or built. A missing image
|
||||||
// must surface as a unit start failure, not a silent retry storm.
|
// must surface as a unit start failure, not a silent retry storm.
|
||||||
let _ = writeln!(s, "Pull=never");
|
let _ = writeln!(s, "Pull=never");
|
||||||
@@ -350,6 +355,15 @@ impl QuadletUnit {
|
|||||||
// the unit stuck in deactivating. Health/status remains app-level state,
|
// the unit stuck in deactivating. Health/status remains app-level state,
|
||||||
// not a systemd start gate.
|
// not a systemd start gate.
|
||||||
let _ = writeln!(s, "TimeoutStartSec=0");
|
let _ = writeln!(s, "TimeoutStartSec=0");
|
||||||
|
let _ = writeln!(s, "TimeoutStopSec={}", grace.saturating_add(15));
|
||||||
|
// Stop explicitly before Quadlet's generated `podman rm -f`. The
|
||||||
|
// existing container may still carry Podman's old 10-second default;
|
||||||
|
// StopTimeout alone only protects containers created after migration.
|
||||||
|
let _ = writeln!(s, "ExecStop=");
|
||||||
|
let _ = writeln!(
|
||||||
|
s,
|
||||||
|
"ExecStop=/usr/bin/podman stop --ignore --time={grace} --cidfile=%t/%N.cid"
|
||||||
|
);
|
||||||
// Restart policy + 10s backoff. RestartSec keeps a crash-loop
|
// Restart policy + 10s backoff. RestartSec keeps a crash-loop
|
||||||
// from saturating the journal. Companions: Always. Backends:
|
// from saturating the journal. Companions: Always. Backends:
|
||||||
// OnFailure (clean stops stay stopped).
|
// OnFailure (clean stops stay stopped).
|
||||||
@@ -525,6 +539,9 @@ impl QuadletUnit {
|
|||||||
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
|
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
|
||||||
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
|
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
|
||||||
restart_policy: RestartPolicy::Always,
|
restart_policy: RestartPolicy::Always,
|
||||||
|
stop_grace_secs: Some(super::prod_orchestrator::resolve_stop_grace_secs(
|
||||||
|
manifest, name,
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -792,7 +809,11 @@ pub async fn stop_service(service: &str) -> Result<()> {
|
|||||||
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
||||||
/// less than `QUADLET_STOP_TIMEOUT`.
|
/// less than `QUADLET_STOP_TIMEOUT`.
|
||||||
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
|
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
|
||||||
let timeout = timeout.max(QUADLET_STOP_TIMEOUT);
|
let name = service.strip_suffix(".service").unwrap_or(service);
|
||||||
|
let body = fs::read_to_string(unit_dir().await?.join(format!("{name}.container")))
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let timeout = timeout.max(stop_wait_timeout(name, &body));
|
||||||
match systemctl_user_status(&["stop", service], timeout).await {
|
match systemctl_user_status(&["stop", service], timeout).await {
|
||||||
Ok(status) if status.success() => Ok(()),
|
Ok(status) if status.success() => Ok(()),
|
||||||
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
||||||
@@ -813,6 +834,20 @@ pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Resu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The command waiter must outlive both the container grace and systemd's
|
||||||
|
/// stop deadline. Restart/repair callers must not kill Bitcoin at 45 seconds.
|
||||||
|
fn stop_wait_timeout(name: &str, unit_body: &str) -> Duration {
|
||||||
|
Duration::from_secs(stop_grace_from_unit(name, unit_body).saturating_add(30))
|
||||||
|
.max(QUADLET_STOP_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_grace_from_unit(name: &str, unit_body: &str) -> u64 {
|
||||||
|
directive_values(unit_body, "StopTimeout=")
|
||||||
|
.last()
|
||||||
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
|
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(name))
|
||||||
|
}
|
||||||
|
|
||||||
async fn systemctl_user_status(
|
async fn systemctl_user_status(
|
||||||
args: &[&str],
|
args: &[&str],
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
@@ -939,6 +974,10 @@ fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
|
|||||||
/// that systemd no longer knows about.
|
/// that systemd no longer knows about.
|
||||||
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||||
let svc = format!("{unit_name}.service");
|
let svc = format!("{unit_name}.service");
|
||||||
|
let path = dir.join(format!("{unit_name}.container"));
|
||||||
|
let body = fs::read_to_string(&path).await.unwrap_or_default();
|
||||||
|
let timeout = stop_wait_timeout(unit_name, &body);
|
||||||
|
let grace = stop_grace_from_unit(unit_name, &body).to_string();
|
||||||
// Stop first; ignore failure (unit may already be down). BOUNDED — on
|
// Stop first; ignore failure (unit may already be down). BOUNDED — on
|
||||||
// rootless podman a generated unit can wedge in "deactivating" while
|
// rootless podman a generated unit can wedge in "deactivating" while
|
||||||
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
|
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
|
||||||
@@ -946,13 +985,12 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
|||||||
// the package entry is stranded in `Removing` (a ghost in My Apps that also
|
// the package entry is stranded in `Removing` (a ghost in My Apps that also
|
||||||
// blocks reinstall). If the graceful stop times out, escalate to
|
// blocks reinstall). If the graceful stop times out, escalate to
|
||||||
// SIGKILL + reset-failed so teardown always proceeds.
|
// SIGKILL + reset-failed so teardown always proceeds.
|
||||||
if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT)
|
if systemctl_user_status(&["stop", &svc], timeout)
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
let _ = kill_and_reset_service(&svc).await;
|
let _ = kill_and_reset_service(&svc).await;
|
||||||
}
|
}
|
||||||
let path = dir.join(format!("{unit_name}.container"));
|
|
||||||
if fs::try_exists(&path).await.unwrap_or(false) {
|
if fs::try_exists(&path).await.unwrap_or(false) {
|
||||||
match fs::remove_file(&path).await {
|
match fs::remove_file(&path).await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
@@ -965,9 +1003,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
|||||||
// Bounded so a hung podman store can't re-introduce the stall this function
|
// Bounded so a hung podman store can't re-introduce the stall this function
|
||||||
// exists to avoid.
|
// exists to avoid.
|
||||||
let _ = tokio::time::timeout(
|
let _ = tokio::time::timeout(
|
||||||
QUADLET_STOP_TIMEOUT,
|
timeout,
|
||||||
Command::new("podman")
|
Command::new("podman")
|
||||||
.args(["rm", "-f", unit_name])
|
.args(["rm", "-f", "--ignore", "--time", &grace, unit_name])
|
||||||
.status(),
|
.status(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -992,6 +1030,118 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shutdown_grace_covers_container_systemd_and_caller() {
|
||||||
|
for (name, grace) in [
|
||||||
|
("bitcoin-core", 600),
|
||||||
|
("bitcoin-knots", 600),
|
||||||
|
("lnd", 330),
|
||||||
|
("electrumx", 300),
|
||||||
|
("other", 30),
|
||||||
|
] {
|
||||||
|
let unit = QuadletUnit {
|
||||||
|
name: name.into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let body = unit.render();
|
||||||
|
assert!(body.contains(&format!("StopTimeout={grace}\n")));
|
||||||
|
assert!(body.contains(&format!("TimeoutStopSec={}\n", grace + 15)));
|
||||||
|
assert!(body.contains(&format!("podman stop --ignore --time={grace} --cidfile=")));
|
||||||
|
assert_eq!(
|
||||||
|
stop_wait_timeout(name, &body),
|
||||||
|
Duration::from_secs(grace + 30)
|
||||||
|
);
|
||||||
|
// Legacy units have no StopTimeout directive yet.
|
||||||
|
assert_eq!(stop_wait_timeout(name, ""), Duration::from_secs(grace + 30));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_stop_grace_survives_render_and_restart_budget() {
|
||||||
|
let manifest: AppManifest = serde_yaml::from_str(
|
||||||
|
r#"
|
||||||
|
app:
|
||||||
|
id: custom-db
|
||||||
|
name: Custom database
|
||||||
|
version: 1.0.0
|
||||||
|
stop_grace_secs: 900
|
||||||
|
container:
|
||||||
|
image: example/db:1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let unit = QuadletUnit::from_manifest(&manifest, "custom-db");
|
||||||
|
assert_eq!(unit.stop_grace_secs, Some(900));
|
||||||
|
assert_eq!(
|
||||||
|
stop_wait_timeout("custom-db", &unit.render()),
|
||||||
|
Duration::from_secs(930)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stop_wait_timeout("lnd", "StopTimeout=invalid"),
|
||||||
|
Duration::from_secs(360)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_grace_migration_does_not_request_an_execution_restart() {
|
||||||
|
let unit = sample_unit();
|
||||||
|
let new = unit.render();
|
||||||
|
let old = new
|
||||||
|
.lines()
|
||||||
|
.filter(|line| {
|
||||||
|
!line.starts_with("StopTimeout=")
|
||||||
|
&& !line.starts_with("TimeoutStopSec=")
|
||||||
|
&& !line.starts_with("ExecStop=")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert!(!exec_changed(&old, &new));
|
||||||
|
assert!(!publish_ports_changed(&old, &new));
|
||||||
|
assert!(!network_aliases_changed(&old, &new));
|
||||||
|
assert!(!health_cmd_changed(&old, &new));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn actual_quadlet_generator_stops_before_forced_removal() {
|
||||||
|
let generator = Path::new("/usr/lib/systemd/system-generators/podman-system-generator");
|
||||||
|
if !generator.exists() {
|
||||||
|
eprintln!(
|
||||||
|
"Quadlet generator unavailable; run this regression on the Linux release host"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let unit = QuadletUnit {
|
||||||
|
name: "grace-test".into(),
|
||||||
|
image: "localhost/test:latest".into(),
|
||||||
|
stop_grace_secs: Some(600),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
std::fs::write(dir.path().join("grace-test.container"), unit.render()).unwrap();
|
||||||
|
let output = std::process::Command::new(generator)
|
||||||
|
.args(["--user", "--dryrun"])
|
||||||
|
.env("QUADLET_UNIT_DIRS", dir.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let generated = String::from_utf8_lossy(&output.stdout).to_string()
|
||||||
|
+ &String::from_utf8_lossy(&output.stderr);
|
||||||
|
let stop = generated
|
||||||
|
.find("ExecStop=/usr/bin/podman stop --ignore --time=600")
|
||||||
|
.unwrap();
|
||||||
|
let remove = generated.find("ExecStop=/usr/bin/podman rm ").unwrap();
|
||||||
|
assert!(
|
||||||
|
stop < remove,
|
||||||
|
"Legacy container must stop gracefully before removal"
|
||||||
|
);
|
||||||
|
assert!(generated.contains("--stop-timeout 600"));
|
||||||
|
assert!(generated.contains("TimeoutStopSec=615"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_emits_secret_env_by_reference_never_value() {
|
fn render_emits_secret_env_by_reference_never_value() {
|
||||||
let u = QuadletUnit {
|
let u = QuadletUnit {
|
||||||
|
|||||||
@@ -238,6 +238,11 @@ pub enum ServeResult {
|
|||||||
Forbidden,
|
Forbidden,
|
||||||
/// Content not found.
|
/// Content not found.
|
||||||
NotFound,
|
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.
|
/// Serve a content item by ID with access control and optional range request.
|
||||||
@@ -252,6 +257,39 @@ pub async fn serve_content(
|
|||||||
range: Option<ByteRange>,
|
range: Option<ByteRange>,
|
||||||
owner_session: bool,
|
owner_session: bool,
|
||||||
) -> Result<ServeResult> {
|
) -> 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 catalog = load_catalog(data_dir).await?;
|
||||||
let item = match catalog.items.iter().find(|i| i.id == id) {
|
let item = match catalog.items.iter().find(|i| i.id == id) {
|
||||||
Some(i) => i,
|
Some(i) => i,
|
||||||
@@ -314,6 +352,29 @@ pub async fn serve_content(
|
|||||||
return Ok(ServeResult::NotFound);
|
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
|
// Check access control
|
||||||
if !owner_session {
|
if !owner_session {
|
||||||
match &item.access {
|
match &item.access {
|
||||||
@@ -331,7 +392,7 @@ pub async fn serve_content(
|
|||||||
"fedimint"
|
"fedimint"
|
||||||
};
|
};
|
||||||
if method_accepted(&item.access, method)
|
if method_accepted(&item.access, method)
|
||||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
&& verify(token.to_owned(), *price_sats).await
|
||||||
{
|
{
|
||||||
authorized = true;
|
authorized = true;
|
||||||
}
|
}
|
||||||
@@ -358,55 +419,127 @@ pub async fn serve_content(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let metadata = fs::metadata(&file_path)
|
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)
|
||||||
.await
|
.await
|
||||||
.context("Failed to read file metadata")?;
|
{
|
||||||
let total_size = metadata.len();
|
Ok(file) => file,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||||
// Handle range request for streaming
|
let bytes = read_filebrowser_via_userns(data_dir, &path).await?;
|
||||||
if let Some(range) = range {
|
return slice_prepared_content(bytes, range, mime);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
Err(error) => return Err(error).context("Opening shared content"),
|
||||||
let len = (end - start + 1) as usize;
|
};
|
||||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
let metadata = file.metadata().await?;
|
||||||
let mut file = tokio::fs::File::open(&file_path)
|
anyhow::ensure!(metadata.is_file(), "Shared content is not a regular file");
|
||||||
|
let total = metadata.len();
|
||||||
|
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)
|
||||||
.await
|
.await
|
||||||
.context("Failed to open content file")?;
|
.context("Reading shared content range")?;
|
||||||
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 {
|
return Ok(ServeResult::Partial {
|
||||||
bytes: buf,
|
bytes,
|
||||||
mime_type: item.mime_type.clone(),
|
mime_type: mime,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
total: total_size,
|
total,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let mut bytes = Vec::new();
|
||||||
let bytes = fs::read(&file_path)
|
file.read_to_end(&mut bytes)
|
||||||
.await
|
.await
|
||||||
.context("Failed to read content file")?;
|
.context("Reading shared content")?;
|
||||||
|
Ok(ServeResult::Ok(bytes, mime))
|
||||||
|
}
|
||||||
|
|
||||||
debug!("Serving content '{}' ({} bytes)", id, bytes.len());
|
fn checked_range(range: &ByteRange, total: u64) -> Option<(u64, u64)> {
|
||||||
Ok(ServeResult::Ok(bytes, item.mime_type.clone()))
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of attempting to serve a preview.
|
/// Result of attempting to serve a preview.
|
||||||
@@ -729,3 +862,301 @@ mod prune_missing_content_tests {
|
|||||||
assert_eq!(reloaded.items[0].id, "present-item");
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,25 @@ fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
|
|||||||
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
|
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.
|
/// DNS suffix appended to a peer's bech32 npub.
|
||||||
pub const FIPS_DNS_SUFFIX: &str = "fips";
|
pub const FIPS_DNS_SUFFIX: &str = "fips";
|
||||||
|
|
||||||
@@ -113,7 +132,21 @@ pub fn client() -> reqwest::Client {
|
|||||||
/// before the Tor fallback ever gets a chance. The generous `connect_timeout`
|
/// 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.
|
/// is preserved so a cold hole-punched path still gets time to establish.
|
||||||
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
|
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()
|
reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.redirect(delivery_redirect_policy(single))
|
||||||
.timeout(timeout)
|
.timeout(timeout)
|
||||||
.connect_timeout(Duration::from_secs(8))
|
.connect_timeout(Duration::from_secs(8))
|
||||||
.user_agent("archipelago-fips/1")
|
.user_agent("archipelago-fips/1")
|
||||||
@@ -130,10 +163,18 @@ pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
|
|||||||
/// robust". Only connect/timeout errors are retried (a real HTTP response,
|
/// robust". Only connect/timeout errors are retried (a real HTTP response,
|
||||||
/// including 4xx/5xx, is returned as-is for the caller to interpret).
|
/// 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> {
|
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();
|
let retry = rb.try_clone();
|
||||||
match rb.send().await {
|
match rb.send().await {
|
||||||
Ok(resp) => Ok(resp),
|
Ok(resp) => Ok(resp),
|
||||||
Err(e) if (e.is_connect() || e.is_timeout()) && retry.is_some() => {
|
Err(e) if retryable(&e) && retry.is_some() => {
|
||||||
// Brief pause so the hole-punch packets from the first attempt can
|
// Brief pause so the hole-punch packets from the first attempt can
|
||||||
// traverse before we re-dial onto the warmed path.
|
// traverse before we re-dial onto the warmed path.
|
||||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||||
@@ -350,6 +391,9 @@ pub struct PeerRequest<'a> {
|
|||||||
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
|
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
|
||||||
/// every caller has a data dir in scope.
|
/// every caller has a data dir in scope.
|
||||||
pub record_data_dir: Option<std::path::PathBuf>,
|
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> {
|
impl<'a> PeerRequest<'a> {
|
||||||
@@ -363,9 +407,25 @@ impl<'a> PeerRequest<'a> {
|
|||||||
fips_timeout: None,
|
fips_timeout: None,
|
||||||
service: None,
|
service: None,
|
||||||
record_data_dir: 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
|
/// Record the transport that serves this request into federation storage
|
||||||
/// (matched by this request's onion host). Best-effort, off the hot path.
|
/// (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 {
|
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
|
||||||
@@ -442,7 +502,7 @@ impl<'a> PeerRequest<'a> {
|
|||||||
// Use the FIPS reply unless it's one a Tor retry could
|
// Use the FIPS reply unless it's one a Tor retry could
|
||||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||||
// fall back. FIPS-only never falls back.
|
// fall back. FIPS-only never falls back.
|
||||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
|
||||||
telemetry::record_fips_ok();
|
telemetry::record_fips_ok();
|
||||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
self.spawn_record(crate::transport::TransportKind::Fips);
|
||||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||||
@@ -481,7 +541,7 @@ impl<'a> PeerRequest<'a> {
|
|||||||
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
||||||
match self.try_fips_get().await? {
|
match self.try_fips_get().await? {
|
||||||
Some(resp) => {
|
Some(resp) => {
|
||||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
|
||||||
telemetry::record_fips_ok();
|
telemetry::record_fips_ok();
|
||||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
self.spawn_record(crate::transport::TransportKind::Fips);
|
||||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||||
@@ -551,13 +611,21 @@ impl<'a> PeerRequest<'a> {
|
|||||||
} else {
|
} else {
|
||||||
budget
|
budget
|
||||||
};
|
};
|
||||||
let c = client_with_timeout(per_attempt);
|
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
|
||||||
let mut rb = c.post(&url).json(body);
|
let mut rb = c.post(&url).json(body);
|
||||||
for (k, v) in &self.headers {
|
for (k, v) in &self.headers {
|
||||||
rb = rb.header(*k, v);
|
rb = rb.header(*k, v);
|
||||||
}
|
}
|
||||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
let single = self.single_delivery;
|
||||||
|
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
|
||||||
|
match tokio::time::timeout(budget, attempt).await {
|
||||||
Ok(Ok(r)) => Ok(Some(r)),
|
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)) => {
|
Ok(Err(e)) => {
|
||||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -612,13 +680,28 @@ impl<'a> PeerRequest<'a> {
|
|||||||
} else {
|
} else {
|
||||||
budget
|
budget
|
||||||
};
|
};
|
||||||
let c = client_with_timeout(per_attempt);
|
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
|
||||||
let mut rb = c.get(&url);
|
let mut rb = c.get(&url);
|
||||||
for (k, v) in &self.headers {
|
for (k, v) in &self.headers {
|
||||||
rb = rb.header(*k, v);
|
rb = rb.header(*k, v);
|
||||||
}
|
}
|
||||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
let single = self.single_delivery;
|
||||||
|
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
|
||||||
|
match tokio::time::timeout(budget, attempt).await {
|
||||||
Ok(Ok(r)) => Ok(Some(r)),
|
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)) => {
|
Ok(Err(e)) => {
|
||||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -676,6 +759,7 @@ impl<'a> PeerRequest<'a> {
|
|||||||
.context("Invalid Tor SOCKS proxy URL")?;
|
.context("Invalid Tor SOCKS proxy URL")?;
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.proxy(proxy)
|
.proxy(proxy)
|
||||||
|
.redirect(delivery_redirect_policy(self.single_delivery))
|
||||||
.timeout(self.timeout)
|
.timeout(self.timeout)
|
||||||
.build()
|
.build()
|
||||||
.context("Build Tor HTTP client")
|
.context("Build Tor HTTP client")
|
||||||
@@ -759,4 +843,181 @@ mod tests {
|
|||||||
let err = decode_response(0xAABB, &r, "x").unwrap_err();
|
let err = decode_response(0xAABB, &r, "x").unwrap_err();
|
||||||
assert!(err.to_string().contains("no AAAA"));
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-5
@@ -3,15 +3,31 @@
|
|||||||
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
||||||
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
||||||
|
|
||||||
## Blocking incident — before unrelated work
|
## Framework incident — closed with operator acceptance
|
||||||
|
|
||||||
- **OPEN: Framework LND startup / missing Receive address / false zero balance.**
|
- **CLOSED WITH OPERATOR ACCEPTANCE (2026-09-30): Framework LND startup /
|
||||||
User requires investigation and a verified fix on the actual node before later
|
missing Receive address / false zero balance.** Startup, native balances,
|
||||||
unrelated work. Startup and native balances were verified on the actual node;
|
Cashu address and source integration were verified; the operator accepted the
|
||||||
final display confirmation is pending. See the incident record for evidence.
|
remaining display check and authorized release. See the incident record for evidence.
|
||||||
See [incident evidence and closure criteria](incident-framework-lnd-startup.md)
|
See [incident evidence and closure criteria](incident-framework-lnd-startup.md)
|
||||||
and the repository `AGENTS.md` session-start instructions.
|
and the repository `AGENTS.md` session-start instructions.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
## Current repair and release tasks — 2026-09-29
|
## Current repair and release tasks — 2026-09-29
|
||||||
|
|
||||||
Release is blocked until these pass; see [execution record](repair-release-20260929.md).
|
Release is blocked until these pass; see [execution record](repair-release-20260929.md).
|
||||||
|
|||||||
@@ -257,3 +257,102 @@ Both catalog and OTA signatures verify against the pinned release root. Staged
|
|||||||
artifact hash/size checks and catalog drift/trust checks pass. User accepted the
|
artifact hash/size checks and catalog drift/trust checks pass. User accepted the
|
||||||
remaining Framework display check and explicitly authorized release. Publication
|
remaining Framework display check and explicitly authorized release. Publication
|
||||||
and ISO build may proceed; do not regenerate the signed manifest or artifacts.
|
and ISO build may proceed; do not regenerate the signed manifest or artifacts.
|
||||||
|
|
||||||
|
### Published OTA; ISO withheld after live shutdown defect — 2026-09-30
|
||||||
|
|
||||||
|
Signed 1.8.20 OTA/catalog published to git and ngit, with public asset hashes
|
||||||
|
verified. Catalog rollout triggered a Bitcoin command update at 08:34 UTC.
|
||||||
|
Although the orchestrator allowed a long stop, Quadlet's generated Podman removal
|
||||||
|
still used its ten-second default and killed Bitcoin. Core replayed its block
|
||||||
|
index; LND later lost its connection to the previous Bitcoin container IP.
|
||||||
|
|
||||||
|
Stopped the ISO build and queued boot check; any partial 1.8.20 ISO is invalid
|
||||||
|
and must not be published. Preparing 1.8.21 to supersede the immutable signed OTA.
|
||||||
|
Installed explicit graceful-stop systemd overrides on dev and Shorty without
|
||||||
|
restarting native services. Candidate Quadlet fix adds per-app container, systemd,
|
||||||
|
and command-wait budgets, including existing containers and uninstall fallback.
|
||||||
|
Focused 43 tests pass, including actual Quadlet generator stop-before-remove order.
|
||||||
|
Full tests, disposable slow-stop verification, build and deployment remain pending.
|
||||||
|
|
||||||
|
Disposable live regression passed: started an Alpine container with its legacy
|
||||||
|
ten-second stop setting, rewrote and reloaded its Quadlet with explicit twenty-
|
||||||
|
second graceful stop, verified the same container ID and old internal timeout
|
||||||
|
remained running, then stopped it. Its twelve-second shutdown handler completed
|
||||||
|
in 12.6 seconds, emitted the completion marker, and exited without SIGKILL/137.
|
||||||
|
Fixture had no network or wallet mounts and was removed afterward.
|
||||||
|
|
||||||
|
Core finished index loading and resumed unpruned initial sync. LND automatically
|
||||||
|
unlocked at 08:47 UTC. The existing backend-address cascade then performed a
|
||||||
|
graceful LND restart at 08:57 UTC after Bitcoin reconciliation completed; LND
|
||||||
|
automatically unlocked again and reached chain-sync waiting. No manual wallet
|
||||||
|
unlock or restart was used for this recovery.
|
||||||
|
|
||||||
|
### False dependency restart exposed during monitoring — 09:08 UTC
|
||||||
|
|
||||||
|
The initial 1.8.21 candidate passed all 1,557 isolated backend tests and 1,120
|
||||||
|
frontend tests. Monitoring nevertheless found another managed LND restart at
|
||||||
|
09:08:32 while Bitcoin's container/start timestamp remained unchanged. Management
|
||||||
|
logs explicitly attribute it to the backend-address cascade. This also makes
|
||||||
|
the earlier 08:57 cascade suspect; it must not be described as a proven necessary
|
||||||
|
restart. These service restarts preceded the isolated test executable, whose
|
||||||
|
namespace boundaries remain intact.
|
||||||
|
|
||||||
|
The cascade trusted Started/Installed action reports. A failed runtime inspection
|
||||||
|
followed by successful systemctl start of an already active unit can produce
|
||||||
|
Started without changing Bitcoin. Dependency restarts now require observed
|
||||||
|
container-ID, running-state, or start-time changes. Failed observations remain
|
||||||
|
unknown, not absence; a known absent backend becoming running still qualifies.
|
||||||
|
Actual exec-drift restarts are recognized even when their outer report is NoOp.
|
||||||
|
Stopped/lifecycle-in-flight dependents remain excluded, and user stop markers
|
||||||
|
are re-read after the potentially slow pass. Added runtime-observation and
|
||||||
|
false-action/real-exec-drift regression cases; full isolated rerun pending.
|
||||||
|
Stopped the first optimized build and preparing new artifacts from this correction.
|
||||||
|
|
||||||
|
### Final 1.8.21 artifacts and live verification — 2026-09-30
|
||||||
|
|
||||||
|
Source and frontend/AIUI attribution: c993d9dd. Full isolated backend suite:
|
||||||
|
1,559 passed, zero failed, four existing hardware/live tests ignored. Frontend
|
||||||
|
suite: 1,120 passed; final production type-check/build passed after the last
|
||||||
|
release-note-only edit. Optimized backend built in 13m22s.
|
||||||
|
|
||||||
|
Staged unsigned 1.8.21 OTA manifest and artifacts:
|
||||||
|
- Backend: 64,748,176 bytes; SHA256
|
||||||
|
ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb.
|
||||||
|
- Frontend archive: 97,152,546 bytes; SHA256
|
||||||
|
6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620.
|
||||||
|
|
||||||
|
Artifact validator passed. Actual archive has flat paths, readable root index,
|
||||||
|
and exact fresh AIUI, doctor and LND UI payload bytes. Exact files deployed to
|
||||||
|
dev at 09:32 UTC and Shorty at 09:35 UTC; rollback binaries and dashboards under
|
||||||
|
root-only /var/lib/archipelago/support/release-1821 on each node. Only management
|
||||||
|
services restarted. Existing Bitcoin/Core-or-Knots and native LND container IDs
|
||||||
|
and start times were preserved. Correct generated graceful-stop commands are
|
||||||
|
present before forced removal on both nodes; temporary grace overrides removed.
|
||||||
|
Dev systemd deadlines are 615 seconds for Bitcoin and 345 seconds for LND.
|
||||||
|
|
||||||
|
Desktop/mobile Lightning UI checks passed again: waiting for Bitcoin sync,
|
||||||
|
unknown balance, no unavailable native RPC requests. Served dashboard and AIUI
|
||||||
|
attribution bytes match the release. Native LND states: dev RPC_ACTIVE while
|
||||||
|
Bitcoin syncs; Shorty SERVER_ACTIVE. Dev completed full reconciliation passes
|
||||||
|
at 09:34:21 and 09:36:40 with Bitcoin/LND NoOp, and no dependency restart.
|
||||||
|
Shorty's first full pass completed 09:36:55 with Knots/LND NoOp.
|
||||||
|
|
||||||
|
Final paid-file check on these exact binaries passed: fresh one-sat dev-to-Shorty
|
||||||
|
purchase, exact one-sat refund on underpayment, identical response aliases,
|
||||||
|
correct Files copy, and zero-charge cached repeat. Removed temporary seller
|
||||||
|
entries/files and Files copy; retained purchase audit and owned cache. Total net
|
||||||
|
transfer across all three live payment rounds in this repair session: three sats.
|
||||||
|
|
||||||
|
Remaining: finish Shorty observation and remove temporary diagnostic logging;
|
||||||
|
user-local 1.8.21 OTA signature (existing catalog signature remains valid),
|
||||||
|
publish git/ngit, build/boot-test/sign and publish the raw 1.8.21 ISO.
|
||||||
|
No 1.8.21 release tag or public OTA yet. Do not publish the quarantined partial
|
||||||
|
1.8.20 ISO. The existing 1.8.20 git/ngit release notes now explain the withheld ISO
|
||||||
|
and pending hotfix; signed 1.8.20 assets remain immutable.
|
||||||
|
|
||||||
|
Shorty's second clean full pass completed at 09:38:06 UTC. Removed temporary
|
||||||
|
diagnostic logging on both nodes and restarted only management again; native
|
||||||
|
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.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.8.19-alpha",
|
"version": "1.8.21-alpha",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.8.19-alpha",
|
"version": "1.8.21-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scure/bip39": "^2.2.0",
|
"@scure/bip39": "^2.2.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.20-alpha",
|
"version": "1.8.21-alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "./start-dev.sh",
|
"start": "./start-dev.sh",
|
||||||
|
|||||||
@@ -362,6 +362,19 @@ init()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||||
|
<!-- v1.8.21-alpha -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.21-alpha</span>
|
||||||
|
<span class="text-xs text-white/40">September 30, 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
|
<p>Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.</p>
|
||||||
|
<p>Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.</p>
|
||||||
|
<p>Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.</p>
|
||||||
|
<p>Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- v1.8.20-alpha -->
|
<!-- v1.8.20-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
+18
-17
@@ -1,29 +1,30 @@
|
|||||||
{
|
{
|
||||||
"changelog": [
|
"changelog": [
|
||||||
"Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.",
|
"Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.",
|
||||||
"Embedded AIUI now stays transparent so the dashboard background appears once.",
|
"Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.",
|
||||||
"AIUI background fixes are now included reliably in OTA updates and fresh installations."
|
"Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.",
|
||||||
|
"Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20."
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"current_version": "1.8.19-alpha",
|
"current_version": "1.8.21-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.19-alpha/archipelago",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"new_version": "1.8.19-alpha",
|
"new_version": "1.8.21-alpha",
|
||||||
"sha256": "bb500d02567ad16179179d43138cc6fdafee680835262026eeaa7d1bc8cdd307",
|
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
|
||||||
"size_bytes": 64495784
|
"size_bytes": 64748176
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"current_version": "1.8.19-alpha",
|
"current_version": "1.8.21-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.19-alpha/archipelago-frontend-1.8.19-alpha.tar.gz",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||||
"name": "archipelago-frontend-1.8.19-alpha.tar.gz",
|
"name": "archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||||
"new_version": "1.8.19-alpha",
|
"new_version": "1.8.21-alpha",
|
||||||
"sha256": "fbbaa237e2ea4e9e576dda9b15fdcf3c59c40bc55bfee4ebbea303b0172fc49b",
|
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
|
||||||
"size_bytes": 97142031
|
"size_bytes": 97152546
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"release_date": "2026-09-28",
|
"release_date": "2026-09-30",
|
||||||
"signature": "4da9bf766d94c2de6641619a36663106791c7a1d342b729c666662cdde1feabdf11c51994c1cbbe2a0e2b270c316d77763435e976ce22730855b3822bc9d390a",
|
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||||
"version": "1.8.19-alpha"
|
"version": "1.8.21-alpha"
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-17
@@ -1,29 +1,30 @@
|
|||||||
{
|
{
|
||||||
"changelog": [
|
"changelog": [
|
||||||
"Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.",
|
"Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.",
|
||||||
"Embedded AIUI now stays transparent so the dashboard background appears once.",
|
"Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.",
|
||||||
"AIUI background fixes are now included reliably in OTA updates and fresh installations."
|
"Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.",
|
||||||
|
"Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20."
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"current_version": "1.8.19-alpha",
|
"current_version": "1.8.21-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.19-alpha/archipelago",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"new_version": "1.8.19-alpha",
|
"new_version": "1.8.21-alpha",
|
||||||
"sha256": "bb500d02567ad16179179d43138cc6fdafee680835262026eeaa7d1bc8cdd307",
|
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
|
||||||
"size_bytes": 64495784
|
"size_bytes": 64748176
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"current_version": "1.8.19-alpha",
|
"current_version": "1.8.21-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.19-alpha/archipelago-frontend-1.8.19-alpha.tar.gz",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||||
"name": "archipelago-frontend-1.8.19-alpha.tar.gz",
|
"name": "archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||||
"new_version": "1.8.19-alpha",
|
"new_version": "1.8.21-alpha",
|
||||||
"sha256": "fbbaa237e2ea4e9e576dda9b15fdcf3c59c40bc55bfee4ebbea303b0172fc49b",
|
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
|
||||||
"size_bytes": 97142031
|
"size_bytes": 97152546
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"release_date": "2026-09-28",
|
"release_date": "2026-09-30",
|
||||||
"signature": "4da9bf766d94c2de6641619a36663106791c7a1d342b729c666662cdde1feabdf11c51994c1cbbe2a0e2b270c316d77763435e976ce22730855b3822bc9d390a",
|
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||||
"version": "1.8.19-alpha"
|
"version": "1.8.21-alpha"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"changelog": [
|
|
||||||
"Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.",
|
|
||||||
"Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging.",
|
|
||||||
"Improved saving paid files into Files and reopening purchases without paying again.",
|
|
||||||
"Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning.",
|
|
||||||
"Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts.",
|
|
||||||
"LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure.",
|
|
||||||
"Bitcoin startup messages explain block-index loading without exposing raw RPC errors, and Lightning keeps known balances clearly marked during outages.",
|
|
||||||
"Changed the public transaction-explorer default to mempool.space while preserving local explorers and custom choices."
|
|
||||||
],
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"current_version": "1.8.20-alpha",
|
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.20-alpha/archipelago",
|
|
||||||
"name": "archipelago",
|
|
||||||
"new_version": "1.8.20-alpha",
|
|
||||||
"sha256": "16a173129672cbb40c250446ec52ba4a9bd1974cbb3a4988f90c6f3187b7a1f7",
|
|
||||||
"size_bytes": 64716656
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"current_version": "1.8.20-alpha",
|
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.20-alpha/archipelago-frontend-1.8.20-alpha.tar.gz",
|
|
||||||
"name": "archipelago-frontend-1.8.20-alpha.tar.gz",
|
|
||||||
"new_version": "1.8.20-alpha",
|
|
||||||
"sha256": "658b78fce0dfa20a627c987dd153b24cbac15adbde905cc6518744c637e12802",
|
|
||||||
"size_bytes": 97152297
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"release_date": "2026-09-29",
|
|
||||||
"signature": "326cab454902abcb4f8037751af67aaae2860ecf00300177ec377a1f223a6a85b6e92d49297e7bc29a73220d501e6f4c83ee23477e2b688e33e19d093c6d210b",
|
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
|
||||||
"version": "1.8.20-alpha"
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user