feat(cuprate-ui): companion dashboard for the Cuprate Monero node
Same companion shape as bitcoin-ui/electrs-ui: host-networked nginx bound to 127.0.0.1:18091 (auth: gated + session_passthrough), serving a dark glass status page that polls the node's restricted RPC via a session-gated /cuprate-rpc/ proxy — sync height/target with progress bar, peers, mempool, chain size and free disk (from get_info), plus a wallet 'remote node' endpoint. The offline state explains the disk gate so a refused node says why. No secret rendering: the restricted RPC is Monero's safe-for-public subset, so nginx.conf is baked into the image (no pre_start hook, no bind mount). companion.rs auto-provisions archy-cuprate-ui alongside cuprate and reaps it when cuprate goes. Catalog regenerated (cuprate-ui entry + manifest embed, 18091 into the mesh launch-port list). NOTE: releases/app-catalog.json is UNSIGNED as committed — run scripts/sign-catalog.sh before publishing.
This commit is contained in:
@@ -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
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -6,7 +6,41 @@
|
|||||||
//! no listener, so allowing them is inert.
|
//! no listener, so allowing them is inert.
|
||||||
|
|
||||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||||
2283, 2342, 3000, 3001, 3002, 3030, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087,
|
2283,
|
||||||
8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
|
2342,
|
||||||
18081, 18083, 23000, 32838, 50002,
|
3000,
|
||||||
|
3001,
|
||||||
|
3002,
|
||||||
|
3030,
|
||||||
|
4080,
|
||||||
|
5180,
|
||||||
|
7778,
|
||||||
|
8080,
|
||||||
|
8081,
|
||||||
|
8082,
|
||||||
|
8083,
|
||||||
|
8084,
|
||||||
|
8085,
|
||||||
|
8087,
|
||||||
|
8090,
|
||||||
|
8096,
|
||||||
|
8123,
|
||||||
|
8175,
|
||||||
|
8176,
|
||||||
|
8187,
|
||||||
|
8240,
|
||||||
|
8334,
|
||||||
|
8336,
|
||||||
|
8888,
|
||||||
|
8999,
|
||||||
|
9000,
|
||||||
|
9100,
|
||||||
|
10380,
|
||||||
|
11434,
|
||||||
|
18081,
|
||||||
|
18083,
|
||||||
|
18091,
|
||||||
|
23000,
|
||||||
|
32838,
|
||||||
|
50002,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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/
|
||||||
|
# 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;"]
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
<!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:
|
||||||
|
radial-gradient(1200px 600px at 80% -10%, rgba(247, 147, 26, 0.16), transparent 60%),
|
||||||
|
radial-gradient(900px 500px at 10% 110%, rgba(112, 76, 240, 0.12), transparent 60%),
|
||||||
|
#000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
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: #f7931a; }
|
||||||
|
.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; }
|
||||||
|
.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>
|
||||||
|
<div>
|
||||||
|
<h1><span class="accent">Cuprate</span> Monero Node</h1>
|
||||||
|
<div class="sub">Rust implementation of the Monero protocol, on Archipelago</div>
|
||||||
|
</div>
|
||||||
|
<span id="statusPill" class="pill"><span class="dot"></span><span id="statusText">Connecting…</span></span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="offlineNotice" class="notice error" style="display:none;">
|
||||||
|
<b>Cuprate is not reachable.</b> The node is stopped, still installing, or was refused
|
||||||
|
by the disk-space gate — a Monero node cannot run pruned, so Archipelago keeps it off
|
||||||
|
disks too small to hold the full chain (~250 GB and growing) rather than let it fill
|
||||||
|
the drive. Attach a larger disk and start it again.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid" style="margin-top:1rem;">
|
||||||
|
<div class="glass-card">
|
||||||
|
<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">
|
||||||
|
<div class="card-title">Peers & 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">
|
||||||
|
<div class="card-title">Chain & 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 class="notice">
|
||||||
|
<b>Monero has no pruned mode.</b> Unlike the Bitcoin apps on this node — which
|
||||||
|
drop old block data automatically when disk is scarce — a cuprate node stores the
|
||||||
|
full chain. Archipelago therefore only runs it on disks of 450 GB or more and
|
||||||
|
refuses it (with this explanation) anywhere smaller.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-card">
|
||||||
|
<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) {
|
||||||
|
const pill = document.getElementById('statusPill');
|
||||||
|
pill.className = 'pill ' + kind;
|
||||||
|
document.getElementById('statusText').textContent = text;
|
||||||
|
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;
|
||||||
|
const target = info.target_height ?? info.target ?? 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);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+3320
-3261
File diff suppressed because one or more lines are too long
@@ -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",
|
||||||
|
|||||||
@@ -64,6 +64,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",
|
||||||
|
|||||||
@@ -127,6 +127,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"
|
||||||
|
|||||||
Reference in New Issue
Block a user