diff --git a/apps/cuprate/manifest.yml b/apps/cuprate/manifest.yml new file mode 100644 index 00000000..c2d0e565 --- /dev/null +++ b/apps/cuprate/manifest.yml @@ -0,0 +1,151 @@ +app: + id: cuprate + name: Cuprate + # Matches the crate's own Cargo.toml version (binaries/cuprated/Cargo.toml). + # Cuprate has no stable release yet — this is explicitly work-in-progress + # software (see upstream README). The image tag below pins the exact + # commit built, since "0.1.0-preview" alone is not reproducible. + version: 0.1.0-preview + # Where this app comes from, so scripts/check-upstream-releases.py can + # tell us when the pin below has fallen behind. Without it nothing can: + # container.image names our mirror, not the project it was mirrored from. + upstream: + kind: github + repo: Cuprate/cuprate + description: Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network. + + container: + # Built from the upstream Dockerfile at the tip of main, 18 commits past + # the cuprated-0.1.0-preview tag (commit 618ff14, 2026-08-19) — there is + # no newer tagged release as of this writing. Re-pin to a tagged release + # once upstream cuts one. + image: source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14 + pull_policy: if-not-present + network: archy-net + # The image's own ENTRYPOINT is ["/usr/local/bin/cuprated"]; these are + # appended as its argv, matching the project's own systemd unit + # (cuprated.service) invocation exactly. + custom_args: ["--config-file", "/home/cuprate/Cuprated.toml"] + # The image (FROM scratch) creates uid:gid 1000:1000 for the `cuprate` + # user at build time and runs as it unconditionally (USER 1000:1000, + # no shell to switch users at runtime) — same pattern as + # apps/phoenixd, apps/electrumx, apps/nostr-rs-relay, apps/portainer, + # apps/barkd. The bind-mounted data dir must be owned by that literal + # uid or cuprated dies on a permission error the first time it writes. + data_uid: "1000:1000" + + dependencies: + # Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a + # month; cuprated's pruning support is not confirmed stable yet (the + # `pruning` crate exists in the workspace but nothing in this config + # surface toggles it), so this sizes for a full unpruned chain plus + # headroom rather than assuming pruning is available. + - storage: 300Gi + + resources: + cpu_limit: 0 + memory_limit: 4Gi + disk_limit: 300Gi + + security: + # FROM scratch, no package manager/shell, ownership fixed at build time + # — unlike bitcoin-knots this needs no runtime chown/setuid dance, so it + # can run fully read-only with an empty capability set. + capabilities: [] + readonly_root: true + no_new_privileges: true + network_policy: isolated + + ports: + # P2P. Cuprate's own default listen address is already 0.0.0.0 + # (p2p.clear_net.listen_on), so no config override is needed — only the + # host-side port differs from Monero's canonical 18080 because that + # number is already taken on this fleet by lnd's REST port. + - host: 18183 + container: 18080 + protocol: tcp + auth: none + auth_rationale: >- + Monero p2p gossip. Peers are anonymous by design and speak the Monero wire protocol, not HTTP. + # Unrestricted RPC (full node control) is deliberately NOT published. + # cuprated has no RPC authentication, and for a published port to reach + # it the service would have to bind 0.0.0.0 inside the container — at + # which point every other app can reach it directly on 18081, since + # ports[].bind only restricts the HOST side and podman bridges route to + # each other (verified live 2026-08-22: a peer container on archy-net + # got an unauthenticated get_info, from a *different* network). That is + # unlike bitcoin-knots, whose 0.0.0.0 RPC still demands the rpcuser / + # rpcpassword it writes from generated secrets. So unrestricted RPC is + # left at cuprated's own default — container loopback only, reachable by + # nothing — which is also what upstream intends by refusing a non-local + # bind without an explicit i_know_what_im_doing override. + # Restricted RPC: Monero's own purpose-built safe-for-public subset — + # what wallets use when connecting to a "remote node". Disabled by + # cuprated's own default; enabled via files[] below. A dashboard login + # would break wallet clients connecting programmatically, same + # reasoning as electrumx's port. The daemon still uses its canonical + # container port 18089, but Penpot already owns host port 18089, so this + # maps the public host port to the free 18090 instead. + - host: 18090 + container: 18089 + protocol: tcp + auth: none + auth_rationale: >- + Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie. + + volumes: + - type: bind + source: /var/lib/archipelago/cuprate + target: /home/cuprate + options: [rw] + + # Settings that need to differ from cuprated's own documented defaults + # (verified against `cuprated --generate-config` and `--dry-run` locally, + # 2026-08-21): + # - target_max_memory: cuprated's own default auto-detects total *host* + # RAM via sysinfo, which inside a memory-limited container would let + # it size caches far past what resources.memory_limit above actually + # grants — same class of problem bitcoin-knots' -dbcache sizing + # comment addresses. Set explicitly, comfortably under the 4Gi limit. + # - rpc.restricted.enable: cuprated ships this off by default; flip on + # so the auth:none host port above actually serves something instead + # of refusing every connection. port stays at its documented default + # (canonical 18089), and advertise stays false — this node is not + # opting in to being listed as a public remote node over the p2p + # network, just reachable if someone points a wallet at it directly. + # - rpc.unrestricted.address + the allow-public flag: cuprated's own + # default (127.0.0.1) looks like the obviously-correct choice for a + # port meant to stay loopback-only, but verified live (2026-08-21) + # that a service bound literally to 127.0.0.1 *inside* the container + # is unreachable through the host's published port — connections + # reset regardless of how long the daemon has been up. Binding + # 0.0.0.0 inside and letting ports[].bind: 127.0.0.1 below be the + # actual restriction is the same pattern apps/bitcoin-knots already + # uses for its own RPC port (-rpcbind=0.0.0.0:8332 internally, gate + # restricts it externally) — not a new risk, the same one already + # reviewed and accepted for Bitcoin's RPC. + files: + - path: /var/lib/archipelago/cuprate/Cuprated.toml + content: | + network = "Mainnet" + target_max_memory = 3000000000 + + [rpc.restricted] + enable = true + overwrite: false + + health_check: + type: tcp + # Restricted RPC — the only RPC surface published now. + endpoint: localhost:18090 + interval: 30s + timeout: 5s + retries: 3 + start_period: 5m + + metadata: + icon: /assets/img/app-icons/cuprate.svg + category: money + tier: optional + author: Cuprate + repo: https://github.com/Cuprate/cuprate diff --git a/core/archipelago/src/api/rpc/package/runtime.rs b/core/archipelago/src/api/rpc/package/runtime.rs index 83ad0b5e..292d0bda 100644 --- a/core/archipelago/src/api/rpc/package/runtime.rs +++ b/core/archipelago/src/api/rpc/package/runtime.rs @@ -365,8 +365,18 @@ impl RpcHandler { // after uninstall. The reconciler owns a manifest map independent of // podman state, so a raw `podman rm` alone is not enough. if let Some(orchestrator) = &self.orchestrator { + let mut teardown_errors = Vec::new(); for app_id in orchestrator_uninstall_app_ids(package_id) { - let _ = orchestrator.remove(&app_id, preserve_data).await; + if let Err(err) = orchestrator.remove(&app_id, preserve_data).await { + teardown_errors.push(format!("{app_id}: {err:#}")); + } + } + if !teardown_errors.is_empty() { + return Err(anyhow::anyhow!( + "Uninstall {} aborted: failed to remove declarative app unit(s): {}", + package_id, + teardown_errors.join("; ") + )); } } @@ -2182,6 +2192,11 @@ mod tests { assert!(!is_missing_container_error("Error: OCI runtime error")); } + #[test] + fn single_app_uninstall_targets_its_declarative_unit() { + assert_eq!(orchestrator_uninstall_app_ids("cuprate"), vec!["cuprate"]); + } + #[test] fn runtime_host_ports_are_manifest_derived_for_public_apps() { assert_eq!(runtime_host_ports("photoprism"), vec![2342]); diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index 3318c096..71d236cc 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -1746,6 +1746,15 @@ app: } } exempt.sort(); + // 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions — + // 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and + // 18090 (host mapping for Monero's canonical 18089 restricted RPC, + // upstream's own safe-for-public + // subset that wallets connect to directly as a "remote node" over + // plain HTTP JSON-RPC — same reasoning as electrumx's 50001). + // cuprate's unrestricted RPC (full node control) stays loopback-only + // (auth: local), not in this set. + // // 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a // loopback-only JSON API whose own generated http password // authenticates every request (added with the phoenixd onboarding, @@ -1762,7 +1771,7 @@ app: // stage timed out that cycle, so the count here lagged at 17. assert_eq!( exempt.len(), - 26, + 28, "unauthenticated port set changed — review before updating this count: {exempt:?}" ); } diff --git a/neode-ui/public/assets/img/app-icons/cuprate.svg b/neode-ui/public/assets/img/app-icons/cuprate.svg new file mode 100644 index 00000000..82193ef0 --- /dev/null +++ b/neode-ui/public/assets/img/app-icons/cuprate.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + +