From eacd74e1db05096ea06b9078403c2513803d7c52 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Wed, 9 Sep 2026 07:49:18 +0000 Subject: [PATCH] feat(cuprate-ui): companion dashboard for the Cuprate Monero node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/cuprate-ui/manifest.yml | 67 ++++ core/archipelago/src/container/companion.rs | 48 ++- .../src/container/image_versions.rs | 1 + core/archipelago/src/fips/app_ports.rs | 41 ++- core/archipelago/src/health_monitor.rs | 4 +- docker/cuprate-ui/50x.html | 19 + docker/cuprate-ui/Dockerfile | 20 ++ docker/cuprate-ui/index.html | 339 ++++++++++++++++++ docker/cuprate-ui/nginx.conf | 56 +++ scripts/check-app-catalog-drift.py | 1 + scripts/generate-app-catalog.sh | 1 + scripts/image-versions.sh | 1 + 12 files changed, 588 insertions(+), 10 deletions(-) create mode 100644 apps/cuprate-ui/manifest.yml create mode 100644 docker/cuprate-ui/50x.html create mode 100644 docker/cuprate-ui/Dockerfile create mode 100644 docker/cuprate-ui/index.html create mode 100644 docker/cuprate-ui/nginx.conf diff --git a/apps/cuprate-ui/manifest.yml b/apps/cuprate-ui/manifest.yml new file mode 100644 index 00000000..05260c95 --- /dev/null +++ b/apps/cuprate-ui/manifest.yml @@ -0,0 +1,67 @@ +app: + id: cuprate-ui + name: Cuprate UI + version: 1.0.0 + # Built by this project — there is no upstream release feed to watch. + upstream: + kind: internal + description: | + Archipelago-native HTTP frontend for the Cuprate Monero node. Runs nginx + inside a container, serves a static status dashboard, and proxies + /cuprate-rpc/ to the cuprate restricted RPC on 127.0.0.1:18090 (the + published host port for the container's 18089). No credentials are + injected — the restricted RPC is Monero's own safe-for-public subset — so + the nginx.conf is baked into the image and there is no rendered-config + bind-mount like bitcoin-ui's. + + container: + build: + context: /opt/archipelago/docker/cuprate-ui + dockerfile: Dockerfile + tag: localhost/cuprate-ui:local + + dependencies: + - app_id: cuprate + + resources: + memory_limit: 64Mi + + security: + readonly_root: false + network_policy: host + + # Host networking: nginx listens on 18091 directly on the host IP. + # Declared so the APP GATE can see this port. Host networking means Podman + # publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here + # is a statement of where the container's own nginx listens — 127.0.0.1 — + # not a publish instruction. Without this declaration the gate would have no + # idea the port existed: neither protected nor listed as unprotected. + ports: + - host: 18091 + container: 18091 + protocol: tcp + bind: 127.0.0.1 + auth: gated + # First-party companion UI: its nginx forwards the node session cookie + # to the daemon's authenticated endpoints; without passthrough the gate + # strips it and every data call 401s while the page shell renders. + session_passthrough: true + + volumes: [] + + environment: [] + + health_check: + type: http + endpoint: http://127.0.0.1:18091 + path: / + interval: 30s + timeout: 5s + retries: 3 + + metadata: + icon: /assets/img/app-icons/cuprate.svg + category: money + tier: optional + author: Archipelago + repo: https://github.com/Cuprate/cuprate diff --git a/core/archipelago/src/container/companion.rs b/core/archipelago/src/container/companion.rs index 934137c3..9752454f 100644 --- a/core/archipelago/src/container/companion.rs +++ b/core/archipelago/src/container/companion.rs @@ -10,6 +10,7 @@ //! | lnd | archy-lnd-ui | wallet/channel UI | //! | electrumx | archy-electrs-ui | indexer status UI | //! | fedimint | archy-fedimint-ui | wait/proxy Guardian UI | +//! | cuprate | archy-cuprate-ui | Monero node status UI | //! //! Lifecycle: `install` writes a Quadlet `.container` unit to //! `~/.config/containers/systemd/`, daemon-reloads, then starts the @@ -97,6 +98,7 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] { "lnd" => LND_UI, "electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI, "fedimint" | "fedimintd" => FEDIMINT_UI, + "cuprate" => CUPRATE_UI, _ => &[], } } @@ -104,7 +106,8 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] { /// Every companion this build knows how to provision. Kept beside /// `companions_for` — a new companion must be added to both, or the reaper /// will not recognise it as one of ours and will leave it running forever. -const ALL_COMPANIONS: &[&[CompanionSpec]] = &[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI]; +const ALL_COMPANIONS: &[&[CompanionSpec]] = + &[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI, CUPRATE_UI]; const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec { name: "archy-bitcoin-ui", @@ -172,6 +175,24 @@ const FEDIMINT_UI: &[CompanionSpec] = &[CompanionSpec { host_network: true, }]; +const CUPRATE_UI: &[CompanionSpec] = &[CompanionSpec { + name: "archy-cuprate-ui", + image_base: "cuprate-ui", + build_dir_candidates: &[ + "/opt/archipelago/docker/cuprate-ui", + "/home/archipelago/archy/docker/cuprate-ui", + "/home/archipelago/Projects/archy/docker/cuprate-ui", + ], + // No pre-start hook and no bind mounts: unlike bitcoin-ui there is no + // secret to inject. Cuprate's restricted RPC (the only thing this UI + // proxies) is unauthenticated by design — Monero's safe-for-public + // subset — so the nginx.conf is baked into the image. + pre_start: None, + bind_mounts: &[], + ports: &[], + host_network: true, +}]; + fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> { Box::pin(async { let paths = crate::container::bitcoin_ui::RenderPaths::default(); @@ -869,6 +890,7 @@ mod tests { "mempool-electrs", "fedimint", "fedimintd", + "cuprate", ]; let known: std::collections::HashSet<&str> = ALL_COMPANIONS .iter() @@ -893,6 +915,7 @@ mod tests { names(&orphan_companions(&[])), vec![ "archy-bitcoin-ui", + "archy-cuprate-ui", "archy-electrs-ui", "archy-fedimint-ui", "archy-lnd-ui" @@ -906,7 +929,10 @@ mod tests { // electrumx installed, fedimint and lnd not — yet all four companions // were running because the reconciler was fed the manifest list. let orphans = orphan_companions(&ids(&["bitcoin-knots", "electrumx"])); - assert_eq!(names(&orphans), vec!["archy-fedimint-ui", "archy-lnd-ui"]); + assert_eq!( + names(&orphans), + vec!["archy-cuprate-ui", "archy-fedimint-ui", "archy-lnd-ui"] + ); } #[test] @@ -926,12 +952,18 @@ mod tests { #[test] fn apps_without_companions_orphan_everything_and_panic_nothing() { let orphans = orphan_companions(&ids(&["nextcloud", "not-a-real-app"])); - assert_eq!(orphans.len(), 4); + assert_eq!(orphans.len(), 5); } #[test] fn every_backend_installed_leaves_no_orphans() { - let orphans = orphan_companions(&ids(&["bitcoin-knots", "lnd", "electrumx", "fedimint"])); + let orphans = orphan_companions(&ids(&[ + "bitcoin-knots", + "lnd", + "electrumx", + "fedimint", + "cuprate", + ])); assert!( names(&orphans).is_empty(), "unexpected orphans: {:?}", @@ -970,7 +1002,12 @@ mod tests { let due = due_after_grace(orphans, &names_seen, &mut since, start + ORPHAN_GRACE); assert_eq!( names(&due), - vec!["archy-electrs-ui", "archy-fedimint-ui", "archy-lnd-ui"] + vec![ + "archy-cuprate-ui", + "archy-electrs-ui", + "archy-fedimint-ui", + "archy-lnd-ui" + ] ); } @@ -1024,6 +1061,7 @@ mod tests { assert_eq!(companions_for("mempool-electrs").len(), 1); assert_eq!(companions_for("fedimint").len(), 1); assert_eq!(companions_for("fedimintd").len(), 1); + assert_eq!(companions_for("cuprate").len(), 1); assert_eq!(companions_for("nextcloud").len(), 0); assert_eq!(companions_for("not-a-real-app").len(), 0); } diff --git a/core/archipelago/src/container/image_versions.rs b/core/archipelago/src/container/image_versions.rs index a82ba467..0fd991c9 100644 --- a/core/archipelago/src/container/image_versions.rs +++ b/core/archipelago/src/container/image_versions.rs @@ -146,6 +146,7 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> { "bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"), "lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"), "electrs-ui" | "archy-electrs-ui" => Some("ELECTRS_UI_IMAGE"), + "cuprate-ui" | "archy-cuprate-ui" => Some("CUPRATE_UI_IMAGE"), // Mempool stack (primary = web) "mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"), diff --git a/core/archipelago/src/fips/app_ports.rs b/core/archipelago/src/fips/app_ports.rs index 85c0ba53..54bbd536 100644 --- a/core/archipelago/src/fips/app_ports.rs +++ b/core/archipelago/src/fips/app_ports.rs @@ -6,7 +6,42 @@ //! no listener, so allowing them is inert. pub const APP_LAUNCH_PORTS: &[u16] = &[ - 2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8090, - 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8337, 8888, 8999, 9000, 9100, 10380, 11434, - 18081, 18083, 23000, 32838, 50002, + 2283, + 2342, + 3000, + 3001, + 3002, + 4080, + 5180, + 7778, + 8080, + 8081, + 8082, + 8083, + 8084, + 8085, + 8087, + 8090, + 8096, + 8123, + 8175, + 8176, + 8187, + 8240, + 8334, + 8336, + 8337, + 8888, + 8999, + 9000, + 9100, + 10380, + 11434, + 18081, + 18083, + 18091, + 18091, + 23000, + 32838, + 50002, ]; diff --git a/core/archipelago/src/health_monitor.rs b/core/archipelago/src/health_monitor.rs index fbcdc2cd..a203e138 100644 --- a/core/archipelago/src/health_monitor.rs +++ b/core/archipelago/src/health_monitor.rs @@ -53,8 +53,8 @@ fn container_tier(name: &str) -> StartupTier { | "indeedhub-api" => StartupTier::DependentService, // Tier 4: Frontend/UI - "mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "penpot-frontend" - | "penpot-exporter" | "indeedhub" => StartupTier::Frontend, + "mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "cuprate-ui" + | "penpot-frontend" | "penpot-exporter" | "indeedhub" => StartupTier::Frontend, // Tier 3: Application layer (everything else) _ => StartupTier::Application, diff --git a/docker/cuprate-ui/50x.html b/docker/cuprate-ui/50x.html new file mode 100644 index 00000000..a57c2f93 --- /dev/null +++ b/docker/cuprate-ui/50x.html @@ -0,0 +1,19 @@ + + + +Error + + + +

An error occurred.

+

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

+

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

+

Faithfully yours, nginx.

+ + diff --git a/docker/cuprate-ui/Dockerfile b/docker/cuprate-ui/Dockerfile new file mode 100644 index 00000000..1ede64e3 --- /dev/null +++ b/docker/cuprate-ui/Dockerfile @@ -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;"] diff --git a/docker/cuprate-ui/index.html b/docker/cuprate-ui/index.html new file mode 100644 index 00000000..4b110574 --- /dev/null +++ b/docker/cuprate-ui/index.html @@ -0,0 +1,339 @@ + + + + + + + + + Cuprate Node - Archipelago + + + +
+
+
+
+

Cuprate Monero Node

+
Rust implementation of the Monero protocol, on Archipelago
+
+ Connecting… +
+ + + +
+
+
Blockchain Sync
+
+ — + of — +
+
+
Waiting for node…
+
Network—
+
Uptime—
+
Node time—
+
+ +
+
Peers & Traffic
+
Outgoing connections—
+
Incoming connections—
+
RPC connections—
+
Known peers (white)—
+
Known peers (gray)—
+
Mempool transactions—
+
Alt blocks—
+
+ +
+
Chain & Disk
+
Difficulty—
+
Chain size—
+
Free disk—
+
+ Monero has no pruned mode. 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. +
+
+ +
+
Connect a Wallet
+
+ — + +
+
+ Restricted RPC — Monero's own safe-for-public subset, what Feather, + monero-wallet-rpc and the GUI use for a “remote node”. Full (unrestricted) RPC + stays container-loopback only and is never published. +
+
P2P port18183
+
Restricted RPC port18090
+
+
+ +
+ Cuprate is work-in-progress software; it independently validates Monero consensus rules. + Data served from this node's restricted RPC, refreshed every 15 seconds. +
+
+ + + + diff --git a/docker/cuprate-ui/nginx.conf b/docker/cuprate-ui/nginx.conf new file mode 100644 index 00000000..12c73c7c --- /dev/null +++ b/docker/cuprate-ui/nginx.conf @@ -0,0 +1,56 @@ +server { + # Loopback ONLY — same rule as docker/bitcoin-ui and docker/electrs-ui. + # This container is host-networked, so nginx binds the HOST's address + # directly; a bare `listen` would expose the page on LAN, Tailscale and + # the mesh with the app gate nowhere in front of it. Binding loopback lets + # the daemon claim the external addresses and authenticate them; + # see appgate::listener and apps/cuprate-ui/manifest.yml (auth: gated). + listen 127.0.0.1:18091; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Session gate for the RPC proxy below. Internal: reachable only by + # nginx's own auth_request subrequest, never by a client. + location = /_session_check { + internal; + proxy_pass http://127.0.0.1:5678/auth/session-check; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header Host $host; + proxy_set_header Cookie $http_cookie; + proxy_set_header X-CSRF-Token $http_x_csrf_token; + } + + # Cuprate's restricted RPC (host-published on 127.0.0.1:18090, auth: open + # — Monero's own safe-for-public subset, what remote-node wallets use). + # It injects no credentials the caller lacks, but it is still session- + # gated here so the whole companion behaves as one authenticated surface + # (same defence-in-depth bitcoin-ui applies to its credential-injecting + # proxy: loopback reaches it without the gate's challenge). + location /cuprate-rpc/ { + # Preflight carries no cookies by design — answer it before the gate, + # otherwise the browser reports an opaque CORS failure instead of a 401. + if ($request_method = OPTIONS) { return 204; } + auth_request /_session_check; + proxy_pass http://127.0.0.1:18090/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + add_header Access-Control-Allow-Origin $scheme://$http_host always; + add_header Access-Control-Allow-Credentials "true" always; + add_header Vary "Origin" always; + add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; + } + + # no-cache (revalidate), not no-store — same reasoning as docker/bitcoin-ui: + # a rebuilt companion image must actually be seen by the browser, while the + # ETag still saves the transfer when nothing changed. + location / { + add_header Cache-Control "no-cache"; + try_files $uri $uri/ /index.html; + } +} diff --git a/scripts/check-app-catalog-drift.py b/scripts/check-app-catalog-drift.py index 88f62db2..cf957cfc 100644 --- a/scripts/check-app-catalog-drift.py +++ b/scripts/check-app-catalog-drift.py @@ -19,6 +19,7 @@ INTERNAL_MANIFEST_IDS = { "archy-nbxplorer", "bitcoin-ui", "core-lightning", + "cuprate-ui", "electrs-ui", "fips-ui", "lnd-ui", diff --git a/scripts/generate-app-catalog.sh b/scripts/generate-app-catalog.sh index 7c02ae2c..cfb976cc 100755 --- a/scripts/generate-app-catalog.sh +++ b/scripts/generate-app-catalog.sh @@ -65,6 +65,7 @@ SINGLE = { "bitcoin-ui": "BITCOIN_UI_IMAGE", "lnd-ui": "LND_UI_IMAGE", "electrs-ui": "ELECTRS_UI_IMAGE", + "cuprate-ui": "CUPRATE_UI_IMAGE", "homeassistant": "HOMEASSISTANT_IMAGE", "grafana": "GRAFANA_IMAGE", "uptime-kuma": "UPTIME_KUMA_IMAGE", diff --git a/scripts/image-versions.sh b/scripts/image-versions.sh index 12a49032..81ed49f6 100644 --- a/scripts/image-versions.sh +++ b/scripts/image-versions.sh @@ -126,6 +126,7 @@ IMMICH_SERVER_IMAGE="$ARCHY_REGISTRY/immich-server:release" BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.123-alpha" LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:1.7.123-alpha" ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:1.7.123-alpha" +CUPRATE_UI_IMAGE="$ARCHY_REGISTRY/cuprate-ui:1.7.123-alpha" # Base images NGINX_ALPINE_IMAGE="$ARCHY_REGISTRY/nginx:1.27.4-alpine"