Compare commits

..
Author SHA1 Message Date
ssmithxandClaude Sonnet 5 edcce5a308 feat(nostr-vpn): package paid-exit seller + web control panel as manifest apps
Phase 1 of docs/nostr-vpn-integration-plan.md's Phase 0->4 plan (seller-side
rootless feasibility already confirmed there). Two apps, one image:

- apps/nostr-vpn: the daemon. Own network namespace (container.network:
  pasta), NET_ADMIN+NET_RAW scoped to that netns, /dev/net/tun, and the
  net.ipv4.ip_forward sysctl via the primitive added in e42bd26. UDP 51822
  (not upstream's default 51820, which collides with archipelago-wg on
  fleet nodes per the Phase 0 log). Seller mode stays off until an operator
  explicitly enables it (paid_exit.enabled defaults to false upstream).
- apps/nostr-vpn-web: the control panel, gated behind 127.0.0.1:38080,
  talking to the daemon only through the shared /data volume (state-file
  status + shelling out to the nvpn CLI) -- no network link between the
  two containers, matching upstream's own umbrel/docker-compose.yml.
- docker/nostr-vpn: upstream's umbrel/Dockerfile, unchanged except for how
  the pinned commit arrives (shallow git fetch of a verified SHA, since
  codeload.github.com archive tarballs 404 from this environment and
  GitHub won't fetch an arbitrary SHA directly). Entrypoint seeds a minimal
  config.toml with the chosen listen_port on first boot only -- every
  AppConfig field is `serde(default = ...)`, confirmed by reading
  nostr-vpn-core directly, so this merges with nvpn's own identity/wallet
  bootstrap instead of needing a generated_secrets entry or full config
  template, and never touches a config that already exists.

Both volumes point at /var/lib/archipelago/nostr-vpn, adopting state from
the old root-mode install. Build and the seed-config path were verified
against the real `nvpn daemon` binary, not just read -- see the plan doc's
Phase 1 log for what that caught (a fabricated commit SHA, the codeload
404, wrong default branch name, and confirming identity/wallet persistence
actually survives container recreation).

Not done here, flagged in the plan doc instead: removing the old root-mode
path (rpc/vpn.rs, rpc/auth.rs's auto-enable-on-login) touches live
onboarding on every node, not just this app -- needs explicit sign-off.
Also missing: a stop-hook/uninstall-guard manifest primitive (doesn't
exist yet -- LifecycleHooks only has post_install/pre_start) for the
collect-due-on-stop and non-zero-wallet uninstall guard, and registry
mirroring + catalog signing (need credentials this pass doesn't have).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 00:29:51 +00:00
ssmithxandClaude Opus 5.5 e42bd26ec7 feat(manifest): add allow-listed per-netns sysctls primitive
Routing apps (a rootless VPN exit) need packet forwarding in their own
network namespace, but /proc/sys is read-only inside a rootless
container, so it can only be set at create time. Add `app.sysctls`,
allow-listed to net.ipv4.ip_forward / net.ipv6.conf.all.forwarding with
values "0"/"1", and rejected under host networking where it would change
the host. Rendered on all three create paths: podman CLI --sysctl, the
libpod spec `sysctl` map, and Quadlet `Sysctl=`. Absent by default and
not serialized when empty, so existing manifests and units are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:16:53 +00:00
64 changed files with 943 additions and 2801 deletions
-11
View File
@@ -29,14 +29,3 @@ unrestricted `cargo test` on a node with installed apps: older mocked-runtime
tests still reached real service commands. The runner isolates wallet data, tests still reached real service commands. The runner isolates wallet data,
service buses, container storage, networking, and process IDs. Compilation with service buses, container storage, networking, and process IDs. Compilation with
`cargo test --no-run` is safe. Keep separately authorized live checks explicit. `cargo test --no-run` is safe. Keep separately authorized live checks explicit.
## Active release regression checklist
Before resuming release work, read
`docs/post-1.8.22-regressions-20261001.md` and retain its unfinished tasks.
The operator requested that every reported issue be tracked, fixed and tested
before another OTA/ISO. Keep source/unit-test results separate from actual-node
acceptance. In particular, paid-file recovery must not send another payment,
and app cleanup must preserve wallets, persistent data and uninstall decisions.
Do not mark the new paid-file incident resolved merely because the earlier
Framework LND startup incident was closed.
-6
View File
@@ -2,12 +2,6 @@
## v1.8.22-alpha (2026-09-30) ## v1.8.22-alpha (2026-09-30)
- Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.
- Network diagnostic failures no longer stop all apps or rebuild shared container networking.
- Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.
- Fixed companion dashboard builds still referencing a retired image registry.
- Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service. - Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.
- Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API. - Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.
+89
View File
@@ -0,0 +1,89 @@
app:
id: nostr-vpn-web
name: Nostr VPN Control Panel
version: 1.0.0
upstream:
kind: github
repo: mmalmi/nostr-vpn
description: |
Web control panel for the nostr-vpn paid-exit seller (apps/nostr-vpn).
Talks to the daemon only through the shared /data volume (state-file
status + shelling out to the nvpn CLI) -- no network link between the
two containers, mirroring upstream's own umbrel/docker-compose.yml
exactly (read directly, not assumed). Same image as apps/nostr-vpn,
different entrypoint args.
category: money
container:
build:
context: /opt/archipelago/docker/nostr-vpn
dockerfile: Dockerfile
tag: localhost/nostr-vpn:local
entrypoint: ["/usr/local/bin/archy-nvpn-entrypoint.sh"]
custom_args:
- /usr/local/bin/nvpn-web
- --listen
- 0.0.0.0:38080
- --behind-trusted-proxy
- --config
- /data/config/nvpn/config.toml
dependencies:
- app_id: nostr-vpn
resources:
memory_limit: 128Mi
security:
capabilities: []
readonly_root: false
no_new_privileges: true
network_policy: bridge
ports:
- host: 38080
container: 38080
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
# Same volume as apps/nostr-vpn, read-write: the panel's wallet/seller
# actions (wallet send, paid-exit run) shell out to the nvpn CLI
# against this same config.toml and data dir, per
# NVPN_EXTERNAL_DAEMON/NVPN_DAEMON_STATUS_MODE below.
- type: bind
source: /var/lib/archipelago/nostr-vpn
target: /data
options: [rw]
environment:
- NVPN_CLI_PATH=/usr/local/bin/nvpn
- NVPN_DAEMON_STATUS_MODE=state-file
- NVPN_EXTERNAL_DAEMON=true
health_check:
type: http
endpoint: http://127.0.0.1:38080
path: /
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Control Panel
description: nostr-vpn paid-exit status, wallet, and seller settings
type: ui
port: 38080
protocol: http
path: /
metadata:
category: money
tier: optional
author: mmalmi
repo: https://github.com/mmalmi/nostr-vpn
features:
- Paid-exit seller status, wallet, and offer controls
- Shares state with apps/nostr-vpn via one data volume, no RPC link
+119
View File
@@ -0,0 +1,119 @@
app:
id: nostr-vpn
name: Nostr VPN (paid exit)
version: 1.0.0
# Pinned commit, not a tag -- upstream has no release tags yet. Re-pin
# deliberately in docker/nostr-vpn/Dockerfile's NVPN_COMMIT build arg; see
# docs/nostr-vpn-integration-plan.md for the Phase 0 feasibility log this
# pin was verified against.
upstream:
kind: github
repo: mmalmi/nostr-vpn
description: |
Sells spare bandwidth as a Nostr-discovered, Cashu-metered paid exit
(github.com/mmalmi/nostr-vpn). Runs rootless in its own network
namespace (pasta) -- NET_ADMIN/NET_RAW are scoped to that netns, never
the host. Seller mode defaults OFF (upstream's own `paid_exit.enabled`
default); turning it on is a separate step (Phase 3 UI, not yet built).
This replaces the old root-mode integration (image-recipe's
nostr-vpn.service running `nvpn daemon` as root, auto-enabled on first
login via rpc/auth.rs) that broke the rootless/no-OS-reliance
invariant. That old path and its RPC TOML-rewriting code
(rpc/vpn.rs::handle_vpn_add_participant) are a separate, higher-risk
removal -- not done here, since it's wired into every node's login
flow today, not just this app.
category: money
container:
build:
context: /opt/archipelago/docker/nostr-vpn
dockerfile: Dockerfile
tag: localhost/nostr-vpn:local
network: pasta
# Image has no image-level ENTRYPOINT/CMD (see Dockerfile) -- both this
# app and nostr-vpn-web point the shared seed-config entrypoint at
# different binaries/args.
entrypoint: ["/usr/local/bin/archy-nvpn-entrypoint.sh"]
custom_args:
- /usr/local/bin/nvpn
- daemon
- --config
- /data/config/nvpn/config.toml
dependencies:
- storage: 1Gi
resources:
memory_limit: 256Mi
security:
# NET_ADMIN/NET_RAW: TUN device + the exit forwarding/NAT nvpn installs
# itself inside its own netns (nvpn-exit-forward-in/out, nvpn-exit-masq,
# the MSS clamp) -- confirmed working rootless in Phase 0 testing, with
# no capabilities beyond these two plus the sysctl below. Host iptables
# and routes were confirmed untouched.
capabilities: [NET_ADMIN, NET_RAW]
# false: not verified read-only-root-compatible in Phase 0 testing (the
# working run flags there didn't include --read-only). nvpn's own state
# (config/identity/wallet) lives on the /data volume either way.
readonly_root: false
no_new_privileges: true
network_policy: isolated
# Rootless /proc/sys is read-only, so forwarding can only be set at
# container-create time via this primitive (added for exactly this app --
# see commit e42bd26). nvpn only *reads* ip_forward and writes it when 0,
# so setting it here once at create is enough; nvpn's own cleanup path
# leaves it alone.
sysctls:
net.ipv4.ip_forward: "1"
devices:
- /dev/net/tun
ports:
# Paid-exit buyers dial this directly from the open internet to pay for
# bandwidth -- it's the whole point of the app, not an admin surface,
# and it speaks nvpn's own FIPS UDP wire protocol, not HTTP, so the app
# gate cannot front it. 51822, not upstream's default 51820: that
# collides with archipelago-wg (kernel WireGuard) on fleet nodes --
# found running both side by side in Phase 0 testing.
- host: 51822
container: 51822
protocol: udp
auth: none
auth_rationale: >-
FIPS UDP transport for paid-exit buyers. Anonymous by design (not
HTTP), and the seller is off by default (paid_exit.enabled=false)
until an operator explicitly turns on selling, so exposure here
alone grants no access to anything.
volumes:
# Adopts whatever a node already has under the old root-mode path
# (nostr-vpn.service wrote here too) -- an identity, wallet balance, or
# pending Cashu credit must survive this migration, not reset.
- type: bind
source: /var/lib/archipelago/nostr-vpn
target: /data
options: [rw]
environment:
- NVPN_LISTEN_PORT=51822
health_check:
type: exec
endpoint: nvpn status
interval: 30s
timeout: 10s
retries: 3
metadata:
category: money
tier: optional
author: mmalmi
repo: https://github.com/mmalmi/nostr-vpn
features:
- Sell spare bandwidth as a Cashu-metered Nostr paid exit
- Rootless: own network namespace, no host network access
- Seller mode off by default
+26 -63
View File
@@ -74,7 +74,7 @@ impl ApiHandler {
let invoice_hash = headers let invoice_hash = headers
.get("x-invoice-hash") .get("x-invoice-hash")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.map(|s| s.to_ascii_lowercase()) .map(|s| s.to_string())
.or_else(|| { .or_else(|| {
headers headers
.get("x-onchain-address") .get("x-onchain-address")
@@ -98,46 +98,6 @@ impl ApiHandler {
None => false, None => false,
}; };
// Payment settlement is verified on the seller even when no status
// poll preceded this download (e.g. direct payment from another node).
let requires_payment = if !owner_session && headers.contains_key("x-invoice-hash") {
content_server::load_catalog(&config.data_dir)
.await?
.items
.iter()
.any(|item| {
item.id == content_id
&& matches!(item.access, content_server::AccessControl::Paid { .. })
})
} else {
false
};
if requires_payment {
if let Some(hash) = headers.get("x-invoice-hash").and_then(|v| v.to_str().ok()) {
if hash.len() != 64 || !hash.bytes().all(|c| c.is_ascii_hexdigit()) {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid payment hash"),
));
}
if let Err(error) = self
.rpc_handler
.settle_content_invoice(hash, content_id)
.await
{
tracing::warn!("Cannot verify peer-file invoice settlement: {error:#}");
return Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(
r#"{"error":"Payment verification is temporarily unavailable. Retry the download without paying again."}"#,
),
));
}
}
}
// Parse Range header for streaming support // Parse Range header for streaming support
let range = headers let range = headers
.get("range") .get("range")
@@ -289,13 +249,7 @@ impl ApiHandler {
.await .await
{ {
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => { Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
crate::content_invoice::record_pending( crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await;
&self.config.data_dir,
&payment_hash,
content_id,
price_sats,
)
.await?;
let body = serde_json::json!({ let body = serde_json::json!({
"bolt11": bolt11, "bolt11": bolt11,
"payment_hash": payment_hash, "payment_hash": payment_hash,
@@ -355,10 +309,26 @@ impl ApiHandler {
)); ));
} }
let paid = self // The hash must be one we issued for exactly this content item.
.rpc_handler match crate::content_invoice::lookup(payment_hash).await {
.settle_content_invoice(payment_hash, content_id) Some((cid, _)) if cid == content_id => {}
.await?; _ => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"application/json",
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
))
}
}
// Already paid? Otherwise ask our LND and persist the result.
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
if !paid {
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
crate::content_invoice::mark_paid(payment_hash).await;
paid = true;
}
}
let body = serde_json::json!({ "paid": paid }); let body = serde_json::json!({ "paid": paid });
Ok(build_response( Ok(build_response(
@@ -419,13 +389,7 @@ impl ApiHandler {
match self.rpc_handler.new_onchain_address().await { match self.rpc_handler.new_onchain_address().await {
Ok(address) if !address.is_empty() => { Ok(address) if !address.is_empty() => {
crate::content_invoice::record_pending( crate::content_invoice::record_pending(&address, content_id, price_sats).await;
&self.config.data_dir,
&address,
content_id,
price_sats,
)
.await?;
let body = serde_json::json!({ let body = serde_json::json!({
"address": address, "address": address,
"amount_sats": price_sats, "amount_sats": price_sats,
@@ -475,7 +439,7 @@ impl ApiHandler {
)); ));
} }
// The address must be one we issued for exactly this content item. // The address must be one we issued for exactly this content item.
let price = match crate::content_invoice::lookup(&self.config.data_dir, address).await? { let price = match crate::content_invoice::lookup(address).await {
Some((cid, price)) if cid == content_id => price, Some((cid, price)) if cid == content_id => price,
_ => { _ => {
return Ok(build_response( return Ok(build_response(
@@ -486,11 +450,10 @@ impl ApiHandler {
} }
}; };
let mut paid = let mut paid = crate::content_invoice::is_paid_for(address, content_id).await;
crate::content_invoice::is_paid_for(&self.config.data_dir, address, content_id).await;
if !paid { if !paid {
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await { if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
crate::content_invoice::mark_paid(&self.config.data_dir, address).await?; crate::content_invoice::mark_paid(address).await;
paid = true; paid = true;
} }
} }
+9 -84
View File
@@ -73,17 +73,6 @@ fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json
}) })
} }
// Updated clients open the persisted file through the Range-capable HTTP
// endpoint. Avoid putting two base64 copies of a large video in a JSON reply.
// Keep older clients compatible until both sides have upgraded.
fn invoice_download_response(bytes: &[u8], mime: &str, cache_only: bool) -> serde_json::Value {
if cache_only {
serde_json::json!({ "owned": true, "mime_type": mime, "size_bytes": bytes.len() })
} else {
paid_content_response(bytes, mime, 0)
}
}
/// File purchases through an atomic no-clobber write in Files' own namespace. /// File purchases through an atomic no-clobber write in Files' own namespace.
async fn file_purchase_in_files( async fn file_purchase_in_files(
data_dir: &std::path::Path, data_dir: &std::path::Path,
@@ -881,29 +870,10 @@ impl RpcHandler {
if !is_valid_v3_onion(onion) { if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address")); return Err(anyhow::anyhow!("Invalid v3 onion address"));
} }
if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) { if payment_hash.is_empty() || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("Invalid payment_hash")); return Err(anyhow::anyhow!("Invalid payment_hash"));
} }
let cache_only = params
.get("cache_only")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if let Some((mime, bytes)) =
crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await
{
return Ok(invoice_download_response(&bytes, &mime, cache_only));
}
// Older sellers only mark settlement during status polling. Always
// perform that handshake before requesting bytes; retries never pay.
// The download gate remains authoritative: a file may have become
// free, and newer sellers verify directly if status polling fails.
let _ = self
.handle_content_invoice_status(Some(serde_json::json!({
"onion": onion, "content_id": content_id, "payment_hash": payment_hash,
})))
.await;
let (data, _) = self.state_manager.get_snapshot().await; let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
@@ -942,7 +912,7 @@ impl RpcHandler {
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
return Ok(serde_json::json!({ return Ok(serde_json::json!({
"error": "The seller has not confirmed access yet. Retry the download without paying again." "error": "Seller has not registered this payment yet — wait for settlement and retry."
})); }));
} }
if !response.status().is_success() { if !response.status().is_success() {
@@ -951,45 +921,16 @@ impl RpcHandler {
})); }));
} }
let mime = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.split(';')
.next()
.unwrap_or("application/octet-stream")
.to_string();
let bytes = response let bytes = response
.bytes() .bytes()
.await .await
.context("Paid file transfer interrupted; retry the download without paying again")?; .context("Failed to read response body")?;
let filename = params use base64::Engine;
.get("filename") let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
.and_then(|v| v.as_str()) Ok(serde_json::json!({
.unwrap_or(content_id); "data": encoded,
crate::content_owned::record_purchase( "size": bytes.len(),
&self.config.data_dir, }))
onion,
content_id,
filename,
&mime,
&bytes,
params
.get("price_sats")
.and_then(|v| v.as_u64())
.unwrap_or(0),
"lightning",
&chrono::Utc::now().to_rfc3339(),
)
.await
.context("Paid file could not be saved; retry the download without paying again")?;
if let Err(error) =
file_purchase_in_files(&self.config.data_dir, filename, &mime, &bytes).await
{
tracing::warn!("Lightning purchase cached; optional Files copy failed: {error:#}");
}
Ok(invoice_download_response(&bytes, &mime, cache_only))
} }
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay. /// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
@@ -1464,19 +1405,3 @@ impl RpcHandler {
#[cfg(test)] #[cfg(test)]
#[path = "content_tests.rs"] #[path = "content_tests.rs"]
mod tests; mod tests;
#[cfg(test)]
mod invoice_delivery_response_tests {
use super::*;
#[test]
fn cached_delivery_avoids_base64_but_keeps_old_clients_compatible() {
let cached = invoice_download_response(b"paid bytes", "video/mp4", true);
assert_eq!(cached["owned"], true);
assert_eq!(cached["size_bytes"], 10);
assert!(cached.get("data").is_none());
assert!(cached.get("data_base64").is_none());
let legacy = invoice_download_response(b"paid bytes", "video/mp4", false);
assert_eq!(legacy["data"], "cGFpZCBieXRlcw==");
assert_eq!(legacy["data"], legacy["data_base64"]);
}
}
+4 -101
View File
@@ -473,7 +473,6 @@ impl RpcHandler {
)); ));
} }
let fee_query = close_channel_fee_query(&params)?;
let force = params let force = params
.get("force") .get("force")
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
@@ -499,11 +498,13 @@ impl RpcHandler {
.build() .build()
.context("Failed to create streaming HTTP client")?; .context("Failed to create streaming HTTP client")?;
let url = format!("{LND_REST_BASE_URL}/v1/channels/{}/{}", parts[0], parts[1]); let url = format!(
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
parts[0], parts[1], force
);
let mut resp = client let mut resp = client
.delete(&url) .delete(&url)
.query(&fee_query)
.header("Grpc-Metadata-macaroon", &macaroon_hex) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.send() .send()
.await .await
@@ -571,101 +572,3 @@ impl RpcHandler {
} }
} }
} }
/// LND's CloseChannel REST endpoint takes fee selection as query parameters.
/// With neither parameter LND uses a lax target; keep legacy clients on our
/// explicit Standard target rather than silently accepting that default.
fn close_channel_fee_query(params: &serde_json::Value) -> Result<Vec<(&'static str, String)>> {
let force = match params.get("force") {
None | Some(serde_json::Value::Null) => false,
Some(value) => value
.as_bool()
.ok_or_else(|| anyhow::anyhow!("force must be a boolean"))?,
};
let integer = |key: &str, max: u64| -> Result<Option<u64>> {
match params.get(key) {
None | Some(serde_json::Value::Null) => Ok(None),
Some(value) => {
let n = value
.as_u64()
.ok_or_else(|| anyhow::anyhow!("{key} must be a positive whole number"))?;
anyhow::ensure!((1..=max).contains(&n), "{key} must be between 1 and {max}");
Ok(Some(n))
}
}
};
let target = integer("target_conf", 1008)?;
let rate = integer("sat_per_vbyte", 5000)?;
anyhow::ensure!(
target.is_none() || rate.is_none(),
"Specify either target_conf or sat_per_vbyte, not both"
);
anyhow::ensure!(
!force || (target.is_none() && rate.is_none()),
"Closing fee selection requires a cooperative close"
);
let mut query = vec![("force", force.to_string())];
if !force {
if let Some(rate) = rate {
query.push(("sat_per_vbyte", rate.to_string()));
} else {
query.push(("target_conf", target.unwrap_or(6).to_string()));
}
}
Ok(query)
}
#[cfg(test)]
mod close_fee_tests {
use super::*;
#[test]
fn close_fee_query_forwards_presets_custom_and_legacy_default() {
for target in [1, 3, 6, 1008] {
assert_eq!(
close_channel_fee_query(&serde_json::json!({"target_conf":target})).unwrap(),
vec![
("force", "false".into()),
("target_conf", target.to_string())
]
);
}
for rate in [1, 25, 5000] {
let query =
close_channel_fee_query(&serde_json::json!({"sat_per_vbyte":rate})).unwrap();
let request = reqwest::Client::new()
.delete("http://localhost/v1/channels/test/0")
.query(&query)
.build()
.unwrap();
assert_eq!(request.method(), reqwest::Method::DELETE);
assert_eq!(
request.url().query(),
Some(format!("force=false&sat_per_vbyte={rate}").as_str())
);
}
assert_eq!(
close_channel_fee_query(&serde_json::json!({})).unwrap(),
vec![("force", "false".into()), ("target_conf", "6".into())]
);
assert_eq!(
close_channel_fee_query(&serde_json::json!({"force":true})).unwrap(),
vec![("force", "true".into())]
);
}
#[test]
fn malformed_or_conflicting_close_fees_fail_before_wallet_access() {
for params in [
serde_json::json!({"target_conf":1,"sat_per_vbyte":2}),
serde_json::json!({"force":true,"target_conf":1}),
serde_json::json!({"force":"false"}),
serde_json::json!({"target_conf":0}),
serde_json::json!({"target_conf":1009}),
serde_json::json!({"sat_per_vbyte":5001}),
serde_json::json!({"sat_per_vbyte":-1}),
serde_json::json!({"sat_per_vbyte":1.5}),
serde_json::json!({"sat_per_vbyte":"25"}),
] {
assert!(close_channel_fee_query(&params).is_err(), "{params}");
}
}
}
-128
View File
@@ -453,56 +453,6 @@ impl RpcHandler {
Ok(settled) Ok(settled)
} }
/// Verify against LND at download time, rather than relying on a browser
/// having polled first. The memo/amount also recover pre-upgrade in-memory
/// entitlements after restart; unrelated invoices never unlock a file.
pub(crate) async fn settle_content_invoice(
&self,
hash: &str,
content_id: &str,
) -> Result<bool> {
anyhow::ensure!(
hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit()),
"Invalid payment hash"
);
let hash = hash.to_ascii_lowercase();
let existing = crate::content_invoice::lookup(&self.config.data_dir, &hash).await?;
if let Some((id, _)) = &existing {
if id != content_id {
return Ok(false);
}
}
if crate::content_invoice::is_paid_for(&self.config.data_dir, &hash, content_id).await {
return Ok(true);
}
let (client, macaroon_hex) = self.lnd_client().await?;
let response = client
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{hash}"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(false);
}
let body: serde_json::Value = response.error_for_status()?.json().await?;
let Some(price) = content_invoice_amount(&body, content_id) else {
return Ok(false);
};
if existing
.as_ref()
.is_some_and(|(_, expected)| *expected != price)
{
return Ok(false);
}
crate::content_invoice::record_pending(&self.config.data_dir, &hash, content_id, price)
.await?;
let settled = content_invoice_fully_settled(&body, price);
if settled {
crate::content_invoice::mark_paid(&self.config.data_dir, &hash).await?;
}
Ok(settled)
}
/// Generate a fresh on-chain receive address (seller side, #46). /// Generate a fresh on-chain receive address (seller side, #46).
pub(crate) async fn new_onchain_address(&self) -> Result<String> { pub(crate) async fn new_onchain_address(&self) -> Result<String> {
let (client, macaroon_hex) = self.lnd_client().await?; let (client, macaroon_hex) = self.lnd_client().await?;
@@ -1494,81 +1444,3 @@ mod tests {
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}"); assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
} }
} }
// LND REST uses decimal strings for int64 fields. Match the complete seller
// memo, not a substring supplied by a buyer or an arbitrary settled invoice.
fn json_u64(value: &serde_json::Value) -> Option<u64> {
value.as_u64().or_else(|| value.as_str()?.parse().ok())
}
fn content_invoice_fully_settled(body: &serde_json::Value, price: u64) -> bool {
let settled = match body.get("state").and_then(|v| v.as_str()) {
Some(state) => state == "SETTLED",
None => body.get("settled").and_then(|v| v.as_bool()) == Some(true),
};
settled
&& price > 0
&& body
.get("amt_paid_sat")
.and_then(json_u64)
.is_some_and(|paid| paid >= price)
}
fn content_invoice_amount(body: &serde_json::Value, content_id: &str) -> Option<u64> {
if body.get("memo")?.as_str()? != format!("Archipelago peer file {content_id}") {
return None;
}
body.get("value").and_then(json_u64).filter(|v| *v > 0)
}
#[cfg(test)]
mod peer_file_invoice_tests {
use super::*;
#[test]
fn settlement_requires_terminal_state_and_full_amount() {
for state in ["OPEN", "ACCEPTED", "CANCELED", "unknown"] {
assert!(!content_invoice_fully_settled(
&serde_json::json!({"state":state,"settled":true,"amt_paid_sat":"100"}),
7
));
}
for amount in [
serde_json::json!(6),
serde_json::json!("-1"),
serde_json::json!(null),
serde_json::json!("bad"),
] {
assert!(!content_invoice_fully_settled(
&serde_json::json!({"state":"SETTLED","amt_paid_sat":amount}),
7
));
}
for amount in [serde_json::json!(7), serde_json::json!("8")] {
assert!(content_invoice_fully_settled(
&serde_json::json!({"state":"SETTLED","amt_paid_sat":amount}),
7
));
}
assert!(content_invoice_fully_settled(
&serde_json::json!({"settled":true,"amt_paid_sat":"7"}),
7
));
assert!(!content_invoice_fully_settled(
&serde_json::json!({"state":"SETTLED","amt_paid_sat":"7"}),
0
));
}
#[test]
fn legacy_recovery_requires_exact_file_memo_and_positive_amount() {
let invoice = serde_json::json!({"memo":"Archipelago peer file file-1", "value":"7"});
assert_eq!(content_invoice_amount(&invoice, "file-1"), Some(7));
assert_eq!(content_invoice_amount(&invoice, "file-2"), None);
for value in [
serde_json::json!("-1"),
serde_json::json!(0),
serde_json::json!("bad"),
] {
let mut invalid = invoice.clone();
invalid["value"] = value;
assert_eq!(content_invoice_amount(&invalid, "file-1"), None);
}
}
}
@@ -103,15 +103,6 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
} }
} }
/// Missing companion UIs are provisioned here, never by snapshot recovery.
/// A stale running-container snapshot must not resurrect an orphaned UI.
pub fn is_companion_app(app_id: &str) -> bool {
ALL_COMPANIONS
.iter()
.flat_map(|specs| specs.iter())
.any(|spec| spec.image_base == app_id)
}
/// Every companion this build knows how to provision. Kept beside /// Every companion this build knows how to provision. Kept beside
/// `companions_for` — a new companion must be added to both, or the reaper /// `companions_for` — a new companion must be added to both, or the reaper
/// will not recognise it as one of ours and will leave it running forever. /// will not recognise it as one of ours and will leave it running forever.
@@ -20,9 +20,7 @@ use crate::data_model::{
/// stopped-app restoration path in agreement with live-container discovery. /// stopped-app restoration path in agreement with live-container discovery.
fn canonical_package_id(name: &str) -> &str { fn canonical_package_id(name: &str) -> &str {
match name.strip_prefix("archy-").unwrap_or(name) { match name.strip_prefix("archy-").unwrap_or(name) {
"immich_server" | "immich-server" => "immich", "immich_server" => "immich",
"immich-postgres" => "immich_postgres",
"immich-redis" => "immich_redis",
"mempool-web" | "mempool-frontend" => "mempool", "mempool-web" | "mempool-frontend" => "mempool",
name => name, name => name,
} }
@@ -445,19 +443,6 @@ mod lifecycle_regression_tests {
use super::*; use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test]
fn immich_dependency_aliases_share_the_hidden_component_ids() {
for id in [
"immich-postgres",
"immich_postgres",
"archy-immich-postgres",
] {
assert_eq!(canonical_package_id(id), "immich_postgres");
}
assert_eq!(canonical_package_id("immich-redis"), "immich_redis");
assert_eq!(canonical_package_id("immich-server"), "immich");
}
#[test] #[test]
fn registry_survives_empty_runtime_and_deduplicates_aliases() { fn registry_survives_empty_runtime_and_deduplicates_aliases() {
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"] let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
@@ -993,20 +978,14 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
let mut first_candidate = None; let mut first_candidate = None;
for port_str in ports { for port_str in ports {
// Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp" // Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp"
let Some((public_part, _)) = port_str.split_once("->") else { let Some(public_part) = port_str.split("->").next() else {
continue; continue;
}; };
let Some((_, port_part)) = public_part.rsplit_once(':') else { let Some(port_part) = public_part.split(':').nth(1) else {
continue; continue;
}; };
// Extract just the first port if it's a range (e.g., "18443-18444" -> "18443") // Extract just the first port if it's a range (e.g., "18443-18444" -> "18443")
let host_port = port_part.split('-').next().unwrap_or(port_part); let host_port = port_part.split('-').next().unwrap_or(port_part);
let Ok(host_port) = host_port.parse::<u16>() else {
continue;
};
if host_port == 0 {
continue;
}
let candidate = format!("http://localhost:{}", host_port); let candidate = format!("http://localhost:{}", host_port);
if first_candidate.is_none() { if first_candidate.is_none() {
first_candidate = Some(candidate.clone()); first_candidate = Some(candidate.clone());
@@ -1134,29 +1113,6 @@ fn package_launch_candidate(
if let Some(companion) = companion_lan_address(app_id) { if let Some(companion) = companion_lan_address(app_id) {
return Some(companion); return Some(companion);
} }
if app_id == "nginx-proxy-manager" {
// 80/443 serve users' proxy hosts; only container port 81 serves the
// admin UI. Podman's binding order is unstable across recreation.
// Resolve its actual host allocation rather than guessing the first
// HTTP port or hardcoding the default host port 8081.
let admin_ports: Vec<String> = ports
.iter()
.filter(|port| {
port.split_once("->")
.is_some_and(|(_, target)| target == "81/tcp")
})
.cloned()
.collect();
// With published bindings, a missing admin mapping is not evidence
// that some unrelated service on the default host port is this UI.
return extract_lan_address(&admin_ports).or_else(|| {
if ports.is_empty() {
known
} else {
None
}
});
}
if uses_allocated_launch_port(app_id) { if uses_allocated_launch_port(app_id) {
extract_lan_address(ports).or(known) extract_lan_address(ports).or(known)
} else { } else {
@@ -1285,98 +1241,6 @@ mod extract_lan_address_tests {
); );
} }
#[test]
fn npm_admin_launch_is_independent_of_proxy_binding_order() {
let mappings = [
"10.77.0.2:18081->80/tcp",
"10.77.0.2:18443->443/tcp",
"127.0.0.1:8081->81/tcp",
];
for order in [
[0, 1, 2],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
[2, 1, 0],
] {
let ports: Vec<String> = order.iter().map(|&i| mappings[i].into()).collect();
assert_eq!(
package_launch_candidate(
"nginx-proxy-manager",
&ports,
Some("http://localhost:8081/".into())
)
.as_deref(),
Some("http://localhost:8081")
);
}
}
#[test]
fn npm_admin_launch_respects_host_allocation_and_ipv6_bindings() {
for binding in ["127.0.0.1", "0.0.0.0", "[::1]", "[::]"] {
let ports = vec![
"10.77.0.2:18081->80/tcp".into(),
format!("{binding}:28081->81/tcp"),
];
assert_eq!(
package_launch_candidate(
"nginx-proxy-manager",
&ports,
Some("http://localhost:8081/".into())
)
.as_deref(),
Some("http://localhost:28081")
);
}
let proxies = vec![
"10.77.0.2:18081->80/tcp".into(),
"10.77.0.2:18443->443/tcp".into(),
];
assert_eq!(
package_launch_candidate("nginx-proxy-manager", &proxies, None),
None
);
assert_eq!(
package_launch_candidate(
"nginx-proxy-manager",
&proxies,
Some("http://localhost:8081/".into())
),
None
);
}
#[test]
fn npm_without_port_information_uses_declared_admin_url() {
assert_eq!(
package_launch_candidate(
"nginx-proxy-manager",
&[],
Some("http://localhost:8081/".into())
)
.as_deref(),
Some("http://localhost:8081/")
);
}
#[test]
fn malformed_published_ports_do_not_become_launch_urls() {
for port in [
"81/tcp",
"127.0.0.1:bad->81/tcp",
"[::1]:0->81/tcp",
"[::]:65536->81/tcp",
"127.0.0.1:8081->81/udp",
] {
assert_eq!(
package_launch_candidate("nginx-proxy-manager", &[port.into()], None),
None
);
}
}
#[test] #[test]
fn skips_ssh_port_when_web_port_is_published() { fn skips_ssh_port_when_web_port_is_published() {
// gitea: SSH published before the web port, in podman's list order. // gitea: SSH published before the web port, in podman's list order.
@@ -2071,10 +2071,6 @@ impl ProdContainerOrchestrator {
Ok(ReconcileAction::Left(reason)) Ok(ReconcileAction::Left(reason))
if mode == ReconcileMode::ExistingOnly if mode == ReconcileMode::ExistingOnly
&& reason == "absent" && reason == "absent"
// companion.rs owns missing UI provisioning/removal.
// Never resurrect an orphan from a stale snapshot.
// Existing UIs still pass through security config repair.
&& !super::companion::is_companion_app(&app_id)
&& (was_running.contains(&compute_container_name(&lm.manifest)) && (was_running.contains(&compute_container_name(&lm.manifest))
// The durable answer, and the one that does not // The durable answer, and the one that does not
// erode. `was_running` only records what was // erode. `was_running` only records what was
@@ -3153,12 +3149,7 @@ impl ProdContainerOrchestrator {
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body); let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body); let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
let restart_for_security_change = quadlet::security_changed(&old_body, &new_body); let restart_for_security_change = quadlet::security_changed(&old_body, &new_body);
let restart_for_managed_override =
quadlet::redundant_managed_network_override(&unit, &unit_dir)
.await?
.is_some();
let needs_restart = restart_required let needs_restart = restart_required
|| restart_for_managed_override
|| restart_for_port_change || restart_for_port_change
|| restart_for_network_alias_change || restart_for_network_alias_change
|| restart_for_exec_change || restart_for_exec_change
@@ -4886,26 +4877,6 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
/// here (production volumes live under `/var/lib/archipelago` — removal is a /// here (production volumes live under `/var/lib/archipelago` — removal is a
/// separate operation owned by the data layer, not this orchestrator). /// separate operation owned by the data layer, not this orchestrator).
async fn remove(&self, app_id: &str, _preserve_data: bool) -> Result<()> { async fn remove(&self, app_id: &str, _preserve_data: bool) -> Result<()> {
// A removed catalog entry must remain uninstallable. The RPC caller
// still removes legacy containers and persists uninstall intent after
// confirming they are gone; do not block it on a missing manifest.
if !self.state.read().await.manifests.contains_key(app_id) {
anyhow::ensure!(
!app_id.is_empty()
&& app_id.len() <= 128
&& app_id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_')),
"Invalid app id"
);
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
for name in [app_id.to_string(), format!("archy-{app_id}")] {
self.remove_quadlet_unit_if_present(&name).await?;
}
self.state.write().await.disabled.insert(app_id.to_string());
return Ok(());
}
let lm = self.loaded(app_id).await?; let lm = self.loaded(app_id).await?;
let lock = self.app_lock(app_id).await; let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await; let _guard = lock.lock().await;
@@ -7254,52 +7225,6 @@ app:
assert!(!calls.iter().any(|c| c.starts_with("start_container:"))); assert!(!calls.iter().any(|c| c.starts_with("start_container:")));
} }
#[tokio::test]
async fn reconcile_existing_does_not_resurrect_orphaned_companions() {
let rt = Arc::new(MockRuntime::default());
let mut orch = orch_with(rt.clone()).await;
orch.set_disk_gb_for_test(500);
let companions = [
"bitcoin-ui",
"electrs-ui",
"lnd-ui",
"fedimint-ui",
"cuprate-ui",
];
let mut names = Vec::new();
for id in companions {
let manifest = pull_manifest(id, "localhost/companion:local");
names.push(compute_container_name(&manifest));
orch.insert_manifest_for_test(manifest, PathBuf::from("/tmp/companion"))
.await;
}
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
crate::crash_recovery::save_container_snapshot_for_test(&orch.data_dir, &refs).await;
// Repeated passes must leave lifecycle ownership with companion.rs.
for _ in 0..3 {
let report = orch.reconcile_existing().await;
assert_eq!(report.actions.len(), companions.len());
assert!(report
.actions
.iter()
.all(|(_, action)| *action == ReconcileAction::Left("absent".into())));
assert!(report.failures.is_empty());
}
let calls = rt.calls();
for operation in [
"pull_image:",
"create_container:",
"start_container:",
"stop_container:",
"remove_container:",
] {
assert!(
!calls.iter().any(|call| call.starts_with(operation)),
"{calls:?}"
);
}
}
#[tokio::test] #[tokio::test]
async fn reconcile_existing_self_heals_missing_optional_installed_app() { async fn reconcile_existing_self_heals_missing_optional_installed_app() {
// A non-baseline app (gitea) self-heals ONLY with installation // A non-baseline app (gitea) self-heals ONLY with installation
@@ -7460,19 +7385,6 @@ app:
); );
} }
#[tokio::test]
async fn removed_catalog_entry_does_not_block_legacy_uninstall() {
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
orch.remove("cryptpad", true).await.unwrap();
assert!(orch.state.read().await.disabled.contains("cryptpad"));
assert!(
rt.calls().is_empty(),
"legacy RPC teardown owns the actual containers"
);
assert!(orch.remove("../other", true).await.is_err());
}
#[tokio::test] #[tokio::test]
async fn remove_disables_manifest_so_reconcile_does_not_reinstall() { async fn remove_disables_manifest_so_reconcile_does_not_reinstall() {
let rt = Arc::new(MockRuntime::default()); let rt = Arc::new(MockRuntime::default());
+57 -140
View File
@@ -176,6 +176,8 @@ pub struct QuadletUnit {
/// for rotation-drift detection. /// for rotation-drift detection.
pub labels: Vec<(String, String)>, pub labels: Vec<(String, String)>,
pub devices: Vec<String>, pub devices: Vec<String>,
/// Namespaced sysctls (`Sysctl=k=v`), already allow-listed by the manifest.
pub sysctls: Vec<(String, String)>,
pub add_hosts: Vec<(String, String)>, pub add_hosts: Vec<(String, String)>,
pub network_aliases: Vec<String>, pub network_aliases: Vec<String>,
pub entrypoint: Option<Vec<String>>, pub entrypoint: Option<Vec<String>>,
@@ -307,6 +309,9 @@ impl QuadletUnit {
for dev in &self.devices { for dev in &self.devices {
let _ = writeln!(s, "AddDevice={dev}"); let _ = writeln!(s, "AddDevice={dev}");
} }
for (k, v) in &self.sysctls {
let _ = writeln!(s, "Sysctl={k}={v}");
}
for (name, ip) in &self.add_hosts { for (name, ip) in &self.add_hosts {
let _ = writeln!(s, "AddHost={name}:{ip}"); let _ = writeln!(s, "AddHost={name}:{ip}");
} }
@@ -521,6 +526,11 @@ impl QuadletUnit {
}) })
.collect(), .collect(),
devices: app.devices.clone(), devices: app.devices.clone(),
sysctls: app
.sysctls
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())], add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
// Container always answers to its own name; manifest extras add the // Container always answers to its own name; manifest extras add the
// short hostnames peers bake in (e.g. indeedhub api/minio/relay). // short hostnames peers bake in (e.g. indeedhub api/minio/relay).
@@ -713,76 +723,29 @@ pub async fn unit_dir() -> Result<PathBuf> {
Ok(dir) Ok(dir)
} }
/// The early same-node Portainer repair used a managed Quadlet drop-in. Once /// Atomically write `unit` into `dir/<name>.container` if the bytes
/// the manifest supplies slirp, the two Network= entries are additive and /// differ from what's already there. Returns true if the file changed.
/// Podman rejects startup. Retire only that exact redundant managed override;
/// arbitrary operator settings must survive reconciliation.
pub async fn redundant_managed_network_override(
unit: &QuadletUnit,
dir: &Path,
) -> Result<Option<PathBuf>> {
if unit.name != "portainer" || !matches!(unit.network, NetworkMode::Slirp4netns) {
return Ok(None);
}
let path = dir.join("portainer.container.d/archy-same-node-network.conf");
let body = match fs::read_to_string(&path).await {
Ok(body) => body,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error).context("read managed Portainer network override"),
};
let lines: Vec<&str> = body
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with(['#', ';']))
.collect();
Ok((lines == ["[Container]", "Network=slirp4netns"]).then_some(path))
}
async fn retire_managed_network_override(path: &Path) -> Result<()> {
let backup = path.with_extension("conf.retired");
match fs::hard_link(path, &backup).await {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
anyhow::ensure!(
fs::read(path).await? == fs::read(&backup).await?,
"Existing Portainer override backup differs; preserve both for operator review"
);
}
Err(error) => return Err(error).context("back up managed Portainer network override"),
}
fs::remove_file(path)
.await
.context("retire redundant Portainer network override")?;
tracing::info!("Retired redundant managed Portainer network override; backup retained");
Ok(())
}
/// Atomically write the manifest unit and retire known redundant managed
/// overrides. Returns true whenever systemd needs a daemon-reload.
pub async fn write_if_changed(unit: &QuadletUnit, dir: &Path) -> Result<bool> { pub async fn write_if_changed(unit: &QuadletUnit, dir: &Path) -> Result<bool> {
let path = dir.join(unit.unit_filename()); let path = dir.join(unit.unit_filename());
let new_bytes = unit.render(); let new_bytes = unit.render();
let redundant = redundant_managed_network_override(unit, dir).await?;
let changed = fs::read_to_string(&path) if let Ok(old) = fs::read_to_string(&path).await {
if old == new_bytes {
return Ok(false);
}
}
fs::create_dir_all(dir)
.await .await
.map(|old| old != new_bytes) .with_context(|| format!("create_dir_all {}", dir.display()))?;
.unwrap_or(true); let tmp = path.with_extension("container.tmp");
if changed { fs::write(&tmp, new_bytes.as_bytes())
fs::create_dir_all(dir) .await
.await .with_context(|| format!("write tmp {}", tmp.display()))?;
.with_context(|| format!("create_dir_all {}", dir.display()))?; fs::rename(&tmp, &path)
let tmp = path.with_extension("container.tmp"); .await
fs::write(&tmp, new_bytes.as_bytes()) .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
.await Ok(true)
.with_context(|| format!("write tmp {}", tmp.display()))?;
fs::rename(&tmp, &path)
.await
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
}
if let Some(override_path) = &redundant {
retire_managed_network_override(override_path).await?;
}
Ok(changed || redundant.is_some())
} }
/// Reload the user systemd manager. Required after any quadlet write /// Reload the user systemd manager. Required after any quadlet write
@@ -1534,6 +1497,7 @@ app:
"RELAY_NAME=Archipelago Nostr Relay".into(), "RELAY_NAME=Archipelago Nostr Relay".into(),
], ],
devices: vec!["/dev/kvm".into()], devices: vec!["/dev/kvm".into()],
sysctls: vec![("net.ipv4.ip_forward".into(), "1".into())],
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())], add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
entrypoint: Some(vec!["/usr/local/bin/bitcoind".into()]), entrypoint: Some(vec!["/usr/local/bin/bitcoind".into()]),
command: vec!["-server=1".into(), "-rpcbind=0.0.0.0".into()], command: vec!["-server=1".into(), "-rpcbind=0.0.0.0".into()],
@@ -1550,6 +1514,7 @@ app:
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret")); assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\"")); assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
assert!(s.contains("AddDevice=/dev/kvm")); assert!(s.contains("AddDevice=/dev/kvm"));
assert!(s.contains("Sysctl=net.ipv4.ip_forward=1"));
assert!(s.contains("AddHost=host.archipelago:10.89.0.1")); assert!(s.contains("AddHost=host.archipelago:10.89.0.1"));
assert!(s.contains("ReadOnly=true")); assert!(s.contains("ReadOnly=true"));
assert!(s.contains("NoNewPrivileges=true")); assert!(s.contains("NoNewPrivileges=true"));
@@ -1571,6 +1536,7 @@ app:
assert!(!s.contains("PublishPort=")); assert!(!s.contains("PublishPort="));
assert!(!s.contains("Environment=")); assert!(!s.contains("Environment="));
assert!(!s.contains("AddDevice=")); assert!(!s.contains("AddDevice="));
assert!(!s.contains("Sysctl="));
assert!(!s.contains("AddHost=")); assert!(!s.contains("AddHost="));
assert!(!s.contains("ReadOnly=")); assert!(!s.contains("ReadOnly="));
assert!(!s.contains("NoNewPrivileges=")); assert!(!s.contains("NoNewPrivileges="));
@@ -1668,6 +1634,31 @@ app:
assert!(!s.contains("Network=host")); assert!(!s.contains("Network=host"));
} }
#[test]
fn from_manifest_renders_namespaced_sysctls() {
let yaml = r#"
app:
id: vpn-exit
name: VPN Exit
version: 1.0.0
container:
image: test/vpn:1.0.0
network: pasta
devices: [/dev/net/tun]
sysctls:
net.ipv4.ip_forward: "1"
security:
capabilities: [NET_ADMIN, NET_RAW]
"#;
let m = AppManifest::parse(yaml).expect("manifest must parse");
let s = QuadletUnit::from_manifest(&m, "vpn-exit").render();
assert!(s.contains("Network=pasta"));
assert!(s.contains("AddDevice=/dev/net/tun"));
assert!(s.contains("Sysctl=net.ipv4.ip_forward=1"));
assert!(s.contains("AddCapability=NET_ADMIN"));
}
#[test] #[test]
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() { fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
let manifest = AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")) let manifest = AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml"))
@@ -2038,80 +2029,6 @@ app:
assert!(!network_aliases_changed(new, new)); assert!(!network_aliases_changed(new, new));
} }
#[tokio::test]
async fn redundant_portainer_override_is_backed_up_and_retired_even_when_base_matches() {
let dir = tempfile::tempdir().unwrap();
let manifest =
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap();
let unit = QuadletUnit::from_manifest(&manifest, "portainer");
assert!(write_if_changed(&unit, dir.path()).await.unwrap());
let path = dir
.path()
.join("portainer.container.d/archy-same-node-network.conf");
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
let old = "[Container]\nNetwork=slirp4netns\n";
fs::write(&path, old).await.unwrap();
assert!(redundant_managed_network_override(&unit, dir.path())
.await
.unwrap()
.is_some());
assert!(write_if_changed(&unit, dir.path()).await.unwrap());
assert!(!path.exists());
assert_eq!(
fs::read_to_string(path.with_extension("conf.retired"))
.await
.unwrap(),
old
);
assert!(!write_if_changed(&unit, dir.path()).await.unwrap());
assert_eq!(
fs::read_to_string(dir.path().join("portainer.container"))
.await
.unwrap()
.matches("Network=slirp4netns")
.count(),
1
);
}
#[tokio::test]
async fn network_override_migration_preserves_operator_customizations_and_failed_backups() {
let dir = tempfile::tempdir().unwrap();
let manifest =
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap();
let mut unit = QuadletUnit::from_manifest(&manifest, "portainer");
let path = dir
.path()
.join("portainer.container.d/archy-same-node-network.conf");
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
for custom in [
"[Container]\nNetwork=custom-net\n",
"[Container]\nNetwork=slirp4netns\nEnvironment=OPERATOR_SETTING=1\n",
] {
fs::write(&path, custom).await.unwrap();
assert!(redundant_managed_network_override(&unit, dir.path())
.await
.unwrap()
.is_none());
write_if_changed(&unit, dir.path()).await.unwrap();
assert_eq!(fs::read_to_string(&path).await.unwrap(), custom);
}
fs::write(&path, "[Container]\nNetwork=slirp4netns\n")
.await
.unwrap();
unit.network = NetworkMode::Pasta;
assert!(redundant_managed_network_override(&unit, dir.path())
.await
.unwrap()
.is_none());
unit.network = NetworkMode::Slirp4netns;
fs::write(path.with_extension("conf.retired"), "different backup")
.await
.unwrap();
assert!(write_if_changed(&unit, dir.path()).await.is_err());
assert!(path.exists(), "failure must preserve the active override");
}
#[tokio::test] #[tokio::test]
async fn failed_runtime_change_remains_pending_when_unit_already_matches() { async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+57 -132
View File
@@ -1,155 +1,80 @@
//! Durable seller-side entitlements for peer-file invoices and on-chain sales. //! Seller-side pending entitlements for Lightning-invoice peer-file sales (#46).
//! Payment records must outlive browser polling, process restarts and invoice //!
//! expiry: an invoice can settle while the buyer is disconnected. //! When a buyer asks to pay for a paid catalog item with an external wallet (as
//! opposed to the local-ecash fast path), the *selling* node mints a Lightning
//! invoice on its own LND and records a pending entitlement here, keyed by the
//! invoice's payment hash. The buyer pays the invoice from any wallet and polls
//! for settlement; once the seller's LND confirms the invoice is settled we mark
//! the entitlement paid, and the content gate (`content_server::serve_content`)
//! then releases the file to anyone presenting that payment hash.
//!
//! State is in-memory and bounded by a TTL. If the seller restarts before the
//! buyer pays, the buyer simply requests a fresh invoice — no value is lost
//! because an unpaid invoice represents no money.
use anyhow::{Context, Result}; use std::collections::HashMap;
use serde::{Deserialize, Serialize}; use std::sync::LazyLock;
use sha2::{Digest, Sha256}; use std::time::{Duration, Instant};
use std::path::{Path, PathBuf}; use tokio::sync::Mutex;
use tokio::{fs, io::AsyncWriteExt, sync::Mutex};
static WRITES: Mutex<()> = Mutex::const_new(()); /// How long a pending/paid entitlement is retained. Generous enough for a human
/// to pay an invoice and download, short enough to keep the map small.
const ENTITLEMENT_TTL: Duration = Duration::from_secs(3600); // 1 hour
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone)]
struct Entitlement { struct Entitlement {
content_id: String, content_id: String,
price_sats: u64, price_sats: u64,
paid: bool, paid: bool,
created_at: Instant,
} }
fn path(data_dir: &Path, token: &str) -> PathBuf { static ENTITLEMENTS: LazyLock<Mutex<HashMap<String, Entitlement>>> =
data_dir.join("content-entitlements").join(format!( LazyLock::new(|| Mutex::new(HashMap::new()));
"{}.json",
hex::encode(Sha256::digest(token.as_bytes())) /// Drop expired entries. Caller must hold the lock.
)) fn prune(map: &mut HashMap<String, Entitlement>) {
map.retain(|_, e| e.created_at.elapsed() < ENTITLEMENT_TTL);
} }
async fn read(data_dir: &Path, token: &str) -> Result<Option<Entitlement>> { /// Record a freshly-minted invoice as a pending (unpaid) entitlement.
match fs::read(path(data_dir, token)).await { pub async fn record_pending(payment_hash: &str, content_id: &str, price_sats: u64) {
Ok(bytes) => Ok(Some( let mut map = ENTITLEMENTS.lock().await;
serde_json::from_slice(&bytes).context("Invalid payment entitlement")?, prune(&mut map);
)), map.insert(
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), payment_hash.to_string(),
Err(e) => Err(e).context("Reading payment entitlement"), Entitlement {
} content_id: content_id.to_string(),
}
async fn write(data_dir: &Path, token: &str, entry: &Entitlement) -> Result<()> {
let target = path(data_dir, token);
let dir = target.parent().unwrap();
fs::create_dir_all(dir).await?;
let tmp = target.with_extension("tmp");
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)
.await?;
file.write_all(&serde_json::to_vec(entry)?).await?;
file.sync_all().await?;
drop(file);
fs::rename(&tmp, &target).await?;
fs::File::open(dir).await?.sync_all().await?;
Ok(())
}
/// Save before exposing an invoice/address to the buyer. Never overwrite an
/// existing payment or silently rebind its token to another item or price.
pub async fn record_pending(
data_dir: &Path,
token: &str,
content_id: &str,
price_sats: u64,
) -> Result<()> {
let _lock = WRITES.lock().await;
if let Some(existing) = read(data_dir, token).await? {
anyhow::ensure!(
existing.content_id == content_id && existing.price_sats == price_sats,
"Payment entitlement mismatch"
);
return Ok(());
}
write(
data_dir,
token,
&Entitlement {
content_id: content_id.into(),
price_sats, price_sats,
paid: false, paid: false,
created_at: Instant::now(),
}, },
) );
.await
} }
pub async fn mark_paid(data_dir: &Path, token: &str) -> Result<()> { /// Mark the entitlement for `payment_hash` paid. No-op if unknown/expired.
let _lock = WRITES.lock().await; pub async fn mark_paid(payment_hash: &str) {
let mut entry = read(data_dir, token) let mut map = ENTITLEMENTS.lock().await;
.await? prune(&mut map);
.context("Unknown payment entitlement")?; if let Some(e) = map.get_mut(payment_hash) {
entry.paid = true; e.paid = true;
write(data_dir, token, &entry).await }
} }
pub async fn lookup(data_dir: &Path, token: &str) -> Result<Option<(String, u64)>> { /// The content_id + price an entitlement was issued for, if still live.
Ok(read(data_dir, token) pub async fn lookup(payment_hash: &str) -> Option<(String, u64)> {
.await? let mut map = ENTITLEMENTS.lock().await;
.map(|e| (e.content_id, e.price_sats))) prune(&mut map);
map.get(payment_hash)
.map(|e| (e.content_id.clone(), e.price_sats))
} }
pub async fn is_paid_for(data_dir: &Path, token: &str, content_id: &str) -> bool { /// True if `payment_hash` is a paid entitlement for exactly `content_id`.
read(data_dir, token) /// This is the gate the content server consults to release a file.
.await pub async fn is_paid_for(payment_hash: &str, content_id: &str) -> bool {
.ok() let mut map = ENTITLEMENTS.lock().await;
.flatten() prune(&mut map);
map.get(payment_hash)
.map(|e| e.paid && e.content_id == content_id) .map(|e| e.paid && e.content_id == content_id)
.unwrap_or(false) .unwrap_or(false)
} }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn paid_entitlement_survives_reload_and_cannot_be_rebound() {
let dir = tempfile::tempdir().unwrap();
record_pending(dir.path(), "hash", "file", 12)
.await
.unwrap();
assert!(!is_paid_for(dir.path(), "hash", "file").await);
mark_paid(dir.path(), "hash").await.unwrap();
// All reads reopen disk; no process-local entitlement map exists.
assert!(is_paid_for(dir.path(), "hash", "file").await);
assert!(!is_paid_for(dir.path(), "hash", "other").await);
record_pending(dir.path(), "hash", "file", 12)
.await
.unwrap();
assert!(is_paid_for(dir.path(), "hash", "file").await);
assert!(record_pending(dir.path(), "hash", "other", 12)
.await
.is_err());
assert!(record_pending(dir.path(), "hash", "file", 13)
.await
.is_err());
let other = tempfile::tempdir().unwrap();
assert!(!is_paid_for(other.path(), "hash", "file").await);
assert!(mark_paid(dir.path(), "unknown").await.is_err());
}
#[tokio::test]
async fn corrupt_or_unwritable_records_fail_closed() {
let dir = tempfile::tempdir().unwrap();
record_pending(dir.path(), "../../token", "file", 1)
.await
.unwrap();
fs::write(path(dir.path(), "../../token"), b"broken")
.await
.unwrap();
assert!(lookup(dir.path(), "../../token").await.is_err());
assert!(!is_paid_for(dir.path(), "../../token", "file").await);
assert!(record_pending(dir.path(), "../../token", "file", 1)
.await
.is_err());
let file = dir.path().join("not-directory");
fs::write(&file, b"x").await.unwrap();
assert!(record_pending(&file, "hash", "file", 1).await.is_err());
}
}
+12 -130
View File
@@ -12,9 +12,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tokio::{fs, io::AsyncWriteExt, sync::Mutex}; use tokio::fs;
static PURCHASE_WRITES: Mutex<()> = Mutex::const_new(());
const OWNED_DIR: &str = "purchased-content"; const OWNED_DIR: &str = "purchased-content";
const OWNED_INDEX: &str = "owned.json"; const OWNED_INDEX: &str = "owned.json";
@@ -68,42 +66,11 @@ fn bytes_path(data_dir: &Path, onion: &str, content_id: &str) -> PathBuf {
.join(sanitize(content_id)) .join(sanitize(content_id))
} }
async fn load_index_checked(data_dir: &Path) -> Result<OwnedIndex> {
match fs::read_to_string(index_path(data_dir)).await {
Ok(s) => serde_json::from_str(&s)
.context("Invalid purchase index; existing records were preserved"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(OwnedIndex::default()),
Err(error) => Err(error).context("Reading purchase index"),
}
}
async fn load_index(data_dir: &Path) -> OwnedIndex { async fn load_index(data_dir: &Path) -> OwnedIndex {
load_index_checked(data_dir).await.unwrap_or_default() match fs::read_to_string(index_path(data_dir)).await {
} Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => OwnedIndex::default(),
async fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path.parent().context("Purchase path has no parent")?;
fs::create_dir_all(parent).await?;
let temp = parent.join(format!(".purchase-{}.tmp", uuid::Uuid::new_v4()));
let result = async {
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temp)
.await?;
file.write_all(bytes).await?;
file.sync_all().await?;
drop(file);
fs::rename(&temp, path).await?;
fs::File::open(parent).await?.sync_all().await?;
Ok::<_, anyhow::Error>(())
} }
.await;
if result.is_err() {
let _ = fs::remove_file(&temp).await;
}
result
} }
async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> { async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> {
@@ -112,7 +79,7 @@ async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> {
.await .await
.with_context(|| format!("creating {}", root.display()))?; .with_context(|| format!("creating {}", root.display()))?;
let content = serde_json::to_string_pretty(index).context("serializing owned index")?; let content = serde_json::to_string_pretty(index).context("serializing owned index")?;
atomic_write(&index_path(data_dir), content.as_bytes()) fs::write(index_path(data_dir), content)
.await .await
.context("writing owned index") .context("writing owned index")
} }
@@ -131,15 +98,17 @@ pub async fn record_purchase(
ecash_backend: &str, ecash_backend: &str,
purchased_at: &str, purchased_at: &str,
) -> Result<()> { ) -> Result<()> {
// Read-modify-write must be one serialized transaction. Never replace a
// damaged index with an empty one, and never expose partially written bytes.
let _lock = PURCHASE_WRITES.lock().await;
let mut index = load_index_checked(data_dir).await?;
let path = bytes_path(data_dir, onion, content_id); let path = bytes_path(data_dir, onion, content_id);
atomic_write(&path, bytes) if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.await
.with_context(|| format!("creating {}", parent.display()))?;
}
fs::write(&path, bytes)
.await .await
.with_context(|| format!("writing purchased bytes to {}", path.display()))?; .with_context(|| format!("writing purchased bytes to {}", path.display()))?;
let mut index = load_index(data_dir).await;
let entry = OwnedItem { let entry = OwnedItem {
onion: onion.to_string(), onion: onion.to_string(),
content_id: content_id.to_string(), content_id: content_id.to_string(),
@@ -196,90 +165,3 @@ pub async fn read_owned(
.unwrap_or_else(|| "application/octet-stream".to_string()); .unwrap_or_else(|| "application/octet-stream".to_string());
Some((mime, bytes)) Some((mime, bytes))
} }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn concurrent_purchases_preserve_every_item_and_exact_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut jobs = tokio::task::JoinSet::new();
for n in 0..24 {
let root = dir.path().to_path_buf();
jobs.spawn(async move {
let id = format!("file-{n}");
record_purchase(
&root,
"seller.onion",
&id,
&id,
"text/plain",
id.as_bytes(),
5,
"lightning",
"now",
)
.await
.unwrap();
});
}
while let Some(result) = jobs.join_next().await {
result.unwrap();
}
assert_eq!(list_owned(dir.path()).await.len(), 24);
for n in 0..24 {
let id = format!("file-{n}");
assert!(is_owned(dir.path(), "seller.onion", &id).await);
let (mime, bytes) = read_owned(dir.path(), "seller.onion", &id).await.unwrap();
assert_eq!(mime, "text/plain");
assert_eq!(bytes, id.as_bytes());
}
record_purchase(
dir.path(),
"seller.onion",
"file-0",
"file-0",
"text/plain",
b"updated",
5,
"lightning",
"later",
)
.await
.unwrap();
assert_eq!(list_owned(dir.path()).await.len(), 24);
assert_eq!(
read_owned(dir.path(), "seller.onion", "file-0")
.await
.unwrap()
.1,
b"updated"
);
}
#[tokio::test]
async fn damaged_index_is_preserved_instead_of_erasing_prior_ownership() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(owned_root(dir.path())).await.unwrap();
fs::write(index_path(dir.path()), b"damaged but preserve me")
.await
.unwrap();
assert!(record_purchase(
dir.path(),
"seller.onion",
"new",
"new",
"text/plain",
b"bytes",
5,
"lightning",
"now"
)
.await
.is_err());
assert_eq!(
fs::read(index_path(dir.path())).await.unwrap(),
b"damaged but preserve me"
);
assert!(!bytes_path(dir.path(), "seller.onion", "new").exists());
}
}
+1 -1
View File
@@ -400,7 +400,7 @@ where
if !authorized { if !authorized {
if let Some(hash) = invoice_hash { if let Some(hash) = invoice_hash {
if method_accepted(&item.access, "lightning") if method_accepted(&item.access, "lightning")
&& crate::content_invoice::is_paid_for(data_dir, hash, id).await && crate::content_invoice::is_paid_for(hash, id).await
{ {
authorized = true; authorized = true;
} }
+17 -45
View File
@@ -664,10 +664,6 @@ pub async fn start_stopped_stack_containers(data_dir: &Path) -> RecoveryReport {
start_stopped_app_stacks(data_dir).await start_stopped_app_stacks(data_dir).await
} }
fn stack_member_needs_recovery(state: Option<&str>, user_stopped: bool) -> bool {
!user_stopped && matches!(state, Some("exited" | "stopped" | "created" | "configured"))
}
async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport { async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
let user_stopped = load_user_stopped(data_dir).await; let user_stopped = load_user_stopped(data_dir).await;
let mut report = RecoveryReport { let mut report = RecoveryReport {
@@ -681,27 +677,24 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
continue; continue;
} }
// Healthy members must never acquire a restarting overlay merely info!(
// because the periodic recovery scan ran. Queue existing stopped "Recovering stopped {} stack containers after boot",
// members only; recheck each immediately before starting below. stack.name
let mut pending = Vec::new(); );
for container in stack.containers {
let state = container_state(container).await;
if stack_member_needs_recovery(state.as_deref(), user_stopped.contains(*container)) {
pending.push((*container).to_string());
}
}
if pending.is_empty() {
continue;
}
info!("Recovering stopped {} stack containers", stack.name);
repair_stack_network_aliases(stack).await; repair_stack_network_aliases(stack).await;
pending_boot_starts_add(pending.iter().cloned());
// Register the whole stack up front: the per-member dependency waits
// below can take minutes, and the UI should say "Restarting", not
// "Stopped", for members still queued behind them.
pending_boot_starts_add(
stack
.containers
.iter()
.filter(|c| !user_stopped.contains(**c))
.map(|c| (*c).to_string()),
);
for container in stack.containers { for container in stack.containers {
if !pending.iter().any(|name| name.as_str() == *container) {
continue;
}
if user_stopped.contains(*container) { if user_stopped.contains(*container) {
info!("Skipping user-stopped container: {}", container); info!("Skipping user-stopped container: {}", container);
continue; continue;
@@ -713,8 +706,8 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
pending_boot_start_done(container); pending_boot_start_done(container);
continue; continue;
} }
Some(state) if stack_member_needs_recovery(Some(&state), false) => {} Some(_) => {}
_ => { None => {
pending_boot_start_done(container); pending_boot_start_done(container);
continue; continue;
} }
@@ -1541,24 +1534,3 @@ mod installed_concurrency_tests {
assert!(!dir.path().join("installed-apps.json.tmp").exists()); assert!(!dir.path().join("installed-apps.json.tmp").exists());
} }
} }
#[cfg(test)]
mod stack_recovery_overlay_tests {
use super::stack_member_needs_recovery;
#[test]
fn only_existing_stopped_members_receive_recovery_overlay() {
for state in [
None,
Some("running"),
Some("paused"),
Some("restarting"),
Some("removing"),
] {
assert!(!stack_member_needs_recovery(state, false));
}
for state in ["exited", "stopped", "created", "configured"] {
assert!(stack_member_needs_recovery(Some(state), false));
assert!(!stack_member_needs_recovery(Some(state), true));
}
}
}
+117 -1
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -54,6 +54,12 @@ pub struct AppDefinition {
#[serde(default)] #[serde(default)]
pub devices: Vec<String>, pub devices: Vec<String>,
/// Namespaced kernel parameters for the app's OWN network namespace
/// (podman `--sysctl`). Allow-listed to [`ALLOWED_SYSCTLS`] and rejected
/// under host networking, where they would change the host itself.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub sysctls: BTreeMap<String, String>,
#[serde(default)] #[serde(default)]
pub interfaces: HashMap<String, AppInterface>, pub interfaces: HashMap<String, AppInterface>,
@@ -1009,6 +1015,11 @@ impl AppManifest {
} }
validate_environment(&self.app.environment)?; validate_environment(&self.app.environment)?;
validate_devices(&self.app.devices)?; validate_devices(&self.app.devices)?;
validate_sysctls(
&self.app.sysctls,
self.app.container.network.as_deref(),
&self.app.security.network_policy,
)?;
// Volume tmpfs_options: only meaningful for type: tmpfs. // Volume tmpfs_options: only meaningful for type: tmpfs.
for (i, v) in self.app.volumes.iter().enumerate() { for (i, v) in self.app.volumes.iter().enumerate() {
@@ -1342,6 +1353,45 @@ fn validate_devices(devices: &[String]) -> Result<(), ManifestError> {
Ok(()) Ok(())
} }
/// Sysctls an app may set. Each is scoped to the container's own network
/// namespace, so it cannot reach the host. Packet forwarding is what a
/// routing app (a VPN exit) needs, and rootless `/proc/sys` is read-only
/// inside the container, so it can only be set at create time.
pub const ALLOWED_SYSCTLS: &[&str] = &["net.ipv4.ip_forward", "net.ipv6.conf.all.forwarding"];
fn validate_sysctls(
sysctls: &BTreeMap<String, String>,
network: Option<&str>,
network_policy: &str,
) -> Result<(), ManifestError> {
if sysctls.is_empty() {
return Ok(());
}
let host_network = match network {
Some(n) => n == "host",
None => network_policy == "host",
};
if host_network {
return Err(ManifestError::Invalid(
"sysctls require the app's own network namespace, not host networking".into(),
));
}
for (key, value) in sysctls {
if !ALLOWED_SYSCTLS.contains(&key.as_str()) {
return Err(ManifestError::Invalid(format!(
"sysctls.{key} is not allowed (allowed: {})",
ALLOWED_SYSCTLS.join(", ")
)));
}
if value != "0" && value != "1" {
return Err(ManifestError::Invalid(format!(
"sysctls.{key} must be \"0\" or \"1\""
)));
}
}
Ok(())
}
fn validate_bind_source(index: usize, source: &str) -> Result<(), ManifestError> { fn validate_bind_source(index: usize, source: &str) -> Result<(), ManifestError> {
let path = std::path::Path::new(source); let path = std::path::Path::new(source);
if !path.is_absolute() { if !path.is_absolute() {
@@ -2784,6 +2834,72 @@ app:
assert_eq!(m.app.ports[2].bind, ""); assert_eq!(m.app.ports[2].bind, "");
} }
fn sysctl_manifest(network: &str, sysctls: &str) -> String {
format!(
r#"
app:
id: sysctl-app
name: Sysctl App
version: 1.0.0
container:
image: test/image:1.0.0
network: {network}
sysctls:
{sysctls}
"#
)
}
#[test]
fn forwarding_sysctls_parse_in_own_netns() {
let m = AppManifest::parse(&sysctl_manifest(
"pasta",
" net.ipv4.ip_forward: \"1\"\n net.ipv6.conf.all.forwarding: \"0\"",
))
.expect("allow-listed forwarding sysctls must validate");
assert_eq!(m.app.sysctls["net.ipv4.ip_forward"], "1");
assert_eq!(m.app.sysctls["net.ipv6.conf.all.forwarding"], "0");
}
#[test]
fn sysctls_absent_by_default_and_not_serialized() {
let m = AppManifest::parse(
"app:\n id: plain\n name: Plain\n version: 1.0.0\n container:\n image: test/image:1.0.0\n",
)
.unwrap();
assert!(m.app.sysctls.is_empty());
assert!(!serde_yaml::to_string(&m).unwrap().contains("sysctls"));
}
#[test]
fn unsafe_sysctls_are_rejected() {
let cases = [
(
sysctl_manifest("pasta", " kernel.core_pattern: \"|/bin/sh\""),
"not allowed",
),
(
sysctl_manifest("pasta", " net.ipv4.ip_forward: \"2\""),
"must be \"0\" or \"1\"",
),
(
sysctl_manifest("host", " net.ipv4.ip_forward: \"1\""),
"own network namespace",
),
(
// No explicit network: the host policy still means the host netns.
sysctl_manifest("pasta", " net.ipv4.ip_forward: \"1\"")
.replace(" network: pasta\n", "")
.replace(" sysctls:", " security:\n network_policy: host\n sysctls:"),
"own network namespace",
),
];
for (yaml, expected) in cases {
let msg = AppManifest::parse(&yaml).unwrap_err().to_string();
assert!(msg.contains(expected), "expected '{expected}', got: {msg}");
}
}
#[test] #[test]
fn reviewed_host_bind_exceptions_parse() { fn reviewed_host_bind_exceptions_parse() {
let yaml = r#" let yaml = r#"
+1
View File
@@ -439,6 +439,7 @@ impl PodmanClient {
"devices": manifest.app.devices.iter().map(|d| { "devices": manifest.app.devices.iter().map(|d| {
serde_json::json!({"path": d}) serde_json::json!({"path": d})
}).collect::<Vec<_>>(), }).collect::<Vec<_>>(),
"sysctl": manifest.app.sysctls,
"resource_limits": resource_limits, "resource_limits": resource_limits,
"cap_add": cap_add, "cap_add": cap_add,
"cap_drop": cap_drop, "cap_drop": cap_drop,
+3
View File
@@ -712,6 +712,9 @@ impl ContainerRuntime for DockerRuntime {
for device in &manifest.app.devices { for device in &manifest.app.devices {
cmd.arg("--device").arg(device); cmd.arg("--device").arg(device);
} }
for (key, value) in &manifest.app.sysctls {
cmd.arg("--sysctl").arg(format!("{key}={value}"));
}
// Environment variables // Environment variables
for env in &manifest.app.environment { for env in &manifest.app.environment {
+1 -1
View File
@@ -1,4 +1,4 @@
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
# Static site content. # Static site content.
COPY index.html /usr/share/nginx/html/ COPY index.html /usr/share/nginx/html/
COPY tailwind.css /usr/share/nginx/html/ COPY tailwind.css /usr/share/nginx/html/
+1 -1
View File
@@ -1,4 +1,4 @@
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
# Static site content. # Static site content.
COPY index.html /usr/share/nginx/html/ COPY index.html /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/ COPY 50x.html /usr/share/nginx/html/
+1 -1
View File
@@ -1,4 +1,4 @@
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
COPY index.html /usr/share/nginx/html/ COPY index.html /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/ COPY 50x.html /usr/share/nginx/html/
COPY qrcode.js /usr/share/nginx/html/ COPY qrcode.js /usr/share/nginx/html/
+1 -1
View File
@@ -1,4 +1,4 @@
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
COPY index.html /usr/share/nginx/html/index.html COPY index.html /usr/share/nginx/html/index.html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
+1 -1
View File
@@ -1,4 +1,4 @@
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
# Static site content. # Static site content.
COPY index.html /usr/share/nginx/html/ COPY index.html /usr/share/nginx/html/
# #
+1 -1
View File
@@ -1,4 +1,4 @@
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
# Copy the HTML file # Copy the HTML file
COPY index.html /usr/share/nginx/html/ COPY index.html /usr/share/nginx/html/
+98
View File
@@ -0,0 +1,98 @@
# syntax=docker/dockerfile:1.7
#
# Packages nostr-vpn (github.com/mmalmi/nostr-vpn) as the paid-exit seller
# daemon + its web control panel. Both apps/nostr-vpn and apps/nostr-vpn-web
# build from this one image (same binaries, different entrypoint/command),
# mirroring upstream's own umbrel/docker-compose.yml, which runs `daemon`
# and `web` as two containers sharing one /data volume with no network link
# between them — reviewed directly, not assumed.
#
# This is upstream's own umbrel/Dockerfile, unchanged except for how the
# source arrives (a pinned commit tarball here, instead of a local checkout
# in their build context) — see docs/nostr-vpn-integration-plan.md for why
# the pin exists and what was verified against this exact commit.
ARG NVPN_COMMIT=87f19447741998ab5a06aadc701abc7ae021004b
FROM debian:bookworm-slim AS source
ARG NVPN_COMMIT
# git clone, not a codeload.github.com/archive/<sha>.tar.gz tarball: the
# latter 404s from this environment even for refs/heads/main HEAD (network
# policy on that specific endpoint, not a real upstream 404 — plain
# `git clone https://github.com/...` works fine).
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
# GitHub's anonymous smart-HTTP upload-pack refuses to fetch an arbitrary
# SHA directly (only advertised refs) — fetch main by name and verify the
# pinned commit is actually what we land on, so a force-push to main can't
# silently swap out the reviewed code.
RUN git init -q . \
&& git remote add origin https://github.com/mmalmi/nostr-vpn.git \
&& git fetch -q --depth 1 origin master \
&& git checkout -q FETCH_HEAD \
&& test "$(git rev-parse HEAD)" = "${NVPN_COMMIT}" \
&& rm -rf .git
FROM node:24-bookworm AS web-builder
WORKDIR /work/web/control-panel
COPY --from=source /src/web/control-panel/package.json /src/web/control-panel/pnpm-lock.yaml ./
RUN --mount=type=cache,id=nostr-vpn-pnpm-store,target=/pnpm/store \
corepack enable \
&& corepack prepare pnpm@10.28.2 --activate \
&& pnpm install --frozen-lockfile --store-dir /pnpm/store
COPY --from=source /src/web/control-panel ./
RUN pnpm run build
FROM rust:1.94-bookworm AS rust-builder
ARG TARGETPLATFORM
WORKDIR /work
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
clang \
libclang-dev \
libdbus-1-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY --from=source /src/Cargo.toml /src/Cargo.lock ./
COPY --from=source /src/crates ./crates
COPY --from=source /src/vendor ./vendor
RUN --mount=type=cache,id=nostr-vpn-cargo-registry-${TARGETPLATFORM},target=/usr/local/cargo/registry \
--mount=type=cache,id=nostr-vpn-cargo-git-${TARGETPLATFORM},target=/usr/local/cargo/git \
--mount=type=cache,id=nostr-vpn-cargo-target-${TARGETPLATFORM},target=/work/target \
cargo build --release -p nvpn -p nostr-vpn-web \
&& mkdir -p /out \
&& cp /work/target/release/nvpn /out/nvpn \
&& cp /work/target/release/nostr-vpn-web /out/nvpn-web
FROM debian:bookworm-slim AS runtime
LABEL org.opencontainers.image.source="https://github.com/mmalmi/nostr-vpn" \
org.opencontainers.image.description="nostr-vpn, packaged as an Archipelago paid-exit seller app" \
org.opencontainers.image.licenses="MIT"
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
iproute2 \
iptables \
iputils-ping \
libdbus-1-3 \
procps \
wireguard-tools \
&& rm -rf /var/lib/apt/lists/*
COPY --from=rust-builder /out/nvpn /usr/local/bin/nvpn
COPY --from=rust-builder /out/nvpn-web /usr/local/bin/nvpn-web
COPY --from=web-builder /work/web/control-panel/dist /usr/share/nostr-vpn/web
COPY docker-entrypoint.sh /usr/local/bin/archy-nvpn-entrypoint.sh
RUN chmod +x /usr/local/bin/archy-nvpn-entrypoint.sh
ENV HOME=/data/home \
XDG_CONFIG_HOME=/data/config \
NVPN_CLI_PATH=/usr/local/bin/nvpn \
RUST_LOG=info
EXPOSE 38080
VOLUME ["/data"]
# No image-level ENTRYPOINT/CMD: apps/nostr-vpn and apps/nostr-vpn-web set
# their own entrypoint/custom_args in their manifests (daemon vs. web),
# both pointing at archy-nvpn-entrypoint.sh — see that script for why the
# seed-config step has to run before either binary starts.
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# Shared entrypoint for both apps/nostr-vpn (daemon) and apps/nostr-vpn-web
# (control panel) -- they're the same image, differing only in the args
# this script execs into (see each manifest's container.entrypoint/custom_args).
#
# Seeds a minimal config.toml with our chosen listen_port BEFORE nvpn's own
# bootstrap (config_bootstrap.rs::load_or_default_config) ever runs, so the
# very first boot never has to self-heal off upstream's default 51820 --
# archy-x250 fleet nodes already run archipelago-wg on that port (found in
# Phase 0 testing, see docs/nostr-vpn-integration-plan.md). Every AppConfig
# field has #[serde(default = ...)], confirmed by reading
# crates/nostr-vpn-core/src/config/types.rs directly, so a partial TOML here
# merges cleanly with nvpn's own defaults (including the self-generated
# Nostr seller identity) instead of needing a full config.
#
# Never overwrites an existing config.toml: this volume may already hold a
# seller's identity, wallet, and pending Cashu credit adopted from the old
# root-mode install (/var/lib/archipelago/nostr-vpn) -- clobbering it would
# be a real funds-safety bug, not just a config reset.
set -eu
NVPN_LISTEN_PORT="${NVPN_LISTEN_PORT:-51822}"
CONFIG_DIR=/data/config/nvpn
CONFIG_PATH="$CONFIG_DIR/config.toml"
mkdir -p "$CONFIG_DIR" /data/home
if [ ! -f "$CONFIG_PATH" ]; then
cat > "$CONFIG_PATH" <<EOF
[node]
listen_port = ${NVPN_LISTEN_PORT}
EOF
chmod 600 "$CONFIG_PATH"
echo "nostr-vpn: seeded $CONFIG_PATH with listen_port=${NVPN_LISTEN_PORT} (first boot)"
fi
exec "$@"
+1
View File
@@ -35,6 +35,7 @@ As of the current `1.8-alpha` workstream:
- Manifest-owned generated files exist through `app.files` and have been used for app config material (e.g. strfry, netbird config regeneration). - Manifest-owned generated files exist through `app.files` and have been used for app config material (e.g. strfry, netbird config regeneration).
- Local image builds are represented with `container.build`; pulled images are represented with `container.image`. - Local image builds are represented with `container.build`; pulled images are represented with `container.image`.
- Data ownership repair is represented with `container.data_uid`. - Data ownership repair is represented with `container.data_uid`.
- Per-app network-namespace kernel parameters are represented with `app.sysctls`, allow-listed to packet forwarding (added for rootless VPN exits such as nostr-vpn).
- Derived host facts and secret-file-backed environment variables are represented with `container.derived_env` and `container.secret_env`. - Derived host facts and secret-file-backed environment variables are represented with `container.derived_env` and `container.secret_env`.
- Catalog metadata generation is implemented by `scripts/generate-app-catalog.py`. - Catalog metadata generation is implemented by `scripts/generate-app-catalog.py`.
- App-session launch ports/titles and new-tab launch behavior now have a generated TypeScript metadata path from manifests, with manual overrides preserved for companion UIs and aliases that do not have manifest-owned metadata yet. - App-session launch ports/titles and new-tab launch behavior now have a generated TypeScript metadata path from manifests, with manual overrides preserved for companion UIs and aliases that do not have manifest-owned metadata yet.
+1
View File
@@ -124,6 +124,7 @@ app:
| `app.environment` | Static `KEY=value` environment entries | | `app.environment` | Static `KEY=value` environment entries |
| `app.health_check` | HTTP or TCP health check settings | | `app.health_check` | HTTP or TCP health check settings |
| `app.devices` | Explicit device paths | | `app.devices` | Explicit device paths |
| `app.sysctls` | Namespaced packet-forwarding sysctls for the app's own network namespace (allow-listed; not with host networking) |
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and [launch hints](#browser-iframe-and-companion-launch-modes) | | `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and [launch hints](#browser-iframe-and-companion-launch-modes) |
| `app.interfaces.main` | Optional primary UI launch surface with `port`, `protocol`, and `path` | | `app.interfaces.main` | Optional primary UI launch surface with `port`, `protocol`, and `path` |
+4
View File
@@ -74,6 +74,7 @@ because a wrong source produces a confident wrong verdict.
| `environment` | list of string | — | `- KEY=value` pairs (static). | | `environment` | list of string | — | `- KEY=value` pairs (static). |
| `health_check` | HealthCheck | — | `{ type, endpoint/path, interval, timeout, retries }`. `type` is free-form today; `http` is what the monitor exercises. | | `health_check` | HealthCheck | — | `{ type, endpoint/path, interval, timeout, retries }`. `type` is free-form today; `http` is what the monitor exercises. |
| `devices` | list of string | — | Host device paths; must start with `/dev/`. | | `devices` | list of string | — | Host device paths; must start with `/dev/`. |
| `sysctls` | map | — | Kernel parameters for the app's **own** network namespace (podman `--sysctl`, Quadlet `Sysctl=`). Allow-list: `net.ipv4.ip_forward`, `net.ipv6.conf.all.forwarding`; values `"0"`/`"1"`. Rejected under host networking. Needed by routing apps because rootless `/proc/sys` is read-only inside the container. |
| `interfaces` | map | — | Launch surfaces, keyed by name (`main`): `{ name, description, type, port, protocol, path }`. | | `interfaces` | map | — | Launch surfaces, keyed by name (`main`): `{ name, description, type, port, protocol, path }`. |
| `hooks` | LifecycleHooks | — | Allow-listed lifecycle hooks. See [Hooks](#hooks). | | `hooks` | LifecycleHooks | — | Allow-listed lifecycle hooks. See [Hooks](#hooks). |
| `upstream` | UpstreamSource | — | Where the app comes from, so release tooling can tell when the pin has fallen behind. See [Upstream tracking](#upstream-tracking). | | `upstream` | UpstreamSource | — | Where the app comes from, so release tooling can tell when the pin has fallen behind. See [Upstream tracking](#upstream-tracking). |
@@ -116,6 +117,9 @@ Validation (enforced at `AppManifest::validate()`):
FOWNER, NET_ADMIN, NET_BIND_SERVICE, NET_RAW, SETGID, SETUID, SYS_ADMIN). FOWNER, NET_ADMIN, NET_BIND_SERVICE, NET_RAW, SETGID, SETUID, SYS_ADMIN).
- `network_policy` must be exactly `isolated`, `bridge`, or `host`. - `network_policy` must be exactly `isolated`, `bridge`, or `host`.
- No `container:`/`ns:` network modes; devices must be `/dev/*`. - No `container:`/`ns:` network modes; devices must be `/dev/*`.
- `sysctls` keys must be on `ALLOWED_SYSCTLS` (packet forwarding only) and
need the app's own network namespace — never host networking, where they
would change the host.
- Bind-mount sources are confined to `/var/lib/archipelago` (reviewed - Bind-mount sources are confined to `/var/lib/archipelago` (reviewed
exceptions: the rootless podman socket and dbus). exceptions: the rootless podman socket and dbus).
- `derived_env` templates may only use the placeholder allow-list; - `derived_env` templates may only use the placeholder allow-list;
+18 -141
View File
@@ -1,15 +1,11 @@
# Next OTA and raw ISO after 1.8.21 # Next OTA and raw ISO after 1.8.21
**Status: COMPLETE — 1.8.22-alpha OTA, compatible signed app catalog and raw ISO published on Git and ngit on 2026-10-01; artifact signatures, public downloads and fleet feed verified. Angor full-chain indexing still awaits dev Bitcoin synchronization.** **Status: implementation and acceptance in progress; NOT ready to release.**
Current acceptance evidence: [1.8.22 release acceptance](release-1.8.22-acceptance.md).
The chronological notes below retain earlier failures and superseded candidates;
the final tested source is `6d5f3ffb`.
This is the consolidated execution checklist for the operator's chat requests. This is the consolidated execution checklist for the operator's chat requests.
Release acceptance and publication are complete, with live wallet and app data A targeted node repair is not completion of the release. Finish the remaining
preservation checks documented below. No universal absence of future failures acceptance gates, preserve live wallets and app data, and publish both artifacts
is claimed. through git and ngit. No universal absence of future failures is claimed.
## Changes already shipped in 1.8.21 or earlier ## Changes already shipped in 1.8.21 or earlier
@@ -35,12 +31,12 @@ See the Framework incident and 1.8.21 execution records for evidence/limits.
| Task | Implemented/verified | Remaining before release | | Task | Implemented/verified | Remaining before release |
| --- | --- | --- | | --- | --- | --- |
| X250 Bitcoin picker | Inline choices; actual Chromium kiosk selection, readability and pruning layout passed | Final UI/build checks passed | | X250 Bitcoin picker | Inline choices; actual Chromium kiosk selection, readability and pruning layout passed | Include in final UI/build checks |
| App disappearance/readiness | Durable inventory and safe lifecycle repair; delayed HTTP and desktop/mobile hard-refresh checks passed | Final candidate lifecycle, hard-refresh and stability checks passed | | App disappearance/readiness | Durable inventory and safe lifecycle repair; delayed HTTP and desktop/mobile hard-refresh checks passed | Final lifecycle/reboot gate on candidate |
| X250 GitWorkshop/Nginx | Missing build contexts restored, dependency/build checks and live UI passed; Nginx slow pull diagnosed; truthful progress label | OTA and ISO build-context/content checks passed | | X250 GitWorkshop/Nginx | Missing build contexts restored, dependency/build checks and live UI passed; Nginx slow pull diagnosed; truthful progress label | Verify both artifact payloads contain all build contexts |
| PRs 161/162 | Reviewed, repaired, merged/closed normally; combined regression suite passed | Funded Tor-only candidate purchase, retained change, refund, Files bytes and cached repeat passed; included in signed artifacts | | PRs 161/162 | Reviewed, repaired, merged/closed normally; combined regression suite passed | Funded Tor-only candidate purchase, retained change, refund, Files bytes and cached repeat passed; include in signed artifacts |
| Gitea/Portainer | Root cause confirmed; source network/backup/retry/catalog changes; real X250 routing repair and restart verified; private Git, SSH, LFS, registry and browser fixture checks passed | Automatic migration, scratch restore, failed-start recovery and reverse installation order passed. Operator confirms production site works through Portainer; production host reboot also preserved network/Git/Compose access; final candidate delivery and integration checks passed | | Gitea/Portainer | Root cause confirmed; source network/backup/retry/catalog changes; real X250 routing repair and restart verified; private Git, SSH, LFS, registry and browser fixture checks passed | Automatic migration, scratch restore, failed-start recovery and reverse installation order passed. Operator confirms production site works through Portainer; production host reboot also preserved network/Git/Compose access; final candidate delivery and release checks remain |
| Angor headless store service | Implemented standard Mempool adapter and separate optional relay, official logo, headless store entries and declarative dependency guard. API security/outage/DNS tests and five relay lifecycle cycles passed | Final candidate prerequisite/API, lifecycle and catalog checks passed; real indexing on dev waits for Bitcoin sync | | Angor headless store service | Implemented standard Mempool adapter and separate optional relay, official logo, headless store entries and declarative dependency guard. API security/outage/DNS tests and five relay lifecycle cycles passed | Final candidate prerequisite/install acceptance, management restart/reboot checks and signed catalog delivery; real indexing on dev waits for Bitcoin sync |
Durable payment receipts after a lost seller response remain a separately Durable payment receipts after a lost seller response remain a separately
recorded design follow-up. Preserve the truthful unconfirmed-refund warning and recorded design follow-up. Preserve the truthful unconfirmed-refund warning and
@@ -49,23 +45,21 @@ completed. See PR review for the accepted scope and coverage limits.
## Final release checklist ## Final release checklist
- [x] Finish new-scope implementation and release acceptance; full-chain Angor - [ ] Finish all new-scope implementation and specific acceptance above.
indexing still depends on the dev node finishing initial sync.
- [x] Remove disposable fixtures and temporary test overrides; verify native - [x] Remove disposable fixtures and temporary test overrides; verify native
Bitcoin/LND identity and start-state baselines remain protected. Bitcoin/LND identity and start-state baselines remain protected.
- [x] Commit and push completed source changes to git and ngit. - [x] Commit and push completed source changes to git and ngit.
- [x] Run final backend/UI/regression/release gates on the final source; inspect - [ ] Run final backend/UI/regression/release gates on the final source; inspect
skipped tests and report actual hardware/runtime coverage. skipped tests and report actual hardware/runtime coverage.
- [x] Prepare compatible signed app catalog; old runtimes must not apply a - [ ] Prepare compatible signed app catalog; old runtimes must not apply a
migration before they have backup/recovery support. migration before they have backup/recovery support.
- [x] Version/changelog and OTA payload prepared, validated and signed by user. - [ ] Version/changelog and OTA payload prepared, validated and signed by user.
- [x] Raw ISO built; payload hashes/content verified; full installation and - [ ] Raw ISO built; payload hashes/content verified; installer boot tested.
installed-system boot tested in QEMU/KVM without network. - [ ] User signs ISO checksums; publish OTA and ISO plus verification files on
- [x] User signs ISO checksums; publish OTA and ISO plus verification files on
git and ngit; independently read back hashes and update discovery. git and ngit; independently read back hashes and update discovery.
- [x] Provide LAN scp command for the new raw ISO. - [ ] Provide LAN scp command for the new raw ISO.
Latest backend source verification: 1,617 passed, zero failed, four existing Latest backend source verification: 1,609 passed, zero failed, four existing
ignored tests. This is one layer of evidence, not a substitute for live gates. ignored tests. This is one layer of evidence, not a substitute for live gates.
## Angor verification — 2026-09-30 ## Angor verification — 2026-09-30
@@ -340,120 +334,3 @@ repository branch and Compose content from its own network namespace.
Version preparation is 1.8.22-alpha. No new release tag or fleet-visible update Version preparation is 1.8.22-alpha. No new release tag or fleet-visible update
manifest is published by the version commit. Optimized candidate deployment, manifest is published by the version commit. Optimized candidate deployment,
artifact inspection, ISO smoke/boot checks and offline signatures follow. artifact inspection, ISO smoke/boot checks and offline signatures follow.
### Release blocker discovered during candidate observation: scheduled doctor
The initial `02b840f2` 1.8.22 candidate is rejected for release. On the X250,
2026-09-30 21:10–21:11 UTC, the scheduled `archipelago-doctor.service` explicitly
ran `podman stop --all --time 30`, killed rootless network helpers and ran
`podman system migrate` after a two-attempt external network probe failed.
The journal attributes the stop to that unit, not the app health monitor or a
host reboot. All apps restarted, including Bitcoin, LND and the production site.
The earlier unchanged-container acceptance applies only to immediate deployment;
the later observation failed and must not be represented as a stability pass.
No persistent-data loss has been established. Keep this distinct from the closed
Framework incident; do not wipe or recreate any wallet as a recovery action.
Containment: stopped doctor timers on both test boxes, installed a safe diagnostic
script into both the executable and runtime payload, and rejected/stopped the
old ISO build. Network failure now produces a warning without stopping apps,
killing network processes, migrating Podman or deleting network state. Repeated
failures remain warnings, never a successful repair/check. Regression cases cover
healthy, absent network, non-root invocation, host failure, transient recovery,
repeated endpoint failure and namespace access failure, with mutation tripwires.
Live scheduled-cycle observation and final rebuilt-artifact acceptance are pending.
Recovery also exposed retired `git.tx1138.com` nginx base references in six
companion UI Dockerfiles. They now use the existing primary registry at the same
pinned version. All six images built successfully against that registry; payload
validation rejects the retired host before OTA/ISO packaging.
The post-recovery X250 check passes: Bitcoin authenticated RPC responds and IBD
advances; NPM/Gitea/Portainer APIs respond; Portainer's real namespace fetches
`demo-portainer` at `3ae171d6b0c728665a860520fe393c0abb772798` and its Compose
file; Portainer's original persistent mounts match the earlier backup evidence;
LND wallet/channel databases remain present on their persistent mount. No new
pre-incident cryptographic wallet-identity baseline was available, so these checks
must not be described as an exact identity/balance comparison.
The dev all-container observation also caught a separate Cuprate UI orphan loop:
`companion.rs` removed it because Cuprate was not installed, while generic desired-
state recovery resurrected it from an old running snapshot, using a unit without
nginx's required capabilities. Generic desired-state recovery now excludes missing companions
owned by `companion.rs`; existing companion provisioning/reaping remains the
single owner. Running UIs still receive the existing security configuration repairs. Regression runs repeated reconciliation against stale companion
snapshots and checks that no image/container lifecycle operations occur.
Safe-doctor live acceptance: the X250 completed a 12-minute observation with all
running container IDs, start times and data mounts unchanged. Its journal records
successful doctor runs at 21:22:06, 21:27:51 and 21:33:10 UTC. Both doctor timers
are restored with the safe script. Dev's native Bitcoin/LND stayed running;
all-container dev acceptance remains pending the companion-loop backend fix.
Final-source UI suite: 1,133 passed. Heavy backend compilation is serialized with
remaining build steps to reduce memory/IO pressure on the syncing dev node.
Final source release gates at `96fb5a4f`: 1,613 backend tests passed, zero failed,
four explicitly ignored; 1,133 UI tests passed; type-check, production UI build,
catalog/trust, shell, pruning, LND readiness, NPM migration and doctor regressions
passed. The isolated companion-loop regression passed independently as well.
ISO cache hardening: the installer now carries the current doctor script and
service/timer separately from rootfs.tar and overwrites both historical and active
script locations before first boot. This prevents a cached base image restoring
the old recovery code. A regression executes the actual installer block against
stale disposable files twice and confirms a missing safety payload fails closed.
The mounted-ISO smoke test also compares all three overlay files to source.
The final ISO build captures the exact newly deployed OTA UI/runtime payload.
### Final kiosk acceptance found nondeterministic NPM launch selection
Do not publish the staged `d1bc1273` candidate. NPM itself remains healthy and its
API, Portainer integration, site, and native services passed stability checks.
However, final kiosk acceptance found its card stuck at "Web UI not ready".
The runtime reported bindings in proxy-HTTP, proxy-HTTPS, admin order. The scanner
chose the first non-database/SSH binding, then rejected its tunnel-only host port
as unreachable on loopback, leaving the launch address empty. Earlier tests had
passed with admin first. This is a confirmed order-dependent scanner defect.
The candidate fix explicitly resolves NPM container port 81 to its actual host
allocation. Proxy ports never become the admin URL. Missing/malformed admin
bindings do not fall back to another service when published bindings are present.
Port parsing handles IPv6 authorities and rejects invalid ports. Regressions
cover all six three-port permutations, allocated admin ports, IPv4/IPv6 binding
strings, missing admin mappings, missing runtime port information, and malformed
or UDP bindings. Full backend regression execution is pending for this change.
The ISO build is frozen at installer-environment creation; no release was signed
or published. Rebuild/revalidate the OTA and ISO with this correction.
The companion orphan fix worked live: Cuprate UI was automatically removed and
all installed app container IDs remained unchanged. One observation helper raced
that expected removal between `podman ps` and `inspect`; it now excludes that
known orphan before inspection and repeats the stability check. This was a test
snapshot race, not another installed-app restart.
NPM selector final backend gate passed: 1,617 tests, zero failures, four explicitly
ignored, through the isolated runner. This includes all new port-selection cases
and the existing companion security/configuration and lifecycle regressions.
Rebuild the release binary and UI metadata, deploy those exact OTA bytes to both
boxes, and require actual kiosk hard-refresh/Launch acceptance before ISO assembly.
## Final accepted artifacts — 1.8.22-alpha
Source `6d5f3ffb` passed 1,617 backend tests (four explicit opt-in exclusions),
1,133 frontend tests and final release gates. Exact OTA bytes were deployed to
both boxes. Actual X250 kiosk NPM Launch, version/pruning, desktop/mobile
readiness/AIUI, production Portainer Git/Compose and 12-minute stability checks
on both boxes passed. No installed app was restarted by the safe diagnostics,
and the Cuprate orphan stayed absent. Native Bitcoin/LND and the production site
were preserved during final management deployment.
The raw ISO passed mounted payload checks and matches all 653 OTA frontend/runtime
files plus the backend. Full offline installation and installed UEFI boot to the
visible setup screen passed in a disposable QEMU/KVM VM. Both installed doctor
paths and the installed backend have the expected hashes. No VM wallet was set up.
See `release-1.8.22-acceptance.md` for exact artifact hashes, hardware/runtime
coverage and limits. Draft upload verification, offline signatures, publication
and public readback remain; the fleet still advertises 1.8.21 until those gates
finish. Do not confuse a draft asset or source push with completed publication.
-258
View File
@@ -1,258 +0,0 @@
# Post-1.8.22 regressions and retained release checklist
Status: OPEN. New regressions reported after publication on 2026-10-01.
Do not mark complete from source changes alone. Preserve wallets, app state and
operator uninstall decisions. Never send a second payment to recover delivery.
The earlier Framework startup incident remains separately closed with operator
acceptance; this is a new paid-file incident.
## Current tasks
- [ ] Recover the Framework's Lightning paid-file purchase without another payment;
inspect buyer/seller evidence and verify delivered bytes.
- [ ] Correct seller settlement verification when local-node payment skips polling.
- [ ] Durable seller entitlements and safe buyer retry after navigation/restart;
do not issue another payment on an uncertain or successful attempt.
- [ ] Cache Lightning purchases, preserve ownership, optional Files copy, free repeat.
- [ ] Diagnose mobile companion uploads on the affected route/device.
- [ ] Real progress in the existing compact upload bar; no increased height.
- [ ] Preserve uploads/progress across screens and original batch destination.
- [ ] Cancel active transfer and queued files; truthful partial/error/server-save status.
- [ ] Transparent transaction-filter container; single horizontal scrolling mobile row.
- [ ] Immich displayed as one app, internal components hidden; diagnose restarting services.
- [ ] Diagnose unwanted CryptPad after upgrade, failed uninstall, and persistent removal.
- [ ] Identify the other removed unexpected service from affected-node records.
- [ ] Upgrade regression matrix: installed/stopped/restarting/removed/legacy apps,
aliases, dependencies, inventory, desired-state reconciliation and data preservation.
- [ ] Portainer duplicate-network migration: retire the redundant managed repair
override, preserve operator settings/state, verify generated command,
actual request namespace, dashboard readiness and repeated reconciliation.
- [ ] Lightning cooperative-close fees: Standard/Medium/Fast/Custom selection,
explicit default target, strict backend validation and forwarding, error
handling, mobile layout and no real channel closure during tests.
- [x] Apps search clear control: My Apps, Services and App Store, desktop/mobile,
existing design tokens, right-aligned icon, no size change, keyboard focus.
## Retained release work (previous acceptance is not new-regression acceptance)
- Mempool patched image/catalog version agreement, update-button clearing, one card.
- Minibits PR160, Lightning address availability, concise single-column backup copy.
- Framework LND startup/Receive and unknown-vs-zero balance behavior.
- Friendly Bitcoin warmup; LND waiting for install/sync; Bitcoin UI during IBD;
headless Phoenixd without self-waiting or bogus launch action.
- Cashu same-mint paid files, exact amounts/change/refund, errors, stored bytes,
Files copy and repeat access without re-payment.
- mempool.space public explorer fallback, preserving local/custom configuration.
- Optional install pruning and consistent automatic-pruning policy.
- X250 kiosk version picker layering/contrast and pruning layout.
- AIUI single desktop/mobile background, transparent embedded layers,
preserved standalone wallpaper.
- PR review/fixes/tests and normal merge/closure (160 previously shipped;
161/162 merged and included in 1.8.22).
- Installed inventory retained during app restart/hard-refresh.
- Correct iframe/browser launch readiness, useful errors and delayed startup.
- GitWorkshop payload/build contexts, progress and persistence after refresh.
- Gitea/Portainer same-server Git from actual request namespace; URLs, auth,
fresh installation in either order, migration/rollback, restart/reboot,
Git/SSH/LFS/registry/browser compatibility and data/stack preservation.
- NPM correct admin port/URL, malformed URL behavior, bind-aware readiness,
persistent backed-up tunnel/LND port-conflict repair on OTA and ISO.
- Angor headless indexer on DEV BOX only, full unpruned Bitcoin/Mempool/ElectrumX
prerequisites, optional separate relay, official icon with green white areas.
- Compact named readiness messages and bottom-aligned app-card actions.
- Remove unused integration/build fixtures from Apps/Services, preserve app data.
- Safe network doctor, no all-app stop/reset on failed egress probe.
- No orphan companion resurrection; retain existing companion security repairs.
- Current companion image registry, build contexts, runtime asset promotion order,
generated-service argument quoting and graceful Bitcoin/LND shutdown.
- OTA + RAW ISO, root signatures/catalog compatibility/checksums, independently
verified public files, Git/ngit source/releases and fleet discovery.
- Correct LAN SCP command for the new ISO.
## Explicit boundaries/follow-ups
- Full-chain Angor indexing awaits development Bitcoin IBD.
- Primal automatic comment exceeding Minibits metadata limit: previously accepted
upstream limitation, no unsupported local identity/metadata rewrite.
- Lost-response ecash seller receipt redesign is a separately accepted follow-up;
do not claim an uncertain refund completed or automatically pay twice.
- Optional external-provider/hardware tests must be labelled if not exercised.
## Initial source evidence
`PeerFiles.vue::payWithLightning` immediately downloaded after buyer payment,
while only seller `handle_content_invoice_status` marked a pending invoice paid.
Seller download checked only that cached flag. This matches the reported error
and is supported by source inspection. An earlier diagnostic's HTTP 404 is not
valid confirmation: it incorrectly base64-decoded lncli's already-hex payment
hash. The corrected live diagnostic recognizes the settled invoice on the
candidate; do not cite the earlier 404 as proof of the original failure sequence.
`content_invoice.rs` stored all entitlements only in process memory with a
one-hour TTL, losing both pending and paid access on restart/expiry.
Lightning download returned transient base64 without the Cashu ownership cache.
CloudFolder's view-local spinner had no byte progress/cancel; batch upload read
`currentPath` independently for each file, allowing navigation to move destinations.
Immich's underscore dependencies are scanner-excluded; hyphen manifest IDs are
not. Live inventory confirmed both hyphenated synthetic entries while the
actual underscore-named containers had remained running for nine days.
## Access / acceptance
Operator provided updated Framework SSH authentication privately in chat.
Do not put credentials or deployment addresses in this public document.
Framework was reached over SSH. Native Bitcoin, LND and all three Immich
container identities/start times were recorded before candidate deployment.
The kiosk is at its login page. Dashboard password authentication succeeds but
requires the operator's second factor; normal uninstall acceptance remains pending.
Confirmed live evidence:
- A 10,000-sat peer-file invoice settled at 12:19:29 UTC. The original status
diagnostic used an incorrectly decoded hash; see the correction above. Buyer
identity and confirmation that this is the reported sale remain pending.
- The matching item currently allows free access; preserve that operator setting.
- CryptPad has no container but remains in installed-apps metadata. Uninstall
repeatedly aborts because the removed catalog ID has no manifest.
- Immich server/database/cache are running; synthetic hyphenated dependencies
appear stopped and the recovery overlay briefly advertises restarting.
- The other removed service was Core Lightning; uninstall tombstones exist.
- No Android resource-upload POST appears in the inspected recent nginx log.
This does not establish why the affected companion failed.
## Candidate implementation and validation
Source changes persist seller entitlements with atomic writes, verify settlement
at delivery, recover older Lightning entitlements from the seller's LND invoice,
perform the status handshake for older sellers, and cache delivered Lightning
files. Buyer purchase bytes and the shared ownership index now use atomic,
synced writes and a serialized read/modify/write transaction; a corrupt index
fails the write instead of silently replacing existing ownership. The browser saves the invoice before payment and retries delivery without
another payment. Browser receipts are not yet a node-wide recovery store.
The upload queue now belongs to the shared Cloud store, captures its original
folder, reports actual sent bytes and server completion, and cancels its active
XHR and remaining queue. The fixed-height bar remains available across routes.
Transaction filters use a transparent container and one scrollable row. Immich
aliases normalize to their real component names and internal cards are hidden.
Unknown catalog entries no longer prevent the regular uninstall flow.
Validation so far (additional acceptance still pending):
- Final isolated backend suite: **1,631 passed**, zero failed, four optional
tests ignored. This includes invoice settlement/amount boundaries, durable
seller records, concurrent buyer ownership, damaged-index preservation,
Portainer override retirement/idempotence/customization/backup failures,
recovery overlays and channel-close fee forwarding/validation.
- Final frontend suite: **1,157 passed** across 142 files. Production build
passed. Six payment-recovery and twelve channel-close tests are included.
- Real FileBrowser uploads at 1440px and 390px: exact bytes and original folder
verified after navigation, 44px bar, cancellation and queue stop passed.
- Mobile viewport acceptance is not physical Android companion acceptance.
- Final release backend build passed. Candidate backend and dashboard are now
deployed on dev and Framework. Another OTA/ISO remains pending; published
1.8.22 artifacts remain unchanged.
Release gates still include actual-node payment recovery/delivery, durable
CryptPad removal through normal controls, Immich inventory after refresh/restart,
physical companion diagnosis, and remaining upgrade regression acceptance.
No new payments, native-service restarts or wallet changes were used in testing.
## Additional live Portainer regression
The X250 user service exited 125 because the generated command supplied
`--network slirp4netns` twice. The manifest already supplies the network, while
an older Archipelago-created `archy-same-node-network.conf` drop-in adds it
again. Quadlet's Network directives accumulate; they do not override each other.
This repair artifact should have been retired when the declarative fix shipped.
The live repair backed up the override and Portainer state, removed only the
exact redundant override, reloaded user systemd and restarted Portainer. API
status returned HTTP 200 with version 2.45.0; the actual kiosk's package state
reported running and UI-ready. Bitcoin/LND and the production site's container
identities/start times remained unchanged. The source migration now detects
this exact managed override before preparing the persistent restart obligation,
backs up app state, retires the redundant file with a retained copy, and reloads
and restarts through normal reconciliation. Custom overrides are preserved.
Automated migration coverage passed; candidate is now deployed on dev and
Framework. The X250 retains its verified live repair pending the next OTA.
## Channel-close fee selection
The existing close UI sent only the channel point, and the backend forwarded
only `force=false`. LND therefore used its lax default confirmation target.
The candidate reuses the channel-opening fee choices (six/three/one block target,
or custom target/rate), explicitly sends six blocks for legacy clients that omit
fees, and validates query parameters before accessing the wallet. Cooperative
fees are never silently applied to force closes. Close RPC retries are disabled
so a timeout cannot silently repeat this mutation.
Protocol reference: [LND CloseChannel](https://lightning.engineering/api-docs/api/lnd/lightning/close-channel/).
Fee targets are estimates, not guaranteed confirmation times. Tests use mocked
requests; no production channel is closed to verify the feature.
Additional browser acceptance:
- Transaction filters at 390px: computed transparent background, one row and
horizontal overflow verified.
- Close-channel selector at 1440px and 390px: preset/custom controls visible,
no overflow, custom 25 sat/vB forwarded. The close request was intercepted;
no real channel closure or wallet mutation occurred.
- All three Apps search screens at both widths: clear icon stays inside the
field; click/Escape clear; input retains focus; desktop 40px/mobile 52px heights
stay unchanged. Shared design-system search-field classes are retained.
- Portainer remained active with zero service restarts and no pending marker.
Its real network namespace read smart HTTP Git refs and the Compose file.
Original persistent mounts were unchanged. The old integration test containers
are absent from dev, Framework and X250. One leftover upload-test folder was
removed after checking it contained only this task's test files.
## Build resource observation
The final optimized compile coincided with heavy memory/disk pressure and local
Bitcoin/LND RPC timeouts on the development node. After pausing the compiler,
both authenticated RPCs responded again; Bitcoin reported height 506400 and
19.6% verification progress, with LND waiting for chain sync. No native service
was restarted. Compilation resumed in a separate user scope limited to one CPU,
with nice 19 and idle I/O priority. This is evidence of resource contention,
not proof of a new wallet or startup defect. Verify native RPC health again
before candidate deployment.
## Candidate deployment and live acceptance — 2026-10-01
Source: `f4d34554` (later commits update this checklist only).
Backend SHA-256:
`8fb6249d1869bb8c9aea26d0f846de5b3eec328113a5c7f573628306e26e652e`.
- Backed up backend, dashboard and app metadata on both nodes under the root-only
support directory `post1822-regressions-20261001`. Deployed assets before
promoting the dashboard entry point; manager health passed first.
- Only the Archipelago manager restarted. Bitcoin/LND IDs and start times stayed
unchanged; Framework's three Immich containers also stayed unchanged.
- Dev authenticated Bitcoin/LND RPCs responded after deployment. Bitcoin IBD
continued above height 513000; LND correctly reported not yet chain-synced.
- Both nodes serve dashboard entry bytes identical to the production build.
All six search-clear cases passed against the live dev dashboard (three
screens, desktop/mobile), including focus, Escape and unchanged field size.
- Framework's stale CryptPad installed claim was removed while the manager was
stopped; both legacy IDs were recorded as user-uninstalled. Data was preserved.
Removal remained after another management restart. Dashboard uninstall-flow
acceptance still awaits authentication; this repair was performed over SSH.
- Corrected invoice diagnostic recovered the settled seller entitlement, returned
HTTP 200 with `paid: true`, and saved a mode-0600 record. A different item was
rejected. Paid status survived another manager restart without native restarts
or a second payment.
- Delivery acceptance remains OPEN: the catalog's file is absent from both its
dedicated content path and FileBrowser path; a privileged filename search of
those trees found no copy. Its free-access setting was preserved. Buyer and
reported-purchase identity still need confirmation; do not claim the actual
buyer received the file.
- The malformed-hash diagnostic also exposed that invoice-status currently
propagates validation errors as a closed connection. Before release, return a
structured 400 for malformed hashes and an explicit retryable response for
settlement-service errors, with endpoint coverage.
Physical companion upload diagnosis and remaining release acceptance stay OPEN.
This is a candidate deployment, not a newly signed OTA or ISO.
+2
View File
@@ -43,6 +43,8 @@ PublishPort=<bind>:<host>:<container>/<proto>
Environment=<KEY>=<value> # non-secret env only Environment=<KEY>=<value> # non-secret env only
Secret=<secret_name>,type=env,target=<KEY> # secrets by REFERENCE, never value Secret=<secret_name>,type=env,target=<KEY> # secrets by REFERENCE, never value
Volume=<source>:<target><opts> Volume=<source>:<target><opts>
AddDevice=<path> # manifest devices
Sysctl=<key>=<value> # manifest sysctls (own netns, allow-listed)
ReadOnly=true # when security.readonly_root ReadOnly=true # when security.readonly_root
NoNewPrivileges=true # when security.no_new_privileges NoNewPrivileges=true # when security.no_new_privileges
HealthCmd=<cmd> # from the health_check block HealthCmd=<cmd> # from the health_check block
-101
View File
@@ -1,101 +0,0 @@
# Archipelago 1.8.22-alpha acceptance
Source: `6d5f3ffb850bfd3dcd396bac986ba770935d1daa`.
## Verified application and runtime changes
- Full isolated backend suite: 1,617 passed, zero failed, four explicit opt-in exclusions.
- Frontend suite: 1,133 passed. Final frontend and AIUI production builds succeeded.
- Container suite: 79 passed. Catalog compatibility/trust, release manifest, build contexts, pruning, Lightning readiness, NPM migration, safe doctor, companion recovery and ISO doctor-overlay regressions passed.
- Six companion dashboard images built using the current registry.
- Final OTA backend SHA-256: `e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b`.
- Final OTA frontend SHA-256: `2da485a2da75ff2fbe4aba52d6f217150e303be43a031723480c9c4ff9d43f41`.
## Live acceptance
The exact OTA bytes were deployed to the development box and ThinkPad X250.
Native Bitcoin/LND and the X250 production site retained their container identity
and start time during these final management deployments. Both boxes completed
12-minute observations including scheduled diagnostics with running containers
and persistent mounts unchanged. The orphaned Cuprate dashboard stayed absent.
Actual X250 Chromium kiosk: hard refresh, NPM Launch to the correct admin URL,
visible login/admin page, readable inline Bitcoin version choices and pruning
checkbox passed. No Bitcoin installation was triggered by this test.
Final desktop/mobile checks passed for Bitcoin's IBD dashboard, one Mempool card,
headless Phoenixd, LND waiting/unknown-balance behavior and all five transparent
AIUI embedding layers. Standalone AIUI retains its wallpaper.
Portainer's actual production network namespace fetched Git refs and the Compose
file after final deployment. Original mounts were preserved. Earlier disposable
fresh/reverse-install and migration/rollback tests, and the production host's
operator-initiated reboot check, passed.
Live Tor-only Cashu paid-file acceptance verified a one-satoshi net purchase,
change, rejected-payment refund, exact file bytes, Files access and free repeat
delivery. No native Bitcoin/LND funds were moved. PRs 161/162 are merged and
closed; the open pull-request list is empty.
## Boundaries
- Angor's real dev API, fees, block tip, CORS and rootless/headless configuration
passed. Full-chain indexing remains dependent on initial Bitcoin sync finishing.
- Optional live AI providers, physical RNode hardware, the opt-in Reticulum TCP
subprocess test and creation of a production Minibits profile were not run.
- The previously recorded unsafe-doctor incident changed X250 container start
times before the final fix. Persistent databases were present after recovery,
but no pre-incident cryptographic wallet-identity baseline was available.
Do not describe recovery evidence as an exact pre-incident balance comparison.
- No claim of perfect behavior on every device, network or future failure is made.
## Raw ISO acceptance
The raw ISO is 2,755,072,000 bytes. SHA-256:
`cf7be6378dcd52f6f62774523341fa75dd453fa73a9cadff5390846f483e0140`.
Mounted-artifact smoke checks passed, including BIOS/UEFI boot files, live-boot
hooks, build contexts, current doctor overlay, crash-capture configuration,
version and frontend payload. The ISO backend and all 653 OTA frontend/runtime
files match exactly. AIUI metadata names the tested source commit.
A disposable QEMU/KVM x86_64 VM with UEFI firmware, 3 GiB RAM, two vCPUs, a fresh
64 GiB NVMe virtual disk and no network completed the full installation. This
covered partitioning, LUKS2 data encryption, swap, system configuration, UEFI
bootloader and initramfs generation. Cold boot with the ISO detached reached the
visible Welcome to Archipelago setup screen. The installed backend and both
historical/current doctor paths matched source hashes. Backend/nginx were active;
health reported RPC/sessions ready, crash recovery complete and version 1.8.22.
No wallet was initialized in this disposable VM.
The first automatic VM reboot selected the still-attached installer ISO. That
was corrected in the test configuration by detaching the ISO and explicitly
booting NVMe. It was not accepted as an installed-system boot. The subsequent
cold boot above is the successful acceptance run.
The dev native Bitcoin/LND identity/start-time baseline also remained unchanged
after the ISO build and VM acceptance.
## Publication verification
All three operator signatures verify against the pinned release root. The five
Gitea assets match independent server-side SHA-256 checks. Both OTA components
also passed complete public HTTPS downloads with exact hashes and sizes; the
raw ISO passed public size/range checks and both checksum sidecars read back
exactly. Only after these checks were the signed OTA manifest and compatible
app catalog promoted. The release tag identifies the tested source above.
Git and ngit publication completed on 2026-10-01. Both repository relays
acknowledged the ngit release; independent `release view` resolved all five
assets with exact hashes and sizes and no unresolved asset IDs. Main and the
release tag were pushed to both remotes.
Public main-branch OTA manifests and the app catalog read back byte-for-byte
and verified cryptographically. Live `update.check` on the accepted dev node
reported 1.8.22-alpha with no further update, as expected for the installed
release. Discovery on an older production node was not repeated during this
publication step.
- [Release and verification files](https://source.archipelago-foundation.org/lfg2025/archy/releases/tag/v1.8.22-alpha)
- [Raw ISO](https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago-installer-1.8.22-alpha-unbundled-x86_64_RC1.iso)
@@ -2607,11 +2607,6 @@ if [ -f "$SCRIPT_DIR/../../scripts/image-versions.sh" ]; then
echo " ✅ Bundled image-versions.sh" echo " ✅ Bundled image-versions.sh"
fi fi
# Always overlay the current doctor, including when rootfs.tar is cached.
cp "$SCRIPT_DIR/../../scripts/container-doctor.sh" "$ARCH_DIR/scripts/"
cp "$SCRIPT_DIR/../configs/archipelago-doctor.service" "$ARCH_DIR/scripts/"
cp "$SCRIPT_DIR/../configs/archipelago-doctor.timer" "$ARCH_DIR/scripts/"
# Build-source apps need their complete contexts even on unbundled ISOs. # Build-source apps need their complete contexts even on unbundled ISOs.
# Keep this identical to the OTA runtime payload; a per-app allowlist silently # Keep this identical to the OTA runtime payload; a per-app allowlist silently
# omitted GitWorkshop, FIPS and Cuprate and made fresh installs fail at 70%. # omitted GitWorkshop, FIPS and Cuprate and made fresh installs fail at 70%.
@@ -3235,18 +3230,6 @@ for test_script in run-e2e-tests.sh run-post-install-tests.sh; do
fi fi
done done
# BEGIN DOCTOR OVERLAY
# Replace both the active and historical script locations before first boot.
# A cached rootfs can contain the unsafe network recovery implementation.
mkdir -p /mnt/target/opt/archipelago/scripts /mnt/target/home/archipelago/archy/scripts
for doctor_dir in /mnt/target/opt/archipelago/scripts /mnt/target/home/archipelago/archy/scripts; do
install -m 755 "$BOOT_MEDIA/archipelago/scripts/container-doctor.sh" "$doctor_dir/container-doctor.sh" || exit 1
done
for doctor_unit in archipelago-doctor.service archipelago-doctor.timer; do
install -m 644 "$BOOT_MEDIA/archipelago/scripts/$doctor_unit" "/mnt/target/etc/systemd/system/$doctor_unit" || exit 1
done
# END DOCTOR OVERLAY
# Copy self-update script # Copy self-update script
if [ -f "$BOOT_MEDIA/archipelago/scripts/self-update.sh" ]; then if [ -f "$BOOT_MEDIA/archipelago/scripts/self-update.sh" ]; then
cp "$BOOT_MEDIA/archipelago/scripts/self-update.sh" /mnt/target/opt/archipelago/scripts/ cp "$BOOT_MEDIA/archipelago/scripts/self-update.sh" /mnt/target/opt/archipelago/scripts/
-3
View File
@@ -19,8 +19,6 @@
<AppLauncherOverlay /> <AppLauncherOverlay />
<AppCredentialInterstitial /> <AppCredentialInterstitial />
<UploadProgress v-if="route.name !== 'cloud-folder'" floating />
<!-- Global toast notifications --> <!-- Global toast notifications -->
<ToastStack /> <ToastStack />
@@ -98,7 +96,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import UploadProgress from '@/components/cloud/UploadProgress.vue'
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue' import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import SplashScreen from './components/SplashScreen.vue' import SplashScreen from './components/SplashScreen.vue'
@@ -336,45 +336,3 @@ describe('sanitizePath', () => {
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg') expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
}) })
}) })
describe('upload transport progress and cancellation', () => {
class UploadXHR {
static instances: UploadXHR[] = []
upload = { onprogress: null as ((e: { loaded: number }) => void) | null }
onload: (() => void) | null = null
onabort: (() => void) | null = null
onerror: (() => void) | null = null
status = 200
withCredentials = false
contentType = 'application/json'
open = vi.fn(); setRequestHeader = vi.fn(); send = vi.fn()
abort = vi.fn(() => this.onabort?.())
getResponseHeader() { return this.contentType }
constructor() { UploadXHR.instances.push(this) }
}
beforeEach(() => { setAuthenticated(); UploadXHR.instances = []; vi.stubGlobal('XMLHttpRequest', UploadXHR) })
it('sends the file bytes, escapes folder names, and waits for server acceptance after 100%', async () => {
const controller = new AbortController(); const onProgress = vi.fn()
const file = new File(['data'], 'a #.txt')
let complete = false
const job = fileBrowserClient.upload('/folder #1', file, { signal: controller.signal, onProgress }).then(() => { complete = true })
await Promise.resolve(); await Promise.resolve()
const xhr = UploadXHR.instances[0]!
expect(xhr.open).toHaveBeenCalledWith('POST', expect.stringMatching(/\/app\/filebrowser\/api\/resources\/folder%20%231\/a%20%23.txt\?override=true$/))
expect(xhr.send).toHaveBeenCalledWith(file)
xhr.upload.onprogress?.({ loaded: 4 }); expect(onProgress).toHaveBeenCalledWith(4)
expect(complete).toBe(false)
xhr.onload?.(); await job; expect(complete).toBe(true)
})
it('cancels the actual request and refuses HTML masquerading as upload success', async () => {
const controller = new AbortController()
const job = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: controller.signal, onProgress: vi.fn() })
await Promise.resolve(); await Promise.resolve()
controller.abort(); await expect(job).rejects.toMatchObject({ name: 'AbortError' })
expect(UploadXHR.instances[0]!.abort).toHaveBeenCalled()
const next = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: new AbortController().signal, onProgress: vi.fn() })
await Promise.resolve(); await Promise.resolve()
const xhr = UploadXHR.instances[1]!; xhr.contentType = 'text/html'; xhr.onload?.()
await expect(next).rejects.toThrow('login page')
})
})
+1 -37
View File
@@ -201,43 +201,7 @@ class FileBrowserClient {
URL.revokeObjectURL(blobUrl) URL.revokeObjectURL(blobUrl)
} }
async upload(dirPath: string, file: File, options?: { signal: AbortSignal; onProgress: (sent: number) => void }): Promise<void> { async upload(dirPath: string, file: File): Promise<void> {
if (options) {
await this.ensureAuth()
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
const folder = sanitizePath(dirPath).split('/').map(encodeURIComponent).join('/').replace(/\/$/, '')
const url = `${this.baseUrl}/api/resources${folder}/${encodeURIComponent(file.name)}?override=true`
const send = () => new Promise<number>((resolve, reject) => {
const xhr = new XMLHttpRequest()
const cleanup = () => options.signal.removeEventListener('abort', abort)
const abort = () => { xhr.abort(); cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
xhr.open('POST', url)
xhr.withCredentials = true
for (const [key, value] of Object.entries(this.headers())) xhr.setRequestHeader(key, value)
xhr.upload.onprogress = (event) => options.onProgress(Math.min(file.size, event.loaded))
xhr.onerror = () => { cleanup(); reject(new Error('Upload connection lost. Keep the companion open and check the server connection.')) }
xhr.onabort = () => { cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
xhr.onload = () => {
cleanup()
if (xhr.status === 401) { resolve(401); return }
if (xhr.status < 200 || xhr.status >= 300) { reject(new Error(`Upload failed (HTTP ${xhr.status})`)); return }
if ((xhr.getResponseHeader('Content-Type') || '').includes('text/html')) {
reject(new Error('File Browser returned a login page instead of accepting the upload.')); return
}
resolve(xhr.status)
}
options.signal.addEventListener('abort', abort, { once: true })
if (options.signal.aborted) { abort(); return }
xhr.send(file)
})
if (await send() === 401) {
this._authenticated = false
await this.ensureAuth()
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
if (await send() === 401) throw new Error('Upload authentication expired. Sign in again.')
}
return
}
const sanitized = sanitizePath(dirPath) const sanitized = sanitizePath(dirPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/` const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const encodedName = encodeURIComponent(file.name) const encodedName = encodeURIComponent(file.name)
@@ -1,37 +0,0 @@
<template>
<div class="relative min-w-0 flex-1">
<input
ref="input"
v-model="query"
type="text"
:placeholder="placeholder"
:aria-label="label"
data-controller-no-submit
class="app-header-search w-full pr-10 text-white placeholder-white/50 focus:outline-none transition-colors"
@keydown.esc.prevent="clear"
/>
<button
v-if="query.length"
type="button"
aria-label="Clear search"
title="Clear search"
class="absolute right-1 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-400/60 transition-colors"
@click="clear"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6 6 12 12M6 18 18 6" />
</svg>
</button>
</div>
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue'
defineProps<{ placeholder: string; label: string }>()
const query = defineModel<string>({ default: '' })
const input = ref<HTMLInputElement | null>(null)
async function clear() {
query.value = ''
await nextTick()
input.value?.focus()
}
</script>
@@ -353,57 +353,15 @@
<!-- Close Confirmation Modal --> <!-- Close Confirmation Modal -->
<Teleport to="body"> <Teleport to="body">
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="!closingChannel && (closeTarget = null)"> <div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
<div class="glass-card p-6 w-full max-w-sm mx-4"> <div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2> <h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p> <p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
<!-- Fee selection -->
<div class="mb-4">
<label class="text-white/60 text-sm block mb-1">Fee</label>
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
<button
v-for="preset in feePresets"
:key="preset.key"
@click="closeForm.feePreset = preset.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="closeForm.feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ preset.label }}</button>
</div>
<p v-if="closeForm.feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
{{ feePresets.find(p => p.key === closeForm.feePreset)?.hint }}
</p>
<div v-else class="grid grid-cols-2 gap-3 mt-2">
<div>
<label class="text-white/60 text-xs block mb-1">Target confirmations</label>
<input
v-model.number="closeForm.customConfTarget"
type="number"
min="1"
max="1008"
placeholder="6"
class="w-full input-glass"
/>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
<input
v-model.number="closeForm.customSatPerVbyte"
type="number"
min="1"
max="5000"
placeholder="—"
class="w-full input-glass"
/>
</div>
<p class="text-white/40 text-xs col-span-2">Set one — sats per vByte takes precedence when both are set</p>
</div>
</div>
<div v-if="closeError" class="mb-3 alert-error"> <div v-if="closeError" class="mb-3 alert-error">
<p class="text-xs">{{ closeError }}</p> <p class="text-xs">{{ closeError }}</p>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<button @click="closeTarget = null" :disabled="closingChannel" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button> <button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button <button
@click="closeChannel" @click="closeChannel"
:disabled="closingChannel" :disabled="closingChannel"
@@ -499,8 +457,8 @@ function closeTypeLabel(ch: ClosedChannel): string {
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom' type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [ const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
{ key: 'standard', label: 'Standard', hint: 'Targets ~6 blocks (about an hour)', confTarget: 6 }, { key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
{ key: 'medium', label: 'Medium', hint: 'Targets ~3 blocks (about 30 minutes)', confTarget: 3 }, { key: 'medium', label: 'Medium', hint: 'Confirms within ~3 blocks (about 30 minutes)', confTarget: 3 },
{ key: 'fast', label: 'Fast', hint: 'Targets the next block', confTarget: 1 }, { key: 'fast', label: 'Fast', hint: 'Targets the next block', confTarget: 1 },
{ key: 'custom', label: 'Custom' }, { key: 'custom', label: 'Custom' },
] ]
@@ -619,24 +577,22 @@ function loadChannels(): Promise<void> {
return main return main
} }
function feeParams( function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
form: { feePreset: FeePreset; customSatPerVbyte: number | null; customConfTarget: number | null } = openForm.value, const form = openForm.value
setError: (message: string) => void = message => { openError.value = message },
): { target_conf?: number; sat_per_vbyte?: number } | null {
if (form.feePreset !== 'custom') { if (form.feePreset !== 'custom') {
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 } return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 }
} }
const rate = form.customSatPerVbyte const rate = form.customSatPerVbyte
const conf = form.customConfTarget const conf = form.customConfTarget
if (rate != null && rate !== 0) { if (rate != null && rate !== 0) {
if (!Number.isInteger(rate) || rate < 1 || rate > 5000) { setError('Sats per vByte must be a whole number between 1 and 5000'); return null } if (rate < 1 || rate > 5000) { openError.value = 'Sats per vByte must be between 1 and 5000'; return null }
return { sat_per_vbyte: Math.floor(rate) } return { sat_per_vbyte: Math.floor(rate) }
} }
if (conf != null && conf !== 0) { if (conf != null && conf !== 0) {
if (!Number.isInteger(conf) || conf < 1 || conf > 1008) { setError('Target confirmations must be a whole number between 1 and 1008'); return null } if (conf < 1 || conf > 1008) { openError.value = 'Target confirmations must be between 1 and 1008'; return null }
return { target_conf: Math.floor(conf) } return { target_conf: Math.floor(conf) }
} }
setError('Custom fee requires target confirmations or sats per vByte') openError.value = 'Custom fee requires target confirmations or sats per vByte'
return null return null
} }
@@ -673,12 +629,7 @@ async function openChannel() {
} }
} }
const defaultCloseForm = () => ({ feePreset: 'standard' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null })
const closeForm = ref(defaultCloseForm())
function confirmClose(ch: Channel) { function confirmClose(ch: Channel) {
if (closingChannel.value) return
closeForm.value = defaultCloseForm()
closeTarget.value = ch closeTarget.value = ch
closeError.value = null closeError.value = null
} }
@@ -686,15 +637,12 @@ function confirmClose(ch: Channel) {
async function closeChannel() { async function closeChannel() {
if (closingChannel.value || !closeTarget.value) return if (closingChannel.value || !closeTarget.value) return
closeError.value = null closeError.value = null
const fee = feeParams(closeForm.value, message => { closeError.value = message })
if (!fee) return
closingChannel.value = true closingChannel.value = true
try { try {
await rpcClient.call({ await rpcClient.call({
method: 'lnd.closechannel', method: 'lnd.closechannel',
params: { channel_point: closeTarget.value.channel_point, ...fee }, params: { channel_point: closeTarget.value.channel_point },
timeout: 45000, timeout: 30000,
maxRetries: 1,
}) })
closeTarget.value = null closeTarget.value = null
await loadChannels() await loadChannels()
@@ -9,11 +9,11 @@
<!-- Transparent glass, not a black slab (operator, 2026-08-09): the <!-- Transparent glass, not a black slab (operator, 2026-08-09): the
backdrop blur alone keeps the pinned tabs legible over scrolling backdrop blur alone keeps the pinned tabs legible over scrolling
rows without painting an opaque container onto the modal. --> rows without painting an opaque container onto the modal. -->
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-nowrap overflow-x-auto bg-transparent backdrop-blur-md"> <div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-wrap bg-white/5 backdrop-blur-md">
<button <button
v-for="f in filters" v-for="f in filters"
:key="f.key" :key="f.key"
class="shrink-0 whitespace-nowrap px-2.5 py-1 rounded-full text-xs transition-colors" class="px-2.5 py-1 rounded-full text-xs transition-colors"
:class="activeFilter === f.key :class="activeFilter === f.key
? 'bg-orange-500/25 text-orange-200 border border-orange-400/40' ? 'bg-orange-500/25 text-orange-200 border border-orange-400/40'
: 'bg-white/5 text-white/50 border border-white/10 hover:text-white/80'" : 'bg-white/5 text-white/50 border border-white/10 hover:text-white/80'"
@@ -1,53 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LightningChannelsPanel from '../LightningChannelsPanel.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('@/composables/useTxExplorer', () => ({ useTxExplorer: () => ({ openTx: vi.fn() }) }))
vi.mock('@/composables/useCachedResource', async () => {
const { ref } = await import('vue')
return { useCachedResource: () => ({ data: ref(null), loadState: ref('ready'), error: ref(null), refresh: vi.fn().mockResolvedValue(undefined) }) }
})
const channel = { chan_id: 'test', remote_pubkey: '02' + 'a'.repeat(64), channel_point: 'b'.repeat(64) + ':0', capacity: 100000, local_balance: 50000, remote_balance: 50000, active: true }
function open() {
const wrapper = mount(LightningChannelsPanel, { global: { stubs: { Teleport: true } } })
const vm = (wrapper.vm as any).$.setupState
vm.confirmClose(channel)
return { wrapper, vm }
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockResolvedValue({ success: true } as never) })
describe('channel closing fee choice', () => {
it.each([['standard', 6], ['medium', 3], ['fast', 1]])('forwards %s target and never automatically retries the mutation', async (preset, target) => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = preset
await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.closechannel', params: { channel_point: channel.channel_point, target_conf: target }, maxRetries: 1 }))
expect(vm.closeTarget).toBeNull(); wrapper.unmount()
})
it('sends the custom rate instead of a confirmation target', async () => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'; vm.closeForm.customSatPerVbyte = 25; vm.closeForm.customConfTarget = 3
await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, sat_per_vbyte: 25 } }))
wrapper.unmount()
})
it.each([0.5, -1, 5001, NaN, Infinity])('rejects invalid custom rate %s before RPC', async rate => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'; vm.closeForm.customSatPerVbyte = rate
await vm.closeChannel(); expect(rpcClient.call).not.toHaveBeenCalled(); expect(vm.closeError).toBeTruthy(); wrapper.unmount()
})
it('requires a custom value and accepts a custom confirmation target', async () => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'
await vm.closeChannel(); expect(rpcClient.call).not.toHaveBeenCalled()
vm.closeForm.customConfTarget = 2; await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 2 } })); wrapper.unmount()
})
it('keeps the chosen fee and error visible when LND rejects a close', async () => {
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Peer is offline'))
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'fast'
await vm.closeChannel(); expect(vm.closeTarget).not.toBeNull(); expect(vm.closeError).toBe('Peer is offline'); expect(vm.closeForm.feePreset).toBe('fast'); wrapper.unmount()
})
it('prevents duplicate submits while a close is pending', async () => {
let finish!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(() => new Promise(resolve => { finish = resolve }) as never)
const { wrapper, vm } = open(); const pending = vm.closeChannel(); await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledTimes(1); finish({ success: true }); await pending; await flushPromises(); wrapper.unmount()
})
})
@@ -45,7 +45,7 @@
</button> </button>
</div> </div>
<button class="glass-button cloud-toolbar-btn" title="Upload file" :disabled="uploading" @click="triggerUpload"> <button class="glass-button cloud-toolbar-btn" title="Upload file" @click="triggerUpload">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg> </svg>
@@ -61,7 +61,6 @@
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
:disabled="uploading"
class="hidden" class="hidden"
multiple multiple
@change="handleFileSelect" @change="handleFileSelect"
@@ -75,7 +74,6 @@ import { ref } from 'vue'
defineProps<{ defineProps<{
breadcrumbs: { name: string; path: string }[] breadcrumbs: { name: string; path: string }[]
viewMode: 'list' | 'grid' viewMode: 'list' | 'grid'
uploading?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -1,29 +0,0 @@
<template>
<div v-if="task" :class="floating ? 'fixed z-50 top-20 left-4 right-4 md:left-auto md:w-96' : 'mb-3 shrink-0'">
<div class="glass-card relative overflow-hidden h-11 px-3 flex items-center gap-2" role="status" aria-live="polite">
<div v-if="task.active" class="absolute inset-y-0 left-0 bg-emerald-400/10 transition-[width] duration-200 pointer-events-none" :style="{ width: `${percent}%` }" />
<div v-if="task.active" class="absolute bottom-0 left-0 h-0.5 bg-emerald-400 transition-[width] duration-200" :style="{ width: `${percent}%` }" role="progressbar" :aria-valuenow="percent" aria-valuemin="0" aria-valuemax="100" :aria-label="`Uploading ${task.filename}`" />
<span class="relative min-w-0 flex-1 text-sm truncate" :class="task.error ? 'text-red-300' : 'text-white/80'" :title="label">{{ label }}</span>
<span v-if="task.active" class="relative shrink-0 text-xs tabular-nums text-white/60">{{ percent }}%</span>
<button class="relative shrink-0 w-8 h-8 flex items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/10" :aria-label="task.active ? 'Cancel upload' : 'Dismiss upload'" @click="task.active ? store.cancelUpload() : store.dismissUpload()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-width="2" d="m6 6 12 12M6 18 18 6" /></svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCloudStore } from '@/stores/cloud'
defineProps<{ floating?: boolean }>()
const store = useCloudStore()
const task = computed(() => store.upload)
const percent = computed(() => !task.value ? 0 : task.value.total ? Math.min(100, Math.floor(task.value.sent * 100 / task.value.total)) : task.value.active ? 0 : 100)
const label = computed(() => {
const t = task.value
if (!t) return ''
if (t.error) return t.error
if (t.cancelled) return `Upload stopped · ${t.completed}/${t.count} saved`
if (!t.active) return `${t.count === 1 ? t.filename : `${t.count} files`} uploaded`
return `${percent.value === 100 ? 'Saving' : 'Uploading'} ${t.filename}${t.count > 1 ? ` · ${t.completed + 1}/${t.count}` : ''}`
})
</script>
@@ -231,50 +231,3 @@ describe('useCloudStore', () => {
expect(store.error).toBeNull() expect(store.error).toBeNull()
}) })
}) })
describe('persistent upload batch', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('retains the destination and byte progress across folder navigation', async () => {
const store = useCloudStore(); store.authenticated = true; store.currentPath = '/original'
let release!: () => void
mockedClient.upload.mockImplementationOnce(async (_path, _file, options) => {
options!.onProgress(2)
await new Promise<void>(resolve => { release = resolve })
}).mockResolvedValueOnce(undefined)
mockedClient.listDirectory.mockResolvedValue([])
const job = store.uploadFiles([new File(['abcd'], 'one'), new File(['ef'], 'two')])
expect(store.upload?.sent).toBe(2)
expect(store.upload?.total).toBe(6)
await store.navigate('/elsewhere')
expect(useCloudStore().upload?.active).toBe(true)
release(); await job
expect(mockedClient.upload.mock.calls.map(call => call[0])).toEqual(['/original', '/original'])
expect(store.currentPath).toBe('/elsewhere')
expect(store.upload).toMatchObject({ active: false, sent: 6, completed: 2, error: null })
})
it('aborts the active request, stops the queue and preserves completed files', async () => {
const store = useCloudStore(); store.authenticated = true
mockedClient.listDirectory.mockResolvedValue([])
mockedClient.upload.mockResolvedValueOnce(undefined).mockImplementationOnce((_path, _file, options) => new Promise((_resolve, reject) => {
options!.signal.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')))
}))
const files = ['one', 'two', 'three'].map(name => new File(['abc'], name))
const job = store.uploadFiles(files)
await Promise.resolve(); await Promise.resolve()
store.cancelUpload(); await job
expect(mockedClient.upload).toHaveBeenCalledTimes(2)
expect(store.upload).toMatchObject({ active: false, completed: 1, cancelled: true, error: null })
store.dismissUpload(); expect(store.upload).toBeNull()
})
it('keeps failures visible and does not start a second overlapping batch', async () => {
const store = useCloudStore(); store.authenticated = true
mockedClient.listDirectory.mockResolvedValue([])
let fail!: (error: Error) => void
mockedClient.upload.mockImplementationOnce(() => new Promise((_resolve, reject) => { fail = reject }))
const file = new File(['x'], 'one')
const job = store.uploadFiles([file]); await store.uploadFiles([file])
expect(mockedClient.upload).toHaveBeenCalledTimes(1)
fail(new Error('No space')); await job
expect(store.upload).toMatchObject({ active: false, error: 'No space', completed: 0 })
})
})
-36
View File
@@ -8,41 +8,6 @@ export const useCloudStore = defineStore('cloud', () => {
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const authenticated = ref(false) const authenticated = ref(false)
const upload = ref<{ active: boolean; filename: string; destination: string; sent: number; total: number; completed: number; count: number; error: string | null; cancelled: boolean } | null>(null)
let uploadController: AbortController | null = null
function cancelUpload() { uploadController?.abort() }
function dismissUpload() { if (!upload.value?.active) upload.value = null }
async function uploadFiles(files: File[]) {
if (!files.length || upload.value?.active) return
const destination = currentPath.value
const controller = new AbortController()
uploadController = controller
const task = { active: true, filename: files[0]!.name, destination, sent: 0, total: files.reduce((n, f) => n + f.size, 0), completed: 0, count: files.length, error: null as string | null, cancelled: false }
upload.value = task
let completedBytes = 0
try {
for (const file of files) {
if (controller.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
upload.value.filename = file.name
await fileBrowserClient.upload(destination, file, { signal: controller.signal, onProgress: (sent) => {
if (upload.value?.active) upload.value.sent = completedBytes + sent
} })
completedBytes += file.size
upload.value.sent = completedBytes
upload.value.completed++
}
} catch (error) {
upload.value.cancelled = controller.signal.aborted
if (!upload.value.cancelled) upload.value.error = error instanceof Error ? error.message : 'Upload failed'
} finally {
upload.value.active = false
uploadController = null
// Navigation must never redirect subsequent files into the new folder.
pathCache.delete(destination)
if (currentPath.value === destination) await refresh()
}
}
// Per-path listing cache: re-entering a folder paints the last listing // Per-path listing cache: re-entering a folder paints the last listing
// immediately (no spinner) while the fresh listing loads behind it. // immediately (no spinner) while the fresh listing loads behind it.
const pathCache = new Map<string, FileBrowserItem[]>() const pathCache = new Map<string, FileBrowserItem[]>()
@@ -166,7 +131,6 @@ export const useCloudStore = defineStore('cloud', () => {
} }
return { return {
upload, uploadFiles, cancelUpload, dismissUpload,
currentPath, currentPath,
items, items,
loading, loading,
+16 -3
View File
@@ -56,7 +56,14 @@
</button> </button>
</div> </div>
<div class="app-header-search-wrap flex items-center gap-2"> <div class="app-header-search-wrap flex items-center gap-2">
<AppSearchField v-model="searchQuery" :placeholder="t('apps.searchPlaceholder')" :label="t('apps.searchLabel')" /> <input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
data-controller-no-submit
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
/>
<button <button
type="button" type="button"
class="sideload-icon-btn" class="sideload-icon-btn"
@@ -99,7 +106,14 @@
>{{ category.name }}</button> >{{ category.name }}</button>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<AppSearchField v-model="searchQuery" :placeholder="t('apps.searchPlaceholder')" :label="t('apps.searchLabel')" /> <input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
data-controller-no-submit
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
/>
<button <button
type="button" type="button"
class="sideload-icon-btn sideload-icon-btn-mobile" class="sideload-icon-btn sideload-icon-btn-mobile"
@@ -360,7 +374,6 @@ let appsAnimationDone = false
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import AppSearchField from '@/components/AppSearchField.vue'
import { computed, ref, watch, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue' import { computed, ref, watch, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router' import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
+22 -6
View File
@@ -39,8 +39,6 @@
</div> </div>
</div> </div>
<UploadProgress />
<!-- App Not Installed --> <!-- App Not Installed -->
<div v-if="!appRunning" class="glass-card p-12 text-center flex-1 flex flex-col items-center justify-center"> <div v-if="!appRunning" class="glass-card p-12 text-center flex-1 flex flex-col items-center justify-center">
<svg class="w-20 h-20 text-white/15 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-20 h-20 text-white/15 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -77,11 +75,19 @@
<p class="text-sm text-white/50">Files will be added to the current folder</p> <p class="text-sm text-white/50">Files will be added to the current folder</p>
</div> </div>
</div> </div>
<!-- Upload progress -->
<div v-if="uploading" class="glass-card p-3 mb-3 flex items-center gap-3">
<div class="w-5 h-5 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
<span class="text-sm text-white/70">Uploading...</span>
</div>
<div v-if="uploadError" class="glass-card p-3 mb-3 flex items-center gap-3 border border-red-500/30">
<span class="text-sm text-red-400">{{ uploadError }}</span>
<button class="text-xs text-white/50 hover:text-white ml-auto" @click="uploadError = null">Dismiss</button>
</div>
<CloudToolbar <CloudToolbar
:breadcrumbs="cloudStore.breadcrumbs" :breadcrumbs="cloudStore.breadcrumbs"
:view-mode="viewMode" :view-mode="viewMode"
:uploading="!!cloudStore.upload?.active"
@navigate="navigateCloudPath" @navigate="navigateCloudPath"
@refresh="cloudStore.refresh()" @refresh="cloudStore.refresh()"
@upload="handleUpload" @upload="handleUpload"
@@ -99,7 +105,6 @@
:items="cloudStore.sortedItems" :items="cloudStore.sortedItems"
:loading="cloudStore.loading" :loading="cloudStore.loading"
:view-mode="viewMode" :view-mode="viewMode"
:uploading="!!cloudStore.upload?.active"
@navigate="navigateCloudPath" @navigate="navigateCloudPath"
@delete="handleDelete" @delete="handleDelete"
@play="handlePlay" @play="handlePlay"
@@ -154,7 +159,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import UploadProgress from '@/components/cloud/UploadProgress.vue'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router' import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app' import { useAppStore } from '../stores/app'
@@ -189,6 +193,7 @@ watch(() => cloudStore.currentPath, (path) => {
}) })
const iframeLoaded = ref(false) const iframeLoaded = ref(false)
const uploading = ref(false)
const folderId = computed(() => route.params.folderId as string) const folderId = computed(() => route.params.folderId as string)
const routeFolderPath = computed(() => normalizeCloudPath(route.query.path, section.value?.initialPath || '/')) const routeFolderPath = computed(() => normalizeCloudPath(route.query.path, section.value?.initialPath || '/'))
@@ -343,6 +348,7 @@ function handleShare(path: string, name: string, isDir: boolean) {
shareTarget.value = { path, name, isDir } shareTarget.value = { path, name, isDir }
} }
const uploadError = ref<string | null>(null)
const draggingOver = ref(false) const draggingOver = ref(false)
let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null
@@ -366,7 +372,17 @@ function onDrop(e: DragEvent) {
} }
async function handleUpload(files: File[]) { async function handleUpload(files: File[]) {
await cloudStore.uploadFiles(files) uploading.value = true
uploadError.value = null
try {
for (const file of files) {
await cloudStore.uploadFile(file)
}
} catch (e) {
uploadError.value = e instanceof Error ? e.message : 'Upload failed'
} finally {
uploading.value = false
}
} }
async function handleDelete(path: string) { async function handleDelete(path: string) {
+16 -3
View File
@@ -49,7 +49,14 @@
{{ section.name }} {{ section.name }}
</button> </button>
</div> </div>
<AppSearchField v-model="searchQuery" placeholder="Search apps..." label="Search apps" /> <input
v-model="searchQuery"
type="text"
placeholder="Search apps..."
aria-label="Search apps"
data-controller-no-submit
class="app-header-search text-white placeholder-white/50 focus:outline-none transition-colors"
/>
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" /> <RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
</div> </div>
@@ -75,7 +82,14 @@
type="button" type="button"
>{{ section.name }}</button> >{{ section.name }}</button>
</div> </div>
<AppSearchField v-model="searchQuery" placeholder="Search apps..." label="Search apps" /> <input
v-model="searchQuery"
type="text"
placeholder="Search apps..."
aria-label="Search apps"
data-controller-no-submit
class="app-header-search w-full text-white placeholder-white/50 focus:outline-none transition-colors"
/>
</div> </div>
</div> </div>
@@ -331,7 +345,6 @@ let discoverAnimationDone = false
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import AppSearchField from '@/components/AppSearchField.vue'
import { ref, computed, onBeforeUnmount, onMounted } from 'vue' import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router' import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
+52 -76
View File
@@ -396,7 +396,7 @@
accepts for this item are offered --> accepts for this item are offered -->
<div v-if="payMode === 'choose'" class="space-y-3"> <div v-if="payMode === 'choose'" class="space-y-3">
<button <button
v-if="!lnReceipt && (acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint'))" v-if="acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left" class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="ecashPreparing || downloading === payItem.id" :disabled="ecashPreparing || downloading === payItem.id"
@click="prepareEcashPay" @click="prepareEcashPay"
@@ -420,8 +420,8 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg> </svg>
<span> <span>
<span class="block text-base text-white">{{ lnPaying ? (lnReceipt ? 'Checking payment…' : 'Paying…') : (lnReceipt ? 'Retry paid download' : 'Pay with my Lightning node') }}</span> <span class="block text-base text-white">{{ lnPaying ? 'Paying…' : 'Pay with my Lightning node' }}</span>
<span class="block text-sm text-white/50">{{ lnReceipt ? 'Uses the saved payment; does not send more sats' : 'Pays the seller’s invoice from your node’s Lightning wallet' }}</span> <span class="block text-sm text-white/50">Pays the seller’s invoice from your node’s Lightning wallet</span>
</span> </span>
</button> </button>
@@ -843,22 +843,6 @@ const onchainError = ref('')
const onchainCopied = ref(false) const onchainCopied = ref(false)
const lnPaying = ref(false) const lnPaying = ref(false)
const lnError = ref('') const lnError = ref('')
type LightningReceipt = { bolt11: string; payment_hash: string; price_sats: number }
const lnReceipt = ref<LightningReceipt | null>(null)
function receiptKey(onion: string, id: string) { return `peer-file-lightning:${onion}:${id}` }
function readReceipt(onion: string, id: string): LightningReceipt | null {
const raw = localStorage.getItem(receiptKey(onion, id))
if (!raw) return null
const receipt = JSON.parse(raw) as LightningReceipt
if (!receipt.bolt11 || !/^[a-f0-9]{64}$/i.test(receipt.payment_hash)) throw new Error('Saved payment needs recovery. Do not pay again.')
return receipt
}
function keepReceipt(onion: string, id: string, receipt: LightningReceipt) {
// Must succeed before handing an invoice to a payer. A failed transfer or
// navigation must never turn Retry into a second payment.
localStorage.setItem(receiptKey(onion, id), JSON.stringify(receipt))
lnReceipt.value = receipt
}
const onchainPaying = ref(false) const onchainPaying = ref(false)
let onchainPollTimer: ReturnType<typeof setTimeout> | null = null let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
@@ -1111,8 +1095,6 @@ function openPayModal(item: CatalogItem) {
onchainCopied.value = false onchainCopied.value = false
lnPaying.value = false lnPaying.value = false
lnError.value = '' lnError.value = ''
try { lnReceipt.value = readReceipt(props.peerId || currentPeer.value?.onion || '', item.id) }
catch { lnError.value = 'Saved payment could not be read. Do not pay again.' }
onchainPaying.value = false onchainPaying.value = false
} }
@@ -1134,8 +1116,6 @@ function closePayModal() {
* immediately for any external wallet). * immediately for any external wallet).
*/ */
function openQrPay() { function openQrPay() {
payMode.value = 'qr'
if (lnReceipt.value) { qrTab.value = 'lightning'; void payWithInvoice(); return }
payMode.value = 'qr' payMode.value = 'qr'
invoiceData.value = null invoiceData.value = null
invoiceQr.value = '' invoiceQr.value = ''
@@ -1159,10 +1139,6 @@ function openQrPay() {
* forth doesn't silently stop watching for payment). */ * forth doesn't silently stop watching for payment). */
function selectQrTab(tab: 'onchain' | 'lightning') { function selectQrTab(tab: 'onchain' | 'lightning') {
if (qrTab.value === tab) return if (qrTab.value === tab) return
if (tab === 'onchain' && lnReceipt.value) {
invoiceError.value = 'A Lightning payment is saved. Recover it before choosing another payment method.'
return
}
qrTab.value = tab qrTab.value = tab
if (tab === 'onchain') { if (tab === 'onchain') {
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null } if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
@@ -1362,27 +1338,20 @@ async function prepareEcashPay() {
* mobile companion ("paid but never unlocked"); the viewer's Save button * mobile companion ("paid but never unlocked"); the viewer's Save button
* still offers an explicit download. * still offers an explicit download.
*/ */
function openPurchased(item: CatalogItem, base64Data: string | undefined, mimeType?: string, seller?: string) { function openPurchased(item: CatalogItem, base64Data: string, mimeType?: string) {
const onion = seller || props.peerId || currentPeer.value?.onion const onion = props.peerId || currentPeer.value?.onion
const url = base64Data !== undefined
? URL.createObjectURL(base64ToBlob(base64Data, mimeType || item.mime_type))
: `/api/peer-content/${encodeURIComponent(onion || "")}/${encodeURIComponent(item.id)}`
if (onion) {
try { localStorage.removeItem(receiptKey(onion, item.id)) } catch { /* owned cache remains authoritative */ }
lnReceipt.value = null
}
if (onion) ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id)) if (onion) ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
const mime = mimeType || item.mime_type const mime = mimeType || item.mime_type
if (mime.startsWith('audio/')) { if (mime.startsWith('audio/')) {
// Straight to the bottom-bar player — the blob URL intentionally stays // Straight to the bottom-bar player — the blob URL intentionally stays
// alive while the bar owns playback. // alive while the bar owns playback.
audioPlayer.play( audioPlayer.play(
url, URL.createObjectURL(base64ToBlob(base64Data, mime)),
item.filename.split('/').pop() || item.filename, item.filename.split('/').pop() || item.filename,
) )
} else { } else {
releaseViewerUrl() releaseViewerUrl()
viewerUrl.value = url viewerUrl.value = URL.createObjectURL(base64ToBlob(base64Data, mime))
viewerMime.value = mime viewerMime.value = mime
viewerItem.value = item viewerItem.value = item
} }
@@ -1430,7 +1399,7 @@ async function payWithInvoice() {
invoiceError.value = '' invoiceError.value = ''
invoiceWaiting.value = true invoiceWaiting.value = true
try { try {
const res = readReceipt(onion, item.id) as (LightningReceipt & { error?: string }) | null || await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({ const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
method: 'content.request-invoice', method: 'content.request-invoice',
params: { onion, content_id: item.id }, params: { onion, content_id: item.id },
timeout: 45000, timeout: 45000,
@@ -1441,7 +1410,6 @@ async function payWithInvoice() {
return return
} }
invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) } invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) }
keepReceipt(onion, item.id, invoiceData.value)
try { try {
invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 }) invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 })
} catch { } catch {
@@ -1456,46 +1424,54 @@ async function payWithInvoice() {
/** /**
* Pay the seller's invoice straight from THIS node's Lightning wallet, then * Pay the seller's invoice straight from THIS node's Lightning wallet, then
* release the file. Keep the invoice before payment so uncertain outcomes can * release the file. payLightningInvoice resolves to a real terminal state, so
* retry seller verification and delivery without sending a second payment. * on success the payment_hash is immediately valid as the download gate token.
*/ */
async function payWithLightning() { async function payWithLightning() {
const item = payItem.value const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion || lnPaying.value) return if (!item || !onion || lnPaying.value) return
lnPaying.value = true lnPaying.value = true
lnError.value = '' lnError.value = ''
try { try {
let inv = readReceipt(onion, item.id) // 1. Ask the seller to mint a bolt11 (also records the pending entitlement).
if (!inv) { const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
const result = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({ method: 'content.request-invoice',
method: 'content.request-invoice', params: { onion, content_id: item.id }, timeout: 45000, params: { onion, content_id: item.id },
}) timeout: 45000,
if (!result?.bolt11 || !result.payment_hash) throw new Error(result?.error || 'The seller could not create an invoice.')
inv = { bolt11: result.bolt11, payment_hash: result.payment_hash, price_sats: getItemPrice(item.access) }
keepReceipt(onion, item.id, inv)
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
if (pay.status === 'failed') {
localStorage.removeItem(receiptKey(onion, item.id)); lnReceipt.value = null
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
return
}
if (pay.status === 'pending') {
lnError.value = 'Payment is still settling. Retry checks this payment without sending more sats.'
return
}
}
lnReceipt.value = inv
const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({
method: 'content.download-peer-invoice',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
timeout: 960000,
}) })
if (dl?.data !== undefined || dl?.owned === true) openPurchased(item, dl.data, dl.mime_type, onion) if (!inv?.bolt11 || !inv?.payment_hash) {
else lnError.value = dl?.error || 'Download unavailable. Retry uses this payment without sending more sats.' lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'
return
}
// 2. Pay it from our own node. Tracked to a REAL terminal state — a slow
// multi-hop route resolves via status polling instead of a false failure.
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
if (pay.status === 'failed') {
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
return
}
if (pay.status === 'pending') {
lnError.value = 'Payment is still settling — this can take a few minutes. Check your wallet transactions before paying again.'
return
}
// 3. Settled — pull the file using the payment hash as the gate token.
const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
method: 'content.download-peer-invoice',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
timeout: 120000,
})
if (dl?.data) {
openPurchased(item, dl.data, dl.mime_type)
} else {
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
}
} catch (e: unknown) { } catch (e: unknown) {
lnError.value = (e instanceof Error ? e.message : 'Payment or download could not be confirmed') + ' Retry checks the saved payment; do not pay again.' lnError.value = e instanceof Error ? e.message : 'Could not pay from your Lightning node'
} finally { lnPaying.value = false } } finally {
lnPaying.value = false
}
} }
function scheduleInvoicePoll() { function scheduleInvoicePoll() {
@@ -1511,19 +1487,19 @@ async function pollInvoice() {
try { try {
const res = await rpcClient.call<{ paid?: boolean }>({ const res = await rpcClient.call<{ paid?: boolean }>({
method: 'content.invoice-status', method: 'content.invoice-status',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true }, params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
timeout: 30000, timeout: 30000,
}) })
if (res?.paid) { if (res?.paid) {
// Settled — pull the file using the payment hash as the gate token. // Settled — pull the file using the payment hash as the gate token.
invoiceWaiting.value = false invoiceWaiting.value = false
const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({ const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
method: 'content.download-peer-invoice', method: 'content.download-peer-invoice',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true }, params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
timeout: 960000, timeout: 120000,
}) })
if (dl?.data !== undefined || dl?.owned === true) { if (dl?.data) {
openPurchased(item, dl.data, dl.mime_type, onion) openPurchased(item, dl.data, dl.mime_type)
} else { } else {
invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.' invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
} }
@@ -1,92 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import PeerFiles from '../PeerFiles.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: vi.fn() } }))
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) }))
const hash = 'a'.repeat(64)
const item = { id: 'paid-file', filename: 'bought.txt', mime_type: 'text/plain', size_bytes: 4, description: '', access: { paid: { price_sats: 5, accepted: ['lightning'] } } }
const receiptKey = 'peer-file-lightning:peer.onion:paid-file'
const download = vi.fn()
async function open() {
const wrapper = mount(PeerFiles, { props: { peerId: 'peer.onion' }, global: { plugins: [createPinia()], stubs: { Teleport: true } } })
await flushPromises()
// Drive the actual component payment handlers, asserting RPC effects rather
// than a duplicate implementation of the payment state machine.
const vm = (wrapper.vm as any).$.setupState
vm.openPayModal(item)
return { wrapper, vm }
}
beforeEach(() => {
localStorage.clear(); vi.clearAllMocks()
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [] } as never)
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.request-invoice') return { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
if (method === 'content.download-peer-invoice') return download()
return { items: [] }
})
vi.mocked(rpcClient.payLightningInvoice).mockResolvedValue({ status: 'succeeded' } as never)
})
describe('Lightning file delivery recovery', () => {
it('retries delivery after a seller rejection without paying or requesting another invoice', async () => {
download.mockResolvedValue({ error: 'Seller has not registered this payment yet' })
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash })
vm.closePayModal(); vm.openPayModal(item)
await vm.payWithLightning()
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(1)
expect(download).toHaveBeenCalledTimes(2)
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-invoice')![0].params).toMatchObject({ payment_hash: hash, filename: 'bought.txt', price_sats: 5 })
wrapper.unmount()
})
it('restores an uncertain payment on a newly mounted page and only checks/downloads', async () => {
vi.mocked(rpcClient.payLightningInvoice).mockRejectedValue(new Error('Connection lost'))
download.mockResolvedValue({ error: 'Pending' })
const first = await open(); await first.vm.payWithLightning(); first.wrapper.unmount()
const second = await open(); await second.vm.payWithLightning()
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
expect(download).toHaveBeenCalledTimes(1)
second.wrapper.unmount()
})
it('opens cached delivery through HTTP without a base64 file in the response', async () => {
download.mockResolvedValue({ owned: true, mime_type: 'video/mp4', size_bytes: 206165161 })
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
expect(vm.viewerMime).toBe('video/mp4')
expect(localStorage.getItem(receiptKey)).toBeNull()
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-invoice')![0].params).toMatchObject({ cache_only: true })
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('never pays again when the saved receipt is corrupt', async () => {
localStorage.setItem(receiptKey, '{broken')
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(0)
wrapper.unmount()
})
it('keeps QR recovery on the saved Lightning payment instead of creating another rail', async () => {
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }))
const { wrapper, vm } = await open()
vm.openQrPay(); await flushPromises()
expect(vm.payMode).toBe('qr')
expect(vm.qrTab).toBe('lightning')
vm.selectQrTab('onchain'); await flushPromises()
expect(vm.qrTab).toBe('lightning')
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => ['content.request-invoice', 'content.request-onchain'].includes(v.method))).toHaveLength(0)
wrapper.unmount()
})
it('does not send payment if the recovery record cannot be saved', async () => {
const { wrapper, vm } = await open()
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage full') })
await vm.payWithLightning()
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
save.mockRestore(); wrapper.unmount()
})
})
@@ -246,9 +246,3 @@ it('does not display a stale Mempool frontend alias beside its live package', ()
expect(shown.map(([id]) => id)).toEqual(['mempool']) expect(shown.map(([id]) => id)).toEqual(['mempool'])
expect(filterEntriesForTab([['mempool-web', alias]], 'apps', 'all').map(([id]) => id)).toEqual(['mempool-web']) expect(filterEntriesForTab([['mempool-web', alias]], 'apps', 'all').map(([id]) => id)).toEqual(['mempool-web'])
}) })
it('shows Immich as one app without internal database/cache cards in either tab', () => {
const entries: [string, PackageDataEntry][] = ['immich', 'immich-postgres', 'immich-redis', 'immich_postgres', 'immich_redis'].map(id => [id, makePkg(id, id, 'media')])
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['immich'])
expect(filterEntriesForTab(entries, 'services', 'all')).toEqual([])
})
-3
View File
@@ -29,9 +29,6 @@ export const isServiceContainer = sharedIsServiceContainer
const INTERNAL_TOOLING_NAMES = new Set([ const INTERNAL_TOOLING_NAMES = new Set([
'buildx_buildkit_default', 'buildx_buildkit_default',
// Stack internals belong to their parent app, including cached inventories
// from nodes predating backend alias normalization.
'immich-postgres', 'immich-redis', 'immich_postgres', 'immich_redis',
// Cuprate's dashboard is bundled as a companion of the primary cuprate // Cuprate's dashboard is bundled as a companion of the primary cuprate
// package; showing the generated container as a second Services entry // package; showing the generated container as a second Services entry
// defeats the one-app presentation. // defeats the one-app presentation.
@@ -369,10 +369,6 @@ init()
<span class="text-xs text-white/40">September 30, 2026</span> <span class="text-xs text-white/40">September 30, 2026</span>
</div> </div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10"> <div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.</p>
<p>Network diagnostic failures no longer stop all apps or rebuild shared container networking.</p>
<p>Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.</p>
<p>Fixed companion dashboard builds still referencing a retired image registry.</p>
<p>Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.</p> <p>Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.</p>
<p>Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.</p> <p>Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.</p>
<p>Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.</p> <p>Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.</p>
+17 -33
View File
@@ -1,46 +1,30 @@
{ {
"changelog": [ "changelog": [
"Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.", "Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.",
"Network diagnostic failures no longer stop all apps or rebuild shared container networking.", "Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.",
"Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.", "Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.",
"Fixed companion dashboard builds still referencing a retired image registry.", "Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20."
"Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.",
"Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.",
"Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.",
"Checked Bitcoin and Electrum companion dashboards instead of backend protocol ports, preserving dashboard access during initial sync.",
"Removed web-interface waiting messages from headless services such as Phoenixd and clarified which interface is unavailable for launchable apps.",
"Finished runtime app-file promotion before manifest loading, preventing startup catalog refresh from forgetting disk-only apps.",
"Named the app in compact readiness messages and kept app-card actions aligned at the bottom.",
"Removed duplicate Mempool cards caused by frontend container aliases in restored inventory.",
"Kept installed apps visible through restarts and hard refreshes, and delayed app launches until their web interface is ready.",
"Made Bitcoin version selection readable and usable in the ThinkPad kiosk, above the pruning settings.",
"Restored GitWorkshop build files in installation/update payloads and made slow image-pull progress clearer.",
"Fixed same-node Gitea access from Portainer, with persistent runtime migration, state backups and recovery after failed restarts.",
"Preserved Gitea configuration and SSH operation during fresh setup and upgrades.",
"Improved paid-file delivery, saved-file permissions and repeat-download compatibility; verified Tor-only payment with change, rejection refunds and free repeat downloads.",
"Added a headless Angor Indexer service using the existing Mempool/ElectrumX stack, and an optional separate Angor relay.",
"Prevented manifest command arguments containing apostrophes from being corrupted in generated services."
], ],
"components": [ "components": [
{ {
"current_version": "1.8.22-alpha", "current_version": "1.8.21-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-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.22-alpha", "new_version": "1.8.21-alpha",
"sha256": "e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b", "sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
"size_bytes": 65627704 "size_bytes": 64748176
}, },
{ {
"current_version": "1.8.22-alpha", "current_version": "1.8.21-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago-frontend-1.8.22-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.22-alpha.tar.gz", "name": "archipelago-frontend-1.8.21-alpha.tar.gz",
"new_version": "1.8.22-alpha", "new_version": "1.8.21-alpha",
"sha256": "2da485a2da75ff2fbe4aba52d6f217150e303be43a031723480c9c4ff9d43f41", "sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
"size_bytes": 98131119 "size_bytes": 97152546
} }
], ],
"release_date": "2026-09-30", "release_date": "2026-09-30",
"signature": "34e9e3902d5960c977b528c4edbb4366ad862f761076755632296840604c8a84ae1bbb503d8d26b2fe7622ca1eccfaa15cb8d23935bcec6dbecdaf6c53f7f50e", "signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.22-alpha" "version": "1.8.21-alpha"
} }
+4 -437
View File
@@ -141,198 +141,6 @@
}, },
"version": "1.23.0" "version": "1.23.0"
}, },
"angor-indexer": {
"manifest": {
"app": {
"bitcoin_integration": {
"pruning_support": false,
"rpc_access": "none",
"sync_required": true
},
"category": "money",
"container": {
"image": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1",
"network": "archy-net",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"app_id": "mempool-api",
"version": ">=3.0.0"
},
"bitcoin:archival"
],
"description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
"health_check": {
"endpoint": "http://127.0.0.1:8080",
"interval": "30s",
"path": "/health",
"retries": 3,
"timeout": "8s",
"type": "http"
},
"id": "angor-indexer",
"install_prerequisites": [
"mempool-api"
],
"interfaces": {
"main": {
"description": "Use this origin as Angor’s custom mainnet indexer URL. HTTPS is required for browser clients.",
"name": "Angor Indexer API",
"path": "/",
"port": 8998,
"protocol": "http",
"type": "api"
}
},
"metadata": {
"features": [
"Angor mainnet API",
"Reuses existing Mempool indexing",
"No separate blockchain database",
"Optional independent relay"
],
"icon": "/assets/img/app-icons/angor-green.png",
"repo": "https://github.com/block-core/angor",
"tier": "optional"
},
"name": "Angor Indexer",
"ports": [
{
"auth": "open",
"auth_rationale": "Public Bitcoin chain-data API and validated transaction broadcast for Angor clients; no wallet keys or node RPC credentials are exposed. Browser cookie login would break machine clients.",
"bind": "127.0.0.1",
"container": 8080,
"host": 8998,
"protocol": "tcp"
}
],
"resources": {
"cpu_limit": 1,
"disk_limit": "128Mi",
"memory_limit": "128Mi"
},
"security": {
"capabilities": [],
"network_policy": "isolated",
"no_new_privileges": true,
"readonly_root": true,
"user": 101
},
"upstream": {
"kind": "github",
"repo": "block-core/angor"
},
"version": "1.0.1"
}
},
"version": "1.0.1"
},
"angor-relay": {
"manifest": {
"app": {
"category": "nostr",
"container": {
"image": "source.archipelago-foundation.org/chaum/angor-relay:1.1.2",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"storage": "5Gi"
}
],
"description": "Optional dedicated Nostr relay for Angor project metadata. Separate storage and access settings keep the node’s internal relay private. Add this service’s address to Angor’s relay settings; use WSS for browser clients.",
"files": [
{
"content": "##\n## Default strfry config\n##\n\n# Directory that contains the strfry LMDB database (restart required)\ndb = \"./strfry-db/\"\n\ndbParams {\n # Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required)\n maxreaders = 256\n\n # Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required)\n mapsize = 10995116277760\n\n # Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required)\n noReadAhead = false\n}\n\nevents {\n # Maximum size of normalised JSON, in bytes\n maxEventSize = 65536\n\n # Events newer than this will be rejected\n rejectEventsNewerThanSeconds = 900\n\n # Events older than this will be rejected\n rejectEventsOlderThanSeconds = 94608000\n\n # Ephemeral events older than this will be rejected\n rejectEphemeralEventsOlderThanSeconds = 60\n\n # Ephemeral events will be deleted from the DB when older than this\n ephemeralEventsLifetimeSeconds = 300\n\n # Maximum number of tags allowed\n maxNumTags = 2000\n\n # Maximum size for tag values, in bytes\n maxTagValSize = 1024\n}\n\nrelay {\n # Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required)\n bind = \"0.0.0.0\"\n\n # Port to open for the nostr websocket protocol (restart required)\n port = 7777\n\n # Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)\n nofiles = 0\n\n # HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)\n realIpHeader = \"\"\n\n info {\n # NIP-11: Name of this server. Short/descriptive (< 30 characters)\n name = \"Angor Relay\"\n\n # NIP-11: Detailed information about relay, free-form\n description = \"Dedicated public relay for Angor project metadata.\"\n\n # NIP-11: Administrative nostr pubkey, for contact purposes\n pubkey = \"\"\n\n # NIP-11: Alternative administrative contact (email, website, etc)\n contact = \"\"\n\n # NIP-11: URL pointing to an image to be used as an icon for the relay\n icon = \"\"\n\n # List of supported lists as JSON array, or empty string to use default. Example: \"[1,2]\"\n nips = \"\"\n }\n\n # Maximum accepted incoming websocket frame size (should be larger than max event) (restart required)\n maxWebsocketPayloadSize = 131072\n\n # Maximum number of filters allowed in a REQ\n maxReqFilterSize = 200\n\n # Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required)\n autoPingSeconds = 55\n\n # If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy)\n enableTcpKeepalive = false\n\n # How much uninterrupted CPU time a REQ query should get during its DB scan\n queryTimesliceBudgetMicroseconds = 10000\n\n # Maximum records that can be returned per filter\n maxFilterLimit = 500\n\n # Maximum number of subscriptions (concurrent REQs) a connection can have open at any time\n maxSubsPerConnection = 20\n\n writePolicy {\n # If non-empty, path to an executable script that implements the writePolicy plugin logic\n plugin = \"\"\n }\n\n compression {\n # Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required)\n enabled = true\n\n # Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required)\n slidingWindow = true\n }\n\n logging {\n # Dump all incoming messages\n dumpInAll = false\n\n # Dump all incoming EVENT messages\n dumpInEvents = false\n\n # Dump all incoming REQ/CLOSE messages\n dumpInReqs = false\n\n # Log performance metrics for initial REQ database scans\n dbScanPerf = false\n\n # Log reason for invalid event rejection? Can be disabled to silence excessive logging\n invalidEvents = true\n }\n\n numThreads {\n # Ingester threads: route incoming requests, validate events/sigs (restart required)\n ingester = 3\n\n # reqWorker threads: Handle initial DB scan for events (restart required)\n reqWorker = 3\n\n # reqMonitor threads: Handle filtering of new events (restart required)\n reqMonitor = 3\n\n # negentropy threads: Handle negentropy protocol messages (restart required)\n negentropy = 2\n }\n\n negentropy {\n # Support negentropy protocol messages\n enabled = true\n\n # Maximum records that sync will process before returning an error\n maxSyncEvents = 1000000\n }\n}\n",
"overwrite": false,
"path": "/var/lib/archipelago/angor-relay-config/angor-relay.conf"
}
],
"health_check": {
"endpoint": "http://127.0.0.1:7777",
"interval": "30s",
"path": "/health",
"retries": 3,
"timeout": "5s",
"type": "http"
},
"id": "angor-relay",
"interfaces": {
"main": {
"description": "Nostr WebSocket endpoint; use ws:// for LAN or wss:// through your HTTPS domain.",
"name": "Angor Relay",
"path": "/",
"port": 8091,
"protocol": "http",
"type": "api"
}
},
"metadata": {
"features": [
"Angor project metadata",
"Separate from the node relay",
"Persistent Nostr event storage"
],
"icon": "/assets/img/app-icons/angor-green.png",
"repo": "https://github.com/hoytech/strfry",
"tier": "optional"
},
"name": "Angor Relay",
"nostr_integration": {
"monetization_enabled": false,
"relay_type": "public"
},
"ports": [
{
"auth": "open",
"auth_rationale": "Dedicated public Nostr relay for Angor project metadata; strfry verifies event signatures. It has separate storage from the private node relay and no wallet or node credentials.",
"bind": "127.0.0.1",
"container": 7777,
"host": 8091,
"protocol": "tcp"
}
],
"resources": {
"cpu_limit": 1,
"disk_limit": "5Gi",
"memory_limit": "512Mi"
},
"security": {
"apparmor_profile": "nostr-relay",
"capabilities": [],
"network_policy": "isolated",
"no_new_privileges": true,
"readonly_root": true,
"seccomp_profile": "default"
},
"upstream": {
"kind": "github",
"repo": "hoytech/strfry"
},
"version": "1.1.2",
"volumes": [
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/angor-relay",
"target": "/app/strfry-db",
"type": "bind"
},
{
"options": [
"ro"
],
"source": "/var/lib/archipelago/angor-relay-config/angor-relay.conf",
"target": "/etc/strfry.conf",
"type": "bind"
}
]
}
},
"version": "1.1.2"
},
"archipelago-source": { "archipelago-source": {
"manifest": { "manifest": {
"app": { "app": {
@@ -2387,142 +2195,6 @@
] ]
} }
}, },
"manifest_variants": [
{
"manifest": {
"app": {
"backup_before_runtime_change": true,
"category": "development",
"container": {
"image": "source.archipelago-foundation.org/lfg2025/gitea:1.27.3",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"storage": "50Gi"
}
],
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
"environment": [
"GITEA__database__DB_TYPE=sqlite3",
"GITEA__server__SSH_PORT=2222",
"GITEA__server__SSH_LISTEN_PORT=22",
"GITEA__server__LFS_START_SERVER=true",
"GITEA__packages__ENABLED=true",
"GITEA__packages__LIMIT_TOTAL_OWNER_SIZE=-1",
"GITEA__packages__LIMIT_SIZE_CONTAINER=-1",
"GITEA__repository_0x2Erelease__FILE_MAX_SIZE=10240",
"GITEA__repository_0x2Erelease__MAX_FILES=20",
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true"
],
"files": [
{
"content": "[server]\nDOMAIN = {{HOST_IP}}\nSSH_DOMAIN = {{HOST_IP}}\nROOT_URL = http://{{HOST_IP}}:3001/\n",
"overwrite": false,
"path": "/var/lib/archipelago/gitea/data/gitea/conf/app.ini"
}
],
"health_check": {
"endpoint": "http://localhost:3000",
"interval": "120s",
"path": "/",
"retries": 5,
"timeout": "30s",
"type": "http"
},
"id": "gitea",
"interfaces": {
"main": {
"description": "Gitea web interface",
"name": "Web UI",
"path": "/",
"port": 3001,
"protocol": "http",
"type": "ui"
}
},
"metadata": {
"features": [
"Git repositories with web UI",
"Built-in container/package registry",
"Issue tracking and pull requests",
"CI/CD via Gitea Actions",
"Lightweight SQLite deployment"
],
"icon": "/assets/img/app-icons/gitea.svg",
"launch": {
"open_in_new_tab": true
},
"repo": "https://gitea.com",
"tier": "optional"
},
"name": "Gitea",
"ports": [
{
"auth": "open",
"auth_rationale": "Gitea enforces its own account login on every page and API route; git clients authenticate with basic-auth/tokens and cannot complete a browser login challenge.",
"bind": "127.0.0.1",
"container": 3000,
"host": 3001,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "Git over SSH, authenticated by the user's own SSH keypair. Not HTTP, so the gate cannot serve a login page here.",
"container": 22,
"host": 2222,
"protocol": "tcp"
}
],
"resources": {
"disk_limit": "50Gi",
"memory_limit": "256Mi"
},
"security": {
"capabilities": [
"CHOWN",
"FOWNER",
"SETUID",
"SETGID",
"DAC_OVERRIDE",
"NET_BIND_SERVICE",
"SYS_CHROOT"
],
"network_policy": "bridge",
"no_new_privileges": false,
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "go-gitea/gitea"
},
"version": "1.27.3",
"volumes": [
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/gitea/data",
"target": "/data",
"type": "bind"
},
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/gitea/config",
"target": "/etc/gitea",
"type": "bind"
}
]
}
},
"requires": [
"runtime-migration-backup-v1"
]
}
],
"version": "1.27.3" "version": "1.27.3"
}, },
"grafana": { "grafana": {
@@ -4419,11 +4091,11 @@
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).", "description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
"environment": [], "environment": [],
"health_check": { "health_check": {
"endpoint": "http://127.0.0.1:81/api/", "endpoint": "localhost:81",
"interval": "30s", "interval": "30s",
"retries": 3, "retries": 3,
"timeout": "5s", "timeout": "5s",
"type": "http" "type": "tcp"
}, },
"id": "nginx-proxy-manager", "id": "nginx-proxy-manager",
"interfaces": { "interfaces": {
@@ -5324,111 +4996,6 @@
] ]
} }
}, },
"manifest_variants": [
{
"manifest": {
"app": {
"backup_before_runtime_change": true,
"category": "development",
"container": {
"data_uid": "1000:1000",
"image": "source.archipelago-foundation.org/lfg2025/portainer:2.45.0",
"network": "slirp4netns",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"storage": "1Gi"
}
],
"description": "Container management web UI for the local Podman socket.",
"environment": [],
"id": "portainer",
"interfaces": {
"main": {
"description": "Portainer web interface",
"name": "Web UI",
"path": "/",
"port": 9000,
"protocol": "http",
"type": "ui"
}
},
"metadata": {
"features": [
"Container management dashboard",
"Local Podman socket access",
"Compose stack storage"
],
"icon": "/assets/img/app-icons/portainer.webp",
"launch": {
"open_in_new_tab": true
},
"tier": "optional"
},
"name": "Portainer",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 9000,
"host": 9000,
"protocol": "tcp"
}
],
"resources": {
"disk_limit": "1Gi",
"memory_limit": "256Mi"
},
"security": {
"capabilities": [
"CHOWN",
"SETUID",
"SETGID",
"DAC_OVERRIDE"
],
"network_policy": "isolated",
"no_new_privileges": true,
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "portainer/portainer"
},
"version": "2.45.0",
"volumes": [
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/portainer",
"target": "/data",
"type": "bind"
},
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/portainer/compose",
"target": "/data/compose",
"type": "bind"
},
{
"options": [
"rw"
],
"source": "/run/user/1000/podman/podman.sock",
"target": "/var/run/docker.sock",
"type": "bind"
}
]
}
},
"requires": [
"runtime-migration-backup-v1"
]
}
],
"version": "2.45.0" "version": "2.45.0"
}, },
"router": { "router": {
@@ -5958,7 +5525,7 @@
"tag": "NOSTR IDENTITY // YOUR NODE" "tag": "NOSTR IDENTITY // YOUR NODE"
}, },
"schema": 1, "schema": 1,
"signature": "66b78a5bc60992222b01ae901c5f4a40802667332a5ae47bdad7f34e149669261012ff6fd543478a4f318e850b0339be497af71a50266d829395a872ad386704", "signature": "bbcc938b855c1cb5d803e4510e1aac3259fbf3eabf6f36294c7773634047a3d5edb5b37a17d01d62d1407e5701c62853e15e20e15cc7f486b8975b22eeb94c07",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"storefront": { "storefront": {
"popular": [ "popular": [
@@ -5984,5 +5551,5 @@
} }
] ]
}, },
"updated": "2026-09-30" "updated": "2026-09-29"
} }
+17 -33
View File
@@ -1,46 +1,30 @@
{ {
"changelog": [ "changelog": [
"Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.", "Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.",
"Network diagnostic failures no longer stop all apps or rebuild shared container networking.", "Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.",
"Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.", "Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.",
"Fixed companion dashboard builds still referencing a retired image registry.", "Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20."
"Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.",
"Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.",
"Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.",
"Checked Bitcoin and Electrum companion dashboards instead of backend protocol ports, preserving dashboard access during initial sync.",
"Removed web-interface waiting messages from headless services such as Phoenixd and clarified which interface is unavailable for launchable apps.",
"Finished runtime app-file promotion before manifest loading, preventing startup catalog refresh from forgetting disk-only apps.",
"Named the app in compact readiness messages and kept app-card actions aligned at the bottom.",
"Removed duplicate Mempool cards caused by frontend container aliases in restored inventory.",
"Kept installed apps visible through restarts and hard refreshes, and delayed app launches until their web interface is ready.",
"Made Bitcoin version selection readable and usable in the ThinkPad kiosk, above the pruning settings.",
"Restored GitWorkshop build files in installation/update payloads and made slow image-pull progress clearer.",
"Fixed same-node Gitea access from Portainer, with persistent runtime migration, state backups and recovery after failed restarts.",
"Preserved Gitea configuration and SSH operation during fresh setup and upgrades.",
"Improved paid-file delivery, saved-file permissions and repeat-download compatibility; verified Tor-only payment with change, rejection refunds and free repeat downloads.",
"Added a headless Angor Indexer service using the existing Mempool/ElectrumX stack, and an optional separate Angor relay.",
"Prevented manifest command arguments containing apostrophes from being corrupted in generated services."
], ],
"components": [ "components": [
{ {
"current_version": "1.8.22-alpha", "current_version": "1.8.21-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-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.22-alpha", "new_version": "1.8.21-alpha",
"sha256": "e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b", "sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
"size_bytes": 65627704 "size_bytes": 64748176
}, },
{ {
"current_version": "1.8.22-alpha", "current_version": "1.8.21-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago-frontend-1.8.22-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.22-alpha.tar.gz", "name": "archipelago-frontend-1.8.21-alpha.tar.gz",
"new_version": "1.8.22-alpha", "new_version": "1.8.21-alpha",
"sha256": "2da485a2da75ff2fbe4aba52d6f217150e303be43a031723480c9c4ff9d43f41", "sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
"size_bytes": 98131119 "size_bytes": 97152546
} }
], ],
"release_date": "2026-09-30", "release_date": "2026-09-30",
"signature": "34e9e3902d5960c977b528c4edbb4366ad862f761076755632296840604c8a84ae1bbb503d8d26b2fe7622ca1eccfaa15cb8d23935bcec6dbecdaf6c53f7f50e", "signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.22-alpha" "version": "1.8.21-alpha"
} }
-2
View File
@@ -27,8 +27,6 @@ def check(root: Path) -> int:
dockerfile = (context / build.get('dockerfile', 'Dockerfile')).resolve() dockerfile = (context / build.get('dockerfile', 'Dockerfile')).resolve()
if not dockerfile.is_relative_to(context) or not dockerfile.is_file(): if not dockerfile.is_relative_to(context) or not dockerfile.is_file():
raise ValueError(f'{app["id"]}: missing or out-of-context Dockerfile: {dockerfile}') raise ValueError(f'{app["id"]}: missing or out-of-context Dockerfile: {dockerfile}')
if 'git.tx1138.com/' in dockerfile.read_text():
raise ValueError(f'{app["id"]}: Dockerfile references retired registry git.tx1138.com')
count += 1 count += 1
return count return count
+102 -31
View File
@@ -32,8 +32,6 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FIXES_APPLIED=0 FIXES_APPLIED=0
CHECKS_PASSED=0 CHECKS_PASSED=0
CHECKS_WARNED=0
WARNING_NAMES=()
FIX_NAMES=() FIX_NAMES=()
log() { echo "[$(date +%H:%M:%S)] DOCTOR: $*"; } log() { echo "[$(date +%H:%M:%S)] DOCTOR: $*"; }
@@ -85,13 +83,7 @@ run_fix() {
FIXES_APPLIED=$((FIXES_APPLIED + 1)) FIXES_APPLIED=$((FIXES_APPLIED + 1))
FIX_NAMES+=("$name") FIX_NAMES+=("$name")
else else
local status=$? CHECKS_PASSED=$((CHECKS_PASSED + 1))
if [ "$status" = 1 ]; then
CHECKS_PASSED=$((CHECKS_PASSED + 1))
else
CHECKS_WARNED=$((CHECKS_WARNED + 1))
WARNING_NAMES+=("$name")
fi
fi fi
} }
@@ -454,33 +446,114 @@ print(' '.join(['\"' + a + '\"' if ' ' in a else a for a in args[2:]]))
[ ${#fixed_names[@]} -gt 0 ] && return 0 || return 1 [ ${#fixed_names[@]} -gt 0 ] && return 0 || return 1
} }
# ── Check 8: Rootless network egress (diagnostic only) ────── # ── Fix 8: Rootless netns egress lost ────────────────────────
# A single external endpoint or nsenter failure cannot establish that the # Rootless podman uses pasta to give containers internet egress. If pasta's
# containers have lost connectivity. In particular, entering only the network # tap vanishes (host link flap, mount churn, pasta dying during a boot-time
# namespace can fail for rootless user namespaces. Never stop apps, kill network # restart storm), the rootless-netns keeps inter-container traffic working
# helpers, migrate Podman, or remove network state in response to this probe. # but silently loses outbound. Bitcoin IBD stalls at 0 peers; package pulls
# Return 1 for healthy/not applicable and 2 for an inconclusive warning. # fail. The repair must rebuild the netns from scratch: merely cycling the
check_rootless_netns_egress() { # containers reuses the existing (broken) netns because its holders
# (aardvark-dns, podman's pause process) survive — observed on a test node
# 2026-07-10, where the old stop/start-only cycle bounced all 35 containers
# every timer run for ~an hour without ever restoring egress. So: stop the
# containers, kill the netns holders, `podman system migrate`, clear the
# stale netns state, then start everything back up.
#
# Destructive-action latch: cycling the whole fleet is a last resort. After
# NETNS_CYCLE_MAX consecutive failed repairs we stop cycling (and log loudly)
# until a run observes egress healthy again, which resets the counter.
NETNS_CYCLE_STATE="/var/lib/archipelago/doctor-netns-cycle-failures"
NETNS_CYCLE_MAX=3
fix_rootless_netns_egress() {
# Needs root for nsenter. When doctor runs as the rootless container owner,
# a failed nsenter probe is a permissions artifact, not evidence of broken
# egress; do not cycle the fleet from that context.
[ "$(id -u)" = "0" ] || return 1 [ "$(id -u)" = "0" ] || return 1
local archi_uid aardvark_pid
archi_uid=$(id -u archipelago 2>/dev/null) || return 1
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
[ -n "$aardvark_pid" ] || return 1
local archi_uid
archi_uid=$(id -u archipelago 2>/dev/null) || return 1
# Locate the rootless-netns via aardvark-dns (it lives inside it).
local aardvark_pid
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
[ -z "$aardvark_pid" ] && return 1 # no rootless network active
# Host precheck: if the host itself can't reach the internet, no point
# cycling containers — this is an upstream problem.
if ! timeout 3 bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then if ! timeout 3 bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
log "WARNING: host connectivity probe failed; external endpoint may be unavailable. Apps left running."
return 2
fi
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
return 1 return 1
fi fi
# Probe egress from inside the rootless-netns. One probe is noisy;
# require two consecutive failures 10s apart to rule out transients.
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
rm -f "$NETNS_CYCLE_STATE" # healthy again — re-arm the latch
return 1 # first probe succeeded
fi
sleep 10 sleep 10
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1) aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
if [ -n "$aardvark_pid" ] && timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then [ -z "$aardvark_pid" ] && return 1
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
rm -f "$NETNS_CYCLE_STATE"
return 1 # recovered on its own
fi
# Latch: don't keep bouncing the fleet when the rebuild demonstrably
# isn't fixing it.
local failures
failures=$(cat "$NETNS_CYCLE_STATE" 2>/dev/null || echo 0)
case "$failures" in *[!0-9]*|"") failures=0;; esac
if [ "$failures" -ge "$NETNS_CYCLE_MAX" ]; then
log "Rootless-netns egress still broken but $failures rebuilds already failed — NOT cycling again (manual intervention needed; rm $NETNS_CYCLE_STATE to re-arm)"
return 1 return 1
fi fi
log "WARNING: rootless network probe inconclusive (endpoint, connectivity, or namespace access). Inspect affected apps before repair. Apps left running."
return 2 log "Rootless-netns egress is broken (host online, container netns unreachable) — rebuilding netns"
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/$archi_uid podman"
local running
running=$($PODMANCMD ps --format '{{.Names}}' 2>/dev/null)
if [ -z "$running" ]; then
log " No running containers to cycle — skipping"
return 1
fi
local count
count=$(echo "$running" | wc -l)
log " Stopping $count running containers (graceful, 30s)..."
$PODMANCMD stop --all --time 30 >/dev/null 2>&1
sleep 5
# Tear the broken netns down for real: kill its holders and drop the
# stale state so the first container start rebuilds pasta + aardvark-dns
# from scratch. Without this, podman re-enters the old netns and the
# missing pasta tap never comes back.
log " Rebuilding rootless netns (killing holders, clearing state)..."
pkill -U "$archi_uid" -x aardvark-dns 2>/dev/null
pkill -U "$archi_uid" -x pasta 2>/dev/null
pkill -U "$archi_uid" -x pasta.avx2 2>/dev/null
pkill -U "$archi_uid" -x slirp4netns 2>/dev/null
sleep 2
$PODMANCMD system migrate >/dev/null 2>&1
rm -rf "/run/user/$archi_uid/containers/networks"
log " Starting containers back up..."
for c in $running; do
$PODMANCMD start "$c" >/dev/null 2>&1 &
done
wait
sleep 5
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
if [ -n "$aardvark_pid" ] && timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
log " Rootless-netns egress restored ($count containers cycled)"
rm -f "$NETNS_CYCLE_STATE"
else
failures=$((failures + 1))
echo "$failures" > "$NETNS_CYCLE_STATE"
log " WARN: egress still broken after rebuild (failure $failures/$NETNS_CYCLE_MAX) — may need manual intervention"
fi
return 0
} }
# ── Fix 9: Restart stopped core containers ────────────────── # ── Fix 9: Restart stopped core containers ──────────────────
@@ -658,7 +731,7 @@ run_fix "tor-permissions" fix_tor_permissions
run_fix "searxng" fix_searxng run_fix "searxng" fix_searxng
run_fix "bitcoin-txindex" fix_bitcoin_txindex run_fix "bitcoin-txindex" fix_bitcoin_txindex
run_fix "exit-127" fix_exit_127 run_fix "exit-127" fix_exit_127
run_fix "netns-egress" check_rootless_netns_egress run_fix "netns-egress" fix_rootless_netns_egress
run_fix "stopped-core" fix_stopped_core_containers run_fix "stopped-core" fix_stopped_core_containers
run_fix "rootless-ports" fix_missing_rootless_ports run_fix "rootless-ports" fix_missing_rootless_ports
run_fix "npm-public-hosts" fix_npm_public_hosts run_fix "npm-public-hosts" fix_npm_public_hosts
@@ -667,9 +740,7 @@ run_fix "catatonit" fix_missing_catatonit
run_fix "dialout" fix_archipelago_dialout run_fix "dialout" fix_archipelago_dialout
echo "" echo ""
if [ "$CHECKS_WARNED" -gt 0 ]; then if [ $FIXES_APPLIED -gt 0 ]; then
log "Done: $CHECKS_WARNED unresolved warnings (${WARNING_NAMES[*]}), $FIXES_APPLIED fixes applied, $CHECKS_PASSED checks passed"
elif [ $FIXES_APPLIED -gt 0 ]; then
log "Done: $FIXES_APPLIED fixes applied (${FIX_NAMES[*]}), $CHECKS_PASSED checks passed" log "Done: $FIXES_APPLIED fixes applied (${FIX_NAMES[*]}), $CHECKS_PASSED checks passed"
else else
log "Done: all $CHECKS_PASSED checks passed — no fixes needed" log "Done: all $CHECKS_PASSED checks passed — no fixes needed"
-19
View File
@@ -71,25 +71,6 @@ else
bad "incomplete app build payload" bad "incomplete app build payload"
fi fi
# The cached rootfs must never restore the unsafe historical doctor on boot.
for doctor_file in container-doctor.sh archipelago-doctor.service archipelago-doctor.timer; do
if [[ "$doctor_file" == container-doctor.sh ]]; then
doctor_source="$REPO/scripts/$doctor_file"
else
doctor_source="$REPO/image-recipe/configs/$doctor_file"
fi
if cmp -s "$doctor_source" "$MNT/archipelago/scripts/$doctor_file"; then
ok "current doctor payload: $doctor_file"
else
bad "missing/stale doctor overlay: $doctor_file"
fi
done
if grep -Fq '# BEGIN DOCTOR OVERLAY' "$MNT/archipelago/auto-install.sh"; then
ok "installer replaces cached doctor before first boot"
else
bad "installer lacks cached doctor replacement"
fi
# ── GRUB must boot the live system ─────────────────────────────────── # ── GRUB must boot the live system ───────────────────────────────────
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
ok "grub.cfg has boot=live" ok "grub.cfg has boot=live"
-6
View File
@@ -40,12 +40,6 @@ class BuildPayloadTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'out-of-payload'): with self.assertRaisesRegex(ValueError, 'out-of-payload'):
contexts.check(self.root) contexts.check(self.root)
def test_retired_registry_rejected(self):
target = self.root / 'docker/lnd-ui/Dockerfile'
target.write_text('FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine\n')
with self.assertRaisesRegex(ValueError, 'lnd-ui.*retired registry'):
contexts.check(self.root)
def test_empty_payload_rejected(self): def test_empty_payload_rejected(self):
shutil.rmtree(self.root / 'apps') shutil.rmtree(self.root / 'apps')
with self.assertRaisesRegex(ValueError, 'No app manifests'): with self.assertRaisesRegex(ValueError, 'No app manifests'):
@@ -1,53 +0,0 @@
#!/usr/bin/env bash
# Simulate failures without namespaces, network access, or real repair commands.
set -euo pipefail
source "$(dirname "$0")/../../scripts/container-doctor.sh"
id() { if [[ "$*" == '-u archipelago' ]]; then echo 1000; else echo "${TEST_UID:-0}"; fi; }
pgrep() { if [[ "$HAS_NETWORK" == 1 ]]; then echo 123; else return 1; fi; }
sleep() { :; }
# Any mutation fails the test immediately, including within command substitution.
tripwire() { echo 'FAIL: diagnostic attempted a mutation' >&2; exit 99; }
podman() { tripwire; }
podman_rootless() { tripwire; }
sudo() { tripwire; }
systemctl() { tripwire; }
pkill() { tripwire; }
kill() { tripwire; }
rm() { tripwire; }
mkdir() { tripwire; }
timeout() {
if [[ "$2" == bash ]]; then return "$HOST_STATUS"; fi
[[ "$2" == nsenter ]] || exit 98
PROBES=$((PROBES + 1))
if [[ "$PROBES" == 1 ]]; then return "$FIRST_STATUS"; fi
return "$SECOND_STATUS"
}
check_case() {
local label=$1 expected=$2 expected_probes=$3
PROBES=0
local status=0
check_rootless_netns_egress > /dev/null || status=$?
[[ "$status" == "$expected" && "$PROBES" == "$expected_probes" ]] || {
echo "FAIL: $label status=$status probes=$PROBES"; exit 1;
}
echo "PASS: $label"
}
HAS_NETWORK=1 HOST_STATUS=0 FIRST_STATUS=0 SECOND_STATUS=0
check_case healthy 1 1
TEST_UID=1000 check_case rootless-caller 1 0
HAS_NETWORK=0 check_case no-network 1 0
HOST_STATUS=1 check_case host-offline 2 0
FIRST_STATUS=1 check_case transient-recovery 1 2
FIRST_STATUS=1 SECOND_STATUS=1 check_case repeated-egress-failure 2 2
FIRST_STATUS=126 SECOND_STATUS=126 check_case namespace-access-failure 2 2
# Failure must remain an unresolved warning on every scheduled invocation.
FIRST_STATUS=1 SECOND_STATUS=1
for attempt in 1 2 3 4 5; do
PROBES=0
run_fix netns-egress check_rootless_netns_egress > /dev/null
done
[[ "$CHECKS_WARNED" == 5 && "$FIXES_APPLIED" == 0 && "$CHECKS_PASSED" == 0 ]]
FIRST_STATUS=0 PROBES=0
run_fix netns-egress check_rootless_netns_egress > /dev/null
[[ "$CHECKS_PASSED" == 1 && "$FIXES_APPLIED" == 0 ]]
echo 'PASS: repeated failure warnings never trigger repair or report a successful check'
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env python3
"""Execute the installer's actual overlay against a stale disposable rootfs."""
import pathlib, subprocess, tempfile, shutil, unittest
ROOT = pathlib.Path(__file__).resolve().parents[2]
class DoctorOverlayTests(unittest.TestCase):
def test_cached_rootfs_and_missing_payload(self):
source = (ROOT / 'image-recipe/_archived/build-auto-installer-iso.sh').read_text()
block = source.split('# BEGIN DOCTOR OVERLAY\n', 1)[1].split('# END DOCTOR OVERLAY', 1)[0]
with tempfile.TemporaryDirectory() as temp:
base = pathlib.Path(temp)
target = base / 'target'
media = base / 'media'
payload = media / 'archipelago/scripts'
payload.mkdir(parents=True)
units = target / 'etc/systemd/system'
units.mkdir(parents=True)
for directory in ['opt/archipelago/scripts', 'home/archipelago/archy/scripts']:
dest = target / directory
dest.mkdir(parents=True)
(dest / 'container-doctor.sh').write_text('UNSAFE OLD SCRIPT')
files = [ROOT / 'scripts/container-doctor.sh',
ROOT / 'image-recipe/configs/archipelago-doctor.service',
ROOT / 'image-recipe/configs/archipelago-doctor.timer']
for path in files:
shutil.copyfile(path, payload / path.name)
script = block.replace('/mnt/target', str(target))
for _ in range(2):
subprocess.run(['bash', '-c', script], env={'BOOT_MEDIA': str(media), 'PATH': '/usr/bin:/bin'}, check=True)
for directory in ['opt/archipelago/scripts', 'home/archipelago/archy/scripts']:
dest = target / directory / 'container-doctor.sh'
self.assertEqual(dest.read_bytes(), files[0].read_bytes())
self.assertEqual(dest.stat().st_mode & 0o777, 0o755)
for path in files[1:]:
self.assertEqual((units / path.name).read_bytes(), path.read_bytes())
(payload / 'container-doctor.sh').unlink()
failed = subprocess.run(['bash', '-c', script], env={'BOOT_MEDIA': str(media), 'PATH': '/usr/bin:/bin'}, capture_output=True)
self.assertNotEqual(failed.returncode, 0, 'Missing safety overlay must fail installation')
if __name__ == '__main__':
unittest.main()
-2
View File
@@ -74,8 +74,6 @@ stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml -
stage "app-build-contexts" python3 tests/regression/app-build-contexts.py stage "app-build-contexts" python3 tests/regression/app-build-contexts.py
stage "manifest-shell" python3 scripts/check-manifest-shell.py stage "manifest-shell" python3 scripts/check-manifest-shell.py
stage "npm-tunnel-migration" python3 -m unittest discover -s scripts/tests -p test_repair_npm_tunnel.py stage "npm-tunnel-migration" python3 -m unittest discover -s scripts/tests -p test_repair_npm_tunnel.py
stage "iso-doctor-overlay" python3 tests/regression/iso-doctor-overlay.py
stage "doctor-egress" bash tests/regression/container-doctor-egress.sh
stage "doctor-ports" bash tests/regression/container-doctor-ports.sh stage "doctor-ports" bash tests/regression/container-doctor-ports.sh
stage "bitcoin-pruning" python3 tests/regression/bitcoin-prune-entrypoint.py stage "bitcoin-pruning" python3 tests/regression/bitcoin-prune-entrypoint.py
stage "lnd-ui-readiness" node --test tests/regression/lnd-ui-readiness.cjs stage "lnd-ui-readiness" node --test tests/regression/lnd-ui-readiness.cjs