diff --git a/apps/cuprate-ui/manifest.yml b/apps/cuprate-ui/manifest.yml new file mode 100644 index 00000000..05260c95 --- /dev/null +++ b/apps/cuprate-ui/manifest.yml @@ -0,0 +1,67 @@ +app: + id: cuprate-ui + name: Cuprate UI + version: 1.0.0 + # Built by this project — there is no upstream release feed to watch. + upstream: + kind: internal + description: | + Archipelago-native HTTP frontend for the Cuprate Monero node. Runs nginx + inside a container, serves a static status dashboard, and proxies + /cuprate-rpc/ to the cuprate restricted RPC on 127.0.0.1:18090 (the + published host port for the container's 18089). No credentials are + injected — the restricted RPC is Monero's own safe-for-public subset — so + the nginx.conf is baked into the image and there is no rendered-config + bind-mount like bitcoin-ui's. + + container: + build: + context: /opt/archipelago/docker/cuprate-ui + dockerfile: Dockerfile + tag: localhost/cuprate-ui:local + + dependencies: + - app_id: cuprate + + resources: + memory_limit: 64Mi + + security: + readonly_root: false + network_policy: host + + # Host networking: nginx listens on 18091 directly on the host IP. + # Declared so the APP GATE can see this port. Host networking means Podman + # publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here + # is a statement of where the container's own nginx listens — 127.0.0.1 — + # not a publish instruction. Without this declaration the gate would have no + # idea the port existed: neither protected nor listed as unprotected. + ports: + - host: 18091 + container: 18091 + protocol: tcp + bind: 127.0.0.1 + auth: gated + # First-party companion UI: its nginx forwards the node session cookie + # to the daemon's authenticated endpoints; without passthrough the gate + # strips it and every data call 401s while the page shell renders. + session_passthrough: true + + volumes: [] + + environment: [] + + health_check: + type: http + endpoint: http://127.0.0.1:18091 + path: / + interval: 30s + timeout: 5s + retries: 3 + + metadata: + icon: /assets/img/app-icons/cuprate.svg + category: money + tier: optional + author: Archipelago + repo: https://github.com/Cuprate/cuprate diff --git a/apps/cuprate/manifest.yml b/apps/cuprate/manifest.yml index f41bdf41..87a6ffca 100644 --- a/apps/cuprate/manifest.yml +++ b/apps/cuprate/manifest.yml @@ -36,12 +36,26 @@ app: data_uid: "1000:1000" dependencies: - # Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a - # month; cuprated's pruning support is not confirmed stable yet (the - # `pruning` crate exists in the workspace but nothing in this config - # surface toggles it), so this sizes for a full unpruned chain plus - # headroom rather than assuming pruning is available. - - storage: 300Gi + # Monero mainnet is ~250GiB unpruned as of 2026 and growing ~60GiB/year. + # Verified against upstream main (binaries/cuprated/src/config.rs, 2026-09): + # cuprated has NO on-disk pruning setting of any kind — the `pruning` + # crate in its workspace is Monero's p2p *protocol* pruning, not a + # smaller chain — so unlike bitcoin-knots this app CANNOT self-prune + # when disk is scarce (see the DISK_GB branch in + # apps/bitcoin-knots/manifest.yml). Left running on a too-small disk it + # syncs until the filesystem fills and takes Archipelago down. The + # disk-scarce equivalent is enforced in Rust instead: install, start, + # restart and update refuse, and boot reconcile skips, on any node under + # CUPRATE_MIN_DISK_GB (450GB — chain + headroom; refuses the 250GB VPS + # class, allows 500GB-class disks). If upstream ever ships a prune flag, + # replace that gate with the bitcoin-style entrypoint branch. + # + # 450Gi, not the chain size (~250GiB): every manifest-driven surface + # (store size display, install pre-checks, docs) must show the number the + # Rust gate actually enforces, or a user provisioned to the displayed + # value gets refused at a different, unexplained one. Single source of + # truth is crate::constants::CUPRATE_MIN_DISK_GB — keep in lockstep. + - storage: 450Gi resources: cpu_limit: 0 @@ -51,7 +65,9 @@ app: # CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves # headroom above the 8GiB cache for the process itself. memory_limit: 10Gi - disk_limit: 300Gi + # Matches the storage dependency above (= the enforced disk floor), + # not the raw chain size — see the CUPRATE_MIN_DISK_GB note. + disk_limit: 450Gi security: # FROM scratch, no package manager/shell, ownership fixed at build time diff --git a/core/archipelago/src/api/rpc/package/async_lifecycle.rs b/core/archipelago/src/api/rpc/package/async_lifecycle.rs index 29ea77bc..ada96e83 100644 --- a/core/archipelago/src/api/rpc/package/async_lifecycle.rs +++ b/core/archipelago/src/api/rpc/package/async_lifecycle.rs @@ -55,6 +55,7 @@ impl RpcHandler { .to_string(); super::validation::validate_app_id(&package_id)?; super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?; + super::dependencies::check_cuprate_disk_compatibility(&package_id).await?; // Reject if already in a transitional lifecycle (prevents double-click // queuing two installs on the same package). @@ -294,6 +295,12 @@ impl RpcHandler { .ok_or_else(|| anyhow::anyhow!("Missing package id"))? .to_string(); super::validation::validate_app_id(&package_id)?; + // Update is stop → pull → remove → recreate, i.e. a fresh start by + // another name: on a disk that shrank since install it would resume + // cuprate's unprunable sync unchecked. Same gate as install and + // start, run BEFORE the Updating flip so a refusal leaves the app + // cleanly in its previous state. + super::dependencies::check_cuprate_disk_compatibility(&package_id).await?; // Reject if already in a transitional lifecycle. { diff --git a/core/archipelago/src/api/rpc/package/dependencies.rs b/core/archipelago/src/api/rpc/package/dependencies.rs index db61187d..4c079b16 100644 --- a/core/archipelago/src/api/rpc/package/dependencies.rs +++ b/core/archipelago/src/api/rpc/package/dependencies.rs @@ -670,6 +670,50 @@ async fn detect_disk_gb() -> u64 { .unwrap_or(u64::MAX) } +/// Smallest disk (GB, total) a cuprate node can live on. The value and its +/// rationale live in ONE place — `crate::constants::CUPRATE_MIN_DISK_GB` — +/// shared with the boot reconciler so install/start and boot can never +/// disagree about where cuprate may run. +use crate::constants::CUPRATE_MIN_DISK_GB; + +/// The bitcoin apps pick `-prune` automatically when disk is scarce, because +/// bitcoind supports pruning. Cuprate CANNOT: upstream has no pruning config +/// at all (the `pruning` crate in its workspace is Monero's p2p *protocol* +/// pruning, not on-disk pruning), so the disk-scarce equivalent is to refuse +/// to run cuprate at all rather than let it sync until the filesystem fills — +/// which took Archipelago itself down on nodes with too little disk. +fn cuprate_insufficient_disk_message(disk_gb: u64) -> String { + format!( + "Cuprate needs a disk of at least {} GB and this node has {} GB. \ + A Monero node cannot run pruned — upstream cuprate has no pruning \ + support — so the chain (~250 GB and growing) would fill the disk and \ + take Archipelago down with it. Attach a larger disk (or move \ + /var/lib/archipelago to one) and try again. Bitcoin apps CAN run \ + pruned on smaller disks; Monero currently cannot.", + CUPRATE_MIN_DISK_GB, disk_gb + ) +} + +/// Pure decision half of the cuprate disk gate — testable without df. +pub(super) fn cuprate_disk_gate(disk_gb: u64) -> Option { + (disk_gb < CUPRATE_MIN_DISK_GB).then(|| cuprate_insufficient_disk_message(disk_gb)) +} + +/// Install/start-time pre-check: refuse cuprate on disks too small to hold +/// the Monero chain. Mirrors `check_bitcoin_pruning_compatibility`'s +/// fail-open-on-unknown-disk behaviour (`detect_disk_gb` returns u64::MAX +/// when df fails, so an unreadable disk never blocks an install). +pub(super) async fn check_cuprate_disk_compatibility(package_id: &str) -> Result<()> { + if package_id != "cuprate" { + return Ok(()); + } + let disk_gb = detect_disk_gb().await; + if let Some(message) = cuprate_disk_gate(disk_gb) { + anyhow::bail!(message); + } + Ok(()) +} + /// Log informational messages about optional dependencies. pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) { if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd { @@ -873,9 +917,9 @@ pub(super) fn configure_fedimint_lnd( #[cfg(test)] mod tests { use super::{ - bitcoin_is_warming_up, dependency_list_declares_archival_bitcoin, + bitcoin_is_warming_up, cuprate_disk_gate, dependency_list_declares_archival_bitcoin, manifest_declares_archival_bitcoin, order_present_containers, requires_unpruned_bitcoin, - startup_order, BITCOIN_WARMUP_BUDGET, + startup_order, BITCOIN_WARMUP_BUDGET, CUPRATE_MIN_DISK_GB, }; use archipelago_container::Dependency; @@ -1017,6 +1061,37 @@ mod tests { assert!(!manifest_declares_archival_bitcoin("does-not-exist")); } + #[test] + fn cuprate_disk_gate_refuses_disks_too_small_for_the_monero_chain() { + // 250 GB VPS class: the ~250 GiB chain does not fit, full stop. + assert!(cuprate_disk_gate(0).is_some()); + assert!(cuprate_disk_gate(250).is_some()); + assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB - 1).is_some()); + assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB).is_none()); + assert!(cuprate_disk_gate(1000).is_none()); + // df failure reads as u64::MAX — an unreadable disk must not block. + assert!(cuprate_disk_gate(u64::MAX).is_none()); + } + + #[test] + fn cuprate_disk_gate_message_names_the_fix_not_just_the_problem() { + let msg = cuprate_disk_gate(250).expect("250 GB must be refused"); + assert!(msg.contains("cannot run pruned"), "{msg}"); + assert!(msg.contains("larger disk"), "{msg}"); + assert!(msg.contains("250 GB"), "{msg}"); + } + + #[tokio::test] + async fn cuprate_disk_gate_only_applies_to_cuprate() { + // Every other package passes regardless of disk — including the + // bitcoin apps, which self-prune via their manifest entrypoint. + for package_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] { + super::check_cuprate_disk_compatibility(package_id) + .await + .expect("non-cuprate installs must not be gated here"); + } + } + mod dep_wait { use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps}; use std::sync::atomic::{AtomicU32, Ordering}; diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index bd460de3..9ee88898 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -3,10 +3,10 @@ use super::config::{ is_readonly_compatible, is_valid_docker_image, }; use super::dependencies::{ - check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers, - detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info, - needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL, - DEP_WAIT_MAX_ATTEMPTS, + check_bitcoin_pruning_compatibility, check_cuprate_disk_compatibility, configure_fedimint_lnd, + detect_existing_containers, detect_running_deps, detect_running_deps_from_package_data, + log_optional_dep_info, needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, + DEP_WAIT_INTERVAL, DEP_WAIT_MAX_ATTEMPTS, }; use super::progress::parse_pull_progress; use super::validation::validate_app_id; @@ -374,6 +374,7 @@ impl RpcHandler { // failing instantly. let deps = self.gate_install_deps(package_id).await?; check_bitcoin_pruning_compatibility(package_id).await?; + check_cuprate_disk_compatibility(package_id).await?; log_optional_dep_info(package_id, &deps); if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") { // Materialise the RPC password file before any install path diff --git a/core/archipelago/src/api/rpc/package/runtime.rs b/core/archipelago/src/api/rpc/package/runtime.rs index 292d0bda..de627004 100644 --- a/core/archipelago/src/api/rpc/package/runtime.rs +++ b/core/archipelago/src/api/rpc/package/runtime.rs @@ -60,6 +60,12 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing package id"))?; validate_app_id(package_id)?; + // A cuprate node that starts on a too-small disk fills it and takes + // Archipelago down with it (no upstream pruning — see + // dependencies::check_cuprate_disk_compatibility). Fail the start + // before clearing user-stopped or flipping state, so the app stays + // cleanly stopped and the error carries the actionable message. + super::dependencies::check_cuprate_disk_compatibility(package_id).await?; let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) { vec![orchestrator_app_id(package_id).to_string()] @@ -251,6 +257,11 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing package id"))?; validate_app_id(package_id)?; + // Restart is stop + recreate, so on a disk that shrank below the cuprate + // minimum after install it resumes the doomed unprunable sync just like + // start would — same gate, same "fail before clearing user-stopped / + // flipping state" contract (see handle_package_start). + super::dependencies::check_cuprate_disk_compatibility(package_id).await?; let single_orchestrator_app = self.orchestrator.is_some() && uses_single_orchestrator_app(package_id); diff --git a/core/archipelago/src/constants.rs b/core/archipelago/src/constants.rs index bebac5c6..b0957d5b 100644 --- a/core/archipelago/src/constants.rs +++ b/core/archipelago/src/constants.rs @@ -9,3 +9,19 @@ pub const DWN_HEALTH_URL: &str = "http://127.0.0.1:3100/health"; /// Tor SOCKS5 proxy for outbound onion connections. pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050"; + +/// Smallest disk (GB, total) a cuprate node may be installed, started, +/// restarted, updated, or boot-reconciled onto. Cuprate has no on-disk +/// pruning (verified against upstream `cuprated/src/config.rs` — the +/// `pruning` crate is Monero's p2p protocol pruning), so unlike the bitcoin +/// apps it cannot self-shrink on a scarce disk; below this line the ~250 GiB +/// Monero chain simply does not fit and running it would fill the filesystem +/// and take Archipelago down. 450 = chain + growth/headroom: allows +/// 500 GB-class disks, refuses the 250 GB VPS class. +/// +/// SINGLE SOURCE OF TRUTH — the RPC gates +/// (`api::rpc::package::dependencies`) and the boot reconciler +/// (`container::prod_orchestrator`) both read this; a drift between them +/// would silently reopen the disk-fill failure the gate exists to close. +/// Keep `apps/cuprate/manifest.yml` (storage dependency + comments) aligned. +pub const CUPRATE_MIN_DISK_GB: u64 = 450; diff --git a/core/archipelago/src/container/companion.rs b/core/archipelago/src/container/companion.rs index 934137c3..9752454f 100644 --- a/core/archipelago/src/container/companion.rs +++ b/core/archipelago/src/container/companion.rs @@ -10,6 +10,7 @@ //! | lnd | archy-lnd-ui | wallet/channel UI | //! | electrumx | archy-electrs-ui | indexer status UI | //! | fedimint | archy-fedimint-ui | wait/proxy Guardian UI | +//! | cuprate | archy-cuprate-ui | Monero node status UI | //! //! Lifecycle: `install` writes a Quadlet `.container` unit to //! `~/.config/containers/systemd/`, daemon-reloads, then starts the @@ -97,6 +98,7 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] { "lnd" => LND_UI, "electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI, "fedimint" | "fedimintd" => FEDIMINT_UI, + "cuprate" => CUPRATE_UI, _ => &[], } } @@ -104,7 +106,8 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] { /// Every companion this build knows how to provision. Kept beside /// `companions_for` — a new companion must be added to both, or the reaper /// will not recognise it as one of ours and will leave it running forever. -const ALL_COMPANIONS: &[&[CompanionSpec]] = &[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI]; +const ALL_COMPANIONS: &[&[CompanionSpec]] = + &[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI, CUPRATE_UI]; const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec { name: "archy-bitcoin-ui", @@ -172,6 +175,24 @@ const FEDIMINT_UI: &[CompanionSpec] = &[CompanionSpec { host_network: true, }]; +const CUPRATE_UI: &[CompanionSpec] = &[CompanionSpec { + name: "archy-cuprate-ui", + image_base: "cuprate-ui", + build_dir_candidates: &[ + "/opt/archipelago/docker/cuprate-ui", + "/home/archipelago/archy/docker/cuprate-ui", + "/home/archipelago/Projects/archy/docker/cuprate-ui", + ], + // No pre-start hook and no bind mounts: unlike bitcoin-ui there is no + // secret to inject. Cuprate's restricted RPC (the only thing this UI + // proxies) is unauthenticated by design — Monero's safe-for-public + // subset — so the nginx.conf is baked into the image. + pre_start: None, + bind_mounts: &[], + ports: &[], + host_network: true, +}]; + fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> { Box::pin(async { let paths = crate::container::bitcoin_ui::RenderPaths::default(); @@ -869,6 +890,7 @@ mod tests { "mempool-electrs", "fedimint", "fedimintd", + "cuprate", ]; let known: std::collections::HashSet<&str> = ALL_COMPANIONS .iter() @@ -893,6 +915,7 @@ mod tests { names(&orphan_companions(&[])), vec![ "archy-bitcoin-ui", + "archy-cuprate-ui", "archy-electrs-ui", "archy-fedimint-ui", "archy-lnd-ui" @@ -906,7 +929,10 @@ mod tests { // electrumx installed, fedimint and lnd not — yet all four companions // were running because the reconciler was fed the manifest list. let orphans = orphan_companions(&ids(&["bitcoin-knots", "electrumx"])); - assert_eq!(names(&orphans), vec!["archy-fedimint-ui", "archy-lnd-ui"]); + assert_eq!( + names(&orphans), + vec!["archy-cuprate-ui", "archy-fedimint-ui", "archy-lnd-ui"] + ); } #[test] @@ -926,12 +952,18 @@ mod tests { #[test] fn apps_without_companions_orphan_everything_and_panic_nothing() { let orphans = orphan_companions(&ids(&["nextcloud", "not-a-real-app"])); - assert_eq!(orphans.len(), 4); + assert_eq!(orphans.len(), 5); } #[test] fn every_backend_installed_leaves_no_orphans() { - let orphans = orphan_companions(&ids(&["bitcoin-knots", "lnd", "electrumx", "fedimint"])); + let orphans = orphan_companions(&ids(&[ + "bitcoin-knots", + "lnd", + "electrumx", + "fedimint", + "cuprate", + ])); assert!( names(&orphans).is_empty(), "unexpected orphans: {:?}", @@ -970,7 +1002,12 @@ mod tests { let due = due_after_grace(orphans, &names_seen, &mut since, start + ORPHAN_GRACE); assert_eq!( names(&due), - vec!["archy-electrs-ui", "archy-fedimint-ui", "archy-lnd-ui"] + vec![ + "archy-cuprate-ui", + "archy-electrs-ui", + "archy-fedimint-ui", + "archy-lnd-ui" + ] ); } @@ -1024,6 +1061,7 @@ mod tests { assert_eq!(companions_for("mempool-electrs").len(), 1); assert_eq!(companions_for("fedimint").len(), 1); assert_eq!(companions_for("fedimintd").len(), 1); + assert_eq!(companions_for("cuprate").len(), 1); assert_eq!(companions_for("nextcloud").len(), 0); assert_eq!(companions_for("not-a-real-app").len(), 0); } diff --git a/core/archipelago/src/container/image_versions.rs b/core/archipelago/src/container/image_versions.rs index a82ba467..0fd991c9 100644 --- a/core/archipelago/src/container/image_versions.rs +++ b/core/archipelago/src/container/image_versions.rs @@ -146,6 +146,7 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> { "bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"), "lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"), "electrs-ui" | "archy-electrs-ui" => Some("ELECTRS_UI_IMAGE"), + "cuprate-ui" | "archy-cuprate-ui" => Some("CUPRATE_UI_IMAGE"), // Mempool stack (primary = web) "mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"), diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 628db61f..8f388ee2 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -47,8 +47,17 @@ use crate::update::host_sudo; /// /// Keep in sync with the running fixture on .116. Centralized as a constant /// so the rule is visible in one place and unit-testable. -const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"]; +const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui", "cuprate-ui"]; const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000; +// The cuprate disk floor is `crate::constants::CUPRATE_MIN_DISK_GB` — one +// value shared with the install/start/restart/update RPC gates so boot +// reconcile can never resume below the line they refuse at. + +use crate::constants::CUPRATE_MIN_DISK_GB; + +fn requires_cuprate_disk(app_id: &str, disk_gb: u64) -> bool { + app_id == "cuprate" && disk_gb < CUPRATE_MIN_DISK_GB +} /// Apps expected to exist from first boot on every node — the ONLY apps the /// boot reconciler may install from nothing. Every other app needs @@ -1944,6 +1953,23 @@ impl ProdContainerOrchestrator { crate::crash_recovery::pending_boot_start_done(&container_name); continue; } + // Same shape as the archival-bitcoin skip above: recorded BEFORE + // ensure_running_with_mode, so the "absent" desired-state recovery + // below can never fire on this reason and undo it. + if mode == ReconcileMode::ExistingOnly && requires_cuprate_disk(&app_id, disk_gb) { + tracing::warn!( + app_id = %app_id, + disk_gb, + "cuprate needs a larger disk (no pruning support) — skipping start" + ); + report.record( + &app_id, + ReconcileAction::Left("cuprate-insufficient-disk".into()), + ); + crate::crash_recovery::pending_boot_start_done(&app_id); + crate::crash_recovery::pending_boot_start_done(&container_name); + continue; + } match self.ensure_running_with_mode(&lm, mode).await { // Desired-state recovery: the app has no container and was left // "absent" by boot reconcile, BUT it was running at the last @@ -5365,6 +5391,27 @@ app: assert_eq!(compute_container_name(&m), "archy-electrs-ui"); let m = pull_manifest("lnd-ui", "foo:1"); assert_eq!(compute_container_name(&m), "archy-lnd-ui"); + let m = pull_manifest("cuprate-ui", "foo:1"); + assert_eq!(compute_container_name(&m), "archy-cuprate-ui"); + } + + #[test] + fn cuprate_disk_gate_blocks_only_cuprate_on_small_disks() { + // 250 GB VPS class: the ~250 GiB Monero chain cannot fit and cuprate + // has no pruning — boot reconcile must leave it down. + assert!(requires_cuprate_disk("cuprate", 250)); + assert!(requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB - 1)); + assert!(!requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB)); + assert!(!requires_cuprate_disk("cuprate", 1000)); + // df failure in detect_disk_gb reads as 0 → fail closed at boot: a + // doomed sync is worse than a node that stays down until it can + // measure (same direction as the archival-bitcoin skip). + assert!(requires_cuprate_disk("cuprate", 0)); + // Nothing else is gated here: bitcoin apps self-prune, everything + // else is irrelevant to the Monero chain. + for app_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] { + assert!(!requires_cuprate_disk(app_id, 0), "{app_id}"); + } } #[test] diff --git a/core/archipelago/src/fips/app_ports.rs b/core/archipelago/src/fips/app_ports.rs index 85c0ba53..51567d01 100644 --- a/core/archipelago/src/fips/app_ports.rs +++ b/core/archipelago/src/fips/app_ports.rs @@ -8,5 +8,5 @@ pub const APP_LAUNCH_PORTS: &[u16] = &[ 2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8337, 8888, 8999, 9000, 9100, 10380, 11434, - 18081, 18083, 23000, 32838, 50002, + 18081, 18083, 18091, 23000, 32838, 50002, ]; diff --git a/core/archipelago/src/health_monitor.rs b/core/archipelago/src/health_monitor.rs index fbcdc2cd..a203e138 100644 --- a/core/archipelago/src/health_monitor.rs +++ b/core/archipelago/src/health_monitor.rs @@ -53,8 +53,8 @@ fn container_tier(name: &str) -> StartupTier { | "indeedhub-api" => StartupTier::DependentService, // Tier 4: Frontend/UI - "mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "penpot-frontend" - | "penpot-exporter" | "indeedhub" => StartupTier::Frontend, + "mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "cuprate-ui" + | "penpot-frontend" | "penpot-exporter" | "indeedhub" => StartupTier::Frontend, // Tier 3: Application layer (everything else) _ => StartupTier::Application, diff --git a/docker/cuprate-ui/50x.html b/docker/cuprate-ui/50x.html new file mode 100644 index 00000000..a57c2f93 --- /dev/null +++ b/docker/cuprate-ui/50x.html @@ -0,0 +1,19 @@ + + + +Error + + + +

An error occurred.

+

Sorry, the page you are looking for is currently unavailable.
+Please try again later.

+

If you are the system administrator of this resource then you should check +the error log for details.

+

Faithfully yours, nginx.

+ + diff --git a/docker/cuprate-ui/Dockerfile b/docker/cuprate-ui/Dockerfile new file mode 100644 index 00000000..0bf1b297 --- /dev/null +++ b/docker/cuprate-ui/Dockerfile @@ -0,0 +1,21 @@ +FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine +# Static site content. +COPY index.html /usr/share/nginx/html/ +COPY 50x.html /usr/share/nginx/html/ +COPY assets/ /usr/share/nginx/html/assets/ +# Unlike bitcoin-ui, the nginx.conf is baked into the image, not +# bind-mounted: there is no secret to render in. Cuprate's restricted RPC +# (the only upstream this UI proxies) is unauthenticated by design — +# Monero's safe-for-public subset — so there is nothing to substitute at +# start time and no rotation to follow. +COPY nginx.conf /etc/nginx/conf.d/default.conf +# +# Run nginx as root to avoid chown failures in rootless Podman user +# namespaces. The rest of the nginx image is unchanged. +RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \ + mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \ + /var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \ + /var/cache/nginx/scgi_temp +EXPOSE 18091 +ENTRYPOINT [] +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/cuprate-ui/assets/img/app-icons/cuprate.svg b/docker/cuprate-ui/assets/img/app-icons/cuprate.svg new file mode 100644 index 00000000..82193ef0 --- /dev/null +++ b/docker/cuprate-ui/assets/img/app-icons/cuprate.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/docker/cuprate-ui/assets/img/bg-network.jpg b/docker/cuprate-ui/assets/img/bg-network.jpg new file mode 100644 index 00000000..2f3afb80 Binary files /dev/null and b/docker/cuprate-ui/assets/img/bg-network.jpg differ diff --git a/docker/cuprate-ui/index.html b/docker/cuprate-ui/index.html new file mode 100644 index 00000000..5ff98f80 --- /dev/null +++ b/docker/cuprate-ui/index.html @@ -0,0 +1,407 @@ + + + + + + + + + Cuprate Node - Archipelago + + + +
+
+
+
+
+
Cuprate
+
+

Cuprate Monero Node

+
Rust implementation of the Monero protocol, on Archipelago
+
+
+
+
+
+
Status
Connecting…
+
+
+ +
Network
—
+
+
+ +
Height
—
+
+
+
+
+ + + + + +
+
+
Blockchain Sync
+
+ — + of — +
+
+
Waiting for node…
+
Network—
+
Uptime—
+
Node time—
+
+ +
+
Peers & Traffic
+
Outgoing connections—
+
Incoming connections—
+
RPC connections—
+
Known peers (white)—
+
Known peers (gray)—
+
Mempool transactions—
+
Alt blocks—
+
+ +
+
Chain & Disk
+
Difficulty—
+
Chain size—
+
Free disk—
+
+ +
+
Connect a Wallet
+
+ — + +
+
+ Restricted RPC — Monero's own safe-for-public subset, what Feather, + monero-wallet-rpc and the GUI use for a “remote node”. Full (unrestricted) RPC + stays container-loopback only and is never published. +
+
P2P port18183
+
Restricted RPC port18090
+
+
+ +
+ Cuprate is work-in-progress software; it independently validates Monero consensus rules. + Data served from this node's restricted RPC, refreshed every 15 seconds. +
+
+ + + + diff --git a/docker/cuprate-ui/nginx.conf b/docker/cuprate-ui/nginx.conf new file mode 100644 index 00000000..12c73c7c --- /dev/null +++ b/docker/cuprate-ui/nginx.conf @@ -0,0 +1,56 @@ +server { + # Loopback ONLY — same rule as docker/bitcoin-ui and docker/electrs-ui. + # This container is host-networked, so nginx binds the HOST's address + # directly; a bare `listen` would expose the page on LAN, Tailscale and + # the mesh with the app gate nowhere in front of it. Binding loopback lets + # the daemon claim the external addresses and authenticate them; + # see appgate::listener and apps/cuprate-ui/manifest.yml (auth: gated). + listen 127.0.0.1:18091; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Session gate for the RPC proxy below. Internal: reachable only by + # nginx's own auth_request subrequest, never by a client. + location = /_session_check { + internal; + proxy_pass http://127.0.0.1:5678/auth/session-check; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header Host $host; + proxy_set_header Cookie $http_cookie; + proxy_set_header X-CSRF-Token $http_x_csrf_token; + } + + # Cuprate's restricted RPC (host-published on 127.0.0.1:18090, auth: open + # — Monero's own safe-for-public subset, what remote-node wallets use). + # It injects no credentials the caller lacks, but it is still session- + # gated here so the whole companion behaves as one authenticated surface + # (same defence-in-depth bitcoin-ui applies to its credential-injecting + # proxy: loopback reaches it without the gate's challenge). + location /cuprate-rpc/ { + # Preflight carries no cookies by design — answer it before the gate, + # otherwise the browser reports an opaque CORS failure instead of a 401. + if ($request_method = OPTIONS) { return 204; } + auth_request /_session_check; + proxy_pass http://127.0.0.1:18090/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + add_header Access-Control-Allow-Origin $scheme://$http_host always; + add_header Access-Control-Allow-Credentials "true" always; + add_header Vary "Origin" always; + add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; + } + + # no-cache (revalidate), not no-store — same reasoning as docker/bitcoin-ui: + # a rebuilt companion image must actually be seen by the browser, while the + # ETag still saves the transfer when nothing changed. + location / { + add_header Cache-Control "no-cache"; + try_files $uri $uri/ /index.html; + } +} diff --git a/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts b/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts index dc92f3ef..290eb458 100644 --- a/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts +++ b/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts @@ -149,8 +149,8 @@ describe('appSessionConfig', () => { // A runtime port the gate does NOT front keeps plain http (https would // fail to connect outright). expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083') - // Cuprate's UI port is auth:none — plain HTTP stays plain. - expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090') + // Cuprate's raw RPC is never a launch surface; use the companion UI. + expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('/app/cuprate-ui/') }) it('keeps the pre-catalog Source app on the dashboard origin', () => { diff --git a/neode-ui/src/views/appSession/appSessionConfig.ts b/neode-ui/src/views/appSession/appSessionConfig.ts index 6929150a..675996c3 100644 --- a/neode-ui/src/views/appSession/appSessionConfig.ts +++ b/neode-ui/src/views/appSession/appSessionConfig.ts @@ -34,6 +34,9 @@ export const APP_PORTS: Record = { 'bitcoin-knots': 8334, 'bitcoin-core': 8334, 'bitcoin-ui': 8334, + 'cuprate': 18091, + 'cuprate-ui': 18091, + 'archy-cuprate-ui': 18091, 'electrumx': 50002, 'electrs': 50002, 'archy-electrs-ui': 50002, @@ -153,6 +156,15 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: return appOrigin(8334, id) } + // Cuprate UI is the same companion shape on :18091. The cuprate app itself + // publishes only the restricted RPC (18090) — a raw JSON endpoint, not a + // page — so cuprate launches must land on the companion, never on the + // runtimeUrl a running cuprate reports. + if (id === 'cuprate' || id === 'cuprate-ui' || id === 'archy-cuprate-ui') { + if (import.meta.env.DEV) return '/app/cuprate-ui/' + return appOrigin(18091, id) + } + if (runtimeUrl && id !== 'netbird') { let base = runtimeUrl.replace(/localhost/i, window.location.hostname) // The backend reports runtime URLs as http:// because that is how the app diff --git a/neode-ui/src/views/appSession/generatedAppSessionConfig.ts b/neode-ui/src/views/appSession/generatedAppSessionConfig.ts index 590bb7a0..7051f1b4 100644 --- a/neode-ui/src/views/appSession/generatedAppSessionConfig.ts +++ b/neode-ui/src/views/appSession/generatedAppSessionConfig.ts @@ -9,6 +9,7 @@ export const GENERATED_APP_PORTS: Record = { "bitcoin-ui": 8334, "botfights": 9100, "btcpay-server": 23000, + "cuprate-ui": 18091, "electrs-ui": 50002, "electrumx": 50002, "fedimint": 8175, @@ -54,6 +55,7 @@ export const GENERATED_APP_TITLES: Record = { "btcpay-server": "BTCPay Server", "core-lightning": "Core Lightning (CLN)", "cuprate": "Cuprate", + "cuprate-ui": "Cuprate UI", "electrs-ui": "Electrs UI", "electrumx": "ElectrumX", "fedimint": "Fedimint Guardian", diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts index a1aa650e..88ba0885 100644 --- a/neode-ui/src/views/discover/curatedApps.ts +++ b/neode-ui/src/views/discover/curatedApps.ts @@ -123,6 +123,8 @@ const CATALOG_APP_ID_ALIASES: Record = { 'archy-lnd-ui': 'lnd-ui', 'bitcoin-knots': 'bitcoin-ui', 'bitcoin-core': 'bitcoin-ui', + 'cuprate': 'cuprate-ui', + 'archy-cuprate-ui': 'cuprate-ui', 'fedimintd': 'fedimint', 'immich_server': 'immich', } diff --git a/neode-ui/vite.config.ts b/neode-ui/vite.config.ts index cacae92d..33864499 100644 --- a/neode-ui/vite.config.ts +++ b/neode-ui/vite.config.ts @@ -173,6 +173,11 @@ export default defineConfig({ changeOrigin: true, secure: false, }, + '/app/cuprate-ui': { + target: process.env.BACKEND_URL || 'http://localhost:5959', + changeOrigin: true, + secure: false, + }, // Demo mock app UIs (electrumx, lnd, fedimint) + generic notice page. '/app/electrumx': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false }, '/app/electrs': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false }, diff --git a/releases/app-catalog.json b/releases/app-catalog.json index a8029499..706e90a6 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -1315,13 +1315,13 @@ }, "dependencies": [ { - "storage": "300Gi" + "storage": "450Gi" } ], "resources": { "cpu_limit": 0, "memory_limit": "10Gi", - "disk_limit": "300Gi" + "disk_limit": "450Gi" }, "security": { "capabilities": [], diff --git a/scripts/check-app-catalog-drift.py b/scripts/check-app-catalog-drift.py index 88f62db2..cf957cfc 100644 --- a/scripts/check-app-catalog-drift.py +++ b/scripts/check-app-catalog-drift.py @@ -19,6 +19,7 @@ INTERNAL_MANIFEST_IDS = { "archy-nbxplorer", "bitcoin-ui", "core-lightning", + "cuprate-ui", "electrs-ui", "fips-ui", "lnd-ui", diff --git a/scripts/generate-app-catalog.sh b/scripts/generate-app-catalog.sh index 7c02ae2c..cfb976cc 100755 --- a/scripts/generate-app-catalog.sh +++ b/scripts/generate-app-catalog.sh @@ -65,6 +65,7 @@ SINGLE = { "bitcoin-ui": "BITCOIN_UI_IMAGE", "lnd-ui": "LND_UI_IMAGE", "electrs-ui": "ELECTRS_UI_IMAGE", + "cuprate-ui": "CUPRATE_UI_IMAGE", "homeassistant": "HOMEASSISTANT_IMAGE", "grafana": "GRAFANA_IMAGE", "uptime-kuma": "UPTIME_KUMA_IMAGE", diff --git a/scripts/image-versions.sh b/scripts/image-versions.sh index 12a49032..81ed49f6 100644 --- a/scripts/image-versions.sh +++ b/scripts/image-versions.sh @@ -126,6 +126,7 @@ IMMICH_SERVER_IMAGE="$ARCHY_REGISTRY/immich-server:release" BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.123-alpha" LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:1.7.123-alpha" ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:1.7.123-alpha" +CUPRATE_UI_IMAGE="$ARCHY_REGISTRY/cuprate-ui:1.7.123-alpha" # Base images NGINX_ALPINE_IMAGE="$ARCHY_REGISTRY/nginx:1.27.4-alpine"