Compare commits

...
21 Commits
Author SHA1 Message Date
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
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
26 changed files with 3661 additions and 3461 deletions
+13
View File
@@ -2,6 +2,19 @@
## Unreleased ## Unreleased
## 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) ## 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. - **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.
+5 -3
View File
@@ -1,7 +1,7 @@
app: app:
id: archy-mempool-web id: archy-mempool-web
name: 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 # 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: # 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. # container.image names our mirror, not the project it was mirrored from.
@@ -12,7 +12,7 @@ app:
container_name: mempool container_name: mempool
container: 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 pull_policy: if-not-present
network: archy-net network: archy-net
@@ -45,7 +45,9 @@ app:
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets # first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop. # "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
endpoint: http://127.0.0.1:8080 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 interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
+2 -2
View File
@@ -1,7 +1,7 @@
app: app:
id: mempool id: mempool
name: Mempool Explorer name: Mempool Explorer
version: 3.0.0 version: 3.3.1-archy1
# Where this app comes from, so scripts/check-upstream-releases.py can # 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: # 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. # 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. description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
container: 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://... image_signature: cosign://...
pull_policy: if-not-present pull_policy: if-not-present
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.8.13-alpha" version = "1.8.15-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.8.13-alpha" version = "1.8.15-alpha"
edition = "2021" edition = "2021"
license.workspace = true license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
+19 -2
View File
@@ -377,6 +377,23 @@ async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
Ok(()) 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)] #[cfg(test)]
mod torrc_tests { mod torrc_tests {
use super::app_hidden_service_port_line; 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 { pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
match name { match name {
"archipelago" => 80, "archipelago" => 80,
"bitcoin" | "bitcoin-knots" => 8333, "bitcoin" | "bitcoin-core" | "bitcoin-knots" => 8333,
"electrs" | "electrumx" => 50001, "electrs" | "electrumx" => 50001,
"lnd" => 8080, "lnd" => 8080,
"btcpay" | "btcpay-server" | "btcpayserver" => 23000, "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 { pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool {
matches!( matches!(
name, name,
"bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd" "bitcoin" | "bitcoin-core" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
) )
} }
@@ -657,9 +657,19 @@ fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
/// Map app_id to Tor hidden service directory name. /// Map app_id to Tor hidden service directory name.
/// "archipelago" is the main web UI (nginx port 80). /// "archipelago" is the main web UI (nginx port 80).
/// Supports container names from deploy (archy-*, btcpay-server, etc.). /// 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> { fn tor_service_name(app_id: &str) -> Option<&'static str> {
match app_id { match app_id {
"archipelago" => Some("archipelago"), "archipelago" => Some("archipelago"),
"bitcoin-core" => Some("bitcoin-core"),
"bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"), "bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"),
"electrumx" | "electrs" | "electrum" => Some("electrumx"), "electrumx" | "electrs" | "electrum" => Some("electrumx"),
"lnd" | "lnd-ui" => Some("lnd"), "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)] #[cfg(test)]
mod extract_lan_address_tests { mod extract_lan_address_tests {
use super::extract_lan_address; use super::extract_lan_address;
+6 -7
View File
@@ -1,14 +1,13 @@
# Archipelago mempool frontend — adds a resilient nginx backend proxy. # Archipelago mempool frontend — adds a resilient nginx backend proxy.
# #
# The only delta vs the upstream image is /patch/entrypoint.sh, which rewrites # Keep the upstream startup logic; repair its rendered proxy configuration.
# the generated nginx-mempool.conf to use `resolver` + a variable proxy_pass so # Publish this derived image under an Archipelago-specific tag, never the
# the frontend re-resolves the backend (mempool-api) via DNS on every request. # upstream version tag that the registry mirror can overwrite.
# Without this, nginx pins the backend IP at startup and serves 502 / "offline" ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend@sha256:d63498a109622475c913db4e3199d893f2440a451450e542923d2e55a38407a0
# after any backend restart (podman reassigns the IP). See the script header.
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.0
FROM ${BASE} FROM ${BASE}
# --chmod keeps the exec bit (build runs as USER 1000, plain COPY lands root:0644 # --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 / # → "not executable"). Base USER/ENTRYPOINT/CMD (1000 / /patch/entrypoint.sh /
# nginx -g "daemon off;") are inherited unchanged. # 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 #!/bin/sh
__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__=${BACKEND_MAINNET_HTTP_HOST:=127.0.0.1} set -eu
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999} # Preserve the pinned upstream entrypoint (including new runtime options).
__MEMPOOL_FRONTEND_HTTP_PORT__=${FRONTEND_HTTP_PORT:=8080} # Apply our DNS repair only after it has rendered the nginx configuration.
exec /patch/upstream-entrypoint.sh /patch/start-nginx.sh "$@"
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 "$@"
+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", "name": "neode-ui",
"version": "1.8.13-alpha", "version": "1.8.15-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.13-alpha", "version": "1.8.15-alpha",
"dependencies": { "dependencies": {
"@scure/bip39": "^2.2.0", "@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.8.13-alpha", "version": "1.8.15-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",
@@ -95,7 +95,7 @@ describe('useAppLauncherStore', () => {
const store = useAppLauncherStore() const store = useAppLauncherStore()
store.openSession('archipelago-source') store.openSession('archipelago-source')
expect(openInApp).toHaveBeenCalledWith( 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.panelAppId).toBeNull()
expect(store.isOpen).toBe(false) expect(store.isOpen).toBe(false)
@@ -78,12 +78,11 @@ describe('appsConfig service filtering', () => {
it('shows Cuprate as one My Apps entry while hiding its daemon dependency', () => { it('shows Cuprate as one My Apps entry while hiding its daemon dependency', () => {
const entries: Array<[string, PackageDataEntry]> = [ const entries: Array<[string, PackageDataEntry]> = [
['cuprate-ui', makePkg('cuprate-ui', 'Cuprate UI', 'money')], ['cuprate', makePkg('cuprate', 'Cuprate', 'money')],
['cuprate', makePkg('cuprate', 'Cuprate daemon', 'money')], ['archy-cuprate-ui', makePkg('archy-cuprate-ui', 'Cuprate UI companion', 'money')],
] ]
;(entries[0][1].manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'http://localhost:18091' } } expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['cuprate'])
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['cuprate-ui']) expect(filterEntriesForTab(entries, 'services', 'all').map(([id]) => id)).toEqual([])
expect(filterEntriesForTab(entries, 'services', 'all').map(([id]) => id)).toEqual(['cuprate'])
}) })
it('falls back to packaged app icon when static icon token is not a path', () => { it('falls back to packaged app icon when static icon token is not a path', () => {
+5 -1
View File
@@ -20,6 +20,10 @@ export const isServiceContainer = sharedIsServiceContainer
const INTERNAL_TOOLING_NAMES = new Set([ const INTERNAL_TOOLING_NAMES = new Set([
'buildx_buildkit_default', '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 { 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) // Known app -> category mappings (matches App Store categorisation)
export const APP_CATEGORY_MAP: Record<string, string> = { export const APP_CATEGORY_MAP: Record<string, string> = {
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'cuprate-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', 'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
'fedimint': 'money', 'fedimint-gateway': 'money', 'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media', 'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
+4 -2
View File
@@ -14,8 +14,10 @@
// SERVICE_NAMES set that used to live in appsConfig.ts verbatim. // SERVICE_NAMES set that used to live in appsConfig.ts verbatim.
export const SERVICE_NAMES = new Set([ export const SERVICE_NAMES = new Set([
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor', 'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
// Cuprate's daemon is the backend dependency of the Cuprate UI app. // Cuprate is presented as one user-facing app. Its companion container
'cuprate', // (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, // 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 // the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
// built-in Mesh tab) belong in Services, not My Apps. // built-in Mesh tab) belong in Services, not My Apps.
@@ -362,6 +362,30 @@ init()
</button> </button>
</div> </div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1"> <div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- 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>
</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 --> <!-- v1.8.13-alpha -->
<div> <div>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
@@ -369,7 +393,9 @@ init()
<span class="text-xs text-white/40">September 12, 2026</span> <span class="text-xs text-white/40">September 12, 2026</span>
</div> </div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10"> <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>
</div> </div>
<!-- v1.8.12-alpha --> <!-- v1.8.12-alpha -->
+17 -17
View File
@@ -1,29 +1,29 @@
{ {
"changelog": [ "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.", "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.",
"**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.", "Added regression coverage for Cuprate install and installed-state grouping.",
"**Curated app classification is regression-tested.** Every user-facing app remains in My Apps during installation, while headless services stay in Services." "Release validation was rerun on the corrected tree before OTA and ISO publication."
], ],
"components": [ "components": [
{ {
"current_version": "1.8.13-alpha", "current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-alpha/archipelago", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.8.13-alpha", "new_version": "1.8.15-alpha",
"sha256": "832c7e75b395f94f919a21d1ad7d32d367e1ef84a0e0995b4e8fe87268aa21a4", "sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
"size_bytes": 64585424 "size_bytes": 64571544
}, },
{ {
"current_version": "1.8.13-alpha", "current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-alpha/archipelago-frontend-1.8.13-alpha.tar.gz", "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.13-alpha.tar.gz", "name": "archipelago-frontend-1.8.15-alpha.tar.gz",
"new_version": "1.8.13-alpha", "new_version": "1.8.15-alpha",
"sha256": "d0159b61f84eb30013634a97177801474376dddb1189024b94f16236ce7ff538", "sha256": "86a32ef3334b03c197e47d9d28f4435c6f2fa7c4ccacc839a8fc4a0a749495dc",
"size_bytes": 97915796 "size_bytes": 98797600
} }
], ],
"release_date": "2026-09-12", "release_date": "2026-09-13",
"signature": "45a322e793a2fc7565cc148f14efbbcf03e0c934183590375368091368ffe49ed2a7e9962692caf54b3432c71976a6c1b0f06a48cf8c4634c7b2e573e5782a0f", "signature": "5e13396e2f33571f136bb1a9ea0356486b3d42c6ba99ee5d33990cd565d9b1178f61eaf6a70a937a006e7de3fd388bcebd63f2daca4bb28accc1b810d8455d03",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.13-alpha" "version": "1.8.15-alpha"
} }
+3251 -3249
View File
File diff suppressed because one or more lines are too long
+17 -17
View File
@@ -1,29 +1,29 @@
{ {
"changelog": [ "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.", "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.",
"**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.", "Added regression coverage for Cuprate install and installed-state grouping.",
"**Curated app classification is regression-tested.** Every user-facing app remains in My Apps during installation, while headless services stay in Services." "Release validation was rerun on the corrected tree before OTA and ISO publication."
], ],
"components": [ "components": [
{ {
"current_version": "1.8.13-alpha", "current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-alpha/archipelago", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.8.13-alpha", "new_version": "1.8.15-alpha",
"sha256": "832c7e75b395f94f919a21d1ad7d32d367e1ef84a0e0995b4e8fe87268aa21a4", "sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
"size_bytes": 64585424 "size_bytes": 64571544
}, },
{ {
"current_version": "1.8.13-alpha", "current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-alpha/archipelago-frontend-1.8.13-alpha.tar.gz", "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.13-alpha.tar.gz", "name": "archipelago-frontend-1.8.15-alpha.tar.gz",
"new_version": "1.8.13-alpha", "new_version": "1.8.15-alpha",
"sha256": "d0159b61f84eb30013634a97177801474376dddb1189024b94f16236ce7ff538", "sha256": "86a32ef3334b03c197e47d9d28f4435c6f2fa7c4ccacc839a8fc4a0a749495dc",
"size_bytes": 97915796 "size_bytes": 98797600
} }
], ],
"release_date": "2026-09-12", "release_date": "2026-09-13",
"signature": "45a322e793a2fc7565cc148f14efbbcf03e0c934183590375368091368ffe49ed2a7e9962692caf54b3432c71976a6c1b0f06a48cf8c4634c7b2e573e5782a0f", "signature": "5e13396e2f33571f136bb1a9ea0356486b3d42c6ba99ee5d33990cd565d9b1178f61eaf6a70a937a006e7de3fd388bcebd63f2daca4bb28accc1b810d8455d03",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.13-alpha" "version": "1.8.15-alpha"
} }
+2 -1
View File
@@ -33,7 +33,8 @@ ELECTRUMX_IMAGE="$ARCHY_REGISTRY/electrumx:v1.18.0"
# Mempool stack # Mempool stack
MEMPOOL_BACKEND_IMAGE="$ARCHY_REGISTRY/mempool-backend:v3.3.1" 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="${MEMPOOL_WEB_IMAGE:-source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1}"
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10" MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
# BTCPay # BTCPay
+20 -11
View File
@@ -53,24 +53,33 @@ if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
fi fi
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE") remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
# https is accepted as well as http. Requiring http:// meant the only remote # Remote URLs are public metadata: ngit can include them in repository
# whose credential actually works for git push (the https one) was rejected, # announcements. Keep credentials in Git's credential helper, never in URLs.
# 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.
case "$remote_url" in case "$remote_url" in
http://*@*|https://*@*) ;; http://*@*|https://*@*) fail "$REMOTE embeds credentials; move them to a Git credential helper and remove them from the remote URL" ;;
*) fail "$REMOTE must be an authenticated http(s):// Gitea remote URL for API uploads" ;; http://*|https://*) ;;
*) fail "$REMOTE must be an http(s):// Gitea remote URL for API uploads" ;;
esac esac
scheme=${remote_url%%://*} scheme=${remote_url%%://*}
rest=${remote_url#*://} host_path=${remote_url#*://}
auth=${rest%%@*}
host_path=${rest#*@}
host=${host_path%%/*} host=${host_path%%/*}
repo_path=${host_path#*/} repo_path=${host_path#*/}
repo_path=${repo_path%.git} 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" api="$scheme://$host/api/v1/repos/$repo_path"
release_url="$api/releases/tags/v${VERSION}" 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)
+1 -1
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"] source_promotions = [item for item in promotions if item.get("id") == "archipelago-source"]
if not source_promotions: if not source_promotions:
raise SystemExit("registry candidate omits the Archipelago source promotion") 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: if source_promotions[0].get("path") != expected_path:
raise SystemExit("source promotion does not open the canonical Archipelago repository") raise SystemExit("source promotion does not open the canonical Archipelago repository")
print("registry candidate includes GitWorkshop and its source promotion") print("registry candidate includes GitWorkshop and its source promotion")