Merge PR #157: Cuprate disk gate and companion dashboard
Demo images / Build & push demo images (push) Successful in 3m19s

This commit is contained in:
archipelago
2026-09-13 01:37:46 -04:00
27 changed files with 865 additions and 26 deletions
+67
View File
@@ -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
+23 -7
View File
@@ -36,12 +36,26 @@ app:
data_uid: "1000:1000" data_uid: "1000:1000"
dependencies: dependencies:
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a # Monero mainnet is ~250GiB unpruned as of 2026 and growing ~60GiB/year.
# month; cuprated's pruning support is not confirmed stable yet (the # Verified against upstream main (binaries/cuprated/src/config.rs, 2026-09):
# `pruning` crate exists in the workspace but nothing in this config # cuprated has NO on-disk pruning setting of any kind — the `pruning`
# surface toggles it), so this sizes for a full unpruned chain plus # crate in its workspace is Monero's p2p *protocol* pruning, not a
# headroom rather than assuming pruning is available. # smaller chain — so unlike bitcoin-knots this app CANNOT self-prune
- storage: 300Gi # 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: resources:
cpu_limit: 0 cpu_limit: 0
@@ -51,7 +65,9 @@ app:
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves # CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
# headroom above the 8GiB cache for the process itself. # headroom above the 8GiB cache for the process itself.
memory_limit: 10Gi 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: security:
# FROM scratch, no package manager/shell, ownership fixed at build time # FROM scratch, no package manager/shell, ownership fixed at build time
@@ -55,6 +55,7 @@ impl RpcHandler {
.to_string(); .to_string();
super::validation::validate_app_id(&package_id)?; super::validation::validate_app_id(&package_id)?;
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?; 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 // Reject if already in a transitional lifecycle (prevents double-click
// queuing two installs on the same package). // queuing two installs on the same package).
@@ -294,6 +295,12 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))? .ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string(); .to_string();
super::validation::validate_app_id(&package_id)?; 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. // Reject if already in a transitional lifecycle.
{ {
@@ -670,6 +670,50 @@ async fn detect_disk_gb() -> u64 {
.unwrap_or(u64::MAX) .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<String> {
(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. /// Log informational messages about optional dependencies.
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) { pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd { if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
@@ -873,9 +917,9 @@ pub(super) fn configure_fedimint_lnd(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ 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, 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; use archipelago_container::Dependency;
@@ -1017,6 +1061,37 @@ mod tests {
assert!(!manifest_declares_archival_bitcoin("does-not-exist")); 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 { mod dep_wait {
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps}; use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
@@ -3,10 +3,10 @@ use super::config::{
is_readonly_compatible, is_valid_docker_image, is_readonly_compatible, is_valid_docker_image,
}; };
use super::dependencies::{ use super::dependencies::{
check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers, check_bitcoin_pruning_compatibility, check_cuprate_disk_compatibility, configure_fedimint_lnd,
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info, detect_existing_containers, detect_running_deps, detect_running_deps_from_package_data,
needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL, log_optional_dep_info, needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps,
DEP_WAIT_MAX_ATTEMPTS, DEP_WAIT_INTERVAL, DEP_WAIT_MAX_ATTEMPTS,
}; };
use super::progress::parse_pull_progress; use super::progress::parse_pull_progress;
use super::validation::validate_app_id; use super::validation::validate_app_id;
@@ -374,6 +374,7 @@ impl RpcHandler {
// failing instantly. // failing instantly.
let deps = self.gate_install_deps(package_id).await?; let deps = self.gate_install_deps(package_id).await?;
check_bitcoin_pruning_compatibility(package_id).await?; check_bitcoin_pruning_compatibility(package_id).await?;
check_cuprate_disk_compatibility(package_id).await?;
log_optional_dep_info(package_id, &deps); log_optional_dep_info(package_id, &deps);
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") { if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
// Materialise the RPC password file before any install path // Materialise the RPC password file before any install path
@@ -60,6 +60,12 @@ impl RpcHandler {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?; .ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(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) { let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
vec![orchestrator_app_id(package_id).to_string()] vec![orchestrator_app_id(package_id).to_string()]
@@ -251,6 +257,11 @@ impl RpcHandler {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?; .ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(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 = let single_orchestrator_app =
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id); self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
+16
View File
@@ -9,3 +9,19 @@ pub const DWN_HEALTH_URL: &str = "http://127.0.0.1:3100/health";
/// Tor SOCKS5 proxy for outbound onion connections. /// Tor SOCKS5 proxy for outbound onion connections.
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050"; 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;
+43 -5
View File
@@ -10,6 +10,7 @@
//! | lnd | archy-lnd-ui | wallet/channel UI | //! | lnd | archy-lnd-ui | wallet/channel UI |
//! | electrumx | archy-electrs-ui | indexer status UI | //! | electrumx | archy-electrs-ui | indexer status UI |
//! | fedimint | archy-fedimint-ui | wait/proxy Guardian 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 //! Lifecycle: `install` writes a Quadlet `.container` unit to
//! `~/.config/containers/systemd/`, daemon-reloads, then starts the //! `~/.config/containers/systemd/`, daemon-reloads, then starts the
@@ -97,6 +98,7 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
"lnd" => LND_UI, "lnd" => LND_UI,
"electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI, "electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI,
"fedimint" | "fedimintd" => FEDIMINT_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 /// Every companion this build knows how to provision. Kept beside
/// `companions_for` — a new companion must be added to both, or the reaper /// `companions_for` — a new companion must be added to both, or the reaper
/// will not recognise it as one of ours and will leave it running forever. /// will not recognise it as one of ours and will leave it running forever.
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 { const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec {
name: "archy-bitcoin-ui", name: "archy-bitcoin-ui",
@@ -172,6 +175,24 @@ const FEDIMINT_UI: &[CompanionSpec] = &[CompanionSpec {
host_network: true, 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<()>> { fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> {
Box::pin(async { Box::pin(async {
let paths = crate::container::bitcoin_ui::RenderPaths::default(); let paths = crate::container::bitcoin_ui::RenderPaths::default();
@@ -869,6 +890,7 @@ mod tests {
"mempool-electrs", "mempool-electrs",
"fedimint", "fedimint",
"fedimintd", "fedimintd",
"cuprate",
]; ];
let known: std::collections::HashSet<&str> = ALL_COMPANIONS let known: std::collections::HashSet<&str> = ALL_COMPANIONS
.iter() .iter()
@@ -893,6 +915,7 @@ mod tests {
names(&orphan_companions(&[])), names(&orphan_companions(&[])),
vec![ vec![
"archy-bitcoin-ui", "archy-bitcoin-ui",
"archy-cuprate-ui",
"archy-electrs-ui", "archy-electrs-ui",
"archy-fedimint-ui", "archy-fedimint-ui",
"archy-lnd-ui" "archy-lnd-ui"
@@ -906,7 +929,10 @@ mod tests {
// electrumx installed, fedimint and lnd not — yet all four companions // electrumx installed, fedimint and lnd not — yet all four companions
// were running because the reconciler was fed the manifest list. // were running because the reconciler was fed the manifest list.
let orphans = orphan_companions(&ids(&["bitcoin-knots", "electrumx"])); 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] #[test]
@@ -926,12 +952,18 @@ mod tests {
#[test] #[test]
fn apps_without_companions_orphan_everything_and_panic_nothing() { fn apps_without_companions_orphan_everything_and_panic_nothing() {
let orphans = orphan_companions(&ids(&["nextcloud", "not-a-real-app"])); let orphans = orphan_companions(&ids(&["nextcloud", "not-a-real-app"]));
assert_eq!(orphans.len(), 4); assert_eq!(orphans.len(), 5);
} }
#[test] #[test]
fn every_backend_installed_leaves_no_orphans() { 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!( assert!(
names(&orphans).is_empty(), names(&orphans).is_empty(),
"unexpected orphans: {:?}", "unexpected orphans: {:?}",
@@ -970,7 +1002,12 @@ mod tests {
let due = due_after_grace(orphans, &names_seen, &mut since, start + ORPHAN_GRACE); let due = due_after_grace(orphans, &names_seen, &mut since, start + ORPHAN_GRACE);
assert_eq!( assert_eq!(
names(&due), 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("mempool-electrs").len(), 1);
assert_eq!(companions_for("fedimint").len(), 1); assert_eq!(companions_for("fedimint").len(), 1);
assert_eq!(companions_for("fedimintd").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("nextcloud").len(), 0);
assert_eq!(companions_for("not-a-real-app").len(), 0); assert_eq!(companions_for("not-a-real-app").len(), 0);
} }
@@ -146,6 +146,7 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
"bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"), "bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"),
"lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"), "lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"),
"electrs-ui" | "archy-electrs-ui" => Some("ELECTRS_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 stack (primary = web)
"mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"), "mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
@@ -47,8 +47,17 @@ use crate::update::host_sudo;
/// ///
/// Keep in sync with the running fixture on .116. Centralized as a constant /// Keep in sync with the running fixture on .116. Centralized as a constant
/// so the rule is visible in one place and unit-testable. /// 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; 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 /// Apps expected to exist from first boot on every node — the ONLY apps the
/// boot reconciler may install from nothing. Every other app needs /// 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); crate::crash_recovery::pending_boot_start_done(&container_name);
continue; 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 { match self.ensure_running_with_mode(&lm, mode).await {
// Desired-state recovery: the app has no container and was left // Desired-state recovery: the app has no container and was left
// "absent" by boot reconcile, BUT it was running at the last // "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"); assert_eq!(compute_container_name(&m), "archy-electrs-ui");
let m = pull_manifest("lnd-ui", "foo:1"); let m = pull_manifest("lnd-ui", "foo:1");
assert_eq!(compute_container_name(&m), "archy-lnd-ui"); 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] #[test]
+1 -1
View File
@@ -8,5 +8,5 @@
pub const APP_LAUNCH_PORTS: &[u16] = &[ pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8090, 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, 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,
]; ];
+2 -2
View File
@@ -53,8 +53,8 @@ fn container_tier(name: &str) -> StartupTier {
| "indeedhub-api" => StartupTier::DependentService, | "indeedhub-api" => StartupTier::DependentService,
// Tier 4: Frontend/UI // Tier 4: Frontend/UI
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "penpot-frontend" "mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "cuprate-ui"
| "penpot-exporter" | "indeedhub" => StartupTier::Frontend, | "penpot-frontend" | "penpot-exporter" | "indeedhub" => StartupTier::Frontend,
// Tier 3: Application layer (everything else) // Tier 3: Application layer (everything else)
_ => StartupTier::Application, _ => StartupTier::Application,
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>An error occurred.</h1>
<p>Sorry, the page you are looking for is currently unavailable.<br/>
Please try again later.</p>
<p>If you are the system administrator of this resource then you should check
the error log for details.</p>
<p><em>Faithfully yours, nginx.</em></p>
</body>
</html>
+21
View File
@@ -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;"]
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

+407
View File
@@ -0,0 +1,407 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Cuprate Node - Archipelago</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
min-height: 100vh;
background: #000;
color: white;
overflow-x: hidden;
}
.bg-layer {
position: fixed;
inset: 0;
z-index: -10;
background-image: url('assets/img/bg-network.jpg');
background-size: cover;
background-position: center;
opacity: .42;
}
.glass-card {
position: relative;
background: rgba(0, 0, 0, 0.60);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
border-radius: 1rem;
padding: 1.5rem;
}
.glass-button {
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.9);
border-radius: 0.5rem;
padding: 0.4rem 0.9rem;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.3s ease;
}
.glass-button:hover { color: white; background-color: rgba(0, 0, 0, 0.7); }
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem 3rem; }
/* Match the Bitcoin dashboard's app-header rhythm: identity on the
left, compact live status cards on the right. */
.app-header { display:flex; align-items:center; justify-content:space-between; gap:1.5rem; flex-wrap:wrap; }
.app-header-id { display:flex; align-items:center; gap:1rem; min-width:0; flex:1 1 auto; }
.app-header-actions { display:flex; align-items:center; justify-content:flex-end; gap:.75rem; flex:0 0 auto; flex-wrap:nowrap; margin-left:auto; }
.info-card { display:flex; align-items:center; gap:.75rem; background:rgba(0,0,0,.6); backdrop-filter:blur(24px); border-radius:.75rem; padding:.65rem .85rem; box-shadow:inset 0 1px 0 rgba(255,255,255,.16); min-height:3.25rem; }
.info-card .card-icon { width:1.25rem; height:1.25rem; color:rgba(255,255,255,.6); flex:0 0 auto; }
.info-card-copy { display:flex; flex-direction:column; gap:.15rem; }
.info-card-copy .sub { margin:0; font-size:.6875rem; }
.info-card-copy strong { font-size:.8125rem; font-weight:600; color:rgba(255,255,255,.95); white-space:nowrap; }
.node-mark { width:3.25rem; height:3.25rem; border-radius:.85rem; display:grid; place-items:center; background:#050505; border:1px solid rgba(255,255,255,.18); box-shadow:0 8px 24px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.2); }
.tabbar { display:flex; gap:.35rem; padding:.35rem; margin:1.25rem 0; background:rgba(0,0,0,.62); border:1px solid rgba(255,255,255,.12); border-radius:.85rem; overflow-x:auto; }
.tab-btn { flex:1 0 auto; border:0; border-radius:.6rem; padding:.65rem 1rem; background:transparent; color:rgba(255,255,255,.55); cursor:pointer; font-size:.8rem; font-weight:600; }
.tab-btn:hover { color:#fff; background:rgba(255,255,255,.06); }
.tab-btn.active { color:#fff; background:linear-gradient(135deg,rgba(247,147,26,.3),rgba(255,255,255,.08)); box-shadow:inset 0 1px 0 rgba(255,255,255,.15); }
[data-panel].tab-hidden { display:none; }
@media (max-width:700px) {
.wrap { padding:1rem 1rem calc(6.75rem + env(safe-area-inset-bottom, 0px)); }
.app-header { flex-direction:column; align-items:stretch; gap:1rem; }
.app-header-id { flex-direction:column; justify-content:center; text-align:center; }
.app-header-text { text-align:center; }
.app-header-actions { flex-direction:column; align-items:stretch; justify-content:center; gap:.6rem; margin-left:0; }
.app-header-actions > .info-card { width:100%; }
.tabbar { position:fixed; left:.75rem; right:.75rem; bottom:calc(.5rem + env(safe-area-inset-bottom, 0px)); z-index:40; margin:0; padding:.35rem; border-radius:1rem; box-shadow:0 10px 30px rgba(0,0,0,.65); }
.tab-btn { min-width:4.5rem; padding:.7rem .5rem; font-size:.7rem; }
}
@media (min-width:701px) {
.app-header { flex-wrap:nowrap; }
.app-header-text { min-width:0; }
}
header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; }
header h1 .accent { color: inherit; }
.sub { color: rgba(255, 255, 255, 0.55); font-size: 0.85rem; margin-top: 0.2rem; }
.pill {
display: inline-flex; align-items: center; gap: 0.45rem;
padding: 0.35rem 0.8rem; border-radius: 999px;
font-size: 0.78rem; font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.06);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
.dot.online { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.dot.syncing { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
.dot.offline { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
.pill.online .dot { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.pill.syncing .dot { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
.pill.offline .dot { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; }
.card-title {
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.12em;
text-transform: uppercase; color: rgba(255, 255, 255, 0.5);
margin-bottom: 1rem;
}
.stat { display: flex; justify-content: space-between; align-items: baseline; padding: 0.45rem 0; border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
.stat:last-child { border-bottom: none; }
.stat .label { color: rgba(255, 255, 255, 0.55); font-size: 0.82rem; }
.stat .value { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.95rem; text-align: right; }
.stat .value.warn { color: #f7931a; }
.stat .value.err { color: #ef4444; }
.stat .value.ok { color: #22c55e; }
.height-hero { display: flex; align-items: baseline; gap: 0.6rem; margin-bottom: 0.75rem; }
.height-hero .big { font-size: 2.4rem; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.height-hero .of { color: rgba(255, 255, 255, 0.45); font-size: 1rem; font-variant-numeric: tabular-nums; }
.progress { height: 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); overflow: hidden; margin: 0.5rem 0 0.35rem; }
.progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, #f7931a, #ffc46b); transition: width 0.6s ease; }
.progress-label { font-size: 0.75rem; color: rgba(255, 255, 255, 0.55); font-variant-numeric: tabular-nums; }
.notice {
margin-top: 1rem; padding: 0.9rem 1.1rem; border-radius: 0.75rem;
background: rgba(247, 147, 26, 0.08);
border: 1px solid rgba(247, 147, 26, 0.35);
font-size: 0.82rem; line-height: 1.5; color: rgba(255, 255, 255, 0.8);
}
.notice.error { background: rgba(239, 68, 68, 0.08); border-color: rgba(239, 68, 68, 0.4); }
.notice b { color: white; }
.endpoint {
display: flex; align-items: center; gap: 0.6rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.5rem; padding: 0.55rem 0.75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.82rem; overflow-x: auto; white-space: nowrap;
}
.hint { font-size: 0.75rem; color: rgba(255, 255, 255, 0.45); margin-top: 0.6rem; line-height: 1.5; }
footer { margin-top: 2rem; text-align: center; color: rgba(255, 255, 255, 0.35); font-size: 0.72rem; }
</style>
</head>
<body>
<div class="bg-layer"></div>
<div class="wrap">
<header class="glass-card" style="padding:1.5rem;">
<div class="app-header">
<div class="app-header-id">
<div class="node-mark"><img src="assets/img/app-icons/cuprate.svg" alt="Cuprate" style="width:2.35rem;height:2.35rem;object-fit:contain"></div>
<div class="app-header-text">
<h1><span class="accent">Cuprate</span> Monero Node</h1>
<div class="sub">Rust implementation of the Monero protocol, on Archipelago</div>
</div>
</div>
<div class="app-header-actions">
<div class="info-card">
<div class="relative"><span class="dot" id="headerStatusDot"></span><span class="absolute inset-0 dot animate-ping opacity-50"></span></div>
<div class="info-card-copy"><div class="sub">Status</div><strong id="statusText">Connecting…</strong></div>
</div>
<div class="info-card">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
<div class="info-card-copy"><div class="sub">Network</div><strong id="headerNetwork">—</strong></div>
</div>
<div class="info-card">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h16M4 17h16" /></svg>
<div class="info-card-copy"><div class="sub">Height</div><strong id="headerHeight">—</strong></div>
</div>
</div>
</div>
</header>
<div id="offlineNotice" class="notice error" style="display:none;">
<b>Cuprate is not reachable.</b> The node may be stopped, still installing, or syncing.
Check the service status and try again shortly.
</div>
<nav class="tabbar" role="tablist" aria-label="Cuprate dashboard sections">
<button class="tab-btn active" data-tab="node" role="tab">Node</button>
<button class="tab-btn" data-tab="insights" role="tab">Insights</button>
<button class="tab-btn" data-tab="peers" role="tab">Peers</button>
<button class="tab-btn" data-tab="connect" role="tab">Connect</button>
</nav>
<div class="grid" style="margin-top:1rem;">
<div class="glass-card" data-panel="node">
<div class="card-title">Blockchain Sync</div>
<div class="height-hero">
<span class="big" id="height">—</span>
<span class="of" id="targetOf">of —</span>
</div>
<div class="progress"><div class="progress-fill" id="syncBar"></div></div>
<div class="progress-label" id="syncLabel">Waiting for node…</div>
<div class="stat"><span class="label">Network</span><span class="value" id="nettype">—</span></div>
<div class="stat"><span class="label">Uptime</span><span class="value" id="uptime">—</span></div>
<div class="stat"><span class="label">Node time</span><span class="value" id="nodeTime">—</span></div>
</div>
<div class="glass-card tab-hidden" data-panel="peers">
<div class="card-title">Peers &amp; Traffic</div>
<div class="stat"><span class="label">Outgoing connections</span><span class="value" id="outConns">—</span></div>
<div class="stat"><span class="label">Incoming connections</span><span class="value" id="inConns">—</span></div>
<div class="stat"><span class="label">RPC connections</span><span class="value" id="rpcConns">—</span></div>
<div class="stat"><span class="label">Known peers (white)</span><span class="value" id="whitePeers">—</span></div>
<div class="stat"><span class="label">Known peers (gray)</span><span class="value" id="grayPeers">—</span></div>
<div class="stat"><span class="label">Mempool transactions</span><span class="value" id="txPool">—</span></div>
<div class="stat"><span class="label">Alt blocks</span><span class="value" id="altBlocks">—</span></div>
</div>
<div class="glass-card tab-hidden" data-panel="insights">
<div class="card-title">Chain &amp; Disk</div>
<div class="stat"><span class="label">Difficulty</span><span class="value" id="difficulty">—</span></div>
<div class="stat"><span class="label">Chain size</span><span class="value" id="chainSize">—</span></div>
<div class="stat"><span class="label">Free disk</span><span class="value" id="freeSpace">—</span></div>
</div>
<div class="glass-card tab-hidden" data-panel="connect">
<div class="card-title">Connect a Wallet</div>
<div class="endpoint">
<span id="walletEndpoint">—</span>
<button class="glass-button" onclick="copyEndpoint(this)">Copy</button>
</div>
<div class="hint">
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.
</div>
<div class="stat" style="margin-top:0.9rem;"><span class="label">P2P port</span><span class="value" id="p2pPort">18183</span></div>
<div class="stat"><span class="label">Restricted RPC port</span><span class="value">18090</span></div>
</div>
</div>
<footer>
Cuprate is work-in-progress software; it independently validates Monero consensus rules.
Data served from this node's restricted RPC, refreshed every 15 seconds.
</footer>
</div>
<script>
const RPC = 'cuprate-rpc/';
const POLL_MS = 15000;
function tabular(n) { return Number(n || 0).toLocaleString('en-US'); }
function formatBytes(bytes) {
const n = Number(bytes);
if (!Number.isFinite(n) || n <= 0) return '—';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let v = n, i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
return `${v >= 100 || i === 0 ? tabular(Math.round(v)) : v.toFixed(1)} ${units[i]}`;
}
function formatUptime(secs) {
const s = Number(secs);
if (!Number.isFinite(s) || s < 0) return '—';
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function setStat(id, value, cls) {
const el = document.getElementById(id);
el.textContent = (value === null || value === undefined || value === '') ? '—' : value;
el.className = 'value' + (cls ? ' ' + cls : '');
}
function setStatus(kind, text) {
document.getElementById('statusText').textContent = text;
const dot = document.getElementById('headerStatusDot');
dot.className = 'dot ' + kind;
document.getElementById('offlineNotice').style.display = (kind === 'offline') ? '' : 'none';
}
async function callRpc(endpoint) {
const response = await fetch(RPC + endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
cache: 'no-store',
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data && data.error) throw new Error(data.error);
return data;
}
function render(info, heightFallback) {
const height = info.height ?? heightFallback ?? 0;
// Monero's get_info returns target_height == 0 when the node is
// FULLY SYNCED — the field is the height being caught up to, not
// the chain tip, so `??` cannot substitute for the 0 case (a
// synced node would sit forever at "Syncing — 0.00%"). Treat
// 0/absent as "target is our own height" — the same sentinel
// core/archipelago/src/electrs_status.rs branches on.
const rawTarget = info.target_height ?? info.target ?? 0;
const target = rawTarget > 0 ? rawTarget : height;
document.getElementById('height').textContent = tabular(height);
document.getElementById('targetOf').textContent = `of ${tabular(target)}`;
const pct = target > 0 ? Math.min(100, (height / target) * 100) : 0;
document.getElementById('syncBar').style.width = pct.toFixed(2) + '%';
const synced = target > 0 && height >= target;
if (synced) {
document.getElementById('syncLabel').textContent = 'Fully synced';
setStatus('online', 'Synced');
} else {
const behind = Math.max(0, target - height);
document.getElementById('syncLabel').textContent =
`${pct.toFixed(2)}% — ${tabular(behind)} blocks behind`;
setStatus('syncing', 'Syncing');
}
const nettype = info.mainnet ? 'Mainnet'
: info.testnet ? 'Testnet'
: info.stagenet ? 'Stagenet'
: (info.net || info.nettype || '—');
setStat('nettype', nettype);
document.getElementById('headerNetwork').textContent = nettype;
document.getElementById('headerHeight').textContent = tabular(height);
const nowSecs = info.time ?? info.adjusted_time ?? Math.floor(Date.now() / 1000);
const started = info.start_time ?? info.startup_time;
setStat('uptime', started ? formatUptime(nowSecs - started) : '—');
setStat('nodeTime', new Date(nowSecs * 1000).toLocaleString());
setStat('outConns', tabular(info.outgoing_connections_count));
setStat('inConns', tabular(info.incoming_connections_count));
setStat('rpcConns', tabular(info.rpc_connections_count));
setStat('whitePeers', tabular(info.white_peerlist_size));
setStat('grayPeers', tabular(info.grey_peerlist_size));
setStat('txPool', tabular(info.tx_pool_size));
const alt = Number(info.alt_blocks_count || 0);
setStat('altBlocks', tabular(alt), alt > 0 ? 'warn' : undefined);
setStat('difficulty', tabular(info.difficulty));
setStat('chainSize', formatBytes(info.blocks_size ?? info.block_sizes?.[0]));
const free = info.free_space;
const freeWarn = Number.isFinite(free) && free > 0 && free < 50 * 1024 * 1024 * 1024;
setStat('freeSpace', formatBytes(free), freeWarn ? 'warn' : undefined);
if (!document.getElementById('walletEndpoint').textContent.includes(':')) {
document.getElementById('walletEndpoint').textContent =
`${window.location.hostname}:18090`;
}
}
async function refresh() {
let height = null;
try {
const h = await callRpc('get_height');
height = h.height;
} catch { /* get_info below carries the real error */ }
try {
const info = await callRpc('get_info');
render(info, height);
} catch {
setStatus('offline', 'Node offline');
if (height !== null) document.getElementById('height').textContent = tabular(height);
}
}
function copyEndpoint(btn) {
const text = document.getElementById('walletEndpoint').textContent;
const done = () => { btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = 'Copy'; }, 1500); };
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done).catch(() => { done(); });
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch { /* best effort */ }
document.body.removeChild(ta);
done();
}
}
document.querySelectorAll('.tab-btn').forEach((button) => {
button.addEventListener('click', () => {
const tab = button.dataset.tab;
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.toggle('active', b === button));
document.querySelectorAll('[data-panel]').forEach((panel) => panel.classList.toggle('tab-hidden', panel.dataset.panel !== tab));
});
});
// Height alone answers even while get_info is warming up; if both
// fail the offline card explains the disk gate as a likely cause.
refresh();
setInterval(refresh, POLL_MS);
</script>
</body>
</html>
+56
View File
@@ -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;
}
}
@@ -149,8 +149,8 @@ describe('appSessionConfig', () => {
// A runtime port the gate does NOT front keeps plain http (https would // A runtime port the gate does NOT front keeps plain http (https would
// fail to connect outright). // fail to connect outright).
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083') 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. // Cuprate's raw RPC is never a launch surface; use the companion UI.
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090') expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('/app/cuprate-ui/')
}) })
it('keeps the pre-catalog Source app on the dashboard origin', () => { it('keeps the pre-catalog Source app on the dashboard origin', () => {
@@ -34,6 +34,9 @@ export const APP_PORTS: Record<string, number> = {
'bitcoin-knots': 8334, 'bitcoin-knots': 8334,
'bitcoin-core': 8334, 'bitcoin-core': 8334,
'bitcoin-ui': 8334, 'bitcoin-ui': 8334,
'cuprate': 18091,
'cuprate-ui': 18091,
'archy-cuprate-ui': 18091,
'electrumx': 50002, 'electrumx': 50002,
'electrs': 50002, 'electrs': 50002,
'archy-electrs-ui': 50002, 'archy-electrs-ui': 50002,
@@ -153,6 +156,15 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
return appOrigin(8334, id) 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') { if (runtimeUrl && id !== 'netbird') {
let base = runtimeUrl.replace(/localhost/i, window.location.hostname) let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
// The backend reports runtime URLs as http:// because that is how the app // The backend reports runtime URLs as http:// because that is how the app
@@ -9,6 +9,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"bitcoin-ui": 8334, "bitcoin-ui": 8334,
"botfights": 9100, "botfights": 9100,
"btcpay-server": 23000, "btcpay-server": 23000,
"cuprate-ui": 18091,
"electrs-ui": 50002, "electrs-ui": 50002,
"electrumx": 50002, "electrumx": 50002,
"fedimint": 8175, "fedimint": 8175,
@@ -54,6 +55,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"btcpay-server": "BTCPay Server", "btcpay-server": "BTCPay Server",
"core-lightning": "Core Lightning (CLN)", "core-lightning": "Core Lightning (CLN)",
"cuprate": "Cuprate", "cuprate": "Cuprate",
"cuprate-ui": "Cuprate UI",
"electrs-ui": "Electrs UI", "electrs-ui": "Electrs UI",
"electrumx": "ElectrumX", "electrumx": "ElectrumX",
"fedimint": "Fedimint Guardian", "fedimint": "Fedimint Guardian",
@@ -123,6 +123,8 @@ const CATALOG_APP_ID_ALIASES: Record<string, string> = {
'archy-lnd-ui': 'lnd-ui', 'archy-lnd-ui': 'lnd-ui',
'bitcoin-knots': 'bitcoin-ui', 'bitcoin-knots': 'bitcoin-ui',
'bitcoin-core': 'bitcoin-ui', 'bitcoin-core': 'bitcoin-ui',
'cuprate': 'cuprate-ui',
'archy-cuprate-ui': 'cuprate-ui',
'fedimintd': 'fedimint', 'fedimintd': 'fedimint',
'immich_server': 'immich', 'immich_server': 'immich',
} }
+5
View File
@@ -173,6 +173,11 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
secure: false, 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. // 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/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 }, '/app/electrs': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
+2 -2
View File
@@ -1315,13 +1315,13 @@
}, },
"dependencies": [ "dependencies": [
{ {
"storage": "300Gi" "storage": "450Gi"
} }
], ],
"resources": { "resources": {
"cpu_limit": 0, "cpu_limit": 0,
"memory_limit": "10Gi", "memory_limit": "10Gi",
"disk_limit": "300Gi" "disk_limit": "450Gi"
}, },
"security": { "security": {
"capabilities": [], "capabilities": [],
+1
View File
@@ -19,6 +19,7 @@ INTERNAL_MANIFEST_IDS = {
"archy-nbxplorer", "archy-nbxplorer",
"bitcoin-ui", "bitcoin-ui",
"core-lightning", "core-lightning",
"cuprate-ui",
"electrs-ui", "electrs-ui",
"fips-ui", "fips-ui",
"lnd-ui", "lnd-ui",
+1
View File
@@ -65,6 +65,7 @@ SINGLE = {
"bitcoin-ui": "BITCOIN_UI_IMAGE", "bitcoin-ui": "BITCOIN_UI_IMAGE",
"lnd-ui": "LND_UI_IMAGE", "lnd-ui": "LND_UI_IMAGE",
"electrs-ui": "ELECTRS_UI_IMAGE", "electrs-ui": "ELECTRS_UI_IMAGE",
"cuprate-ui": "CUPRATE_UI_IMAGE",
"homeassistant": "HOMEASSISTANT_IMAGE", "homeassistant": "HOMEASSISTANT_IMAGE",
"grafana": "GRAFANA_IMAGE", "grafana": "GRAFANA_IMAGE",
"uptime-kuma": "UPTIME_KUMA_IMAGE", "uptime-kuma": "UPTIME_KUMA_IMAGE",
+1
View File
@@ -126,6 +126,7 @@ IMMICH_SERVER_IMAGE="$ARCHY_REGISTRY/immich-server:release"
BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.123-alpha" BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.123-alpha"
LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-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" ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:1.7.123-alpha"
CUPRATE_UI_IMAGE="$ARCHY_REGISTRY/cuprate-ui:1.7.123-alpha"
# Base images # Base images
NGINX_ALPINE_IMAGE="$ARCHY_REGISTRY/nginx:1.27.4-alpine" NGINX_ALPINE_IMAGE="$ARCHY_REGISTRY/nginx:1.27.4-alpine"