Compare commits

...
Author SHA1 Message Date
archipelago 1b0ed281b2 chore: prepare release v1.8.16-alpha 2026-09-15 03:59:43 -04:00
archipelago 9c6580f5c0 fix: prevent stale catalog updates and redundant container recreation
Demo images / Build & push demo images (push) Failing after 40s
2026-09-15 03:40:21 -04:00
archipelago 83abb0485d fix: publish signed mempool DNS recovery catalog 2026-09-15 03:13:02 -04:00
archipelago b35409ca74 fix: recover mempool frontend after backend address changes 2026-09-15 02:49:06 -04:00
archipelago 700d39c425 fix: keep release credentials out of public remote URLs 2026-09-14 10:47:58 -04:00
archipelago 4272c47ee5 chore: sign app catalog for v1.8.15-alpha 2026-09-13 13:21:24 -04:00
archipelago c7cb043485 chore: publish release v1.8.15-alpha 2026-09-13 05:09:56 -04:00
archipelago 4dfe79290e chore: sign release v1.8.15-alpha manifest 2026-09-13 04:42:17 -04:00
archipelago d3e3df6d24 docs: add release validation note 2026-09-13 04:32:58 -04:00
archipelago 969570e38b chore: prepare release v1.8.15-alpha
Demo images / Build & push demo images (push) Successful in 3m29s
2026-09-13 04:32:16 -04:00
archipelago b73d646db5 docs: prepare 1.8.15 alpha notes
Demo images / Build & push demo images (push) Successful in 3m22s
2026-09-13 03:06:53 -04:00
archipelago 8c37ff412c fix: present Cuprate as one launchable app
Demo images / Build & push demo images (push) Successful in 3m28s
2026-09-13 03:06:22 -04:00
archipelago 06bf359535 chore: publish release v1.8.14-alpha 2026-09-13 02:56:29 -04:00
archipelago a4f3415f0f chore: prepare release v1.8.14-alpha
Demo images / Build & push demo images (push) Successful in 3m25s
2026-09-13 02:53:11 -04:00
archipelago c9c9ebe6d4 docs: sync whats new for 1.8.14 alpha
Demo images / Build & push demo images (push) Successful in 3m18s
2026-09-13 02:34:45 -04:00
archipelago 100993445b docs: prepare 1.8.14 alpha release notes 2026-09-13 02:33:30 -04:00
archipelago a4f80e7ec1 test: update GitWorkshop launcher deep-link expectation
Demo images / Build & push demo images (push) Successful in 3m14s
2026-09-13 01:48:36 -04:00
archipelago 4ad34d3a0a test: validate full Archipelago ngit promotion path 2026-09-13 01:45:22 -04:00
archipelago c9bae926a5 test: satisfy strict indexed access
Demo images / Build & push demo images (push) Successful in 3m14s
2026-09-13 01:41:11 -04:00
archipelago cb3f7e8720 Merge PR #157: Cuprate disk gate and companion dashboard
Demo images / Build & push demo images (push) Successful in 3m19s
2026-09-13 01:37:46 -04:00
archipelago eb98ebb682 Merge PR #158: preserve Bitcoin Core Tor service naming 2026-09-13 01:37:15 -04:00
archipelago 00682e6420 test: expect Cuprate launches through companion UI 2026-09-12 16:17:47 -04:00
archipelago 95cdc3daea fix: retain source port in Cuprate generated ports 2026-09-12 16:15:28 -04:00
archipelago 1d05f2c27a style: format generated app launch ports 2026-09-12 16:15:01 -04:00
archipelago b3f16d07a6 style(cuprate-ui): anchor desktop details to right edge 2026-09-12 16:14:55 -04:00
archipelago 14d2b37e99 style(cuprate-ui): right-align desktop header cards 2026-09-12 16:14:55 -04:00
archipelago f5b255ee68 style(cuprate-ui): improve mobile dashboard layout 2026-09-12 16:14:55 -04:00
archipelago 6e8d90fb5f style(cuprate-ui): match bitcoin status cards 2026-09-12 16:14:55 -04:00
archipelago 66c4b0d375 feat(cuprate-ui): add bitcoin-style dashboard tabs 2026-09-12 16:14:55 -04:00
archipelago 0f74ebfbbe feat(cuprate-ui): use app icon and shared dashboard background 2026-09-12 16:14:55 -04:00
archipelago ee11863ada refactor(cuprate-ui): align dashboard with bitcoin UI style 2026-09-12 16:14:55 -04:00
ssmithxandarchipelago 86052d9552 refactor(cuprate): one CUPRATE_MIN_DISK_GB, manifest matches it (review)
450 existed as two independent Rust constants (RPC gates vs boot
reconciler) linked only by a "keep in lockstep" comment — updating one
would reopen the disk-fill hole. Move it to crate::constants as the
single source of truth both paths import.

Also raise apps/cuprate/manifest.yml storage dependency and disk_limit
from 300Gi to 450Gi so manifest-driven surfaces (store size, pre-checks)
show the number the gate actually enforces — a user provisioning to the
displayed 300 was refused at an unexplained 450. Catalog regenerated
(cuprate entry re-embedded; still unsigned pending sign-catalog.sh).
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago 047ef98987 fix(cuprate-ui): target_height 0 means synced, not stuck (review)
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.
The '??' fallback left 0 in place, so every healthy node rendered
"Syncing — 0.00%, 0 blocks behind" forever. Treat 0/absent as
target = own height, the same sentinel electrs_status.rs branches on.
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago c681472e15 fix(cuprate): gate package.restart and package.update too (review)
Restart and update are stop + recreate — a fresh start by another name —
but only start carried the disk gate, so on a disk that shrank below the
floor after install, either action silently resumed the unprunable
Monero sync: the exact failure the gate exists to close.

Both now call check_cuprate_disk_compatibility after validate_app_id and
BEFORE any state mutation (user-stopped clear / Restarting / Updating
flip), matching handle_package_start's fail-clean contract.
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago 7c0ba14a00 feat(neode-ui): launch cuprate tiles on the Cuprate UI companion
cuprate publishes only raw JSON RPC (18090 restricted, 18183 p2p), so
launches must land on the companion on :18091, never on the running
node's runtimeUrl — same root-path special-case bitcoin uses, with the
dev vite proxy for /app/cuprate-ui/. Alias cuprate -> cuprate-ui so the
port-auth lookup finds the gated launch port on HTTPS nodes; pin the
companion icon to the cuprate mark.
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago eacd74e1db 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.
2026-09-12 16:14:29 -04:00
ssmithxandarchipelago 34b68001d1 fix(cuprate): refuse to run on disks too small for the Monero chain
Cuprate has no pruning — verified against upstream main
(binaries/cuprated/src/config.rs): the 'pruning' crate is Monero's p2p
protocol pruning, not on-disk. Unlike the bitcoin apps, which branch on
DISK_GB in their entrypoint and self-prune, a disk-constrained cuprate
can only sync until the filesystem fills and take Archipelago down.

Translate the bitcoin disk-awareness into the only form cuprate can
honor — refuse rather than prune:
- install (sync + async RPC paths) and package.start fail with an
  actionable message below CUPRATE_MIN_DISK_GB (450 GB total: chain
  ~250 GiB + headroom; allows 500 GB-class, refuses the 250 GB VPS)
- boot reconcile skips an already-installed cuprate on a shrunken disk,
  recorded as Left("cuprate-insufficient-disk") before ensure_running
  so desired-state recovery can never undo it (same shape as
  requires-archival-bitcoin)
- df failure fail-opens at install (never block on an unreadable disk),
  fail-closes at boot (never start a doomed sync)

prod_orchestrator also registers cuprate-ui in UI_APP_IDS (its
companion commit follows).
2026-09-12 16:14:02 -04:00
archipelago 0fac51b9c5 chore: preserve signed release catalog 2026-09-12 16:00:16 -04:00
archipelago 4f0d123f27 feat: open GitWorkshop at Archipelago repository
Demo images / Build & push demo images (push) Successful in 3m33s
2026-09-12 15:57:57 -04:00
archipelago 13b1329c21 test: keep Cuprate stack as one app entry
Demo images / Build & push demo images (push) Successful in 4m0s
2026-09-12 15:33:05 -04:00
archipelago c4aa72dccc fix: route installs to apps or services
Demo images / Build & push demo images (push) Successful in 3m39s
2026-09-12 15:07:33 -04:00
archipelago d35474f774 fix: defensively hide legacy node identity
Demo images / Build & push demo images (push) Successful in 3m31s
2026-09-12 10:24:01 -04:00
archipelago a03f340bd1 fix: keep node key out of profile signer picker
Demo images / Build & push demo images (push) Successful in 3m26s
2026-09-12 10:06:41 -04:00
archipelago caaa2e729e fix: gate app launches on health readiness
Demo images / Build & push demo images (push) Successful in 3m47s
2026-09-12 09:35:25 -04:00
ssmithxandClaude Sonnet 5 dc7b598558 fix(tor): un-alias bitcoin-core's hidden-service name; add regression tests
read_tor_address("bitcoin-core") was resolving through tor_service_name to
the shared "bitcoin" alias, but enrollment (install.rs auto-enroll and the
tor.create-service RPC) always names HiddenServiceDir/tor-hostnames entries
using the raw package_id verbatim — never canonicalized. On a real node
that's hidden_service_bitcoin-core, which the aliased lookup never found,
so the per-app UI Tor badge stayed empty even after the previous commit
made bitcoin-core auto-enrollable.

Give bitcoin-core its own identity-mapped arm instead of folding it into
the legacy bitcoin/bitcoin-knots/bitcoind alias, and pin all three lookup
tables (known_service_port, is_protocol_service, tor_service_name) with
regression tests so this alias-drift class of bug can't recur silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxfWiFfnBkdSxwKUuV2tNy
2026-09-10 16:26:59 +00:00
ssmithxandClaude Sonnet 5 69f3a355c7 fix(tor): recognize bitcoin-core in Tor auto-enrollment tables
apps/bitcoin-core/manifest.yml uses id "bitcoin-core", but
known_service_port/is_protocol_service (tor/mod.rs) and
tor_service_name (docker_packages.rs) only matched "bitcoin" and
"bitcoin-knots", so the app silently never got auto-enrolled for a
P2P (8333) hidden service at install time, and the UI's Tor address
lookup for it always returned None.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxfWiFfnBkdSxwKUuV2tNy
2026-09-10 15:28:17 +00:00
64 changed files with 5164 additions and 3592 deletions
+20
View File
@@ -2,6 +2,26 @@
## Unreleased
## v1.8.16-alpha (2026-09-15)
- App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.
- Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.
- Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.
- Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.
## v1.8.15-alpha (2026-09-13)
- Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.
- Added regression coverage for Cuprate install and installed-state grouping.
- Release validation was rerun on the corrected tree before OTA and ISO publication.
## v1.8.14-alpha (2026-09-13)
- **Cuprate gains a first-party companion dashboard.** The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.
- **Bitcoin Core Tor enrollment uses the correct protocol identity.** `bitcoin-core` is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.
- **GitWorkshop opens Archipelago’s canonical ngit repository by default.** The launcher and registry promotion use the full maintainer/relay/`archy` coordinate, with regression coverage for Companion and browser-tab launches.
- **Release validation is stricter.** The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites.
## v1.8.13-alpha (2026-09-12)
- **GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.
+3 -3
View File
@@ -26,7 +26,7 @@
"headline": "Your node. Your source.",
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
"tag": "NGIT // NOSTR // NO SILO",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
"launchLabel": "Open GitWorkshop",
"installLabel": "Install GitWorkshop",
"detailsLabel": "How contribution works →"
@@ -378,13 +378,13 @@
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"version": "3.3.1-archy1",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
+5 -3
View File
@@ -1,7 +1,7 @@
app:
id: archy-mempool-web
name: Mempool Web
version: 3.0.1
version: 3.3.1-archy1
# 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.
@@ -12,7 +12,7 @@ app:
container_name: mempool
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
pull_policy: if-not-present
network: archy-net
@@ -45,7 +45,9 @@ app:
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
endpoint: http://127.0.0.1:8080
path: /
# Probe the backend through nginx: a static page can be healthy while
# every API/WebSocket request is stuck on a dead backend address.
path: /api/v1/backend-info
interval: 30s
timeout: 5s
retries: 3
+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"
dependencies:
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
# month; cuprated's pruning support is not confirmed stable yet (the
# `pruning` crate exists in the workspace but nothing in this config
# surface toggles it), so this sizes for a full unpruned chain plus
# headroom rather than assuming pruning is available.
- storage: 300Gi
# Monero mainnet is ~250GiB unpruned as of 2026 and growing ~60GiB/year.
# Verified against upstream main (binaries/cuprated/src/config.rs, 2026-09):
# cuprated has NO on-disk pruning setting of any kind — the `pruning`
# crate in its workspace is Monero's p2p *protocol* pruning, not a
# smaller chain — so unlike bitcoin-knots this app CANNOT self-prune
# when disk is scarce (see the DISK_GB branch in
# apps/bitcoin-knots/manifest.yml). Left running on a too-small disk it
# syncs until the filesystem fills and takes Archipelago down. The
# disk-scarce equivalent is enforced in Rust instead: install, start,
# restart and update refuse, and boot reconcile skips, on any node under
# CUPRATE_MIN_DISK_GB (450GB — chain + headroom; refuses the 250GB VPS
# class, allows 500GB-class disks). If upstream ever ships a prune flag,
# replace that gate with the bitcoin-style entrypoint branch.
#
# 450Gi, not the chain size (~250GiB): every manifest-driven surface
# (store size display, install pre-checks, docs) must show the number the
# Rust gate actually enforces, or a user provisioned to the displayed
# value gets refused at a different, unexplained one. Single source of
# truth is crate::constants::CUPRATE_MIN_DISK_GB — keep in lockstep.
- storage: 450Gi
resources:
cpu_limit: 0
@@ -51,7 +65,9 @@ app:
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
# headroom above the 8GiB cache for the process itself.
memory_limit: 10Gi
disk_limit: 300Gi
# Matches the storage dependency above (= the enforced disk floor),
# not the raw chain size — see the CUPRATE_MIN_DISK_GB note.
disk_limit: 450Gi
security:
# FROM scratch, no package manager/shell, ownership fixed at build time
+2 -2
View File
@@ -1,7 +1,7 @@
app:
id: mempool
name: Mempool Explorer
version: 3.0.0
version: 3.3.1-archy1
# 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.
@@ -11,7 +11,7 @@ app:
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
image_signature: cosign://...
pull_policy: if-not-present
+2 -2
View File
@@ -67,13 +67,13 @@
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"version": "3.3.1-archy1",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.13-alpha"
version = "1.8.16-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.13-alpha"
version = "1.8.16-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
@@ -55,6 +55,10 @@ impl RpcHandler {
"did": id.did,
"created_at": id.created_at,
"is_default": is_default,
// The node's operational Nostr key is intentionally
// distinguishable from user profile identities. Clients
// must never offer it in app sign-in pickers.
"is_node": is_node,
"nostr_pubkey": nostr_pubkey,
"nostr_npub": nostr_npub,
"profile": id.profile,
@@ -55,6 +55,7 @@ impl RpcHandler {
.to_string();
super::validation::validate_app_id(&package_id)?;
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
// Reject if already in a transitional lifecycle (prevents double-click
// queuing two installs on the same package).
@@ -294,6 +295,12 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string();
super::validation::validate_app_id(&package_id)?;
// Update is stop → pull → remove → recreate, i.e. a fresh start by
// another name: on a disk that shrank since install it would resume
// cuprate's unprunable sync unchecked. Same gate as install and
// start, run BEFORE the Updating flip so a refusal leaves the app
// cleanly in its previous state.
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
// Reject if already in a transitional lifecycle.
{
@@ -323,7 +330,7 @@ impl RpcHandler {
let package_id_spawn = package_id.clone();
tokio::spawn(async move {
match handler.handle_package_update(params).await {
Ok(_) => {
Ok(result) => {
info!("package.update {}: complete", package_id_spawn);
// Same reasoning as install: the merge_preserving_transitional
// helper treats Updating as RPC-owned, so we MUST write the
@@ -338,7 +345,11 @@ impl RpcHandler {
set_package_state(
&handler.state_manager,
&package_id_spawn,
PackageState::Running,
if result.get("status").and_then(|v| v.as_str()) == Some("up-to-date") {
pre_state.clone().unwrap_or(PackageState::Running)
} else {
PackageState::Running
},
)
.await;
}
@@ -670,6 +670,50 @@ async fn detect_disk_gb() -> u64 {
.unwrap_or(u64::MAX)
}
/// Smallest disk (GB, total) a cuprate node can live on. The value and its
/// rationale live in ONE place — `crate::constants::CUPRATE_MIN_DISK_GB` —
/// shared with the boot reconciler so install/start and boot can never
/// disagree about where cuprate may run.
use crate::constants::CUPRATE_MIN_DISK_GB;
/// The bitcoin apps pick `-prune` automatically when disk is scarce, because
/// bitcoind supports pruning. Cuprate CANNOT: upstream has no pruning config
/// at all (the `pruning` crate in its workspace is Monero's p2p *protocol*
/// pruning, not on-disk pruning), so the disk-scarce equivalent is to refuse
/// to run cuprate at all rather than let it sync until the filesystem fills —
/// which took Archipelago itself down on nodes with too little disk.
fn cuprate_insufficient_disk_message(disk_gb: u64) -> String {
format!(
"Cuprate needs a disk of at least {} GB and this node has {} GB. \
A Monero node cannot run pruned — upstream cuprate has no pruning \
support — so the chain (~250 GB and growing) would fill the disk and \
take Archipelago down with it. Attach a larger disk (or move \
/var/lib/archipelago to one) and try again. Bitcoin apps CAN run \
pruned on smaller disks; Monero currently cannot.",
CUPRATE_MIN_DISK_GB, disk_gb
)
}
/// Pure decision half of the cuprate disk gate — testable without df.
pub(super) fn cuprate_disk_gate(disk_gb: u64) -> Option<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.
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
@@ -873,9 +917,9 @@ pub(super) fn configure_fedimint_lnd(
#[cfg(test)]
mod tests {
use super::{
bitcoin_is_warming_up, dependency_list_declares_archival_bitcoin,
bitcoin_is_warming_up, cuprate_disk_gate, dependency_list_declares_archival_bitcoin,
manifest_declares_archival_bitcoin, order_present_containers, requires_unpruned_bitcoin,
startup_order, BITCOIN_WARMUP_BUDGET,
startup_order, BITCOIN_WARMUP_BUDGET, CUPRATE_MIN_DISK_GB,
};
use archipelago_container::Dependency;
@@ -1017,6 +1061,37 @@ mod tests {
assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
}
#[test]
fn cuprate_disk_gate_refuses_disks_too_small_for_the_monero_chain() {
// 250 GB VPS class: the ~250 GiB chain does not fit, full stop.
assert!(cuprate_disk_gate(0).is_some());
assert!(cuprate_disk_gate(250).is_some());
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB - 1).is_some());
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB).is_none());
assert!(cuprate_disk_gate(1000).is_none());
// df failure reads as u64::MAX — an unreadable disk must not block.
assert!(cuprate_disk_gate(u64::MAX).is_none());
}
#[test]
fn cuprate_disk_gate_message_names_the_fix_not_just_the_problem() {
let msg = cuprate_disk_gate(250).expect("250 GB must be refused");
assert!(msg.contains("cannot run pruned"), "{msg}");
assert!(msg.contains("larger disk"), "{msg}");
assert!(msg.contains("250 GB"), "{msg}");
}
#[tokio::test]
async fn cuprate_disk_gate_only_applies_to_cuprate() {
// Every other package passes regardless of disk — including the
// bitcoin apps, which self-prune via their manifest entrypoint.
for package_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
super::check_cuprate_disk_compatibility(package_id)
.await
.expect("non-cuprate installs must not be gated here");
}
}
mod dep_wait {
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
use std::sync::atomic::{AtomicU32, Ordering};
@@ -3,10 +3,10 @@ use super::config::{
is_readonly_compatible, is_valid_docker_image,
};
use super::dependencies::{
check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers,
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info,
needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL,
DEP_WAIT_MAX_ATTEMPTS,
check_bitcoin_pruning_compatibility, check_cuprate_disk_compatibility, configure_fedimint_lnd,
detect_existing_containers, detect_running_deps, detect_running_deps_from_package_data,
log_optional_dep_info, needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps,
DEP_WAIT_INTERVAL, DEP_WAIT_MAX_ATTEMPTS,
};
use super::progress::parse_pull_progress;
use super::validation::validate_app_id;
@@ -374,6 +374,7 @@ impl RpcHandler {
// failing instantly.
let deps = self.gate_install_deps(package_id).await?;
check_bitcoin_pruning_compatibility(package_id).await?;
check_cuprate_disk_compatibility(package_id).await?;
log_optional_dep_info(package_id, &deps);
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
// Materialise the RPC password file before any install path
@@ -60,6 +60,12 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
// A cuprate node that starts on a too-small disk fills it and takes
// Archipelago down with it (no upstream pruning — see
// dependencies::check_cuprate_disk_compatibility). Fail the start
// before clearing user-stopped or flipping state, so the app stays
// cleanly stopped and the error carries the actionable message.
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
vec![orchestrator_app_id(package_id).to_string()]
@@ -251,6 +257,11 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
// Restart is stop + recreate, so on a disk that shrank below the cuprate
// minimum after install it resumes the doomed unprunable sync just like
// start would — same gate, same "fail before clearing user-stopped /
// flipping state" contract (see handle_package_start).
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
let single_orchestrator_app =
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
+232 -25
View File
@@ -19,7 +19,7 @@ use tracing::{error, info, warn};
const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
impl RpcHandler {
/// Update a package to the version pinned in image-versions.sh.
/// Update a package to the freshly verified catalog target.
/// This is a manual operation — the user clicks "Update" in the UI.
pub(in crate::api::rpc) async fn handle_package_update(
&self,
@@ -32,6 +32,21 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
// An Update click must not act on an hourly cache that predates the
// button. Fetch and verify first; failure leaves running containers alone.
crate::container::app_catalog::refresh_catalog(&self.config.data_dir)
.await
.context(
"Cannot check the signed app catalog; update cancelled before changing containers",
)?;
if let Some(orch) = &self.orchestrator {
// Reload even when bytes did not change: a previous reload may have
// failed after the cache was written, or another refresher wrote it.
orch.reload_manifests()
.await
.context("Cannot load current app manifests; update cancelled")?;
}
// Resolve the target image. Prefer the remote app catalog (decoupled
// from the binary OTA), falling back to the image-versions.sh pin. This
// is OPTIONAL for orchestrator-managed apps: the orchestrator resolves
@@ -42,6 +57,22 @@ impl RpcHandler {
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
.or_else(|| image_versions::pinned_image_for_app(package_id));
let targets = pinned
.as_ref()
.map(|target| self.resolve_images_to_pull(package_id, target));
if let Some(targets) = &targets {
let installed = inspect_update_images(package_id).await?;
if !update_targets_need_change(targets, &installed)? {
install_log(&format!(
"UPDATE SKIP: {} — target versions already installed",
package_id
))
.await;
self.clear_install_progress(package_id).await;
return Ok(serde_json::json!({"status": "up-to-date", "package_id": package_id}));
}
}
// Note: the `already updating` guard lives in `spawn_package_update`
// (the async wrapper that dispatch actually routes to). By the time
// this inner function runs, the wrapper has already flipped state to
@@ -80,6 +111,12 @@ impl RpcHandler {
if let Some(orchestrator) = self.orchestrator.as_ref() {
match orchestrator.upgrade(orchestrator_app_id).await {
Ok(()) => {
if let Some(targets) = &targets {
verify_update_targets(
targets,
&inspect_update_images(package_id).await?,
)?;
}
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
.await;
if let Ok(health) = orchestrator.health(orchestrator_app_id).await {
@@ -133,7 +170,8 @@ impl RpcHandler {
};
// Resolve images to pull — either a stack or single container
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
let images_to_pull =
targets.unwrap_or_else(|| self.resolve_images_to_pull(package_id, &pinned));
// Get all containers for this app
let containers = get_containers_for_app(package_id).await?;
@@ -324,15 +362,22 @@ impl RpcHandler {
.await;
if let Ok(o) = status {
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
if state == "exited" {
warn!(
"Update {}: container {} exited after recreate",
package_id, name
);
}
anyhow::ensure!(
o.status.success() && state == "running",
"Update {}: container {} is not running after recreate",
package_id,
name
);
} else {
anyhow::bail!(
"Update {}: cannot inspect recreated container {}",
package_id,
name
);
}
}
verify_update_targets(images_to_pull, &inspect_update_images(package_id).await?)?;
Ok(())
}
@@ -514,6 +559,98 @@ impl RpcHandler {
}
}
async fn inspect_update_images(package_id: &str) -> Result<Vec<(String, String)>> {
let containers = get_containers_for_app(package_id).await?;
anyhow::ensure!(
!containers.is_empty(),
"No containers found for {}",
package_id
);
let mut command = tokio::process::Command::new("podman");
command.arg("inspect").args(&containers).kill_on_drop(true);
let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output())
.await
.context("Timed out checking installed images")??;
anyhow::ensure!(
output.status.success(),
"Cannot inspect installed images; update cancelled"
);
let inspected: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)?;
inspected
.iter()
.map(|entry| {
let name = entry
.get("Name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Container inspection omitted Name"))?;
let image = entry
.get("ImageName")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Container inspection omitted ImageName"))?;
Ok((name.trim_start_matches('/').to_string(), image.to_string()))
})
.collect()
}
fn installed_image_for_target<'a>(
app_id: &str,
installed: &'a [(String, String)],
) -> Option<&'a str> {
installed
.iter()
.find(|(name, _)| {
candidate_app_ids_for_container(name)
.iter()
.any(|id| id == app_id)
})
.map(|(_, image)| image.as_str())
}
/// A successful recreate is not proof that it used the downloaded image.
fn verify_update_targets(
targets: &[(String, String)],
installed: &[(String, String)],
) -> Result<()> {
for (app_id, target) in targets {
let running = installed_image_for_target(app_id, installed).ok_or_else(|| {
anyhow::anyhow!("Update {}: target container missing after recreate", app_id)
})?;
anyhow::ensure!(
image_versions::extract_version_from_image(target)
== image_versions::extract_version_from_image(running)
|| image_versions::compare_image_versions(target, running)
== Some(std::cmp::Ordering::Equal),
"Update {}: recreated container did not reach target version {}",
app_id,
image_versions::extract_version_from_image(target)
);
}
Ok(())
}
/// Check every stack component, not just the version shown on its tile. A
/// newer backend must still update when its frontend version is unchanged.
/// A stale target for any component cancels before pulling or stopping anything.
fn update_targets_need_change(
targets: &[(String, String)],
installed: &[(String, String)],
) -> Result<bool> {
use std::cmp::Ordering;
let mut changed = false;
for (app_id, target) in targets {
let running = installed_image_for_target(app_id, installed);
match running.and_then(|image| image_versions::compare_image_versions(target, image)) {
Some(Ordering::Less) => anyhow::bail!(
"Catalog target for {} is older than the installed image; refusing downgrade",
app_id
),
Some(Ordering::Equal) => {}
Some(Ordering::Greater) | None => changed = true,
}
}
Ok(changed)
}
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
orchestrator_available && !uses_legacy_update_flow(package_id)
}
@@ -526,11 +663,14 @@ fn orchestrator_update_app_id(package_id: &str) -> &str {
}
fn uses_legacy_update_flow(package_id: &str) -> bool {
matches!(
package_id,
// Multi-container stacks still updated via the stack-aware path.
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
)
// A primary container already at its target does not mean its backend or
// database is current. Route every mapped stack through the component flow.
!image_versions::containers_for_stack(package_id).is_empty()
|| matches!(
package_id,
// Multi-container stacks still updated via the stack-aware path.
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
)
}
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
@@ -554,7 +694,12 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
"archy-bitcoin-ui" => push("bitcoin-ui"),
"archy-lnd-ui" => push("lnd-ui"),
"archy-electrs-ui" => push("electrs-ui"),
"mempool" => {
"mysql-mempool" => push("archy-mempool-db"),
"btcpay" | "btcpayserver" | "archy-btcpay" => push("btcpay-server"),
"homeassistant" | "archy-homeassistant" => push("home-assistant"),
"fedimintd" => push("fedimint"),
"electrs" | "mempool-electrs" => push("electrumx"),
"mempool" | "mempool-web" => {
push("archy-mempool-web");
push("mempool");
}
@@ -572,27 +717,89 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
mod tests {
use super::{
candidate_app_ids_for_container, orchestrator_update_app_id,
should_try_orchestrator_update, uses_legacy_update_flow,
should_try_orchestrator_update, update_targets_need_change, uses_legacy_update_flow,
verify_update_targets,
};
#[test]
fn mempool_update_preflight_rejects_stale_catalog_without_reinstalling() {
let installed = vec![(
"mempool".into(),
"r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into(),
)];
let stale = vec![(
"archy-mempool-web".into(),
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
)];
assert!(update_targets_need_change(&stale, &installed).is_err());
let current = vec![(
"archy-mempool-web".into(),
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
)];
assert!(!update_targets_need_change(&current, &installed).unwrap());
let legacy = vec![(
"mempool-web".into(),
"r.test/old/mempool-frontend:v3.3.1-archy1".into(),
)];
assert!(!update_targets_need_change(&current, &legacy).unwrap());
let newer = vec![(
"archy-mempool-web".into(),
"r.test/chaum/mempool-frontend:v3.3.1-archy2".into(),
)];
assert!(update_targets_need_change(&newer, &installed).unwrap());
}
#[test]
fn stack_update_checks_backend_even_when_frontend_matches() {
let installed = vec![
("mempool".into(), "r.test/team/web:3.3.1-archy1".into()),
("mempool-api".into(), "r.test/team/api:3.3.1".into()),
];
let mut targets = vec![
(
"archy-mempool-web".into(),
"r.test/team/web:3.3.1-archy1".into(),
),
("mempool-api".into(), "r.test/team/api:3.3.2".into()),
];
assert!(update_targets_need_change(&targets, &installed).unwrap());
targets[0].1 = "r.test/team/web:3.3.1".into();
assert!(update_targets_need_change(&targets, &installed).is_err());
}
#[test]
fn update_completion_requires_the_target_version_to_be_installed() {
let targets = vec![(
"archy-mempool-web".into(),
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
)];
let mut installed = vec![(
"mempool".into(),
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
)];
assert!(verify_update_targets(&targets, &installed).is_err());
assert!(verify_update_targets(&targets, &[]).is_err());
installed[0].1 = "r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into();
assert!(verify_update_targets(&targets, &installed).is_ok());
}
#[test]
fn legacy_flow_for_stack_apps() {
for app in ["immich", "penpot", "indeedhub"] {
for app in [
"immich",
"penpot",
"indeedhub",
"mempool",
"btcpay-server",
"netbird",
] {
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
}
}
#[test]
fn orchestrator_flow_for_single_apps() {
for app in [
"lnd",
"bitcoin-core",
"searxng",
"grafana",
"btcpay-server",
"mempool",
"fedimint",
] {
for app in ["lnd", "bitcoin-core", "searxng", "grafana", "fedimint"] {
assert!(
!uses_legacy_update_flow(app),
"{app} should be orchestrator-first"
+19 -2
View File
@@ -377,6 +377,23 @@ async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod known_service_tests {
use super::{is_protocol_service, known_service_port};
#[test]
fn bitcoin_core_is_a_protocol_service_on_the_p2p_port() {
// Regression: apps/bitcoin-core/manifest.yml uses id "bitcoin-core",
// distinct from the legacy "bitcoin"/"bitcoin-knots" ids. Missing
// here means auto-enrollment silently skips it (known_service_port
// returns 0) and, separately, regenerate_torrc falls back to the
// web-app HiddenServicePort-80 default instead of forwarding 8333
// straight through.
assert_eq!(known_service_port("bitcoin-core"), 8333);
assert!(is_protocol_service("bitcoin-core"));
}
}
#[cfg(test)]
mod torrc_tests {
use super::app_hidden_service_port_line;
@@ -594,7 +611,7 @@ fn is_valid_v3_onion(s: &str) -> bool {
pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
match name {
"archipelago" => 80,
"bitcoin" | "bitcoin-knots" => 8333,
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => 8333,
"electrs" | "electrumx" => 50001,
"lnd" => 8080,
"btcpay" | "btcpay-server" | "btcpayserver" => 23000,
@@ -619,7 +636,7 @@ pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool {
matches!(
name,
"bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
)
}
+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.
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;
@@ -400,7 +400,7 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
}
if let Some(catalog_image) = catalog_primary_image(app_id) {
// Catalog covers this app with a concrete image -> authoritative.
return crate::container::image_versions::available_update_for_images(
return crate::container::image_versions::available_catalog_update_for_images(
&catalog_image,
running_image,
);
+43 -5
View File
@@ -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);
}
@@ -657,9 +657,19 @@ fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
/// Map app_id to Tor hidden service directory name.
/// "archipelago" is the main web UI (nginx port 80).
/// Supports container names from deploy (archy-*, btcpay-server, etc.).
///
/// This must match what enrollment actually names the hidden service dir
/// with — both the install-time auto-enroll (`install.rs`) and the manual
/// `tor.create-service` RPC write `HiddenServiceDir` using the raw
/// `package_id`/`name` verbatim, with no canonicalization. So `bitcoin-core`
/// gets its own identity arm rather than folding into the "bitcoin" alias:
/// aliasing it here without also canonicalizing the write side would point
/// this lookup at `hidden_service_bitcoin`, which never gets created — the
/// on-disk dir is always `hidden_service_bitcoin-core` for this app id.
fn tor_service_name(app_id: &str) -> Option<&'static str> {
match app_id {
"archipelago" => Some("archipelago"),
"bitcoin-core" => Some("bitcoin-core"),
"bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"),
"electrumx" | "electrs" | "electrum" => Some("electrumx"),
"lnd" | "lnd-ui" => Some("lnd"),
@@ -906,6 +916,28 @@ mod launch_url_port_tests {
}
}
#[cfg(test)]
mod tor_service_name_tests {
use super::tor_service_name;
#[test]
fn bitcoin_core_resolves_to_its_own_hidden_service_dir() {
// Regression: enrollment (install.rs, tor.create-service) writes
// HiddenServiceDir/tor-hostnames entries using the raw package_id
// verbatim, never canonicalized. Aliasing "bitcoin-core" to the
// shared "bitcoin" name here would point reads at a directory
// enrollment never creates.
assert_eq!(tor_service_name("bitcoin-core"), Some("bitcoin-core"));
}
#[test]
fn legacy_bitcoin_ids_share_the_bitcoin_alias() {
assert_eq!(tor_service_name("bitcoin"), Some("bitcoin"));
assert_eq!(tor_service_name("bitcoin-knots"), Some("bitcoin"));
assert_eq!(tor_service_name("bitcoind"), Some("bitcoin"));
}
}
#[cfg(test)]
mod extract_lan_address_tests {
use super::extract_lan_address;
+131 -34
View File
@@ -100,6 +100,12 @@ fn parse_image_versions(content: &str) -> HashMap<String, String> {
// Match VAR="value" or VAR=value
if let Some((key, val)) = parse_assignment(line) {
// Read a self-default assignment without evaluating shell code.
let default_prefix = format!("${{{key}:-");
let val = val
.strip_prefix(&default_prefix)
.and_then(|v| v.strip_suffix('}'))
.unwrap_or(val);
let expanded = val.replace("$ARCHY_REGISTRY", &registry);
if key == "ARCHY_REGISTRY" {
registry = expanded.clone();
@@ -146,6 +152,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"),
@@ -204,48 +211,71 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
}
pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
let pinned_version = extract_version_from_image(&pinned);
if image_without_registry_or_tag(pinned) != image_without_registry_or_tag(running_image) {
return None;
}
available_catalog_update_for_images(pinned, running_image)
}
/// A signed catalog binds the image to an app id, so a publisher namespace
/// migration must not hide a real upgrade. Baseline pins still require the
/// same repository via `available_update_for_images` above.
pub fn available_catalog_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
let pinned_version = extract_version_from_image(pinned);
if is_floating_tag(&pinned_version) {
return None;
}
let running_version = extract_version_from_image(running_image);
if pinned_version == running_version {
return None;
}
let pinned_repo = image_without_registry_or_tag(&pinned);
let running_repo = image_without_registry_or_tag(running_image);
if pinned_repo != running_repo {
return None;
}
// Never advertise a LOWER version as an update.
//
// Everything upstream of here is a version claim that can go stale: the
// signed catalog, a legacy catalog entry with no manifest, the
// image-versions.sh baseline pin. When one lags behind what a node is
// actually running, a bare `pinned != running` check turns that staleness
// into an "Update" button that rolls the node BACKWARDS — and a rollback
// to a version withdrawn for a vulnerability is precisely the case where
// that must not happen. Observed with BTCPay: 2.4.2 installed, a stale
// 2.3.9 pin, and the UI offering "update" to the exploited release.
//
// Only suppress when both tags parse as comparable version numbers, so
// apps with opaque tags (RELEASE.2024-11-07T00-52-20Z, 14-vectorchord0.4.3)
// keep the previous behaviour rather than silently losing updates.
if let (Some(p), Some(r)) = (
parse_version_parts(&pinned_version),
parse_version_parts(&running_version),
if matches!(
compare_image_versions(pinned, running_image),
Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
) {
if p < r {
return None;
}
return None;
}
Some(pinned_version)
}
/// Compare explicit image tags, ignoring registry and namespace. `None` means
/// unknown ordering (including floating tags), never permission to downgrade.
/// Archipelago's `-archyN` is a downstream patch revision ABOVE the upstream
/// release, not a SemVer prerelease below it.
pub fn compare_image_versions(target: &str, running: &str) -> Option<std::cmp::Ordering> {
use std::cmp::Ordering;
let target = extract_version_from_image(target);
let running = extract_version_from_image(running);
if is_floating_tag(&target) || is_floating_tag(&running) {
return None;
}
let target = target.strip_prefix('v').unwrap_or(&target);
let running = running.strip_prefix('v').unwrap_or(&running);
if target == running {
return Some(Ordering::Equal);
}
let mut target_core = parse_version_parts(target)?;
let mut running_core = parse_version_parts(running)?;
while target_core.last() == Some(&0) {
target_core.pop();
}
while running_core.last() == Some(&0) {
running_core.pop();
}
match target_core.cmp(&running_core) {
Ordering::Equal => {
fn patch_revision(tag: &str) -> Option<u64> {
if let Some((base, revision)) = tag.rsplit_once("-archy") {
if base.chars().all(|c| c.is_ascii_digit() || c == '.') {
return revision.parse().ok();
}
}
tag.chars()
.all(|c| c.is_ascii_digit() || c == '.')
.then_some(0)
}
Some(patch_revision(target)?.cmp(&patch_revision(running)?))
}
order => Some(order),
}
}
/// Numeric components of a version tag, for ordering comparisons only.
///
/// Accepts a leading `v` and a trailing pre-release suffix (`v0.18.4-beta`),
@@ -422,6 +452,57 @@ mod tests {
);
}
#[test]
fn downstream_patch_is_newer_than_upstream_and_orders_revisions() {
let upstream = "registry.test/team/mempool-frontend:v3.3.1";
let patch1 = "registry.test/team/mempool-frontend:v3.3.1-archy1";
let patch2 = "registry.test/team/mempool-frontend:v3.3.1-archy2";
assert_eq!(available_update_for_images(upstream, patch1), None);
assert_eq!(available_update_for_images(patch1, patch2), None);
assert_eq!(
available_update_for_images(patch1, upstream),
Some("v3.3.1-archy1".into())
);
assert_eq!(
available_update_for_images(patch2, patch1),
Some("v3.3.1-archy2".into())
);
}
#[test]
fn catalog_namespace_migration_does_not_hide_patch_or_offer_reinstall() {
let old = "registry.test/lfg2025/mempool-frontend:v3.3.1";
let patched = "registry.test/chaum/mempool-frontend:v3.3.1-archy1";
assert_eq!(
available_catalog_update_for_images(patched, old),
Some("v3.3.1-archy1".into())
);
assert_eq!(
available_catalog_update_for_images(
patched,
"registry.test/lfg2025/mempool-frontend:v3.3.1-archy1"
),
None
);
assert_eq!(available_update_for_images(patched, old), None);
}
#[test]
fn equivalent_version_spelling_does_not_offer_update() {
assert_eq!(
available_update_for_images("r.test/team/app:v3.3.1", "r.test/team/app:3.3.1"),
None
);
assert_eq!(
available_update_for_images("r.test/team/app:3.3.0", "r.test/team/app:3.3"),
None
);
assert_eq!(
compare_image_versions("r.test/team/app:latest", "r.test/team/app:latest"),
None
);
}
#[test]
fn test_parse_image_versions() {
let content = r#"
@@ -444,6 +525,22 @@ NOT_AN_IMAGE="something"
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
}
#[test]
fn shipped_image_pins_expand_shell_defaults_to_concrete_refs() {
let images = parse_image_versions(include_str!("../../../../scripts/image-versions.sh"));
assert_eq!(
images["MEMPOOL_WEB_IMAGE"],
"source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
);
assert_eq!(
images["MEMPOOL_BACKEND_IMAGE"],
"source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1"
);
assert!(images
.values()
.all(|v| !v.contains('$') && !v.contains('}')));
}
#[test]
fn test_image_var_mapping() {
assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE"));
@@ -47,8 +47,17 @@ use crate::update::host_sudo;
///
/// Keep in sync with the running fixture on .116. Centralized as a constant
/// so the rule is visible in one place and unit-testable.
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"];
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui", "cuprate-ui"];
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
// The cuprate disk floor is `crate::constants::CUPRATE_MIN_DISK_GB` — one
// value shared with the install/start/restart/update RPC gates so boot
// reconcile can never resume below the line they refuse at.
use crate::constants::CUPRATE_MIN_DISK_GB;
fn requires_cuprate_disk(app_id: &str, disk_gb: u64) -> bool {
app_id == "cuprate" && disk_gb < CUPRATE_MIN_DISK_GB
}
/// Apps expected to exist from first boot on every node — the ONLY apps the
/// boot reconciler may install from nothing. Every other app needs
@@ -1944,6 +1953,23 @@ impl ProdContainerOrchestrator {
crate::crash_recovery::pending_boot_start_done(&container_name);
continue;
}
// Same shape as the archival-bitcoin skip above: recorded BEFORE
// ensure_running_with_mode, so the "absent" desired-state recovery
// below can never fire on this reason and undo it.
if mode == ReconcileMode::ExistingOnly && requires_cuprate_disk(&app_id, disk_gb) {
tracing::warn!(
app_id = %app_id,
disk_gb,
"cuprate needs a larger disk (no pruning support) — skipping start"
);
report.record(
&app_id,
ReconcileAction::Left("cuprate-insufficient-disk".into()),
);
crate::crash_recovery::pending_boot_start_done(&app_id);
crate::crash_recovery::pending_boot_start_done(&container_name);
continue;
}
match self.ensure_running_with_mode(&lm, mode).await {
// Desired-state recovery: the app has no container and was left
// "absent" by boot reconcile, BUT it was running at the last
@@ -4641,6 +4667,27 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
let name = compute_container_name(&lm.manifest);
let mut resolved = lm.manifest.clone();
resolve_catalog_image(&mut resolved);
if resolved.app.container.build.is_none() {
if let Some(target) = &resolved.app.container.image {
if let Ok(running) = self.runtime.get_container_status(&name).await {
match crate::container::image_versions::compare_image_versions(
target,
&running.image,
) {
Some(std::cmp::Ordering::Less) => anyhow::bail!(
"Refusing to downgrade {} from {} to {} during update",
app_id,
running.image,
target
),
Some(std::cmp::Ordering::Equal) => return Ok(()),
_ => {}
}
}
}
}
let _ = self.runtime.stop_container(&name).await;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(&lm).await
@@ -5050,6 +5097,7 @@ mod tests {
calls: StdMutex<Vec<String>>,
/// container_name -> ContainerState. Absence = "doesn't exist".
containers: StdMutex<HashMap<String, ContainerState>>,
running_images: StdMutex<HashMap<String, String>>,
/// container_name -> Podman health status.
health: StdMutex<HashMap<String, String>>,
/// image_ref -> present. Absence = "not present in local storage".
@@ -5174,7 +5222,13 @@ mod tests {
health,
exit_code: None,
started_at: None,
image: "test-image".to_string(),
image: self
.running_images
.lock()
.unwrap()
.get(name)
.cloned()
.unwrap_or_else(|| "test-image".to_string()),
created: "now".to_string(),
ports: vec![],
lan_address: None,
@@ -5365,6 +5419,27 @@ app:
assert_eq!(compute_container_name(&m), "archy-electrs-ui");
let m = pull_manifest("lnd-ui", "foo:1");
assert_eq!(compute_container_name(&m), "archy-lnd-ui");
let m = pull_manifest("cuprate-ui", "foo:1");
assert_eq!(compute_container_name(&m), "archy-cuprate-ui");
}
#[test]
fn cuprate_disk_gate_blocks_only_cuprate_on_small_disks() {
// 250 GB VPS class: the ~250 GiB Monero chain cannot fit and cuprate
// has no pruning — boot reconcile must leave it down.
assert!(requires_cuprate_disk("cuprate", 250));
assert!(requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB - 1));
assert!(!requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB));
assert!(!requires_cuprate_disk("cuprate", 1000));
// df failure in detect_disk_gb reads as 0 → fail closed at boot: a
// doomed sync is worse than a node that stays down until it can
// measure (same direction as the archival-bitcoin skip).
assert!(requires_cuprate_disk("cuprate", 0));
// Nothing else is gated here: bitcoin apps self-prune, everything
// else is irrelevant to the Monero chain.
for app_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
assert!(!requires_cuprate_disk(app_id, 0), "{app_id}");
}
}
#[test]
@@ -6724,6 +6799,41 @@ app:
assert_eq!(ids, vec!["bitcoin-knots", "bitcoin-ui"]);
}
#[tokio::test]
async fn upgrade_preserves_container_when_catalog_is_stale_or_already_installed() {
for (target, should_error) in [("v3.3.1", true), ("v3.3.1-archy1", false)] {
let rt = Arc::new(MockRuntime::default());
rt.set_state("update-regression", ContainerState::Running);
rt.running_images.lock().unwrap().insert(
"update-regression".into(),
"registry.test/old/mempool-frontend:v3.3.1-archy1".into(),
);
let orch = orch_with(rt.clone()).await;
orch.insert_manifest_for_test(
pull_manifest(
"update-regression",
&format!("registry.test/new/mempool-frontend:{target}"),
),
PathBuf::from("/tmp/update-regression"),
)
.await;
assert_eq!(
orch.upgrade("update-regression").await.is_err(),
should_error
);
assert!(
!rt.calls()
.iter()
.any(|call| call.starts_with("stop_container:")
|| call.starts_with("remove_container:")
|| call.starts_with("pull_image:")
|| call.starts_with("create_container:")),
"{:?}",
rt.calls()
);
}
}
#[tokio::test]
async fn upgrade_removes_and_reinstalls() {
let rt = Arc::new(MockRuntime::default());
+1 -1
View File
@@ -8,5 +8,5 @@
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8090,
8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8337, 8888, 8999, 9000, 9100, 10380, 11434,
18081, 18083, 23000, 32838, 50002,
18081, 18083, 18091, 23000, 32838, 50002,
];
+2 -2
View File
@@ -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,
+35 -20
View File
@@ -2159,20 +2159,25 @@ async fn apply_per_app_auto_updates(
}
}
/// After a catalog refresh that changed the cached bytes, rebuild the
/// orchestrator's manifest map so registry-shipped manifest changes take
/// effect now instead of at the next service restart.
async fn reload_manifests_if_changed(
refresh: crate::container::app_catalog::CatalogRefresh,
/// Reload after every successful refresh, including unchanged bytes: the cache
/// may have been written before a previous reload failed. Auto-updates only run
/// when the catalog and the orchestrator's manifests are ready together.
async fn reload_catalog_manifests(
_refresh: crate::container::app_catalog::CatalogRefresh,
orchestrator: &Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
) {
if !refresh.changed {
return;
}
let Some(orch) = orchestrator else { return };
) -> bool {
let Some(orch) = orchestrator else {
return false;
};
match orch.reload_manifests().await {
Ok(n) => info!("Update scheduler: catalog changed, reloaded {n} manifest(s)"),
Err(e) => warn!("Update scheduler: manifest reload after catalog change failed: {e}"),
Ok(n) => {
info!("Update scheduler: refreshed catalog, reloaded {n} manifest(s)");
true
}
Err(e) => {
warn!("Update scheduler: manifest reload failed; skipping auto-updates: {e}");
false
}
}
}
@@ -2188,7 +2193,9 @@ pub async fn run_update_scheduler(
// Refresh the app catalog once at startup so per-app "update available"
// badges appear without waiting for the first hourly tick.
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
Ok(refresh) => {
reload_catalog_manifests(refresh, &orchestrator).await;
}
Err(e) => debug!(
"Update scheduler: initial app-catalog refresh failed: {}",
e
@@ -2204,14 +2211,22 @@ pub async fn run_update_scheduler(
// previously cached catalog stays in place (origin-always-wins).
// A changed catalog also reloads the orchestrator's manifest overlay so
// catalog-shipped manifest fixes apply without a service restart.
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
Err(e) => debug!("Update scheduler: app-catalog refresh failed: {}", e),
}
let catalog_ready = match crate::container::app_catalog::refresh_catalog(&data_dir).await {
Ok(refresh) => reload_catalog_manifests(refresh, &orchestrator).await,
Err(e) => {
debug!(
"Update scheduler: app-catalog refresh failed; skipping auto-updates: {}",
e
);
false
}
};
// Per-app auto-update-to-latest (multi-version support). Runs every tick
// regardless of the binary-OTA schedule below; opt-in + pin-respecting.
apply_per_app_auto_updates(&orchestrator).await;
// Per-app updates require fresh, loaded manifests; a failed refresh
// may still show cached badges but must not trigger container changes.
if catalog_ready {
apply_per_app_auto_updates(&orchestrator).await;
}
let state = match load_state(&data_dir).await {
Ok(s) => s,
+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;
}
}
+6 -7
View File
@@ -1,14 +1,13 @@
# Archipelago mempool frontend — adds a resilient nginx backend proxy.
#
# The only delta vs the upstream image is /patch/entrypoint.sh, which rewrites
# the generated nginx-mempool.conf to use `resolver` + a variable proxy_pass so
# the frontend re-resolves the backend (mempool-api) via DNS on every request.
# Without this, nginx pins the backend IP at startup and serves 502 / "offline"
# after any backend restart (podman reassigns the IP). See the script header.
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.0
# Keep the upstream startup logic; repair its rendered proxy configuration.
# Publish this derived image under an Archipelago-specific tag, never the
# upstream version tag that the registry mirror can overwrite.
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend@sha256:d63498a109622475c913db4e3199d893f2440a451450e542923d2e55a38407a0
FROM ${BASE}
# --chmod keeps the exec bit (build runs as USER 1000, plain COPY lands root:0644
# → "not executable"). Base USER/ENTRYPOINT/CMD (1000 / /patch/entrypoint.sh /
# nginx -g "daemon off;") are inherited unchanged.
COPY --chmod=0755 entrypoint.sh /patch/entrypoint.sh
RUN cp /patch/entrypoint.sh /patch/upstream-entrypoint.sh
COPY --chmod=0755 entrypoint.sh start-nginx.sh repair-nginx.sh /patch/
+29
View File
@@ -0,0 +1,29 @@
# Mempool frontend DNS recovery
The stock v3.3.1 nginx configuration resolves `mempool-api` only when workers
start. Recreating the backend can change its Podman address while the frontend
continues to serve its static page, leaving all API/WebSocket requests offline.
Build and test the derived image before publishing:
```sh
podman build --pull=never -t source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1 docker/mempool-frontend
python3 scripts/test-mempool-dns-recovery.py
```
The base is pinned by digest. The wrapper preserves upstream runtime options,
then repairs all four local API/WebSocket routes after placeholder rendering.
DNS is cached for five seconds using the container network resolver. Explicit
rewrites preserve API prefixes and query arguments; backend absence does not
prevent nginx startup. An unexpected upstream configuration fails startup
instead of silently omitting the fix.
Use an Archipelago-specific image tag. Do not replace it with a stock upstream
mirror when updating mempool. Every upstream update must rebuild this wrapper
and pass the recovery test (backend absent, changed IP, HTTP and WebSocket
mapping, repeated repair, and frontend restart).
Publish the tested image before publishing the signed app catalog. Both the
mempool umbrella image mapping and the archy-mempool-web embedded manifest must
point at the patched image. Keep scripts/image-versions.sh in sync. The frontend
health check must reach `/api/v1/backend-info` through nginx, not only `/`.
+4 -136
View File
@@ -1,137 +1,5 @@
#!/bin/sh
__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__=${BACKEND_MAINNET_HTTP_HOST:=127.0.0.1}
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999}
__MEMPOOL_FRONTEND_HTTP_PORT__=${FRONTEND_HTTP_PORT:=8080}
CONF=/etc/nginx/conf.d/nginx-mempool.conf
# ─── archipelago patch ────────────────────────────────────────────────────
# The stock frontend writes `proxy_pass http://<backend>:8999` with a literal
# hostname and NO resolver, so nginx resolves the backend IP ONCE at worker
# start and caches it for the process lifetime. Podman reassigns the backend
# container's IP whenever it is restarted/recreated (gate, OTA, crash, reboot
# re-IPAM), after which nginx keeps proxying to the dead IP → /api hangs, the
# websocket 502s, and the mempool UI shows "offline" until nginx is reloaded.
#
# Fix: force per-request DNS re-resolution via `resolver` + a variable in
# proxy_pass. Because a variable in proxy_pass disables nginx's automatic
# location→URI rewriting, each block is rewritten to preserve its original
# path mapping exactly:
# /api/v1/ws, /ws → "/" (var + "/" replaces the whole URI)
# /api/v1 → identity (no-URI proxy_pass passes $uri unchanged)
# /api/ → /api/v1/$1 (explicit rewrite, then no-URI proxy_pass)
# Operates on the __PLACEHOLDER__ tokens so the host/port sed below fills in
# the concrete values (incl. the `set $mp_backend` line). Idempotent.
# Resolver address: podman's aardvark-dns answers on the network gateway
# (e.g. 10.89.0.1), NOT Docker's 127.0.0.11. Read it from resolv.conf so this
# works on any podman network/subnet (and still falls back for Docker).
ARCHY_RESOLVER=$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null)
ARCHY_RESOLVER=${ARCHY_RESOLVER:-127.0.0.11}
if ! grep -q 'set \$mp_backend' "$CONF"; then
awk -v res_addr="$ARCHY_RESOLVER" '
BEGIN { res = 0 }
/^[[:space:]]*location / && res == 0 {
print "\tresolver " res_addr " valid=10s ipv6=off;"
res = 1
}
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/;/ {
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/;"
next
}
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1\/;/ {
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
print "\t\trewrite ^/api/(.*)$ /api/v1/$1 break;"
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
next
}
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1;/ {
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
next
}
{ print }
' "$CONF" > "$CONF.archy" && mv "$CONF.archy" "$CONF"
fi
# ─── end archipelago patch ────────────────────────────────────────────────
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__/${__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__}/g" /etc/nginx/conf.d/nginx-mempool.conf
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/${__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__}/g" /etc/nginx/conf.d/nginx-mempool.conf
cp /etc/nginx/nginx.conf /patch/nginx.conf
sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf
cat /patch/nginx.conf > /etc/nginx/nginx.conf
if [ "${LIGHTNING_DETECTED_PORT}" != "" ];then
export LIGHTNING=true
fi
# Runtime overrides - read env vars defined in docker compose
__MAINNET_ENABLED__=${MAINNET_ENABLED:=true}
__TESTNET_ENABLED__=${TESTNET_ENABLED:=false}
__TESTNET4_ENABLED__=${TESTNET_ENABLED:=false}
__SIGNET_ENABLED__=${SIGNET_ENABLED:=false}
__LIQUID_ENABLED__=${LIQUID_ENABLED:=false}
__LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false}
__ITEMS_PER_PAGE__=${ITEMS_PER_PAGE:=10}
__KEEP_BLOCKS_AMOUNT__=${KEEP_BLOCKS_AMOUNT:=8}
__NGINX_PROTOCOL__=${NGINX_PROTOCOL:=http}
__NGINX_HOSTNAME__=${NGINX_HOSTNAME:=localhost}
__NGINX_PORT__=${NGINX_PORT:=8999}
__BLOCK_WEIGHT_UNITS__=${BLOCK_WEIGHT_UNITS:=4000000}
__MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_BLOCKS_AMOUNT:=8}
__BASE_MODULE__=${BASE_MODULE:=mempool}
__ROOT_NETWORK__=${ROOT_NETWORK:=}
__MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://mempool.space}
__LIQUID_WEBSITE_URL__=${LIQUID_WEBSITE_URL:=https://liquid.network}
__MINING_DASHBOARD__=${MINING_DASHBOARD:=true}
__LIGHTNING__=${LIGHTNING:=false}
__AUDIT__=${AUDIT:=false}
__MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0}
__TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0}
__SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0}
__ACCELERATOR__=${ACCELERATOR:=false}
__ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true}
__SERVICES_API__=${SERVICES_API:=https://mempool.space/api/v1/services}
__PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false}
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
__ADDITIONAL_CURRENCIES__=${ADDITIONAL_CURRENCIES:=false}
# Export as environment variables to be used by envsubst
export __MAINNET_ENABLED__
export __TESTNET_ENABLED__
export __TESTNET4_ENABLED__
export __SIGNET_ENABLED__
export __LIQUID_ENABLED__
export __LIQUID_TESTNET_ENABLED__
export __ITEMS_PER_PAGE__
export __KEEP_BLOCKS_AMOUNT__
export __NGINX_PROTOCOL__
export __NGINX_HOSTNAME__
export __NGINX_PORT__
export __BLOCK_WEIGHT_UNITS__
export __MEMPOOL_BLOCKS_AMOUNT__
export __BASE_MODULE__
export __ROOT_NETWORK__
export __MEMPOOL_WEBSITE_URL__
export __LIQUID_WEBSITE_URL__
export __MINING_DASHBOARD__
export __LIGHTNING__
export __AUDIT__
export __MAINNET_BLOCK_AUDIT_START_HEIGHT__
export __TESTNET_BLOCK_AUDIT_START_HEIGHT__
export __SIGNET_BLOCK_AUDIT_START_HEIGHT__
export __ACCELERATOR__
export __ACCELERATOR_BUTTON__
export __SERVICES_API__
export __PUBLIC_ACCELERATIONS__
export __HISTORICAL_PRICE__
export __ADDITIONAL_CURRENCIES__
folder=$(find /var/www/mempool -name "config.js" | xargs dirname)
echo ${folder}
envsubst < ${folder}/config.template.js > ${folder}/config.js
exec "$@"
set -eu
# Preserve the pinned upstream entrypoint (including new runtime options).
# Apply our DNS repair only after it has rendered the nginx configuration.
exec /patch/upstream-entrypoint.sh /patch/start-nginx.sh "$@"
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
# Resolve the backend again after container IP changes. Run after upstream
# placeholder substitution, so the repair also works on an existing container.
set -eu
conf=${1:-/etc/nginx/conf.d/nginx-mempool.conf}
resolv=${2:-/etc/resolv.conf}
backend=${BACKEND_MAINNET_HTTP_HOST:-127.0.0.1}
port=${BACKEND_MAINNET_HTTP_PORT:-8999}
resolver=$(awk '/^nameserver/ { print $2; exit }' "$resolv")
[ -n "$resolver" ] || { echo 'No DNS resolver configured' >&2; exit 1; }
case "$resolver" in *:*) resolver="[$resolver]" ;; esac
case "$backend" in *[!a-zA-Z0-9._-]*|'') echo 'Invalid backend hostname' >&2; exit 1 ;; esac
case "$port" in *[!0-9]*|'') echo 'Invalid backend port' >&2; exit 1 ;; esac
tmp=$(mktemp "${conf}.archy.XXXXXX")
trap 'rm -f "$tmp"' EXIT HUP INT TERM
awk -v backend="$backend" -v port="$port" -v resolver="$resolver" '
BEGIN {
base = "http://" backend ":" port
print "# Archipelago: refresh backend DNS after container replacement."
print "resolver " resolver " valid=5s ipv6=off; # archy-dns"
print "resolver_timeout 3s; # archy-dns"
}
/# Archipelago: refresh backend DNS/ || /# archy-dns/ { next }
/^[[:space:]]*location[[:space:]]/ { location = $2 }
/^[[:space:]]*proxy_pass[[:space:]]/ && index($2, base) == 1 {
target = $2
sub(/;$/, "", target)
path = substr(target, length(base) + 1)
if (location != "/api/v1/ws" && location != "/ws" && location != "/api/v1" && location != "/api/") {
print "Unexpected backend location: " location > "/dev/stderr"
failed = 1; exit 1
}
if (path != "/" && path != "/api/v1" && path != "/api/v1/") {
print "Unexpected backend URI mapping" > "/dev/stderr"
failed = 1; exit 1
}
# Explicitly preserve prefix substitution and query arguments. A variable
# proxy_pass without a URI forwards the rewritten URI and original args.
print "\t\tset $mp_backend " backend ";"
if (path != location)
print "\t\trewrite ^" location "(.*)$ " path "$1 break;"
print "\t\tproxy_pass http://$mp_backend:" port ";"
count++
next
}
/proxy_pass http:\/\/\$mp_backend:/ { count++ }
{ print }
END {
if (failed || count != 4) {
print "Expected four backend proxies; refusing an incomplete DNS repair" > "/dev/stderr"
exit 1
}
}
' "$conf" > "$tmp"
cat "$tmp" > "$conf"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
/patch/repair-nginx.sh
nginx -t
exec "$@"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.13-alpha",
"version": "1.8.16-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.13-alpha",
"version": "1.8.16-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.13-alpha",
"version": "1.8.16-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+3 -3
View File
@@ -26,7 +26,7 @@
"headline": "Your node. Your source.",
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
"tag": "NGIT // NOSTR // NO SILO",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
"launchLabel": "Open GitWorkshop",
"installLabel": "Install GitWorkshop",
"detailsLabel": "How contribution works →"
@@ -378,13 +378,13 @@
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"version": "3.3.1-archy1",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
@@ -45,13 +45,13 @@
</button>
</div>
<div v-else-if="identities.length === 0" class="text-center py-8">
<div v-else-if="userIdentities.length === 0" class="text-center py-8">
<p class="text-white/50 text-sm">No identities found.</p>
<p class="text-white/30 text-xs mt-1">Create one in Settings &rarr; Credentials</p>
</div>
<button
v-for="identity in identities"
v-for="identity in userIdentities"
:key="identity.id"
type="button"
role="radio"
@@ -130,6 +130,7 @@ interface Identity {
is_default: boolean
nostr_pubkey?: string
nostr_npub?: string
is_node?: boolean
}
const props = defineProps<{
@@ -148,10 +149,23 @@ const selectedId = ref<string | null>(null)
const loading = ref(false)
const loadError = ref<string | null>(null)
// The node key authenticates the appliance itself (mesh/discovery and other
// platform operations), not the person's public profile. The API marks it
// explicitly; keep a defensive name/purpose fallback for older nodes that do
// not send is_node yet.
const userIdentities = computed(() => identities.value.filter(identity =>
// `node-<pubkey>` is the deterministic id used by older node APIs before
// the explicit is_node marker was added. Keep this fallback so an older
// backend can never expose the appliance key as a profile choice.
!identity.is_node
&& !identity.id.trim().toLowerCase().startsWith('node-')
&& identity.name.trim().toLowerCase() !== 'node'
))
useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel'))
const hasNostrKey = computed(() => {
const selected = identities.value.find(i => i.id === selectedId.value)
const selected = userIdentities.value.find(i => i.id === selectedId.value)
return selected?.nostr_pubkey != null
})
@@ -169,8 +183,8 @@ async function loadIdentities() {
try {
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
identities.value = res.identities || []
const defaultId = identities.value.find(i => i.is_default && i.nostr_pubkey)
|| identities.value.find(i => i.nostr_pubkey)
const defaultId = userIdentities.value.find(i => i.is_default && i.nostr_pubkey)
|| userIdentities.value.find(i => i.nostr_pubkey)
if (defaultId) selectedId.value = defaultId.id
} catch (error) {
identities.value = []
@@ -183,7 +197,7 @@ async function loadIdentities() {
}
function confirm() {
const selected = identities.value.find(i => i.id === selectedId.value)
const selected = userIdentities.value.find(i => i.id === selectedId.value)
if (selected) emit('select', selected)
}
@@ -95,7 +95,7 @@ describe('useAppLauncherStore', () => {
const store = useAppLauncherStore()
store.openSession('archipelago-source')
expect(openInApp).toHaveBeenCalledWith(
'http://192.0.2.10/app/archipelago-source/',
'http://192.0.2.10/app/archipelago-source/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy',
)
expect(store.panelAppId).toBeNull()
expect(store.isOpen).toBe(false)
+10 -1
View File
@@ -7,7 +7,8 @@ import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils
import { directAppUrl, HOST_FRAME_APPS, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { appPortIsGateFronted } from '@/views/appSession/appSessionConfig'
import { useAppStore } from '@/stores/app'
import { resolveAppIcon } from '@/views/apps/appsConfig'
import { resolveAppIcon, isAppReadyForLaunch } from '@/views/apps/appsConfig'
import { useToast } from '@/composables/useToast'
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
import type { AppCredential, AppCredentialsResponse } from '@/types/api'
import { resolveAppCredentials } from '@/views/apps/appCredentials'
@@ -290,6 +291,14 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
* Previously each Apps view owned a private modal, so Home skipped the
* Portainer first-run token entirely. */
function openSession(appId: string, opts: LaunchOptions = {}) {
// Home/goal/deep-link launchers do not pass through AppCard.canLaunch.
// Apply the same readiness gate here so a container that has just entered
// `running` cannot race nginx and show a transient 502 to the user.
const pkg = useAppStore().data?.['package-data']?.[appId]
if (pkg && pkg.state === 'running' && !isAppReadyForLaunch(pkg)) {
useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`)
return
}
if (!opts.skipCredentialPrompt && CREDENTIAL_INTERSTITIAL_APPS.has(appId)) {
void prepareCredentialLaunch(appId, opts.path)
return
+13 -4
View File
@@ -365,6 +365,7 @@ import AppGrid from './discover/AppGrid.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue'
import type { MarketplaceApp, FeaturedApp } from './discover/types'
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured, type CatalogStorefront } from './discover/curatedApps'
import { isServiceContainer } from './apps/serviceNames'
const router = useRouter()
const store = useAppStore()
@@ -733,6 +734,16 @@ onBeforeUnmount(() => {
const toast = useToast()
function installToast(app: MarketplaceApp) {
const service = isServiceContainer(app.id)
const destination = service ? 'Services' : 'My Apps'
toast.action(
`Installing ${app.title ?? app.id} — it will appear in ${destination}`,
{ label: `View ${destination}`, onClick: () => router.push({ path: '/dashboard/apps', query: service ? { tab: 'services' } : {} }) },
{ variant: 'info', duration: 15000 },
)
}
function installBlockedReason(appId: string): string | undefined {
if (!bitcoinPruned.value) return undefined
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
@@ -766,8 +777,7 @@ function failInstall(app: MarketplaceApp, err: unknown) {
async function installApp(app: MarketplaceApp, versionOverride?: string) {
if (installingApps.has(app.id) || isInstalled(app.id)) return
queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
router.push('/dashboard/apps').catch(() => {})
installToast(app)
try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 })
@@ -780,8 +790,7 @@ async function installApp(app: MarketplaceApp, versionOverride?: string) {
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) {
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
router.push('/dashboard/apps').catch(() => {})
installToast(app)
try {
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
if ((app as Record<string, unknown>).containerConfig) {
+14 -2
View File
@@ -185,6 +185,7 @@ import {
getCuratedAppList,
} from './marketplace/marketplaceData'
import { fetchAppCatalog } from './discover/curatedApps'
import { isServiceContainer } from './apps/serviceNames'
const router = useRouter()
const route = useRoute()
@@ -207,6 +208,17 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS)
const installingApps = server.installingApps
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
function installToast(app: MarketplaceApp) {
const service = isServiceContainer(app.id)
const tab = service ? 'services' : 'apps'
const destination = service ? 'Services' : 'My Apps'
toast.action(
`Installing ${app.title ?? app.id} — it will appear in ${destination}`,
{ label: `View ${destination}`, onClick: () => router.push({ path: '/dashboard/apps', query: service ? { tab } : {} }) },
{ variant: 'info', duration: 15000 },
)
}
// Install progress tracking is now in serverStore (global watcher on WebSocket data)
// so it works regardless of which page is active
@@ -518,7 +530,7 @@ async function installApp(app: MarketplaceApp) {
// Stay on the store page: the tile itself shows install progress via the
// global watcher, and a forced jump to My Apps yanked the user out of the
// page they were deliberately browsing.
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps")
installToast(app)
try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
@@ -543,7 +555,7 @@ async function installCommunityApp(app: MarketplaceApp) {
queueInstall(app)
// Stay on the store page (see installApp).
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps")
installToast(app)
try {
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
@@ -1,5 +1,5 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { HOST_FRAME_APPS, NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { DEFAULT_GITWORKSHOP_REPO_PATH, HOST_FRAME_APPS, NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_HOST_FRAME_APPS, GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
import { __setSignedCatalogForTests } from '../../discover/curatedApps'
@@ -149,8 +149,8 @@ describe('appSessionConfig', () => {
// A runtime port the gate does NOT front keeps plain http (https would
// fail to connect outright).
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083')
// Cuprate's UI port is auth:none — plain HTTP stays plain.
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090')
// Cuprate's raw RPC is never a launch surface; use the companion UI.
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('/app/cuprate-ui/')
})
it('keeps the pre-catalog Source app on the dashboard origin', () => {
@@ -159,9 +159,9 @@ describe('appSessionConfig', () => {
// Source is intentionally absent from SIGNED until owner UAT passes. It
// must follow the already-working dashboard ingress instead of assuming
// that the same address also exposes a dedicated high port.
expect(resolveAppUrl('archipelago-source')).toBe('/app/archipelago-source/')
expect(resolveAppUrl('archipelago-source')).toBe(`/app/archipelago-source${DEFAULT_GITWORKSHOP_REPO_PATH}`)
expect(resolveAppUrl('archipelago-source', undefined, 'http://localhost:8337'))
.toBe('/app/archipelago-source/')
.toBe(`/app/archipelago-source${DEFAULT_GITWORKSHOP_REPO_PATH}`)
expect(resolveAppUrl('archipelago-source', '/search'))
.toBe('/app/archipelago-source/search')
})
@@ -34,6 +34,9 @@ export const APP_PORTS: Record<string, number> = {
'bitcoin-knots': 8334,
'bitcoin-core': 8334,
'bitcoin-ui': 8334,
'cuprate': 18091,
'cuprate-ui': 18091,
'archy-cuprate-ui': 18091,
'electrumx': 50002,
'electrs': 50002,
'archy-electrs-ui': 50002,
@@ -61,6 +64,10 @@ export const PROXY_APPS: Record<string, string> = {
'uptime-kuma': '/app/uptime-kuma/',
}
/** The repository shown when GitWorkshop is opened from the app launcher. */
export const DEFAULT_GITWORKSHOP_REPO_PATH =
'/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy'
/** App launches use direct ports. Do not route through /app/... path proxies. */
export const HTTPS_PROXY_PATHS: Record<string, string> = {
}
@@ -137,8 +144,8 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
// high port is reachable through the same address.
if (id === 'archipelago-source') {
const base = PROXY_APPS['archipelago-source']!
if (!routeQueryPath) return base
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : `/${routeQueryPath}`)
const path = routeQueryPath || DEFAULT_GITWORKSHOP_REPO_PATH
return base.replace(/\/+$/, '') + (path.startsWith('/') ? path : `/${path}`)
}
// Bitcoin UI is a host-network companion on :8334. Do not launch it via
@@ -149,6 +156,15 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
return appOrigin(8334, id)
}
// Cuprate UI is the same companion shape on :18091. The cuprate app itself
// publishes only the restricted RPC (18090) — a raw JSON endpoint, not a
// page — so cuprate launches must land on the companion, never on the
// runtimeUrl a running cuprate reports.
if (id === 'cuprate' || id === 'cuprate-ui' || id === 'archy-cuprate-ui') {
if (import.meta.env.DEV) return '/app/cuprate-ui/'
return appOrigin(18091, id)
}
if (runtimeUrl && id !== 'netbird') {
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
// The backend reports runtime URLs as http:// because that is how the app
@@ -9,6 +9,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"bitcoin-ui": 8334,
"botfights": 9100,
"btcpay-server": 23000,
"cuprate-ui": 18091,
"electrs-ui": 50002,
"electrumx": 50002,
"fedimint": 8175,
@@ -54,6 +55,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"btcpay-server": "BTCPay Server",
"core-lightning": "Core Lightning (CLN)",
"cuprate": "Cuprate",
"cuprate-ui": "Cuprate UI",
"electrs-ui": "Electrs UI",
"electrumx": "ElectrumX",
"fedimint": "Fedimint Guardian",
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { APP_CATEGORY_MAP, canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig'
import { APP_CATEGORY_MAP, canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, isAppReadyForLaunch, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig'
function makePkg(id: string, title: string, category: string): PackageDataEntry {
return {
@@ -76,6 +76,15 @@ describe('appsConfig service filtering', () => {
expect(services.map(([id]) => id)).toEqual(['core-lnd-ui'])
})
it('shows Cuprate as one My Apps entry while hiding its daemon dependency', () => {
const entries: Array<[string, PackageDataEntry]> = [
['cuprate', makePkg('cuprate', 'Cuprate', 'money')],
['archy-cuprate-ui', makePkg('archy-cuprate-ui', 'Cuprate UI companion', 'money')],
]
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['cuprate'])
expect(filterEntriesForTab(entries, 'services', 'all').map(([id]) => id)).toEqual([])
})
it('falls back to packaged app icon when static icon token is not a path', () => {
const pkg = makePkg('gitea', 'Gitea', 'dev')
pkg['static-files']!.icon = 'git-branch'
@@ -141,6 +150,19 @@ describe('appsConfig service filtering', () => {
expect(canLaunch(confirmedUi)).toBe(true)
})
it('does not launch a health-checked app during the running-before-ready race', () => {
const pkg = makePkg('archipelago-source', 'GitWorkshop', 'development')
;(pkg.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
;(pkg.manifest as unknown as Record<string, unknown>).health_check = { path: '/healthz' }
pkg.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8337' } }, status: 'running' } as unknown as PackageDataEntry['installed']
pkg.health = null
expect(isAppReadyForLaunch(pkg)).toBe(false)
expect(canLaunch(pkg)).toBe(false)
expect(launchBlockedReason(pkg.manifest.id, pkg)).toContain('Starting up')
pkg.health = 'healthy'
expect(canLaunch(pkg)).toBe(true)
})
it('never offers Launch for curated service containers even with a UI flag', () => {
const service = makePkg('indeedhub-api', 'IndeeHub API', 'media')
;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
+25 -3
View File
@@ -20,6 +20,10 @@ export const isServiceContainer = sharedIsServiceContainer
const INTERNAL_TOOLING_NAMES = new Set([
'buildx_buildkit_default',
// Cuprate's dashboard is bundled as a companion of the primary cuprate
// package; showing the generated container as a second Services entry
// defeats the one-app presentation.
'archy-cuprate-ui',
])
export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): boolean {
@@ -37,7 +41,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
// Known app -> category mappings (matches App Store categorisation)
export const APP_CATEGORY_MAP: Record<string, string> = {
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'cuprate': 'money', 'cuprate-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
@@ -259,11 +263,26 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
// the tile stays launchable while the backend is still 'starting' (ElectrumX
// indexes for 10m+ on first run). A genuinely 'unhealthy' backend still
// blocks. Apps that rely on a runtime interface-address keep the strict gate.
const blockedByHealth =
pkg.health === 'unhealthy' || (pkg.health === 'starting' && !hasKnownLaunchUrl)
const blockedByHealth = !isAppReadyForLaunch(pkg) ||
(pkg.health === 'starting' && !hasKnownLaunchUrl)
return !!hasUI && pkg.state === 'running' && !blockedByHealth
}
/**
* A published port is not the same thing as a usable app. During the short
* interval between the container entering `running` and its HTTP health check
* passing, nginx quite correctly returns 502 because the upstream has not
* bound its socket yet. Keep every app with a declared health check out of
* the launch path until the platform has observed readiness. Apps without a
* health check retain the legacy state/port behaviour.
*/
export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
const manifest = pkg.manifest as unknown as Record<string, unknown>
const hasHealthCheck = Boolean(manifest.health_check || manifest['health-check'])
if (!hasHealthCheck) return pkg.health !== 'unhealthy'
return pkg.health === 'healthy'
}
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
const appId = pkg?.manifest?.id || id
if (
@@ -272,6 +291,9 @@ export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null):
) {
return 'Guardian opens a wait page until Bitcoin finishes initial sync.'
}
if (pkg && pkg.state === PackageState.Running && !isAppReadyForLaunch(pkg)) {
return 'Starting up — Launch will appear when the app is ready.'
}
return ''
}
+4
View File
@@ -14,6 +14,10 @@
// SERVICE_NAMES set that used to live in appsConfig.ts verbatim.
export const SERVICE_NAMES = new Set([
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
// Cuprate is presented as one user-facing app. Its companion container
// (archy-cuprate-ui) is the implementation detail; do not classify the
// primary package as a Service or it disappears from My Apps and loses its
// launch button during/after install.
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
// built-in Mesh tab) belong in Services, not My Apps.
@@ -123,6 +123,8 @@ const CATALOG_APP_ID_ALIASES: Record<string, string> = {
'archy-lnd-ui': 'lnd-ui',
'bitcoin-knots': 'bitcoin-ui',
'bitcoin-core': 'bitcoin-ui',
'cuprate': 'cuprate-ui',
'archy-cuprate-ui': 'cuprate-ui',
'fedimintd': 'fedimint',
'immich_server': 'immich',
}
@@ -308,6 +310,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
// Supporting containers (DBs, caches, workers) do NOT — having only a DB
// without the main app should not mark the app as installed in the UI.
export const INSTALLED_ALIASES: Record<string, string[]> = {
'cuprate-ui': ['cuprate-ui', 'cuprate'],
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server'],
@@ -68,6 +68,7 @@ const REGISTRY = 'source.archipelago-foundation.org/lfg2025'
/** Marketplace app ID -> backend package keys (for "Already Installed" when first-boot/deploy created them) */
export const INSTALLED_ALIASES: Record<string, string[]> = {
'cuprate-ui': ['cuprate-ui', 'cuprate'],
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
@@ -362,6 +362,44 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.8.16-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.16-alpha</span>
<span class="text-xs text-white/40">September 15, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.</p>
<p>Fixed repeated Mempool update offers: downstream -archyN patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.</p>
<p>Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.</p>
<p>Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.</p>
</div>
</div>
<!-- v1.8.15-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.15-alpha</span>
<span class="text-xs text-white/40">September 13, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.</p>
<p>Added regression coverage for Cuprate install and installed-state grouping.</p>
<p>Release validation was rerun on the corrected tree before OTA and ISO publication.</p>
</div>
</div>
<!-- v1.8.14-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.14-alpha</span>
<span class="text-xs text-white/40">September 13, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>Cuprate gains a first-party companion dashboard.</strong> The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.</p>
<p><strong>Bitcoin Core Tor enrollment uses the correct protocol identity.</strong> bitcoin-core is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.</p>
<p><strong>GitWorkshop opens Archipelago’s canonical ngit repository by default.</strong> The launcher and registry promotion use the full maintainer/relay/archy coordinate, with regression coverage for Companion and browser-tab launches.</p>
<p><strong>Release validation is stricter.</strong> The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites.</p>
</div>
</div>
<!-- v1.8.13-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
@@ -369,7 +407,9 @@ init()
<span class="text-xs text-white/40">September 12, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>GitWorkshop installs reliably on fresh nodes.</strong> The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services. Fresh installs now use the production orchestrator to build the bundled GitWorkshop image rather than sending its local image reference through the legacy registry-pull path. Regression coverage now protects all curated app classifications.</p>
<p><strong>GitWorkshop installs reliably on fresh nodes.</strong> The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.</p>
<p><strong>Fresh GitWorkshop installs build the correct image.</strong> The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.</p>
<p><strong>Curated app classification is regression-tested.</strong> Every user-facing app remains in My Apps during installation, while headless services stay in Services.</p>
</div>
</div>
<!-- v1.8.12-alpha -->
+5
View File
@@ -173,6 +173,11 @@ export default defineConfig({
changeOrigin: true,
secure: false,
},
'/app/cuprate-ui': {
target: process.env.BACKEND_URL || 'http://localhost:5959',
changeOrigin: true,
secure: false,
},
// Demo mock app UIs (electrumx, lnd, fedimint) + generic notice page.
'/app/electrumx': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
'/app/electrs': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
+17 -17
View File
@@ -1,29 +1,29 @@
{
"changelog": [
"**GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.",
"**Fresh GitWorkshop installs build the correct image.** The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.",
"**Curated app classification is regression-tested.** Every user-facing app remains in My Apps during installation, while headless services stay in Services."
"Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.",
"Added regression coverage for Cuprate install and installed-state grouping.",
"Release validation was rerun on the corrected tree before OTA and ISO publication."
],
"components": [
{
"current_version": "1.8.13-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-alpha/archipelago",
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.13-alpha",
"sha256": "832c7e75b395f94f919a21d1ad7d32d367e1ef84a0e0995b4e8fe87268aa21a4",
"size_bytes": 64585424
"new_version": "1.8.15-alpha",
"sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
"size_bytes": 64571544
},
{
"current_version": "1.8.13-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-alpha/archipelago-frontend-1.8.13-alpha.tar.gz",
"name": "archipelago-frontend-1.8.13-alpha.tar.gz",
"new_version": "1.8.13-alpha",
"sha256": "d0159b61f84eb30013634a97177801474376dddb1189024b94f16236ce7ff538",
"size_bytes": 97915796
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago-frontend-1.8.15-alpha.tar.gz",
"name": "archipelago-frontend-1.8.15-alpha.tar.gz",
"new_version": "1.8.15-alpha",
"sha256": "86a32ef3334b03c197e47d9d28f4435c6f2fa7c4ccacc839a8fc4a0a749495dc",
"size_bytes": 98797600
}
],
"release_date": "2026-09-12",
"signature": "45a322e793a2fc7565cc148f14efbbcf03e0c934183590375368091368ffe49ed2a7e9962692caf54b3432c71976a6c1b0f06a48cf8c4634c7b2e573e5782a0f",
"release_date": "2026-09-13",
"signature": "5e13396e2f33571f136bb1a9ea0356486b3d42c6ba99ee5d33990cd565d9b1178f61eaf6a70a937a006e7de3fd388bcebd63f2daca4bb28accc1b810d8455d03",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.13-alpha"
"version": "1.8.15-alpha"
}
+3252 -3250
View File
File diff suppressed because one or more lines are too long
+17 -17
View File
@@ -1,29 +1,29 @@
{
"changelog": [
"**GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.",
"**Fresh GitWorkshop installs build the correct image.** The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.",
"**Curated app classification is regression-tested.** Every user-facing app remains in My Apps during installation, while headless services stay in Services."
"Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.",
"Added regression coverage for Cuprate install and installed-state grouping.",
"Release validation was rerun on the corrected tree before OTA and ISO publication."
],
"components": [
{
"current_version": "1.8.13-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-alpha/archipelago",
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.13-alpha",
"sha256": "832c7e75b395f94f919a21d1ad7d32d367e1ef84a0e0995b4e8fe87268aa21a4",
"size_bytes": 64585424
"new_version": "1.8.15-alpha",
"sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
"size_bytes": 64571544
},
{
"current_version": "1.8.13-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-alpha/archipelago-frontend-1.8.13-alpha.tar.gz",
"name": "archipelago-frontend-1.8.13-alpha.tar.gz",
"new_version": "1.8.13-alpha",
"sha256": "d0159b61f84eb30013634a97177801474376dddb1189024b94f16236ce7ff538",
"size_bytes": 97915796
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago-frontend-1.8.15-alpha.tar.gz",
"name": "archipelago-frontend-1.8.15-alpha.tar.gz",
"new_version": "1.8.15-alpha",
"sha256": "86a32ef3334b03c197e47d9d28f4435c6f2fa7c4ccacc839a8fc4a0a749495dc",
"size_bytes": 98797600
}
],
"release_date": "2026-09-12",
"signature": "45a322e793a2fc7565cc148f14efbbcf03e0c934183590375368091368ffe49ed2a7e9962692caf54b3432c71976a6c1b0f06a48cf8c4634c7b2e573e5782a0f",
"release_date": "2026-09-13",
"signature": "5e13396e2f33571f136bb1a9ea0356486b3d42c6ba99ee5d33990cd565d9b1178f61eaf6a70a937a006e7de3fd388bcebd63f2daca4bb28accc1b810d8455d03",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.13-alpha"
"version": "1.8.15-alpha"
}
@@ -0,0 +1,30 @@
{
"changelog": [
"App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.",
"Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.",
"Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.",
"Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed."
],
"components": [
{
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.16-alpha",
"sha256": "1800f57678a0b994ab2e43a830ef06d1c96fd3cc7be47ce4e6e46b7df8a5420f",
"size_bytes": 64851944
},
{
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago-frontend-1.8.16-alpha.tar.gz",
"name": "archipelago-frontend-1.8.16-alpha.tar.gz",
"new_version": "1.8.16-alpha",
"sha256": "7dd73c50a54bc530385d9e450a18cbff9c3f4ffaf289a2a7b21e5d3803116722",
"size_bytes": 98799570
}
],
"release_date": "2026-09-15",
"signature": "083b131a6b895e1ff8fb9e9a52b1ead260e2140081a0295ae6756cbbc4f8f2c30e8a8bc72822c905702e21ac90f7cb85d5cca9b5f8c10fc87f32a365da202c0d",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.16-alpha"
}
+1
View File
@@ -19,6 +19,7 @@ INTERNAL_MANIFEST_IDS = {
"archy-nbxplorer",
"bitcoin-ui",
"core-lightning",
"cuprate-ui",
"electrs-ui",
"fips-ui",
"lnd-ui",
+1
View File
@@ -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",
+3 -1
View File
@@ -33,7 +33,8 @@ ELECTRUMX_IMAGE="$ARCHY_REGISTRY/electrumx:v1.18.0"
# Mempool stack
MEMPOOL_BACKEND_IMAGE="$ARCHY_REGISTRY/mempool-backend:v3.3.1"
MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.3.1"
# The patched frontend is published by chaum on the same trusted registry.
MEMPOOL_WEB_IMAGE="source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
# BTCPay
@@ -126,6 +127,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"
+20 -11
View File
@@ -53,24 +53,33 @@ if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
fi
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
# https is accepted as well as http. Requiring http:// meant the only remote
# whose credential actually works for git push (the https one) was rejected,
# while the http remote it forced you to use had a dead token — so publishing
# failed on auth after the manifest had already passed every check
# (v1.7.121-alpha, 2026-08-04). The scheme is carried through to the API URL
# rather than assumed.
# Remote URLs are public metadata: ngit can include them in repository
# announcements. Keep credentials in Git's credential helper, never in URLs.
case "$remote_url" in
http://*@*|https://*@*) ;;
*) fail "$REMOTE must be an authenticated http(s):// Gitea remote URL for API uploads" ;;
http://*@*|https://*@*) fail "$REMOTE embeds credentials; move them to a Git credential helper and remove them from the remote URL" ;;
http://*|https://*) ;;
*) fail "$REMOTE must be an http(s):// Gitea remote URL for API uploads" ;;
esac
scheme=${remote_url%%://*}
rest=${remote_url#*://}
auth=${rest%%@*}
host_path=${rest#*@}
host_path=${remote_url#*://}
host=${host_path%%/*}
repo_path=${host_path#*/}
repo_path=${repo_path%.git}
credential=$(printf 'url=%s\n\n' "$remote_url" | GIT_TERMINAL_PROMPT=0 git -C "$PROJECT_ROOT" credential fill) \
|| fail "no Git credential available for $REMOTE; configure a credential helper first"
auth_user=""
auth_password=""
while IFS= read -r field; do
case "$field" in
username=*) auth_user=${field#username=} ;;
password=*) auth_password=${field#password=} ;;
esac
done <<< "$credential"
[ -n "$auth_user" ] && [ -n "$auth_password" ] \
|| fail "Git credential helper did not provide a username and password for $REMOTE"
auth="$auth_user:$auth_password"
unset credential auth_user auth_password
api="$scheme://$host/api/v1/repos/$repo_path"
release_url="$api/releases/tags/v${VERSION}"
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Exercise the built frontend against a backend that disappears and changes IP.
Uses an isolated Podman network and disposable containers, never the node stack.
Usage: python3 scripts/test-mempool-dns-recovery.py [frontend-image]
"""
import ipaddress
import json
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
IMAGE = sys.argv[1] if len(sys.argv) > 1 else (
"source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
)
BACKEND = "source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1"
prefix = "mempool-dns-test-" + uuid.uuid4().hex[:8]
network, frontend, backend = prefix, prefix + "-web", prefix + "-api"
def podman(*args, check=True):
return subprocess.run(["podman", *args], capture_output=True, text=True,
check=check, timeout=60).stdout.strip()
def eventually(check, timeout=25):
deadline = time.monotonic() + timeout
while True:
try:
return check()
except (AssertionError, OSError, urllib.error.URLError):
if time.monotonic() >= deadline:
raise
time.sleep(1)
server = r"""
const http = require('http'), crypto = require('crypto');
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({url: req.url, instance: process.env.INSTANCE}));
});
server.on('upgrade', (req, socket) => {
const key = crypto.createHash('sha1')
.update(req.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
.digest('base64');
socket.end('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n' +
'Connection: Upgrade\r\nSec-WebSocket-Accept: ' + key + '\r\n' +
'X-Upstream-Url: ' + req.url + '\r\nX-Instance: ' + process.env.INSTANCE + '\r\n\r\n');
});
server.listen(8999, '0.0.0.0');
"""
try:
podman("network", "create", network)
subnet = ipaddress.ip_network(json.loads(podman("network", "inspect", network))[0]["subnets"][0]["subnet"])
podman("run", "-d", "--name", frontend, "--network", network,
"-p", "127.0.0.1::8080", "-e", "BACKEND_MAINNET_HTTP_HOST=mempool-api",
"-e", "FRONTEND_HTTP_PORT=8080", IMAGE)
port = int(podman("port", frontend, "8080/tcp").rsplit(":", 1)[1])
url = f"http://127.0.0.1:{port}"
def static_ready():
assert urllib.request.urlopen(url, timeout=4).status == 200
eventually(static_ready)
started = podman("inspect", frontend, "--format", "{{.State.StartedAt}}")
try:
urllib.request.urlopen(url + "/api/v1/backend-info", timeout=6)
raise AssertionError("An absent backend must not appear healthy")
except urllib.error.HTTPError as error:
assert error.code == 502
print("PASS: frontend starts while backend DNS is absent", flush=True)
for instance, offset in [("first", 10), ("replacement", 11)]:
if instance == "replacement":
podman("rm", "-f", backend)
# Ensure the cached address has expired while the backend is absent.
time.sleep(6)
podman("run", "-d", "--name", backend, "--network", network,
"--network-alias", "mempool-api", "--ip", str(subnet[offset]),
"-e", "INSTANCE=" + instance, "--entrypoint", "node", BACKEND,
"-e", server)
for path, expected in [
("/api/blocks/tip/height?probe=one", "/api/v1/blocks/tip/height?probe=one"),
("/api/v1/fees/recommended?probe=two", "/api/v1/fees/recommended?probe=two"),
]:
def check_http():
with urllib.request.urlopen(url + path, timeout=4) as response:
result = json.load(response)
assert result == {"url": expected, "instance": instance}, result
eventually(check_http)
for path in ["/api/v1/ws?probe=ws", "/ws?probe=ws"]:
def check_ws():
with socket.create_connection(("127.0.0.1", port), timeout=4) as sock:
sock.sendall((f"GET {path} HTTP/1.1\r\nHost: localhost\r\n"
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
"Sec-WebSocket-Version: 13\r\n"
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n").encode())
response = b""
while b"\r\n\r\n" not in response:
part = sock.recv(4096)
assert part, response
response += part
assert b"101 Switching Protocols" in response, response
assert b"X-Upstream-Url: /?probe=ws" in response, response
assert ("X-Instance: " + instance).encode() in response, response
eventually(check_ws)
assert podman("inspect", frontend, "--format", "{{.State.StartedAt}}") == started
print(f"PASS: {instance} backend at {subnet[offset]}: HTTP paths, query strings, both WebSocket routes; frontend never restarted", flush=True)
before = podman("exec", frontend, "cat", "/etc/nginx/conf.d/nginx-mempool.conf")
podman("exec", frontend, "/patch/repair-nginx.sh")
assert podman("exec", frontend, "cat", "/etc/nginx/conf.d/nginx-mempool.conf") == before
podman("exec", frontend, "nginx", "-t")
print("PASS: repeated repair is idempotent and nginx configuration is valid", flush=True)
podman("restart", frontend)
eventually(static_ready)
def after_restart():
with urllib.request.urlopen(url + "/api/blocks/tip/height?restart=1", timeout=4) as response:
assert json.load(response) == {
"url": "/api/v1/blocks/tip/height?restart=1", "instance": "replacement"
}
eventually(after_restart)
eventually(check_ws)
print("PASS: frontend restart preserves DNS recovery and HTTP/WebSocket routing", flush=True)
finally:
podman("rm", "-f", frontend, backend, check=False)
podman("network", "rm", network, check=False)
+2 -2
View File
@@ -104,7 +104,7 @@ if "archipelago-source" not in apps:
source_promotions = [item for item in promotions if item.get("id") == "archipelago-source"]
if not source_promotions:
raise SystemExit("registry candidate omits the Archipelago source promotion")
expected_path = "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy"
expected_path = "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy"
if source_promotions[0].get("path") != expected_path:
raise SystemExit("source promotion does not open the canonical Archipelago repository")
print("registry candidate includes GitWorkshop and its source promotion")
@@ -168,7 +168,7 @@ stage "cargo-check" timeout 580 cargo check --manifest-path core/Cargo.toml
# 3600s leaves headroom; a warm target/ finishes in a fraction of it.
stage "cargo-test-weekly" timeout 3600 env CARGO_INCREMENTAL=0 \
cargo test --manifest-path core/Cargo.toml -p archipelago -- \
update:: lnd container::image_versions scanner drift missing_secret collision
update:: lnd container::image_versions upgrade_preserves_container scanner drift missing_secret collision
# ── Stage 4: live node smoke ─────────────────────────────────────────
if [[ $LIVE -eq 1 ]]; then