diff --git a/core/archipelago/src/api/handler/cdp.rs b/core/archipelago/src/api/handler/cdp.rs index a20e2875..62e71b2a 100644 --- a/core/archipelago/src/api/handler/cdp.rs +++ b/core/archipelago/src/api/handler/cdp.rs @@ -31,7 +31,7 @@ use futures_util::{SinkExt, StreamExt}; use serde_json::{json, Value}; use tokio::sync::mpsc; use tokio_tungstenite::tungstenite::Message; -use tracing::{debug, info, warn}; +use tracing::{debug, info}; const CDP_HTTP: &str = "http://127.0.0.1:9222"; /// Marker whose presence means this node drives a local kiosk display. diff --git a/core/archipelago/src/api/handler/content.rs b/core/archipelago/src/api/handler/content.rs index 67b9344d..86b9645b 100644 --- a/core/archipelago/src/api/handler/content.rs +++ b/core/archipelago/src/api/handler/content.rs @@ -583,15 +583,17 @@ impl ApiHandler { paid = true; } Ok(false) => {} - Err(_) => return Ok(build_response( - StatusCode::OK, - "application/json", - hyper::Body::from(serde_json::to_vec(&serde_json::json!({ - "paid": false, - "status": "unknown", - "error": "Exact on-chain outputs could not be verified. Keep the original payment address and do not pay again." - }))?), - )), + Err(_) => { + return Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&serde_json::json!({ + "paid": false, + "status": "unknown", + "error": "Exact on-chain outputs could not be verified. Keep the original payment address and do not pay again." + }))?), + )) + } } } let body = serde_json::json!({ "paid": paid }); diff --git a/core/archipelago/src/api/handler/lightning_purchase.rs b/core/archipelago/src/api/handler/lightning_purchase.rs index 36d55e0a..bc27b247 100644 --- a/core/archipelago/src/api/handler/lightning_purchase.rs +++ b/core/archipelago/src/api/handler/lightning_purchase.rs @@ -193,16 +193,6 @@ impl ApiHandler { let data = self.config.data_dir.clone(); let id = binding.content_id.clone(); let retained = source.clone(); - struct CancelCopy(std::sync::Arc); - impl Drop for CancelCopy { - fn drop(&mut self) { - self.0.store(true, std::sync::atomic::Ordering::SeqCst); - } - } - let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new( - false, - ))); - let cancelled = cancel_copy.0.clone(); let snapshot = tokio::task::spawn_blocking(move || { crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size) }) diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index f259aa91..08eb7d03 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -1013,19 +1013,28 @@ impl RpcHandler { let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let path = format!("/content/{}/onchain-status/{}", content_id, address); - let (response, _transport) = - match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) - .service(crate::settings::transport::PeerService::PeerFiles) - .timeout(std::time::Duration::from_secs(15)) - .fips_timeout(std::time::Duration::from_secs(6)) - .send_content_get(&self.config.data_dir) - .await - { - Ok(v) => v, - Err(_) => return Ok(serde_json::json!({ "paid": false, "unreachable": true, "status": "unknown", "error": "Payment verification is unavailable. Keep the original address and do not pay again." })), - }; + let (response, _transport) = match crate::fips::dial::PeerRequest::new( + fips_npub.as_deref(), + onion, + &path, + ) + .service(crate::settings::transport::PeerService::PeerFiles) + .timeout(std::time::Duration::from_secs(15)) + .fips_timeout(std::time::Duration::from_secs(6)) + .send_content_get(&self.config.data_dir) + .await + { + Ok(v) => v, + Err(_) => { + return Ok( + serde_json::json!({ "paid": false, "unreachable": true, "status": "unknown", "error": "Payment verification is unavailable. Keep the original address and do not pay again." }), + ) + } + }; if !response.status().is_success() { - return Ok(serde_json::json!({ "paid": false, "status": "unknown", "error": "The seller could not verify this payment. Keep the original address and do not pay again." })); + return Ok( + serde_json::json!({ "paid": false, "status": "unknown", "error": "The seller could not verify this payment. Keep the original address and do not pay again." }), + ); } let body: serde_json::Value = response .json() diff --git a/core/archipelago/src/api/rpc/identity/handlers.rs b/core/archipelago/src/api/rpc/identity/handlers.rs index eb53469e..320d4e21 100644 --- a/core/archipelago/src/api/rpc/identity/handlers.rs +++ b/core/archipelago/src/api/rpc/identity/handlers.rs @@ -114,17 +114,34 @@ impl RpcHandler { /// Explicit owner-key import into a separate native business identity. pub(in crate::api::rpc) async fn handle_identity_import_nostr( - &self, params: Option, + &self, + params: Option, ) -> Result { let params = params.unwrap_or_default(); - let password = params.get("password").and_then(|v| v.as_str()).unwrap_or(""); + let password = params + .get("password") + .and_then(|v| v.as_str()) + .unwrap_or(""); if !self.auth_manager.verify_password(password).await? { anyhow::bail!("Invalid node password"); } - let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("Just Works"); - anyhow::ensure!(!name.trim().is_empty() && name.len() <= 100, "Invalid identity name"); - let nsec = params.get("nsec").and_then(|v| v.as_str()).unwrap_or("").trim(); - let npub = params.get("expected_npub").and_then(|v| v.as_str()).unwrap_or(""); + let name = params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Just Works"); + anyhow::ensure!( + !name.trim().is_empty() && name.len() <= 100, + "Invalid identity name" + ); + let nsec = params + .get("nsec") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let npub = params + .get("expected_npub") + .and_then(|v| v.as_str()) + .unwrap_or(""); let manager = IdentityManager::new(&self.config.data_dir).await?; let record = manager.import_nostr(name.to_string(), nsec, npub).await?; Ok(serde_json::json!({"id":record.id, "name":record.name, diff --git a/core/archipelago/src/api/rpc/media_registration.rs b/core/archipelago/src/api/rpc/media_registration.rs index b72dd885..a2a2a380 100644 --- a/core/archipelago/src/api/rpc/media_registration.rs +++ b/core/archipelago/src/api/rpc/media_registration.rs @@ -61,8 +61,8 @@ fn verified_resolution(input: serde_json::Value, now: u64) -> Result= params.intent.created_at.saturating_sub(30) - && event.created_at.as_u64() <= now.saturating_add(30), + && event.created_at.as_secs() >= params.intent.created_at.saturating_sub(30) + && event.created_at.as_secs() <= now.saturating_add(30), "Invalid resolution signature time or scope" ); let content: serde_json::Value = serde_json::from_str(&event.content)?; @@ -103,7 +103,7 @@ fn verified_producer(params: &Params, now: u64) -> Result { producer == params.intent.producer, "The signing identity differs from the project producer" ); - let created = event.created_at.as_u64(); + let created = event.created_at.as_secs(); anyhow::ensure!( created >= params.intent.created_at.saturating_sub(30) && created < params.intent.expires_at diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 58323397..341cc06b 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -18,10 +18,10 @@ mod handshake; mod identity; mod interfaces; mod lightning_purchase; -mod onchain_purchase; pub(crate) mod lnd; mod marketplace; mod media_registration; +mod onchain_purchase; mod playback; mod purchase; // pub(crate): 13-10's `assistant::backends::select_backend` reuses @@ -34,12 +34,12 @@ mod monitoring; mod music; mod names; mod network; -mod publishing; mod node; mod nostr; mod onboarding_gate; mod openwrt; mod package; +mod publishing; pub(crate) use package::patch_indeedhub_nostr_provider; pub(crate) use package::wyoming_satellite_keeper; mod peers; @@ -112,7 +112,6 @@ fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_m | "media.registration.context" | "media.registration.resolve" | "content.rental-purchase" - | "content.onchain-cancel" | "content.onchain-attempt" | "content.onchain-create" diff --git a/core/archipelago/src/api/rpc/onchain_purchase.rs b/core/archipelago/src/api/rpc/onchain_purchase.rs index cdfe8aa1..2c7a11f9 100644 --- a/core/archipelago/src/api/rpc/onchain_purchase.rs +++ b/core/archipelago/src/api/rpc/onchain_purchase.rs @@ -442,7 +442,7 @@ impl RpcHandler { if record.quote.is_none() { engine::mark_address_allocation(&journal, true)?; let status = self.request_onchain_allocation(&record, &fips).await?; - record = engine::accept_quote( + engine::accept_quote( &journal, status.quote()?.context( "Original seller allocation is unresolved; recover this operation", diff --git a/core/archipelago/src/api/rpc/package/runtime.rs b/core/archipelago/src/api/rpc/package/runtime.rs index 51662dcd..2fa2176b 100644 --- a/core/archipelago/src/api/rpc/package/runtime.rs +++ b/core/archipelago/src/api/rpc/package/runtime.rs @@ -883,7 +883,8 @@ async fn do_orchestrator_package_start( if i > 0 { tokio::time::sleep(std::time::Duration::from_secs(2)).await; } - let managed = crate::container::supervised_update::installed_unit(data_dir, name)?.is_some(); + let managed = + crate::container::supervised_update::installed_unit(data_dir, name)?.is_some(); if !managed { repair_before_package_start(name).await; wait_before_package_start(name).await; @@ -1145,7 +1146,8 @@ async fn do_orchestrator_package_stop( ) -> Result<()> { let mut errors = Vec::new(); for name in containers { - let managed = crate::container::supervised_update::installed_unit(data_dir, name)?.is_some(); + let managed = + crate::container::supervised_update::installed_unit(data_dir, name)?.is_some(); match orchestrator.stop(name).await { Ok(()) => {} Err(e) if !managed && is_unknown_app_id_error(&e) => { diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 38de05b8..535aedbc 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -2402,7 +2402,10 @@ impl ProdContainerOrchestrator { return Ok(ReconcileAction::Left("user-uninstalled".into())); } self.sync_quadlet_unit(lm, &managed_name).await?; - let status = self.runtime.get_container_status(&managed_name).await + let status = self + .runtime + .get_container_status(&managed_name) + .await .context("Reviewed managed runtime is missing; explicit recovery required")?; anyhow::ensure!(matches!(status.state, ContainerState::Running), "Reviewed managed runtime is not running; recover its saved systemd unit explicitly instead of recreating from the catalog"); @@ -4019,36 +4022,38 @@ impl ProdContainerOrchestrator { .clone() .unwrap_or_else(|| "bitcoin-knots".to_string()); } - #[allow(unreachable_code)] - // The known Bitcoin node containers, preferred in order. Any archy - // Bitcoin distribution runs as a container named `bitcoin-` - // (or bare `bitcoin`), all reachable on archy-net by name. - const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"]; - let names = tokio::process::Command::new("podman") - .args(["ps", "--format", "{{.Names}}"]) - .output() - .await - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) - .unwrap_or_default(); - let running: Vec<&str> = names.lines().map(|l| l.trim()).collect(); - // Prefer a known name in priority order… - if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) { - return hit.to_string(); + #[cfg(not(test))] + { + // The known Bitcoin node containers, preferred in order. Any archy + // Bitcoin distribution runs as a container named `bitcoin-` + // (or bare `bitcoin`), all reachable on archy-net by name. + const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"]; + let names = tokio::process::Command::new("podman") + .args(["ps", "--format", "{{.Names}}"]) + .output() + .await + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default(); + let running: Vec<&str> = names.lines().map(|l| l.trim()).collect(); + // Prefer a known name in priority order… + if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) { + return hit.to_string(); + } + // …else accept ANY running `bitcoin-*` / `bitcoin` container, so a + // future Bitcoin distribution archy ships works without editing this + // list (user req 2026-07-22). Excludes companions/sidecars like + // `bitcoin-ui` and `archy-*`. + if let Some(other) = running.iter().find(|n| { + (**n == "bitcoin" || n.starts_with("bitcoin-")) + && !n.ends_with("-ui") + && !n.starts_with("archy-") + }) { + return other.to_string(); + } + "bitcoin-knots".to_string() } - // …else accept ANY running `bitcoin-*` / `bitcoin` container, so a - // future Bitcoin distribution archy ships works without editing this - // list (user req 2026-07-22). Excludes companions/sidecars like - // `bitcoin-ui` and `archy-*`. - if let Some(other) = running.iter().find(|n| { - (**n == "bitcoin" || n.starts_with("bitcoin-")) - && !n.ends_with("-ui") - && !n.starts_with("archy-") - }) { - return other.to_string(); - } - "bitcoin-knots".to_string() } #[cfg(test)] @@ -5227,8 +5232,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { }; let name = compute_container_name(&lm.manifest); if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() { - anyhow::ensure!(!super::update_transaction::is_held(&self.data_dir, &name)?, - "Reviewed managed runtime is held for update recovery"); + anyhow::ensure!( + !super::update_transaction::is_held(&self.data_dir, &name)?, + "Reviewed managed runtime is held for update recovery" + ); self.sync_quadlet_unit(&lm, &name).await?; self.ensure_resolved_source_available(&lm).await?; } @@ -5328,13 +5335,18 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { let _guard = lock.lock().await; let name = compute_container_name(&lm.manifest); if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() { - anyhow::ensure!(!super::update_transaction::is_held(&self.data_dir, &name)?, - "Reviewed managed runtime is held for update recovery"); + anyhow::ensure!( + !super::update_transaction::is_held(&self.data_dir, &name)?, + "Reviewed managed runtime is held for update recovery" + ); self.sync_quadlet_unit(&lm, &name).await?; quadlet::stop_service(&format!("{name}.service")).await?; if let Ok(status) = self.runtime.get_container_status(&name).await { anyhow::ensure!( - matches!(status.state, ContainerState::Stopped | ContainerState::Exited | ContainerState::Created), + matches!( + status.state, + ContainerState::Stopped | ContainerState::Exited | ContainerState::Created + ), "Reviewed managed runtime is still active after systemd stop" ); } @@ -5391,7 +5403,12 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { async fn restart(&self, app_id: &str) -> Result<()> { if let Ok(lm) = self.loaded(app_id).await { - if super::supervised_update::installed_unit(&self.data_dir, &compute_container_name(&lm.manifest))?.is_some() { + if super::supervised_update::installed_unit( + &self.data_dir, + &compute_container_name(&lm.manifest), + )? + .is_some() + { self.validate_start(app_id).await?; self.stop(app_id).await?; return self.start(app_id).await; @@ -7966,27 +7983,59 @@ app: #[tokio::test] async fn reviewed_runtime_survives_catalog_drift_and_refuses_repairs_before_mutation() { use std::os::unix::fs::PermissionsExt; - for case in ["running", "stopped", "missing", "changed-unit", "missing-unit", "user-stopped", "user-uninstalled"] { + for case in [ + "running", + "stopped", + "missing", + "changed-unit", + "missing-unit", + "user-stopped", + "user-uninstalled", + ] { let rt = Arc::new(MockRuntime::default()); let orch = orch_with(rt.clone()).await; let name = format!("managed-{}", uuid::Uuid::new_v4().simple()); let mut manifest = pull_manifest(&name, "catalog:new"); manifest.app.environment = vec!["NEW_CATALOG_ENV=changed".into()]; - orch.insert_manifest_for_test(manifest, PathBuf::from("/tmp/catalog-drift")).await; + orch.insert_manifest_for_test(manifest, PathBuf::from("/tmp/catalog-drift")) + .await; let body = "[Container]\nImage=original:retained\nEnvironment=OLD_ENV=preserved\nPublishPort=127.0.0.1:1234:80\n"; let records = orch.data_dir.join("update-transactions/installed-units"); std::fs::create_dir_all(&records).unwrap(); - std::fs::write(records.join(format!("{name}.json")), serde_json::to_vec(&serde_json::json!({ - "schema": 1, "operation": uuid::Uuid::new_v4().to_string(), - "name": name, "body": body, "mode": 0o600 - })).unwrap()).unwrap(); - let unit = quadlet::unit_dir().await.unwrap().join(format!("{name}.container")); + std::fs::write( + records.join(format!("{name}.json")), + serde_json::to_vec(&serde_json::json!({ + "schema": 1, "operation": uuid::Uuid::new_v4().to_string(), + "name": name, "body": body, "mode": 0o600 + })) + .unwrap(), + ) + .unwrap(); + let unit = quadlet::unit_dir() + .await + .unwrap() + .join(format!("{name}.container")); if case != "missing-unit" { - std::fs::write(&unit, if case == "changed-unit" { "operator changed" } else { body }).unwrap(); + std::fs::write( + &unit, + if case == "changed-unit" { + "operator changed" + } else { + body + }, + ) + .unwrap(); std::fs::set_permissions(&unit, std::fs::Permissions::from_mode(0o600)).unwrap(); } if case != "missing" { - rt.set_state(&name, if case == "stopped" { ContainerState::Stopped } else { ContainerState::Running }); + rt.set_state( + &name, + if case == "stopped" { + ContainerState::Stopped + } else { + ContainerState::Running + }, + ); } if case == "user-stopped" { crate::crash_recovery::mark_user_stopped(&orch.data_dir, &name).await; @@ -8010,7 +8059,13 @@ app: assert!(result.is_err(), "{case} must refuse before mutation"); } assert_eq!(*rt.containers.lock().unwrap(), before, "{case}"); - assert!(rt.calls().iter().all(|call| call.starts_with("get_container_status:")), "{case}: {:?}", rt.calls()); + assert!( + rt.calls() + .iter() + .all(|call| call.starts_with("get_container_status:")), + "{case}: {:?}", + rt.calls() + ); if case == "running" { assert_eq!(std::fs::read_to_string(&unit).unwrap(), body); } @@ -8026,8 +8081,14 @@ app: orch.validate_start(&name).await.unwrap(); orch.start(&name).await.unwrap(); assert_eq!(std::fs::read_to_string(&unit).unwrap(), body); - assert!(!crate::crash_recovery::load_user_stopped(&orch.data_dir).await.contains(&name)); - assert!(!crate::crash_recovery::load_user_uninstalled(&orch.data_dir).await.contains(&name)); + assert!(!crate::crash_recovery::load_user_stopped(&orch.data_dir) + .await + .contains(&name)); + assert!( + !crate::crash_recovery::load_user_uninstalled(&orch.data_dir) + .await + .contains(&name) + ); } else { // Missing images/units and modified units refuse an explicit // start before service mutation; no catalog pull is attempted. @@ -8037,7 +8098,14 @@ app: assert!(orch.stop(&name).await.is_err()); } } - assert!(rt.calls().iter().all(|call| call.starts_with("get_container_status:") || call.starts_with("image_exists:")), "{case}: {:?}", rt.calls()); + assert!( + rt.calls() + .iter() + .all(|call| call.starts_with("get_container_status:") + || call.starts_with("image_exists:")), + "{case}: {:?}", + rt.calls() + ); assert_eq!(*rt.containers.lock().unwrap(), before, "{case}"); let _ = std::fs::remove_file(unit); } diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index 476883ad..fad5b9cf 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -742,14 +742,17 @@ pub async fn unit_dir() -> Result { .get_or_init(|| tempfile::tempdir().unwrap().keep()) .clone()); } - let home = std::env::var_os("HOME") - .map(PathBuf::from) - .ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?; - let dir = home.join(DEFAULT_REL_UNIT_DIR); - fs::create_dir_all(&dir) - .await - .with_context(|| format!("create_dir_all {}", dir.display()))?; - Ok(dir) + #[cfg(not(test))] + { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?; + let dir = home.join(DEFAULT_REL_UNIT_DIR); + fs::create_dir_all(&dir) + .await + .with_context(|| format!("create_dir_all {}", dir.display()))?; + Ok(dir) + } } /// The early same-node Portainer repair used a managed Quadlet drop-in. Once @@ -944,21 +947,25 @@ async fn systemctl_user_status( #[cfg(test)] { use std::os::unix::process::ExitStatusExt; + let _ = (args, timeout); return Ok(std::process::ExitStatus::from_raw(0)); } - let mut cmd = Command::new("systemctl"); - cmd.arg("--user").args(args); - cmd.kill_on_drop(true); - tokio::time::timeout(timeout, cmd.status()) - .await - .with_context(|| { - format!( - "systemctl --user {} timed out after {}s", - args.join(" "), - timeout.as_secs() - ) - })? - .with_context(|| format!("spawn systemctl --user {}", args.join(" "))) + #[cfg(not(test))] + { + let mut cmd = Command::new("systemctl"); + cmd.arg("--user").args(args); + cmd.kill_on_drop(true); + tokio::time::timeout(timeout, cmd.status()) + .await + .with_context(|| { + format!( + "systemctl --user {} timed out after {}s", + args.join(" "), + timeout.as_secs() + ) + })? + .with_context(|| format!("spawn systemctl --user {}", args.join(" "))) + } } async fn kill_and_reset_service(service: &str) -> Result<()> { @@ -994,21 +1001,25 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool { async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result { #[cfg(test)] { + let _ = (args, timeout); anyhow::bail!("Unit tests have no real user service manager"); } - let mut cmd = Command::new("systemctl"); - cmd.arg("--user").args(args); - cmd.kill_on_drop(true); - tokio::time::timeout(timeout, cmd.output()) - .await - .with_context(|| { - format!( - "systemctl --user {} timed out after {}s", - args.join(" "), - timeout.as_secs() - ) - })? - .with_context(|| format!("spawn systemctl --user {}", args.join(" "))) + #[cfg(not(test))] + { + let mut cmd = Command::new("systemctl"); + cmd.arg("--user").args(args); + cmd.kill_on_drop(true); + tokio::time::timeout(timeout, cmd.output()) + .await + .with_context(|| { + format!( + "systemctl --user {} timed out after {}s", + args.join(" "), + timeout.as_secs() + ) + })? + .with_context(|| format!("spawn systemctl --user {}", args.join(" "))) + } } pub fn contains_stale_health_gate(unit_body: &str) -> bool { diff --git a/core/archipelago/src/content_snapshot.rs b/core/archipelago/src/content_snapshot.rs index 1d4105f5..feef914d 100644 --- a/core/archipelago/src/content_snapshot.rs +++ b/core/archipelago/src/content_snapshot.rs @@ -171,10 +171,7 @@ pub(crate) fn prepare( Err(error) if error .downcast_ref::() - .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => - { - () - } + .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {} Err(error) => return Err(error), } let reservation = crate::snapshot_budget::reserve( diff --git a/core/archipelago/src/fips/iface.rs b/core/archipelago/src/fips/iface.rs index 4faaaf1f..3175179e 100644 --- a/core/archipelago/src/fips/iface.rs +++ b/core/archipelago/src/fips/iface.rs @@ -24,7 +24,7 @@ pub const FIPS_IFACE: &str = "fips0"; /// - Link-local (`fe80::/10`) and non-ULA addresses are ignored — we /// only want the mesh-routable ULA that `.fips` DNS resolves to. pub fn fips0_ula() -> Option { - addresses_on(FIPS_IFACE).into_iter().find(|a| is_ula(a)) + addresses_on(FIPS_IFACE).into_iter().find(is_ula) } /// List every IPv6 address bound to a given interface from diff --git a/core/archipelago/src/health_monitor.rs b/core/archipelago/src/health_monitor.rs index 76c9d981..0268921e 100644 --- a/core/archipelago/src/health_monitor.rs +++ b/core/archipelago/src/health_monitor.rs @@ -946,7 +946,10 @@ pub fn spawn_health_monitor(state: Arc, data_dir: PathBuf) { } if matches!( pkg.state, - PackageState::Starting | PackageState::Stopping | PackageState::Restarting | PackageState::Updating + PackageState::Starting + | PackageState::Stopping + | PackageState::Restarting + | PackageState::Updating ) { debug!( "Skipping container during package lifecycle transition: {} ({:?})", @@ -1069,7 +1072,8 @@ pub fn spawn_health_monitor(state: Arc, data_dir: PathBuf) { app_id: Some(container.app_id.clone()), }); if data.notifications.len() > 20 { - data.notifications = data.notifications.split_off(data.notifications.len() - 20); + data.notifications = + data.notifications.split_off(data.notifications.len() - 20); } state_changed = true; } @@ -1164,7 +1168,8 @@ pub fn spawn_health_monitor(state: Arc, data_dir: PathBuf) { // the restart resyncs cleanly instead of crash-looping. maybe_recover_corrupt_electrumx(&container.name, attempt).await; - let restarted = restart_container(&container.name, &container.state, &data_dir).await; + let restarted = + restart_container(&container.name, &container.state, &data_dir).await; if !restarted || attempt >= MAX_RESTART_ATTEMPTS { let notification = Notification { @@ -1249,10 +1254,15 @@ mod tests { let installed = root.path().join("update-transactions/installed-units"); std::fs::create_dir_all(&installed).unwrap(); let record = installed.join(format!("{name}.json")); - std::fs::write(&record, serde_json::to_vec(&serde_json::json!({ - "schema": 1, "operation": uuid::Uuid::new_v4().to_string(), - "name": name, "body": "[Container]\nImage=original:retained\n", "mode": 0o600 - })).unwrap()).unwrap(); + std::fs::write( + &record, + serde_json::to_vec(&serde_json::json!({ + "schema": 1, "operation": uuid::Uuid::new_v4().to_string(), + "name": name, "body": "[Container]\nImage=original:retained\n", "mode": 0o600 + })) + .unwrap(), + ) + .unwrap(); assert!(!automatic_recovery_allowed(root.path(), name)); assert!(!restart_container(name, "running", root.path()).await); std::fs::write(&record, b"damaged").unwrap(); diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index 1698da15..469b40a7 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -205,14 +205,22 @@ impl IdentityManager { nsec: &str, expected_npub: &str, ) -> Result { - anyhow::ensure!(!name.trim().is_empty() && name.len() <= 100, "Invalid identity name"); - anyhow::ensure!(nsec.starts_with("nsec1") && nsec.len() == 63, "Enter a plain nsec owner key"); - let secret = nostr_sdk::SecretKey::parse(nsec) - .map_err(|_| anyhow::anyhow!("Invalid owner key"))?; + anyhow::ensure!( + !name.trim().is_empty() && name.len() <= 100, + "Invalid identity name" + ); + anyhow::ensure!( + nsec.starts_with("nsec1") && nsec.len() == 63, + "Enter a plain nsec owner key" + ); + let secret = + nostr_sdk::SecretKey::parse(nsec).map_err(|_| anyhow::anyhow!("Invalid owner key"))?; let keys = nostr_sdk::Keys::new(secret); let nostr_pubkey = keys.public_key().to_hex(); - anyhow::ensure!(keys.public_key().to_bech32()? == expected_npub, - "Owner key does not match this website"); + anyhow::ensure!( + keys.public_key().to_bech32()? == expected_npub, + "Owner key does not match this website" + ); // Serializes imports only; mature creation/signing paths are untouched. static IMPORT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); @@ -260,7 +268,8 @@ impl IdentityManager { // Atomic publication, and unlike rename this cannot replace a file. fs::hard_link(&staging, &destination).await?; Ok(()) - }.await; + } + .await; let _ = fs::remove_file(&staging).await; write_result.context("Could not save imported identity")?; self.get(&id).await @@ -974,23 +983,53 @@ mod tests { async fn import_nostr_preserves_identities_and_rejects_mismatches() { let dir = tempdir().unwrap(); let manager = IdentityManager::new(dir.path()).await.unwrap(); - let original = manager.create("Personal".into(), IdentityPurpose::Personal).await.unwrap(); + let original = manager + .create("Personal".into(), IdentityPurpose::Personal) + .await + .unwrap(); let keys = nostr_sdk::Keys::generate(); let nsec = keys.secret_key().to_bech32().unwrap(); let npub = keys.public_key().to_bech32().unwrap(); - assert!(manager.import_nostr("Wrong".into(), &nsec, "npub1wrong").await.is_err()); + assert!(manager + .import_nostr("Wrong".into(), &nsec, "npub1wrong") + .await + .is_err()); assert_eq!(manager.list().await.unwrap().0.len(), 1); - let imported = manager.import_nostr("Website".into(), &nsec, &npub).await.unwrap(); + let imported = manager + .import_nostr("Website".into(), &nsec, &npub) + .await + .unwrap(); assert_eq!(imported.nostr_npub.as_deref(), Some(npub.as_str())); - assert_eq!(manager.import_nostr("Again".into(), &nsec, &npub).await.unwrap().id, imported.id); + assert_eq!( + manager + .import_nostr("Again".into(), &nsec, &npub) + .await + .unwrap() + .id, + imported.id + ); let (records, default) = manager.list().await.unwrap(); assert_eq!(records.len(), 2); assert_eq!(default.as_deref(), Some(original.id.as_str())); - assert_eq!(manager.get(&original.id).await.unwrap().nostr_pubkey, original.nostr_pubkey); - assert_eq!(manager.export_keys(&imported.id).await.unwrap()["nostr_nsec"], nsec); - #[cfg(unix)] { + assert_eq!( + manager.get(&original.id).await.unwrap().nostr_pubkey, + original.nostr_pubkey + ); + assert_eq!( + manager.export_keys(&imported.id).await.unwrap()["nostr_nsec"], + nsec + ); + #[cfg(unix)] + { use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(dir.path().join("identities").join(format!("{}.json", imported.id))).unwrap().permissions().mode(); + let mode = std::fs::metadata( + dir.path() + .join("identities") + .join(format!("{}.json", imported.id)), + ) + .unwrap() + .permissions() + .mode(); assert_eq!(mode & 0o777, 0o600); } } @@ -1012,14 +1051,24 @@ mod tests { assert_eq!(records.len(), 1); assert!(default.is_none()); let hash = [7u8; 32]; - let signature = manager.nostr_sign(&first.id, &hex::encode(hash)).await.unwrap(); + let signature = manager + .nostr_sign(&first.id, &hex::encode(hash)) + .await + .unwrap(); let signature: nostr_sdk::secp256k1::schnorr::Signature = signature.parse().unwrap(); - let pubkey: nostr_sdk::secp256k1::XOnlyPublicKey = keys.public_key().to_hex().parse().unwrap(); - nostr_sdk::secp256k1::Secp256k1::verification_only().verify_schnorr( - &signature, &nostr_sdk::secp256k1::Message::from_digest(hash), &pubkey, - ).unwrap(); + let pubkey: nostr_sdk::secp256k1::XOnlyPublicKey = + keys.public_key().to_hex().parse().unwrap(); + nostr_sdk::secp256k1::Secp256k1::verification_only() + .verify_schnorr( + &signature, + &nostr_sdk::secp256k1::Message::from_digest(hash), + &pubkey, + ) + .unwrap(); let entries = std::fs::read_dir(dir.path().join("identities")).unwrap(); - assert!(entries.map(|entry| entry.unwrap().file_name()).all(|name| !name.to_string_lossy().ends_with(".tmp"))); + assert!(entries + .map(|entry| entry.unwrap().file_name()) + .all(|name| !name.to_string_lossy().ends_with(".tmp"))); } #[tokio::test] diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index ff8ed972..6646fa09 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -77,7 +77,6 @@ mod monitoring; mod music; mod names; mod network; -mod publishing; mod node_message; mod nostr_discovery; mod nostr_handshake; @@ -87,6 +86,7 @@ mod nostr_security_tests; mod peers; mod port_allocator; mod prepared_media; +mod publishing; mod rate_limit; mod registered_media; mod rental_chunk_index; diff --git a/core/archipelago/src/media_registration.rs b/core/archipelago/src/media_registration.rs index 50f03450..1ac808cb 100644 --- a/core/archipelago/src/media_registration.rs +++ b/core/archipelago/src/media_registration.rs @@ -683,10 +683,7 @@ pub fn resolve( Err(error) if error .downcast_ref::() - .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => - { - () - } + .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {} Err(error) => return Err(error), } } diff --git a/core/archipelago/src/mesh/flash.rs b/core/archipelago/src/mesh/flash.rs index 59c370d6..af5954f3 100644 --- a/core/archipelago/src/mesh/flash.rs +++ b/core/archipelago/src/mesh/flash.rs @@ -113,22 +113,19 @@ const PORT_FREE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10 /// resource is gone yet). async fn wait_for_port_free(path: &str) -> Result<()> { let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT; - let mut last_err = None; loop { match serial2_tokio::SerialPort::open(path, 115200) { Ok(_) => return Ok(()), - Err(e) => last_err = Some(e), - } - if tokio::time::Instant::now() >= deadline { - break; + Err(error) if tokio::time::Instant::now() >= deadline => { + return Err(anyhow::anyhow!( + "{path} is still held open by something else after {}s (last error: {error}) — refusing to start the flasher against a contended port", + PORT_FREE_TIMEOUT.as_secs(), + )); + } + Err(_) => {} } tokio::time::sleep(std::time::Duration::from_millis(500)).await; } - Err(anyhow::anyhow!( - "{path} is still held open by something else after {}s (last error: {}) — refusing to start the flasher against a contended port", - PORT_FREE_TIMEOUT.as_secs(), - last_err.map(|e| e.to_string()).unwrap_or_default() - )) } /// Live state for the one flash job that can run at a time. A single global diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index 5cf85f1b..f84dfa43 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -15,7 +15,7 @@ mod frames; mod node_cmd; mod session; -pub(crate) use session::{probe_device, DeviceProbe}; +pub(crate) use session::probe_device; use super::types::*; use serde::{Deserialize, Serialize}; diff --git a/core/archipelago/src/monitoring/collector.rs b/core/archipelago/src/monitoring/collector.rs index c85af143..93e7ad2d 100644 --- a/core/archipelago/src/monitoring/collector.rs +++ b/core/archipelago/src/monitoring/collector.rs @@ -122,7 +122,8 @@ async fn read_disk_usage() -> Result<(u64, u64)> { }; let mut command = tokio::process::Command::new("df"); command.args(["--block-size=1", "--output=used,size", target]); - let output = bounded_output(command, std::time::Duration::from_secs(3)).await + let output = bounded_output(command, std::time::Duration::from_secs(3)) + .await .context("Failed to run df")?; if !output.status.success() { @@ -222,7 +223,8 @@ async fn bounded_output( ) -> Result { command.kill_on_drop(true); tokio::time::timeout(timeout, command.output()) - .await.context("Metrics subprocess timed out")? + .await + .context("Metrics subprocess timed out")? .context("Metrics subprocess failed") } @@ -230,7 +232,8 @@ async fn bounded_output( async fn read_container_stats() -> Result> { let mut command = tokio::process::Command::new("podman"); command.args(["stats", "--no-stream", "--format", "json"]); - let output = bounded_output(command, std::time::Duration::from_secs(8)).await + let output = bounded_output(command, std::time::Duration::from_secs(8)) + .await .context("Failed to run podman stats")?; if !output.status.success() { @@ -411,14 +414,22 @@ mod subprocess_deadline_tests { let dir = tempfile::tempdir().unwrap(); let pid_file = dir.path().join("pid"); let mut command = tokio::process::Command::new("sh"); - command.arg("-c").arg("echo $$ > \"$1\"; exec sleep 30").arg("metrics-test").arg(&pid_file); + command + .arg("-c") + .arg("echo $$ > \"$1\"; exec sleep 30") + .arg("metrics-test") + .arg(&pid_file); let start = std::time::Instant::now(); - let error = bounded_output(command, std::time::Duration::from_millis(500)).await.unwrap_err(); + let error = bounded_output(command, std::time::Duration::from_millis(500)) + .await + .unwrap_err(); assert!(error.to_string().contains("timed out")); assert!(start.elapsed() < std::time::Duration::from_secs(3)); let pid = tokio::fs::read_to_string(pid_file).await.unwrap(); for _ in 0..40 { - if !std::path::Path::new(&format!("/proc/{}", pid.trim())).exists() { return; } + if !std::path::Path::new(&format!("/proc/{}", pid.trim())).exists() { + return; + } tokio::time::sleep(std::time::Duration::from_millis(25)).await; } panic!("Timed-out metrics subprocess was not reaped"); @@ -428,7 +439,9 @@ mod subprocess_deadline_tests { async fn successful_metrics_output_is_preserved() { let mut command = tokio::process::Command::new("printf"); command.arg("metrics-ok"); - let output = bounded_output(command, std::time::Duration::from_secs(1)).await.unwrap(); + let output = bounded_output(command, std::time::Duration::from_secs(1)) + .await + .unwrap(); assert!(output.status.success()); assert_eq!(output.stdout, b"metrics-ok"); } diff --git a/core/archipelago/src/nostr_handshake.rs b/core/archipelago/src/nostr_handshake.rs index 0466f123..dae19202 100644 --- a/core/archipelago/src/nostr_handshake.rs +++ b/core/archipelago/src/nostr_handshake.rs @@ -227,7 +227,7 @@ pub async fn publish_presence( // NIP-40 expiration: relays that honour it garbage-collect the event if // this node stops heartbeating (reinstall, decommission, long outage). // `discover` enforces the same window client-side for relays that don't. - let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS); + let expires = Timestamp::from(Timestamp::now().as_secs() + PRESENCE_TTL_SECS); let builder = EventBuilder::new(Kind::Custom(30078), content) .tag(Tag::identifier("archipelago-node")) .tag(Tag::expiration(expires)); @@ -268,7 +268,7 @@ pub async fn publish_tombstone( } // Tombstone also expires: after TTL the relay may drop it entirely, // which is the desired end state (nothing left to list). - let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS); + let expires = Timestamp::from(Timestamp::now().as_secs() + PRESENCE_TTL_SECS); let builder = EventBuilder::new(Kind::Custom(30078), "{}") .tag(Tag::identifier("archipelago-node")) .tag(Tag::expiration(expires)); @@ -326,7 +326,8 @@ pub async fn discover_nodes( client.disconnect().await; let mut nodes = Vec::new(); - let stale_cutoff = Timestamp::from(Timestamp::now().as_u64().saturating_sub(PRESENCE_TTL_SECS)); + let stale_cutoff = + Timestamp::from(Timestamp::now().as_secs().saturating_sub(PRESENCE_TTL_SECS)); for event in events { // Client-side staleness enforcement: pre-TTL events (and events from // relays that ignore NIP-40) would otherwise list dead installs diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 949cfb25..0c0c677d 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -1194,11 +1194,13 @@ impl Server { // Podman needs and can restart-loop apps that publish those ports. let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe())); let publishing_task = tokio::spawn(crate::publishing::serving::run( - self._config.data_dir.clone(), tx.subscribe(), + self._config.data_dir.clone(), + tx.subscribe(), )); let publishing_tor_task = tokio::spawn(crate::publishing::tor::run( - self._config.data_dir.clone(), tx.subscribe(), + self._config.data_dir.clone(), + tx.subscribe(), )); // The app gate: authentication in front of every app port, on every diff --git a/core/archipelago/src/snapshot_budget.rs b/core/archipelago/src/snapshot_budget.rs index b14ece63..a376645d 100644 --- a/core/archipelago/src/snapshot_budget.rs +++ b/core/archipelago/src/snapshot_budget.rs @@ -145,10 +145,7 @@ pub(crate) fn reserve_until( Err(error) if error .downcast_ref::() - .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => - { - () - } + .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {} Err(error) => return Err(error), } } diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 06bf14f4..a9bb616d 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -1496,25 +1496,28 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result .context("isolated test command failed"); } - let mut full: Vec<&str> = vec![ - "systemd-run", - "--wait", - "--quiet", - "--collect", - "--pipe", - // Shell snippets passed as one argument must reach the child intact. - // systemd-run otherwise expands $VAR/${VAR} against the manager's - // environment before `sh -lc` can see them (and usually replaces them - // with empty strings). - "--expand-environment=no", - "--", - ]; - full.extend_from_slice(args); - tokio::process::Command::new("sudo") - .args(&full) - .status() - .await - .context("sudo systemd-run spawn failed") + #[cfg(not(test))] + { + let mut full: Vec<&str> = vec![ + "systemd-run", + "--wait", + "--quiet", + "--collect", + "--pipe", + // Shell snippets passed as one argument must reach the child intact. + // systemd-run otherwise expands $VAR/${VAR} against the manager's + // environment before `sh -lc` can see them (and usually replaces them + // with empty strings). + "--expand-environment=no", + "--", + ]; + full.extend_from_slice(args); + tokio::process::Command::new("sudo") + .args(&full) + .status() + .await + .context("sudo systemd-run spawn failed") + } } /// Same mechanism as `host_sudo` but captures stdout — for read-only probes @@ -1535,21 +1538,24 @@ pub(crate) async fn host_sudo_output(args: &[&str]) -> Result = vec![ - "systemd-run", - "--wait", - "--quiet", - "--collect", - "--pipe", - "--expand-environment=no", - "--", - ]; - full.extend_from_slice(args); - tokio::process::Command::new("sudo") - .args(&full) - .output() - .await - .context("sudo systemd-run output spawn failed") + #[cfg(not(test))] + { + let mut full: Vec<&str> = vec![ + "systemd-run", + "--wait", + "--quiet", + "--collect", + "--pipe", + "--expand-environment=no", + "--", + ]; + full.extend_from_slice(args); + tokio::process::Command::new("sudo") + .args(&full) + .output() + .await + .context("sudo systemd-run output spawn failed") + } } /// Apply a downloaded update. Backs up current binaries, replaces with staged versions. diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index 49869114..a7ba575d 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -16,7 +16,7 @@ use super::nut13::RecoverySource; use anyhow::{Context, Result}; use bitcoin::secp256k1; use serde::{Deserialize, Serialize}; -use tracing::{debug, warn}; +use tracing::debug; /// Default timeout for mint API calls. const MINT_TIMEOUT_SECS: u64 = 10; @@ -83,13 +83,25 @@ impl std::fmt::Debug for PreparedSwap { } impl PreparedSwap { -// Add inside impl PreparedSwap; no mutability or proof/output secrets exposed. -pub(super) fn payment_keyset_id(&self) -> &str { &self.keyset.id } -pub(super) fn input_fee_sats(&self) -> Result { - let inputs = self.inputs.iter().try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)).context("Prepared input sum overflow")?; - let outputs = self.outputs.iter().try_fold(0u64, |sum, output| sum.checked_add(output.amount)).context("Prepared output sum overflow")?; - inputs.checked_sub(outputs).context("Prepared outputs exceed input value") -} + // Add inside impl PreparedSwap; no mutability or proof/output secrets exposed. + pub(super) fn payment_keyset_id(&self) -> &str { + &self.keyset.id + } + pub(super) fn input_fee_sats(&self) -> Result { + let inputs = self + .inputs + .iter() + .try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)) + .context("Prepared input sum overflow")?; + let outputs = self + .outputs + .iter() + .try_fold(0u64, |sum, output| sum.checked_add(output.amount)) + .context("Prepared output sum overflow")?; + inputs + .checked_sub(outputs) + .context("Prepared outputs exceed input value") + } pub(super) fn inputs(&self) -> &[Proof] { &self.inputs diff --git a/core/archipelago/src/wallet/mod.rs b/core/archipelago/src/wallet/mod.rs index e81ca125..143a9453 100644 --- a/core/archipelago/src/wallet/mod.rs +++ b/core/archipelago/src/wallet/mod.rs @@ -11,8 +11,8 @@ pub mod mint_client; pub(crate) mod mutation; pub mod nut13; pub mod profits; -mod send_journal; mod receive_journal; +mod send_journal; pub(crate) mod purchase_fee_plan; diff --git a/core/archipelago/src/wallet/payment_tests.rs b/core/archipelago/src/wallet/payment_tests.rs index e52abb72..8c398af6 100644 --- a/core/archipelago/src/wallet/payment_tests.rs +++ b/core/archipelago/src/wallet/payment_tests.rs @@ -2682,7 +2682,7 @@ async fn rental_catalog_term_mismatch_never_plans_or_creates_buyer_intent() { #[tokio::test] async fn unconfirmed_quote_can_cancel_and_requote_without_exposing_wallet_funds() { use crate::content_purchase_caller::{purchase, ReadyPurchase}; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::AtomicBool; let mint = Mint::start(0, None).await; let buyer = tempfile::tempdir().unwrap(); let seller = mint.wallet().await; diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index db20c45f..2f36c194 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -1795,8 +1795,10 @@ app: } } exempt.sort(); - // Reviewed 2026-09-30: lightning-stack's three retired endpoints + // Reviewed 2026-10-09: lightning-stack's three retired endpoints // disappeared; Cuprate restricted RPC moved from none to gate-open. + // DATUM's Stratum port is a raw public mining protocol; its separate + // administration interface remains gated and loopback-bound. // Compare exact endpoints, not just a count that can hide substitutions. let expected = [ ("bitcoin-core", 8333), @@ -1804,6 +1806,7 @@ app: ("core-lightning", 9736), ("core-lightning", 9835), ("cuprate", 18183), + ("datum", 23334), ("electrumx", 50001), ("fedimint", 8173), ("fedimint", 8174), @@ -1870,6 +1873,8 @@ app: // Angor's indexer exposes public chain data/transaction broadcast; // its optional standalone relay accepts signed public Nostr events. // Neither mounts credentials or the node's internal relay database. + // Gashboard performs its own NIP-98/access-list authentication on + // every data route before issuing or accepting a session. assert_eq!( open, vec![ @@ -1877,6 +1882,7 @@ app: ("angor-relay".to_string(), 8091u16), ("btcpay-server".to_string(), 23000u16), ("cuprate".to_string(), 18090u16), + ("gashboard".to_string(), 1337u16), ("gitea".to_string(), 3001u16), ("nginx-proxy-manager".to_string(), 8081u16), ("tailscale".to_string(), 8240u16), diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index 694b96c2..fe2cba8b 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -1002,7 +1002,10 @@ fn manifest_container_name(manifest: &AppManifest) -> String { } fn manifest_apps_dirs() -> Vec { - let mut dirs = Vec::new(); + // Keep source-tree discovery independent of the caller's working + // directory. Isolated test runners deliberately start in `core/`, while + // production uses one of the installed paths below. + let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps")]; if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { dirs.push(Path::new(&manifest_dir).join("../../apps")); } diff --git a/neode-ui/src/views/__tests__/PeerFilesLightning.test.ts b/neode-ui/src/views/__tests__/PeerFilesLightning.test.ts index 73ff9861..4d078df8 100644 --- a/neode-ui/src/views/__tests__/PeerFilesLightning.test.ts +++ b/neode-ui/src/views/__tests__/PeerFilesLightning.test.ts @@ -3,7 +3,10 @@ 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('vue-router', () => ({ + RouterLink: { template: '' }, + 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) diff --git a/neode-ui/src/views/__tests__/PeerFilesRefresh.test.ts b/neode-ui/src/views/__tests__/PeerFilesRefresh.test.ts index 60898f92..e01473a6 100644 --- a/neode-ui/src/views/__tests__/PeerFilesRefresh.test.ts +++ b/neode-ui/src/views/__tests__/PeerFilesRefresh.test.ts @@ -5,6 +5,7 @@ import PeerFiles from '../PeerFiles.vue' import { rpcClient } from '@/api/rpc-client' vi.mock('vue-router', () => ({ + RouterLink: { template: '' }, useRouter: () => ({ push: vi.fn() }), })) diff --git a/neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts b/neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts index 4879c89d..8b644986 100644 --- a/neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts +++ b/neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts @@ -454,6 +454,7 @@ describe('keepAliveLifecycle: 02-11 gap closure — leaked background pollers in FleetNodeDetail: true, FleetContainerMatrix: true, BackButton: true, + RouterLink: true, }, }, }) diff --git a/neode-ui/src/views/fleet/__tests__/FleetRecovery.test.ts b/neode-ui/src/views/fleet/__tests__/FleetRecovery.test.ts index 270863de..a48b848e 100644 --- a/neode-ui/src/views/fleet/__tests__/FleetRecovery.test.ts +++ b/neode-ui/src/views/fleet/__tests__/FleetRecovery.test.ts @@ -4,7 +4,10 @@ import Fleet from '../../Fleet.vue' import { rpcClient } from '@/api/rpc-client' vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } })) -vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) +vi.mock('vue-router', () => ({ + RouterLink: { template: '' }, + useRouter: () => ({ push: vi.fn() }), +})) afterEach(() => { sessionStorage.clear(); vi.clearAllMocks() }) const stubs = { diff --git a/neode-ui/src/views/goals/goalStepActions.ts b/neode-ui/src/views/goals/goalStepActions.ts index 647c765e..7ddfdd77 100644 --- a/neode-ui/src/views/goals/goalStepActions.ts +++ b/neode-ui/src/views/goals/goalStepActions.ts @@ -7,6 +7,8 @@ const STEP_ROUTE_OVERRIDES: Record = { 'create-passphrase': '/dashboard/settings', 'create-backup': '/dashboard/settings', 'save-backup': '/dashboard/settings', + 'external-access': '/dashboard/setup/external-access', + 'publish-website': '/dashboard/setup/website', // Channel steps land directly on the Lightning channels screen (which // carries the "open a channel with Zeus" suggestion). 'open-channel': '/dashboard/apps/lnd/channels',