Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83abb0485d | ||
|
|
b35409ca74 | ||
|
|
700d39c425 | ||
|
|
4272c47ee5 | ||
|
|
c7cb043485 | ||
|
|
4dfe79290e | ||
|
|
d3e3df6d24 | ||
|
|
969570e38b | ||
|
|
b73d646db5 | ||
|
|
8c37ff412c | ||
|
|
06bf359535 |
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
## 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)
|
## 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.
|
- **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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.14-alpha"
|
version = "1.8.15-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.14-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"
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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 `/`.
|
||||||
Regular → Executable
+4
-136
@@ -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 "$@"
|
|
||||||
|
|||||||
Executable
+56
@@ -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"
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
/patch/repair-nginx.sh
|
||||||
|
nginx -t
|
||||||
|
exec "$@"
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.8.14-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.14-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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.14-alpha",
|
"version": "1.8.15-alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "./start-dev.sh",
|
"start": "./start-dev.sh",
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,17 @@ 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 -->
|
<!-- v1.8.14-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
+17
-17
@@ -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
File diff suppressed because one or more lines are too long
+17
-17
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"changelog": [
|
|
||||||
"**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."
|
|
||||||
],
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"current_version": "1.8.14-alpha",
|
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.14-alpha/archipelago",
|
|
||||||
"name": "archipelago",
|
|
||||||
"new_version": "1.8.14-alpha",
|
|
||||||
"sha256": "3d8e5e7c79a261649e89c4f5ba8d90db9057ebbb002919a812ca82f664b315bf",
|
|
||||||
"size_bytes": 64568360
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"current_version": "1.8.14-alpha",
|
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.14-alpha/archipelago-frontend-1.8.14-alpha.tar.gz",
|
|
||||||
"name": "archipelago-frontend-1.8.14-alpha.tar.gz",
|
|
||||||
"new_version": "1.8.14-alpha",
|
|
||||||
"sha256": "e1c490e52571bf9238435e5986792c6bd602fd7e398783546f7592aa921d3386",
|
|
||||||
"size_bytes": 98792963
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"release_date": "2026-09-13",
|
|
||||||
"signature": "dacfdd2707e415af8a42306a63658a7d41cdfeba29d43802d465d18f00a747ecbfa54ea4ee8ed1eedf94d4f30371453fd9c9d475af9d6a62eac4e2c8e783b405",
|
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
|
||||||
"version": "1.8.14-alpha"
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user