fix: restore green source validation baseline
This commit is contained in:
@@ -31,7 +31,7 @@ use futures_util::{SinkExt, StreamExt};
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
const CDP_HTTP: &str = "http://127.0.0.1:9222";
|
const CDP_HTTP: &str = "http://127.0.0.1:9222";
|
||||||
/// Marker whose presence means this node drives a local kiosk display.
|
/// Marker whose presence means this node drives a local kiosk display.
|
||||||
|
|||||||
@@ -583,15 +583,17 @@ impl ApiHandler {
|
|||||||
paid = true;
|
paid = true;
|
||||||
}
|
}
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Err(_) => return Ok(build_response(
|
Err(_) => {
|
||||||
StatusCode::OK,
|
return Ok(build_response(
|
||||||
"application/json",
|
StatusCode::OK,
|
||||||
hyper::Body::from(serde_json::to_vec(&serde_json::json!({
|
"application/json",
|
||||||
"paid": false,
|
hyper::Body::from(serde_json::to_vec(&serde_json::json!({
|
||||||
"status": "unknown",
|
"paid": false,
|
||||||
"error": "Exact on-chain outputs could not be verified. Keep the original payment address and do not pay again."
|
"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 });
|
let body = serde_json::json!({ "paid": paid });
|
||||||
|
|||||||
@@ -193,16 +193,6 @@ impl ApiHandler {
|
|||||||
let data = self.config.data_dir.clone();
|
let data = self.config.data_dir.clone();
|
||||||
let id = binding.content_id.clone();
|
let id = binding.content_id.clone();
|
||||||
let retained = source.clone();
|
let retained = source.clone();
|
||||||
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
|
|
||||||
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 || {
|
let snapshot = tokio::task::spawn_blocking(move || {
|
||||||
crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
|
crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1013,19 +1013,28 @@ impl RpcHandler {
|
|||||||
|
|
||||||
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;
|
||||||
let path = format!("/content/{}/onchain-status/{}", content_id, address);
|
let path = format!("/content/{}/onchain-status/{}", content_id, address);
|
||||||
let (response, _transport) =
|
let (response, _transport) = match crate::fips::dial::PeerRequest::new(
|
||||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
fips_npub.as_deref(),
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
onion,
|
||||||
.timeout(std::time::Duration::from_secs(15))
|
&path,
|
||||||
.fips_timeout(std::time::Duration::from_secs(6))
|
)
|
||||||
.send_content_get(&self.config.data_dir)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.await
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
{
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
Ok(v) => v,
|
.send_content_get(&self.config.data_dir)
|
||||||
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." })),
|
.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() {
|
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
|
let body: serde_json::Value = response
|
||||||
.json()
|
.json()
|
||||||
|
|||||||
@@ -114,17 +114,34 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Explicit owner-key import into a separate native business identity.
|
/// Explicit owner-key import into a separate native business identity.
|
||||||
pub(in crate::api::rpc) async fn handle_identity_import_nostr(
|
pub(in crate::api::rpc) async fn handle_identity_import_nostr(
|
||||||
&self, params: Option<serde_json::Value>,
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
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? {
|
if !self.auth_manager.verify_password(password).await? {
|
||||||
anyhow::bail!("Invalid node password");
|
anyhow::bail!("Invalid node password");
|
||||||
}
|
}
|
||||||
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("Just Works");
|
let name = params
|
||||||
anyhow::ensure!(!name.trim().is_empty() && name.len() <= 100, "Invalid identity name");
|
.get("name")
|
||||||
let nsec = params.get("nsec").and_then(|v| v.as_str()).unwrap_or("").trim();
|
.and_then(|v| v.as_str())
|
||||||
let npub = params.get("expected_npub").and_then(|v| v.as_str()).unwrap_or("");
|
.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 manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||||
let record = manager.import_nostr(name.to_string(), nsec, npub).await?;
|
let record = manager.import_nostr(name.to_string(), nsec, npub).await?;
|
||||||
Ok(serde_json::json!({"id":record.id, "name":record.name,
|
Ok(serde_json::json!({"id":record.id, "name":record.name,
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ fn verified_resolution(input: serde_json::Value, now: u64) -> Result<ResolvePara
|
|||||||
let encoded = serde_json::to_value(event)?;
|
let encoded = serde_json::to_value(event)?;
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
encoded["tags"] == serde_json::json!([["d", RESOLUTION_DOMAIN]])
|
encoded["tags"] == serde_json::json!([["d", RESOLUTION_DOMAIN]])
|
||||||
&& event.created_at.as_u64() >= params.intent.created_at.saturating_sub(30)
|
&& event.created_at.as_secs() >= params.intent.created_at.saturating_sub(30)
|
||||||
&& event.created_at.as_u64() <= now.saturating_add(30),
|
&& event.created_at.as_secs() <= now.saturating_add(30),
|
||||||
"Invalid resolution signature time or scope"
|
"Invalid resolution signature time or scope"
|
||||||
);
|
);
|
||||||
let content: serde_json::Value = serde_json::from_str(&event.content)?;
|
let content: serde_json::Value = serde_json::from_str(&event.content)?;
|
||||||
@@ -103,7 +103,7 @@ fn verified_producer(params: &Params, now: u64) -> Result<String> {
|
|||||||
producer == params.intent.producer,
|
producer == params.intent.producer,
|
||||||
"The signing identity differs from the project 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!(
|
anyhow::ensure!(
|
||||||
created >= params.intent.created_at.saturating_sub(30)
|
created >= params.intent.created_at.saturating_sub(30)
|
||||||
&& created < params.intent.expires_at
|
&& created < params.intent.expires_at
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ mod handshake;
|
|||||||
mod identity;
|
mod identity;
|
||||||
mod interfaces;
|
mod interfaces;
|
||||||
mod lightning_purchase;
|
mod lightning_purchase;
|
||||||
mod onchain_purchase;
|
|
||||||
pub(crate) mod lnd;
|
pub(crate) mod lnd;
|
||||||
mod marketplace;
|
mod marketplace;
|
||||||
mod media_registration;
|
mod media_registration;
|
||||||
|
mod onchain_purchase;
|
||||||
mod playback;
|
mod playback;
|
||||||
mod purchase;
|
mod purchase;
|
||||||
// pub(crate): 13-10's `assistant::backends::select_backend` reuses
|
// pub(crate): 13-10's `assistant::backends::select_backend` reuses
|
||||||
@@ -34,12 +34,12 @@ mod monitoring;
|
|||||||
mod music;
|
mod music;
|
||||||
mod names;
|
mod names;
|
||||||
mod network;
|
mod network;
|
||||||
mod publishing;
|
|
||||||
mod node;
|
mod node;
|
||||||
mod nostr;
|
mod nostr;
|
||||||
mod onboarding_gate;
|
mod onboarding_gate;
|
||||||
mod openwrt;
|
mod openwrt;
|
||||||
mod package;
|
mod package;
|
||||||
|
mod publishing;
|
||||||
pub(crate) use package::patch_indeedhub_nostr_provider;
|
pub(crate) use package::patch_indeedhub_nostr_provider;
|
||||||
pub(crate) use package::wyoming_satellite_keeper;
|
pub(crate) use package::wyoming_satellite_keeper;
|
||||||
mod peers;
|
mod peers;
|
||||||
@@ -112,7 +112,6 @@ fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_m
|
|||||||
| "media.registration.context"
|
| "media.registration.context"
|
||||||
| "media.registration.resolve"
|
| "media.registration.resolve"
|
||||||
| "content.rental-purchase"
|
| "content.rental-purchase"
|
||||||
|
|
||||||
| "content.onchain-cancel"
|
| "content.onchain-cancel"
|
||||||
| "content.onchain-attempt"
|
| "content.onchain-attempt"
|
||||||
| "content.onchain-create"
|
| "content.onchain-create"
|
||||||
|
|||||||
@@ -442,7 +442,7 @@ impl RpcHandler {
|
|||||||
if record.quote.is_none() {
|
if record.quote.is_none() {
|
||||||
engine::mark_address_allocation(&journal, true)?;
|
engine::mark_address_allocation(&journal, true)?;
|
||||||
let status = self.request_onchain_allocation(&record, &fips).await?;
|
let status = self.request_onchain_allocation(&record, &fips).await?;
|
||||||
record = engine::accept_quote(
|
engine::accept_quote(
|
||||||
&journal,
|
&journal,
|
||||||
status.quote()?.context(
|
status.quote()?.context(
|
||||||
"Original seller allocation is unresolved; recover this operation",
|
"Original seller allocation is unresolved; recover this operation",
|
||||||
|
|||||||
@@ -883,7 +883,8 @@ async fn do_orchestrator_package_start(
|
|||||||
if i > 0 {
|
if i > 0 {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
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 {
|
if !managed {
|
||||||
repair_before_package_start(name).await;
|
repair_before_package_start(name).await;
|
||||||
wait_before_package_start(name).await;
|
wait_before_package_start(name).await;
|
||||||
@@ -1145,7 +1146,8 @@ async fn do_orchestrator_package_stop(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
for name in containers {
|
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 {
|
match orchestrator.stop(name).await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) if !managed && is_unknown_app_id_error(&e) => {
|
Err(e) if !managed && is_unknown_app_id_error(&e) => {
|
||||||
|
|||||||
@@ -2402,7 +2402,10 @@ impl ProdContainerOrchestrator {
|
|||||||
return Ok(ReconcileAction::Left("user-uninstalled".into()));
|
return Ok(ReconcileAction::Left("user-uninstalled".into()));
|
||||||
}
|
}
|
||||||
self.sync_quadlet_unit(lm, &managed_name).await?;
|
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")?;
|
.context("Reviewed managed runtime is missing; explicit recovery required")?;
|
||||||
anyhow::ensure!(matches!(status.state, ContainerState::Running),
|
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");
|
"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()
|
.clone()
|
||||||
.unwrap_or_else(|| "bitcoin-knots".to_string());
|
.unwrap_or_else(|| "bitcoin-knots".to_string());
|
||||||
}
|
}
|
||||||
#[allow(unreachable_code)]
|
#[cfg(not(test))]
|
||||||
// The known Bitcoin node containers, preferred in order. Any archy
|
{
|
||||||
// Bitcoin distribution runs as a container named `bitcoin-<distro>`
|
// The known Bitcoin node containers, preferred in order. Any archy
|
||||||
// (or bare `bitcoin`), all reachable on archy-net by name.
|
// Bitcoin distribution runs as a container named `bitcoin-<distro>`
|
||||||
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
// (or bare `bitcoin`), all reachable on archy-net by name.
|
||||||
let names = tokio::process::Command::new("podman")
|
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||||
.args(["ps", "--format", "{{.Names}}"])
|
let names = tokio::process::Command::new("podman")
|
||||||
.output()
|
.args(["ps", "--format", "{{.Names}}"])
|
||||||
.await
|
.output()
|
||||||
.ok()
|
.await
|
||||||
.filter(|o| o.status.success())
|
.ok()
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
.filter(|o| o.status.success())
|
||||||
.unwrap_or_default();
|
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||||
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
|
.unwrap_or_default();
|
||||||
// Prefer a known name in priority order…
|
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
|
||||||
if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
|
// Prefer a known name in priority order…
|
||||||
return hit.to_string();
|
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)]
|
#[cfg(test)]
|
||||||
@@ -5227,8 +5232,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
|||||||
};
|
};
|
||||||
let name = compute_container_name(&lm.manifest);
|
let name = compute_container_name(&lm.manifest);
|
||||||
if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() {
|
if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() {
|
||||||
anyhow::ensure!(!super::update_transaction::is_held(&self.data_dir, &name)?,
|
anyhow::ensure!(
|
||||||
"Reviewed managed runtime is held for update recovery");
|
!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.sync_quadlet_unit(&lm, &name).await?;
|
||||||
self.ensure_resolved_source_available(&lm).await?;
|
self.ensure_resolved_source_available(&lm).await?;
|
||||||
}
|
}
|
||||||
@@ -5328,13 +5335,18 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
|||||||
let _guard = lock.lock().await;
|
let _guard = lock.lock().await;
|
||||||
let name = compute_container_name(&lm.manifest);
|
let name = compute_container_name(&lm.manifest);
|
||||||
if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() {
|
if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() {
|
||||||
anyhow::ensure!(!super::update_transaction::is_held(&self.data_dir, &name)?,
|
anyhow::ensure!(
|
||||||
"Reviewed managed runtime is held for update recovery");
|
!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.sync_quadlet_unit(&lm, &name).await?;
|
||||||
quadlet::stop_service(&format!("{name}.service")).await?;
|
quadlet::stop_service(&format!("{name}.service")).await?;
|
||||||
if let Ok(status) = self.runtime.get_container_status(&name).await {
|
if let Ok(status) = self.runtime.get_container_status(&name).await {
|
||||||
anyhow::ensure!(
|
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"
|
"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<()> {
|
async fn restart(&self, app_id: &str) -> Result<()> {
|
||||||
if let Ok(lm) = self.loaded(app_id).await {
|
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.validate_start(app_id).await?;
|
||||||
self.stop(app_id).await?;
|
self.stop(app_id).await?;
|
||||||
return self.start(app_id).await;
|
return self.start(app_id).await;
|
||||||
@@ -7966,27 +7983,59 @@ app:
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reviewed_runtime_survives_catalog_drift_and_refuses_repairs_before_mutation() {
|
async fn reviewed_runtime_survives_catalog_drift_and_refuses_repairs_before_mutation() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
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 rt = Arc::new(MockRuntime::default());
|
||||||
let orch = orch_with(rt.clone()).await;
|
let orch = orch_with(rt.clone()).await;
|
||||||
let name = format!("managed-{}", uuid::Uuid::new_v4().simple());
|
let name = format!("managed-{}", uuid::Uuid::new_v4().simple());
|
||||||
let mut manifest = pull_manifest(&name, "catalog:new");
|
let mut manifest = pull_manifest(&name, "catalog:new");
|
||||||
manifest.app.environment = vec!["NEW_CATALOG_ENV=changed".into()];
|
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 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");
|
let records = orch.data_dir.join("update-transactions/installed-units");
|
||||||
std::fs::create_dir_all(&records).unwrap();
|
std::fs::create_dir_all(&records).unwrap();
|
||||||
std::fs::write(records.join(format!("{name}.json")), serde_json::to_vec(&serde_json::json!({
|
std::fs::write(
|
||||||
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
|
records.join(format!("{name}.json")),
|
||||||
"name": name, "body": body, "mode": 0o600
|
serde_json::to_vec(&serde_json::json!({
|
||||||
})).unwrap()).unwrap();
|
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
|
||||||
let unit = quadlet::unit_dir().await.unwrap().join(format!("{name}.container"));
|
"name": name, "body": body, "mode": 0o600
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let unit = quadlet::unit_dir()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.join(format!("{name}.container"));
|
||||||
if case != "missing-unit" {
|
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();
|
std::fs::set_permissions(&unit, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
}
|
}
|
||||||
if case != "missing" {
|
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" {
|
if case == "user-stopped" {
|
||||||
crate::crash_recovery::mark_user_stopped(&orch.data_dir, &name).await;
|
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!(result.is_err(), "{case} must refuse before mutation");
|
||||||
}
|
}
|
||||||
assert_eq!(*rt.containers.lock().unwrap(), before, "{case}");
|
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" {
|
if case == "running" {
|
||||||
assert_eq!(std::fs::read_to_string(&unit).unwrap(), body);
|
assert_eq!(std::fs::read_to_string(&unit).unwrap(), body);
|
||||||
}
|
}
|
||||||
@@ -8026,8 +8081,14 @@ app:
|
|||||||
orch.validate_start(&name).await.unwrap();
|
orch.validate_start(&name).await.unwrap();
|
||||||
orch.start(&name).await.unwrap();
|
orch.start(&name).await.unwrap();
|
||||||
assert_eq!(std::fs::read_to_string(&unit).unwrap(), body);
|
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_stopped(&orch.data_dir)
|
||||||
assert!(!crate::crash_recovery::load_user_uninstalled(&orch.data_dir).await.contains(&name));
|
.await
|
||||||
|
.contains(&name));
|
||||||
|
assert!(
|
||||||
|
!crate::crash_recovery::load_user_uninstalled(&orch.data_dir)
|
||||||
|
.await
|
||||||
|
.contains(&name)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Missing images/units and modified units refuse an explicit
|
// Missing images/units and modified units refuse an explicit
|
||||||
// start before service mutation; no catalog pull is attempted.
|
// start before service mutation; no catalog pull is attempted.
|
||||||
@@ -8037,7 +8098,14 @@ app:
|
|||||||
assert!(orch.stop(&name).await.is_err());
|
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}");
|
assert_eq!(*rt.containers.lock().unwrap(), before, "{case}");
|
||||||
let _ = std::fs::remove_file(unit);
|
let _ = std::fs::remove_file(unit);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -742,14 +742,17 @@ pub async fn unit_dir() -> Result<PathBuf> {
|
|||||||
.get_or_init(|| tempfile::tempdir().unwrap().keep())
|
.get_or_init(|| tempfile::tempdir().unwrap().keep())
|
||||||
.clone());
|
.clone());
|
||||||
}
|
}
|
||||||
let home = std::env::var_os("HOME")
|
#[cfg(not(test))]
|
||||||
.map(PathBuf::from)
|
{
|
||||||
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
|
let home = std::env::var_os("HOME")
|
||||||
let dir = home.join(DEFAULT_REL_UNIT_DIR);
|
.map(PathBuf::from)
|
||||||
fs::create_dir_all(&dir)
|
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
|
||||||
.await
|
let dir = home.join(DEFAULT_REL_UNIT_DIR);
|
||||||
.with_context(|| format!("create_dir_all {}", dir.display()))?;
|
fs::create_dir_all(&dir)
|
||||||
Ok(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
|
/// The early same-node Portainer repair used a managed Quadlet drop-in. Once
|
||||||
@@ -944,21 +947,25 @@ async fn systemctl_user_status(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
{
|
{
|
||||||
use std::os::unix::process::ExitStatusExt;
|
use std::os::unix::process::ExitStatusExt;
|
||||||
|
let _ = (args, timeout);
|
||||||
return Ok(std::process::ExitStatus::from_raw(0));
|
return Ok(std::process::ExitStatus::from_raw(0));
|
||||||
}
|
}
|
||||||
let mut cmd = Command::new("systemctl");
|
#[cfg(not(test))]
|
||||||
cmd.arg("--user").args(args);
|
{
|
||||||
cmd.kill_on_drop(true);
|
let mut cmd = Command::new("systemctl");
|
||||||
tokio::time::timeout(timeout, cmd.status())
|
cmd.arg("--user").args(args);
|
||||||
.await
|
cmd.kill_on_drop(true);
|
||||||
.with_context(|| {
|
tokio::time::timeout(timeout, cmd.status())
|
||||||
format!(
|
.await
|
||||||
"systemctl --user {} timed out after {}s",
|
.with_context(|| {
|
||||||
args.join(" "),
|
format!(
|
||||||
timeout.as_secs()
|
"systemctl --user {} timed out after {}s",
|
||||||
)
|
args.join(" "),
|
||||||
})?
|
timeout.as_secs()
|
||||||
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
|
)
|
||||||
|
})?
|
||||||
|
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn kill_and_reset_service(service: &str) -> Result<()> {
|
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<std::process::Output> {
|
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
{
|
{
|
||||||
|
let _ = (args, timeout);
|
||||||
anyhow::bail!("Unit tests have no real user service manager");
|
anyhow::bail!("Unit tests have no real user service manager");
|
||||||
}
|
}
|
||||||
let mut cmd = Command::new("systemctl");
|
#[cfg(not(test))]
|
||||||
cmd.arg("--user").args(args);
|
{
|
||||||
cmd.kill_on_drop(true);
|
let mut cmd = Command::new("systemctl");
|
||||||
tokio::time::timeout(timeout, cmd.output())
|
cmd.arg("--user").args(args);
|
||||||
.await
|
cmd.kill_on_drop(true);
|
||||||
.with_context(|| {
|
tokio::time::timeout(timeout, cmd.output())
|
||||||
format!(
|
.await
|
||||||
"systemctl --user {} timed out after {}s",
|
.with_context(|| {
|
||||||
args.join(" "),
|
format!(
|
||||||
timeout.as_secs()
|
"systemctl --user {} timed out after {}s",
|
||||||
)
|
args.join(" "),
|
||||||
})?
|
timeout.as_secs()
|
||||||
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
|
)
|
||||||
|
})?
|
||||||
|
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn contains_stale_health_gate(unit_body: &str) -> bool {
|
pub fn contains_stale_health_gate(unit_body: &str) -> bool {
|
||||||
|
|||||||
@@ -171,10 +171,7 @@ pub(crate) fn prepare(
|
|||||||
Err(error)
|
Err(error)
|
||||||
if error
|
if error
|
||||||
.downcast_ref::<std::io::Error>()
|
.downcast_ref::<std::io::Error>()
|
||||||
.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),
|
Err(error) => return Err(error),
|
||||||
}
|
}
|
||||||
let reservation = crate::snapshot_budget::reserve(
|
let reservation = crate::snapshot_budget::reserve(
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ pub const FIPS_IFACE: &str = "fips0";
|
|||||||
/// - Link-local (`fe80::/10`) and non-ULA addresses are ignored — we
|
/// - Link-local (`fe80::/10`) and non-ULA addresses are ignored — we
|
||||||
/// only want the mesh-routable ULA that `<npub>.fips` DNS resolves to.
|
/// only want the mesh-routable ULA that `<npub>.fips` DNS resolves to.
|
||||||
pub fn fips0_ula() -> Option<Ipv6Addr> {
|
pub fn fips0_ula() -> Option<Ipv6Addr> {
|
||||||
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
|
/// List every IPv6 address bound to a given interface from
|
||||||
|
|||||||
@@ -946,7 +946,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
|||||||
}
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
pkg.state,
|
pkg.state,
|
||||||
PackageState::Starting | PackageState::Stopping | PackageState::Restarting | PackageState::Updating
|
PackageState::Starting
|
||||||
|
| PackageState::Stopping
|
||||||
|
| PackageState::Restarting
|
||||||
|
| PackageState::Updating
|
||||||
) {
|
) {
|
||||||
debug!(
|
debug!(
|
||||||
"Skipping container during package lifecycle transition: {} ({:?})",
|
"Skipping container during package lifecycle transition: {} ({:?})",
|
||||||
@@ -1069,7 +1072,8 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
|||||||
app_id: Some(container.app_id.clone()),
|
app_id: Some(container.app_id.clone()),
|
||||||
});
|
});
|
||||||
if data.notifications.len() > 20 {
|
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;
|
state_changed = true;
|
||||||
}
|
}
|
||||||
@@ -1164,7 +1168,8 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
|||||||
// the restart resyncs cleanly instead of crash-looping.
|
// the restart resyncs cleanly instead of crash-looping.
|
||||||
maybe_recover_corrupt_electrumx(&container.name, attempt).await;
|
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 {
|
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
|
||||||
let notification = Notification {
|
let notification = Notification {
|
||||||
@@ -1249,10 +1254,15 @@ mod tests {
|
|||||||
let installed = root.path().join("update-transactions/installed-units");
|
let installed = root.path().join("update-transactions/installed-units");
|
||||||
std::fs::create_dir_all(&installed).unwrap();
|
std::fs::create_dir_all(&installed).unwrap();
|
||||||
let record = installed.join(format!("{name}.json"));
|
let record = installed.join(format!("{name}.json"));
|
||||||
std::fs::write(&record, serde_json::to_vec(&serde_json::json!({
|
std::fs::write(
|
||||||
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
|
&record,
|
||||||
"name": name, "body": "[Container]\nImage=original:retained\n", "mode": 0o600
|
serde_json::to_vec(&serde_json::json!({
|
||||||
})).unwrap()).unwrap();
|
"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!(!automatic_recovery_allowed(root.path(), name));
|
||||||
assert!(!restart_container(name, "running", root.path()).await);
|
assert!(!restart_container(name, "running", root.path()).await);
|
||||||
std::fs::write(&record, b"damaged").unwrap();
|
std::fs::write(&record, b"damaged").unwrap();
|
||||||
|
|||||||
@@ -205,14 +205,22 @@ impl IdentityManager {
|
|||||||
nsec: &str,
|
nsec: &str,
|
||||||
expected_npub: &str,
|
expected_npub: &str,
|
||||||
) -> Result<IdentityRecord> {
|
) -> Result<IdentityRecord> {
|
||||||
anyhow::ensure!(!name.trim().is_empty() && name.len() <= 100, "Invalid identity name");
|
anyhow::ensure!(
|
||||||
anyhow::ensure!(nsec.starts_with("nsec1") && nsec.len() == 63, "Enter a plain nsec owner key");
|
!name.trim().is_empty() && name.len() <= 100,
|
||||||
let secret = nostr_sdk::SecretKey::parse(nsec)
|
"Invalid identity name"
|
||||||
.map_err(|_| anyhow::anyhow!("Invalid owner key"))?;
|
);
|
||||||
|
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 keys = nostr_sdk::Keys::new(secret);
|
||||||
let nostr_pubkey = keys.public_key().to_hex();
|
let nostr_pubkey = keys.public_key().to_hex();
|
||||||
anyhow::ensure!(keys.public_key().to_bech32()? == expected_npub,
|
anyhow::ensure!(
|
||||||
"Owner key does not match this website");
|
keys.public_key().to_bech32()? == expected_npub,
|
||||||
|
"Owner key does not match this website"
|
||||||
|
);
|
||||||
|
|
||||||
// Serializes imports only; mature creation/signing paths are untouched.
|
// Serializes imports only; mature creation/signing paths are untouched.
|
||||||
static IMPORT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
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.
|
// Atomic publication, and unlike rename this cannot replace a file.
|
||||||
fs::hard_link(&staging, &destination).await?;
|
fs::hard_link(&staging, &destination).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}.await;
|
}
|
||||||
|
.await;
|
||||||
let _ = fs::remove_file(&staging).await;
|
let _ = fs::remove_file(&staging).await;
|
||||||
write_result.context("Could not save imported identity")?;
|
write_result.context("Could not save imported identity")?;
|
||||||
self.get(&id).await
|
self.get(&id).await
|
||||||
@@ -974,23 +983,53 @@ mod tests {
|
|||||||
async fn import_nostr_preserves_identities_and_rejects_mismatches() {
|
async fn import_nostr_preserves_identities_and_rejects_mismatches() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let manager = IdentityManager::new(dir.path()).await.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 keys = nostr_sdk::Keys::generate();
|
||||||
let nsec = keys.secret_key().to_bech32().unwrap();
|
let nsec = keys.secret_key().to_bech32().unwrap();
|
||||||
let npub = keys.public_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);
|
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!(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();
|
let (records, default) = manager.list().await.unwrap();
|
||||||
assert_eq!(records.len(), 2);
|
assert_eq!(records.len(), 2);
|
||||||
assert_eq!(default.as_deref(), Some(original.id.as_str()));
|
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!(
|
||||||
assert_eq!(manager.export_keys(&imported.id).await.unwrap()["nostr_nsec"], nsec);
|
manager.get(&original.id).await.unwrap().nostr_pubkey,
|
||||||
#[cfg(unix)] {
|
original.nostr_pubkey
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manager.export_keys(&imported.id).await.unwrap()["nostr_nsec"],
|
||||||
|
nsec
|
||||||
|
);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
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);
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1012,14 +1051,24 @@ mod tests {
|
|||||||
assert_eq!(records.len(), 1);
|
assert_eq!(records.len(), 1);
|
||||||
assert!(default.is_none());
|
assert!(default.is_none());
|
||||||
let hash = [7u8; 32];
|
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 signature: nostr_sdk::secp256k1::schnorr::Signature = signature.parse().unwrap();
|
||||||
let pubkey: nostr_sdk::secp256k1::XOnlyPublicKey = keys.public_key().to_hex().parse().unwrap();
|
let pubkey: nostr_sdk::secp256k1::XOnlyPublicKey =
|
||||||
nostr_sdk::secp256k1::Secp256k1::verification_only().verify_schnorr(
|
keys.public_key().to_hex().parse().unwrap();
|
||||||
&signature, &nostr_sdk::secp256k1::Message::from_digest(hash), &pubkey,
|
nostr_sdk::secp256k1::Secp256k1::verification_only()
|
||||||
).unwrap();
|
.verify_schnorr(
|
||||||
|
&signature,
|
||||||
|
&nostr_sdk::secp256k1::Message::from_digest(hash),
|
||||||
|
&pubkey,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let entries = std::fs::read_dir(dir.path().join("identities")).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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ mod monitoring;
|
|||||||
mod music;
|
mod music;
|
||||||
mod names;
|
mod names;
|
||||||
mod network;
|
mod network;
|
||||||
mod publishing;
|
|
||||||
mod node_message;
|
mod node_message;
|
||||||
mod nostr_discovery;
|
mod nostr_discovery;
|
||||||
mod nostr_handshake;
|
mod nostr_handshake;
|
||||||
@@ -87,6 +86,7 @@ mod nostr_security_tests;
|
|||||||
mod peers;
|
mod peers;
|
||||||
mod port_allocator;
|
mod port_allocator;
|
||||||
mod prepared_media;
|
mod prepared_media;
|
||||||
|
mod publishing;
|
||||||
mod rate_limit;
|
mod rate_limit;
|
||||||
mod registered_media;
|
mod registered_media;
|
||||||
mod rental_chunk_index;
|
mod rental_chunk_index;
|
||||||
|
|||||||
@@ -683,10 +683,7 @@ pub fn resolve(
|
|||||||
Err(error)
|
Err(error)
|
||||||
if error
|
if error
|
||||||
.downcast_ref::<std::io::Error>()
|
.downcast_ref::<std::io::Error>()
|
||||||
.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),
|
Err(error) => return Err(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,22 +113,19 @@ const PORT_FREE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10
|
|||||||
/// resource is gone yet).
|
/// resource is gone yet).
|
||||||
async fn wait_for_port_free(path: &str) -> Result<()> {
|
async fn wait_for_port_free(path: &str) -> Result<()> {
|
||||||
let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT;
|
let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT;
|
||||||
let mut last_err = None;
|
|
||||||
loop {
|
loop {
|
||||||
match serial2_tokio::SerialPort::open(path, 115200) {
|
match serial2_tokio::SerialPort::open(path, 115200) {
|
||||||
Ok(_) => return Ok(()),
|
Ok(_) => return Ok(()),
|
||||||
Err(e) => last_err = Some(e),
|
Err(error) if tokio::time::Instant::now() >= deadline => {
|
||||||
}
|
return Err(anyhow::anyhow!(
|
||||||
if tokio::time::Instant::now() >= deadline {
|
"{path} is still held open by something else after {}s (last error: {error}) — refusing to start the flasher against a contended port",
|
||||||
break;
|
PORT_FREE_TIMEOUT.as_secs(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
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
|
/// Live state for the one flash job that can run at a time. A single global
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ mod frames;
|
|||||||
mod node_cmd;
|
mod node_cmd;
|
||||||
mod session;
|
mod session;
|
||||||
|
|
||||||
pub(crate) use session::{probe_device, DeviceProbe};
|
pub(crate) use session::probe_device;
|
||||||
|
|
||||||
use super::types::*;
|
use super::types::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ async fn read_disk_usage() -> Result<(u64, u64)> {
|
|||||||
};
|
};
|
||||||
let mut command = tokio::process::Command::new("df");
|
let mut command = tokio::process::Command::new("df");
|
||||||
command.args(["--block-size=1", "--output=used,size", target]);
|
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")?;
|
.context("Failed to run df")?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
@@ -222,7 +223,8 @@ async fn bounded_output(
|
|||||||
) -> Result<std::process::Output> {
|
) -> Result<std::process::Output> {
|
||||||
command.kill_on_drop(true);
|
command.kill_on_drop(true);
|
||||||
tokio::time::timeout(timeout, command.output())
|
tokio::time::timeout(timeout, command.output())
|
||||||
.await.context("Metrics subprocess timed out")?
|
.await
|
||||||
|
.context("Metrics subprocess timed out")?
|
||||||
.context("Metrics subprocess failed")
|
.context("Metrics subprocess failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +232,8 @@ async fn bounded_output(
|
|||||||
async fn read_container_stats() -> Result<Vec<ContainerMetrics>> {
|
async fn read_container_stats() -> Result<Vec<ContainerMetrics>> {
|
||||||
let mut command = tokio::process::Command::new("podman");
|
let mut command = tokio::process::Command::new("podman");
|
||||||
command.args(["stats", "--no-stream", "--format", "json"]);
|
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")?;
|
.context("Failed to run podman stats")?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
@@ -411,14 +414,22 @@ mod subprocess_deadline_tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let pid_file = dir.path().join("pid");
|
let pid_file = dir.path().join("pid");
|
||||||
let mut command = tokio::process::Command::new("sh");
|
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 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!(error.to_string().contains("timed out"));
|
||||||
assert!(start.elapsed() < std::time::Duration::from_secs(3));
|
assert!(start.elapsed() < std::time::Duration::from_secs(3));
|
||||||
let pid = tokio::fs::read_to_string(pid_file).await.unwrap();
|
let pid = tokio::fs::read_to_string(pid_file).await.unwrap();
|
||||||
for _ in 0..40 {
|
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;
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||||
}
|
}
|
||||||
panic!("Timed-out metrics subprocess was not reaped");
|
panic!("Timed-out metrics subprocess was not reaped");
|
||||||
@@ -428,7 +439,9 @@ mod subprocess_deadline_tests {
|
|||||||
async fn successful_metrics_output_is_preserved() {
|
async fn successful_metrics_output_is_preserved() {
|
||||||
let mut command = tokio::process::Command::new("printf");
|
let mut command = tokio::process::Command::new("printf");
|
||||||
command.arg("metrics-ok");
|
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!(output.status.success());
|
||||||
assert_eq!(output.stdout, b"metrics-ok");
|
assert_eq!(output.stdout, b"metrics-ok");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ pub async fn publish_presence(
|
|||||||
// NIP-40 expiration: relays that honour it garbage-collect the event if
|
// NIP-40 expiration: relays that honour it garbage-collect the event if
|
||||||
// this node stops heartbeating (reinstall, decommission, long outage).
|
// this node stops heartbeating (reinstall, decommission, long outage).
|
||||||
// `discover` enforces the same window client-side for relays that don't.
|
// `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)
|
let builder = EventBuilder::new(Kind::Custom(30078), content)
|
||||||
.tag(Tag::identifier("archipelago-node"))
|
.tag(Tag::identifier("archipelago-node"))
|
||||||
.tag(Tag::expiration(expires));
|
.tag(Tag::expiration(expires));
|
||||||
@@ -268,7 +268,7 @@ pub async fn publish_tombstone(
|
|||||||
}
|
}
|
||||||
// Tombstone also expires: after TTL the relay may drop it entirely,
|
// Tombstone also expires: after TTL the relay may drop it entirely,
|
||||||
// which is the desired end state (nothing left to list).
|
// 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), "{}")
|
let builder = EventBuilder::new(Kind::Custom(30078), "{}")
|
||||||
.tag(Tag::identifier("archipelago-node"))
|
.tag(Tag::identifier("archipelago-node"))
|
||||||
.tag(Tag::expiration(expires));
|
.tag(Tag::expiration(expires));
|
||||||
@@ -326,7 +326,8 @@ pub async fn discover_nodes(
|
|||||||
client.disconnect().await;
|
client.disconnect().await;
|
||||||
|
|
||||||
let mut nodes = Vec::new();
|
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 {
|
for event in events {
|
||||||
// Client-side staleness enforcement: pre-TTL events (and events from
|
// Client-side staleness enforcement: pre-TTL events (and events from
|
||||||
// relays that ignore NIP-40) would otherwise list dead installs
|
// relays that ignore NIP-40) would otherwise list dead installs
|
||||||
|
|||||||
@@ -1194,11 +1194,13 @@ impl Server {
|
|||||||
// Podman needs and can restart-loop apps that publish those ports.
|
// Podman needs and can restart-loop apps that publish those ports.
|
||||||
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
|
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
|
||||||
let publishing_task = tokio::spawn(crate::publishing::serving::run(
|
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(
|
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
|
// The app gate: authentication in front of every app port, on every
|
||||||
|
|||||||
@@ -145,10 +145,7 @@ pub(crate) fn reserve_until(
|
|||||||
Err(error)
|
Err(error)
|
||||||
if error
|
if error
|
||||||
.downcast_ref::<std::io::Error>()
|
.downcast_ref::<std::io::Error>()
|
||||||
.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),
|
Err(error) => return Err(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1496,25 +1496,28 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
|
|||||||
.context("isolated test command failed");
|
.context("isolated test command failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut full: Vec<&str> = vec![
|
#[cfg(not(test))]
|
||||||
"systemd-run",
|
{
|
||||||
"--wait",
|
let mut full: Vec<&str> = vec![
|
||||||
"--quiet",
|
"systemd-run",
|
||||||
"--collect",
|
"--wait",
|
||||||
"--pipe",
|
"--quiet",
|
||||||
// Shell snippets passed as one argument must reach the child intact.
|
"--collect",
|
||||||
// systemd-run otherwise expands $VAR/${VAR} against the manager's
|
"--pipe",
|
||||||
// environment before `sh -lc` can see them (and usually replaces them
|
// Shell snippets passed as one argument must reach the child intact.
|
||||||
// with empty strings).
|
// systemd-run otherwise expands $VAR/${VAR} against the manager's
|
||||||
"--expand-environment=no",
|
// 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)
|
full.extend_from_slice(args);
|
||||||
.status()
|
tokio::process::Command::new("sudo")
|
||||||
.await
|
.args(&full)
|
||||||
.context("sudo systemd-run spawn failed")
|
.status()
|
||||||
|
.await
|
||||||
|
.context("sudo systemd-run spawn failed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same mechanism as `host_sudo` but captures stdout — for read-only probes
|
/// 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<std::process::Outp
|
|||||||
.context("isolated test command failed");
|
.context("isolated test command failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut full: Vec<&str> = vec![
|
#[cfg(not(test))]
|
||||||
"systemd-run",
|
{
|
||||||
"--wait",
|
let mut full: Vec<&str> = vec![
|
||||||
"--quiet",
|
"systemd-run",
|
||||||
"--collect",
|
"--wait",
|
||||||
"--pipe",
|
"--quiet",
|
||||||
"--expand-environment=no",
|
"--collect",
|
||||||
"--",
|
"--pipe",
|
||||||
];
|
"--expand-environment=no",
|
||||||
full.extend_from_slice(args);
|
"--",
|
||||||
tokio::process::Command::new("sudo")
|
];
|
||||||
.args(&full)
|
full.extend_from_slice(args);
|
||||||
.output()
|
tokio::process::Command::new("sudo")
|
||||||
.await
|
.args(&full)
|
||||||
.context("sudo systemd-run output spawn failed")
|
.output()
|
||||||
|
.await
|
||||||
|
.context("sudo systemd-run output spawn failed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use super::nut13::RecoverySource;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use bitcoin::secp256k1;
|
use bitcoin::secp256k1;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{debug, warn};
|
use tracing::debug;
|
||||||
|
|
||||||
/// Default timeout for mint API calls.
|
/// Default timeout for mint API calls.
|
||||||
const MINT_TIMEOUT_SECS: u64 = 10;
|
const MINT_TIMEOUT_SECS: u64 = 10;
|
||||||
@@ -83,13 +83,25 @@ impl std::fmt::Debug for PreparedSwap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PreparedSwap {
|
impl PreparedSwap {
|
||||||
// Add inside impl PreparedSwap; no mutability or proof/output secrets exposed.
|
// 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 payment_keyset_id(&self) -> &str {
|
||||||
pub(super) fn input_fee_sats(&self) -> Result<u64> {
|
&self.keyset.id
|
||||||
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")?;
|
pub(super) fn input_fee_sats(&self) -> Result<u64> {
|
||||||
inputs.checked_sub(outputs).context("Prepared outputs exceed input value")
|
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] {
|
pub(super) fn inputs(&self) -> &[Proof] {
|
||||||
&self.inputs
|
&self.inputs
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ pub mod mint_client;
|
|||||||
pub(crate) mod mutation;
|
pub(crate) mod mutation;
|
||||||
pub mod nut13;
|
pub mod nut13;
|
||||||
pub mod profits;
|
pub mod profits;
|
||||||
mod send_journal;
|
|
||||||
mod receive_journal;
|
mod receive_journal;
|
||||||
|
mod send_journal;
|
||||||
|
|
||||||
pub(crate) mod purchase_fee_plan;
|
pub(crate) mod purchase_fee_plan;
|
||||||
|
|
||||||
|
|||||||
@@ -2682,7 +2682,7 @@ async fn rental_catalog_term_mismatch_never_plans_or_creates_buyer_intent() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unconfirmed_quote_can_cancel_and_requote_without_exposing_wallet_funds() {
|
async fn unconfirmed_quote_can_cancel_and_requote_without_exposing_wallet_funds() {
|
||||||
use crate::content_purchase_caller::{purchase, ReadyPurchase};
|
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 mint = Mint::start(0, None).await;
|
||||||
let buyer = tempfile::tempdir().unwrap();
|
let buyer = tempfile::tempdir().unwrap();
|
||||||
let seller = mint.wallet().await;
|
let seller = mint.wallet().await;
|
||||||
|
|||||||
@@ -1795,8 +1795,10 @@ app:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
exempt.sort();
|
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.
|
// 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.
|
// Compare exact endpoints, not just a count that can hide substitutions.
|
||||||
let expected = [
|
let expected = [
|
||||||
("bitcoin-core", 8333),
|
("bitcoin-core", 8333),
|
||||||
@@ -1804,6 +1806,7 @@ app:
|
|||||||
("core-lightning", 9736),
|
("core-lightning", 9736),
|
||||||
("core-lightning", 9835),
|
("core-lightning", 9835),
|
||||||
("cuprate", 18183),
|
("cuprate", 18183),
|
||||||
|
("datum", 23334),
|
||||||
("electrumx", 50001),
|
("electrumx", 50001),
|
||||||
("fedimint", 8173),
|
("fedimint", 8173),
|
||||||
("fedimint", 8174),
|
("fedimint", 8174),
|
||||||
@@ -1870,6 +1873,8 @@ app:
|
|||||||
// Angor's indexer exposes public chain data/transaction broadcast;
|
// Angor's indexer exposes public chain data/transaction broadcast;
|
||||||
// its optional standalone relay accepts signed public Nostr events.
|
// its optional standalone relay accepts signed public Nostr events.
|
||||||
// Neither mounts credentials or the node's internal relay database.
|
// 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!(
|
assert_eq!(
|
||||||
open,
|
open,
|
||||||
vec![
|
vec![
|
||||||
@@ -1877,6 +1882,7 @@ app:
|
|||||||
("angor-relay".to_string(), 8091u16),
|
("angor-relay".to_string(), 8091u16),
|
||||||
("btcpay-server".to_string(), 23000u16),
|
("btcpay-server".to_string(), 23000u16),
|
||||||
("cuprate".to_string(), 18090u16),
|
("cuprate".to_string(), 18090u16),
|
||||||
|
("gashboard".to_string(), 1337u16),
|
||||||
("gitea".to_string(), 3001u16),
|
("gitea".to_string(), 3001u16),
|
||||||
("nginx-proxy-manager".to_string(), 8081u16),
|
("nginx-proxy-manager".to_string(), 8081u16),
|
||||||
("tailscale".to_string(), 8240u16),
|
("tailscale".to_string(), 8240u16),
|
||||||
|
|||||||
@@ -1002,7 +1002,10 @@ fn manifest_container_name(manifest: &AppManifest) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn manifest_apps_dirs() -> Vec<PathBuf> {
|
fn manifest_apps_dirs() -> Vec<PathBuf> {
|
||||||
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") {
|
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||||
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import PeerFiles from '../PeerFiles.vue'
|
import PeerFiles from '../PeerFiles.vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
vi.mock('vue-router', () => ({
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
|
}))
|
||||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: 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() }) }))
|
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) }))
|
||||||
const hash = 'a'.repeat(64)
|
const hash = 'a'.repeat(64)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import PeerFiles from '../PeerFiles.vue'
|
|||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
useRouter: () => ({ push: vi.fn() }),
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -454,6 +454,7 @@ describe('keepAliveLifecycle: 02-11 gap closure — leaked background pollers in
|
|||||||
FleetNodeDetail: true,
|
FleetNodeDetail: true,
|
||||||
FleetContainerMatrix: true,
|
FleetContainerMatrix: true,
|
||||||
BackButton: true,
|
BackButton: true,
|
||||||
|
RouterLink: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import Fleet from '../../Fleet.vue'
|
|||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
vi.mock('vue-router', () => ({
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
|
}))
|
||||||
afterEach(() => { sessionStorage.clear(); vi.clearAllMocks() })
|
afterEach(() => { sessionStorage.clear(); vi.clearAllMocks() })
|
||||||
|
|
||||||
const stubs = {
|
const stubs = {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const STEP_ROUTE_OVERRIDES: Record<string, string> = {
|
|||||||
'create-passphrase': '/dashboard/settings',
|
'create-passphrase': '/dashboard/settings',
|
||||||
'create-backup': '/dashboard/settings',
|
'create-backup': '/dashboard/settings',
|
||||||
'save-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
|
// Channel steps land directly on the Lightning channels screen (which
|
||||||
// carries the "open a channel with Zeus" suggestion).
|
// carries the "open a channel with Zeus" suggestion).
|
||||||
'open-channel': '/dashboard/apps/lnd/channels',
|
'open-channel': '/dashboard/apps/lnd/channels',
|
||||||
|
|||||||
Reference in New Issue
Block a user