Compare commits
100
Commits
cb3f7e8720
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02b840f2d1 | ||
|
|
f992780957 | ||
|
|
c82c1eee98 | ||
|
|
2992443d5d | ||
|
|
259c353147 | ||
|
|
1724ea05d1 | ||
|
|
c1e20a71ae | ||
|
|
bf56956790 | ||
|
|
2f1a3ade07 | ||
|
|
ef8254272c | ||
|
|
d50be13232 | ||
|
|
439b55a236 | ||
|
|
5ab65f7581 | ||
|
|
169bf77de6 | ||
|
|
7c4169867c | ||
|
|
acf544500f | ||
|
|
7d767c8cb0 | ||
|
|
eb3ccfa00b | ||
|
|
eda28c4cd6 | ||
|
|
d69e845216 | ||
|
|
dc962c53b0 | ||
|
|
6ac26f637c | ||
|
|
27d81e956d | ||
|
|
eb39391223 | ||
|
|
b02ba4100d | ||
|
|
3daea6623b | ||
|
|
d42f448e31 | ||
|
|
1566f1bb00 | ||
|
|
0677924a64 | ||
|
|
971d477795 | ||
|
|
f12042f194 | ||
|
|
e7cf336665 | ||
|
|
8ca20de82e | ||
|
|
1fa654cb6a | ||
|
|
c993d9dd0d | ||
|
|
33d2b3ce60 | ||
|
|
c7ce35bd43 | ||
|
|
ad1d71a462 | ||
|
|
33477f284b | ||
|
|
03e38d1ca3 | ||
|
|
bded929812 | ||
|
|
3612458e86 | ||
|
|
8d9fad1749 | ||
|
|
d25ed492c9 | ||
|
|
1f9abefc35 | ||
|
|
b634f41a1c | ||
|
|
0f85f588fb | ||
|
|
e5fc99d66c | ||
|
|
8b74803290 | ||
|
|
540639d2c1 | ||
|
|
562871b1ce | ||
|
|
cca3f8bfcd | ||
|
|
89c08be712 | ||
|
|
b4ecf86c13 | ||
|
|
b14fe78306 | ||
|
|
3f0c1038c3 | ||
|
|
1fbefce6df | ||
|
|
63cb68451a | ||
|
|
17cfebbe26 | ||
|
|
379fb930fc | ||
|
|
1bebdeac0f | ||
|
|
f458591132 | ||
|
|
6155539254 | ||
|
|
76e0f1f3b6 | ||
|
|
ba6ce2cdb6 | ||
|
|
8212049f57 | ||
|
|
5814f47659 | ||
|
|
94f5e892c3 | ||
|
|
a3b6467047 | ||
|
|
66db6497ec | ||
|
|
81be17f09f | ||
|
|
4237fb5e79 | ||
|
|
4302138b4f | ||
|
|
3b9b74dae5 | ||
|
|
4021c1f496 | ||
|
|
5f8de584bc | ||
|
|
38de1b3310 | ||
|
|
abfbccc906 | ||
|
|
9d4e74e094 | ||
|
|
db355b759c | ||
|
|
31d77f01ac | ||
|
|
1b0ed281b2 | ||
|
|
9c6580f5c0 | ||
|
|
83abb0485d | ||
|
|
b35409ca74 | ||
|
|
700d39c425 | ||
|
|
4272c47ee5 | ||
|
|
c7cb043485 | ||
|
|
4dfe79290e | ||
|
|
d3e3df6d24 | ||
|
|
969570e38b | ||
|
|
b73d646db5 | ||
|
|
8c37ff412c | ||
|
|
06bf359535 | ||
|
|
a4f3415f0f | ||
|
|
c9c9ebe6d4 | ||
|
|
100993445b | ||
|
|
a4f80e7ec1 | ||
|
|
4ad34d3a0a | ||
|
|
c9bae926a5 |
@@ -0,0 +1,31 @@
|
||||
# Blocking incident: Framework LND startup and false zero balance
|
||||
|
||||
Before starting or resuming work in this repository, read
|
||||
`docs/incident-framework-lnd-startup.md` and check its status.
|
||||
|
||||
The user explicitly required this incident to take priority over later work.
|
||||
While its status is OPEN:
|
||||
|
||||
- Surface this blocker at session start, including when the user asks for unrelated work.
|
||||
- Prioritize investigation and repair on the actual Framework node. If access is
|
||||
unavailable, ask for it and continue useful offline investigation; defer unrelated
|
||||
work unless the user explicitly overrides this priority.
|
||||
- A manual LND restart, a source patch, passing local tests, or publishing an OTA
|
||||
does not resolve the incident. Do not mark it fixed until the Framework's startup,
|
||||
Receive flow, and balance behavior are verified on the node, including a controlled
|
||||
reboot with access and recovery arrangements in place.
|
||||
- Preserve wallet identity, wallet/channel databases, credentials, and backups.
|
||||
Never run wallet wipe/recreation as an automatic investigation or recovery step.
|
||||
- Record evidence, changes, validation, and remaining work in the incident document.
|
||||
|
||||
This priority comes from the user's explicit instruction on 2026-09-15. It remains
|
||||
in effect across sessions until the documented acceptance criteria are met or the
|
||||
user explicitly changes it.
|
||||
|
||||
## Unit tests on a live node
|
||||
|
||||
Run backend unit tests through `scripts/test-backend-isolated.sh`. Do not run
|
||||
unrestricted `cargo test` on a node with installed apps: older mocked-runtime
|
||||
tests still reached real service commands. The runner isolates wallet data,
|
||||
service buses, container storage, networking, and process IDs. Compilation with
|
||||
`cargo test --no-run` is safe. Keep separately authorized live checks explicit.
|
||||
+80
-1
@@ -1,6 +1,85 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## v1.8.22-alpha (2026-09-30)
|
||||
|
||||
- Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.
|
||||
|
||||
- Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.
|
||||
- Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.
|
||||
|
||||
- Checked Bitcoin and Electrum companion dashboards instead of backend protocol ports, preserving dashboard access during initial sync.
|
||||
- Removed web-interface waiting messages from headless services such as Phoenixd and clarified which interface is unavailable for launchable apps.
|
||||
|
||||
- Finished runtime app-file promotion before manifest loading, preventing startup catalog refresh from forgetting disk-only apps.
|
||||
|
||||
- Named the app in compact readiness messages and kept app-card actions aligned at the bottom.
|
||||
- Removed duplicate Mempool cards caused by frontend container aliases in restored inventory.
|
||||
|
||||
- Kept installed apps visible through restarts and hard refreshes, and delayed app launches until their web interface is ready.
|
||||
- Made Bitcoin version selection readable and usable in the ThinkPad kiosk, above the pruning settings.
|
||||
- Restored GitWorkshop build files in installation/update payloads and made slow image-pull progress clearer.
|
||||
- Fixed same-node Gitea access from Portainer, with persistent runtime migration, state backups and recovery after failed restarts.
|
||||
- Preserved Gitea configuration and SSH operation during fresh setup and upgrades.
|
||||
- Improved paid-file delivery, saved-file permissions and repeat-download compatibility; verified Tor-only payment with change, rejection refunds and free repeat downloads.
|
||||
- Added a headless Angor Indexer service using the existing Mempool/ElectrumX stack, and an optional separate Angor relay.
|
||||
- Prevented manifest command arguments containing apostrophes from being corrupted in generated services.
|
||||
|
||||
## v1.8.21-alpha (2026-09-30)
|
||||
|
||||
- Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.
|
||||
- Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.
|
||||
- Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.
|
||||
- Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.
|
||||
|
||||
## v1.8.20-alpha (2026-09-29)
|
||||
|
||||
- Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.
|
||||
- Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging.
|
||||
- Improved saving paid files into Files and reopening purchases without paying again.
|
||||
- Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning.
|
||||
- Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts.
|
||||
- LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure.
|
||||
- Bitcoin startup messages explain block-index loading without exposing raw RPC errors, and Lightning keeps known balances clearly marked during outages.
|
||||
- Changed the public transaction-explorer default to mempool.space while preserving local explorers and custom choices.
|
||||
|
||||
## v1.8.19-alpha (2026-09-28)
|
||||
|
||||
- Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.
|
||||
- Embedded AIUI now stays transparent so the dashboard background appears once.
|
||||
- AIUI background fixes are now included reliably in OTA updates and fresh installations.
|
||||
|
||||
## v1.8.18-alpha (2026-09-18)
|
||||
|
||||
- Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.
|
||||
- Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.
|
||||
- Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.
|
||||
|
||||
## v1.8.17-alpha (2026-09-15)
|
||||
|
||||
- Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.
|
||||
- Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.
|
||||
- Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.
|
||||
- Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs.
|
||||
|
||||
## v1.8.16-alpha (2026-09-15)
|
||||
|
||||
- App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.
|
||||
- Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.
|
||||
- Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.
|
||||
- Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.
|
||||
|
||||
## v1.8.15-alpha (2026-09-13)
|
||||
|
||||
- Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.
|
||||
- Added regression coverage for Cuprate install and installed-state grouping.
|
||||
- Release validation was rerun on the corrected tree before OTA and ISO publication.
|
||||
|
||||
## v1.8.14-alpha (2026-09-13)
|
||||
|
||||
- **Cuprate gains a first-party companion dashboard.** The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.
|
||||
- **Bitcoin Core Tor enrollment uses the correct protocol identity.** `bitcoin-core` is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.
|
||||
- **GitWorkshop opens Archipelago’s canonical ngit repository by default.** The launcher and registry promotion use the full maintainer/relay/`archy` coordinate, with regression coverage for Companion and browser-tab launches.
|
||||
- **Release validation is stricter.** The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites.
|
||||
|
||||
## v1.8.13-alpha (2026-09-12)
|
||||
|
||||
|
||||
@@ -46,13 +46,14 @@ interface RateBucket {
|
||||
|
||||
const rateBuckets = new Map<string, RateBucket>()
|
||||
|
||||
// Clean up stale buckets every 5 minutes
|
||||
// Vite imports this module during builds too; cleanup must not keep the
|
||||
// process alive once compilation has finished.
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, bucket] of rateBuckets) {
|
||||
if (now > bucket.resetAt) rateBuckets.delete(key)
|
||||
}
|
||||
}, 5 * 60_000)
|
||||
}, 5 * 60_000).unref()
|
||||
|
||||
function getClientIp(req: IncomingMessage): string {
|
||||
return req.socket.remoteAddress ?? 'unknown'
|
||||
|
||||
@@ -33,6 +33,7 @@ const PWA_CACHE_VERSION = '2'
|
||||
// Only embedded when explicitly requested via ?embedded param
|
||||
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
|
||||
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
|
||||
document.documentElement.classList.toggle('aiui-embedded', _embeddedFlag)
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div
|
||||
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
|
||||
:class="[]"
|
||||
:style="isDark
|
||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||
: isEmbedded
|
||||
? { background: 'transparent' }
|
||||
:style="isEmbedded
|
||||
? { background: 'transparent' }
|
||||
: isDark
|
||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||
: { backgroundColor: '#f5f4f1' }"
|
||||
>
|
||||
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||
<div v-if="isDark && !isEmbedded" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||
|
||||
<!-- Desktop layout -->
|
||||
<div
|
||||
|
||||
@@ -57,12 +57,8 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
/* Every page paints its own explicit background (bg-[#0a0a0a] / bg-[#faf9f6])
|
||||
EXCEPT the embedded Chat page, which intentionally goes transparent so
|
||||
Archy's own dark chrome can show behind it (Chat.vue's iframe host). With
|
||||
no background-color here, "transparent" fell through to the browser's
|
||||
default white canvas instead. Match the theme's own dark/light default so
|
||||
nothing above this ever needs to guess. */
|
||||
/* Standalone canvas fallback. Embedded mode overrides this below so
|
||||
Archy's wallpaper remains visible through the iframe. */
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
@@ -70,6 +66,19 @@ html.light body {
|
||||
background-color: #faf9f6;
|
||||
}
|
||||
|
||||
/* The host owns the wallpaper when AIUI is embedded. The document canvas
|
||||
must be transparent too, otherwise it hides the host behind ChatPage. */
|
||||
html.aiui-embedded {
|
||||
/* Match Archy's dark canvas scheme. Browsers otherwise give an iframe
|
||||
with a different scheme an opaque canvas despite transparent CSS. */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.aiui-embedded,
|
||||
html.aiui-embedded body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
|
||||
|
||||
@layer components {
|
||||
|
||||
@@ -378,13 +378,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
@@ -644,6 +644,35 @@
|
||||
"/var/lib/archipelago/vaultwarden:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "angor-indexer",
|
||||
"title": "Angor Indexer",
|
||||
"version": "1.0.1",
|
||||
"description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1",
|
||||
"author": "Angor / Archipelago",
|
||||
"requires": [
|
||||
"Mempool API",
|
||||
"Unpruned Bitcoin"
|
||||
],
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"icon": "/assets/img/app-icons/angor-green.png",
|
||||
"repoUrl": "https://github.com/block-core/angor"
|
||||
},
|
||||
{
|
||||
"id": "angor-relay",
|
||||
"title": "Angor Relay",
|
||||
"version": "1.1.2",
|
||||
"description": "Optional dedicated Nostr relay for Angor project metadata. Separate storage and access settings keep the node’s internal relay private. Add this service’s address to Angor’s relay settings; use WSS for browser clients.",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/angor-relay:1.1.2",
|
||||
"author": "Angor / Archipelago",
|
||||
"requires": [],
|
||||
"category": "nostr",
|
||||
"tier": "optional",
|
||||
"icon": "/assets/img/app-icons/angor-green.png",
|
||||
"repoUrl": "https://github.com/hoytech/strfry"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Angor Indexer
|
||||
|
||||
Headless mainnet API endpoint for Angor. The service reuses this node's Mempool
|
||||
backend and Electrum index instead of creating a second blockchain database.
|
||||
An unpruned, fully synced Bitcoin node is required. Installing against a pruned
|
||||
node must show the existing archival-node requirement; it must never silently
|
||||
unprune or replace its Bitcoin data.
|
||||
|
||||
## Connect Angor
|
||||
|
||||
Install **Angor Indexer** in the store. Its API appears under **Services**.
|
||||
In Angor settings, use `http://<node-address>:8998/` as the custom indexer origin.
|
||||
The `/health` endpoint reports readiness against Mempool's indexed block height;
|
||||
it returns 503 while that backend is unavailable. Index building may take time.
|
||||
|
||||
Browser clients require a reachable HTTPS origin with a trusted certificate.
|
||||
Configure your HTTPS reverse proxy to forward to port 8998, then use that HTTPS
|
||||
origin in Angor. Do not disable browser TLS checks. The API supports both
|
||||
`/api/v1/` and `/api/` paths, transaction broadcast, and CORS without cookies.
|
||||
|
||||
This endpoint intentionally exposes public blockchain queries and transaction
|
||||
broadcast through the app gate without dashboard-cookie login. It has no Bitcoin
|
||||
RPC password, wallet keys, or persistent wallet data. The backend stays on the
|
||||
managed container network; its private port does not become publicly exposed.
|
||||
You can change network access using the node's normal access controls.
|
||||
|
||||
## Relay
|
||||
|
||||
A relay is optional. Angor can continue using its configured external relays.
|
||||
Install **Angor Relay** separately to host project metadata locally, then add
|
||||
`ws://<node-address>:8091/` in Angor, or a trusted `wss://` proxy origin for browser
|
||||
clients. Its storage and configuration are separate from the node's internal
|
||||
relay; installing or uninstalling it does not change the internal relay.
|
||||
|
||||
## Packaging
|
||||
|
||||
Build the pinned image with:
|
||||
|
||||
```
|
||||
podman build -t source.archipelago-foundation.org/chaum/angor-indexer:1.0.1 apps/angor-indexer/container
|
||||
```
|
||||
|
||||
The image runs as UID 101 with a read-only root filesystem and no capabilities.
|
||||
Only temporary nginx state is writable. Runtime DNS is read from resolv.conf so
|
||||
Mempool recreation does not require editing IP addresses or restarting this app.
|
||||
No app-specific Rust installer is required.
|
||||
|
||||
Source documentation: [Angor's official deployment guide](https://github.com/block-core/angor/blob/869dd43cf38332dd7128a284a6bf4c1cac44c1a7/docker/DEPLOY-INDEXER-AND-RELAY.md).
|
||||
The app icon is based on [Angor’s dark-mode app icon](https://angor.io/images/app-icon-dark-mode.png), retrieved 2026-09-30. At the operator’s request, the outer corners use the same green as the background. The built-in imagegen edit preserved the black mark and filled the square green; the project asset is `neode-ui/public/assets/img/app-icons/angor-green.png`.
|
||||
|
||||
Tests and release acceptance are recorded in the next-release checklist. The
|
||||
health probe establishes backend availability, not a guarantee that every
|
||||
address query is indexed at the latest Bitcoin tip.
|
||||
|
||||
Install Mempool Explorer first. The declarative `install_prerequisites` check
|
||||
refuses a new adapter installation if its Mempool API component is absent, before
|
||||
creating an installed-app record. It does not install or resync Bitcoin for you.
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM docker.io/library/nginx:1.31.3-alpine@sha256:1d40e3eb3bf4f138de1d67193f2aa5309fcaf343eb5ffadbf5e9439de1eb1ebb
|
||||
COPY nginx.conf /etc/angor-nginx.conf.template
|
||||
COPY entrypoint.sh /usr/local/bin/angor-indexer
|
||||
USER 101:101
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/angor-indexer"]
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
# Resolve through the container runtime's DNS, including after dependency
|
||||
# recreation. Never bake a container IP into the indexer endpoint.
|
||||
DNS_RESOLVER=$(awk '/^nameserver[[:space:]]/ {print $2; exit}' /etc/resolv.conf)
|
||||
case "$DNS_RESOLVER" in
|
||||
''|*[!0-9a-fA-F.:]*) echo 'Container DNS resolver is unavailable' >&2; exit 1 ;;
|
||||
esac
|
||||
case "$DNS_RESOLVER" in *:*) DNS_RESOLVER="[$DNS_RESOLVER]" ;; esac
|
||||
export DNS_RESOLVER
|
||||
envsubst '${DNS_RESOLVER}' < /etc/angor-nginx.conf.template > /tmp/nginx.conf
|
||||
exec nginx -c /tmp/nginx.conf -g 'daemon off;'
|
||||
@@ -0,0 +1,63 @@
|
||||
worker_processes 1;
|
||||
pid /tmp/nginx.pid;
|
||||
error_log /dev/stderr warn;
|
||||
events { worker_connections 512; }
|
||||
http {
|
||||
access_log off;
|
||||
server_tokens off;
|
||||
client_body_temp_path /tmp/client_temp;
|
||||
proxy_temp_path /tmp/proxy_temp;
|
||||
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||
scgi_temp_path /tmp/scgi_temp;
|
||||
resolver ${DNS_RESOLVER} valid=10s ipv6=off;
|
||||
upstream mempool_backend {
|
||||
zone mempool_backend 64k;
|
||||
server mempool-api:8999 resolve;
|
||||
}
|
||||
server {
|
||||
listen 8080;
|
||||
client_max_body_size 4m;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Authorization "";
|
||||
proxy_set_header Cookie "";
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
add_header Access-Control-Allow-Origin '*' always;
|
||||
add_header Access-Control-Allow-Methods 'GET, HEAD, POST, OPTIONS' always;
|
||||
add_header Access-Control-Allow-Headers 'Content-Type' always;
|
||||
add_header Cache-Control 'no-store' always;
|
||||
if ($request_method = OPTIONS) { return 204; }
|
||||
# Mempool's backend uses /api/v1. Match its frontend's shorter /api
|
||||
# surface too, without doubling already-versioned Angor URLs.
|
||||
rewrite ^/api/(?!v1/)(.*)$ /api/v1/$1 last;
|
||||
location = / {
|
||||
default_type application/json;
|
||||
return 200 '{"service":"Angor Indexer","network":"mainnet","api":"/api/v1","health":"/health"}\n';
|
||||
}
|
||||
# Readiness checks the indexing backend, not this gateway's process.
|
||||
location = /health {
|
||||
limit_except GET { deny all; }
|
||||
proxy_pass http://mempool_backend/api/v1/blocks/tip/height;
|
||||
proxy_intercept_errors on;
|
||||
error_page 500 502 503 504 =503 @waiting;
|
||||
}
|
||||
location @waiting {
|
||||
default_type application/json;
|
||||
return 503 '{"status":"waiting","message":"Waiting for Bitcoin and Mempool indexing"}\n';
|
||||
}
|
||||
location ~ ^/api/(v1/)?tx$ {
|
||||
limit_except GET POST { deny all; }
|
||||
proxy_pass http://mempool_backend;
|
||||
}
|
||||
location /api/ {
|
||||
limit_except GET { deny all; }
|
||||
proxy_pass http://mempool_backend;
|
||||
}
|
||||
location / { return 404; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
app:
|
||||
id: angor-indexer
|
||||
name: Angor Indexer
|
||||
version: 1.0.1
|
||||
description: Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool
|
||||
and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s
|
||||
address as the custom indexer in Angor settings. A relay is optional and installed
|
||||
separately.
|
||||
category: money
|
||||
install_prerequisites:
|
||||
- mempool-api
|
||||
upstream:
|
||||
kind: github
|
||||
repo: block-core/angor
|
||||
container:
|
||||
image: source.archipelago-foundation.org/chaum/angor-indexer:1.0.1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
dependencies:
|
||||
- app_id: mempool-api
|
||||
version: '>=3.0.0'
|
||||
- bitcoin:archival
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 128Mi
|
||||
disk_limit: 128Mi
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 101
|
||||
network_policy: isolated
|
||||
ports:
|
||||
- host: 8998
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: open
|
||||
auth_rationale: Public Bitcoin chain-data API and validated transaction broadcast for Angor clients; no wallet keys or node RPC credentials are exposed. Browser cookie login would break machine clients.
|
||||
interfaces:
|
||||
main:
|
||||
name: Angor Indexer API
|
||||
description: Use this origin as Angor’s custom mainnet indexer URL. HTTPS is
|
||||
required for browser clients.
|
||||
type: api
|
||||
port: 8998
|
||||
protocol: http
|
||||
path: /
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 8s
|
||||
retries: 3
|
||||
bitcoin_integration:
|
||||
rpc_access: none
|
||||
sync_required: true
|
||||
pruning_support: false
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/angor-green.png
|
||||
tier: optional
|
||||
repo: https://github.com/block-core/angor
|
||||
features:
|
||||
- Angor mainnet API
|
||||
- Reuses existing Mempool indexing
|
||||
- No separate blockchain database
|
||||
- Optional independent relay
|
||||
@@ -0,0 +1,21 @@
|
||||
# Angor Relay
|
||||
|
||||
Optional standalone strfry relay for Angor's public project metadata. See
|
||||
[Angor Indexer setup](../angor-indexer/README.md) for client URLs and HTTPS/WSS.
|
||||
|
||||
The gate exposes port 8091 for Nostr clients. strfry validates event signatures;
|
||||
this is a public relay, not a private messaging archive. It mounts only
|
||||
`/var/lib/archipelago/angor-relay` and its separate configuration directory.
|
||||
It never opens, reconfigures or shares the node's internal strfry database.
|
||||
|
||||
The configuration is seeded only when absent, preserving operator changes.
|
||||
Stop the service before making a consistent backup of its event database.
|
||||
Ordinary start/restart/recreation preserves both mounts. Use the standard app
|
||||
lifecycle; do not manually recreate a systemd-managed container.
|
||||
|
||||
## Image provenance
|
||||
|
||||
Mirrored from `docker.io/dockurr/strfry:1.1.2`, upstream manifest digest
|
||||
`sha256:e81d238db13507f6ef24c49d47cd0b0ea58ff207961f10581fa2a7c901054df4`.
|
||||
The public Angor policy is supplied by this app's own configuration; it does not
|
||||
reuse the internal relay's event whitelist.
|
||||
@@ -0,0 +1,223 @@
|
||||
app:
|
||||
id: angor-relay
|
||||
name: Angor Relay
|
||||
version: 1.1.2
|
||||
upstream:
|
||||
kind: github
|
||||
repo: hoytech/strfry
|
||||
description: Optional dedicated Nostr relay for Angor project metadata. Separate
|
||||
storage and access settings keep the node’s internal relay private. Add this service’s
|
||||
address to Angor’s relay settings; use WSS for browser clients.
|
||||
container:
|
||||
image: source.archipelago-foundation.org/chaum/angor-relay:1.1.2
|
||||
pull_policy: if-not-present
|
||||
dependencies:
|
||||
- storage: 5Gi
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 5Gi
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: nostr-relay
|
||||
ports:
|
||||
- host: 8091
|
||||
container: 7777
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: open
|
||||
auth_rationale: Dedicated public Nostr relay for Angor project metadata; strfry verifies event signatures. It has separate storage from the private node relay and no wallet or node credentials.
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/angor-relay
|
||||
target: /app/strfry-db
|
||||
options:
|
||||
- rw
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/angor-relay-config/angor-relay.conf
|
||||
target: /etc/strfry.conf
|
||||
options:
|
||||
- ro
|
||||
files:
|
||||
- path: /var/lib/archipelago/angor-relay-config/angor-relay.conf
|
||||
overwrite: false
|
||||
content: |
|
||||
##
|
||||
## Default strfry config
|
||||
##
|
||||
|
||||
# Directory that contains the strfry LMDB database (restart required)
|
||||
db = "./strfry-db/"
|
||||
|
||||
dbParams {
|
||||
# Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required)
|
||||
maxreaders = 256
|
||||
|
||||
# Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required)
|
||||
mapsize = 10995116277760
|
||||
|
||||
# Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required)
|
||||
noReadAhead = false
|
||||
}
|
||||
|
||||
events {
|
||||
# Maximum size of normalised JSON, in bytes
|
||||
maxEventSize = 65536
|
||||
|
||||
# Events newer than this will be rejected
|
||||
rejectEventsNewerThanSeconds = 900
|
||||
|
||||
# Events older than this will be rejected
|
||||
rejectEventsOlderThanSeconds = 94608000
|
||||
|
||||
# Ephemeral events older than this will be rejected
|
||||
rejectEphemeralEventsOlderThanSeconds = 60
|
||||
|
||||
# Ephemeral events will be deleted from the DB when older than this
|
||||
ephemeralEventsLifetimeSeconds = 300
|
||||
|
||||
# Maximum number of tags allowed
|
||||
maxNumTags = 2000
|
||||
|
||||
# Maximum size for tag values, in bytes
|
||||
maxTagValSize = 1024
|
||||
}
|
||||
|
||||
relay {
|
||||
# Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required)
|
||||
bind = "0.0.0.0"
|
||||
|
||||
# Port to open for the nostr websocket protocol (restart required)
|
||||
port = 7777
|
||||
|
||||
# Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)
|
||||
nofiles = 0
|
||||
|
||||
# HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)
|
||||
realIpHeader = ""
|
||||
|
||||
info {
|
||||
# NIP-11: Name of this server. Short/descriptive (< 30 characters)
|
||||
name = "Angor Relay"
|
||||
|
||||
# NIP-11: Detailed information about relay, free-form
|
||||
description = "Dedicated public relay for Angor project metadata."
|
||||
|
||||
# NIP-11: Administrative nostr pubkey, for contact purposes
|
||||
pubkey = ""
|
||||
|
||||
# NIP-11: Alternative administrative contact (email, website, etc)
|
||||
contact = ""
|
||||
|
||||
# NIP-11: URL pointing to an image to be used as an icon for the relay
|
||||
icon = ""
|
||||
|
||||
# List of supported lists as JSON array, or empty string to use default. Example: "[1,2]"
|
||||
nips = ""
|
||||
}
|
||||
|
||||
# Maximum accepted incoming websocket frame size (should be larger than max event) (restart required)
|
||||
maxWebsocketPayloadSize = 131072
|
||||
|
||||
# Maximum number of filters allowed in a REQ
|
||||
maxReqFilterSize = 200
|
||||
|
||||
# Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required)
|
||||
autoPingSeconds = 55
|
||||
|
||||
# If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy)
|
||||
enableTcpKeepalive = false
|
||||
|
||||
# How much uninterrupted CPU time a REQ query should get during its DB scan
|
||||
queryTimesliceBudgetMicroseconds = 10000
|
||||
|
||||
# Maximum records that can be returned per filter
|
||||
maxFilterLimit = 500
|
||||
|
||||
# Maximum number of subscriptions (concurrent REQs) a connection can have open at any time
|
||||
maxSubsPerConnection = 20
|
||||
|
||||
writePolicy {
|
||||
# If non-empty, path to an executable script that implements the writePolicy plugin logic
|
||||
plugin = ""
|
||||
}
|
||||
|
||||
compression {
|
||||
# Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required)
|
||||
enabled = true
|
||||
|
||||
# Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required)
|
||||
slidingWindow = true
|
||||
}
|
||||
|
||||
logging {
|
||||
# Dump all incoming messages
|
||||
dumpInAll = false
|
||||
|
||||
# Dump all incoming EVENT messages
|
||||
dumpInEvents = false
|
||||
|
||||
# Dump all incoming REQ/CLOSE messages
|
||||
dumpInReqs = false
|
||||
|
||||
# Log performance metrics for initial REQ database scans
|
||||
dbScanPerf = false
|
||||
|
||||
# Log reason for invalid event rejection? Can be disabled to silence excessive logging
|
||||
invalidEvents = true
|
||||
}
|
||||
|
||||
numThreads {
|
||||
# Ingester threads: route incoming requests, validate events/sigs (restart required)
|
||||
ingester = 3
|
||||
|
||||
# reqWorker threads: Handle initial DB scan for events (restart required)
|
||||
reqWorker = 3
|
||||
|
||||
# reqMonitor threads: Handle filtering of new events (restart required)
|
||||
reqMonitor = 3
|
||||
|
||||
# negentropy threads: Handle negentropy protocol messages (restart required)
|
||||
negentropy = 2
|
||||
}
|
||||
|
||||
negentropy {
|
||||
# Support negentropy protocol messages
|
||||
enabled = true
|
||||
|
||||
# Maximum records that sync will process before returning an error
|
||||
maxSyncEvents = 1000000
|
||||
}
|
||||
}
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:7777
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
nostr_integration:
|
||||
relay_type: public
|
||||
monetization_enabled: false
|
||||
category: nostr
|
||||
interfaces:
|
||||
main:
|
||||
name: Angor Relay
|
||||
description: Nostr WebSocket endpoint; use ws:// for LAN or wss:// through your
|
||||
HTTPS domain.
|
||||
type: api
|
||||
port: 8091
|
||||
protocol: http
|
||||
path: /
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/angor-green.png
|
||||
tier: optional
|
||||
repo: https://github.com/hoytech/strfry
|
||||
features:
|
||||
- Angor project metadata
|
||||
- Separate from the node relay
|
||||
- Persistent Nostr event storage
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: archy-mempool-web
|
||||
name: Mempool Web
|
||||
version: 3.0.1
|
||||
version: 3.3.1-archy1
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -12,7 +12,7 @@ app:
|
||||
container_name: mempool
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
|
||||
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
|
||||
@@ -45,7 +45,9 @@ app:
|
||||
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
|
||||
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /
|
||||
# Probe the backend through nginx: a static page can be healthy while
|
||||
# every API/WebSocket request is stuck on a dead backend address.
|
||||
path: /api/v1/backend-info
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -54,7 +54,7 @@ app:
|
||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
|
||||
@@ -60,7 +60,7 @@ app:
|
||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
|
||||
+15
-8
@@ -15,6 +15,9 @@ app:
|
||||
image: source.archipelago-foundation.org/lfg2025/gitea:1.27.3
|
||||
pull_policy: if-not-present
|
||||
|
||||
# Preserve repositories, database, keys and configuration during runtime repairs.
|
||||
backup_before_runtime_change: true
|
||||
|
||||
dependencies:
|
||||
# Source history, LFS objects, release artifacts and OCI layers all share
|
||||
# this persistent store. 500Mi was only suitable for an empty demo node.
|
||||
@@ -25,7 +28,7 @@ app:
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE, SYS_CHROOT]
|
||||
readonly_root: false
|
||||
no_new_privileges: false
|
||||
network_policy: bridge
|
||||
@@ -62,6 +65,17 @@ app:
|
||||
target: /etc/gitea
|
||||
options: [rw]
|
||||
|
||||
# Seed a fresh installation with the same origin advertised by the app gate.
|
||||
# Existing app.ini (including custom HTTPS/domain settings) is never replaced.
|
||||
files:
|
||||
- path: /var/lib/archipelago/gitea/data/gitea/conf/app.ini
|
||||
overwrite: false
|
||||
content: |
|
||||
[server]
|
||||
DOMAIN = {{HOST_IP}}
|
||||
SSH_DOMAIN = {{HOST_IP}}
|
||||
ROOT_URL = http://{{HOST_IP}}:3001/
|
||||
|
||||
environment:
|
||||
- GITEA__database__DB_TYPE=sqlite3
|
||||
- GITEA__server__SSH_PORT=2222
|
||||
@@ -106,10 +120,3 @@ app:
|
||||
- Issue tracking and pull requests
|
||||
- CI/CD via Gitea Actions
|
||||
- Lightweight SQLite deployment
|
||||
|
||||
nginx_proxy:
|
||||
listen: 3000
|
||||
proxy_pass: http://127.0.0.1:3001
|
||||
extra_headers:
|
||||
- proxy_hide_header X-Frame-Options
|
||||
- proxy_hide_header Content-Security-Policy
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: mempool
|
||||
name: Mempool Explorer
|
||||
version: 3.0.0
|
||||
version: 3.3.1-archy1
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -11,7 +11,7 @@ app:
|
||||
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
|
||||
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
|
||||
@@ -64,9 +64,11 @@ app:
|
||||
|
||||
environment: []
|
||||
|
||||
# Probe the admin API inside the container, independent of optional
|
||||
# tunnel listeners. This also verifies the Node backend is ready.
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:81
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:81/api/
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -14,8 +14,16 @@ app:
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
|
||||
pull_policy: if-not-present
|
||||
# Portainer fetches Git sources and images from services on this same node.
|
||||
# Rootless pasta copies the host LAN address into its namespace, so a LAN
|
||||
# URL points back at Portainer itself. Give it a private address with the
|
||||
# supported rootless slirp backend; public app URLs still traverse the gate.
|
||||
network: slirp4netns
|
||||
data_uid: "1000:1000"
|
||||
|
||||
# Snapshot state before an upgrade recreates this app with new networking.
|
||||
backup_before_runtime_change: true
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
|
||||
+2
-2
@@ -67,13 +67,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.13-alpha"
|
||||
version = "1.8.22-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.13-alpha"
|
||||
version = "1.8.22-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -162,11 +162,33 @@ impl ApiHandler {
|
||||
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
||||
),
|
||||
)),
|
||||
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
||||
Ok(content_server::ServeResult::Unavailable) => Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller's node can't read this file right now. This request did not redeem an ecash payment."}"#,
|
||||
),
|
||||
)),
|
||||
Ok(content_server::ServeResult::RangeNotSatisfiable(total)) => Ok(Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header("Content-Range", format!("bytes */{total}"))
|
||||
.body(hyper::Body::empty())
|
||||
.unwrap()),
|
||||
Ok(content_server::ServeResult::NotFound) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
)),
|
||||
// Not a 404: a paid request may already have been charged by the
|
||||
// time this fails, and "not found" hid the real error entirely.
|
||||
Err(e) => {
|
||||
tracing::error!("Serving content {content_id} failed: {e:#}");
|
||||
Ok(build_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"text/plain",
|
||||
hyper::Body::from("Failed to serve content"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,19 @@ impl ApiHandler {
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
if suffix == "/archy-status" {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(
|
||||
rpc.handle_lnd_readiness().await.to_string(),
|
||||
))?);
|
||||
}
|
||||
|
||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||
// A bare reqwest::get() uses the default client, which rejects the
|
||||
|
||||
@@ -22,9 +22,9 @@ const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-cata
|
||||
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
||||
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
||||
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if
|
||||
/// the seller already claimed the token (then the value is genuinely gone).
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Report the actual
|
||||
/// recovered amount, or explicitly say when a refund could not be confirmed.
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> String {
|
||||
let res = match backend {
|
||||
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||
.await
|
||||
@@ -32,16 +32,81 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
|
||||
_ => ecash::receive_token(data_dir, token).await,
|
||||
};
|
||||
match res {
|
||||
Ok(sats) => tracing::info!(
|
||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||
claimed it): {e:#}"
|
||||
),
|
||||
Ok(sats) => {
|
||||
tracing::info!("paid download: reclaimed {sats} sats after failed sale");
|
||||
format!("Refunded {sats} sats to your wallet.")
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("paid download: refund not confirmed: {e}");
|
||||
"Your refund could not be confirmed. The seller may have received the payment. Do not pay again until this is checked.".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Only pass through the peer's bounded, printable explanation; refund status
|
||||
/// is always determined locally and must never come from the peer's wording.
|
||||
fn seller_error_message(status: reqwest::StatusCode, body: &str) -> String {
|
||||
let reason = serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_owned));
|
||||
match reason {
|
||||
Some(reason) if !reason.trim().is_empty() => {
|
||||
let clean: String = reason
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.take(240)
|
||||
.collect();
|
||||
format!("Seller response ({status}): {clean}")
|
||||
}
|
||||
_ => format!("Peer returned an error ({status})."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep first purchases and cached repeats compatible with both existing clients.
|
||||
fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json::Value {
|
||||
use base64::Engine;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
serde_json::json!({
|
||||
"data": data, "data_base64": data,
|
||||
"size": bytes.len(), "size_bytes": bytes.len(),
|
||||
"mime_type": mime, "paid_sats": paid_sats, "owned": true,
|
||||
})
|
||||
}
|
||||
|
||||
/// File purchases through an atomic no-clobber write in Files' own namespace.
|
||||
async fn file_purchase_in_files(
|
||||
data_dir: &std::path::Path,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<String> {
|
||||
let folder = if mime.starts_with("image/") || mime.starts_with("video/") {
|
||||
"Photos"
|
||||
} else if mime.starts_with("audio/") {
|
||||
"Music"
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let root = data_dir.join("filebrowser");
|
||||
anyhow::ensure!(
|
||||
tokio::fs::metadata(&root).await?.is_dir(),
|
||||
"Files storage is unavailable"
|
||||
);
|
||||
let name = std::path::Path::new(filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or("download");
|
||||
let path =
|
||||
crate::container::filebrowser::save_new_file(&root.join(folder), name, bytes).await?;
|
||||
Ok(format!(
|
||||
"{folder}/{}",
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.context("Invalid Files name")?
|
||||
))
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// List content I'm sharing.
|
||||
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
|
||||
@@ -463,17 +528,10 @@ impl RpcHandler {
|
||||
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
|
||||
.await
|
||||
{
|
||||
use base64::Engine;
|
||||
return Ok(serde_json::json!({
|
||||
"owned": true,
|
||||
"already_owned": true,
|
||||
"filename": o.filename,
|
||||
"mime_type": mime,
|
||||
"size_bytes": bytes.len(),
|
||||
"paid_sats": 0,
|
||||
"data_base64":
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
}));
|
||||
let mut result = paid_content_response(&bytes, &mime, 0);
|
||||
result["already_owned"] = serde_json::json!(true);
|
||||
result["filename"] = serde_json::json!(o.filename);
|
||||
return Ok(result);
|
||||
}
|
||||
// Cache record exists but bytes are gone — fall through and
|
||||
// repurchase rather than stranding the user.
|
||||
@@ -545,31 +603,30 @@ impl RpcHandler {
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
// Surface a real reason instead of the generic sanitized error (#30):
|
||||
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
||||
// here means the peer is genuinely unreachable on both transports.
|
||||
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
||||
fips_npub.as_deref(),
|
||||
onion,
|
||||
&path,
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str.clone())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
// The token was already minted/spent — reclaim it so the buyer
|
||||
// doesn't lose the value when the seller was simply unreachable.
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
||||
}));
|
||||
}
|
||||
};
|
||||
// A bearer token must not be replayed after an ambiguous delivery.
|
||||
// A transport error can mean the seller received it without replying.
|
||||
let (response, transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str.clone())
|
||||
.single_delivery()
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
// The token was already minted/spent — reclaim it so the buyer
|
||||
// doesn't lose the value when the seller was simply unreachable.
|
||||
let refund =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("The purchase could not be completed. {refund}")
|
||||
}));
|
||||
}
|
||||
};
|
||||
// Record which transport actually reached the peer (B14).
|
||||
if let Err(e) = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
@@ -583,25 +640,17 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
// Payment was rejected by the seller. Surface the most likely cause
|
||||
// per backend — for ecash both sides must share a redemption network
|
||||
// (a Cashu mint, or a Fedimint federation).
|
||||
// A 402 can mean mint validation, network failure, underpayment,
|
||||
// or an unaccepted mint. Do not invent a mint-mismatch diagnosis.
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
|
||||
);
|
||||
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let hint = match used_backend {
|
||||
"fedimint" => "the seller isn't in the same Fedimint federation as you",
|
||||
_ => "the seller doesn't accept your Cashu mint",
|
||||
};
|
||||
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!(
|
||||
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
||||
your wallet. Try the other ecash type, or use a shared mint/federation."
|
||||
)
|
||||
"error": format!("The seller could not verify the payment. {refund}")
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -609,9 +658,9 @@ impl RpcHandler {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
|
||||
"error": format!("{} {refund}", seller_error_message(status, &body))
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -625,10 +674,17 @@ impl RpcHandler {
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
let bytes = match response.bytes().await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
tracing::warn!("paid download: response body failed: {error}");
|
||||
let refund =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("The file transfer was interrupted after payment was sent. {refund}")
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Persist the purchase so it "stays unlocked" for this buyer: cache the
|
||||
// bytes + metadata keyed by (onion, content_id). The gallery then renders
|
||||
@@ -658,63 +714,21 @@ impl RpcHandler {
|
||||
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
||||
}
|
||||
|
||||
// Auto-file the purchase into the user's Files area (2026-07-22):
|
||||
// Photos for images/video, Music for audio, Documents otherwise —
|
||||
// same buckets the Cloud view uses. The in-app viewer still plays
|
||||
// from the purchase cache; this makes the file ALSO show up where
|
||||
// files live, on every device, without relying on a browser
|
||||
// download. Best-effort: never fail a paid download over it.
|
||||
{
|
||||
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
|
||||
"Photos"
|
||||
} else if mime_type.starts_with("audio/") {
|
||||
"Music"
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let base = std::path::Path::new(&filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let dir = self.config.data_dir.join("filebrowser").join(folder);
|
||||
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
|
||||
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
|
||||
} else {
|
||||
// Don't clobber an existing file of the same name: "x.jpg"
|
||||
// → "x (2).jpg" etc.
|
||||
let mut target = dir.join(&base);
|
||||
let (stem, ext) = match base.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (base.clone(), String::new()),
|
||||
};
|
||||
let mut n = 2;
|
||||
while target.exists() {
|
||||
target = dir.join(format!("{stem} ({n}){ext}"));
|
||||
n += 1;
|
||||
}
|
||||
match tokio::fs::write(&target, &bytes).await {
|
||||
Ok(()) => tracing::info!("paid download: filed into {}", target.display()),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: filing into {} failed (non-fatal): {e}",
|
||||
target.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
// The durable purchased-content cache above is primary. A Files copy
|
||||
// remains optional: a stopped FileBrowser must not undo a paid download.
|
||||
let filed =
|
||||
file_purchase_in_files(&self.config.data_dir, &filename, &mime_type, &bytes).await;
|
||||
match filed {
|
||||
Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
|
||||
Err(error) => tracing::warn!(
|
||||
"paid download: optional Files copy failed; purchase cache retained: {error}"
|
||||
),
|
||||
}
|
||||
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
"paid_sats": price_sats,
|
||||
"ecash_backend": used_backend,
|
||||
"mime_type": mime_type,
|
||||
"owned": true,
|
||||
}))
|
||||
let mut result = paid_content_response(&bytes, &mime_type, price_sats);
|
||||
result["ecash_backend"] = serde_json::json!(used_backend);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
|
||||
@@ -1387,3 +1401,7 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "content_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
||||
use base64::Engine;
|
||||
for paid in [0, 1] {
|
||||
let response = paid_content_response(&[0, 255, 123], "application/octet-stream", paid);
|
||||
assert_eq!(response["data"], response["data_base64"]);
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(response["data"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
[0, 255, 123]
|
||||
);
|
||||
assert_eq!(response["size"], 3);
|
||||
assert_eq!(response["size_bytes"], 3);
|
||||
assert_eq!(response["paid_sats"], paid);
|
||||
assert_eq!(response["owned"], true);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_routes_media_and_sanitizes_the_filename() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
tokio::fs::create_dir(dir.path().join("filebrowser"))
|
||||
.await
|
||||
.unwrap();
|
||||
for (mime, folder) in [
|
||||
("image/png", "Photos"),
|
||||
("video/mp4", "Photos"),
|
||||
("audio/mpeg", "Music"),
|
||||
("text/plain", "Documents"),
|
||||
] {
|
||||
let relative = file_purchase_in_files(dir.path(), "../name #?.bin", mime, b"paid")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(relative.starts_with(&format!("{folder}/name #?")));
|
||||
assert_eq!(
|
||||
tokio::fs::read(dir.path().join("filebrowser").join(relative))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"paid"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_files_storage_is_reported_without_creating_a_fake_installation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(
|
||||
file_purchase_in_files(dir.path(), "name", "text/plain", b"bytes")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(!dir.path().join("filebrowser").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seller_errors_are_bounded_printable_and_identified_as_peer_text() {
|
||||
let status = reqwest::StatusCode::SERVICE_UNAVAILABLE;
|
||||
let message = seller_error_message(status, r#"{"error":"Cannot read file\n\u0000"}"#);
|
||||
assert!(message.starts_with("Seller response (503"));
|
||||
assert!(message.ends_with("Cannot read file"));
|
||||
assert!(!message.contains('\n') && !message.contains('\0'));
|
||||
let body = serde_json::json!({"error": "é".repeat(1000)}).to_string();
|
||||
assert!(seller_error_message(status, &body).chars().count() < 300);
|
||||
for body in ["not JSON", r#"{"error": 7}"#, r#"{"error":" "}"#] {
|
||||
assert_eq!(
|
||||
seller_error_message(status, body),
|
||||
"Peer returned an error (503 Service Unavailable)."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,86 @@ struct LndChannelBalanceResponse {
|
||||
pending_open_local_balance: Option<LndAmount>,
|
||||
}
|
||||
|
||||
/// Reject unavailable LND data before it can be decoded as an empty, zero wallet.
|
||||
async fn get_lnd_json<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
macaroon_hex: &str,
|
||||
) -> Result<T> {
|
||||
client
|
||||
.get(url)
|
||||
.header("Grpc-Metadata-macaroon", macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND is unavailable; balance could not be checked")?
|
||||
.error_for_status()
|
||||
.context("LND is not ready; balance could not be checked")?
|
||||
.json()
|
||||
.await
|
||||
.context("LND returned invalid wallet data")
|
||||
}
|
||||
|
||||
fn checked_balances(
|
||||
wallet: LndBalanceResponse,
|
||||
channels: LndChannelBalanceResponse,
|
||||
) -> Result<(i64, i64, i64)> {
|
||||
fn sats(value: Option<String>) -> Result<i64> {
|
||||
let value = value.context("LND omitted a balance; balance is unavailable")?;
|
||||
let amount: i64 = value.parse().context("LND returned an invalid balance")?;
|
||||
anyhow::ensure!(amount >= 0, "LND returned a negative balance");
|
||||
Ok(amount)
|
||||
}
|
||||
Ok((
|
||||
sats(wallet.total_balance)?,
|
||||
sats(channels.local_balance.and_then(|a| a.sat))?,
|
||||
sats(channels.pending_open_local_balance.and_then(|a| a.sat))?,
|
||||
))
|
||||
}
|
||||
|
||||
fn bitcoin_wait_state(
|
||||
installed: bool,
|
||||
running: bool,
|
||||
fresh: bool,
|
||||
ibd: Option<bool>,
|
||||
) -> (&'static str, &'static str) {
|
||||
if !installed {
|
||||
("waiting_install", "Waiting for Bitcoin to be installed")
|
||||
} else if !running {
|
||||
("waiting_start", "Waiting for Bitcoin to start")
|
||||
} else if !fresh || ibd.is_none() {
|
||||
("waiting_start", "Waiting for Bitcoin to start")
|
||||
} else if ibd == Some(true) {
|
||||
("waiting_sync", "Waiting for Bitcoin to sync")
|
||||
} else {
|
||||
("bitcoin_ready", "Bitcoin is ready")
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(crate) async fn handle_lnd_readiness(&self) -> serde_json::Value {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if !data.server_info.status_info.containers_scanned {
|
||||
return serde_json::json!({"state":"checking", "message":"Checking Bitcoin availability"});
|
||||
}
|
||||
let nodes: Vec<_> = ["bitcoin-core", "bitcoin-knots", "bitcoin"]
|
||||
.iter()
|
||||
.filter_map(|id| data.package_data.get(*id))
|
||||
.collect();
|
||||
let installed = !nodes.is_empty();
|
||||
let running = nodes
|
||||
.iter()
|
||||
.any(|p| p.state == crate::data_model::PackageState::Running);
|
||||
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
let ibd = bitcoin
|
||||
.blockchain_info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("initialblockdownload"))
|
||||
.and_then(|v| v.as_bool());
|
||||
let (state, message) =
|
||||
bitcoin_wait_state(installed, running, bitcoin.ok && !bitcoin.stale, ibd);
|
||||
serde_json::json!({"state": state, "message": message})
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
@@ -85,45 +164,26 @@ impl RpcHandler {
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let get_info: LndGetInfoResponse = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND getinfo response")?;
|
||||
|
||||
let channel_balance: LndChannelBalanceResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
}),
|
||||
Err(_) => LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
},
|
||||
};
|
||||
|
||||
let wallet_balance: LndBalanceResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
|
||||
total_balance: None,
|
||||
}),
|
||||
Err(_) => LndBalanceResponse {
|
||||
total_balance: None,
|
||||
},
|
||||
};
|
||||
let get_info: LndGetInfoResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/getinfo"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let channel_balance: LndChannelBalanceResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/balance/channels"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let wallet_balance: LndBalanceResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/balance/blockchain"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let (balance_sats, channel_balance_sats, pending_open_balance) =
|
||||
checked_balances(wallet_balance, channel_balance)?;
|
||||
|
||||
let (identity_pubkey, uris) = map_identity(&get_info);
|
||||
|
||||
@@ -135,18 +195,9 @@ impl RpcHandler {
|
||||
num_peers: get_info.num_peers.unwrap_or(0),
|
||||
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
||||
block_height: get_info.block_height.unwrap_or(0),
|
||||
balance_sats: wallet_balance
|
||||
.total_balance
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
channel_balance_sats: channel_balance
|
||||
.local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
pending_open_balance: channel_balance
|
||||
.pending_open_local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
balance_sats,
|
||||
channel_balance_sats,
|
||||
pending_open_balance,
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(info)?)
|
||||
@@ -268,6 +319,76 @@ impl RpcHandler {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unavailable_balances_are_not_zero() {
|
||||
for body in [r#"{}"#, r#"{"code":14,"message":"wallet locked"}"#] {
|
||||
assert!(checked_balances(
|
||||
serde_json::from_str(body).unwrap(),
|
||||
serde_json::from_str(body).unwrap(),
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
for value in ["bad", "-1", "9223372036854775808"] {
|
||||
let wallet = LndBalanceResponse {
|
||||
total_balance: Some(value.into()),
|
||||
};
|
||||
let channels = serde_json::from_str(
|
||||
r#"{"local_balance":{"sat":"5"},"pending_open_local_balance":{"sat":"0"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(checked_balances(wallet, channels).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_zero_and_nonzero_balances_survive() {
|
||||
for expected in [0, 42] {
|
||||
let wallet = LndBalanceResponse {
|
||||
total_balance: Some(expected.to_string()),
|
||||
};
|
||||
let channels = serde_json::from_value(serde_json::json!({
|
||||
"local_balance":{"sat":expected.to_string()},
|
||||
"pending_open_local_balance":{"sat":"0"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
checked_balances(wallet, channels).unwrap(),
|
||||
(expected, expected, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locked_wallet_http_response_is_not_successful_getinfo() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0; 2048];
|
||||
stream.read(&mut buf).await.unwrap();
|
||||
let body =
|
||||
r#"{"code":9,"message":"wallet locked, unlock it to enable full RPC access"}"#;
|
||||
stream.write_all(format!(
|
||||
"HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(), body
|
||||
).as_bytes()).await.unwrap();
|
||||
});
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap();
|
||||
assert!(get_lnd_json::<LndGetInfoResponse>(
|
||||
&client,
|
||||
&format!("http://{addr}/v1/getinfo"),
|
||||
"test"
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
|
||||
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
||||
|
||||
@@ -341,3 +462,44 @@ mod tests {
|
||||
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dependency_readiness_tests {
|
||||
use super::bitcoin_wait_state;
|
||||
#[test]
|
||||
fn waiting_states_cover_install_start_sync_outage_and_recovery() {
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(false, false, false, None).0,
|
||||
"waiting_install"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, false, false, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, false, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(true)).0,
|
||||
"waiting_sync"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(false)).0,
|
||||
"bitcoin_ready"
|
||||
);
|
||||
// Previously synced cached information must not hide a current outage.
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, false, Some(false)).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(false)).0,
|
||||
"bitcoin_ready"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,12 +133,36 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
|
||||
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||
/// operator.
|
||||
fn bitcoin_ready_for_lnd_watchdog(status: &crate::bitcoin_status::BitcoinNodeStatus) -> bool {
|
||||
status.ok
|
||||
&& !status.stale
|
||||
&& status.age_ms < 30_000
|
||||
&& status
|
||||
.blockchain_info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("initialblockdownload"))
|
||||
.and_then(|v| v.as_bool())
|
||||
== Some(false)
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
tokio::spawn(async move {
|
||||
let mut bad_minutes: u32 = 0;
|
||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||
let mut last_height: Option<u64> = None;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
// Initial Bitcoin sync, warmup, and outages are dependencies to
|
||||
// wait for, never evidence that LND is wedged. Do not accumulate
|
||||
// restart pressure during a days-long initial block download.
|
||||
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
if !bitcoin_ready_for_lnd_watchdog(&bitcoin)
|
||||
|| crate::app_ops::lifecycle_op_in_flight("lnd")
|
||||
{
|
||||
bad_minutes = 0;
|
||||
last_height = None;
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||
continue;
|
||||
@@ -161,6 +185,10 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
bad_minutes = 0; // down/locked — not the wedge signature
|
||||
continue;
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
}
|
||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
@@ -182,7 +210,12 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
.get("num_pending_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let wedged = !synced || (channels > 0 && peers == 0);
|
||||
let height = info.get("block_height").and_then(|v| v.as_u64());
|
||||
let progressing = height
|
||||
.zip(last_height)
|
||||
.is_some_and(|(now, before)| now > before);
|
||||
last_height = height;
|
||||
let wedged = !progressing && (!synced || (channels > 0 && peers == 0));
|
||||
if !wedged {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
@@ -239,3 +272,31 @@ impl RpcHandler {
|
||||
Ok((client, macaroon_hex))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod watchdog_dependency_tests {
|
||||
use super::bitcoin_ready_for_lnd_watchdog;
|
||||
use crate::bitcoin_status::BitcoinNodeStatus;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn initial_sync_warmup_outage_stale_and_unknown_never_trigger_lnd_restart() {
|
||||
let mut status = BitcoinNodeStatus::default();
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.ok = true;
|
||||
status.blockchain_info = Some(json!({"initialblockdownload":true}));
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.blockchain_info = Some(json!({"initialblockdownload":false}));
|
||||
assert!(bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.stale = true;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.stale = false;
|
||||
status.ok = false;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.ok = true;
|
||||
status.age_ms = 30_000;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.age_ms = 0;
|
||||
status.blockchain_info = Some(json!({}));
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,15 @@ impl RpcHandler {
|
||||
match handler.handle_package_install(params).await {
|
||||
Ok(_) => {
|
||||
info!("package.install {}: complete", package_id_spawn);
|
||||
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
|
||||
crate::crash_recovery::clear_user_uninstalled(&handler.config.data_dir, id)
|
||||
.await;
|
||||
}
|
||||
crate::crash_recovery::mark_installed(
|
||||
&handler.config.data_dir,
|
||||
&package_id_spawn,
|
||||
)
|
||||
.await;
|
||||
// The install pipeline has verified the container is up
|
||||
// and healthy (see install.rs post-start exit check).
|
||||
// Kick the scanner first so the fresh manifest (with
|
||||
@@ -184,17 +193,20 @@ impl RpcHandler {
|
||||
// phase is cleared (None) so no stale InstallPhase
|
||||
// lingers on the card.
|
||||
let err_msg = format!("Install failed: {:#}", e);
|
||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
||||
entry.state = PackageState::Stopped;
|
||||
entry.install_progress = Some(crate::data_model::InstallProgress {
|
||||
size: 0,
|
||||
downloaded: 0,
|
||||
phase: None,
|
||||
message: Some(err_msg),
|
||||
});
|
||||
handler.state_manager.update_data(data).await;
|
||||
}
|
||||
handler
|
||||
.state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
||||
entry.state = PackageState::Stopped;
|
||||
entry.install_progress = Some(crate::data_model::InstallProgress {
|
||||
size: 0,
|
||||
downloaded: 0,
|
||||
phase: None,
|
||||
message: Some(err_msg),
|
||||
});
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -252,6 +264,11 @@ impl RpcHandler {
|
||||
match handler.handle_package_uninstall(params).await {
|
||||
Ok(_) => {
|
||||
info!("package.uninstall {}: complete", package_id_spawn);
|
||||
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
|
||||
crate::crash_recovery::mark_user_uninstalled(&handler.config.data_dir, id)
|
||||
.await;
|
||||
crate::crash_recovery::clear_installed(&handler.config.data_dir, id).await;
|
||||
}
|
||||
// Inner handler already removed the package entry on
|
||||
// success. Nothing more to do here.
|
||||
}
|
||||
@@ -330,7 +347,7 @@ impl RpcHandler {
|
||||
let package_id_spawn = package_id.clone();
|
||||
tokio::spawn(async move {
|
||||
match handler.handle_package_update(params).await {
|
||||
Ok(_) => {
|
||||
Ok(result) => {
|
||||
info!("package.update {}: complete", package_id_spawn);
|
||||
// Same reasoning as install: the merge_preserving_transitional
|
||||
// helper treats Updating as RPC-owned, so we MUST write the
|
||||
@@ -345,7 +362,11 @@ impl RpcHandler {
|
||||
set_package_state(
|
||||
&handler.state_manager,
|
||||
&package_id_spawn,
|
||||
PackageState::Running,
|
||||
if result.get("status").and_then(|v| v.as_str()) == Some("up-to-date") {
|
||||
pre_state.clone().unwrap_or(PackageState::Running)
|
||||
} else {
|
||||
PackageState::Running
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -378,52 +399,56 @@ impl RpcHandler {
|
||||
/// call, but fires before the spawn so the UI sees it immediately.
|
||||
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
|
||||
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| PackageDataEntry {
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
// Leave icon empty during the transient Installing window:
|
||||
// hardcoding `<id>.png` is wrong for ~half our apps (many use
|
||||
// `.svg` / `.webp`), producing a broken-image flicker until
|
||||
// the scanner refreshes the entry. The frontend's `icon`
|
||||
// computed falls through to `curatedMap.get(id)?.icon` which
|
||||
// has the correct extensions for known apps.
|
||||
icon: String::new(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: package_id.to_string(),
|
||||
title: package_id.to_string(),
|
||||
version: String::new(),
|
||||
description: Description {
|
||||
short: "Installing...".to_string(),
|
||||
long: String::new(),
|
||||
},
|
||||
release_notes: String::new(),
|
||||
license: String::new(),
|
||||
wrapper_repo: String::new(),
|
||||
upstream_repo: String::new(),
|
||||
support_site: String::new(),
|
||||
marketing_site: String::new(),
|
||||
donation_url: None,
|
||||
author: None,
|
||||
website: None,
|
||||
interfaces: None,
|
||||
tier: None,
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
available_update: None,
|
||||
});
|
||||
entry.state = PackageState::Installing;
|
||||
state_manager.update_data(data).await;
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| PackageDataEntry {
|
||||
ui_ready: None,
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
// Leave icon empty during the transient Installing window:
|
||||
// hardcoding `<id>.png` is wrong for ~half our apps (many use
|
||||
// `.svg` / `.webp`), producing a broken-image flicker until
|
||||
// the scanner refreshes the entry. The frontend's `icon`
|
||||
// computed falls through to `curatedMap.get(id)?.icon` which
|
||||
// has the correct extensions for known apps.
|
||||
icon: String::new(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: package_id.to_string(),
|
||||
title: package_id.to_string(),
|
||||
version: String::new(),
|
||||
description: Description {
|
||||
short: "Installing...".to_string(),
|
||||
long: String::new(),
|
||||
},
|
||||
release_notes: String::new(),
|
||||
license: String::new(),
|
||||
wrapper_repo: String::new(),
|
||||
upstream_repo: String::new(),
|
||||
support_site: String::new(),
|
||||
marketing_site: String::new(),
|
||||
donation_url: None,
|
||||
author: None,
|
||||
website: None,
|
||||
interfaces: None,
|
||||
tier: None,
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
available_update: None,
|
||||
});
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = PackageState::Installing;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// True when the failed install still has a real footprint: any container
|
||||
@@ -481,20 +506,23 @@ async fn remove_entry_with_notification(
|
||||
id_prefix: &str,
|
||||
message: &str,
|
||||
) {
|
||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
||||
data.package_data.remove(package_id);
|
||||
data.notifications.push(crate::data_model::Notification {
|
||||
id: format!("{id_prefix}-{package_id}"),
|
||||
level: crate::data_model::NotificationLevel::Error,
|
||||
title: format!("Could not install {package_id}"),
|
||||
message: message.to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
app_id: Some(package_id.to_string()),
|
||||
});
|
||||
while data.notifications.len() > 20 {
|
||||
data.notifications.remove(0);
|
||||
}
|
||||
handler.state_manager.update_data(data).await;
|
||||
handler
|
||||
.state_manager
|
||||
.mutate_data(|data| {
|
||||
data.package_data.remove(package_id);
|
||||
data.notifications.push(crate::data_model::Notification {
|
||||
id: format!("{id_prefix}-{package_id}"),
|
||||
level: crate::data_model::NotificationLevel::Error,
|
||||
title: format!("Could not install {package_id}"),
|
||||
message: message.to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
app_id: Some(package_id.to_string()),
|
||||
});
|
||||
while data.notifications.len() > 20 {
|
||||
data.notifications.remove(0);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Flip an existing entry's state and return the pre-flip value (or None if
|
||||
@@ -504,18 +532,23 @@ async fn flip_package_state(
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) -> Option<PackageState> {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
} else {
|
||||
warn!(
|
||||
"flip_package_state: no entry for {} — cannot flip",
|
||||
package_id
|
||||
);
|
||||
}
|
||||
prev
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if new_state != PackageState::Running {
|
||||
entry.ui_ready = Some(false);
|
||||
}
|
||||
entry.state = new_state;
|
||||
} else {
|
||||
warn!(
|
||||
"flip_package_state: no entry for {} — cannot flip",
|
||||
package_id
|
||||
);
|
||||
}
|
||||
prev
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Set state unconditionally (no-op if entry no longer exists).
|
||||
@@ -524,13 +557,18 @@ async fn set_package_state(
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if entry.state != new_state {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if entry.state != new_state {
|
||||
if new_state != PackageState::Running {
|
||||
entry.ui_ready = Some(false);
|
||||
}
|
||||
entry.state = new_state;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Set state and clear the uninstall_stage label. Used when an uninstall
|
||||
@@ -541,12 +579,17 @@ async fn set_package_state_and_clear_uninstall_stage(
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.state = new_state;
|
||||
entry.uninstall_stage = None;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if new_state != PackageState::Running {
|
||||
entry.ui_ready = Some(false);
|
||||
}
|
||||
entry.state = new_state;
|
||||
entry.uninstall_stage = None;
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Kick the container scanner to run immediately and wait for it to finish
|
||||
|
||||
@@ -22,6 +22,18 @@ const ARCHIVAL_BITCOIN_DEPENDENCY: &str = "bitcoin:archival";
|
||||
/// hardcoded id list below — a new app just declares the dependency instead
|
||||
/// of needing a code change here.
|
||||
fn manifest_declares_archival_bitcoin(package_id: &str) -> bool {
|
||||
// Registry-only apps need the same guard as OTA-bundled manifests. Honor
|
||||
// the verified catalog's effective manifest before the disk fallback.
|
||||
if let Some((_, value)) = crate::container::app_catalog::catalog_manifest_values()
|
||||
.into_iter()
|
||||
.find(|(id, _)| id == package_id)
|
||||
{
|
||||
if let Some(manifest) =
|
||||
crate::container::app_catalog::catalog_manifest_overlay(package_id, value)
|
||||
{
|
||||
return dependency_list_declares_archival_bitcoin(&manifest.app.dependencies);
|
||||
}
|
||||
}
|
||||
for apps_dir in manifest_apps_dirs() {
|
||||
let path = apps_dir.join(package_id).join("manifest.yml");
|
||||
let Ok(contents) = std::fs::read_to_string(&path) else {
|
||||
@@ -1055,6 +1067,14 @@ mod tests {
|
||||
// edit to `requires_unpruned_bitcoin`.
|
||||
assert!(manifest_declares_archival_bitcoin("electrumx"));
|
||||
assert!(manifest_declares_archival_bitcoin("mempool"));
|
||||
let angor = archipelago_container::AppManifest::parse(include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../apps/angor-indexer/manifest.yml"
|
||||
)))
|
||||
.unwrap();
|
||||
assert!(dependency_list_declares_archival_bitcoin(
|
||||
&angor.app.dependencies
|
||||
));
|
||||
// An app whose manifest exists but never declares the marker.
|
||||
assert!(!manifest_declares_archival_bitcoin("bitcoin-knots"));
|
||||
// An id with no manifest on disk at all.
|
||||
|
||||
@@ -326,6 +326,10 @@ impl RpcHandler {
|
||||
// an older version pins it so install_fresh resolves that image and the
|
||||
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
|
||||
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
if let Some(value) = params.get("prune") {
|
||||
let prune = value.as_bool().context("prune must be a boolean")?;
|
||||
crate::settings::bitcoin_storage::save(&self.config.data_dir, prune).await?;
|
||||
}
|
||||
if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
|
||||
persist_install_version_selection(package_id, version).await;
|
||||
}
|
||||
@@ -541,7 +545,7 @@ impl RpcHandler {
|
||||
// Keep legacy install flow as default while migration is in progress.
|
||||
if orchestrator_managed {
|
||||
let orchestrator_app_id = orchestrator_install_app_id(package_id);
|
||||
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
|
||||
self.set_install_phase(package_id, InstallPhase::PreparingApp)
|
||||
.await;
|
||||
install_log(&format!(
|
||||
"INSTALL ORCH: {} — attempting orchestrator install as {}",
|
||||
@@ -569,6 +573,9 @@ impl RpcHandler {
|
||||
"message": format!("Package {} installed and started", package_id)
|
||||
}));
|
||||
}
|
||||
Err(e) if e.downcast_ref::<crate::container::prod_orchestrator::InstallPrerequisiteError>().is_some() => {
|
||||
return Err(super::dependencies::DependencyGateError(e.to_string()).into());
|
||||
}
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
info!(
|
||||
"Install {}: orchestrator has no manifest mapping yet, falling back to legacy installer",
|
||||
@@ -1695,32 +1702,10 @@ autopilot.active=false\n",
|
||||
patch_indeedhub_nostr_provider().await;
|
||||
}
|
||||
|
||||
// Gitea: keep it on its native host port (3001). The UI opens Gitea
|
||||
// in a new tab on that direct port so absolute asset URLs must be
|
||||
// rooted at the host port rather than Archipelago's /app/gitea/ path.
|
||||
if package_id == "gitea" {
|
||||
let _ = tokio::fs::remove_file("/etc/nginx/conf.d/gitea-iframe.conf").await;
|
||||
|
||||
// Set ROOT_URL to the direct launch route so links/assets stay
|
||||
// anchored under the same origin Gitea is launched from.
|
||||
let host_ip = &self.config.host_ip;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["exec", "gitea", "sh", "-c",
|
||||
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3001/|' /data/gitea/conf/app.ini || true", host_ip)])
|
||||
.output()
|
||||
.await;
|
||||
// Also ensure X_FRAME_OPTIONS is empty so Gitea doesn't send the header
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["exec", "gitea", "sh", "-c",
|
||||
"grep -q X_FRAME_OPTIONS /data/gitea/conf/app.ini && sed -i 's|X_FRAME_OPTIONS.*|X_FRAME_OPTIONS =|' /data/gitea/conf/app.ini || sed -i '/^\\[security\\]/a X_FRAME_OPTIONS =' /data/gitea/conf/app.ini"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
info!(
|
||||
"Gitea: ROOT_URL set to http://{}:3001/, X_FRAME_OPTIONS cleared",
|
||||
host_ip
|
||||
);
|
||||
}
|
||||
// Gitea owns its public URL and security settings in app.ini, including
|
||||
// values chosen in its first-run setup. Do not rewrite operator values
|
||||
// or claim success from best-effort grep/sed commands. The app gate
|
||||
// fronts its declared HTTP port and handles frame headers separately.
|
||||
|
||||
if package_id == "nextcloud" {
|
||||
let host_ip = &self.config.host_ip;
|
||||
@@ -2049,25 +2034,8 @@ fn parse_setup_token(lines: &[&str]) -> Option<String> {
|
||||
}
|
||||
|
||||
async fn cleanup_stale_package_ports(package_id: &str) {
|
||||
match package_id {
|
||||
"grafana" => cleanup_stale_pasta_port("3000").await,
|
||||
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
|
||||
"searxng" => cleanup_stale_pasta_port("8888").await,
|
||||
"uptime-kuma" => cleanup_stale_pasta_port("3002").await,
|
||||
"gitea" => {
|
||||
cleanup_stale_pasta_port("3001").await;
|
||||
cleanup_stale_pasta_port("2222").await;
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
}
|
||||
"nginx-proxy-manager" => {
|
||||
cleanup_stale_pasta_port("8081").await;
|
||||
cleanup_stale_pasta_port("8084").await;
|
||||
cleanup_stale_pasta_port("8444").await;
|
||||
}
|
||||
"nextcloud" => cleanup_stale_pasta_port("8085").await,
|
||||
"portainer" => cleanup_stale_pasta_port("9000").await,
|
||||
_ => {}
|
||||
}
|
||||
// Never kill by port: another app or the management gate may own it.
|
||||
crate::container::ghost_reaper::reap_for_app(package_id).await;
|
||||
}
|
||||
|
||||
fn install_command_tail(
|
||||
@@ -2192,93 +2160,11 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
match package_id {
|
||||
"grafana"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
true
|
||||
}
|
||||
"homeassistant" | "home-assistant"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("8123").await;
|
||||
true
|
||||
}
|
||||
"searxng"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("8888").await;
|
||||
true
|
||||
}
|
||||
"uptime-kuma"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("3002").await;
|
||||
true
|
||||
}
|
||||
"gitea" if stderr.contains("pasta failed") || stderr.contains("address already in use") => {
|
||||
cleanup_stale_pasta_port("3001").await;
|
||||
cleanup_stale_pasta_port("2222").await;
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
true
|
||||
}
|
||||
"nginx-proxy-manager"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("8081").await;
|
||||
cleanup_stale_pasta_port("8084").await;
|
||||
cleanup_stale_pasta_port("8444").await;
|
||||
true
|
||||
}
|
||||
"nextcloud"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("8085").await;
|
||||
true
|
||||
}
|
||||
"portainer"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("9000").await;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") {
|
||||
crate::container::ghost_reaper::reap_for_app(package_id).await;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_stale_pasta_port(port: &str) {
|
||||
// NEVER kill our own process. The daemon holds catalog app ports over
|
||||
// IPv6 (the mesh app-port relay), so a blunt `fuser -k <port>/tcp` would
|
||||
// terminate archipelago itself mid-install — installs failed and apps
|
||||
// vanished on a test node 2026-07-27. Kill every listener on the port
|
||||
// EXCEPT our PID (and our process group), leaving the relay/daemon alive.
|
||||
let self_pid = std::process::id();
|
||||
let kill_listener = format!(
|
||||
"ss -ltnp 'sport = :{port}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | \
|
||||
while read p; do [ \"$p\" = \"{self_pid}\" ] || kill \"$p\" 2>/dev/null; done || true",
|
||||
);
|
||||
let _ = tokio::process::Command::new("sh")
|
||||
.args(["-c", &kill_listener])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// sudo fuser -k, but exclude our own PID: fuser prints the PIDs holding
|
||||
// the port; kill each except self. (`fuser -k` has no exclusion flag.)
|
||||
let fuser_kill = format!(
|
||||
"for p in $(sudo fuser {port}/tcp 2>/dev/null); do [ \"$p\" = \"{self_pid}\" ] || sudo kill \"$p\" 2>/dev/null; done || true",
|
||||
);
|
||||
let _ = tokio::process::Command::new("sh")
|
||||
.args(["-c", &fuser_kill])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let pattern = format!("pasta.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
false
|
||||
}
|
||||
|
||||
async fn repair_nextcloud_permissions() {
|
||||
|
||||
@@ -14,20 +14,23 @@ impl RpcHandler {
|
||||
/// the rare case where the pull stream actually parses, but podman
|
||||
/// almost never emits parseable progress on a piped stderr.
|
||||
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.state = PackageState::Installing;
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
self.state_manager
|
||||
.mutate_data(|data| {
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = PackageState::Installing;
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Set the install pipeline phase and broadcast. This is the
|
||||
@@ -35,76 +38,86 @@ impl RpcHandler {
|
||||
/// percentage and a user-facing label. Byte counters are retained
|
||||
/// for the rare case podman emits parseable progress.
|
||||
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
// Preparing / PullingImage / CreatingContainer / StartingContainer /
|
||||
// WaitingHealthy / PostInstall all map to the Installing state.
|
||||
// Updates use Updating state — the wrapper has already flipped
|
||||
// state to Updating, so don't clobber it.
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let (size, downloaded) = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.map(|p| (p.size, p.downloaded))
|
||||
.unwrap_or((0, 0));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: Some(phase),
|
||||
message: None,
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
self.state_manager
|
||||
.mutate_data(|data| {
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
// Preparing / PullingImage / CreatingContainer / StartingContainer /
|
||||
// WaitingHealthy / PostInstall all map to the Installing state.
|
||||
// Updates use Updating state — the wrapper has already flipped
|
||||
// state to Updating, so don't clobber it.
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let (size, downloaded) = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.map(|p| (p.size, p.downloaded))
|
||||
.unwrap_or((0, 0));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: Some(phase),
|
||||
message: None,
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin
|
||||
/// to start…") without disturbing the current phase/byte counters.
|
||||
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let (size, downloaded, phase) = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.map(|p| (p.size, p.downloaded, p.phase))
|
||||
.unwrap_or((0, 0, None));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase,
|
||||
message: Some(message.to_string()),
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
self.state_manager
|
||||
.mutate_data(|data| {
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let (size, downloaded, phase) = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.map(|p| (p.size, p.downloaded, p.phase))
|
||||
.unwrap_or((0, 0, None));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase,
|
||||
message: Some(message.to_string()),
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Clear install progress after pull completes or fails.
|
||||
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.install_progress = None;
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
self.state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.install_progress = None;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Set the uninstall stage label so the UI can show what's happening
|
||||
/// instead of a generic spinner. Each call broadcasts a state change
|
||||
/// — call sparingly (one per pipeline phase, not per container).
|
||||
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.uninstall_stage = Some(stage.to_string());
|
||||
entry.state = crate::data_model::PackageState::Removing;
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
self.state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.uninstall_stage = Some(stage.to_string());
|
||||
entry.state = crate::data_model::PackageState::Removing;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Update install progress (static method for use in async closures).
|
||||
@@ -114,25 +127,28 @@ impl RpcHandler {
|
||||
downloaded: u64,
|
||||
total: u64,
|
||||
) {
|
||||
let (mut data, _rev) = state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size: total,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
state_manager.update_data(data).await;
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size: total,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a minimal PackageDataEntry for a package being installed.
|
||||
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
PackageDataEntry {
|
||||
ui_ready: None,
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
|
||||
@@ -1431,10 +1431,9 @@ async fn repair_before_package_start(container_name: &str) {
|
||||
// published port and the data-dir file locks, so the replacement either
|
||||
// fails to bind (`address already in use`) or starts and dies on the
|
||||
// lock — and `Restart=always` loops it there forever. Ordered before
|
||||
// the port cleanup below: killing the owner is what actually frees the
|
||||
// port, and the port sweep alone cannot tell a ghost from a live app.
|
||||
// starting the replacement. A port sweep cannot distinguish a ghost
|
||||
// from the dashboard gate or another live app and must never kill it.
|
||||
crate::container::ghost_reaper::reap_for_app(container_name).await;
|
||||
cleanup_runtime_host_ports(container_name).await;
|
||||
}
|
||||
|
||||
async fn wait_before_package_start(container_name: &str) {
|
||||
@@ -1579,7 +1578,6 @@ async fn repair_netbird_network() {
|
||||
async fn repair_nginx_proxy_manager_container() {
|
||||
repair_nginx_proxy_manager_dirs().await;
|
||||
if !nginx_proxy_manager_has_legacy_admin_port().await {
|
||||
cleanup_nginx_proxy_manager_ports().await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1588,7 +1586,7 @@ async fn repair_nginx_proxy_manager_container() {
|
||||
)
|
||||
.await;
|
||||
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
|
||||
cleanup_nginx_proxy_manager_ports().await;
|
||||
crate::container::ghost_reaper::reap_for_app("nginx-proxy-manager").await;
|
||||
if let Err(err) = recreate_nginx_proxy_manager_container().await {
|
||||
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
|
||||
}
|
||||
@@ -1812,6 +1810,9 @@ fn manifest_host_ports(container_name: &str) -> Vec<u16> {
|
||||
|
||||
pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
|
||||
dirs.push(root.into());
|
||||
}
|
||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
||||
}
|
||||
@@ -2032,51 +2033,10 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
|
||||
return;
|
||||
}
|
||||
|
||||
let ports = runtime_host_ports(container_name);
|
||||
if !ports.is_empty() {
|
||||
cleanup_ports(&ports).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_runtime_host_ports(container_name: &str) {
|
||||
let ports = runtime_host_ports(container_name);
|
||||
if !ports.is_empty() {
|
||||
cleanup_ports(&ports).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_nginx_proxy_manager_ports() {
|
||||
cleanup_ports(&[8081, 8084, 8444]).await;
|
||||
}
|
||||
|
||||
async fn cleanup_ports(ports: &[u16]) {
|
||||
for port in ports {
|
||||
cleanup_stale_pasta_port(&port.to_string()).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_stale_pasta_port(port: &str) {
|
||||
let kill_listener = format!(
|
||||
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
||||
port
|
||||
);
|
||||
let _ = tokio::process::Command::new("sh")
|
||||
.args(["-c", &kill_listener])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let pattern = format!("pasta.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
let pattern = format!("rootlessport.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
// Only reap processes proven to belong to an absent container. The app
|
||||
// gate shares the app's port on other addresses and lives in this daemon;
|
||||
// killing port owners (or matching argv with pkill) kills the dashboard.
|
||||
crate::container::ghost_reaper::reap_for_app(container_name).await;
|
||||
}
|
||||
|
||||
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
|
||||
@@ -2095,13 +2055,16 @@ async fn flip_package_state(
|
||||
package_id: &str,
|
||||
transitional: PackageState,
|
||||
) -> Option<PackageState> {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.state = transitional;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
prev
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = transitional;
|
||||
}
|
||||
prev
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Write the package entry's final state. No-op if the entry has since
|
||||
@@ -2111,13 +2074,18 @@ async fn set_package_state(
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if entry.state != new_state {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if entry.state != new_state {
|
||||
if new_state != PackageState::Running {
|
||||
entry.ui_ready = Some(false);
|
||||
}
|
||||
entry.state = new_state;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn reconcile_companions_for(package_id: &str) {
|
||||
@@ -2185,6 +2153,20 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn port_conflict_cleanup_preserves_live_host_listener() {
|
||||
// The previous ss|kill sweep terminated the daemon's app gate on a
|
||||
// restart. Keep a real listening socket owned by this test process.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.2:2342")
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
cleanup_start_conflict("photoprism", "address already in use").await;
|
||||
let client = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
let _connection = listener.accept().await.unwrap();
|
||||
drop(client);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_container_classifier_covers_podman5_phrasings() {
|
||||
// Regression (.228 gate 2026-07-08): podman 5.x `inspect` on a missing
|
||||
|
||||
@@ -153,8 +153,18 @@ impl RpcHandler {
|
||||
let default = app_catalog::catalog_default_version(app_id);
|
||||
let cfg = version_config::read(app_id);
|
||||
let installed = installed_version(app_id).await;
|
||||
let bitcoin_prune = if matches!(app_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
Some(
|
||||
crate::settings::bitcoin_storage::load(&self.config.data_dir)
|
||||
.await?
|
||||
.prune,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"bitcoinPrune": bitcoin_prune,
|
||||
"id": app_id,
|
||||
"supportsVersions": supports_versions(app_id),
|
||||
"default": default,
|
||||
|
||||
@@ -19,7 +19,7 @@ use tracing::{error, info, warn};
|
||||
const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
impl RpcHandler {
|
||||
/// Update a package to the version pinned in image-versions.sh.
|
||||
/// Update a package to the freshly verified catalog target.
|
||||
/// This is a manual operation — the user clicks "Update" in the UI.
|
||||
pub(in crate::api::rpc) async fn handle_package_update(
|
||||
&self,
|
||||
@@ -32,6 +32,21 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
// An Update click must not act on an hourly cache that predates the
|
||||
// button. Fetch and verify first; failure leaves running containers alone.
|
||||
crate::container::app_catalog::refresh_catalog(&self.config.data_dir)
|
||||
.await
|
||||
.context(
|
||||
"Cannot check the signed app catalog; update cancelled before changing containers",
|
||||
)?;
|
||||
if let Some(orch) = &self.orchestrator {
|
||||
// Reload even when bytes did not change: a previous reload may have
|
||||
// failed after the cache was written, or another refresher wrote it.
|
||||
orch.reload_manifests()
|
||||
.await
|
||||
.context("Cannot load current app manifests; update cancelled")?;
|
||||
}
|
||||
|
||||
// Resolve the target image. Prefer the remote app catalog (decoupled
|
||||
// from the binary OTA), falling back to the image-versions.sh pin. This
|
||||
// is OPTIONAL for orchestrator-managed apps: the orchestrator resolves
|
||||
@@ -42,6 +57,22 @@ impl RpcHandler {
|
||||
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
|
||||
.or_else(|| image_versions::pinned_image_for_app(package_id));
|
||||
|
||||
let targets = pinned
|
||||
.as_ref()
|
||||
.map(|target| self.resolve_images_to_pull(package_id, target));
|
||||
if let Some(targets) = &targets {
|
||||
let installed = inspect_update_images(package_id).await?;
|
||||
if !update_targets_need_change(targets, &installed)? {
|
||||
install_log(&format!(
|
||||
"UPDATE SKIP: {} — target versions already installed",
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
self.clear_install_progress(package_id).await;
|
||||
return Ok(serde_json::json!({"status": "up-to-date", "package_id": package_id}));
|
||||
}
|
||||
}
|
||||
|
||||
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||
// (the async wrapper that dispatch actually routes to). By the time
|
||||
// this inner function runs, the wrapper has already flipped state to
|
||||
@@ -80,6 +111,12 @@ impl RpcHandler {
|
||||
if let Some(orchestrator) = self.orchestrator.as_ref() {
|
||||
match orchestrator.upgrade(orchestrator_app_id).await {
|
||||
Ok(()) => {
|
||||
if let Some(targets) = &targets {
|
||||
verify_update_targets(
|
||||
targets,
|
||||
&inspect_update_images(package_id).await?,
|
||||
)?;
|
||||
}
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
if let Ok(health) = orchestrator.health(orchestrator_app_id).await {
|
||||
@@ -133,7 +170,8 @@ impl RpcHandler {
|
||||
};
|
||||
|
||||
// Resolve images to pull — either a stack or single container
|
||||
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
|
||||
let images_to_pull =
|
||||
targets.unwrap_or_else(|| self.resolve_images_to_pull(package_id, &pinned));
|
||||
|
||||
// Get all containers for this app
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
@@ -324,15 +362,22 @@ impl RpcHandler {
|
||||
.await;
|
||||
if let Ok(o) = status {
|
||||
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if state == "exited" {
|
||||
warn!(
|
||||
"Update {}: container {} exited after recreate",
|
||||
package_id, name
|
||||
);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
o.status.success() && state == "running",
|
||||
"Update {}: container {} is not running after recreate",
|
||||
package_id,
|
||||
name
|
||||
);
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Update {}: cannot inspect recreated container {}",
|
||||
package_id,
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
verify_update_targets(images_to_pull, &inspect_update_images(package_id).await?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -514,6 +559,98 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn inspect_update_images(package_id: &str) -> Result<Vec<(String, String)>> {
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
anyhow::ensure!(
|
||||
!containers.is_empty(),
|
||||
"No containers found for {}",
|
||||
package_id
|
||||
);
|
||||
let mut command = tokio::process::Command::new("podman");
|
||||
command.arg("inspect").args(&containers).kill_on_drop(true);
|
||||
let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output())
|
||||
.await
|
||||
.context("Timed out checking installed images")??;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Cannot inspect installed images; update cancelled"
|
||||
);
|
||||
let inspected: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)?;
|
||||
inspected
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let name = entry
|
||||
.get("Name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Container inspection omitted Name"))?;
|
||||
let image = entry
|
||||
.get("ImageName")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Container inspection omitted ImageName"))?;
|
||||
Ok((name.trim_start_matches('/').to_string(), image.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn installed_image_for_target<'a>(
|
||||
app_id: &str,
|
||||
installed: &'a [(String, String)],
|
||||
) -> Option<&'a str> {
|
||||
installed
|
||||
.iter()
|
||||
.find(|(name, _)| {
|
||||
candidate_app_ids_for_container(name)
|
||||
.iter()
|
||||
.any(|id| id == app_id)
|
||||
})
|
||||
.map(|(_, image)| image.as_str())
|
||||
}
|
||||
|
||||
/// A successful recreate is not proof that it used the downloaded image.
|
||||
fn verify_update_targets(
|
||||
targets: &[(String, String)],
|
||||
installed: &[(String, String)],
|
||||
) -> Result<()> {
|
||||
for (app_id, target) in targets {
|
||||
let running = installed_image_for_target(app_id, installed).ok_or_else(|| {
|
||||
anyhow::anyhow!("Update {}: target container missing after recreate", app_id)
|
||||
})?;
|
||||
anyhow::ensure!(
|
||||
image_versions::extract_version_from_image(target)
|
||||
== image_versions::extract_version_from_image(running)
|
||||
|| image_versions::compare_image_versions(target, running)
|
||||
== Some(std::cmp::Ordering::Equal),
|
||||
"Update {}: recreated container did not reach target version {}",
|
||||
app_id,
|
||||
image_versions::extract_version_from_image(target)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check every stack component, not just the version shown on its tile. A
|
||||
/// newer backend must still update when its frontend version is unchanged.
|
||||
/// A stale target for any component cancels before pulling or stopping anything.
|
||||
fn update_targets_need_change(
|
||||
targets: &[(String, String)],
|
||||
installed: &[(String, String)],
|
||||
) -> Result<bool> {
|
||||
use std::cmp::Ordering;
|
||||
let mut changed = false;
|
||||
for (app_id, target) in targets {
|
||||
let running = installed_image_for_target(app_id, installed);
|
||||
match running.and_then(|image| image_versions::compare_image_versions(target, image)) {
|
||||
Some(Ordering::Less) => anyhow::bail!(
|
||||
"Catalog target for {} is older than the installed image; refusing downgrade",
|
||||
app_id
|
||||
),
|
||||
Some(Ordering::Equal) => {}
|
||||
Some(Ordering::Greater) | None => changed = true,
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
|
||||
orchestrator_available && !uses_legacy_update_flow(package_id)
|
||||
}
|
||||
@@ -526,11 +663,14 @@ fn orchestrator_update_app_id(package_id: &str) -> &str {
|
||||
}
|
||||
|
||||
fn uses_legacy_update_flow(package_id: &str) -> bool {
|
||||
matches!(
|
||||
package_id,
|
||||
// Multi-container stacks still updated via the stack-aware path.
|
||||
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
|
||||
)
|
||||
// A primary container already at its target does not mean its backend or
|
||||
// database is current. Route every mapped stack through the component flow.
|
||||
!image_versions::containers_for_stack(package_id).is_empty()
|
||||
|| matches!(
|
||||
package_id,
|
||||
// Multi-container stacks still updated via the stack-aware path.
|
||||
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
|
||||
@@ -554,7 +694,12 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
"archy-bitcoin-ui" => push("bitcoin-ui"),
|
||||
"archy-lnd-ui" => push("lnd-ui"),
|
||||
"archy-electrs-ui" => push("electrs-ui"),
|
||||
"mempool" => {
|
||||
"mysql-mempool" => push("archy-mempool-db"),
|
||||
"btcpay" | "btcpayserver" | "archy-btcpay" => push("btcpay-server"),
|
||||
"homeassistant" | "archy-homeassistant" => push("home-assistant"),
|
||||
"fedimintd" => push("fedimint"),
|
||||
"electrs" | "mempool-electrs" => push("electrumx"),
|
||||
"mempool" | "mempool-web" => {
|
||||
push("archy-mempool-web");
|
||||
push("mempool");
|
||||
}
|
||||
@@ -572,27 +717,89 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::{
|
||||
candidate_app_ids_for_container, orchestrator_update_app_id,
|
||||
should_try_orchestrator_update, uses_legacy_update_flow,
|
||||
should_try_orchestrator_update, update_targets_need_change, uses_legacy_update_flow,
|
||||
verify_update_targets,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn mempool_update_preflight_rejects_stale_catalog_without_reinstalling() {
|
||||
let installed = vec![(
|
||||
"mempool".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
let stale = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
|
||||
)];
|
||||
assert!(update_targets_need_change(&stale, &installed).is_err());
|
||||
let current = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
assert!(!update_targets_need_change(¤t, &installed).unwrap());
|
||||
let legacy = vec![(
|
||||
"mempool-web".into(),
|
||||
"r.test/old/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
assert!(!update_targets_need_change(¤t, &legacy).unwrap());
|
||||
let newer = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy2".into(),
|
||||
)];
|
||||
assert!(update_targets_need_change(&newer, &installed).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_update_checks_backend_even_when_frontend_matches() {
|
||||
let installed = vec![
|
||||
("mempool".into(), "r.test/team/web:3.3.1-archy1".into()),
|
||||
("mempool-api".into(), "r.test/team/api:3.3.1".into()),
|
||||
];
|
||||
let mut targets = vec![
|
||||
(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/team/web:3.3.1-archy1".into(),
|
||||
),
|
||||
("mempool-api".into(), "r.test/team/api:3.3.2".into()),
|
||||
];
|
||||
assert!(update_targets_need_change(&targets, &installed).unwrap());
|
||||
targets[0].1 = "r.test/team/web:3.3.1".into();
|
||||
assert!(update_targets_need_change(&targets, &installed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_completion_requires_the_target_version_to_be_installed() {
|
||||
let targets = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
let mut installed = vec![(
|
||||
"mempool".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
|
||||
)];
|
||||
assert!(verify_update_targets(&targets, &installed).is_err());
|
||||
assert!(verify_update_targets(&targets, &[]).is_err());
|
||||
installed[0].1 = "r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into();
|
||||
assert!(verify_update_targets(&targets, &installed).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_flow_for_stack_apps() {
|
||||
for app in ["immich", "penpot", "indeedhub"] {
|
||||
for app in [
|
||||
"immich",
|
||||
"penpot",
|
||||
"indeedhub",
|
||||
"mempool",
|
||||
"btcpay-server",
|
||||
"netbird",
|
||||
] {
|
||||
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_flow_for_single_apps() {
|
||||
for app in [
|
||||
"lnd",
|
||||
"bitcoin-core",
|
||||
"searxng",
|
||||
"grafana",
|
||||
"btcpay-server",
|
||||
"mempool",
|
||||
"fedimint",
|
||||
] {
|
||||
for app in ["lnd", "bitcoin-core", "searxng", "grafana", "fedimint"] {
|
||||
assert!(
|
||||
!uses_legacy_update_flow(app),
|
||||
"{app} should be orchestrator-first"
|
||||
|
||||
@@ -150,23 +150,31 @@ async fn flip_to_transitional(
|
||||
app_id: &str,
|
||||
transitional: PackageState,
|
||||
) -> Option<PackageState> {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||
entry.state = transitional;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
prev
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||
entry.ui_ready = Some(false);
|
||||
entry.state = transitional;
|
||||
}
|
||||
prev
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Set the entry's state to `new_state`. No-ops if the entry has since been
|
||||
/// removed (e.g. uninstall ran concurrently).
|
||||
async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||
if entry.state != new_state {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
state_manager
|
||||
.mutate_data(|data| {
|
||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||
if entry.state != new_state {
|
||||
if new_state != PackageState::Running {
|
||||
entry.ui_ready = Some(false);
|
||||
}
|
||||
entry.state = new_state;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -114,6 +114,9 @@ impl PortMap {
|
||||
/// there.
|
||||
fn apps_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
|
||||
dirs.push(root.into());
|
||||
}
|
||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
|
||||
}
|
||||
|
||||
@@ -144,6 +144,34 @@ pub fn shared_status() -> Arc<RwLock<GateStatus>> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
static REFRESH_KICK: std::sync::LazyLock<tokio::sync::Notify> =
|
||||
std::sync::LazyLock::new(tokio::sync::Notify::new);
|
||||
static REFRESH_REV: std::sync::LazyLock<tokio::sync::watch::Sender<u64>> =
|
||||
std::sync::LazyLock::new(|| tokio::sync::watch::channel(0).0);
|
||||
|
||||
/// Installation must not wait for the minute sweep before becoming reachable.
|
||||
/// Wait for a completed sweep, bounded if shutdown/startup prevents one.
|
||||
pub async fn refresh_now() {
|
||||
let mut completed = REFRESH_REV.subscribe();
|
||||
REFRESH_KICK.notify_one();
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(3), completed.changed()).await;
|
||||
}
|
||||
|
||||
pub fn port_claimed(status: &GateStatus, port: u16) -> bool {
|
||||
let mut external = false;
|
||||
let mut tor = false;
|
||||
for (claimed_port, address) in &status.claimed {
|
||||
if *claimed_port != port {
|
||||
continue;
|
||||
}
|
||||
if let Ok(ip) = address.parse::<IpAddr>() {
|
||||
tor |= ip == GATE_TOR_UPSTREAM;
|
||||
external |= !ip.is_loopback();
|
||||
}
|
||||
}
|
||||
external && tor
|
||||
}
|
||||
|
||||
/// Run the gate. Returns only on shutdown.
|
||||
pub async fn run(
|
||||
gate: Arc<AppGate>,
|
||||
@@ -162,11 +190,12 @@ pub async fn run(
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
sweep(&gate, &status, &mut held, &shutdown_rx).await;
|
||||
}
|
||||
_ = interval.tick() => {}
|
||||
_ = REFRESH_KICK.notified() => {}
|
||||
_ = shutdown_rx.changed() => return,
|
||||
}
|
||||
sweep(&gate, &status, &mut held, &shutdown_rx).await;
|
||||
REFRESH_REV.send_modify(|revision| *revision = revision.wrapping_add(1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,3 +490,19 @@ mod tests {
|
||||
assert!(!status.is_fully_enforced());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod readiness_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn readiness_requires_external_and_tor_claims_for_the_same_port() {
|
||||
let mut status = GateStatus::default();
|
||||
assert!(!port_claimed(&status, 3001));
|
||||
status.claimed.push((3001, "127.0.0.2".into()));
|
||||
assert!(!port_claimed(&status, 3001));
|
||||
status.claimed.push((3002, "192.0.2.10".into()));
|
||||
assert!(!port_claimed(&status, 3001));
|
||||
status.claimed.push((3001, "192.0.2.10".into()));
|
||||
assert!(port_claimed(&status, 3001));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +322,7 @@ async fn eval_rpc_handler() -> (Arc<RpcHandler>, tempfile::TempDir) {
|
||||
fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry {
|
||||
use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles};
|
||||
PackageDataEntry {
|
||||
ui_ready: None,
|
||||
state: PackageState::Running,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
|
||||
@@ -1069,6 +1069,7 @@ mod tests {
|
||||
Description, Manifest, PackageDataEntry, PackageState, StaticFiles,
|
||||
};
|
||||
PackageDataEntry {
|
||||
ui_ready: None,
|
||||
state: PackageState::Running,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
|
||||
@@ -100,7 +100,11 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
|
||||
.trim()
|
||||
.trim_end_matches('.');
|
||||
let lower = detail.to_lowercase();
|
||||
let state = if lower.contains("verifying blocks") {
|
||||
let state = if lower.contains("loading block index") {
|
||||
Some("loading its block index. This can take a while after installation or restart")
|
||||
} else if lower.contains("replaying blocks") {
|
||||
Some("checking saved blocks before startup completes")
|
||||
} else if lower.contains("verifying blocks") {
|
||||
Some("verifying blocks after restart")
|
||||
} else if lower.contains("connection reset") {
|
||||
Some("starting up and not yet accepting RPC connections")
|
||||
@@ -340,3 +344,21 @@ mod tests {
|
||||
assert!(msg.len() < 260);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_message_tests {
|
||||
#[test]
|
||||
fn loading_block_index_is_explained_without_rpc_error_dump() {
|
||||
for cached in [false, true] {
|
||||
let message = super::friendly_transient_error(
|
||||
cached,
|
||||
r#"getblockchaininfo: Bitcoin RPC returned 500 Internal Server Error: {"error":{"code":-28,"message":"Loading block index…"}}"#,
|
||||
);
|
||||
assert!(message.contains("loading its block index"));
|
||||
for raw in ["500", "-28", "Detail:", "getblockchaininfo", "{", "RPC"] {
|
||||
assert!(!message.contains(raw));
|
||||
}
|
||||
assert_eq!(message.contains("last known state"), cached);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,44 @@ const NGINX_FEDIMINT_NEW: &str = " sub_filter_types text/css application/
|
||||
const NGINX_FEDIMINT_SNIPPET_ANCHOR: &str = "proxy_pass http://127.0.0.1:8175/;";
|
||||
const NGINX_FEDIMINT_SNIPPET_INSERT: &str = "proxy_pass http://127.0.0.1:8175/;\n proxy_set_header Accept-Encoding \"\";\n sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';";
|
||||
|
||||
/// Finish manifest promotion before constructing the orchestrator or starting
|
||||
/// catalog refresh/reconciliation. Replacing the app tree in the background
|
||||
/// could let a reload observe its temporary empty state and forget disk-only apps.
|
||||
pub async fn ensure_runtime_assets_ready() {
|
||||
match run_runtime_assets().await {
|
||||
Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"),
|
||||
Ok(_) => debug!("No OTA runtime payload to synchronize"),
|
||||
Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e),
|
||||
}
|
||||
// Repair the narrowly recognized legacy NPM tunnel override before app
|
||||
// reconciliation. The embedded script ships in both OTA and ISO binaries.
|
||||
// It preserves native wallet services and refuses unknown custom routing.
|
||||
match tokio::process::Command::new("python3")
|
||||
.arg("-c")
|
||||
.arg(include_str!("../../../scripts/repair-npm-tunnel.py"))
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
if !output.stdout.is_empty() {
|
||||
info!("{}", String::from_utf8_lossy(&output.stdout).trim());
|
||||
}
|
||||
}
|
||||
Ok(output) => warn!(
|
||||
"NPM tunnel migration needs attention: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
),
|
||||
Err(error) => warn!("NPM tunnel migration could not run: {error}"),
|
||||
}
|
||||
match run_apps_dir_repair().await {
|
||||
Ok(true) => {
|
||||
info!("Populated /opt/archipelago/apps from installer copy at /etc/archipelago/apps")
|
||||
}
|
||||
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
|
||||
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry point called from main startup. Never returns an error to the caller —
|
||||
/// failing to bootstrap host artifacts must not prevent the backend from serving.
|
||||
pub async fn ensure_doctor_installed() {
|
||||
@@ -146,11 +184,6 @@ pub async fn ensure_doctor_installed() {
|
||||
Ok(false) => debug!("No stale Archipelago dev-mode service override found"),
|
||||
Err(e) => warn!("Service override repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_runtime_assets().await {
|
||||
Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"),
|
||||
Ok(_) => debug!("No OTA runtime payload to synchronize"),
|
||||
Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run().await {
|
||||
Ok(changed) if changed => info!("Doctor artifacts synchronized with binary"),
|
||||
Ok(_) => debug!("Doctor artifacts already in sync"),
|
||||
@@ -168,13 +201,6 @@ pub async fn ensure_doctor_installed() {
|
||||
Ok(false) => debug!("No stale bitcoin.conf found"),
|
||||
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_apps_dir_repair().await {
|
||||
Ok(true) => {
|
||||
info!("Populated /opt/archipelago/apps from installer copy at /etc/archipelago/apps")
|
||||
}
|
||||
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
|
||||
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_tor_helper_sync().await {
|
||||
Ok(true) => info!("tor-helper.sh synchronized with binary"),
|
||||
Ok(false) => debug!("tor-helper.sh already current"),
|
||||
|
||||
@@ -102,6 +102,31 @@ pub struct AppCatalogEntry {
|
||||
/// `docs/registry-manifest-design.md`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manifest: Option<serde_json::Value>,
|
||||
/// Backward-compatible catalog rollout: old daemons ignore these and keep
|
||||
/// the base manifest. New daemons choose only variants they can safely apply.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub manifest_variants: Vec<CatalogManifestVariant>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CatalogManifestVariant {
|
||||
pub requires: Vec<String>,
|
||||
pub manifest: serde_json::Value,
|
||||
}
|
||||
|
||||
fn selected_manifest(entry: AppCatalogEntry) -> Option<serde_json::Value> {
|
||||
// Never let an unknown future requirement become an unsafe partial match.
|
||||
for variant in entry.manifest_variants.into_iter().rev() {
|
||||
if !variant.requires.is_empty()
|
||||
&& variant
|
||||
.requires
|
||||
.iter()
|
||||
.all(|capability| capability == "runtime-migration-backup-v1")
|
||||
{
|
||||
return Some(variant.manifest);
|
||||
}
|
||||
}
|
||||
entry.manifest
|
||||
}
|
||||
|
||||
/// One selectable version in an app's `versions[]` list. The catalog carries a
|
||||
@@ -234,7 +259,7 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
|
||||
load_catalog()
|
||||
.apps
|
||||
.into_iter()
|
||||
.filter_map(|(id, e)| e.manifest.map(|m| (id, m)))
|
||||
.filter_map(|(id, e)| selected_manifest(e).map(|m| (id, m)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -400,7 +425,7 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
|
||||
}
|
||||
if let Some(catalog_image) = catalog_primary_image(app_id) {
|
||||
// Catalog covers this app with a concrete image -> authoritative.
|
||||
return crate::container::image_versions::available_update_for_images(
|
||||
return crate::container::image_versions::available_catalog_update_for_images(
|
||||
&catalog_image,
|
||||
running_image,
|
||||
);
|
||||
@@ -557,6 +582,32 @@ fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result<bool> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn catalog_migration_variant_is_compatible_with_old_and_future_daemons() {
|
||||
let raw = serde_json::json!({
|
||||
"version": "2.45.0", "manifest": {"app": {"id": "portainer", "container": {}}},
|
||||
"manifest_variants": [{"requires": ["runtime-migration-backup-v1"],
|
||||
"manifest": {"app": {"id": "portainer", "container": {"network": "slirp4netns"}, "backup_before_runtime_change": true}}}]
|
||||
});
|
||||
#[derive(Deserialize)]
|
||||
struct OldEntry {
|
||||
manifest: serde_json::Value,
|
||||
}
|
||||
let old: OldEntry = serde_json::from_value(raw.clone()).unwrap();
|
||||
assert!(old.manifest["app"]["container"].get("network").is_none());
|
||||
let current: AppCatalogEntry = serde_json::from_value(raw.clone()).unwrap();
|
||||
let chosen = selected_manifest(current).unwrap();
|
||||
assert_eq!(chosen["app"]["container"]["network"], "slirp4netns");
|
||||
assert_eq!(chosen["app"]["backup_before_runtime_change"], true);
|
||||
let mut future = raw;
|
||||
future["manifest_variants"][0]["requires"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(serde_json::json!("unknown-next-capability"));
|
||||
let chosen = selected_manifest(serde_json::from_value(future).unwrap()).unwrap();
|
||||
assert!(chosen["app"]["container"].get("network").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_and_ignores_unknown_fields() {
|
||||
let json = r#"{
|
||||
|
||||
@@ -313,7 +313,7 @@ async fn image_id(image_ref: &str) -> Option<String> {
|
||||
/// should reference (`localhost/<base>:latest` for build, registry
|
||||
/// URL for pull).
|
||||
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
let local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let mut local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let local_image_compat = format!("localhost/{}:local", spec.image_base);
|
||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
|
||||
@@ -322,11 +322,13 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
// `:local` is a deliberate manual override — never auto-rebuild it.
|
||||
// Older installers and self-update create :local themselves. It
|
||||
// must receive source updates too; treating it as a permanent
|
||||
// manual override silently kept the old LND UI after an OTA.
|
||||
if image_exists(&local_image_compat).await {
|
||||
return Ok(local_image_compat);
|
||||
local_image = local_image_compat.clone();
|
||||
}
|
||||
// Reuse the auto-built `:latest` only when the build context has NOT
|
||||
// Reuse either local tag only when the build context has NOT
|
||||
// changed since it was built. Without this staleness check an
|
||||
// already-present image is reused forever, so edits to the baked-in
|
||||
// context (Dockerfile, nginx.conf, …) never reach the node — this is
|
||||
@@ -849,20 +851,43 @@ async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
||||
if !matches_known_shape {
|
||||
return Ok(true);
|
||||
}
|
||||
if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
|
||||
if let Some(image) = managed_local_image(spec, &on_disk) {
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
// Conservative on any timeout/error inside: reuse the cache.
|
||||
return Ok(context_is_newer_than_image(dir, &local_image).await);
|
||||
return Ok(context_is_newer_than_image(dir, &image).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn managed_local_image(spec: &CompanionSpec, unit: &str) -> Option<String> {
|
||||
["latest", "local"]
|
||||
.iter()
|
||||
.map(|tag| format!("localhost/{}:{tag}", spec.image_base))
|
||||
.find(|image| build_unit(spec, image).render() == unit)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn legacy_installer_local_tag_is_checked_for_source_updates_like_latest() {
|
||||
for spec in ALL_COMPANIONS.iter().flat_map(|group| group.iter()) {
|
||||
for tag in ["local", "latest"] {
|
||||
let image = format!("localhost/{}:{tag}", spec.image_base);
|
||||
let unit = build_unit(spec, &image).render();
|
||||
assert_eq!(managed_local_image(spec, &unit), Some(image));
|
||||
}
|
||||
let registry = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
assert_eq!(
|
||||
managed_local_image(spec, &build_unit(spec, ®istry).render()),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> {
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use archipelago_container::{
|
||||
ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient,
|
||||
ContainerRuntime as ContainerRuntimeTrait, ContainerState, ContainerStatus, PodmanClient,
|
||||
};
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info};
|
||||
@@ -15,6 +16,16 @@ use crate::data_model::{
|
||||
PackageDataEntry, PackageState, ServiceStatus, StaticFiles,
|
||||
};
|
||||
|
||||
/// One displayed package for each known container/manifest alias. Keep the
|
||||
/// stopped-app restoration path in agreement with live-container discovery.
|
||||
fn canonical_package_id(name: &str) -> &str {
|
||||
match name.strip_prefix("archy-").unwrap_or(name) {
|
||||
"immich_server" => "immich",
|
||||
"mempool-web" | "mempool-frontend" => "mempool",
|
||||
name => name,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DockerPackageScanner {
|
||||
runtime: Arc<dyn ContainerRuntimeTrait>,
|
||||
}
|
||||
@@ -25,8 +36,15 @@ impl DockerPackageScanner {
|
||||
}
|
||||
|
||||
/// Scan Docker containers and convert to package data
|
||||
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
|
||||
let containers = self.runtime.list_containers().await?;
|
||||
pub async fn scan_containers(
|
||||
&self,
|
||||
data_dir: &std::path::Path,
|
||||
cached: &HashMap<String, PackageDataEntry>,
|
||||
) -> Result<HashMap<String, PackageDataEntry>> {
|
||||
let mut containers = self.runtime.list_containers().await?;
|
||||
let installed = crate::crash_recovery::load_installed_apps(data_dir).await;
|
||||
let uninstalled = crate::crash_recovery::load_user_uninstalled(data_dir).await;
|
||||
restore_absent_installed(&mut containers, &installed, &uninstalled);
|
||||
|
||||
debug!("Found {} containers", containers.len());
|
||||
|
||||
@@ -91,24 +109,8 @@ impl DockerPackageScanner {
|
||||
debug!("Found {} UI containers", ui_containers.len());
|
||||
|
||||
for container in containers {
|
||||
// Extract app ID from container name
|
||||
// Support both archy-* containers (docker-compose) and plain names (manual)
|
||||
let app_id = if container.name.starts_with("archy-") {
|
||||
container
|
||||
.name
|
||||
.strip_prefix("archy-")
|
||||
.unwrap_or(&container.name)
|
||||
.to_string()
|
||||
} else {
|
||||
// Use the container name as-is for manually started containers
|
||||
container.name.clone()
|
||||
};
|
||||
|
||||
// Normalize multi-container app IDs to their canonical names
|
||||
let app_id = match app_id.as_str() {
|
||||
"immich_server" => "immich".to_string(),
|
||||
_ => app_id,
|
||||
};
|
||||
// Use the same alias mapping as stopped-app restoration.
|
||||
let app_id = canonical_package_id(&container.name).to_owned();
|
||||
|
||||
// Skip backend services (databases, APIs, etc.)
|
||||
if excluded_services.contains(&app_id.as_str()) {
|
||||
@@ -139,6 +141,18 @@ impl DockerPackageScanner {
|
||||
continue;
|
||||
}
|
||||
|
||||
if container.id.is_empty() {
|
||||
if let Some(previous) = cached.get(&app_id) {
|
||||
let mut held = previous.clone();
|
||||
held.state = PackageState::Stopped;
|
||||
held.ui_ready = Some(false);
|
||||
held.health = None;
|
||||
held.exit_code = None;
|
||||
packages.insert(app_id.clone(), held);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get metadata for this app
|
||||
let metadata = get_app_metadata(&app_id);
|
||||
// Manifest-owned metadata (icon) wins over the static table: the
|
||||
@@ -158,13 +172,11 @@ impl DockerPackageScanner {
|
||||
} else {
|
||||
// Prefer the known web UI port over arbitrary first binding
|
||||
// (for example Gitea exposes SSH on 2222 before web on 3001).
|
||||
let candidate = if uses_allocated_launch_port(&app_id) {
|
||||
extract_lan_address(&container.ports)
|
||||
.or_else(|| PodmanClient::lan_address_for(&app_id))
|
||||
} else {
|
||||
PodmanClient::lan_address_for(&app_id)
|
||||
.or_else(|| extract_lan_address(&container.ports))
|
||||
};
|
||||
let candidate = package_launch_candidate(
|
||||
&app_id,
|
||||
&container.ports,
|
||||
PodmanClient::lan_address_for(&app_id),
|
||||
);
|
||||
reachable_lan_address(&app_id, candidate).await
|
||||
};
|
||||
|
||||
@@ -179,14 +191,22 @@ impl DockerPackageScanner {
|
||||
let tor_address = read_tor_address(&app_id).await;
|
||||
|
||||
// Extract actual version from container image tag
|
||||
let running_version = image_versions::extract_version_from_image(&container.image);
|
||||
let running_version = if container.id.is_empty() {
|
||||
String::new() // Absence cannot establish the installed image version.
|
||||
} else {
|
||||
image_versions::extract_version_from_image(&container.image)
|
||||
};
|
||||
|
||||
// Decoupled from the binary OTA: prefer the remote app catalog,
|
||||
// falling back to the image-versions.sh pin when uncovered/offline.
|
||||
let available_update =
|
||||
crate::container::app_catalog::available_update_for_app(&app_id, &container.image);
|
||||
let available_update = if container.id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
crate::container::app_catalog::available_update_for_app(&app_id, &container.image)
|
||||
};
|
||||
|
||||
let package = PackageDataEntry {
|
||||
ui_ready: Some(false),
|
||||
state: package_state.clone(),
|
||||
health: container.health.clone(),
|
||||
exit_code: if package_state == PackageState::Exited {
|
||||
@@ -283,10 +303,237 @@ impl DockerPackageScanner {
|
||||
);
|
||||
}
|
||||
|
||||
let probes: Vec<_> = packages
|
||||
.iter()
|
||||
.filter_map(|(id, pkg)| {
|
||||
if pkg.state != PackageState::Running {
|
||||
return None;
|
||||
}
|
||||
let url = pkg
|
||||
.installed
|
||||
.as_ref()?
|
||||
.interface_addresses
|
||||
.get("main")?
|
||||
.lan_address
|
||||
.clone()?;
|
||||
Some((id.clone(), url))
|
||||
})
|
||||
.collect();
|
||||
let mut results = futures_util::stream::iter(
|
||||
probes
|
||||
.into_iter()
|
||||
.map(|(id, url)| async move { (id, launch_http_ready(&url).await) }),
|
||||
)
|
||||
.buffer_unordered(8);
|
||||
while let Some((id, ready)) = results.next().await {
|
||||
if let Some(pkg) = packages.get_mut(&id) {
|
||||
pkg.ui_ready = Some(ready);
|
||||
}
|
||||
}
|
||||
// HTTP on loopback can precede the LAN/Tor listener after install.
|
||||
let port_map = crate::appgate::identity::build_port_map();
|
||||
let gated: Vec<_> = packages
|
||||
.iter()
|
||||
.filter_map(|(id, pkg)| {
|
||||
if pkg.ui_ready != Some(true) {
|
||||
return None;
|
||||
}
|
||||
let url = pkg
|
||||
.installed
|
||||
.as_ref()?
|
||||
.interface_addresses
|
||||
.get("main")?
|
||||
.lan_address
|
||||
.as_deref()?;
|
||||
let port = launch_url_port(url)?;
|
||||
port_map
|
||||
.gated(port)
|
||||
.filter(|gate| gate.declared)
|
||||
.map(|_| (id.clone(), port))
|
||||
})
|
||||
.collect();
|
||||
if !gated.is_empty() {
|
||||
use crate::appgate::listener::{port_claimed, refresh_now, shared_status};
|
||||
let status = shared_status();
|
||||
let needs_refresh = {
|
||||
let current = status.read().await;
|
||||
gated.iter().any(|(_, port)| !port_claimed(¤t, *port))
|
||||
};
|
||||
if needs_refresh {
|
||||
refresh_now().await;
|
||||
}
|
||||
let current = status.read().await;
|
||||
for (id, port) in gated {
|
||||
if !port_claimed(¤t, port) {
|
||||
packages.get_mut(&id).unwrap().ui_ready = Some(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(packages)
|
||||
}
|
||||
}
|
||||
|
||||
/// Quadlet removes containers during ordinary stops/restarts. Rebuild installed
|
||||
/// entries even on the daemon's first scan; a runtime absence is not uninstall.
|
||||
fn restore_absent_installed(
|
||||
containers: &mut Vec<ContainerStatus>,
|
||||
installed: &std::collections::HashSet<String>,
|
||||
uninstalled: &std::collections::HashSet<String>,
|
||||
) {
|
||||
let mut present: std::collections::HashSet<String> = containers
|
||||
.iter()
|
||||
.map(|c| canonical_package_id(&c.name).to_owned())
|
||||
.collect();
|
||||
let removed: std::collections::HashSet<_> = uninstalled
|
||||
.iter()
|
||||
.map(|id| canonical_package_id(id))
|
||||
.collect();
|
||||
for name in installed {
|
||||
let id = canonical_package_id(name);
|
||||
if removed.contains(id) || !present.insert(id.to_owned()) {
|
||||
continue;
|
||||
}
|
||||
containers.push(ContainerStatus {
|
||||
id: String::new(),
|
||||
name: id.to_owned(),
|
||||
state: ContainerState::Stopped,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
started_at: None,
|
||||
image: String::new(),
|
||||
created: String::new(),
|
||||
ports: Vec::new(),
|
||||
lan_address: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the actual loopback upstream, not the app gate's login page. A bound
|
||||
/// TCP socket alone can still reset requests or serve a startup 503.
|
||||
async fn launch_http_ready(candidate: &str) -> bool {
|
||||
let Ok(mut url) = reqwest::Url::parse(candidate) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return false;
|
||||
}
|
||||
if url.set_host(Some("127.0.0.1")).is_err() {
|
||||
return false;
|
||||
}
|
||||
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
|
||||
let client = CLIENT.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
// Self-signed local app certificates are normal. This client only
|
||||
// contacts loopback and never sends credentials or follows redirects.
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.expect("local readiness client")
|
||||
});
|
||||
match client.get(url).send().await {
|
||||
Ok(response) => matches!(response.status().as_u16(), 200..=399 | 401 | 403),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lifecycle_regression_tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[test]
|
||||
fn registry_survives_empty_runtime_and_deduplicates_aliases() {
|
||||
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let removed = ["removed".to_owned()].into_iter().collect();
|
||||
let mut containers = Vec::new();
|
||||
restore_absent_installed(&mut containers, &installed, &removed);
|
||||
assert_eq!(containers.len(), 2);
|
||||
assert!(containers
|
||||
.iter()
|
||||
.all(|c| c.state == ContainerState::Stopped));
|
||||
containers[0].state = ContainerState::Running;
|
||||
restore_absent_installed(&mut containers, &installed, &removed);
|
||||
assert_eq!(containers.len(), 2);
|
||||
assert_eq!(containers[0].state, ContainerState::Running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mempool_frontend_inventory_alias_does_not_create_a_second_package() {
|
||||
let installed = ["mempool", "archy-mempool-web", "mempool-web"]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let mut containers = Vec::new();
|
||||
restore_absent_installed(&mut containers, &installed, &Default::default());
|
||||
assert_eq!(containers.len(), 1);
|
||||
assert_eq!(containers[0].name, "mempool");
|
||||
containers[0].id = "live-frontend".into();
|
||||
containers[0].state = ContainerState::Running;
|
||||
restore_absent_installed(&mut containers, &installed, &Default::default());
|
||||
assert_eq!(containers.len(), 1);
|
||||
assert_eq!(containers[0].id, "live-frontend");
|
||||
assert_eq!(containers[0].state, ContainerState::Running);
|
||||
assert_eq!(canonical_package_id("archy-mempool-web"), "mempool");
|
||||
assert_eq!(canonical_package_id("mempool-api"), "mempool-api");
|
||||
containers.clear();
|
||||
restore_absent_installed(
|
||||
&mut containers,
|
||||
&installed,
|
||||
&["mempool".into()].into_iter().collect(),
|
||||
);
|
||||
assert!(containers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_rejects_startup_errors_and_accepts_auth_and_redirects() {
|
||||
for (status, expected) in [
|
||||
(200, true),
|
||||
(302, true),
|
||||
(401, true),
|
||||
(403, true),
|
||||
(404, false),
|
||||
(500, false),
|
||||
(502, false),
|
||||
(503, false),
|
||||
] {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let task = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0; 2048];
|
||||
let n = stream.read(&mut buf).await.unwrap();
|
||||
assert!(String::from_utf8_lossy(&buf[..n]).starts_with("GET /start HTTP/1.1"));
|
||||
stream.write_all(format!("HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").as_bytes()).await.unwrap();
|
||||
});
|
||||
assert_eq!(
|
||||
launch_http_ready(&format!("http://localhost:{port}/start")).await,
|
||||
expected,
|
||||
"status {status}"
|
||||
);
|
||||
task.await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_rejects_tcp_accept_without_http() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let task = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
drop(stream);
|
||||
});
|
||||
assert!(!launch_http_ready(&format!("http://localhost:{port}/")).await);
|
||||
task.await.unwrap();
|
||||
assert!(!launch_http_ready(&format!("http://localhost:{port}/")).await);
|
||||
assert!(!launch_http_ready("file:///tmp/test").await);
|
||||
}
|
||||
}
|
||||
|
||||
struct AppMetadata {
|
||||
title: String,
|
||||
description: String,
|
||||
@@ -856,6 +1103,23 @@ fn companion_lan_address(app_id: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Companion dashboards remain usable while their backend is syncing. Never
|
||||
/// probe a Bitcoin RPC or Electrum protocol socket as dashboard readiness.
|
||||
fn package_launch_candidate(
|
||||
app_id: &str,
|
||||
ports: &[String],
|
||||
known: Option<String>,
|
||||
) -> Option<String> {
|
||||
if let Some(companion) = companion_lan_address(app_id) {
|
||||
return Some(companion);
|
||||
}
|
||||
if uses_allocated_launch_port(app_id) {
|
||||
extract_lan_address(ports).or(known)
|
||||
} else {
|
||||
known.or_else(|| extract_lan_address(ports))
|
||||
}
|
||||
}
|
||||
|
||||
fn uses_allocated_launch_port(app_id: &str) -> bool {
|
||||
matches!(
|
||||
app_id,
|
||||
@@ -940,7 +1204,42 @@ mod tor_service_name_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod extract_lan_address_tests {
|
||||
use super::extract_lan_address;
|
||||
use super::{extract_lan_address, package_launch_candidate};
|
||||
|
||||
#[test]
|
||||
fn companion_dashboard_wins_over_backend_protocol_ports() {
|
||||
for id in ["bitcoin", "bitcoin-core", "bitcoin-knots"] {
|
||||
assert_eq!(
|
||||
package_launch_candidate(
|
||||
id,
|
||||
&["127.0.0.1:8332->8332/tcp".into()],
|
||||
Some("http://localhost:8332".into())
|
||||
)
|
||||
.as_deref(),
|
||||
Some("http://localhost:8334")
|
||||
);
|
||||
}
|
||||
for id in ["electrumx", "electrs", "mempool-electrs"] {
|
||||
assert_eq!(
|
||||
package_launch_candidate(
|
||||
id,
|
||||
&["127.0.0.1:50001->50001/tcp".into()],
|
||||
Some("http://localhost:50001".into())
|
||||
)
|
||||
.as_deref(),
|
||||
Some("http://localhost:50002")
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
package_launch_candidate(
|
||||
"filebrowser",
|
||||
&["127.0.0.1:19080->80/tcp".into()],
|
||||
Some("http://localhost:8080".into())
|
||||
)
|
||||
.as_deref(),
|
||||
Some("http://localhost:19080")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_ssh_port_when_web_port_is_published() {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! starting the container with `--config /data/.filebrowser.json`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::update::host_sudo;
|
||||
@@ -117,6 +117,197 @@ fn shell_quote(s: &str) -> String {
|
||||
s.replace('\'', "'\\''")
|
||||
}
|
||||
|
||||
/// Save a complete purchase without overwriting any existing directory entry.
|
||||
/// Both host and rootless-namespace paths publish with a no-clobber hard link.
|
||||
pub async fn save_new_file(dir: &Path, name: &str, bytes: &[u8]) -> Result<PathBuf> {
|
||||
save_new_file_with(dir, name, bytes, write_via_userns).await
|
||||
}
|
||||
|
||||
fn validate_filename(name: &str) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
!name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& !name.contains(['/', '\\', '\0'])
|
||||
&& name.len() <= 255,
|
||||
"Invalid purchased filename"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_new_file_with<F, Fut>(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
fallback: F,
|
||||
) -> Result<PathBuf>
|
||||
where
|
||||
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<PathBuf>>,
|
||||
{
|
||||
validate_filename(name)?;
|
||||
// Never follow a user-created destination directory symlink.
|
||||
match fs::symlink_metadata(dir).await {
|
||||
Ok(meta) => anyhow::ensure!(meta.is_dir(), "Files destination is not a directory"),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
save_after_direct_result(
|
||||
write_direct(dir, name, bytes).await,
|
||||
dir,
|
||||
name,
|
||||
bytes,
|
||||
fallback,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn save_after_direct_result<F, Fut>(
|
||||
result: std::io::Result<PathBuf>,
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
fallback: F,
|
||||
) -> Result<PathBuf>
|
||||
where
|
||||
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<PathBuf>>,
|
||||
{
|
||||
match result {
|
||||
Ok(path) => Ok(path),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
fallback(dir.to_owned(), name.to_owned(), bytes.to_vec())
|
||||
.await
|
||||
.context("Saving purchase in Files user namespace")
|
||||
}
|
||||
Err(error) => Err(error).context("Saving purchase in Files"),
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered_name(name: &str, attempt: usize) -> String {
|
||||
if attempt == 1 {
|
||||
return name.to_owned();
|
||||
}
|
||||
match name.rsplit_once('.') {
|
||||
Some((stem, extension)) if !stem.is_empty() => format!("{stem} ({attempt}).{extension}"),
|
||||
_ => format!("{name} ({attempt})"),
|
||||
}
|
||||
}
|
||||
|
||||
struct PendingFile(PathBuf);
|
||||
impl Drop for PendingFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_direct(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
fs::create_dir_all(dir).await?;
|
||||
let temp_path = dir.join(format!(".archy-saving-{}", uuid::Uuid::new_v4()));
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temp_path)
|
||||
.await?;
|
||||
let temp = PendingFile(temp_path);
|
||||
file.write_all(bytes).await?;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o644))
|
||||
.await?;
|
||||
file.sync_all().await?;
|
||||
for attempt in 1..=100 {
|
||||
let target = dir.join(numbered_name(name, attempt));
|
||||
match fs::hard_link(&temp.0, &target).await {
|
||||
Ok(()) => return Ok(target),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"Too many existing copies; purchase cache retained",
|
||||
))
|
||||
}
|
||||
|
||||
// Positional arguments carry all user-controlled text. mktemp prevents temp-name
|
||||
// collisions; ln -T refuses files, symlinks and directories, including races.
|
||||
const WRITE_VIA_USERNS: &str = r#"set -eu
|
||||
dir=$1
|
||||
name=$2
|
||||
expected=$3
|
||||
[ ! -L "$dir" ] || exit 1
|
||||
if [ ! -d "$dir" ]; then
|
||||
mkdir -p -- "$dir"
|
||||
chown --reference="$(dirname -- "$dir")" -- "$dir"
|
||||
fi
|
||||
tmp=$(mktemp "$dir/.archy-saving.XXXXXXXXXX")
|
||||
trap 'rm -f -- "$tmp"' EXIT HUP INT TERM
|
||||
cat > "$tmp"
|
||||
[ "$(wc -c < "$tmp")" -eq "$expected" ] || exit 1
|
||||
chown --reference="$dir" -- "$tmp"
|
||||
chmod 0644 -- "$tmp"
|
||||
sync -f -- "$tmp"
|
||||
stem=$name
|
||||
ext=
|
||||
case "$name" in
|
||||
*.*) prefix=${name%.*}; if [ -n "$prefix" ]; then stem=$prefix; ext=.${name##*.}; fi ;;
|
||||
esac
|
||||
n=1
|
||||
while [ "$n" -le 100 ]; do
|
||||
candidate=$name
|
||||
if [ "$n" -gt 1 ]; then candidate="$stem ($n)$ext"; fi
|
||||
dst="$dir/$candidate"
|
||||
if ln -T -- "$tmp" "$dst" 2>/dev/null; then
|
||||
printf '%s' "$candidate"
|
||||
exit 0
|
||||
fi
|
||||
# A conflict may be a dangling symlink; never follow it or overwrite it.
|
||||
if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then exit 1; fi
|
||||
n=$((n + 1))
|
||||
done
|
||||
exit 1
|
||||
"#;
|
||||
|
||||
async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec<u8>) -> Result<PathBuf> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["unshare", "sh", "-c", WRITE_VIA_USERNS, "sh"])
|
||||
.arg(&dir)
|
||||
.arg(&name)
|
||||
.arg(bytes.len().to_string())
|
||||
.kill_on_drop(true)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("Starting Files namespace writer")?;
|
||||
let mut stdin = child.stdin.take().context("Files writer stdin missing")?;
|
||||
let operation = async {
|
||||
let fed = stdin.write_all(&bytes).await;
|
||||
drop(stdin);
|
||||
let output = child.wait_with_output().await?;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Files namespace writer failed: {}",
|
||||
output.status
|
||||
);
|
||||
fed.context("Sending purchase bytes to Files")?;
|
||||
let chosen =
|
||||
String::from_utf8(output.stdout).context("Files writer returned an invalid name")?;
|
||||
validate_filename(&chosen)?;
|
||||
anyhow::ensure!(
|
||||
(1..=100).any(|n| numbered_name(&name, n) == chosen),
|
||||
"Files writer returned an unexpected name"
|
||||
);
|
||||
Ok(dir.join(chosen))
|
||||
};
|
||||
tokio::time::timeout(std::time::Duration::from_secs(120), operation)
|
||||
.await
|
||||
.context("Files namespace writer timed out")?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -152,3 +343,231 @@ mod tests {
|
||||
assert_eq!(second, EnsureOutcome::Unchanged);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod purchase_write_tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
os::unix::fs::{symlink, PermissionsExt},
|
||||
};
|
||||
|
||||
fn no_temps(dir: &Path) {
|
||||
assert!(std::fs::read_dir(dir).unwrap().all(|e| !e
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".archy-saving")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_write_uses_complete_bytes_and_preserves_originals() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("song.mp3"), b"original")
|
||||
.await
|
||||
.unwrap();
|
||||
let target = save_new_file(dir.path(), "song.mp3", b"new").await.unwrap();
|
||||
assert_eq!(target.file_name().unwrap(), "song (2).mp3");
|
||||
assert_eq!(fs::read(target).await.unwrap(), b"new");
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join("song.mp3")).await.unwrap(),
|
||||
b"original"
|
||||
);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn simultaneous_saves_publish_unique_complete_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut tasks = Vec::new();
|
||||
for n in 0..24u8 {
|
||||
let dir = dir.path().to_owned();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let bytes = vec![n; 32768];
|
||||
let path = save_new_file(&dir, "same.bin", &bytes).await.unwrap();
|
||||
assert_eq!(fs::read(&path).await.unwrap(), bytes);
|
||||
path
|
||||
}));
|
||||
}
|
||||
let mut paths = HashSet::new();
|
||||
for task in tasks {
|
||||
assert!(paths.insert(task.await.unwrap()));
|
||||
}
|
||||
assert_eq!(paths.len(), 24);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_directories_and_dangling_symlinks_are_conflicts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("name")).await.unwrap();
|
||||
symlink("missing", dir.path().join("name (2)")).unwrap();
|
||||
let path = save_new_file(dir.path(), "name", b"new").await.unwrap();
|
||||
assert_eq!(path.file_name().unwrap(), "name (3)");
|
||||
assert!(dir.path().join("name").is_dir());
|
||||
assert!(fs::symlink_metadata(dir.path().join("name (2)"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_symlink());
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_names_and_symlink_destination_are_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"../escape",
|
||||
"/absolute",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
"a\0b",
|
||||
] {
|
||||
assert!(save_new_file(dir.path(), name, b"bytes").await.is_err());
|
||||
}
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
symlink(outside.path(), dir.path().join("Music")).unwrap();
|
||||
assert!(save_new_file(&dir.path().join("Music"), "song", b"bytes")
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collision_limit_preserves_all_files_and_cleans_temporary_data() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for n in 1..=100 {
|
||||
fs::write(dir.path().join(numbered_name("a.txt", n)), b"keep")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert!(save_new_file(dir.path(), "a.txt", b"new").await.is_err());
|
||||
for n in 1..=100 {
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join(numbered_name("a.txt", n)))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"keep"
|
||||
);
|
||||
}
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permission_fallback_is_exercised_without_skipping_as_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = save_after_direct_result(
|
||||
Err(std::io::ErrorKind::PermissionDenied.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|dir, name, bytes| async move {
|
||||
assert_eq!(bytes, b"abc");
|
||||
Ok(dir.join(name))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, dir.path().join("a"));
|
||||
assert!(save_after_direct_result(
|
||||
Err(std::io::ErrorKind::PermissionDenied.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|_, _, _| async { anyhow::bail!("namespace unavailable") }
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("namespace"));
|
||||
assert!(save_after_direct_result(
|
||||
Err(std::io::ErrorKind::StorageFull.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|_, _, _| async { panic!("disk full must not trigger permission fallback") }
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
async fn run_script(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
expected: usize,
|
||||
) -> std::process::Output {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut child = tokio::process::Command::new("sh")
|
||||
.args(["-c", WRITE_VIA_USERNS, "sh"])
|
||||
.arg(dir)
|
||||
.arg(name)
|
||||
.arg(expected.to_string())
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
input.write_all(bytes).await.unwrap();
|
||||
drop(input);
|
||||
child.wait_with_output().await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_preserves_names_bytes_modes_and_existing_entries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let folder = dir.path().join("Music");
|
||||
let name = "song ' $() ; #.mp3";
|
||||
for n in 1..=2 {
|
||||
let output = run_script(&folder, name, b"abc", 3).await;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let chosen = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(chosen, numbered_name(name, n));
|
||||
let path = folder.join(chosen);
|
||||
assert_eq!(fs::read(&path).await.unwrap(), b"abc");
|
||||
assert_eq!(
|
||||
fs::metadata(path).await.unwrap().permissions().mode() & 0o777,
|
||||
0o644
|
||||
);
|
||||
}
|
||||
no_temps(&folder);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_refuses_truncated_input_and_cleans_up() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = run_script(dir.path(), "never.bin", b"partial", 100).await;
|
||||
assert!(!output.status.success());
|
||||
assert!(!dir.path().join("never.bin").exists());
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_does_not_link_inside_existing_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("name")).await.unwrap();
|
||||
symlink("missing", dir.path().join("name (2)")).unwrap();
|
||||
let output = run_script(dir.path(), "name", b"abc", 3).await;
|
||||
assert!(output.status.success());
|
||||
assert_eq!(output.stdout, b"name (3)");
|
||||
assert_eq!(
|
||||
std::fs::read_dir(dir.path().join("name")).unwrap().count(),
|
||||
0
|
||||
);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_keep_extensions_and_dotfiles() {
|
||||
assert_eq!(numbered_name("a.tar.gz", 2), "a.tar (2).gz");
|
||||
assert_eq!(numbered_name(".hidden", 2), ".hidden (2)");
|
||||
assert_eq!(numbered_name("README", 2), "README (2)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,12 @@ fn parse_image_versions(content: &str) -> HashMap<String, String> {
|
||||
|
||||
// Match VAR="value" or VAR=value
|
||||
if let Some((key, val)) = parse_assignment(line) {
|
||||
// Read a self-default assignment without evaluating shell code.
|
||||
let default_prefix = format!("${{{key}:-");
|
||||
let val = val
|
||||
.strip_prefix(&default_prefix)
|
||||
.and_then(|v| v.strip_suffix('}'))
|
||||
.unwrap_or(val);
|
||||
let expanded = val.replace("$ARCHY_REGISTRY", ®istry);
|
||||
if key == "ARCHY_REGISTRY" {
|
||||
registry = expanded.clone();
|
||||
@@ -205,48 +211,71 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
|
||||
}
|
||||
|
||||
pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
let pinned_version = extract_version_from_image(&pinned);
|
||||
if image_without_registry_or_tag(pinned) != image_without_registry_or_tag(running_image) {
|
||||
return None;
|
||||
}
|
||||
available_catalog_update_for_images(pinned, running_image)
|
||||
}
|
||||
|
||||
/// A signed catalog binds the image to an app id, so a publisher namespace
|
||||
/// migration must not hide a real upgrade. Baseline pins still require the
|
||||
/// same repository via `available_update_for_images` above.
|
||||
pub fn available_catalog_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
let pinned_version = extract_version_from_image(pinned);
|
||||
if is_floating_tag(&pinned_version) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let running_version = extract_version_from_image(running_image);
|
||||
if pinned_version == running_version {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pinned_repo = image_without_registry_or_tag(&pinned);
|
||||
let running_repo = image_without_registry_or_tag(running_image);
|
||||
if pinned_repo != running_repo {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Never advertise a LOWER version as an update.
|
||||
//
|
||||
// Everything upstream of here is a version claim that can go stale: the
|
||||
// signed catalog, a legacy catalog entry with no manifest, the
|
||||
// image-versions.sh baseline pin. When one lags behind what a node is
|
||||
// actually running, a bare `pinned != running` check turns that staleness
|
||||
// into an "Update" button that rolls the node BACKWARDS — and a rollback
|
||||
// to a version withdrawn for a vulnerability is precisely the case where
|
||||
// that must not happen. Observed with BTCPay: 2.4.2 installed, a stale
|
||||
// 2.3.9 pin, and the UI offering "update" to the exploited release.
|
||||
//
|
||||
// Only suppress when both tags parse as comparable version numbers, so
|
||||
// apps with opaque tags (RELEASE.2024-11-07T00-52-20Z, 14-vectorchord0.4.3)
|
||||
// keep the previous behaviour rather than silently losing updates.
|
||||
if let (Some(p), Some(r)) = (
|
||||
parse_version_parts(&pinned_version),
|
||||
parse_version_parts(&running_version),
|
||||
if matches!(
|
||||
compare_image_versions(pinned, running_image),
|
||||
Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
|
||||
) {
|
||||
if p < r {
|
||||
return None;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(pinned_version)
|
||||
}
|
||||
|
||||
/// Compare explicit image tags, ignoring registry and namespace. `None` means
|
||||
/// unknown ordering (including floating tags), never permission to downgrade.
|
||||
/// Archipelago's `-archyN` is a downstream patch revision ABOVE the upstream
|
||||
/// release, not a SemVer prerelease below it.
|
||||
pub fn compare_image_versions(target: &str, running: &str) -> Option<std::cmp::Ordering> {
|
||||
use std::cmp::Ordering;
|
||||
let target = extract_version_from_image(target);
|
||||
let running = extract_version_from_image(running);
|
||||
if is_floating_tag(&target) || is_floating_tag(&running) {
|
||||
return None;
|
||||
}
|
||||
let target = target.strip_prefix('v').unwrap_or(&target);
|
||||
let running = running.strip_prefix('v').unwrap_or(&running);
|
||||
if target == running {
|
||||
return Some(Ordering::Equal);
|
||||
}
|
||||
let mut target_core = parse_version_parts(target)?;
|
||||
let mut running_core = parse_version_parts(running)?;
|
||||
while target_core.last() == Some(&0) {
|
||||
target_core.pop();
|
||||
}
|
||||
while running_core.last() == Some(&0) {
|
||||
running_core.pop();
|
||||
}
|
||||
match target_core.cmp(&running_core) {
|
||||
Ordering::Equal => {
|
||||
fn patch_revision(tag: &str) -> Option<u64> {
|
||||
if let Some((base, revision)) = tag.rsplit_once("-archy") {
|
||||
if base.chars().all(|c| c.is_ascii_digit() || c == '.') {
|
||||
return revision.parse().ok();
|
||||
}
|
||||
}
|
||||
tag.chars()
|
||||
.all(|c| c.is_ascii_digit() || c == '.')
|
||||
.then_some(0)
|
||||
}
|
||||
Some(patch_revision(target)?.cmp(&patch_revision(running)?))
|
||||
}
|
||||
order => Some(order),
|
||||
}
|
||||
}
|
||||
|
||||
/// Numeric components of a version tag, for ordering comparisons only.
|
||||
///
|
||||
/// Accepts a leading `v` and a trailing pre-release suffix (`v0.18.4-beta`),
|
||||
@@ -423,6 +452,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downstream_patch_is_newer_than_upstream_and_orders_revisions() {
|
||||
let upstream = "registry.test/team/mempool-frontend:v3.3.1";
|
||||
let patch1 = "registry.test/team/mempool-frontend:v3.3.1-archy1";
|
||||
let patch2 = "registry.test/team/mempool-frontend:v3.3.1-archy2";
|
||||
assert_eq!(available_update_for_images(upstream, patch1), None);
|
||||
assert_eq!(available_update_for_images(patch1, patch2), None);
|
||||
assert_eq!(
|
||||
available_update_for_images(patch1, upstream),
|
||||
Some("v3.3.1-archy1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
available_update_for_images(patch2, patch1),
|
||||
Some("v3.3.1-archy2".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_namespace_migration_does_not_hide_patch_or_offer_reinstall() {
|
||||
let old = "registry.test/lfg2025/mempool-frontend:v3.3.1";
|
||||
let patched = "registry.test/chaum/mempool-frontend:v3.3.1-archy1";
|
||||
assert_eq!(
|
||||
available_catalog_update_for_images(patched, old),
|
||||
Some("v3.3.1-archy1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
available_catalog_update_for_images(
|
||||
patched,
|
||||
"registry.test/lfg2025/mempool-frontend:v3.3.1-archy1"
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(available_update_for_images(patched, old), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_version_spelling_does_not_offer_update() {
|
||||
assert_eq!(
|
||||
available_update_for_images("r.test/team/app:v3.3.1", "r.test/team/app:3.3.1"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
available_update_for_images("r.test/team/app:3.3.0", "r.test/team/app:3.3"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
compare_image_versions("r.test/team/app:latest", "r.test/team/app:latest"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_image_versions() {
|
||||
let content = r#"
|
||||
@@ -445,6 +525,22 @@ NOT_AN_IMAGE="something"
|
||||
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_image_pins_expand_shell_defaults_to_concrete_refs() {
|
||||
let images = parse_image_versions(include_str!("../../../../scripts/image-versions.sh"));
|
||||
assert_eq!(
|
||||
images["MEMPOOL_WEB_IMAGE"],
|
||||
"source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
|
||||
);
|
||||
assert_eq!(
|
||||
images["MEMPOOL_BACKEND_IMAGE"],
|
||||
"source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1"
|
||||
);
|
||||
assert!(images
|
||||
.values()
|
||||
.all(|v| !v.contains('$') && !v.contains('}')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_var_mapping() {
|
||||
assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE"));
|
||||
|
||||
@@ -89,136 +89,84 @@ bitcoind.estimatemode=ECONOMICAL\n"
|
||||
Ok(EnsureOutcome::Written)
|
||||
}
|
||||
|
||||
/// Bitcoin can accept TCP while returning RPC_IN_WARMUP for many minutes.
|
||||
/// Unlocking LND then triggers its short chain-backend timeout and a restart loop.
|
||||
/// Leave the wallet intact and locked; the next reconciliation retries readiness.
|
||||
async fn bitcoin_rpc_ready() -> bool {
|
||||
let (user, password) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let client = match reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let response = client.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(user, Some(password))
|
||||
.json(&serde_json::json!({"jsonrpc":"1.0","id":"lnd-readiness","method":"getblockchaininfo","params":[]}))
|
||||
.send().await;
|
||||
match response {
|
||||
Ok(response) if response.status().is_success() => response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.is_ok_and(|value| bitcoin_readiness_response(&value)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn bitcoin_readiness_response(value: &serde_json::Value) -> bool {
|
||||
value.get("error").is_none_or(|e| e.is_null())
|
||||
&& value
|
||||
.pointer("/result/blocks")
|
||||
.and_then(|v| v.as_u64())
|
||||
.is_some()
|
||||
&& value
|
||||
.pointer("/result/initialblockdownload")
|
||||
.and_then(|v| v.as_bool())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||
if file_exists_as_root(wallet_db).await {
|
||||
// GetInfo can wait for Bitcoin sync even though the wallet is already
|
||||
// unlocked. State RPC stays available during that normal startup phase.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
|
||||
return Ok(());
|
||||
}
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
match unlock_existing_wallet().await? {
|
||||
true => {
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
false => {
|
||||
// Every candidate password was actively rejected: this wallet was
|
||||
// created with a password this node no longer has, so it can never
|
||||
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
|
||||
// locked with an unknown password is already inaccessible, so wipe +
|
||||
// recreate it on the per-node secret to self-heal at boot.
|
||||
recreate_wallet_destructively().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
if !bitcoin_rpc_ready().await {
|
||||
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet unlock");
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !bitcoin_rpc_ready().await {
|
||||
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet initialization");
|
||||
return Ok(());
|
||||
}
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
|
||||
/// LND data subdirectories holding wallet + channel + graph state. Removing them
|
||||
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
|
||||
/// so deletion is destructive — only done once the wallet is already unrecoverable.
|
||||
const LND_STATE_DIRS: &[&str] = &[
|
||||
"/var/lib/archipelago/lnd/data/chain",
|
||||
"/var/lib/archipelago/lnd/data/graph",
|
||||
];
|
||||
|
||||
/// Podman container name for the core LND app (see `compute_container_name`:
|
||||
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
|
||||
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
|
||||
const LND_CONTAINER: &str = "lnd";
|
||||
|
||||
/// Canonical on-host admin macaroon — same path the RPC layer reads.
|
||||
const LND_ADMIN_MACAROON: &str =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
/// Archipelago data dir (default; not overridden in prod). Holds the
|
||||
/// `user-stopped.json` that gates health-monitor auto-restart.
|
||||
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
||||
|
||||
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
|
||||
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
|
||||
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
|
||||
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
|
||||
/// candidate password can open the existing wallet.
|
||||
async fn recreate_wallet_destructively() -> Result<()> {
|
||||
tracing::warn!(
|
||||
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
|
||||
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
|
||||
);
|
||||
|
||||
// The health monitor restarts any container it sees stopped; mark LND
|
||||
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
|
||||
// Always cleared below so LND auto-recovers normally afterwards.
|
||||
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
|
||||
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
let result = wipe_and_reinit_wallet().await;
|
||||
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn wipe_and_reinit_wallet() -> Result<()> {
|
||||
podman_user_scoped(&["stop", LND_CONTAINER])
|
||||
.await
|
||||
.context("stopping lnd before wallet wipe")?;
|
||||
|
||||
for dir in LND_STATE_DIRS {
|
||||
let status = host_sudo(&["rm", "-rf", dir])
|
||||
.await
|
||||
.with_context(|| format!("removing {dir}"))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("removing {dir} exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
podman_user_scoped(&["start", LND_CONTAINER])
|
||||
.await
|
||||
.context("restarting lnd after wallet wipe")?;
|
||||
|
||||
wait_for_wallet_state("NON_EXISTING").await?;
|
||||
init_wallet_via_rest().await
|
||||
}
|
||||
|
||||
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
|
||||
/// how the orchestrator/health-monitor manage rootless containers (keeps the
|
||||
/// container out of the archipelago service's cgroup).
|
||||
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
|
||||
let out = tokio::process::Command::new("systemd-run")
|
||||
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
|
||||
async fn wait_for_wallet_state(target: &str) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
for _ in 0..120 {
|
||||
if wallet_state(&client).await.as_deref() == Some(target) {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND did not reach state {target} after wallet wipe")
|
||||
}
|
||||
|
||||
async fn file_exists_as_root(path: &str) -> bool {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return true;
|
||||
@@ -366,6 +314,9 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
// exactly the nodes least able to afford it. Waiting longer costs nothing —
|
||||
// a wrong password still exits on the first pass via `all_rejected`.
|
||||
for _ in 0..UNLOCK_NOT_READY_ATTEMPTS {
|
||||
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
|
||||
return Ok(true);
|
||||
}
|
||||
let mut all_rejected = true;
|
||||
for pw in &candidates {
|
||||
match try_unlock_once(&client, pw).await {
|
||||
@@ -390,14 +341,8 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Unlock an existing wallet WITHOUT the destructive fallback.
|
||||
///
|
||||
/// `ensure_wallet_initialized` wipes and recreates a wallet no candidate
|
||||
/// password can open — correct for a boot path that must self-heal, and exactly
|
||||
/// wrong for macaroon rotation, which restarts LND against a wallet the operator
|
||||
/// still wants. Rotation calls this instead, so there is no code path from
|
||||
/// "rotate my credentials" to "delete my wallet": a rejected password surfaces
|
||||
/// as an error the caller reports, never as a wipe.
|
||||
/// Unlock the existing wallet, preserving its identity and channel data when
|
||||
/// passwords are unavailable or rejected. Used by boot and credential rotation.
|
||||
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
||||
match unlock_existing_wallet().await? {
|
||||
true => Ok(()),
|
||||
@@ -408,6 +353,10 @@ pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn wallet_is_unlocked(state: Option<&str>) -> bool {
|
||||
matches!(state, Some("UNLOCKED" | "RPC_ACTIVE" | "SERVER_ACTIVE"))
|
||||
}
|
||||
|
||||
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
||||
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
||||
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
||||
@@ -538,7 +487,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
{
|
||||
UnlockerResponse::Value(seed) => seed,
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
@@ -569,7 +518,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
.await;
|
||||
}
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1203,3 +1152,44 @@ mod tests {
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bitcoin_readiness_tests {
|
||||
use super::bitcoin_readiness_response;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn only_usable_bitcoin_rpc_allows_wallet_unlock() {
|
||||
for response in [
|
||||
json!({}),
|
||||
json!({"error":{"code":-28,"message":"Loading block index"},"result":null}),
|
||||
json!({"result":{"blocks":null}}),
|
||||
] {
|
||||
assert!(!bitcoin_readiness_response(&response));
|
||||
}
|
||||
// Initial sync is supported by LND. Loading the database is not.
|
||||
for ibd in [true, false] {
|
||||
assert!(bitcoin_readiness_response(
|
||||
&json!({"result":{"blocks":100,"initialblockdownload":ibd},"error":null})
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod syncing_wallet_state_tests {
|
||||
#[test]
|
||||
fn an_unlocked_wallet_waiting_for_chain_sync_is_never_unlocked_again() {
|
||||
for state in ["UNLOCKED", "RPC_ACTIVE", "SERVER_ACTIVE"] {
|
||||
assert!(super::wallet_is_unlocked(Some(state)));
|
||||
}
|
||||
for state in [
|
||||
None,
|
||||
Some("LOCKED"),
|
||||
Some("NON_EXISTING"),
|
||||
Some("WAITING_TO_START"),
|
||||
Some("unknown"),
|
||||
] {
|
||||
assert!(!super::wallet_is_unlocked(state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Consistent, private snapshots for declaratively opted-in runtime migrations.
|
||||
use anyhow::{bail, Context, Result};
|
||||
use archipelago_container::AppManifest;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn enabled(manifest: &AppManifest) -> Result<bool> {
|
||||
match manifest.app.extensions.get("backup_before_runtime_change") {
|
||||
None => Ok(false),
|
||||
Some(value) => value
|
||||
.as_bool()
|
||||
.context("backup_before_runtime_change must be boolean"),
|
||||
}
|
||||
}
|
||||
|
||||
fn relative_sources(manifest: &AppManifest, data_dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut sources = Vec::new();
|
||||
for volume in &manifest.app.volumes {
|
||||
if volume.options.iter().any(|v| v == "ro") || volume.volume_type == "tmpfs" {
|
||||
continue;
|
||||
}
|
||||
// A runtime socket is a connection, not application state.
|
||||
if volume.source == "/run/user/1000/podman/podman.sock" {
|
||||
continue;
|
||||
}
|
||||
if volume.volume_type != "bind" {
|
||||
bail!("runtime migration backup requires bind-mounted persistent state");
|
||||
}
|
||||
let path = Path::new(&volume.source);
|
||||
let relative = path
|
||||
.strip_prefix(data_dir)
|
||||
.context("runtime migration state must be inside the node data directory")?;
|
||||
if relative.starts_with("migration-backups") {
|
||||
bail!("migration backup cannot include its own archive directory");
|
||||
}
|
||||
if relative.as_os_str().is_empty()
|
||||
|| relative
|
||||
.components()
|
||||
.any(|c| !matches!(c, std::path::Component::Normal(_)))
|
||||
{
|
||||
bail!("invalid runtime migration state path");
|
||||
}
|
||||
sources.push(relative.to_path_buf());
|
||||
}
|
||||
sources.sort();
|
||||
sources.dedup();
|
||||
let mut roots: Vec<PathBuf> = Vec::new();
|
||||
for source in sources {
|
||||
if !roots.iter().any(|root| source.starts_with(root)) {
|
||||
roots.push(source);
|
||||
}
|
||||
}
|
||||
if roots.is_empty() {
|
||||
bail!("runtime migration backup has no persistent state mounts");
|
||||
}
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
/// Caller must gracefully stop the app before this function, and resume the old
|
||||
/// service if it fails. No source files are changed or deleted by this operation.
|
||||
pub async fn snapshot(
|
||||
manifest: &AppManifest,
|
||||
data_dir: &Path,
|
||||
previous_unit: Option<&[u8]>,
|
||||
) -> Result<PathBuf> {
|
||||
let mut command = tokio::process::Command::new("podman");
|
||||
command.args(["unshare", "tar"]);
|
||||
snapshot_with_command(manifest, data_dir, previous_unit, command).await
|
||||
}
|
||||
|
||||
async fn snapshot_with_command(
|
||||
manifest: &AppManifest,
|
||||
data_dir: &Path,
|
||||
previous_unit: Option<&[u8]>,
|
||||
mut command: tokio::process::Command,
|
||||
) -> Result<PathBuf> {
|
||||
let sources = relative_sources(manifest, data_dir)?;
|
||||
let canonical_root = tokio::fs::canonicalize(data_dir).await?;
|
||||
for source in &sources {
|
||||
let path = data_dir.join(source);
|
||||
if tokio::fs::symlink_metadata(&path)
|
||||
.await?
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
{
|
||||
bail!("runtime migration state mount is a symlink; explicit backup required");
|
||||
}
|
||||
let canonical = tokio::fs::canonicalize(&path).await?;
|
||||
if !canonical.starts_with(&canonical_root) {
|
||||
bail!("runtime migration state path resolves outside node data directory");
|
||||
}
|
||||
}
|
||||
let root = data_dir.join("migration-backups");
|
||||
tokio::fs::create_dir_all(&root).await?;
|
||||
tokio::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).await?;
|
||||
let dir = root.join(uuid::Uuid::new_v4().to_string());
|
||||
tokio::fs::create_dir(&dir).await?;
|
||||
tokio::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).await?;
|
||||
if let Some(unit) = previous_unit {
|
||||
let path = dir.join("previous.container");
|
||||
tokio::fs::write(&path, unit).await?;
|
||||
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await?;
|
||||
tokio::fs::File::open(&path).await?.sync_all().await?;
|
||||
}
|
||||
let partial = dir.join("state.tar.partial");
|
||||
let archive = dir.join("state.tar");
|
||||
let output = command
|
||||
.args([
|
||||
"--create",
|
||||
"--numeric-owner",
|
||||
"--acls",
|
||||
"--xattrs",
|
||||
"--file",
|
||||
])
|
||||
.arg(&partial)
|
||||
.arg("--directory")
|
||||
.arg(data_dir)
|
||||
.arg("--")
|
||||
.args(&sources)
|
||||
.output()
|
||||
.await
|
||||
.context("start rootless migration snapshot")?;
|
||||
if !output.status.success() {
|
||||
// No tar stderr in public logs: it can contain private filenames.
|
||||
let _ = tokio::fs::remove_file(&partial).await;
|
||||
bail!("persistent-state snapshot failed; original state was left intact");
|
||||
}
|
||||
tokio::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o600)).await?;
|
||||
tokio::fs::File::open(&partial).await?.sync_all().await?;
|
||||
tokio::fs::rename(&partial, &archive).await?;
|
||||
let metadata = serde_json::json!({"app": manifest.app.id, "version": manifest.app.version,
|
||||
"network": manifest.app.container.network, "capabilities": manifest.app.security.capabilities, "sources": sources});
|
||||
tokio::fs::write(
|
||||
dir.join("metadata.json"),
|
||||
serde_json::to_vec_pretty(&metadata)?,
|
||||
)
|
||||
.await?;
|
||||
tokio::fs::File::open(&dir).await?.sync_all().await?;
|
||||
Ok(archive)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn portainer() -> AppManifest {
|
||||
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap()
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn stopped_state_archive_round_trips_database_compose_and_old_unit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = dir.path().join("portainer");
|
||||
tokio::fs::create_dir_all(state.join("compose"))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(state.join("portainer.db"), b"fixture database")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(state.join("compose/stack.yml"), b"services: {}\n")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut m = portainer();
|
||||
m.app.volumes[0].source = state.display().to_string();
|
||||
m.app.volumes[1].source = state.join("compose").display().to_string();
|
||||
let archive = snapshot_with_command(
|
||||
&m,
|
||||
dir.path(),
|
||||
Some(b"old unit"),
|
||||
tokio::process::Command::new("tar"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(&archive).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(archive.parent().unwrap().join("previous.container"))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"old unit"
|
||||
);
|
||||
let restored = tempfile::tempdir().unwrap();
|
||||
assert!(tokio::process::Command::new("tar")
|
||||
.arg("-xf")
|
||||
.arg(archive)
|
||||
.arg("-C")
|
||||
.arg(restored.path())
|
||||
.status()
|
||||
.await
|
||||
.unwrap()
|
||||
.success());
|
||||
assert_eq!(
|
||||
tokio::fs::read(restored.path().join("portainer/portainer.db"))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"fixture database"
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(restored.path().join("portainer/compose/stack.yml"))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"services: {}\n"
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(state.join("portainer.db")).await.unwrap(),
|
||||
b"fixture database"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_snapshot_never_publishes_archive_or_changes_original_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = dir.path().join("portainer");
|
||||
tokio::fs::create_dir_all(state.join("compose"))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(state.join("portainer.db"), b"unchanged")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut m = portainer();
|
||||
m.app.volumes[0].source = state.display().to_string();
|
||||
m.app.volumes[1].source = state.join("compose").display().to_string();
|
||||
assert!(
|
||||
snapshot_with_command(&m, dir.path(), None, tokio::process::Command::new("false"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(state.join("portainer.db")).await.unwrap(),
|
||||
b"unchanged"
|
||||
);
|
||||
for entry in std::fs::read_dir(dir.path().join("migration-backups")).unwrap() {
|
||||
assert!(!entry.unwrap().path().join("state.tar").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_covers_all_portainer_state_once_and_excludes_runtime_socket() {
|
||||
let m = portainer();
|
||||
assert!(enabled(&m).unwrap());
|
||||
assert_eq!(
|
||||
relative_sources(&m, Path::new("/var/lib/archipelago")).unwrap(),
|
||||
vec![PathBuf::from("portainer")]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn backup_refuses_unknown_state_locations_instead_of_silently_omitting_them() {
|
||||
let mut m = portainer();
|
||||
m.app.volumes[0].source = "/other/operator/state".into();
|
||||
assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err());
|
||||
m.app.volumes[0].source = "/var/lib/archipelago/../secret".into();
|
||||
assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod hooks;
|
||||
pub mod image_policy;
|
||||
pub mod image_versions;
|
||||
pub mod lnd;
|
||||
pub mod migration_backup;
|
||||
pub mod prod_orchestrator;
|
||||
pub mod quadlet;
|
||||
pub mod registry;
|
||||
|
||||
@@ -36,6 +36,11 @@ use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
/// Refusal before installation has created state or changed any dependency.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{0}")]
|
||||
pub struct InstallPrerequisiteError(pub String);
|
||||
|
||||
use crate::config::{Config, ContainerRuntime as ConfigContainerRuntime};
|
||||
use crate::container::bitcoin_ui;
|
||||
use crate::container::quadlet;
|
||||
@@ -91,6 +96,23 @@ fn is_builtin_network_mode(network: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
// Only an explicitly selected rootless mode establishes drift. An omitted
|
||||
// network delegates to Podman and must not recreate unrelated installed apps.
|
||||
fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool {
|
||||
matches!(expected, Some("slirp4netns" | "pasta"))
|
||||
&& !actual.trim().is_empty()
|
||||
&& actual.trim().split(':').next() != expected
|
||||
}
|
||||
|
||||
fn missing_declared_capability(expected: &[String], actual: &[String]) -> bool {
|
||||
expected.iter().any(|required| {
|
||||
let required = required.strip_prefix("CAP_").unwrap_or(required);
|
||||
!actual
|
||||
.iter()
|
||||
.any(|cap| cap.strip_prefix("CAP_").unwrap_or(cap) == required)
|
||||
})
|
||||
}
|
||||
|
||||
fn uses_pasta_network(manifest: &AppManifest) -> bool {
|
||||
manifest.app.container.network.as_deref() == Some("pasta")
|
||||
}
|
||||
@@ -798,6 +820,10 @@ fn host_port_bindings_drifted(
|
||||
}
|
||||
|
||||
async fn ensure_user_podman_socket() -> Result<()> {
|
||||
// Unit tests inject a runtime; they must not restart the host Podman API.
|
||||
if cfg!(test) {
|
||||
return Ok(());
|
||||
}
|
||||
let socket_path = "/run/user/1000/podman/podman.sock";
|
||||
if podman_socket_accepts_connections(socket_path).await {
|
||||
return Ok(());
|
||||
@@ -1170,15 +1196,21 @@ impl ReconcileReport {
|
||||
fn cascade_pairs_for_report<'r>(
|
||||
report: &'r ReconcileReport,
|
||||
user_stopped: &std::collections::HashSet<String>,
|
||||
changed_backends: &HashSet<String>,
|
||||
) -> Vec<(&'r str, &'static str)> {
|
||||
let mut pairs = Vec::new();
|
||||
for (backend, action) in &report.actions {
|
||||
if !matches!(
|
||||
action,
|
||||
ReconcileAction::Installed | ReconcileAction::Started
|
||||
ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// A successful systemctl start can be a no-op after a transient
|
||||
// Podman inspect failure. Require a witnessed lifecycle change.
|
||||
if !changed_backends.contains(backend) {
|
||||
continue;
|
||||
}
|
||||
for dep in crate::app_ops::address_caching_dependents(backend) {
|
||||
let dep_untouched = report
|
||||
.actions
|
||||
@@ -1192,6 +1224,25 @@ fn cascade_pairs_for_report<'r>(
|
||||
pairs
|
||||
}
|
||||
|
||||
/// Only positive runtime evidence permits disrupting an address-caching wallet.
|
||||
/// A known absent/stopped backend becoming running, a new container ID, or a
|
||||
/// changed start timestamp qualifies. A failed observation never does.
|
||||
fn backend_instance_changed(before: Option<&ContainerStatus>, after: &ContainerStatus) -> bool {
|
||||
if after.state != ContainerState::Running || after.id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some(before) = before else {
|
||||
return true;
|
||||
};
|
||||
if before.id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if before.id != after.id || before.state != ContainerState::Running {
|
||||
return true;
|
||||
}
|
||||
matches!((&before.started_at, &after.started_at), (Some(a), Some(b)) if !a.is_empty() && !b.is_empty() && a != b)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AdoptionReport {
|
||||
pub adopted: Vec<String>,
|
||||
@@ -1864,7 +1915,7 @@ impl ProdContainerOrchestrator {
|
||||
// Durable installation record, consulted alongside the perishable
|
||||
// `was_running` snapshot for desired-state recovery below.
|
||||
let installed_apps = crate::crash_recovery::load_installed_apps(&self.data_dir).await;
|
||||
let (manifests, container_name_by_app_id): (
|
||||
let (mut manifests, container_name_by_app_id): (
|
||||
Vec<LoadedManifest>,
|
||||
std::collections::HashMap<String, String>,
|
||||
) = {
|
||||
@@ -1895,15 +1946,50 @@ impl ProdContainerOrchestrator {
|
||||
.collect();
|
||||
(filtered, names)
|
||||
};
|
||||
// Wallet readiness must not wait behind unrelated image pulls/builds.
|
||||
// A running LND container can still be locked after boot; its post-start
|
||||
// hook must run promptly. Reconcile Bitcoin first, then LND, before the
|
||||
// rest of the catalog. Each app still honors stopped/uninstalled markers.
|
||||
manifests.sort_by_key(|lm| match lm.manifest.app.id.as_str() {
|
||||
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 0,
|
||||
"lnd" => 1,
|
||||
_ => 2,
|
||||
});
|
||||
// Live container names (any state), for the same recovery check.
|
||||
let present_containers: std::collections::HashSet<String> = self
|
||||
.runtime
|
||||
.list_containers()
|
||||
.await
|
||||
.map(|cs| cs.into_iter().map(|c| c.name).collect())
|
||||
let listed_containers = self.runtime.list_containers().await.ok();
|
||||
let present_containers: HashSet<String> = listed_containers
|
||||
.as_ref()
|
||||
.map(|cs| cs.iter().map(|c| c.name.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
// Keep unknown distinct from confirmed absence. Runtime queries can
|
||||
// fail under load while systemd still has a healthy running backend.
|
||||
let mut backend_before: HashMap<String, Option<ContainerStatus>> = HashMap::new();
|
||||
for lm in &manifests {
|
||||
let id = &lm.manifest.app.id;
|
||||
if crate::app_ops::address_caching_dependents(id).is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
match self.runtime.get_container_status(&name).await {
|
||||
Ok(status) => {
|
||||
backend_before.insert(id.clone(), Some(status));
|
||||
}
|
||||
Err(_) if listed_containers.is_some() && !present_containers.contains(&name) => {
|
||||
backend_before.insert(id.clone(), None);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(backend = %id, error = %err,
|
||||
"cannot observe backend before reconcile; will not infer a dependency restart from an action report");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut report = ReconcileReport::default();
|
||||
let disk_gb = self.disk_gb().await;
|
||||
let bitcoin_pruned = disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
||||
|| crate::settings::bitcoin_storage::load(&self.data_dir)
|
||||
.await
|
||||
.map(|settings| settings.prune)
|
||||
.unwrap_or(true);
|
||||
// Register every candidate before the (sequential, possibly slow)
|
||||
// pass so the scanner overlays queued-but-down apps as Restarting
|
||||
// instead of Stopped. Each app is deregistered as its turn finishes,
|
||||
@@ -1943,7 +2029,7 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
&& requires_archival_bitcoin(&app_id)
|
||||
&& disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
||||
&& bitcoin_pruned
|
||||
{
|
||||
report.record(
|
||||
&app_id,
|
||||
@@ -2078,7 +2164,20 @@ impl ProdContainerOrchestrator {
|
||||
// state recovery, repair recreate, boot InstallMissing) moves the
|
||||
// address behind a running dependent's back — §C "restart lnd after
|
||||
// ANY bitcoin recreate".
|
||||
for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped) {
|
||||
let mut changed_backends = HashSet::new();
|
||||
for (backend, before) in &backend_before {
|
||||
let Some(name) = container_name_by_app_id.get(backend) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(after) = self.runtime.get_container_status(name).await {
|
||||
if backend_instance_changed(before.as_ref(), &after) {
|
||||
changed_backends.insert(backend.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// A user stop during a slow reconcile pass still takes precedence.
|
||||
let user_stopped = crate::crash_recovery::load_user_stopped(&self.data_dir).await;
|
||||
for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped, &changed_backends) {
|
||||
// Same rule as the RPC cascade: hold the dependent's op lock
|
||||
// across the restart; skip when a worker is mid-sequence.
|
||||
let lock = crate::app_ops::op_lock(dep);
|
||||
@@ -2387,6 +2486,8 @@ impl ProdContainerOrchestrator {
|
||||
.await
|
||||
{
|
||||
tracing::info!(app_id = %app_id, container = %name, "container published-port drift detected — recreating");
|
||||
self.backup_runtime_change(&name, &resolved_manifest)
|
||||
.await?;
|
||||
let _ = self.runtime.stop_container(&name).await;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(lm).await?;
|
||||
@@ -2422,6 +2523,8 @@ impl ProdContainerOrchestrator {
|
||||
return Ok(ReconcileAction::NoOp);
|
||||
}
|
||||
tracing::info!(app_id = %app_id, container = %name, "container env drift detected — recreating");
|
||||
self.backup_runtime_change(&name, &resolved_manifest)
|
||||
.await?;
|
||||
let _ = self.runtime.stop_container(&name).await;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(lm).await?;
|
||||
@@ -2478,6 +2581,8 @@ impl ProdContainerOrchestrator {
|
||||
.await
|
||||
{
|
||||
tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating");
|
||||
self.backup_runtime_change(&name, &resolved_manifest)
|
||||
.await?;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(lm).await?;
|
||||
return Ok(ReconcileAction::Installed);
|
||||
@@ -2534,6 +2639,8 @@ impl ProdContainerOrchestrator {
|
||||
self.prepare_for_start(&resolved_manifest).await?;
|
||||
if self.container_env_drifted(&name, &resolved_manifest).await {
|
||||
tracing::info!(app_id = %app_id, container = %name, "created container env drift detected — recreating");
|
||||
self.backup_runtime_change(&name, &resolved_manifest)
|
||||
.await?;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(lm).await?;
|
||||
return Ok(ReconcileAction::Installed);
|
||||
@@ -3003,13 +3110,9 @@ impl ProdContainerOrchestrator {
|
||||
/// app is a companion (companion.rs owns those units), or when no
|
||||
/// unit file exists yet (install_via_quadlet handles first-write).
|
||||
///
|
||||
/// We DON'T restart the .service when content changes — running
|
||||
/// containers keep their current config until an operator-initiated
|
||||
/// restart picks up the new file. That's the right tradeoff: file
|
||||
/// updates are cheap and non-destructive; service restarts are
|
||||
/// destructive (the SIGKILL cascade we're trying to eliminate).
|
||||
/// systemctl --user daemon-reload runs only when content actually
|
||||
/// changed, so steady-state reconcile ticks pay just one fs read.
|
||||
/// Ordinary metadata changes wait for an operator restart. Runtime-affecting
|
||||
/// changes restart the service and retain a durable pending marker until
|
||||
/// that succeeds, including across daemon restarts and failed reloads.
|
||||
async fn sync_quadlet_unit(&self, lm: &LoadedManifest, name: &str) -> Result<()> {
|
||||
// Companions: same reasoning as migrate_to_quadlet_if_needed —
|
||||
// companion.rs renders these units with a different shape, syncing
|
||||
@@ -3029,7 +3132,7 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
let old_body = tokio::fs::read_to_string(&unit_path)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
.with_context(|| format!("read existing quadlet for {name}"))?;
|
||||
let restart_required = quadlet::contains_stale_health_gate(&old_body);
|
||||
|
||||
let mut resolved = lm.manifest.clone();
|
||||
@@ -3045,49 +3148,49 @@ impl ProdContainerOrchestrator {
|
||||
quadlet::network_aliases_changed(&old_body, &new_body);
|
||||
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
|
||||
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
|
||||
let restart_for_security_change = quadlet::security_changed(&old_body, &new_body);
|
||||
let needs_restart = restart_required
|
||||
|| restart_for_port_change
|
||||
|| restart_for_network_alias_change
|
||||
|| restart_for_exec_change
|
||||
|| restart_for_health_change
|
||||
|| restart_for_security_change;
|
||||
// Record the obligation BEFORE replacing the unit. A failed reload or
|
||||
// restart must not become a no-op on the next tick just because the
|
||||
// generated file already matches the manifest.
|
||||
let pending = quadlet::RestartObligation::prepare(&unit_path, needs_restart).await?;
|
||||
if pending.is_pending() {
|
||||
self.ensure_resolved_source_available(lm).await?;
|
||||
}
|
||||
if needs_restart {
|
||||
self.backup_runtime_change(name, &resolved).await?;
|
||||
}
|
||||
let changed = quadlet::write_if_changed(&unit, &unit_dir)
|
||||
.await
|
||||
.with_context(|| format!("drift-sync quadlet unit for {name}"))?;
|
||||
if changed {
|
||||
if changed || pending.is_pending() {
|
||||
quadlet::daemon_reload_user()
|
||||
.await
|
||||
.context("systemctl --user daemon-reload after drift-syncing quadlet unit")?;
|
||||
tracing::info!(
|
||||
app_id = %lm.manifest.app.id,
|
||||
container = %name,
|
||||
"Quadlet unit drift-synced — file rewritten, .service NOT restarted (operator restart picks up new config)"
|
||||
);
|
||||
}
|
||||
if changed
|
||||
&& (restart_required
|
||||
|| restart_for_port_change
|
||||
|| restart_for_network_alias_change
|
||||
|| restart_for_exec_change
|
||||
|| restart_for_health_change)
|
||||
{
|
||||
self.ensure_resolved_source_available(lm).await?;
|
||||
if pending.is_pending() {
|
||||
let service = unit.service_name();
|
||||
let reason = if restart_required {
|
||||
"stale health gate"
|
||||
} else if restart_for_port_change {
|
||||
"port binding drift"
|
||||
} else if restart_for_network_alias_change {
|
||||
"network alias drift"
|
||||
} else if restart_for_health_change {
|
||||
"health command drift"
|
||||
} else {
|
||||
"exec drift"
|
||||
};
|
||||
tracing::info!(
|
||||
app_id = %lm.manifest.app.id,
|
||||
container = %name,
|
||||
service = %service,
|
||||
reason = reason,
|
||||
"Quadlet unit rewrite requires service restart"
|
||||
"Applying pending Quadlet runtime change"
|
||||
);
|
||||
quadlet::restart_service(&service)
|
||||
.await
|
||||
.with_context(|| format!("restart drifted quadlet service {service}"))?;
|
||||
pending.complete().await?;
|
||||
} else if changed {
|
||||
tracing::info!(
|
||||
app_id = %lm.manifest.app.id,
|
||||
container = %name,
|
||||
"Quadlet metadata updated; operator restart will apply it"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -3217,6 +3320,9 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
async fn ensure_container_network(&self, manifest: &AppManifest) -> Result<()> {
|
||||
if cfg!(test) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(network) = manifest.app.container.network.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3403,11 +3509,9 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
async fn cleanup_stale_grafana_port(&self) {
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", "pasta.*3001"])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
// Port 3001 can belong to Gitea or the daemon's gate. Reap only a
|
||||
// Grafana container proven absent from Podman's inventory.
|
||||
crate::container::ghost_reaper::reap_for_app("grafana").await;
|
||||
}
|
||||
|
||||
async fn detect_host_facts(&self) -> HostFacts {
|
||||
@@ -3711,6 +3815,17 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
let mut env = manifest.app.environment.clone();
|
||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||
if matches!(manifest.app.id.as_str(), "bitcoin-core" | "bitcoin-knots") {
|
||||
let storage = crate::settings::bitcoin_storage::load(&self.data_dir).await?;
|
||||
env.retain(|entry| !entry.starts_with("BITCOIN_PRUNE="));
|
||||
if storage.prune {
|
||||
anyhow::ensure!(
|
||||
manifest.app.container.custom_args.iter().any(|arg| arg.contains("BITCOIN_PRUNE")),
|
||||
"This Bitcoin app definition cannot honor the pruning choice. Refresh the app catalog and try again."
|
||||
);
|
||||
env.push("BITCOIN_PRUNE=1".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
|
||||
// derived_env (works on Knots/Core/any distro). The old hardcoded
|
||||
@@ -3777,6 +3892,77 @@ impl ProdContainerOrchestrator {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn backup_runtime_change(&self, name: &str, manifest: &AppManifest) -> Result<()> {
|
||||
if !crate::container::migration_backup::enabled(manifest)? {
|
||||
return Ok(());
|
||||
}
|
||||
// A persistent disk/permission failure must not repeatedly stop a
|
||||
// working old service. Reuse the reconciler's bounded repair budget.
|
||||
if !self.should_attempt_repair(name).await {
|
||||
anyhow::bail!("runtime migration retry budget exhausted; original service retained, inspect backup failure before retrying");
|
||||
}
|
||||
// Called only before a known runtime change. No app-specific commands;
|
||||
// opted-in manifests identify their persistent state through bind mounts.
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"])
|
||||
.output()
|
||||
.await
|
||||
.context("inspect network before migration backup")?;
|
||||
let present = if output.status.success() {
|
||||
true
|
||||
} else {
|
||||
// A crash after gracefully stopping a --rm Quadlet container can
|
||||
// leave only its data and old unit. Prove absence before snapshotting
|
||||
// stopped state; an inspect/Podman failure is not proof of absence.
|
||||
let exists = tokio::process::Command::new("podman")
|
||||
.args(["container", "exists", name])
|
||||
.status()
|
||||
.await?;
|
||||
if exists.code() != Some(1) {
|
||||
anyhow::bail!("cannot verify existing container before runtime migration backup");
|
||||
}
|
||||
false
|
||||
};
|
||||
let service = format!("{name}.service");
|
||||
let managed = quadlet::unit_exists(name).await;
|
||||
let previous_unit = if managed {
|
||||
Some(
|
||||
tokio::fs::read(quadlet::unit_dir().await?.join(format!("{name}.container")))
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if managed {
|
||||
quadlet::stop_service(&service).await?;
|
||||
} else if present {
|
||||
self.runtime.stop_container(name).await?;
|
||||
}
|
||||
match crate::container::migration_backup::snapshot(
|
||||
manifest,
|
||||
&self.data_dir,
|
||||
previous_unit.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(archive) => {
|
||||
tracing::info!(container = %name, backup = %archive.display(), "Persistent state saved before runtime migration");
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
// The unit has not been rewritten yet. Restore its previous
|
||||
// service on backup failure and report the migration failure.
|
||||
let restored = if managed {
|
||||
quadlet::enable_now(&service).await
|
||||
} else {
|
||||
self.runtime.start_container(name).await
|
||||
};
|
||||
restored.context("restore original app after failed migration snapshot")?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn container_env_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
|
||||
if cfg!(test) {
|
||||
return false;
|
||||
@@ -3786,6 +3972,52 @@ impl ProdContainerOrchestrator {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Generated-unit drift handles managed services; preserve deliberate
|
||||
// systemd drop-in overrides instead of recreating them every tick.
|
||||
let unmanaged = !quadlet::unit_exists(name).await;
|
||||
// Podman's effective bounding set, not Docker-compatible CapAdd (which
|
||||
// can be empty even when Quadlet supplied capabilities).
|
||||
if unmanaged && !manifest.app.security.capabilities.is_empty() {
|
||||
if let Ok(output) = tokio::process::Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{json .BoundingCaps}}"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
if output.status.success() {
|
||||
if let Ok(actual) = serde_json::from_slice::<Vec<String>>(&output.stdout) {
|
||||
if missing_declared_capability(&manifest.app.security.capabilities, &actual)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quadlet handles declarative Network= drift above. Legacy rootless
|
||||
// Podman containers need the same convergence when no unit owns them.
|
||||
if unmanaged
|
||||
&& matches!(
|
||||
manifest.app.container.network.as_deref(),
|
||||
Some("slirp4netns" | "pasta")
|
||||
)
|
||||
{
|
||||
if let Ok(output) = tokio::process::Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
if output.status.success()
|
||||
&& rootless_network_mode_drifted(
|
||||
manifest.app.container.network.as_deref(),
|
||||
&String::from_utf8_lossy(&output.stdout),
|
||||
)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let inspect = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"inspect",
|
||||
@@ -4352,6 +4584,45 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
async fn install(&self, app_id: &str) -> Result<String> {
|
||||
let lm = self.loaded(app_id).await?;
|
||||
// Optional shared-service preconditions are checked before recording
|
||||
// installation or creating anything. A headless adapter must not claim
|
||||
// successful installation against a missing indexing stack.
|
||||
if let Some(required) = lm
|
||||
.manifest
|
||||
.app
|
||||
.extensions
|
||||
.get("install_prerequisites")
|
||||
.and_then(|value| value.as_sequence())
|
||||
{
|
||||
let present = self
|
||||
.runtime
|
||||
.list_containers()
|
||||
.await
|
||||
.context("check installed prerequisite services")?;
|
||||
for id in required.iter().filter_map(|value| value.as_str()) {
|
||||
let dependency = self.loaded(id).await.map_err(|_| InstallPrerequisiteError(
|
||||
format!("Required app {id} is unavailable. Refresh the app catalog before installing {}.",
|
||||
lm.manifest.app.name)))?;
|
||||
let name = compute_container_name(&dependency.manifest);
|
||||
if !present
|
||||
.iter()
|
||||
.any(|container| container.name.trim_start_matches('/') == name)
|
||||
{
|
||||
let owner = crate::app_ops::owning_package(id);
|
||||
let title = self
|
||||
.loaded(owner)
|
||||
.await
|
||||
.map(|app| app.manifest.app.name)
|
||||
.unwrap_or(dependency.manifest.app.name);
|
||||
return Err(InstallPrerequisiteError(format!(
|
||||
"Install {title} first, then install {}.",
|
||||
lm.manifest.app.name
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
state.disabled.remove(app_id);
|
||||
@@ -4378,7 +4649,6 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
// health verification (the .228 "running but unreachable" failure
|
||||
// mode). Routing every install through here means the orchestrator
|
||||
// is the one source of truth for what "installed" means.
|
||||
let lm = self.loaded(app_id).await?;
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
// ensure_running takes the per-app lock itself; release the install
|
||||
// path lock first if we hold one (we don't — install is the entry
|
||||
@@ -4667,6 +4937,27 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
let lock = self.app_lock(app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
let mut resolved = lm.manifest.clone();
|
||||
resolve_catalog_image(&mut resolved);
|
||||
if resolved.app.container.build.is_none() {
|
||||
if let Some(target) = &resolved.app.container.image {
|
||||
if let Ok(running) = self.runtime.get_container_status(&name).await {
|
||||
match crate::container::image_versions::compare_image_versions(
|
||||
target,
|
||||
&running.image,
|
||||
) {
|
||||
Some(std::cmp::Ordering::Less) => anyhow::bail!(
|
||||
"Refusing to downgrade {} from {} to {} during update",
|
||||
app_id,
|
||||
running.image,
|
||||
target
|
||||
),
|
||||
Some(std::cmp::Ordering::Equal) => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = self.runtime.stop_container(&name).await;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(&lm).await
|
||||
@@ -4807,6 +5098,78 @@ mod tests {
|
||||
/// recovered when its siblings have live containers (the stack is
|
||||
/// installed), and left alone when the whole stack is gone or the app
|
||||
/// is not a stack member at all.
|
||||
#[tokio::test]
|
||||
async fn gitea_fresh_url_seed_preserves_operator_config_and_reports_write_failure() {
|
||||
let manifest =
|
||||
AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap();
|
||||
let seed = &manifest.app.files[0];
|
||||
assert!(!seed.overwrite);
|
||||
let content = seed.content.replace("{{HOST_IP}}", "192.0.2.1");
|
||||
assert!(content.contains("ROOT_URL = http://192.0.2.1:3001/"));
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("fresh/app.ini");
|
||||
assert_eq!(
|
||||
ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite)
|
||||
.await
|
||||
.unwrap(),
|
||||
HookOutcome::Rewritten
|
||||
);
|
||||
assert!(tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("ROOT_URL"));
|
||||
let custom =
|
||||
"[server]\nROOT_URL = https://git.example.test/\n[database]\nDB_TYPE = postgres\n";
|
||||
tokio::fs::write(&path, custom).await.unwrap();
|
||||
assert_eq!(
|
||||
ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite)
|
||||
.await
|
||||
.unwrap(),
|
||||
HookOutcome::Unchanged
|
||||
);
|
||||
assert_eq!(tokio::fs::read_to_string(&path).await.unwrap(), custom);
|
||||
let impossible = path.join("app.ini");
|
||||
assert!(
|
||||
ensure_rendered_file(impossible.to_str().unwrap(), &content, seed.overwrite)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_sandbox_capability_repair_uses_bounding_set_and_preserves_extra_overrides() {
|
||||
let required = vec!["CHOWN".into(), "SYS_CHROOT".into()];
|
||||
assert!(missing_declared_capability(
|
||||
&required,
|
||||
&["CAP_CHOWN".into()]
|
||||
));
|
||||
assert!(!missing_declared_capability(
|
||||
&required,
|
||||
&["CAP_CHOWN".into(), "CAP_SYS_CHROOT".into()]
|
||||
));
|
||||
assert!(!missing_declared_capability(
|
||||
&required,
|
||||
&["CHOWN".into(), "SYS_CHROOT".into(), "CAP_KILL".into()]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_rootless_network_change_converges_without_guessing_defaults() {
|
||||
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "pasta"));
|
||||
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge"));
|
||||
assert!(!rootless_network_mode_drifted(
|
||||
Some("slirp4netns"),
|
||||
"slirp4netns"
|
||||
));
|
||||
assert!(!rootless_network_mode_drifted(
|
||||
Some("slirp4netns"),
|
||||
"slirp4netns:allow_host_loopback=true"
|
||||
));
|
||||
assert!(!rootless_network_mode_drifted(None, "pasta"));
|
||||
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), ""));
|
||||
assert!(!rootless_network_mode_drifted(Some("archy-net"), "bridge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_stack_member_recovery_requires_a_live_sibling() {
|
||||
let present: HashSet<String> = ["indeedhub-redis", "indeedhub-relay", "indeedhub"]
|
||||
@@ -5076,6 +5439,7 @@ mod tests {
|
||||
calls: StdMutex<Vec<String>>,
|
||||
/// container_name -> ContainerState. Absence = "doesn't exist".
|
||||
containers: StdMutex<HashMap<String, ContainerState>>,
|
||||
running_images: StdMutex<HashMap<String, String>>,
|
||||
/// container_name -> Podman health status.
|
||||
health: StdMutex<HashMap<String, String>>,
|
||||
/// image_ref -> present. Absence = "not present in local storage".
|
||||
@@ -5200,7 +5564,13 @@ mod tests {
|
||||
health,
|
||||
exit_code: None,
|
||||
started_at: None,
|
||||
image: "test-image".to_string(),
|
||||
image: self
|
||||
.running_images
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "test-image".to_string()),
|
||||
created: "now".to_string(),
|
||||
ports: vec![],
|
||||
lan_address: None,
|
||||
@@ -5481,6 +5851,38 @@ app:
|
||||
orch
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_install_prerequisite_refuses_without_inventory_or_container_mutation() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
let mut app = pull_manifest("indexer-adapter", "docker.io/library/alpine:3.20");
|
||||
app.app.extensions.insert(
|
||||
"install_prerequisites".into(),
|
||||
serde_yaml::to_value(vec!["shared-index"]).unwrap(),
|
||||
);
|
||||
orch.insert_manifest_for_test(app, PathBuf::from("/tmp"))
|
||||
.await;
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest("shared-index", "index:1"),
|
||||
PathBuf::from("/tmp"),
|
||||
)
|
||||
.await;
|
||||
let error = orch.install("indexer-adapter").await.unwrap_err();
|
||||
assert!(error.downcast_ref::<InstallPrerequisiteError>().is_some());
|
||||
assert!(!crate::crash_recovery::load_installed_apps(&orch.data_dir)
|
||||
.await
|
||||
.contains("indexer-adapter"));
|
||||
assert_eq!(rt.calls(), vec!["list_containers"]);
|
||||
// An installed prerequisite satisfies the guard; it is never recreated
|
||||
// or reconfigured as part of installing this adapter.
|
||||
rt.set_state("shared-index", ContainerState::Running);
|
||||
orch.install("indexer-adapter").await.unwrap();
|
||||
assert!(!rt
|
||||
.calls()
|
||||
.iter()
|
||||
.any(|c| c.starts_with("create_container:shared-index")));
|
||||
}
|
||||
|
||||
fn pull_manifest_with_dynamic_env(id: &str, image: &str) -> AppManifest {
|
||||
let yaml = format!(
|
||||
"app:\n id: {id}\n name: {id}\n version: 1.0.0\n container:\n image: {image}\n derived_env:\n - key: FM_API_URL\n template: \"ws://{{{{HOST_MDNS}}}}:8174\"\n secret_env:\n - key: FM_BITCOIND_PASSWORD\n secret_file: bitcoin-rpc-password\n environment:\n - STATIC=1\n"
|
||||
@@ -6036,6 +6438,48 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bitcoin_storage_choice_is_applied_and_old_catalog_cannot_silently_ignore_it() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
orch.set_data_dir(dir.path().to_path_buf());
|
||||
for id in ["bitcoin-core", "bitcoin-knots"] {
|
||||
let mut old = pull_manifest(id, "docker.io/bitcoin/bitcoin:28");
|
||||
// No preference: existing containers need no new environment flag.
|
||||
crate::settings::bitcoin_storage::save(dir.path(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
orch.resolve_dynamic_env(&mut old).await.unwrap();
|
||||
assert!(!old
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|s| s.starts_with("BITCOIN_PRUNE=")));
|
||||
crate::settings::bitcoin_storage::save(dir.path(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(orch
|
||||
.resolve_dynamic_env(&mut old)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("cannot honor"));
|
||||
let mut current = pull_manifest(id, "docker.io/bitcoin/bitcoin:28");
|
||||
current
|
||||
.app
|
||||
.container
|
||||
.custom_args
|
||||
.push("if [ ${BITCOIN_PRUNE:-0} = 1 ]; then :; fi".into());
|
||||
orch.resolve_dynamic_env(&mut current).await.unwrap();
|
||||
assert!(current
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|s| s == "BITCOIN_PRUNE=1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_resolves_derived_and_secret_env_before_create() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
@@ -6307,6 +6751,67 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_cascade_requires_observed_instance_change() {
|
||||
let running = ContainerStatus {
|
||||
id: "container-1".into(),
|
||||
name: "bitcoin-core".into(),
|
||||
state: ContainerState::Running,
|
||||
started_at: Some("start-1".into()),
|
||||
health: None,
|
||||
exit_code: None,
|
||||
image: "bitcoin:1".into(),
|
||||
created: "created-1".into(),
|
||||
ports: vec![],
|
||||
lan_address: None,
|
||||
};
|
||||
assert!(!backend_instance_changed(Some(&running), &running));
|
||||
assert!(backend_instance_changed(None, &running));
|
||||
let mut before = running.clone();
|
||||
before.state = ContainerState::Exited;
|
||||
assert!(backend_instance_changed(Some(&before), &running));
|
||||
before = running.clone();
|
||||
before.id = "old-container".into();
|
||||
assert!(backend_instance_changed(Some(&before), &running));
|
||||
before = running.clone();
|
||||
before.started_at = Some("earlier-start".into());
|
||||
assert!(backend_instance_changed(Some(&before), &running));
|
||||
before.started_at = None;
|
||||
assert!(!backend_instance_changed(Some(&before), &running));
|
||||
before.id.clear();
|
||||
assert!(!backend_instance_changed(Some(&before), &running));
|
||||
let mut after = running.clone();
|
||||
after.state = ContainerState::Exited;
|
||||
assert!(!backend_instance_changed(None, &after));
|
||||
after = running.clone();
|
||||
after.id.clear();
|
||||
assert!(!backend_instance_changed(None, &after));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_ignores_false_started_report_but_detects_real_exec_drift() {
|
||||
let none = HashSet::new();
|
||||
let mut report = ReconcileReport {
|
||||
actions: vec![
|
||||
("bitcoin-core".into(), ReconcileAction::Started),
|
||||
("lnd".into(), ReconcileAction::NoOp),
|
||||
],
|
||||
failures: vec![],
|
||||
};
|
||||
// systemctl start of an already active unit does not move its address.
|
||||
assert!(cascade_pairs_for_report(&report, &none, &none).is_empty());
|
||||
// A unit exec rewrite can restart Bitcoin while the outer reconcile
|
||||
// action remains NoOp. Runtime evidence still requires LND to reconnect.
|
||||
let changed = ["bitcoin-core".into()].into();
|
||||
report.actions[0].1 = ReconcileAction::NoOp;
|
||||
assert_eq!(
|
||||
cascade_pairs_for_report(&report, &none, &changed),
|
||||
vec![("bitcoin-core", "lnd")]
|
||||
);
|
||||
report.actions[0].1 = ReconcileAction::Left("lifecycle-op-in-flight".into());
|
||||
assert!(cascade_pairs_for_report(&report, &none, &changed).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_pairs_cover_backend_recreate_with_running_dependent() {
|
||||
use std::collections::HashSet;
|
||||
@@ -6318,6 +6823,7 @@ app:
|
||||
failures: vec![],
|
||||
};
|
||||
let none = HashSet::new();
|
||||
let changed: HashSet<String> = ["bitcoin-core".into(), "bitcoin-knots".into()].into();
|
||||
|
||||
// Backend recreated while lnd sat running (NoOp) → cascade.
|
||||
let r = report(vec![
|
||||
@@ -6325,7 +6831,7 @@ app:
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert_eq!(
|
||||
cascade_pairs_for_report(&r, &none),
|
||||
cascade_pairs_for_report(&r, &none, &changed),
|
||||
vec![("bitcoin-knots", "lnd")]
|
||||
);
|
||||
|
||||
@@ -6335,7 +6841,7 @@ app:
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert_eq!(
|
||||
cascade_pairs_for_report(&r, &none),
|
||||
cascade_pairs_for_report(&r, &none, &changed),
|
||||
vec![("bitcoin-core", "lnd")]
|
||||
);
|
||||
|
||||
@@ -6344,7 +6850,7 @@ app:
|
||||
("bitcoin-knots", ReconcileAction::NoOp),
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &none, &none).is_empty());
|
||||
|
||||
// Dependent itself (re)started this pass → it already resolved the
|
||||
// fresh address; no cascade.
|
||||
@@ -6352,7 +6858,7 @@ app:
|
||||
("bitcoin-knots", ReconcileAction::Installed),
|
||||
("lnd", ReconcileAction::Started),
|
||||
]);
|
||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty());
|
||||
|
||||
// User-stopped dependent is never bounced.
|
||||
let r = report(vec![
|
||||
@@ -6360,14 +6866,50 @@ app:
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
let stopped: HashSet<String> = ["lnd".to_string()].into();
|
||||
assert!(cascade_pairs_for_report(&r, &stopped).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &stopped, &changed).is_empty());
|
||||
|
||||
// Non-backend recreates don't cascade anything.
|
||||
let r = report(vec![
|
||||
("grafana", ReconcileAction::Installed),
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_wallet_start_precedes_unrelated_failed_image_pull() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
rt.set_state("bitcoin-knots", ContainerState::Exited);
|
||||
rt.set_state("lnd", ContainerState::Exited);
|
||||
*rt.fail_pull.lock().unwrap() = Some("registry unreachable".into());
|
||||
let mut orch = orch_with(rt.clone()).await;
|
||||
orch.set_disk_gb_for_test(2000);
|
||||
for id in ["unrelated", "lnd", "bitcoin-knots"] {
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest(id, &format!("docker.io/example/{id}:1")),
|
||||
PathBuf::from(format!("/tmp/{id}")),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let report = orch.reconcile_all().await;
|
||||
assert!(report.failures.iter().any(|(id, _)| id == "unrelated"));
|
||||
let calls = rt.calls();
|
||||
let bitcoin = calls
|
||||
.iter()
|
||||
.position(|c| c == "start_container:bitcoin-knots")
|
||||
.unwrap();
|
||||
let lnd = calls
|
||||
.iter()
|
||||
.position(|c| c == "start_container:lnd")
|
||||
.unwrap();
|
||||
let pull = calls
|
||||
.iter()
|
||||
.position(|c| c.starts_with("pull_image:"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
bitcoin < lnd && lnd < pull,
|
||||
"wallet startup was delayed by unrelated recovery: {calls:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -6771,6 +7313,41 @@ app:
|
||||
assert_eq!(ids, vec!["bitcoin-knots", "bitcoin-ui"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upgrade_preserves_container_when_catalog_is_stale_or_already_installed() {
|
||||
for (target, should_error) in [("v3.3.1", true), ("v3.3.1-archy1", false)] {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
rt.set_state("update-regression", ContainerState::Running);
|
||||
rt.running_images.lock().unwrap().insert(
|
||||
"update-regression".into(),
|
||||
"registry.test/old/mempool-frontend:v3.3.1-archy1".into(),
|
||||
);
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest(
|
||||
"update-regression",
|
||||
&format!("registry.test/new/mempool-frontend:{target}"),
|
||||
),
|
||||
PathBuf::from("/tmp/update-regression"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
orch.upgrade("update-regression").await.is_err(),
|
||||
should_error
|
||||
);
|
||||
assert!(
|
||||
!rt.calls()
|
||||
.iter()
|
||||
.any(|call| call.starts_with("stop_container:")
|
||||
|| call.starts_with("remove_container:")
|
||||
|| call.starts_with("pull_image:")
|
||||
|| call.starts_with("create_container:")),
|
||||
"{:?}",
|
||||
rt.calls()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upgrade_removes_and_reinstalls() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
|
||||
@@ -184,6 +184,7 @@ pub struct QuadletUnit {
|
||||
pub no_new_privileges: bool,
|
||||
pub cpu_quota: Option<u32>,
|
||||
pub restart_policy: RestartPolicy,
|
||||
pub stop_grace_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl QuadletUnit {
|
||||
@@ -216,6 +217,10 @@ impl QuadletUnit {
|
||||
let _ = writeln!(s, "[Container]");
|
||||
let _ = writeln!(s, "ContainerName={}", self.name);
|
||||
let _ = writeln!(s, "Image={}", self.image);
|
||||
let grace = self
|
||||
.stop_grace_secs
|
||||
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(&self.name));
|
||||
let _ = writeln!(s, "StopTimeout={grace}");
|
||||
// Pull=never: companions are pre-pulled or built. A missing image
|
||||
// must surface as a unit start failure, not a silent retry storm.
|
||||
let _ = writeln!(s, "Pull=never");
|
||||
@@ -350,6 +355,15 @@ impl QuadletUnit {
|
||||
// the unit stuck in deactivating. Health/status remains app-level state,
|
||||
// not a systemd start gate.
|
||||
let _ = writeln!(s, "TimeoutStartSec=0");
|
||||
let _ = writeln!(s, "TimeoutStopSec={}", grace.saturating_add(15));
|
||||
// Stop explicitly before Quadlet's generated `podman rm -f`. The
|
||||
// existing container may still carry Podman's old 10-second default;
|
||||
// StopTimeout alone only protects containers created after migration.
|
||||
let _ = writeln!(s, "ExecStop=");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"ExecStop=/usr/bin/podman stop --ignore --time={grace} --cidfile=%t/%N.cid"
|
||||
);
|
||||
// Restart policy + 10s backoff. RestartSec keeps a crash-loop
|
||||
// from saturating the journal. Companions: Always. Backends:
|
||||
// OnFailure (clean stops stay stopped).
|
||||
@@ -376,7 +390,10 @@ fn shell_join(parts: &[String]) -> String {
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let p = p.replace(['\r', '\n'], " ").replace('%', "%%");
|
||||
if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) {
|
||||
if p.is_empty()
|
||||
|| p.chars()
|
||||
.any(|c| c.is_whitespace() || "'\"\\$`".contains(c))
|
||||
{
|
||||
let escaped = p
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
@@ -396,7 +413,7 @@ fn quote_environment(env: &str) -> String {
|
||||
if env.is_empty()
|
||||
|| env
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || "\"\\$`".contains(c))
|
||||
.any(|c| c.is_whitespace() || "'\"\\$`".contains(c))
|
||||
{
|
||||
let escaped = env
|
||||
.replace('\\', "\\\\")
|
||||
@@ -525,6 +542,9 @@ impl QuadletUnit {
|
||||
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
|
||||
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
|
||||
restart_policy: RestartPolicy::Always,
|
||||
stop_grace_secs: Some(super::prod_orchestrator::resolve_stop_grace_secs(
|
||||
manifest, name,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,6 +696,13 @@ pub async fn unit_exists(name: &str) -> bool {
|
||||
|
||||
/// Resolve the per-user quadlet dir under $HOME. Created if missing.
|
||||
pub async fn unit_dir() -> Result<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
static TEST_UNITS: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
return Ok(TEST_UNITS
|
||||
.get_or_init(|| tempfile::tempdir().unwrap().keep())
|
||||
.clone());
|
||||
}
|
||||
let home = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
|
||||
@@ -785,7 +812,11 @@ pub async fn stop_service(service: &str) -> Result<()> {
|
||||
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
||||
/// less than `QUADLET_STOP_TIMEOUT`.
|
||||
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
|
||||
let timeout = timeout.max(QUADLET_STOP_TIMEOUT);
|
||||
let name = service.strip_suffix(".service").unwrap_or(service);
|
||||
let body = fs::read_to_string(unit_dir().await?.join(format!("{name}.container")))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let timeout = timeout.max(stop_wait_timeout(name, &body));
|
||||
match systemctl_user_status(&["stop", service], timeout).await {
|
||||
Ok(status) if status.success() => Ok(()),
|
||||
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
||||
@@ -806,10 +837,29 @@ pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Resu
|
||||
}
|
||||
}
|
||||
|
||||
/// The command waiter must outlive both the container grace and systemd's
|
||||
/// stop deadline. Restart/repair callers must not kill Bitcoin at 45 seconds.
|
||||
fn stop_wait_timeout(name: &str, unit_body: &str) -> Duration {
|
||||
Duration::from_secs(stop_grace_from_unit(name, unit_body).saturating_add(30))
|
||||
.max(QUADLET_STOP_TIMEOUT)
|
||||
}
|
||||
|
||||
fn stop_grace_from_unit(name: &str, unit_body: &str) -> u64 {
|
||||
directive_values(unit_body, "StopTimeout=")
|
||||
.last()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(name))
|
||||
}
|
||||
|
||||
async fn systemctl_user_status(
|
||||
args: &[&str],
|
||||
timeout: Duration,
|
||||
) -> Result<std::process::ExitStatus> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
return Ok(std::process::ExitStatus::from_raw(0));
|
||||
}
|
||||
let mut cmd = Command::new("systemctl");
|
||||
cmd.arg("--user").args(args);
|
||||
cmd.kill_on_drop(true);
|
||||
@@ -856,6 +906,10 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool {
|
||||
}
|
||||
|
||||
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::bail!("Unit tests have no real user service manager");
|
||||
}
|
||||
let mut cmd = Command::new("systemctl");
|
||||
cmd.arg("--user").args(args);
|
||||
cmd.kill_on_drop(true);
|
||||
@@ -887,12 +941,77 @@ pub fn health_cmd_changed(old_body: &str, new_body: &str) -> bool {
|
||||
!= directive_values(new_body, "HealthRetries=")
|
||||
}
|
||||
|
||||
/// A unit rewrite and a successful systemd restart are separate operations.
|
||||
/// Keep the restart obligation across errors or a management-daemon restart.
|
||||
pub struct RestartObligation {
|
||||
marker: PathBuf,
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
impl RestartObligation {
|
||||
pub async fn prepare(unit_path: &Path, newly_required: bool) -> Result<Self> {
|
||||
let marker = unit_path.with_extension("restart-pending");
|
||||
if newly_required {
|
||||
// Contents contain no manifest environment or credentials. sync_all
|
||||
// makes the obligation durable before the subsequent unit rename.
|
||||
let file = tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(&marker)
|
||||
.await
|
||||
.context("record pending Quadlet restart")?;
|
||||
file.sync_all().await?;
|
||||
if let Some(parent) = marker.parent() {
|
||||
tokio::fs::File::open(parent).await?.sync_all().await?;
|
||||
}
|
||||
}
|
||||
let pending = tokio::fs::try_exists(&marker).await?;
|
||||
Ok(Self { marker, pending })
|
||||
}
|
||||
|
||||
pub fn is_pending(&self) -> bool {
|
||||
self.pending
|
||||
}
|
||||
|
||||
/// Call only after systemd accepted the replacement service successfully.
|
||||
pub async fn complete(self) -> Result<()> {
|
||||
if self.pending {
|
||||
tokio::fs::remove_file(&self.marker)
|
||||
.await
|
||||
.context("clear completed Quadlet restart")?;
|
||||
if let Some(parent) = self.marker.parent() {
|
||||
tokio::fs::File::open(parent).await?.sync_all().await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
|
||||
let old_ports = directive_values(old_body, "PublishPort=");
|
||||
let new_ports = directive_values(new_body, "PublishPort=");
|
||||
old_ports != new_ports
|
||||
}
|
||||
|
||||
pub fn security_changed(old_body: &str, new_body: &str) -> bool {
|
||||
[
|
||||
"AddCapability=",
|
||||
"DropCapability=",
|
||||
"NoNewPrivileges=",
|
||||
"ReadOnly=",
|
||||
"User=",
|
||||
]
|
||||
.iter()
|
||||
.any(|directive| {
|
||||
let mut old = directive_values(old_body, directive);
|
||||
let mut new = directive_values(new_body, directive);
|
||||
old.sort();
|
||||
new.sort();
|
||||
old != new
|
||||
})
|
||||
}
|
||||
|
||||
pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool {
|
||||
let old_network = directive_values(old_body, "Network=");
|
||||
let new_network = directive_values(new_body, "Network=");
|
||||
@@ -923,6 +1042,10 @@ fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
|
||||
/// that systemd no longer knows about.
|
||||
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
let svc = format!("{unit_name}.service");
|
||||
let path = dir.join(format!("{unit_name}.container"));
|
||||
let body = fs::read_to_string(&path).await.unwrap_or_default();
|
||||
let timeout = stop_wait_timeout(unit_name, &body);
|
||||
let grace = stop_grace_from_unit(unit_name, &body).to_string();
|
||||
// Stop first; ignore failure (unit may already be down). BOUNDED — on
|
||||
// rootless podman a generated unit can wedge in "deactivating" while
|
||||
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
|
||||
@@ -930,13 +1053,12 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
// the package entry is stranded in `Removing` (a ghost in My Apps that also
|
||||
// blocks reinstall). If the graceful stop times out, escalate to
|
||||
// SIGKILL + reset-failed so teardown always proceeds.
|
||||
if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT)
|
||||
if systemctl_user_status(&["stop", &svc], timeout)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = kill_and_reset_service(&svc).await;
|
||||
}
|
||||
let path = dir.join(format!("{unit_name}.container"));
|
||||
if fs::try_exists(&path).await.unwrap_or(false) {
|
||||
match fs::remove_file(&path).await {
|
||||
Ok(()) => {}
|
||||
@@ -949,9 +1071,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
// Bounded so a hung podman store can't re-introduce the stall this function
|
||||
// exists to avoid.
|
||||
let _ = tokio::time::timeout(
|
||||
QUADLET_STOP_TIMEOUT,
|
||||
timeout,
|
||||
Command::new("podman")
|
||||
.args(["rm", "-f", unit_name])
|
||||
.args(["rm", "-f", "--ignore", "--time", &grace, unit_name])
|
||||
.status(),
|
||||
)
|
||||
.await;
|
||||
@@ -960,6 +1082,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
|
||||
/// Is the quadlet-generated service currently active?
|
||||
pub async fn is_active(service: &str) -> bool {
|
||||
if cfg!(test) {
|
||||
return false;
|
||||
}
|
||||
Command::new("systemctl")
|
||||
.args(["--user", "is-active", "--quiet", service])
|
||||
.status()
|
||||
@@ -973,6 +1098,118 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn shutdown_grace_covers_container_systemd_and_caller() {
|
||||
for (name, grace) in [
|
||||
("bitcoin-core", 600),
|
||||
("bitcoin-knots", 600),
|
||||
("lnd", 330),
|
||||
("electrumx", 300),
|
||||
("other", 30),
|
||||
] {
|
||||
let unit = QuadletUnit {
|
||||
name: name.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let body = unit.render();
|
||||
assert!(body.contains(&format!("StopTimeout={grace}\n")));
|
||||
assert!(body.contains(&format!("TimeoutStopSec={}\n", grace + 15)));
|
||||
assert!(body.contains(&format!("podman stop --ignore --time={grace} --cidfile=")));
|
||||
assert_eq!(
|
||||
stop_wait_timeout(name, &body),
|
||||
Duration::from_secs(grace + 30)
|
||||
);
|
||||
// Legacy units have no StopTimeout directive yet.
|
||||
assert_eq!(stop_wait_timeout(name, ""), Duration::from_secs(grace + 30));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_stop_grace_survives_render_and_restart_budget() {
|
||||
let manifest: AppManifest = serde_yaml::from_str(
|
||||
r#"
|
||||
app:
|
||||
id: custom-db
|
||||
name: Custom database
|
||||
version: 1.0.0
|
||||
stop_grace_secs: 900
|
||||
container:
|
||||
image: example/db:1
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let unit = QuadletUnit::from_manifest(&manifest, "custom-db");
|
||||
assert_eq!(unit.stop_grace_secs, Some(900));
|
||||
assert_eq!(
|
||||
stop_wait_timeout("custom-db", &unit.render()),
|
||||
Duration::from_secs(930)
|
||||
);
|
||||
assert_eq!(
|
||||
stop_wait_timeout("lnd", "StopTimeout=invalid"),
|
||||
Duration::from_secs(360)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_grace_migration_does_not_request_an_execution_restart() {
|
||||
let unit = sample_unit();
|
||||
let new = unit.render();
|
||||
let old = new
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
!line.starts_with("StopTimeout=")
|
||||
&& !line.starts_with("TimeoutStopSec=")
|
||||
&& !line.starts_with("ExecStop=")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(!exec_changed(&old, &new));
|
||||
assert!(!publish_ports_changed(&old, &new));
|
||||
assert!(!network_aliases_changed(&old, &new));
|
||||
assert!(!health_cmd_changed(&old, &new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_quadlet_generator_stops_before_forced_removal() {
|
||||
let generator = Path::new("/usr/lib/systemd/system-generators/podman-system-generator");
|
||||
if !generator.exists() {
|
||||
eprintln!(
|
||||
"Quadlet generator unavailable; run this regression on the Linux release host"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let dir = tempdir().unwrap();
|
||||
let unit = QuadletUnit {
|
||||
name: "grace-test".into(),
|
||||
image: "localhost/test:latest".into(),
|
||||
stop_grace_secs: Some(600),
|
||||
..Default::default()
|
||||
};
|
||||
std::fs::write(dir.path().join("grace-test.container"), unit.render()).unwrap();
|
||||
let output = std::process::Command::new(generator)
|
||||
.args(["--user", "--dryrun"])
|
||||
.env("QUADLET_UNIT_DIRS", dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let generated = String::from_utf8_lossy(&output.stdout).to_string()
|
||||
+ &String::from_utf8_lossy(&output.stderr);
|
||||
let stop = generated
|
||||
.find("ExecStop=/usr/bin/podman stop --ignore --time=600")
|
||||
.unwrap();
|
||||
let remove = generated.find("ExecStop=/usr/bin/podman rm ").unwrap();
|
||||
assert!(
|
||||
stop < remove,
|
||||
"Legacy container must stop gracefully before removal"
|
||||
);
|
||||
assert!(generated.contains("--stop-timeout 600"));
|
||||
assert!(generated.contains("TimeoutStopSec=615"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_emits_secret_env_by_reference_never_value() {
|
||||
let u = QuadletUnit {
|
||||
@@ -1160,6 +1397,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apostrophes_survive_quadlet_argument_and_environment_parsing() {
|
||||
// A whitespace-free Node script reproduced this in a real Quadlet:
|
||||
// unquoted apostrophes were consumed by the parser, changing JS strings
|
||||
// into identifiers and preventing the app from starting.
|
||||
assert_eq!(
|
||||
shell_join(&["require('http')".into()]),
|
||||
"\"require('http')\""
|
||||
);
|
||||
assert_eq!(quote_environment("NAME=O'Brien"), "\"NAME=O'Brien\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_environment_quotes_values_with_spaces() {
|
||||
assert_eq!(
|
||||
@@ -1372,6 +1621,26 @@ app:
|
||||
assert!(!s.contains("Network=host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
|
||||
let manifest = AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml"))
|
||||
.expect("shipped Portainer manifest must parse");
|
||||
let new = QuadletUnit::from_manifest(&manifest, "portainer").render();
|
||||
assert!(new.contains("Network=slirp4netns\n"));
|
||||
assert!(!new.contains("NetworkAlias="));
|
||||
assert!(new.contains("PublishPort=127.0.0.1:9000:9000/tcp"));
|
||||
assert!(!new.contains("PublishPort=0.0.0.0"));
|
||||
// The upgrade changes networking only: retain both state mounts and the
|
||||
// existing rootless socket, without an app.ini or repository rewrite.
|
||||
assert!(new.contains("Volume=/var/lib/archipelago/portainer:/data"));
|
||||
assert!(new.contains("Volume=/var/lib/archipelago/portainer/compose:/data/compose"));
|
||||
assert!(new.contains("Volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock"));
|
||||
let old = new.replace("Network=slirp4netns\n", "");
|
||||
assert!(network_aliases_changed(&old, &new));
|
||||
assert!(!network_aliases_changed(&new, &new));
|
||||
assert!(!publish_ports_changed(&old, &new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_manifest_slirp4netns_omits_network_alias() {
|
||||
let yaml = r#"
|
||||
@@ -1722,6 +1991,59 @@ app:
|
||||
assert!(!network_aliases_changed(new, new));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let unit = dir.path().join("portainer.container");
|
||||
tokio::fs::write(&unit, "[Container]\n").await.unwrap();
|
||||
let pending = RestartObligation::prepare(&unit, true).await.unwrap();
|
||||
assert!(pending.is_pending());
|
||||
tokio::fs::write(&unit, "[Container]\nNetwork=slirp4netns\n")
|
||||
.await
|
||||
.unwrap();
|
||||
// Simulate systemctl failure or daemon interruption after unit rewrite.
|
||||
drop(pending);
|
||||
let retry = RestartObligation::prepare(&unit, false).await.unwrap();
|
||||
assert!(
|
||||
retry.is_pending(),
|
||||
"matching unit must not discard failed restart"
|
||||
);
|
||||
retry.complete().await.unwrap();
|
||||
assert!(!RestartObligation::prepare(&unit, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_pending());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_runtime_change_errors_are_not_reported_as_success() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("missing/app.container");
|
||||
assert!(RestartObligation::prepare(&missing, true).await.is_err());
|
||||
let unit = dir.path().join("app.container");
|
||||
let pending = RestartObligation::prepare(&unit, true).await.unwrap();
|
||||
tokio::fs::remove_file(unit.with_extension("restart-pending"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(pending.complete().await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitea_ssh_sandbox_capability_is_applied_as_a_runtime_change() {
|
||||
let manifest =
|
||||
AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap();
|
||||
manifest.validate().unwrap();
|
||||
let new = QuadletUnit::from_manifest(&manifest, "gitea").render();
|
||||
assert!(new.contains("AddCapability=SYS_CHROOT\n"));
|
||||
let old = new.replace("AddCapability=SYS_CHROOT\n", "");
|
||||
assert!(security_changed(&old, &new));
|
||||
assert!(!security_changed(&new, &new));
|
||||
assert!(!security_changed(
|
||||
"AddCapability=CHOWN\nAddCapability=SETUID\n",
|
||||
"AddCapability=SETUID\nAddCapability=CHOWN\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_aliases_changed_detects_network_mode_drift() {
|
||||
let old = "[Container]\nNetwork=slirp4netns\n";
|
||||
|
||||
@@ -238,6 +238,11 @@ pub enum ServeResult {
|
||||
Forbidden,
|
||||
/// Content not found.
|
||||
NotFound,
|
||||
/// The catalog entry and file exist but this node can't read the file.
|
||||
/// Returned before any payment is taken.
|
||||
Unavailable,
|
||||
/// Requested byte range cannot be served; no payment was taken.
|
||||
RangeNotSatisfiable(u64),
|
||||
}
|
||||
|
||||
/// Serve a content item by ID with access control and optional range request.
|
||||
@@ -252,6 +257,39 @@ pub async fn serve_content(
|
||||
range: Option<ByteRange>,
|
||||
owner_session: bool,
|
||||
) -> Result<ServeResult> {
|
||||
serve_content_with(
|
||||
data_dir,
|
||||
id,
|
||||
payment_token,
|
||||
invoice_hash,
|
||||
peer_did,
|
||||
range,
|
||||
owner_session,
|
||||
|path, range, mime| prepare_content(data_dir, path, range, mime),
|
||||
|token, amount| async move { verify_payment_token(data_dir, &token, amount).await },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Inject only the read and payment boundaries, so tests can prove ordering
|
||||
// without mint access, file-permission assumptions or privileged commands.
|
||||
async fn serve_content_with<R, RF, V, VF>(
|
||||
data_dir: &Path,
|
||||
id: &str,
|
||||
payment_token: Option<&str>,
|
||||
invoice_hash: Option<&str>,
|
||||
peer_did: Option<&str>,
|
||||
range: Option<ByteRange>,
|
||||
owner_session: bool,
|
||||
read: R,
|
||||
verify: V,
|
||||
) -> Result<ServeResult>
|
||||
where
|
||||
R: FnOnce(PathBuf, Option<ByteRange>, String) -> RF,
|
||||
RF: std::future::Future<Output = Result<ServeResult>>,
|
||||
V: FnOnce(String, u64) -> VF,
|
||||
VF: std::future::Future<Output = bool>,
|
||||
{
|
||||
let catalog = load_catalog(data_dir).await?;
|
||||
let item = match catalog.items.iter().find(|i| i.id == id) {
|
||||
Some(i) => i,
|
||||
@@ -296,6 +334,47 @@ pub async fn serve_content(
|
||||
}
|
||||
}
|
||||
|
||||
let file_path = content_file_path(data_dir, item);
|
||||
if !file_path.exists() {
|
||||
// The catalog entry survived (it's a separate JSON file) but its
|
||||
// backing file is gone — most likely lost in an unrelated data-dir
|
||||
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
||||
// outlived a filebrowser reinstall that wiped the files themselves).
|
||||
// Leaving the entry in place would keep advertising it as available
|
||||
// to every peer forever, each hitting the exact same dead end this
|
||||
// one just did. Prune it so it stops being offered.
|
||||
warn!(
|
||||
content_id = %id,
|
||||
filename = %item.filename,
|
||||
"content catalog entry's file is missing on disk — pruning the stale entry"
|
||||
);
|
||||
prune_missing_content_entry(data_dir, id).await;
|
||||
return Ok(ServeResult::NotFound);
|
||||
}
|
||||
|
||||
// Refuse unauthorized viewers before opening or reading any bytes.
|
||||
if !owner_session && matches!(item.access, AccessControl::PeersOnly) && !is_known_peer {
|
||||
return Ok(ServeResult::Forbidden);
|
||||
}
|
||||
if !owner_session {
|
||||
if let AccessControl::Paid { price_sats, .. } = &item.access {
|
||||
if payment_token.is_none() && invoice_hash.is_none() {
|
||||
return Ok(ServeResult::PaymentRequired(*price_sats));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finish all file I/O before consuming bearer payment. Merely opening then
|
||||
// reopening after charging still lost payments on read errors or deletion.
|
||||
let prepared = match read(file_path, range, item.mime_type.clone()).await {
|
||||
Ok(result @ (ServeResult::Ok(..) | ServeResult::Partial { .. })) => result,
|
||||
Ok(other) => return Ok(other),
|
||||
Err(error) => {
|
||||
warn!(content_id = %id, "Cannot prepare shared content: {error:#}");
|
||||
return Ok(ServeResult::Unavailable);
|
||||
}
|
||||
};
|
||||
|
||||
// Check access control
|
||||
if !owner_session {
|
||||
match &item.access {
|
||||
@@ -307,9 +386,13 @@ pub async fn serve_content(
|
||||
// Each path only counts when the sharer accepts that method.
|
||||
let mut authorized = false;
|
||||
if let Some(token) = payment_token {
|
||||
if (method_accepted(&item.access, "ecash")
|
||||
|| method_accepted(&item.access, "fedimint"))
|
||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||
let method = if token.trim().starts_with("cashu") {
|
||||
"ecash"
|
||||
} else {
|
||||
"fedimint"
|
||||
};
|
||||
if method_accepted(&item.access, method)
|
||||
&& verify(token.to_owned(), *price_sats).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
@@ -336,73 +419,127 @@ pub async fn serve_content(
|
||||
}
|
||||
}
|
||||
|
||||
let file_path = content_file_path(data_dir, item);
|
||||
if !file_path.exists() {
|
||||
// The catalog entry survived (it's a separate JSON file) but its
|
||||
// backing file is gone — most likely lost in an unrelated data-dir
|
||||
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
||||
// outlived a filebrowser reinstall that wiped the files themselves).
|
||||
// Leaving the entry in place would keep advertising it as available
|
||||
// to every peer forever, each hitting the exact same dead end this
|
||||
// one just did. Prune it so it stops being offered.
|
||||
warn!(
|
||||
content_id = %id,
|
||||
filename = %item.filename,
|
||||
"content catalog entry's file is missing on disk — pruning the stale entry"
|
||||
);
|
||||
prune_missing_content_entry(data_dir, id).await;
|
||||
return Ok(ServeResult::NotFound);
|
||||
}
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&file_path)
|
||||
async fn prepare_content(
|
||||
data_dir: &Path,
|
||||
path: PathBuf,
|
||||
range: Option<ByteRange>,
|
||||
mime: String,
|
||||
) -> Result<ServeResult> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
let mut file = match fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(&path)
|
||||
.await
|
||||
.context("Failed to read file metadata")?;
|
||||
let total_size = metadata.len();
|
||||
|
||||
// Handle range request for streaming
|
||||
if let Some(range) = range {
|
||||
let start = range.start.min(total_size.saturating_sub(1));
|
||||
let end = range
|
||||
.end
|
||||
.map(|e| e.min(total_size - 1))
|
||||
.unwrap_or(total_size - 1);
|
||||
|
||||
if start > end || start >= total_size {
|
||||
return Ok(ServeResult::NotFound);
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
let bytes = read_filebrowser_via_userns(data_dir, &path).await?;
|
||||
return slice_prepared_content(bytes, range, mime);
|
||||
}
|
||||
|
||||
let len = (end - start + 1) as usize;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
let mut file = tokio::fs::File::open(&file_path)
|
||||
Err(error) => return Err(error).context("Opening shared content"),
|
||||
};
|
||||
let metadata = file.metadata().await?;
|
||||
anyhow::ensure!(metadata.is_file(), "Shared content is not a regular file");
|
||||
let total = metadata.len();
|
||||
if let Some(range) = range {
|
||||
let Some((start, end)) = checked_range(&range, total) else {
|
||||
return Ok(ServeResult::RangeNotSatisfiable(total));
|
||||
};
|
||||
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||
let len = usize::try_from(end - start + 1).context("Content range is too large")?;
|
||||
let mut bytes = vec![0; len];
|
||||
file.read_exact(&mut bytes)
|
||||
.await
|
||||
.context("Failed to open content file")?;
|
||||
file.seek(std::io::SeekFrom::Start(start))
|
||||
.await
|
||||
.context("Failed to seek")?;
|
||||
let mut buf = vec![0u8; len];
|
||||
file.read_exact(&mut buf)
|
||||
.await
|
||||
.context("Failed to read range")?;
|
||||
|
||||
debug!(
|
||||
"Serving content '{}' range {}-{}/{} ({} bytes)",
|
||||
id, start, end, total_size, len
|
||||
);
|
||||
.context("Reading shared content range")?;
|
||||
return Ok(ServeResult::Partial {
|
||||
bytes: buf,
|
||||
mime_type: item.mime_type.clone(),
|
||||
bytes,
|
||||
mime_type: mime,
|
||||
start,
|
||||
end,
|
||||
total: total_size,
|
||||
total,
|
||||
});
|
||||
}
|
||||
|
||||
let bytes = fs::read(&file_path)
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)
|
||||
.await
|
||||
.context("Failed to read content file")?;
|
||||
.context("Reading shared content")?;
|
||||
Ok(ServeResult::Ok(bytes, mime))
|
||||
}
|
||||
|
||||
debug!("Serving content '{}' ({} bytes)", id, bytes.len());
|
||||
Ok(ServeResult::Ok(bytes, item.mime_type.clone()))
|
||||
fn checked_range(range: &ByteRange, total: u64) -> Option<(u64, u64)> {
|
||||
let last = total.checked_sub(1)?;
|
||||
let end = range.end.unwrap_or(last).min(last);
|
||||
(range.start <= end && range.start < total).then_some((range.start, end))
|
||||
}
|
||||
|
||||
fn slice_prepared_content(
|
||||
bytes: Vec<u8>,
|
||||
range: Option<ByteRange>,
|
||||
mime: String,
|
||||
) -> Result<ServeResult> {
|
||||
let total = bytes.len() as u64;
|
||||
match range {
|
||||
None => Ok(ServeResult::Ok(bytes, mime)),
|
||||
Some(range) => match checked_range(&range, total) {
|
||||
Some((start, end)) => Ok(ServeResult::Partial {
|
||||
bytes: bytes[start as usize..=end as usize].to_vec(),
|
||||
mime_type: mime,
|
||||
start,
|
||||
end,
|
||||
total,
|
||||
}),
|
||||
None => Ok(ServeResult::RangeNotSatisfiable(total)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Read only an explicitly shared, regular file within FileBrowser storage.
|
||||
/// Do not change its mode or grant world-readable access to paid/private data.
|
||||
async fn filebrowser_read_path(data_dir: &Path, path: &Path) -> Result<PathBuf> {
|
||||
let root = fs::canonicalize(data_dir.join("filebrowser")).await?;
|
||||
let target = fs::canonicalize(path).await?;
|
||||
anyhow::ensure!(
|
||||
target.starts_with(&root) && target != root,
|
||||
"Shared file is outside Files storage"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
fs::metadata(&target).await?.is_file(),
|
||||
"Shared content is not a regular file"
|
||||
);
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
async fn read_filebrowser_via_userns(data_dir: &Path, path: &Path) -> Result<Vec<u8>> {
|
||||
let path = filebrowser_read_path(data_dir, path).await?;
|
||||
// Tests exercise the boundary explicitly; they never launch the host Podman.
|
||||
#[cfg(test)]
|
||||
{
|
||||
let _ = path;
|
||||
anyhow::bail!("Files namespace read disabled in unit tests")
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let output = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(900),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["unshare", "cat", "--"])
|
||||
.arg(path)
|
||||
.kill_on_drop(true)
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.context("Files namespace read timed out")??;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Files namespace read failed: {}",
|
||||
output.status
|
||||
);
|
||||
Ok(output.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of attempting to serve a preview.
|
||||
@@ -573,7 +710,7 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
|
||||
}
|
||||
|
||||
/// Verify a payment token covers the required amount.
|
||||
/// Accepts both cashuA tokens (real Cashu) and legacy cashuSend_ format.
|
||||
/// Accepts real Cashu tokens and Fedimint notes.
|
||||
/// Swaps proofs at the mint to verify they're unspent before accepting.
|
||||
async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64) -> bool {
|
||||
match crate::wallet::ecash::verify_and_receive_payment(data_dir, token, required_sats).await {
|
||||
@@ -725,3 +862,301 @@ mod prune_missing_content_tests {
|
||||
assert_eq!(reloaded.items[0].id, "present-item");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod paid_read_order_tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
async fn fixture(bytes: &[u8]) -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join("content/files"))
|
||||
.await
|
||||
.unwrap();
|
||||
fs::write(dir.path().join("content/files/test.bin"), bytes)
|
||||
.await
|
||||
.unwrap();
|
||||
save_catalog(
|
||||
dir.path(),
|
||||
&ContentCatalog {
|
||||
items: vec![ContentItem {
|
||||
id: "paid".into(),
|
||||
filename: "test.bin".into(),
|
||||
mime_type: "application/octet-stream".into(),
|
||||
size_bytes: bytes.len() as u64,
|
||||
description: String::new(),
|
||||
access: AccessControl::Paid {
|
||||
price_sats: 10,
|
||||
accepted: vec!["ecash".into()],
|
||||
},
|
||||
availability: Availability::AllPeers,
|
||||
added_at: "2026-09-30".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_read_failures_precede_redemption_even_as_root() {
|
||||
for kind in [
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
std::io::ErrorKind::NotFound,
|
||||
std::io::ErrorKind::Other,
|
||||
] {
|
||||
let dir = fixture(b"abc").await;
|
||||
let charged = AtomicUsize::new(0);
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|_, _, _| async move { Err(std::io::Error::from(kind).into()) },
|
||||
|_, _| async {
|
||||
charged.fetch_add(1, Ordering::SeqCst);
|
||||
true
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Unavailable));
|
||||
assert_eq!(charged.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(load_catalog(dir.path()).await.unwrap().items.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletion_during_payment_cannot_lose_prepared_bytes() {
|
||||
let dir = fixture(b"original").await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, amount| {
|
||||
assert_eq!(amount, 10);
|
||||
async {
|
||||
fs::remove_file(dir.path().join("content/files/test.bin"))
|
||||
.await
|
||||
.unwrap();
|
||||
true
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"original"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_out_of_bounds_and_reversed_ranges_never_charge() {
|
||||
for (bytes, start, end) in [
|
||||
(b"".as_slice(), 0, None),
|
||||
(b"abc".as_slice(), 3, None),
|
||||
(b"abc".as_slice(), 2, Some(1)),
|
||||
] {
|
||||
let dir = fixture(bytes).await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
Some(ByteRange { start, end }),
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async { panic!("invalid range reached payment") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result, ServeResult::RangeNotSatisfiable(n) if n == bytes.len() as u64)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepared_range_survives_file_change_while_payment_is_verified() {
|
||||
let dir = fixture(b"abcdef").await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
Some(ByteRange {
|
||||
start: 2,
|
||||
end: Some(999),
|
||||
}),
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async {
|
||||
fs::write(dir.path().join("content/files/test.bin"), b"x")
|
||||
.await
|
||||
.unwrap();
|
||||
true
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result, ServeResult::Partial { bytes, start: 2, end: 5, total: 6, .. } if bytes == b"cdef")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn payment_denial_never_returns_prepared_content() {
|
||||
let dir = fixture(b"secret").await;
|
||||
let charged = AtomicUsize::new(0);
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async {
|
||||
charged.fetch_add(1, Ordering::SeqCst);
|
||||
false
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::PaymentRequired(10)));
|
||||
assert_eq!(charged.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_payment_and_peer_restrictions_precede_file_reads() {
|
||||
let dir = fixture(b"secret").await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|_, _, _| async { panic!("unauthorized file read") },
|
||||
|_, _| async { panic!("unexpected payment") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::PaymentRequired(10)));
|
||||
let mut catalog = load_catalog(dir.path()).await.unwrap();
|
||||
catalog.items[0].access = AccessControl::PeersOnly;
|
||||
save_catalog(dir.path(), &catalog).await.unwrap();
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|_, _, _| async { panic!("unauthorized file read") },
|
||||
|_, _| async { panic!("unexpected payment") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_reads_paid_content_without_redemption() {
|
||||
let dir = fixture(b"own file").await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async { panic!("owner charged") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"own file"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directory_in_place_of_file_does_not_charge() {
|
||||
let dir = fixture(b"abc").await;
|
||||
let path = dir.path().join("content/files/test.bin");
|
||||
fs::remove_file(&path).await.unwrap();
|
||||
fs::create_dir(&path).await.unwrap();
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async { panic!("directory charged") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Unavailable));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_namespace_read_is_scoped_to_regular_files_and_keeps_mode() {
|
||||
use std::os::unix::fs::{symlink, PermissionsExt};
|
||||
let dir = fixture(b"outside").await;
|
||||
let root = dir.path().join("filebrowser");
|
||||
fs::create_dir(&root).await.unwrap();
|
||||
let inside = root.join("song");
|
||||
fs::write(&inside, b"song").await.unwrap();
|
||||
fs::set_permissions(&inside, std::fs::Permissions::from_mode(0o640))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
filebrowser_read_path(dir.path(), &inside).await.unwrap(),
|
||||
inside
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&inside).await.unwrap().permissions().mode() & 0o777,
|
||||
0o640
|
||||
);
|
||||
let outside = dir.path().join("content/files/test.bin");
|
||||
symlink(&outside, root.join("escape")).unwrap();
|
||||
for path in [outside, root.join("escape"), root.clone()] {
|
||||
assert!(filebrowser_read_path(dir.path(), &path).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_namespace_bytes_use_the_same_range_rules() {
|
||||
assert!(matches!(
|
||||
slice_prepared_content(
|
||||
vec![],
|
||||
Some(ByteRange {
|
||||
start: 0,
|
||||
end: None
|
||||
}),
|
||||
"x".into()
|
||||
)
|
||||
.unwrap(),
|
||||
ServeResult::RangeNotSatisfiable(0)
|
||||
));
|
||||
assert!(
|
||||
matches!(slice_prepared_content(b"abc".to_vec(), Some(ByteRange { start: 1, end: None }), "x".into()).unwrap(), ServeResult::Partial { bytes, start: 1, end: 2, total: 3, .. } if bytes == b"bc")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ pub async fn clear_user_stopped(data_dir: &Path, name: &str) {
|
||||
// Installation is a decision, not a runtime observation, so it gets a record
|
||||
// of its own that no amount of downtime erodes.
|
||||
const INSTALLED_APPS_FILE: &str = "installed-apps.json";
|
||||
static INSTALLED_APPS_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// Load the durable set of installed app ids / container names.
|
||||
pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<String> {
|
||||
@@ -220,12 +221,23 @@ pub async fn load_installed_apps_if_recorded(
|
||||
async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) {
|
||||
let path = data_dir.join(INSTALLED_APPS_FILE);
|
||||
if let Ok(json) = serde_json::to_string_pretty(installed) {
|
||||
let _ = fs::write(&path, json).await;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let result = async {
|
||||
fs::write(&tmp, json).await?;
|
||||
fs::File::open(&tmp).await?.sync_all().await?;
|
||||
fs::rename(&tmp, &path).await?;
|
||||
fs::File::open(data_dir).await?.sync_all().await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
warn!(%error, "could not persist installed apps");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that an app is installed. Called when an install succeeds.
|
||||
pub async fn mark_installed(data_dir: &Path, name: &str) {
|
||||
let _guard = INSTALLED_APPS_LOCK.lock().await;
|
||||
let mut installed = load_installed_apps(data_dir).await;
|
||||
if installed.insert(name.to_string()) {
|
||||
save_installed_apps(data_dir, &installed).await;
|
||||
@@ -235,6 +247,7 @@ pub async fn mark_installed(data_dir: &Path, name: &str) {
|
||||
/// Forget an app. Called on uninstall, beside `mark_user_uninstalled` — the
|
||||
/// two must move together or a reinstall-after-uninstall leaves a stale claim.
|
||||
pub async fn clear_installed(data_dir: &Path, name: &str) {
|
||||
let _guard = INSTALLED_APPS_LOCK.lock().await;
|
||||
let mut installed = load_installed_apps(data_dir).await;
|
||||
if installed.remove(name) {
|
||||
save_installed_apps(data_dir, &installed).await;
|
||||
@@ -252,6 +265,7 @@ pub async fn clear_installed(data_dir: &Path, name: &str) {
|
||||
/// need it. Runs on every boot, so an app installed before the upgrade is
|
||||
/// still picked up whenever it is next seen alive.
|
||||
pub async fn backfill_installed_apps(data_dir: &Path, present_container_names: &[String]) {
|
||||
let _guard = INSTALLED_APPS_LOCK.lock().await;
|
||||
if present_container_names.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -1497,3 +1511,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod installed_concurrency_tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn concurrent_install_records_are_not_lost() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut tasks = Vec::new();
|
||||
for i in 0..24 {
|
||||
let path = dir.path().to_owned();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
mark_installed(&path, &format!("app-{i}")).await;
|
||||
}));
|
||||
}
|
||||
for task in tasks {
|
||||
task.await.unwrap();
|
||||
}
|
||||
assert_eq!(load_installed_apps(dir.path()).await.len(), 24);
|
||||
clear_installed(dir.path(), "app-3").await;
|
||||
assert_eq!(load_installed_apps(dir.path()).await.len(), 23);
|
||||
assert!(!dir.path().join("installed-apps.json.tmp").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,10 @@ pub enum PackageState {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PackageDataEntry {
|
||||
/// Whether the app's HTTP upstream answered this scan (independent of
|
||||
/// container health and blockchain sync). Missing on older nodes.
|
||||
#[serde(rename = "ui-ready", default, skip_serializing_if = "Option::is_none")]
|
||||
pub ui_ready: Option<bool>,
|
||||
pub state: PackageState,
|
||||
/// Container health: "healthy", "unhealthy", "starting", or null
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -297,6 +301,8 @@ pub enum InstallPhase {
|
||||
/// `podman pull` in progress (the longest phase — up to several
|
||||
/// minutes for large images on slow networks).
|
||||
PullingImage,
|
||||
/// Orchestrator owns download/build and startup as one operation.
|
||||
PreparingApp,
|
||||
/// Creating data directories, writing app-specific configs
|
||||
/// (bitcoin.conf, lnd.conf, searxng settings.yml, chown).
|
||||
CreatingContainer,
|
||||
|
||||
@@ -5,8 +5,45 @@
|
||||
//! are reachable over the mesh; ports of apps that aren't installed have
|
||||
//! no listener, so allowing them is inert.
|
||||
|
||||
#[rustfmt::skip]
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8090,
|
||||
8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8337, 8888, 8999, 9000, 9100, 10380, 11434,
|
||||
18081, 18083, 18091, 23000, 32838, 50002,
|
||||
2283,
|
||||
2342,
|
||||
3000,
|
||||
3001,
|
||||
3002,
|
||||
4080,
|
||||
5180,
|
||||
7778,
|
||||
8080,
|
||||
8081,
|
||||
8082,
|
||||
8083,
|
||||
8084,
|
||||
8085,
|
||||
8087,
|
||||
8090,
|
||||
8091,
|
||||
8096,
|
||||
8123,
|
||||
8175,
|
||||
8176,
|
||||
8187,
|
||||
8240,
|
||||
8334,
|
||||
8336,
|
||||
8337,
|
||||
8888,
|
||||
8998,
|
||||
8999,
|
||||
9000,
|
||||
9100,
|
||||
10380,
|
||||
11434,
|
||||
18081,
|
||||
18083,
|
||||
18091,
|
||||
23000,
|
||||
32838,
|
||||
50002,
|
||||
];
|
||||
|
||||
@@ -46,6 +46,25 @@ fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
|
||||
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
|
||||
}
|
||||
|
||||
/// Is this FIPS answer the final one, or should the request go again over
|
||||
/// Tor? A single-delivery request already reached the peer, so any answer
|
||||
/// is final: a Tor replay would carry the same (possibly spent) payload.
|
||||
fn fips_answer_is_final(
|
||||
pref: crate::settings::transport::TransportPref,
|
||||
single_delivery: bool,
|
||||
status: reqwest::StatusCode,
|
||||
) -> bool {
|
||||
pref == crate::settings::transport::TransportPref::Fips
|
||||
|| single_delivery
|
||||
|| !fips_should_fall_back(status)
|
||||
}
|
||||
|
||||
/// May a failed FIPS attempt be sent again? Only a failed connect proves the
|
||||
/// peer never saw it; a timeout can land after the request was delivered.
|
||||
fn fips_retryable(single_delivery: bool, e: &reqwest::Error) -> bool {
|
||||
e.is_connect() || (!single_delivery && e.is_timeout())
|
||||
}
|
||||
|
||||
/// DNS suffix appended to a peer's bech32 npub.
|
||||
pub const FIPS_DNS_SUFFIX: &str = "fips";
|
||||
|
||||
@@ -113,7 +132,21 @@ pub fn client() -> reqwest::Client {
|
||||
/// before the Tor fallback ever gets a chance. The generous `connect_timeout`
|
||||
/// is preserved so a cold hole-punched path still gets time to establish.
|
||||
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
|
||||
client_with_delivery_policy(timeout, false)
|
||||
}
|
||||
|
||||
fn delivery_redirect_policy(single: bool) -> reqwest::redirect::Policy {
|
||||
if single {
|
||||
reqwest::redirect::Policy::none()
|
||||
} else {
|
||||
reqwest::redirect::Policy::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn client_with_delivery_policy(timeout: Duration, single: bool) -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(delivery_redirect_policy(single))
|
||||
.timeout(timeout)
|
||||
.connect_timeout(Duration::from_secs(8))
|
||||
.user_agent("archipelago-fips/1")
|
||||
@@ -130,10 +163,18 @@ pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
|
||||
/// robust". Only connect/timeout errors are retried (a real HTTP response,
|
||||
/// including 4xx/5xx, is returned as-is for the caller to interpret).
|
||||
async fn send_with_retry(rb: reqwest::RequestBuilder) -> Result<reqwest::Response, reqwest::Error> {
|
||||
send_with_retry_if(rb, |e| e.is_connect() || e.is_timeout()).await
|
||||
}
|
||||
|
||||
/// [`send_with_retry`], retrying only on errors `retryable` accepts.
|
||||
async fn send_with_retry_if(
|
||||
rb: reqwest::RequestBuilder,
|
||||
retryable: impl Fn(&reqwest::Error) -> bool,
|
||||
) -> Result<reqwest::Response, reqwest::Error> {
|
||||
let retry = rb.try_clone();
|
||||
match rb.send().await {
|
||||
Ok(resp) => Ok(resp),
|
||||
Err(e) if (e.is_connect() || e.is_timeout()) && retry.is_some() => {
|
||||
Err(e) if retryable(&e) && retry.is_some() => {
|
||||
// Brief pause so the hole-punch packets from the first attempt can
|
||||
// traverse before we re-dial onto the warmed path.
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
@@ -350,6 +391,9 @@ pub struct PeerRequest<'a> {
|
||||
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
|
||||
/// every caller has a data dir in scope.
|
||||
pub record_data_dir: Option<std::path::PathBuf>,
|
||||
/// The request carries something that must reach the peer at most once
|
||||
/// (a bearer ecash token). See [`PeerRequest::single_delivery`].
|
||||
pub single_delivery: bool,
|
||||
}
|
||||
|
||||
impl<'a> PeerRequest<'a> {
|
||||
@@ -363,9 +407,25 @@ impl<'a> PeerRequest<'a> {
|
||||
fips_timeout: None,
|
||||
service: None,
|
||||
record_data_dir: None,
|
||||
single_delivery: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Never send this request twice. A paid download carries a bearer ecash
|
||||
/// token that the seller redeems on first sight; replaying it over Tor
|
||||
/// after FIPS already delivered it hands the seller a spent token, so the
|
||||
/// buyer is charged and gets a 402 instead of the file (2026-09-29: FIPS
|
||||
/// answered 404 after the seller redeemed, the Tor retry got 402).
|
||||
///
|
||||
/// With this set, whatever FIPS answers is final, the FIPS retry fires
|
||||
/// only when the first attempt never connected, and Tor is used only when
|
||||
/// FIPS could not have delivered the request. An attempt that may have
|
||||
/// been delivered but timed out is an error, not a fallback.
|
||||
pub fn single_delivery(mut self) -> Self {
|
||||
self.single_delivery = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Record the transport that serves this request into federation storage
|
||||
/// (matched by this request's onion host). Best-effort, off the hot path.
|
||||
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
|
||||
@@ -442,7 +502,7 @@ impl<'a> PeerRequest<'a> {
|
||||
// Use the FIPS reply unless it's one a Tor retry could
|
||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||
// fall back. FIPS-only never falls back.
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
|
||||
telemetry::record_fips_ok();
|
||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
@@ -481,7 +541,7 @@ impl<'a> PeerRequest<'a> {
|
||||
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
||||
match self.try_fips_get().await? {
|
||||
Some(resp) => {
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
|
||||
telemetry::record_fips_ok();
|
||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
@@ -551,13 +611,21 @@ impl<'a> PeerRequest<'a> {
|
||||
} else {
|
||||
budget
|
||||
};
|
||||
let c = client_with_timeout(per_attempt);
|
||||
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
|
||||
let mut rb = c.post(&url).json(body);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
||||
let single = self.single_delivery;
|
||||
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
|
||||
match tokio::time::timeout(budget, attempt).await {
|
||||
Ok(Ok(r)) => Ok(Some(r)),
|
||||
Ok(Err(e)) if single && !e.is_connect() => Err(anyhow::anyhow!(
|
||||
"FIPS POST failed after possible delivery; not replaying: {e}"
|
||||
)),
|
||||
Err(_) if single => Err(anyhow::anyhow!(
|
||||
"FIPS POST exceeded its budget after possible delivery; not replaying"
|
||||
)),
|
||||
Ok(Err(e)) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
@@ -612,13 +680,28 @@ impl<'a> PeerRequest<'a> {
|
||||
} else {
|
||||
budget
|
||||
};
|
||||
let c = client_with_timeout(per_attempt);
|
||||
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
|
||||
let mut rb = c.get(&url);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
||||
let single = self.single_delivery;
|
||||
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
|
||||
match tokio::time::timeout(budget, attempt).await {
|
||||
Ok(Ok(r)) => Ok(Some(r)),
|
||||
// Anything but a failed connect may have reached the peer.
|
||||
Ok(Err(e)) if single && !e.is_connect() => Err(anyhow::anyhow!(
|
||||
"FIPS GET {} failed after the request may have been delivered \
|
||||
(not retrying over Tor): {}",
|
||||
self.path,
|
||||
e
|
||||
)),
|
||||
Err(_) if single => Err(anyhow::anyhow!(
|
||||
"FIPS GET {} exceeded its {:?} budget after the request may have \
|
||||
been delivered (not retrying over Tor)",
|
||||
self.path,
|
||||
budget
|
||||
)),
|
||||
Ok(Err(e)) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
@@ -676,6 +759,7 @@ impl<'a> PeerRequest<'a> {
|
||||
.context("Invalid Tor SOCKS proxy URL")?;
|
||||
reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.redirect(delivery_redirect_policy(self.single_delivery))
|
||||
.timeout(self.timeout)
|
||||
.build()
|
||||
.context("Build Tor HTTP client")
|
||||
@@ -759,4 +843,181 @@ mod tests {
|
||||
let err = decode_response(0xAABB, &r, "x").unwrap_err();
|
||||
assert!(err.to_string().contains("no AAAA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_delivery_answer_is_final_whatever_its_status() {
|
||||
use crate::settings::transport::TransportPref;
|
||||
use reqwest::StatusCode;
|
||||
// Regression (2026-09-29): the seller redeemed a paid download's
|
||||
// token, answered 404, and the Tor fallback replayed the spent token.
|
||||
for status in [
|
||||
StatusCode::NOT_FOUND,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
StatusCode::OK,
|
||||
] {
|
||||
assert!(fips_answer_is_final(TransportPref::Auto, true, status));
|
||||
}
|
||||
// Everything else keeps the existing fallback rules.
|
||||
assert!(!fips_answer_is_final(
|
||||
TransportPref::Auto,
|
||||
false,
|
||||
StatusCode::NOT_FOUND
|
||||
));
|
||||
assert!(!fips_answer_is_final(
|
||||
TransportPref::Auto,
|
||||
false,
|
||||
StatusCode::BAD_GATEWAY
|
||||
));
|
||||
assert!(fips_answer_is_final(
|
||||
TransportPref::Auto,
|
||||
false,
|
||||
StatusCode::PAYMENT_REQUIRED
|
||||
));
|
||||
assert!(fips_answer_is_final(
|
||||
TransportPref::Fips,
|
||||
false,
|
||||
StatusCode::NOT_FOUND
|
||||
));
|
||||
}
|
||||
|
||||
/// A listener that accepts connections and never answers, counting them.
|
||||
async fn silent_peer() -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counter = seen.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut held = Vec::new();
|
||||
while let Ok((stream, _)) = listener.accept().await {
|
||||
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
held.push(stream); // keep it open, never reply
|
||||
}
|
||||
});
|
||||
(format!("http://{addr}/content/x"), seen)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_single_delivery_request_is_not_resent_after_a_timeout() {
|
||||
let (url, seen) = silent_peer().await;
|
||||
let c = client_with_timeout(Duration::from_millis(300));
|
||||
let err = send_with_retry_if(c.get(&url), |e| fips_retryable(true, e))
|
||||
.await
|
||||
.expect_err("peer never answers");
|
||||
assert!(err.is_timeout());
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_ordinary_request_is_still_retried_once_after_a_timeout() {
|
||||
let (url, seen) = silent_peer().await;
|
||||
let c = client_with_timeout(Duration::from_millis(300));
|
||||
let _ = send_with_retry_if(c.get(&url), |e| fips_retryable(false, e)).await;
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_single_delivery_request_still_retries_a_refused_connect() {
|
||||
// Nothing listening: the peer provably never saw the request.
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
drop(listener);
|
||||
let c = client_with_timeout(Duration::from_millis(500));
|
||||
let err = send_with_retry_if(c.get(format!("http://{addr}/")), |e| {
|
||||
fips_retryable(true, e)
|
||||
})
|
||||
.await
|
||||
.expect_err("nothing listening");
|
||||
assert!(err.is_connect());
|
||||
assert!(fips_retryable(true, &err));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod delivery_redirect_tests {
|
||||
use super::*;
|
||||
use hyper::{
|
||||
service::{make_service_fn, service_fn},
|
||||
Body, Response, Server,
|
||||
};
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_bearer_request_does_not_follow_redirects_but_normal_get_does() {
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
let counter = seen.clone();
|
||||
let server = Server::bind(&([127, 0, 0, 1], 0).into());
|
||||
let address = server.local_addr();
|
||||
let service = make_service_fn(move |_| {
|
||||
let counter = counter.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
|
||||
let counter = counter.clone();
|
||||
async move {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
let response = if request.uri().path() == "/first" {
|
||||
Response::builder()
|
||||
.status(302)
|
||||
.header("Location", "/replay")
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
} else {
|
||||
Response::new(Body::from("replayed"))
|
||||
};
|
||||
Ok::<_, Infallible>(response)
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
let task = tokio::spawn(server.serve(service));
|
||||
let url = format!("http://{address}/first");
|
||||
let response = client_with_delivery_policy(Duration::from_secs(2), true)
|
||||
.get(&url)
|
||||
.header("X-Payment-Token", "dummy-test-token")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::FOUND);
|
||||
assert_eq!(seen.load(Ordering::SeqCst), 1);
|
||||
let response = client_with_delivery_policy(Duration::from_secs(2), false)
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(seen.load(Ordering::SeqCst), 3);
|
||||
task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_request_is_not_resent_when_peer_disconnects_after_reading_it() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
let counter = seen.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
while let Ok((mut stream, _)) = listener.accept().await {
|
||||
let mut buf = [0; 4096];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
drop(stream);
|
||||
}
|
||||
});
|
||||
let c = client_with_delivery_policy(Duration::from_secs(2), true);
|
||||
let error = send_with_retry_if(c.get(format!("http://{address}/")), |e| {
|
||||
fips_retryable(true, e)
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!error.is_connect());
|
||||
assert_eq!(seen.load(Ordering::SeqCst), 1);
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,12 +501,12 @@ async fn check_containers() -> Vec<ContainerHealth> {
|
||||
out
|
||||
}
|
||||
|
||||
fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<u16> {
|
||||
fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<std::net::SocketAddr> {
|
||||
let Some(ports) = c.get("Ports").and_then(|v| v.as_array()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut out: Vec<u16> = ports
|
||||
let mut out: Vec<std::net::SocketAddr> = ports
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.get("protocol")
|
||||
@@ -515,9 +515,19 @@ fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<u16> {
|
||||
.eq_ignore_ascii_case("tcp")
|
||||
})
|
||||
.filter_map(|p| {
|
||||
p.get("host_port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(|port| u16::try_from(port).ok())
|
||||
let port = p.get("host_port")?.as_u64()?;
|
||||
let port = u16::try_from(port).ok().filter(|port| *port != 0)?;
|
||||
let bind = p.get("host_ip").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// Wildcard listeners are reachable through the corresponding
|
||||
// loopback family. Explicit binds must be probed at that address:
|
||||
// probing a WireGuard-only port on 127.0.0.1 creates false failures
|
||||
// and endlessly restarts an otherwise healthy app.
|
||||
let address: std::net::IpAddr = match bind {
|
||||
"" | "0.0.0.0" => "127.0.0.1".parse().ok()?,
|
||||
"::" => "::1".parse().ok()?,
|
||||
explicit => explicit.parse().ok()?,
|
||||
};
|
||||
Some(std::net::SocketAddr::new(address, port))
|
||||
})
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
@@ -525,11 +535,11 @@ fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<u16> {
|
||||
out
|
||||
}
|
||||
|
||||
async fn host_ports_ready(ports: &[u16]) -> bool {
|
||||
async fn host_ports_ready(ports: &[std::net::SocketAddr]) -> bool {
|
||||
for port in ports {
|
||||
let ready = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", *port)),
|
||||
tokio::net::TcpStream::connect(*port),
|
||||
)
|
||||
.await
|
||||
.is_ok_and(|r| r.is_ok());
|
||||
@@ -1662,4 +1672,51 @@ mod tests {
|
||||
"Prefetcher:catching up to daemon height 953,480"
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn published_port_probes_preserve_explicit_bind_addresses() {
|
||||
let c = serde_json::json!({"Ports": [
|
||||
{"host_ip":"127.0.0.1","host_port":8081,"protocol":"tcp"},
|
||||
{"host_ip":"10.77.0.2","host_port":18081,"protocol":"tcp"},
|
||||
{"host_ip":"10.77.0.2","host_port":18443,"protocol":"tcp"},
|
||||
{"host_ip":"::1","host_port":8082,"protocol":"tcp"}
|
||||
]});
|
||||
let targets = host_tcp_ports_from_container(&c);
|
||||
for target in [
|
||||
"127.0.0.1:8081",
|
||||
"10.77.0.2:18081",
|
||||
"10.77.0.2:18443",
|
||||
"[::1]:8082",
|
||||
] {
|
||||
assert!(targets.contains(&target.parse().unwrap()));
|
||||
}
|
||||
assert!(!targets.contains(&"127.0.0.1:18081".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn published_port_probes_normalize_wildcards_and_ignore_invalid_entries() {
|
||||
let c = serde_json::json!({"Ports": [
|
||||
{"host_ip":"0.0.0.0","host_port":8080},
|
||||
{"host_ip":"","host_port":8080},
|
||||
{"host_ip":"::","host_port":8080},
|
||||
{"host_ip":"10.0.0.1","host_port":53,"protocol":"udp"},
|
||||
{"host_ip":"bad","host_port":8080},
|
||||
{"host_port":0}, {"host_port":65536}, {"container_port":80}
|
||||
]});
|
||||
let targets = host_tcp_ports_from_container(&c);
|
||||
assert_eq!(targets.len(), 2);
|
||||
assert!(targets.contains(&"127.0.0.1:8080".parse().unwrap()));
|
||||
assert!(targets.contains(&"[::1]:8080".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_probe_reaches_non_default_loopback_and_detects_closed_port() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
assert!(host_ports_ready(&[address]).await);
|
||||
// Same port, wrong local address reproduces the former false failure.
|
||||
let wrong = std::net::SocketAddr::new("127.0.0.1".parse().unwrap(), address.port());
|
||||
assert!(!host_ports_ready(&[wrong]).await);
|
||||
drop(listener);
|
||||
assert!(!host_ports_ready(&[address]).await);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +256,10 @@ async fn main() -> Result<()> {
|
||||
boot_report.recovered, boot_report.total, boot_report.failed
|
||||
);
|
||||
}
|
||||
// Disk manifests must be stable before the initial load and all later
|
||||
// catalog reloads. Do not move this into the background doctor bootstrap.
|
||||
bootstrap::ensure_runtime_assets_ready().await;
|
||||
|
||||
// Construct the container orchestrator once. In prod mode we load the
|
||||
// on-disk app manifests, do an initial adoption pass, and spawn the
|
||||
// BootReconciler loop (Step 5/6 of the rust-orchestrator migration).
|
||||
|
||||
@@ -1765,12 +1765,17 @@ fn merge_preserving_transitional(
|
||||
};
|
||||
|
||||
crate::data_model::PackageDataEntry {
|
||||
state,
|
||||
state: state.clone(),
|
||||
// install_progress and uninstall_stage are also owned by the
|
||||
// initiating op (same reason as state) — keep them.
|
||||
install_progress: existing.install_progress.clone(),
|
||||
uninstall_stage: existing.uninstall_stage.clone(),
|
||||
// Everything else comes from the fresh scan.
|
||||
ui_ready: if state == crate::data_model::PackageState::Running {
|
||||
fresh.ui_ready
|
||||
} else {
|
||||
Some(false)
|
||||
},
|
||||
health: fresh.health.clone(),
|
||||
exit_code: fresh.exit_code,
|
||||
static_files: fresh.static_files.clone(),
|
||||
@@ -1809,7 +1814,10 @@ async fn scan_and_update_packages(
|
||||
absence_tracker: &mut HashMap<String, u32>,
|
||||
transitional_since: &mut HashMap<String, Instant>,
|
||||
) -> Result<()> {
|
||||
let mut packages = scanner.scan_containers().await?;
|
||||
let (before_scan, _) = state.get_snapshot().await;
|
||||
let mut packages = scanner
|
||||
.scan_containers(data_dir, &before_scan.package_data)
|
||||
.await?;
|
||||
let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await;
|
||||
for (id, pkg) in packages.iter_mut() {
|
||||
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
|
||||
@@ -1870,11 +1878,14 @@ async fn scan_and_update_packages(
|
||||
// once at load ~2). Better to keep saying "scanning…" than to say "empty".
|
||||
if packages.is_empty() && (!first_scan || !installed_registry.is_empty()) {
|
||||
if tor_changed || update_changed {
|
||||
let mut data = current_data;
|
||||
data.server_info.tor_address = tor_addr.clone();
|
||||
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||
data.server_info.status_info.updated = update_available;
|
||||
state.update_data(data).await;
|
||||
state
|
||||
.mutate_data(|data| {
|
||||
data.server_info.tor_address = tor_addr.clone();
|
||||
data.server_info.node_address =
|
||||
tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||
data.server_info.status_info.updated = update_available;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1899,6 +1910,13 @@ async fn scan_and_update_packages(
|
||||
// died without cleanup and let the scan override it.
|
||||
let now = Instant::now();
|
||||
for (id, pkg) in &packages {
|
||||
if user_uninstalled.contains(id)
|
||||
|| user_uninstalled.contains(&format!("archy-{id}"))
|
||||
|| (before_scan.package_data.contains_key(id)
|
||||
&& !current_data.package_data.contains_key(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
absence_tracker.remove(id);
|
||||
let existing = merged.get(id);
|
||||
let overwrite = match existing {
|
||||
@@ -2054,22 +2072,40 @@ async fn scan_and_update_packages(
|
||||
}
|
||||
|
||||
if changed || tor_changed || first_scan || update_changed {
|
||||
let mut data = current_data;
|
||||
data.package_data = merged;
|
||||
data.server_info.tor_address = tor_addr.clone();
|
||||
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||
data.server_info.status_info.containers_scanned = true;
|
||||
data.server_info.status_info.updated = update_available;
|
||||
state.update_data(data).await;
|
||||
debug!(
|
||||
"📦 State changed (packages={}, tor={}, first_scan={}, update={}), broadcasting update",
|
||||
changed, tor_changed, first_scan, update_changed
|
||||
);
|
||||
state
|
||||
.mutate_data(|data| {
|
||||
// A lifecycle operation may have started/finished while this scan
|
||||
// awaited probes or disk I/O. Never overwrite that newer entry or
|
||||
// resurrect one that an uninstall removed in the meantime.
|
||||
apply_scanned_packages(&mut data.package_data, ¤t_data.package_data, &merged);
|
||||
data.server_info.tor_address = tor_addr.clone();
|
||||
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||
data.server_info.status_info.containers_scanned = true;
|
||||
data.server_info.status_info.updated = update_available;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_scanned_packages(
|
||||
latest: &mut HashMap<String, crate::data_model::PackageDataEntry>,
|
||||
base: &HashMap<String, crate::data_model::PackageDataEntry>,
|
||||
scanned: &HashMap<String, crate::data_model::PackageDataEntry>,
|
||||
) {
|
||||
for (id, fresh) in scanned {
|
||||
if latest.get(id) == base.get(id) {
|
||||
latest.insert(id.clone(), fresh.clone());
|
||||
}
|
||||
}
|
||||
for id in base.keys() {
|
||||
if !scanned.contains_key(id) && latest.get(id) == base.get(id) {
|
||||
latest.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn normalize_reachable_package_health(
|
||||
packages: &mut HashMap<String, crate::data_model::PackageDataEntry>,
|
||||
) {
|
||||
@@ -2268,6 +2304,7 @@ mod merge_tests {
|
||||
|
||||
fn make_entry(state: PackageState, health: Option<&str>) -> PackageDataEntry {
|
||||
PackageDataEntry {
|
||||
ui_ready: None,
|
||||
state,
|
||||
health: health.map(|s| s.to_string()),
|
||||
exit_code: None,
|
||||
@@ -2280,6 +2317,37 @@ mod merge_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_scan_cannot_remove_new_installs_or_overwrite_lifecycle_changes() {
|
||||
let running = make_entry(PackageState::Running, Some("healthy"));
|
||||
let restarting = make_entry(PackageState::Restarting, None);
|
||||
let base = [
|
||||
("restart".into(), running.clone()),
|
||||
("uninstalled".into(), running.clone()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut latest = [
|
||||
("restart".into(), restarting.clone()),
|
||||
("new".into(), running.clone()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let scanned = [
|
||||
("restart".into(), running.clone()),
|
||||
("uninstalled".into(), running.clone()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
apply_scanned_packages(&mut latest, &base, &scanned);
|
||||
assert_eq!(latest.get("restart"), Some(&restarting));
|
||||
assert_eq!(latest.get("new"), Some(&running));
|
||||
assert!(!latest.contains_key("uninstalled"));
|
||||
apply_scanned_packages(&mut latest, &base, &HashMap::new());
|
||||
assert_eq!(latest.get("restart"), Some(&restarting));
|
||||
assert!(latest.contains_key("new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_path_filter_allows_content_catalog_and_items() {
|
||||
// Regression: the content *catalog* is exactly "/content" (no trailing
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Install-time pruning preference, shared by Bitcoin Core and Knots.
|
||||
//! Missing preference preserves the existing disk-based automatic selection.
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct BitcoinStorage {
|
||||
pub prune: bool,
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> Result<BitcoinStorage> {
|
||||
match tokio::fs::read(data_dir.join("settings/bitcoin-storage.json")).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).context("Invalid Bitcoin storage settings"),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BitcoinStorage::default()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save(data_dir: &Path, prune: bool) -> Result<()> {
|
||||
let dir = data_dir.join("settings");
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let path = dir.join("bitcoin-storage.json");
|
||||
let temporary = dir.join("bitcoin-storage.json.tmp");
|
||||
tokio::fs::write(&temporary, serde_json::to_vec(&BitcoinStorage { prune })?).await?;
|
||||
tokio::fs::rename(temporary, path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn missing_setting_keeps_auto_and_explicit_pruning_survives_reload() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(!load(dir.path()).await.unwrap().prune);
|
||||
save(dir.path(), true).await.unwrap();
|
||||
assert!(load(dir.path()).await.unwrap().prune);
|
||||
save(dir.path(), false).await.unwrap();
|
||||
assert!(!load(dir.path()).await.unwrap().prune);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn corrupt_setting_is_not_silently_changed_to_archival() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save(dir.path(), true).await.unwrap();
|
||||
tokio::fs::write(dir.path().join("settings/bitcoin-storage.json"), "broken")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(load(dir.path()).await.is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,5 @@
|
||||
pub mod ai_permissions;
|
||||
pub mod session_policy;
|
||||
pub mod transport;
|
||||
|
||||
pub mod bitcoin_storage;
|
||||
|
||||
@@ -54,6 +54,21 @@ impl StateManager {
|
||||
let _ = self.broadcast_tx.send(message);
|
||||
}
|
||||
|
||||
/// Apply a small state change while holding the write lock. A lifecycle
|
||||
/// task must not replace the entire model from an earlier snapshot.
|
||||
pub async fn mutate_data<T>(&self, change: impl FnOnce(&mut DataModel) -> T) -> T {
|
||||
let mut data = self.data.write().await;
|
||||
let result = change(&mut data);
|
||||
let mut rev = self.revision.write().await;
|
||||
*rev += 1;
|
||||
let _ = self.broadcast_tx.send(WebSocketMessage {
|
||||
rev: *rev,
|
||||
data: Some(data.clone()),
|
||||
patch: None,
|
||||
});
|
||||
result
|
||||
}
|
||||
|
||||
/// Get a WebSocket message with the current state
|
||||
pub async fn get_initial_message(&self) -> WebSocketMessage {
|
||||
let (data, rev) = self.get_snapshot().await;
|
||||
@@ -190,3 +205,29 @@ mod tests {
|
||||
assert_eq!(rev, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod atomic_mutation_tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn concurrent_updates_preserve_independent_entries() {
|
||||
let state = Arc::new(StateManager::new());
|
||||
let mut tasks = Vec::new();
|
||||
for i in 0..24 {
|
||||
let state = state.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
state
|
||||
.mutate_data(|data| {
|
||||
data.peer_health.insert(format!("peer-{i}"), true);
|
||||
})
|
||||
.await;
|
||||
}));
|
||||
}
|
||||
for task in tasks {
|
||||
task.await.unwrap();
|
||||
}
|
||||
let (data, revision) = state.get_snapshot().await;
|
||||
assert_eq!(data.peer_health.len(), 24);
|
||||
assert_eq!(revision, 24);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1481,6 +1481,21 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
|
||||
/// service unit that inherits systemd's default protections (i.e. none
|
||||
/// of ours), escaping the namespace.
|
||||
pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::ensure!(
|
||||
std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"),
|
||||
"Host-operation tests require scripts/test-backend-isolated.sh"
|
||||
);
|
||||
let (program, args) = args.split_first().context("Missing test command")?;
|
||||
// Run inside the test namespace, never escape through sudo/systemd-run.
|
||||
return tokio::process::Command::new(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.await
|
||||
.context("isolated test command failed");
|
||||
}
|
||||
|
||||
let mut full: Vec<&str> = vec![
|
||||
"systemd-run",
|
||||
"--wait",
|
||||
@@ -1505,6 +1520,21 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
|
||||
/// Same mechanism as `host_sudo` but captures stdout — for read-only probes
|
||||
/// (e.g. `stat`) where the answer is in the output, not the exit status.
|
||||
pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Output> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::ensure!(
|
||||
std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"),
|
||||
"Host-operation tests require scripts/test-backend-isolated.sh"
|
||||
);
|
||||
let (program, args) = args.split_first().context("Missing test command")?;
|
||||
// Run inside the test namespace, never escape through sudo/systemd-run.
|
||||
return tokio::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.context("isolated test command failed");
|
||||
}
|
||||
|
||||
let mut full: Vec<&str> = vec![
|
||||
"systemd-run",
|
||||
"--wait",
|
||||
@@ -2159,20 +2189,25 @@ async fn apply_per_app_auto_updates(
|
||||
}
|
||||
}
|
||||
|
||||
/// After a catalog refresh that changed the cached bytes, rebuild the
|
||||
/// orchestrator's manifest map so registry-shipped manifest changes take
|
||||
/// effect now instead of at the next service restart.
|
||||
async fn reload_manifests_if_changed(
|
||||
refresh: crate::container::app_catalog::CatalogRefresh,
|
||||
/// Reload after every successful refresh, including unchanged bytes: the cache
|
||||
/// may have been written before a previous reload failed. Auto-updates only run
|
||||
/// when the catalog and the orchestrator's manifests are ready together.
|
||||
async fn reload_catalog_manifests(
|
||||
_refresh: crate::container::app_catalog::CatalogRefresh,
|
||||
orchestrator: &Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
|
||||
) {
|
||||
if !refresh.changed {
|
||||
return;
|
||||
}
|
||||
let Some(orch) = orchestrator else { return };
|
||||
) -> bool {
|
||||
let Some(orch) = orchestrator else {
|
||||
return false;
|
||||
};
|
||||
match orch.reload_manifests().await {
|
||||
Ok(n) => info!("Update scheduler: catalog changed, reloaded {n} manifest(s)"),
|
||||
Err(e) => warn!("Update scheduler: manifest reload after catalog change failed: {e}"),
|
||||
Ok(n) => {
|
||||
info!("Update scheduler: refreshed catalog, reloaded {n} manifest(s)");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Update scheduler: manifest reload failed; skipping auto-updates: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2188,7 +2223,9 @@ pub async fn run_update_scheduler(
|
||||
// Refresh the app catalog once at startup so per-app "update available"
|
||||
// badges appear without waiting for the first hourly tick.
|
||||
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
|
||||
Ok(refresh) => {
|
||||
reload_catalog_manifests(refresh, &orchestrator).await;
|
||||
}
|
||||
Err(e) => debug!(
|
||||
"Update scheduler: initial app-catalog refresh failed: {}",
|
||||
e
|
||||
@@ -2204,14 +2241,22 @@ pub async fn run_update_scheduler(
|
||||
// previously cached catalog stays in place (origin-always-wins).
|
||||
// A changed catalog also reloads the orchestrator's manifest overlay so
|
||||
// catalog-shipped manifest fixes apply without a service restart.
|
||||
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
|
||||
Err(e) => debug!("Update scheduler: app-catalog refresh failed: {}", e),
|
||||
}
|
||||
let catalog_ready = match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
Ok(refresh) => reload_catalog_manifests(refresh, &orchestrator).await,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"Update scheduler: app-catalog refresh failed; skipping auto-updates: {}",
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// Per-app auto-update-to-latest (multi-version support). Runs every tick
|
||||
// regardless of the binary-OTA schedule below; opt-in + pin-respecting.
|
||||
apply_per_app_auto_updates(&orchestrator).await;
|
||||
// Per-app updates require fresh, loaded manifests; a failed refresh
|
||||
// may still show cached badges but must not trigger container changes.
|
||||
if catalog_ready {
|
||||
apply_per_app_auto_updates(&orchestrator).await;
|
||||
}
|
||||
|
||||
let state = match load_state(&data_dir).await {
|
||||
Ok(s) => s,
|
||||
|
||||
@@ -775,7 +775,9 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
||||
let mut all_target: Vec<u64> = send_denoms.clone();
|
||||
all_target.extend(&change_denoms);
|
||||
|
||||
let swap_result = client.swap(&selected_proofs, &all_target).await?;
|
||||
let swap_result = client
|
||||
.swap_at_least(&selected_proofs, &all_target, amount_sats)
|
||||
.await?;
|
||||
|
||||
// Mark original proofs as spent
|
||||
wallet.mark_spent(&indices);
|
||||
@@ -1192,7 +1194,11 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
// Verify all mints in the token are accepted
|
||||
let accepted = load_accepted_mints(data_dir).await?;
|
||||
for mint_url in token.mint_urls() {
|
||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
||||
if !accepted
|
||||
.mints
|
||||
.iter()
|
||||
.any(|m| m.trim_end_matches('/') == mint_url.trim_end_matches('/'))
|
||||
{
|
||||
anyhow::bail!("Mint '{}' is not in accepted mints list", mint_url);
|
||||
}
|
||||
}
|
||||
@@ -1205,6 +1211,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
// for the log. Remember the last one so a total failure can tell the user
|
||||
// *why* instead of just "nothing was received".
|
||||
let mut last_reason: Option<String> = None;
|
||||
let mut all_already_redeemed = true;
|
||||
|
||||
// Swap proofs at each mint
|
||||
for entry in &token.token {
|
||||
@@ -1216,7 +1223,8 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
received_total += amount;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e);
|
||||
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
|
||||
all_already_redeemed &= e.is::<super::mint_client::AlreadyRedeemed>();
|
||||
last_reason = Some(e.to_string());
|
||||
// Continue with other mints if any
|
||||
}
|
||||
@@ -1224,10 +1232,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
}
|
||||
|
||||
if received_total == 0 {
|
||||
match last_reason {
|
||||
Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason),
|
||||
None => anyhow::bail!("Failed to receive any proofs from token"),
|
||||
}
|
||||
return Err(receive_failure(last_reason, all_already_redeemed));
|
||||
}
|
||||
|
||||
wallet.record_tx(
|
||||
@@ -1243,6 +1248,17 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
Ok(received_total)
|
||||
}
|
||||
|
||||
fn receive_failure(last_reason: Option<String>, all_already_redeemed: bool) -> anyhow::Error {
|
||||
match last_reason {
|
||||
Some(reason) if all_already_redeemed => {
|
||||
anyhow::Error::new(super::mint_client::AlreadyRedeemed)
|
||||
.context(format!("Could not receive this ecash: {reason}"))
|
||||
}
|
||||
Some(reason) => anyhow::anyhow!("Could not receive this ecash: {reason}"),
|
||||
None => anyhow::anyhow!("Failed to receive any proofs from token"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive a legacy format token (cashuSend_{amount}_{uuid}_{timestamp}).
|
||||
/// For backwards compatibility during migration period.
|
||||
async fn receive_legacy_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
@@ -1288,22 +1304,10 @@ pub async fn verify_and_receive_payment(
|
||||
token_str: &str,
|
||||
required_sats: u64,
|
||||
) -> Result<u64> {
|
||||
// Handle legacy tokens
|
||||
let token_str = token_str.trim();
|
||||
// Synthetic legacy balances are not cryptographic proof of payment.
|
||||
if token_str.starts_with("cashuSend_") {
|
||||
let amount = token_str
|
||||
.split('_')
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
if amount < required_sats {
|
||||
anyhow::bail!(
|
||||
"Insufficient payment: {} sats, need {} sats",
|
||||
amount,
|
||||
required_sats
|
||||
);
|
||||
}
|
||||
let received = receive_legacy_token(data_dir, token_str).await?;
|
||||
return Ok(received);
|
||||
anyhow::bail!("Legacy ecash cannot authorize a paid download");
|
||||
}
|
||||
|
||||
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
|
||||
@@ -1326,52 +1330,45 @@ pub async fn verify_and_receive_payment(
|
||||
|
||||
// Parse and validate the token (cashuA or cashuB)
|
||||
let token = CashuToken::deserialize(token_str)?;
|
||||
let total = token.total_amount();
|
||||
|
||||
if token.unit.as_deref().unwrap_or("sat") != "sat" {
|
||||
anyhow::bail!("Payment must be denominated in sats");
|
||||
}
|
||||
// A sale must redeem atomically at one mint. Otherwise a later mint
|
||||
// failure can consume earlier inputs without delivering the purchase.
|
||||
let entry = match token.token.as_slice() {
|
||||
[entry] => entry,
|
||||
_ => anyhow::bail!("Use a single-mint token for this payment"),
|
||||
};
|
||||
let total = entry
|
||||
.proofs
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
|
||||
.ok_or_else(|| anyhow::anyhow!("Payment amount overflow"))?;
|
||||
if total < required_sats {
|
||||
anyhow::bail!(
|
||||
"Insufficient payment: {} sats, need {} sats",
|
||||
total,
|
||||
required_sats
|
||||
);
|
||||
anyhow::bail!("Insufficient payment: {total} sats, need {required_sats} sats");
|
||||
}
|
||||
|
||||
// Verify mints are accepted
|
||||
let accepted = load_accepted_mints(data_dir).await?;
|
||||
for mint_url in token.mint_urls() {
|
||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
||||
anyhow::bail!("Mint '{}' not accepted", mint_url);
|
||||
}
|
||||
if !accepted
|
||||
.mints
|
||||
.iter()
|
||||
.any(|m| m.trim_end_matches('/') == entry.mint.trim_end_matches('/'))
|
||||
{
|
||||
anyhow::bail!("Mint is not in the seller's accepted mints list");
|
||||
}
|
||||
|
||||
// Swap proofs at mint (this verifies they're unspent and gives us fresh proofs)
|
||||
let client = mint_client(data_dir, &entry.mint).await?;
|
||||
let result = client
|
||||
.swap_at_least(
|
||||
&entry.proofs,
|
||||
&amount_to_denominations(total),
|
||||
required_sats,
|
||||
)
|
||||
.await?;
|
||||
let received_total = result.new_proofs.iter().map(|p| p.amount).sum();
|
||||
// Load after the network call, so an unrelated wallet update during the
|
||||
// swap is not overwritten with a pre-swap snapshot.
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mut received_total = 0u64;
|
||||
|
||||
for entry in &token.token {
|
||||
let client = mint_client(data_dir, &entry.mint).await?;
|
||||
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(entry_total);
|
||||
|
||||
match client.swap(&entry.proofs, &target_amounts).await {
|
||||
Ok(result) => {
|
||||
let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum();
|
||||
wallet.add_proofs(&entry.mint, result.new_proofs);
|
||||
received_total += amount;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Payment verification failed at mint {}: {}", entry.mint, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if received_total < required_sats {
|
||||
anyhow::bail!(
|
||||
"Payment verification failed: only {} of {} sats verified",
|
||||
received_total,
|
||||
required_sats
|
||||
);
|
||||
}
|
||||
wallet.add_proofs(entry.mint.trim_end_matches('/'), result.new_proofs);
|
||||
|
||||
wallet.record_tx(
|
||||
TransactionType::Receive,
|
||||
@@ -1632,6 +1629,18 @@ fn default_mint_url() -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn mixed_mint_failures_do_not_discard_a_retryable_claim() {
|
||||
let reason = super::super::mint_client::ALREADY_REDEEMED_MSG.to_string();
|
||||
assert!(super::receive_failure(Some(reason.clone()), true)
|
||||
.is::<super::super::mint_client::AlreadyRedeemed>());
|
||||
assert!(!super::receive_failure(Some(reason), false)
|
||||
.is::<super::super::mint_client::AlreadyRedeemed>());
|
||||
assert!(
|
||||
!super::receive_failure(None, true).is::<super::super::mint_client::AlreadyRedeemed>()
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -2443,3 +2452,7 @@ mod tests {
|
||||
assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "payment_tests.rs"]
|
||||
mod payment_tests;
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
//! key, a crash mid-loop) must not silently lose the coins, so every fetched
|
||||
//! token is persisted to `MinibitsState::pending_claims` *before* decrypt/
|
||||
//! redeem is attempted, and stays there — retried on every later poll — until
|
||||
//! it succeeds. `ClaimOutcome::failed_count` reports how many are still
|
||||
//! it succeeds or every mint reports that it was already spent.
|
||||
//! `ClaimOutcome::failed_count` reports how many are still
|
||||
//! stuck so the caller can surface it instead of it being a log-only event.
|
||||
//! Separately, `ensure_mint_accepted` keeps the Minibits mint on the node's
|
||||
//! accepted-mints allow-list: the address is inherently backed by that one
|
||||
@@ -167,6 +168,9 @@ pub struct MinibitsState {
|
||||
/// already-spent token) but wasteful and noisy.
|
||||
#[serde(default)]
|
||||
pub last_dm_seen_at: u64,
|
||||
/// Resume a bounded backward scan before advancing to newer relay events.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub relay_scan: Option<RelayScan>,
|
||||
/// Event ids already queued from the relay. `created_at` has only
|
||||
/// one-second resolution, so a strict `since = last + 1` watermark can
|
||||
/// permanently miss a second payment published later in the same second.
|
||||
@@ -627,6 +631,7 @@ async fn register_new_state(
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
pending_claims: Vec::new(),
|
||||
last_dm_seen_at: 0,
|
||||
relay_scan: None,
|
||||
seen_dm_ids: Vec::new(),
|
||||
last_receipt_id: 0,
|
||||
last_receipt_sats: 0,
|
||||
@@ -652,6 +657,16 @@ pub struct ClaimOutcome {
|
||||
pub receipt_at: u64,
|
||||
}
|
||||
|
||||
/// True when `ecash::receive_token` failed because the token was already
|
||||
/// redeemed (mint error 11001, see `mint_client::describe_mint_error_code`) —
|
||||
/// a terminal condition, not a reason to retry. Seen on a deployed node,
|
||||
/// 2026-09-15: a claim that had already been swept kept failing this way on
|
||||
/// every poll forever, since nothing distinguished it from a transient
|
||||
/// failure worth retrying.
|
||||
fn is_already_redeemed(err: &anyhow::Error) -> bool {
|
||||
err.is::<super::mint_client::AlreadyRedeemed>()
|
||||
}
|
||||
|
||||
const NO_CLAIMS: ClaimOutcome = ClaimOutcome {
|
||||
claimed_count: 0,
|
||||
received_sats: 0,
|
||||
@@ -697,38 +712,58 @@ fn outcome_with_latest_receipt(
|
||||
/// three real payments that `/claim` never surfaced. Best-effort: a relay
|
||||
/// error here must not abort the poll, since `pending_claims` may still hold
|
||||
/// earlier fetches worth retrying.
|
||||
///
|
||||
/// Queries `RELAY_URL` (the service's own relay) alone first — the happy
|
||||
/// path for a poll is one WebSocket connection, not three, and the wallet's
|
||||
/// derived Nostr pubkey isn't broadcast to the public fallback relays unless
|
||||
/// it's actually needed. Only when that relay is unreachable does it fall
|
||||
/// back to all of `CLAIM_RELAY_URLS`. Results are paged (capped at
|
||||
/// `CLAIM_MAX_PAGES`) since a relay returns only the newest `limit` events for
|
||||
/// a filter. A durable backward cursor keeps older pages reachable even after
|
||||
/// newly queued claims advance the normal forward watermark.
|
||||
async fn fetch_relay_dms(
|
||||
our_pubkey: nostr_sdk::PublicKey,
|
||||
server_pubkey: nostr_sdk::PublicKey,
|
||||
since: u64,
|
||||
) -> Vec<(String, u64, String, String)> {
|
||||
resume: Option<RelayScan>,
|
||||
) -> RelayBatch {
|
||||
let client = Client::default();
|
||||
for url in CLAIM_RELAY_URLS {
|
||||
if let Err(e) = client.add_relay(*url).await {
|
||||
warn!("Minibits: could not add relay {url}: {e}");
|
||||
if let Err(e) = client.add_relay(RELAY_URL).await {
|
||||
warn!("Minibits: could not add relay {RELAY_URL}: {e}");
|
||||
}
|
||||
let primary_reachable = client
|
||||
.try_connect_relay(RELAY_URL, std::time::Duration::from_secs(3))
|
||||
.await
|
||||
.is_ok();
|
||||
if !primary_reachable {
|
||||
warn!("Minibits: primary relay {RELAY_URL} unreachable, falling back to public relays too");
|
||||
for url in &CLAIM_RELAY_URLS[1..] {
|
||||
if let Err(e) = client.add_relay(*url).await {
|
||||
warn!("Minibits: could not add relay {url}: {e}");
|
||||
}
|
||||
}
|
||||
client.connect().await;
|
||||
}
|
||||
client.connect().await;
|
||||
// Give relays a moment to finish the WebSocket handshake before the
|
||||
// fetch's own timeout starts consuming that time.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
|
||||
// Nostr timestamps have one-second resolution. Query the boundary second
|
||||
// inclusively: a later-published payment may legitimately share that
|
||||
// timestamp. `seen_dm_ids` performs the exact deduplication locally.
|
||||
let filter = Filter::new()
|
||||
.author(server_pubkey)
|
||||
.pubkey(our_pubkey)
|
||||
.kind(Kind::from(4u16))
|
||||
.since(Timestamp::from(since))
|
||||
.limit(200);
|
||||
|
||||
let result = match client
|
||||
.fetch_events(filter, std::time::Duration::from_secs(5))
|
||||
.await
|
||||
{
|
||||
Ok(events) => {
|
||||
let mut out: Vec<(String, u64, String, String)> = events
|
||||
let batch = collect_relay_pages(since, resume, |scan| {
|
||||
let client = &client;
|
||||
async move {
|
||||
let mut filter = Filter::new()
|
||||
.author(server_pubkey)
|
||||
.pubkey(our_pubkey)
|
||||
.kind(Kind::from(4u16))
|
||||
.since(Timestamp::from(scan.since))
|
||||
.limit(scan.limit);
|
||||
if let Some(until) = scan.until {
|
||||
filter = filter.until(Timestamp::from(until));
|
||||
}
|
||||
let events = client
|
||||
.fetch_events(filter, std::time::Duration::from_secs(5))
|
||||
.await?;
|
||||
Ok(events
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
(
|
||||
@@ -738,18 +773,83 @@ async fn fetch_relay_dms(
|
||||
e.id.to_hex(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|(_, created_at, _, _)| *created_at);
|
||||
out
|
||||
.collect())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Minibits: relay fetch for claim DMs failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
})
|
||||
.await;
|
||||
client.shutdown().await;
|
||||
result
|
||||
batch
|
||||
}
|
||||
|
||||
const CLAIM_PAGE_LIMIT: usize = 200;
|
||||
const CLAIM_MAX_PAGES: usize = 5;
|
||||
type RelayDm = (String, u64, String, String);
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct RelayScan {
|
||||
since: u64,
|
||||
until: Option<u64>,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
struct RelayBatch {
|
||||
dms: Vec<RelayDm>,
|
||||
resume: Option<RelayScan>,
|
||||
}
|
||||
|
||||
/// NIP-01 returns newest events first. Walk backward with an inclusive `until`
|
||||
/// boundary, deduplicating event ids. A full boundary second needs a larger
|
||||
/// limit, not `until - 1`, which would skip payments sharing that timestamp.
|
||||
/// Persist the cursor at the page cap or on failure so older claims cannot be
|
||||
/// hidden by the newest timestamp already queued in `last_dm_seen_at`.
|
||||
async fn collect_relay_pages<F, Fut>(
|
||||
since: u64,
|
||||
resume: Option<RelayScan>,
|
||||
mut fetch: F,
|
||||
) -> RelayBatch
|
||||
where
|
||||
F: FnMut(RelayScan) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<Vec<RelayDm>>>,
|
||||
{
|
||||
let mut scan = resume.unwrap_or(RelayScan {
|
||||
since,
|
||||
until: None,
|
||||
limit: CLAIM_PAGE_LIMIT,
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
let mut resume = Some(scan);
|
||||
for _ in 0..CLAIM_MAX_PAGES {
|
||||
let events = match fetch(scan).await {
|
||||
Ok(events) => events,
|
||||
Err(e) => {
|
||||
warn!("Minibits: relay fetch failed; preserving scan cursor: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
let count = events.len();
|
||||
let oldest = events.iter().map(|e| e.1).min();
|
||||
for event in events {
|
||||
if ids.insert(event.3.clone()) {
|
||||
out.push(event);
|
||||
}
|
||||
}
|
||||
if count < scan.limit {
|
||||
resume = None;
|
||||
break;
|
||||
}
|
||||
if let Some(oldest) = oldest {
|
||||
if scan.until == Some(oldest) {
|
||||
scan.limit = scan.limit.saturating_add(CLAIM_PAGE_LIMIT);
|
||||
} else {
|
||||
scan.until = Some(oldest);
|
||||
scan.limit = CLAIM_PAGE_LIMIT;
|
||||
}
|
||||
}
|
||||
resume = Some(scan);
|
||||
}
|
||||
out.sort_by(|a, b| (a.1, &a.3).cmp(&(b.1, &b.3)));
|
||||
RelayBatch { dms: out, resume }
|
||||
}
|
||||
|
||||
fn queue_relay_dm(
|
||||
@@ -907,8 +1007,15 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
|
||||
// NIP-04 DM on relays, not via `/claim` above. `since` is our own
|
||||
// watermark (Nostr events never expire off a relay, so without it we'd
|
||||
// re-fetch and re-attempt every claim ever sent on every poll).
|
||||
let dms = fetch_relay_dms(identity.keys.public_key(), server_pk, state.last_dm_seen_at).await;
|
||||
for (content, created_at, author, event_id) in dms {
|
||||
let batch = fetch_relay_dms(
|
||||
identity.keys.public_key(),
|
||||
server_pk,
|
||||
state.last_dm_seen_at,
|
||||
state.relay_scan,
|
||||
)
|
||||
.await;
|
||||
state.relay_scan = batch.resume;
|
||||
for (content, created_at, author, event_id) in batch.dms {
|
||||
if author != state.server_nostr_pubkey {
|
||||
warn!("Minibits: ignoring claim DM from unexpected pubkey {author}");
|
||||
continue;
|
||||
@@ -964,6 +1071,14 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
|
||||
sats += got;
|
||||
info!("Minibits: redeemed a claimed payment ({got} sats)");
|
||||
}
|
||||
Err(e) if is_already_redeemed(&e) => {
|
||||
// Terminal: the value was already swept (a relay-watermark
|
||||
// replay, or a claim redeemed by an earlier run before a
|
||||
// crash lost track of it). Retrying can never succeed, so
|
||||
// drop it instead of leaving `failed_count` stuck non-zero
|
||||
// forever — see archy-x250-pa3, 2026-09-15.
|
||||
info!("Minibits mint reports this claim was already redeemed; removing it from the retry queue");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll");
|
||||
still_pending.push(claim.clone());
|
||||
@@ -994,6 +1109,123 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn simulated_relay_page(events: &[RelayDm], scan: RelayScan) -> Vec<RelayDm> {
|
||||
let mut page: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.1 >= scan.since && scan.until.is_none_or(|until| e.1 <= until))
|
||||
.cloned()
|
||||
.collect();
|
||||
page.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.3.cmp(&b.3)));
|
||||
page.truncate(scan.limit);
|
||||
page
|
||||
}
|
||||
|
||||
fn relay_fixture(count: usize, same_second: bool) -> Vec<RelayDm> {
|
||||
(1..=count)
|
||||
.map(|n| {
|
||||
(
|
||||
format!("claim-{n}"),
|
||||
if same_second { 100 } else { n as u64 },
|
||||
"service".into(),
|
||||
format!("id-{n:06}"),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_paging_fetches_older_claims_in_newest_first_backlog() {
|
||||
let events = relay_fixture(450, false);
|
||||
let batch = collect_relay_pages(0, None, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(batch.dms.len(), 450);
|
||||
assert!(batch.resume.is_none());
|
||||
assert_eq!(batch.dms.first().unwrap().1, 1);
|
||||
assert_eq!(batch.dms.last().unwrap().1, 450);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_paging_preserves_payments_at_the_same_timestamp() {
|
||||
let events = relay_fixture(250, true);
|
||||
let batch = collect_relay_pages(100, None, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(batch.dms.len(), 250);
|
||||
assert!(batch.resume.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_page_cap_resumes_older_claims_after_watermark_advances() {
|
||||
let events = relay_fixture(1300, false);
|
||||
let mut state = MinibitsState::default();
|
||||
let first = collect_relay_pages(0, None, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
assert!(first.resume.is_some());
|
||||
state.relay_scan = first.resume;
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for (content, time, author, id) in first.dms {
|
||||
ids.insert(id.clone());
|
||||
queue_relay_dm(&mut state, content, time, id, author);
|
||||
}
|
||||
assert_eq!(state.last_dm_seen_at, 1300);
|
||||
let state: MinibitsState =
|
||||
serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
|
||||
let second = collect_relay_pages(state.last_dm_seen_at, state.relay_scan, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
assert!(second.resume.is_none());
|
||||
ids.extend(second.dms.into_iter().map(|e| e.3));
|
||||
assert_eq!(ids.len(), 1300);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_fetch_failure_keeps_the_unfinished_page_cursor() {
|
||||
let events = relay_fixture(450, false);
|
||||
let mut requests = 0;
|
||||
let first = collect_relay_pages(0, None, |scan| {
|
||||
requests += 1;
|
||||
std::future::ready(if requests == 1 {
|
||||
Ok(simulated_relay_page(&events, scan))
|
||||
} else {
|
||||
Err(anyhow!("relay timeout"))
|
||||
})
|
||||
})
|
||||
.await;
|
||||
assert_eq!(first.dms.len(), 200);
|
||||
assert_eq!(first.resume.unwrap().until, Some(251));
|
||||
let second = collect_relay_pages(450, first.resume, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
let ids: std::collections::HashSet<_> = first
|
||||
.dms
|
||||
.into_iter()
|
||||
.chain(second.dms)
|
||||
.map(|e| e.3)
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 450);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_typed_spent_claims_are_terminal_even_with_wrapped_errors() {
|
||||
let spent = anyhow::Error::new(super::super::mint_client::AlreadyRedeemed)
|
||||
.context("receive token")
|
||||
.context("claim failed");
|
||||
assert!(is_already_redeemed(&spent));
|
||||
assert!(!is_already_redeemed(&anyhow!(
|
||||
super::super::mint_client::ALREADY_REDEEMED_MSG
|
||||
)));
|
||||
assert!(!is_already_redeemed(&anyhow!(
|
||||
"mint temporarily unreachable"
|
||||
)));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -71,10 +71,28 @@ pub struct MintResult {
|
||||
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call
|
||||
/// can actually hit. Returns `None` for anything else (e.g. Lightning/quote
|
||||
/// codes in the 20000s) so the caller falls back to the mint's own `detail`.
|
||||
///
|
||||
/// Text of the NUT error-code-11001 translation, exposed so callers that
|
||||
/// received an `anyhow::Error` from a receive/redeem path (e.g. a replayed
|
||||
/// Minibits claim) can recognize an already-spent token as terminal rather
|
||||
/// than retrying it forever.
|
||||
pub const ALREADY_REDEEMED_MSG: &str =
|
||||
"This ecash has already been redeemed — it can't be claimed twice.";
|
||||
|
||||
/// Typed terminal condition: never infer spent proofs from a mint's free text.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct AlreadyRedeemed;
|
||||
impl std::fmt::Display for AlreadyRedeemed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(ALREADY_REDEEMED_MSG)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for AlreadyRedeemed {}
|
||||
|
||||
fn describe_mint_error_code(code: i64) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
10001 => "The mint rejected these coins as invalid.",
|
||||
11001 => "This ecash has already been redeemed — it can't be claimed twice.",
|
||||
11001 => ALREADY_REDEEMED_MSG,
|
||||
11002 => "This ecash is already being redeemed elsewhere — try again in a moment.",
|
||||
11003 => "The mint already issued new coins for this exact request — there's nothing left to redeem.",
|
||||
11004 => "This request is still being processed by the mint — try again in a moment.",
|
||||
@@ -124,8 +142,29 @@ fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
|
||||
/// translation layered on top via `.context()` so `{}` — what reaches the
|
||||
/// wallet user — shows something actionable instead of raw mint JSON.
|
||||
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
|
||||
let friendly = describe_mint_error_body(status, body);
|
||||
anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly)
|
||||
let cause = anyhow::anyhow!("{} failed ({}): {}", op, status, body);
|
||||
if serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("code").and_then(|c| c.as_i64()))
|
||||
== Some(11001)
|
||||
{
|
||||
return cause.context(AlreadyRedeemed);
|
||||
}
|
||||
cause.context(describe_mint_error_body(status, body))
|
||||
}
|
||||
|
||||
fn fee_adjusted_targets(requested: &[u64], mut available: u64) -> Vec<u64> {
|
||||
let mut outputs = Vec::new();
|
||||
for &amount in requested {
|
||||
if available >= amount {
|
||||
outputs.push(amount);
|
||||
available -= amount;
|
||||
} else {
|
||||
outputs.extend(amount_to_denominations(available));
|
||||
break;
|
||||
}
|
||||
}
|
||||
outputs
|
||||
}
|
||||
|
||||
/// HTTP client for a single Cashu mint.
|
||||
@@ -487,6 +526,21 @@ impl MintClient {
|
||||
/// Swap proofs for new proofs of different denominations.
|
||||
/// This is how we "receive" a token — swap it for fresh proofs that only we know.
|
||||
pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result<SwapResult> {
|
||||
self.swap_at_least(inputs, target_amounts, 0).await
|
||||
}
|
||||
|
||||
/// Refuse a payment whose mint fees would leave the seller underpaid,
|
||||
/// before consuming any input proofs.
|
||||
pub async fn swap_at_least(
|
||||
&self,
|
||||
inputs: &[Proof],
|
||||
target_amounts: &[u64],
|
||||
minimum: u64,
|
||||
) -> Result<SwapResult> {
|
||||
// V4 tokens carry short keyset IDs. Every swap path (including paid
|
||||
// files and streams) must expand these, not only wallet imports.
|
||||
let resolved = self.resolve_truncated_keyset_ids(inputs).await?;
|
||||
let inputs = resolved.as_slice();
|
||||
let keyset = self.get_active_sat_keyset().await?;
|
||||
|
||||
// NUT-02: a mint may charge a per-input fee, and it rejects the swap
|
||||
@@ -494,16 +548,35 @@ impl MintClient {
|
||||
// should equal outputs less fee`). Applied here rather than at each
|
||||
// call site so send, receive and cross-mint swaps are all covered.
|
||||
// Fee-free mints (Minibits) compute 0 and are unaffected.
|
||||
let inputs_total: u64 = inputs.iter().map(|p| p.amount).sum();
|
||||
let fee = match self.get_keysets().await {
|
||||
Ok(ks) => super::cashu::swap_fee_for(inputs, &ks),
|
||||
Err(e) => {
|
||||
debug!("Could not read keyset fees ({e:#}) — assuming fee-free mint");
|
||||
0
|
||||
}
|
||||
};
|
||||
anyhow::ensure!(!inputs.is_empty(), "No input proofs to swap");
|
||||
let inputs_total = inputs
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
|
||||
.context("Input amount overflow")?;
|
||||
let keysets = self.get_keysets().await?;
|
||||
let mut fee_ppk = 0u64;
|
||||
for proof in inputs {
|
||||
let input_keyset = keysets
|
||||
.iter()
|
||||
.find(|k| k.id == proof.id)
|
||||
.context("The mint does not recognize an input keyset")?;
|
||||
anyhow::ensure!(
|
||||
input_keyset.unit == "sat",
|
||||
"Input keyset is not denominated in sats"
|
||||
);
|
||||
fee_ppk = fee_ppk
|
||||
.checked_add(input_keyset.input_fee_ppk)
|
||||
.context("Mint fee overflow")?;
|
||||
}
|
||||
let fee = fee_ppk.div_ceil(1000);
|
||||
let spendable = inputs_total.saturating_sub(fee);
|
||||
let requested: u64 = target_amounts.iter().sum();
|
||||
if spendable < minimum {
|
||||
anyhow::bail!("Payment would leave {spendable} sats after mint fees; need {minimum} sats. No proofs were redeemed.");
|
||||
}
|
||||
let requested = target_amounts
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, amount| sum.checked_add(*amount))
|
||||
.context("Output amount overflow")?;
|
||||
let owned_targets: Vec<u64>;
|
||||
let target_amounts: &[u64] = if requested > spendable {
|
||||
if spendable == 0 {
|
||||
@@ -514,7 +587,10 @@ impl MintClient {
|
||||
debug!(
|
||||
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
|
||||
);
|
||||
owned_targets = amount_to_denominations(spendable);
|
||||
// Callers put payment outputs before change. Keep that prefix
|
||||
// intact while fees reduce change; re-splitting the entire sum
|
||||
// can omit a payment denomination after consuming the inputs.
|
||||
owned_targets = fee_adjusted_targets(target_amounts, spendable);
|
||||
&owned_targets
|
||||
} else {
|
||||
target_amounts
|
||||
@@ -559,6 +635,9 @@ impl MintClient {
|
||||
|
||||
let mut new_proofs = Vec::new();
|
||||
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) {
|
||||
if sig.amount != *amount || sig.id != keyset.id {
|
||||
anyhow::bail!("Mint returned a swap signature for an unexpected amount or keyset");
|
||||
}
|
||||
let c_prime = sig.c_prime_as_pubkey()?;
|
||||
let mint_key = keyset.key_for_amount(*amount)?;
|
||||
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
|
||||
@@ -705,43 +784,35 @@ impl MintClient {
|
||||
/// Repair proofs whose keyset id is a truncated NUT-02 **v2** id.
|
||||
///
|
||||
/// A v2 keyset id is 33 bytes (version byte `0x01` + 32-byte hash), but
|
||||
/// wallets written against the original 8-byte format truncate it when
|
||||
/// they build a token. The mint then reads the `0x01` version, expects 33
|
||||
/// compact V4 tokens carry an 8-byte short ID. The swap endpoint needs
|
||||
/// the full ID restored from the mint's keyset list. The mint then reads the `0x01` version, expects 33
|
||||
/// bytes, and rejects the swap — reported as
|
||||
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
|
||||
/// a Minibits-issued token, 2026-08-17).
|
||||
///
|
||||
/// The id only names which keyset signed the proof, so restoring the full
|
||||
/// id the mint advertises is exactly what the sender meant. It is also
|
||||
/// safe to attempt: an id that names the wrong keyset fails signature
|
||||
/// verification at the mint and no coins move. Anything already valid, or
|
||||
/// with no unambiguous match, is passed through untouched so the mint's
|
||||
/// own error is what the operator sees.
|
||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
|
||||
/// safe to attempt: the mint still verifies the proof signature. Unknown
|
||||
/// or ambiguous short IDs are rejected before redemption.
|
||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Result<Vec<Proof>> {
|
||||
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
||||
if !needs_repair {
|
||||
return proofs.to_vec();
|
||||
return Ok(proofs.to_vec());
|
||||
}
|
||||
|
||||
// The mint's own keyset list, in the reference implementation's shape
|
||||
// so its NUT-02 resolver can consume it directly.
|
||||
let known = match self.get_cdk_keysets().await {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
debug!("Could not list keysets to repair truncated keyset ids: {e:#}");
|
||||
return proofs.to_vec();
|
||||
}
|
||||
};
|
||||
let known = self.get_cdk_keysets().await?;
|
||||
|
||||
proofs
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut p| {
|
||||
if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) {
|
||||
debug!("Expanded short keyset id {} to {} for swap", p.id, full);
|
||||
p.id = full;
|
||||
if is_truncated_v2_keyset_id(&p.id) {
|
||||
p.id = super::cashu::resolve_keyset_id(&p.id, &known)
|
||||
.context("The mint cannot resolve this short keyset ID unambiguously")?;
|
||||
}
|
||||
p
|
||||
Ok(p)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -777,7 +848,7 @@ impl MintClient {
|
||||
let mut all_new_proofs = Vec::new();
|
||||
|
||||
for entry in &token.token {
|
||||
if entry.mint != self.url {
|
||||
if entry.mint.trim_end_matches('/') != self.url {
|
||||
debug!(
|
||||
"Skipping proofs from different mint {} (ours: {})",
|
||||
entry.mint, self.url
|
||||
@@ -788,8 +859,7 @@ impl MintClient {
|
||||
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(total);
|
||||
|
||||
let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await;
|
||||
let result = self.swap(&proofs, &target_amounts).await?;
|
||||
let result = self.swap(&entry.proofs, &target_amounts).await?;
|
||||
all_new_proofs.extend(result.new_proofs);
|
||||
}
|
||||
|
||||
@@ -803,6 +873,28 @@ impl MintClient {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn spent_condition_comes_from_code_not_remote_text_and_survives_context() {
|
||||
let spent = super::mint_error(
|
||||
"Swap",
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
r#"{"code":11001,"detail":"Token Already Spent"}"#,
|
||||
)
|
||||
.context("Receive failed");
|
||||
assert!(spent.is::<super::AlreadyRedeemed>());
|
||||
let body =
|
||||
serde_json::json!({"code":11002,"detail":super::ALREADY_REDEEMED_MSG}).to_string();
|
||||
assert!(
|
||||
!super::mint_error("Swap", reqwest::StatusCode::BAD_REQUEST, &body)
|
||||
.is::<super::AlreadyRedeemed>()
|
||||
);
|
||||
let body = serde_json::json!({"detail":super::ALREADY_REDEEMED_MSG}).to_string();
|
||||
assert!(
|
||||
!super::mint_error("Swap", reqwest::StatusCode::BAD_GATEWAY, &body)
|
||||
.is::<super::AlreadyRedeemed>()
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
//! Real HTTP/curve-signature regressions for paid Cashu redemption.
|
||||
use super::*;
|
||||
use crate::wallet::{bdhke, cashu::Proof};
|
||||
use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey};
|
||||
use hyper::{
|
||||
service::{make_service_fn, service_fn},
|
||||
Body, Request, Response, Server,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
const ACTIVE: &str = "0011223344556677";
|
||||
const V2: &str = "011111111111111111111111111111111111111111111111111111111111111111";
|
||||
|
||||
struct Mint {
|
||||
url: String,
|
||||
requests: Arc<Mutex<Vec<Value>>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
failure: Arc<std::sync::atomic::AtomicU16>,
|
||||
}
|
||||
impl Drop for Mint {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn signing_key() -> SecretKey {
|
||||
SecretKey::from_slice(&[7; 32]).unwrap()
|
||||
}
|
||||
fn signed_point(point: PublicKey) -> String {
|
||||
point
|
||||
.mul_tweak(&Secp256k1::new(), &Scalar::from(signing_key()))
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
fn proof(id: &str, amount: u64) -> Proof {
|
||||
let secret = format!("test-{id}-{amount}");
|
||||
Proof {
|
||||
amount,
|
||||
id: id.into(),
|
||||
c: signed_point(bdhke::hash_to_curve(secret.as_bytes()).unwrap()),
|
||||
secret,
|
||||
}
|
||||
}
|
||||
impl Mint {
|
||||
async fn start(fee: u64, failure: Option<u16>) -> Self {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = requests.clone();
|
||||
let failure = Arc::new(std::sync::atomic::AtomicU16::new(failure.unwrap_or(0)));
|
||||
let rejection = failure.clone();
|
||||
let spent = Arc::new(Mutex::new(std::collections::HashSet::<String>::new()));
|
||||
let service = make_service_fn(move |_| {
|
||||
let seen = seen.clone();
|
||||
let rejection = rejection.clone();
|
||||
let spent = spent.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
|
||||
let seen = seen.clone();
|
||||
let rejection = rejection.clone();
|
||||
let spent = spent.clone();
|
||||
async move {
|
||||
let mut status = 200;
|
||||
let body = match req.uri().path() {
|
||||
"/v1/keysets" => json!({"keysets":[
|
||||
{"id": ACTIVE,"unit":"sat","active":true,"input_fee_ppk":fee},
|
||||
{"id": V2,"unit":"sat","active":false,"input_fee_ppk":fee}
|
||||
]}),
|
||||
"/v1/keys" => {
|
||||
let public =
|
||||
PublicKey::from_secret_key(&Secp256k1::new(), &signing_key())
|
||||
.to_string();
|
||||
let keys: serde_json::Map<String, Value> = (0..16)
|
||||
.map(|i| ((1u64 << i).to_string(), json!(public)))
|
||||
.collect();
|
||||
json!({"keysets":[{"id": ACTIVE,"unit":"sat","keys":keys}]})
|
||||
}
|
||||
"/v1/swap" => {
|
||||
let body: Value = serde_json::from_slice(
|
||||
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
seen.lock().unwrap().push(body.clone());
|
||||
let inputs = body["inputs"].as_array().unwrap();
|
||||
let outputs = body["outputs"].as_array().unwrap();
|
||||
let code = rejection.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if code != 0 {
|
||||
status = code;
|
||||
json!({"detail":"mock mint rejection"})
|
||||
} else if inputs.iter().any(|p| p["id"] != V2 && p["id"] != ACTIVE)
|
||||
{
|
||||
status = 422;
|
||||
json!({"detail":[{"msg":"NUT02: ID length invalid"}]})
|
||||
} else if inputs.iter().any(|p| {
|
||||
spent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(p["secret"].as_str().unwrap())
|
||||
}) {
|
||||
status = 400;
|
||||
json!({"code":11001,"detail":"Token Already Spent"})
|
||||
} else {
|
||||
let total: u64 =
|
||||
inputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum();
|
||||
let out: u64 =
|
||||
outputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum();
|
||||
assert_eq!(
|
||||
out,
|
||||
total - (inputs.len() as u64 * fee).div_ceil(1000)
|
||||
);
|
||||
for p in inputs {
|
||||
spent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(p["secret"].as_str().unwrap().into());
|
||||
}
|
||||
json!({"signatures":outputs.iter().map(|o| json!({
|
||||
"amount":o["amount"],"id":ACTIVE,
|
||||
"C_":signed_point(o["B_"].as_str().unwrap().parse().unwrap())
|
||||
})).collect::<Vec<_>>()})
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
status = 404;
|
||||
json!({})
|
||||
}
|
||||
};
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
let server = Server::from_tcp(listener).unwrap().serve(service);
|
||||
let task = tokio::spawn(async move {
|
||||
server.await.unwrap();
|
||||
});
|
||||
Self {
|
||||
url,
|
||||
requests,
|
||||
task,
|
||||
failure,
|
||||
}
|
||||
}
|
||||
async fn wallet(&self) -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_accepted_mints(
|
||||
dir.path(),
|
||||
&AcceptedMints {
|
||||
mints: vec![format!("{}/", self.url)],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_v4_inactive_v2_keyset_is_expanded_and_cryptographic_proofs_saved() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
let decoded = CashuToken::deserialize(&token).unwrap();
|
||||
assert_eq!(
|
||||
decoded.token[0].proofs[0].id.len(),
|
||||
16,
|
||||
"reproduce the short V4 ID"
|
||||
);
|
||||
assert_eq!(
|
||||
verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.unwrap(),
|
||||
100
|
||||
);
|
||||
let wallet = load_wallet(dir.path()).await.unwrap();
|
||||
assert_eq!(wallet.balance(), 100);
|
||||
for p in wallet.proofs {
|
||||
assert_eq!(
|
||||
p.proof.c,
|
||||
signed_point(bdhke::hash_to_curve(p.proof.secret.as_bytes()).unwrap())
|
||||
);
|
||||
}
|
||||
assert!(mint.requests.lock().unwrap()[0]["inputs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|p| p["id"] == V2));
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_v3_full_v2_and_v1_ids_work() {
|
||||
for id in [V2, ACTIVE] {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(id, 128)])
|
||||
.serialize()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.unwrap(),
|
||||
128
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fees_cannot_consume_underpayment_and_allowed_fees_credit_actual_value() {
|
||||
let mint = Mint::start(1000, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 128)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("after mint fees"));
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
verify_and_receive_payment(dir.path(), &token, 127)
|
||||
.await
|
||||
.unwrap(),
|
||||
127
|
||||
);
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 127);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_mint_response_does_not_credit_wallet() {
|
||||
for status in [200, 400, 422, 500, 503] {
|
||||
let mint = Mint::start(0, Some(status)).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_untrusted_multimint_and_underpaid_tokens_never_reach_swap() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]);
|
||||
let mut invalid = vec![
|
||||
"cashuSend_500_abc_1700000000".into(),
|
||||
"cashuBinvalid".into(),
|
||||
];
|
||||
let mut wrong_unit = token.clone();
|
||||
wrong_unit.unit = Some("usd".into());
|
||||
invalid.push(wrong_unit.serialize().unwrap());
|
||||
let mut multi = token.clone();
|
||||
multi.token.push(token.token[0].clone());
|
||||
invalid.push(multi.serialize().unwrap());
|
||||
let mut untrusted = token.clone();
|
||||
untrusted.token[0].mint = "http://127.0.0.1:1".into();
|
||||
invalid.push(untrusted.serialize().unwrap());
|
||||
for id in ["00ffffffffffffff", "01ffffffffffffff"] {
|
||||
invalid.push(
|
||||
CashuToken::new(&mint.url, vec![proof(id, 128)])
|
||||
.serialize()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
for value in invalid {
|
||||
assert!(verify_and_receive_payment(dir.path(), &value, 100)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
assert!(
|
||||
verify_and_receive_payment(dir.path(), &token.serialize().unwrap(), 129)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buyer_token_rejected_by_seller_can_be_refunded_without_balance_loss() {
|
||||
let mint = Mint::start(0, Some(422)).await;
|
||||
let buyer = mint.wallet().await;
|
||||
let seller = mint.wallet().await;
|
||||
let mut wallet = load_wallet(buyer.path()).await.unwrap();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
wallet.add_proofs(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)]);
|
||||
save_wallet(buyer.path(), &wallet).await.unwrap();
|
||||
let token = send_token(buyer.path(), 100).await.unwrap();
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 0);
|
||||
assert!(verify_and_receive_payment(seller.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
mint.failure.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
assert_eq!(receive_token(buyer.path(), &token).await.unwrap(), 100);
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100);
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
||||
assert!(receive_token(buyer.path(), &token).await.is_err());
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unreachable_mint_does_not_credit_seller() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
mint.task.abort();
|
||||
tokio::task::yield_now().await;
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_with_fees_preserves_payment_denominations_and_saves_change() {
|
||||
// 128 inputs - 2 fee = 126. Splitting 126 as one sum omits 1,
|
||||
// which is needed for a 65-sat payment, after consuming the inputs.
|
||||
let mint = Mint::start(1000, None).await;
|
||||
let buyer = mint.wallet().await;
|
||||
let mut wallet = load_wallet(buyer.path()).await.unwrap();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
let first = proof(V2, 64);
|
||||
let mut second = first.clone();
|
||||
second.secret.push_str("-second");
|
||||
second.c = signed_point(bdhke::hash_to_curve(second.secret.as_bytes()).unwrap());
|
||||
wallet.add_proofs(&mint.url, vec![first, second]);
|
||||
save_wallet(buyer.path(), &wallet).await.unwrap();
|
||||
let encoded = send_token(buyer.path(), 65).await.unwrap();
|
||||
assert_eq!(
|
||||
CashuToken::deserialize(&encoded).unwrap().total_amount(),
|
||||
65
|
||||
);
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 61);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_file_gate_delivers_bytes_only_after_payment_and_does_not_charge_missing_files() {
|
||||
use crate::content_server::{
|
||||
self, AccessControl, Availability, ContentCatalog, ContentItem, ServeResult,
|
||||
};
|
||||
for (exists, accepts_cashu, price) in [
|
||||
(true, true, 100),
|
||||
(true, false, 100),
|
||||
(false, true, 100),
|
||||
(true, true, 129),
|
||||
] {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let seller = mint.wallet().await;
|
||||
let item = ContentItem {
|
||||
id: "paid-test".into(),
|
||||
filename: "test.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
size_bytes: 5,
|
||||
description: String::new(),
|
||||
added_at: String::new(),
|
||||
availability: Availability::AllPeers,
|
||||
access: AccessControl::Paid {
|
||||
price_sats: price,
|
||||
accepted: vec![if accepts_cashu { "ecash" } else { "fedimint" }.into()],
|
||||
},
|
||||
};
|
||||
content_server::save_catalog(seller.path(), &ContentCatalog { items: vec![item] })
|
||||
.await
|
||||
.unwrap();
|
||||
if exists {
|
||||
tokio::fs::create_dir_all(seller.path().join("content/files"))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(seller.path().join("content/files/test.txt"), b"hello")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
let result = content_server::serve_content(
|
||||
seller.path(),
|
||||
"paid-test",
|
||||
Some(&token),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
if exists && accepts_cashu && price <= 128 {
|
||||
match result {
|
||||
ServeResult::Ok(bytes, mime) => {
|
||||
assert_eq!(bytes, b"hello");
|
||||
assert_eq!(mime, "text/plain");
|
||||
}
|
||||
_ => panic!("paid content was not delivered"),
|
||||
}
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 128);
|
||||
} else {
|
||||
assert!(matches!(
|
||||
result,
|
||||
ServeResult::NotFound | ServeResult::PaymentRequired(_)
|
||||
));
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -989,6 +989,24 @@ impl AppManifest {
|
||||
validate_security(&self.app.security)?;
|
||||
validate_ports(&self.app.ports)?;
|
||||
validate_interfaces(&self.app.interfaces)?;
|
||||
if let Some(value) = self.app.extensions.get("install_prerequisites") {
|
||||
let items = value.as_sequence().ok_or_else(|| {
|
||||
ManifestError::Invalid("install_prerequisites must be a list of app ids".into())
|
||||
})?;
|
||||
for item in items {
|
||||
let id = item.as_str().unwrap_or_default();
|
||||
if id.is_empty()
|
||||
|| id == self.app.id
|
||||
|| !id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
{
|
||||
return Err(ManifestError::Invalid(
|
||||
"install_prerequisites must contain valid other app ids".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
validate_environment(&self.app.environment)?;
|
||||
validate_devices(&self.app.devices)?;
|
||||
|
||||
@@ -1074,6 +1092,14 @@ impl AppManifest {
|
||||
// `..` copy sources). See docs/manifest-hooks-design.md.
|
||||
self.app.hooks.validate()?;
|
||||
|
||||
if let Some(value) = self.app.extensions.get("backup_before_runtime_change") {
|
||||
if value.as_bool().is_none() {
|
||||
return Err(ManifestError::Invalid(
|
||||
"backup_before_runtime_change must be boolean".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1111,6 +1137,7 @@ fn validate_security(policy: &SecurityPolicy) -> Result<(), ManifestError> {
|
||||
"SETGID",
|
||||
"SETUID",
|
||||
"SYS_ADMIN",
|
||||
"SYS_CHROOT",
|
||||
];
|
||||
let mut seen = HashSet::new();
|
||||
for cap in &policy.capabilities {
|
||||
@@ -1746,40 +1773,48 @@ app:
|
||||
}
|
||||
}
|
||||
exempt.sort();
|
||||
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
|
||||
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
|
||||
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
|
||||
// upstream's own safe-for-public
|
||||
// subset that wallets connect to directly as a "remote node" over
|
||||
// plain HTTP JSON-RPC — same reasoning as electrumx's 50001).
|
||||
// cuprate's unrestricted RPC (full node control) stays loopback-only
|
||||
// (auth: local), not in this set.
|
||||
//
|
||||
// 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a
|
||||
// loopback-only JSON API whose own generated http password
|
||||
// authenticates every request (added with the phoenixd onboarding,
|
||||
// which did not update this count — exactly the drift this test
|
||||
// exists to catch).
|
||||
//
|
||||
// 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2),
|
||||
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
|
||||
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack
|
||||
// 8091/9738/10010, lnd 9735/10009/18080, netbird 3478/8086/8087,
|
||||
// pine TLS 10381 + the three voice ports (10200/10300/10400 — the
|
||||
// disclosed known gap), router SSDP/mDNS 1900/5353. Every one is a
|
||||
// deliberate, rationale-carrying exemption; the release-gate test
|
||||
// stage timed out that cycle, so the count here lagged at 17.
|
||||
// Reviewed 2026-09-30: lightning-stack's three retired endpoints
|
||||
// disappeared; Cuprate restricted RPC moved from none to gate-open.
|
||||
// Compare exact endpoints, not just a count that can hide substitutions.
|
||||
let expected = [
|
||||
("bitcoin-core", 8333),
|
||||
("bitcoin-knots", 8333),
|
||||
("core-lightning", 9736),
|
||||
("core-lightning", 9835),
|
||||
("cuprate", 18183),
|
||||
("electrumx", 50001),
|
||||
("fedimint", 8173),
|
||||
("fedimint", 8174),
|
||||
("fedimint-gateway", 8176),
|
||||
("fedimint-gateway", 9737),
|
||||
("gitea", 2222),
|
||||
("lnd", 9735),
|
||||
("lnd", 10009),
|
||||
("lnd", 18080),
|
||||
("netbird", 8087),
|
||||
("netbird-server", 3478),
|
||||
("netbird-server", 8086),
|
||||
("phoenixd", 9740),
|
||||
("pine", 10381),
|
||||
("pine-openwakeword", 10400),
|
||||
("pine-piper", 10200),
|
||||
("pine-whisper", 10300),
|
||||
("router", 1900),
|
||||
("router", 5353),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(id, port)| (id.to_owned(), port))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
28,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
exempt, expected,
|
||||
"unauthenticated endpoint set changed; review each exemption"
|
||||
);
|
||||
}
|
||||
|
||||
/// `auth: open` ports are served by the gate WITHOUT its login challenge,
|
||||
/// so they are the second unauthenticated-by-the-gate surface and get the
|
||||
/// same review guard as `auth: none`. Each one must be an app that
|
||||
/// enforces a real login of its own.
|
||||
/// same review guard as `auth: none`. Each must enforce its own login or
|
||||
/// have an explicitly reviewed public protocol purpose.
|
||||
#[test]
|
||||
fn gate_open_ports_are_all_accounted_for() {
|
||||
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
|
||||
@@ -1801,6 +1836,8 @@ app:
|
||||
}
|
||||
}
|
||||
open.sort();
|
||||
// Cuprate 18090 is its deliberately public restricted RPC subset;
|
||||
// unrestricted node-control RPC remains container-loopback-only.
|
||||
// Gitea 3001 (git clients speak basic-auth, not browser cookies),
|
||||
// BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable
|
||||
// by anonymous payers), and — since the v1.8.7 platform round — the
|
||||
@@ -1808,18 +1845,35 @@ app:
|
||||
// nginx-proxy-manager 8081 (NPM admin accounts), tailscale 8240
|
||||
// (tailnet login on the web console). Both enforce their own login,
|
||||
// and an operator can re-gate either from Settings → Access control.
|
||||
// Angor's indexer exposes public chain data/transaction broadcast;
|
||||
// its optional standalone relay accepts signed public Nostr events.
|
||||
// Neither mounts credentials or the node's internal relay database.
|
||||
assert_eq!(
|
||||
open,
|
||||
vec![
|
||||
("angor-indexer".to_string(), 8998u16),
|
||||
("angor-relay".to_string(), 8091u16),
|
||||
("btcpay-server".to_string(), 23000u16),
|
||||
("cuprate".to_string(), 18090u16),
|
||||
("gitea".to_string(), 3001u16),
|
||||
("nginx-proxy-manager".to_string(), 8081u16),
|
||||
("tailscale".to_string(), 8240u16),
|
||||
],
|
||||
"gate-open port set changed — every entry must be an app with its own login"
|
||||
"gate-open port set changed — review login or intentional public protocol purpose"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_install_prerequisites_are_rejected() {
|
||||
for value in ["not-a-list", "[demo]", "['../other']", "[false]", "['']"] {
|
||||
let yaml = format!("app:\n id: demo\n name: Demo\n version: 1.0.0\n container:\n image: docker.io/library/alpine:3.20\n install_prerequisites: {value}\n");
|
||||
assert!(AppManifest::parse(&yaml)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("install_prerequisites"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undeclared_port_classifies_as_session_but_is_not_declared() {
|
||||
// Two different questions, and conflating them caused both gate
|
||||
|
||||
@@ -310,59 +310,7 @@ impl PodmanClient {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Honour the manifest's protocol (default tcp). netbird's STUN port
|
||||
// is 3478/udp; forcing tcp here would publish the wrong protocol and
|
||||
// silently break relay discovery.
|
||||
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
|
||||
"udp" => "udp",
|
||||
"sctp" => "sctp",
|
||||
_ => "tcp",
|
||||
};
|
||||
// Effective bind. A gated port with no declared bind would
|
||||
// publish 0.0.0.0 — the app would own every host address, which
|
||||
// is both the exposure itself and the reason the daemon's app
|
||||
// gate cannot bind those addresses to authenticate them. Pin it
|
||||
// to loopback so the gate can take the external addresses.
|
||||
//
|
||||
// Doing it HERE, at container creation, is the point: the pin and
|
||||
// the gate's takeover then both come from the daemon and cannot
|
||||
// disagree. The earlier attempt put this decision in manifest
|
||||
// data instead, and a node whose manifests lagged the binary
|
||||
// published Bitcoin's loopback-only RPC across the LAN
|
||||
// (test node, 2026-08-03).
|
||||
//
|
||||
// A port that already declares a bind is never overridden — that
|
||||
// is exactly what keeps `bind: 127.0.0.1` ports host-local and
|
||||
// leaves `auth: none` protocol ports (LND gRPC/REST, electrum)
|
||||
// published as they are, so remote wallets keep working.
|
||||
// NOTE: the daemon deliberately does NOT rewrite this. Pinning a
|
||||
// published port to loopback is how an app hands its external
|
||||
// addresses to the gate, but it belongs in the manifest, not in
|
||||
// daemon-side inference:
|
||||
//
|
||||
// * `bind` is already honoured by every publish path (here and
|
||||
// in package::install), so a manifest edit needs no code.
|
||||
// * inference here would cover only THIS path — proven on
|
||||
// a test node, where a recreate went through another one and
|
||||
// the pin never applied.
|
||||
// * and inferring from an ABSENT field is what republished
|
||||
// Bitcoin's loopback RPC across the LAN, and came within one
|
||||
// container-recreate of pinning LND's gRPC/REST and breaking
|
||||
// every remote wallet.
|
||||
//
|
||||
// So the migration ships as `bind: 127.0.0.1` in the signed
|
||||
// catalog. Verified 2026-08-03 that a disk-only manifest edit is
|
||||
// overridden by the catalog, which is precisely why the catalog is
|
||||
// the right and only place to carry it.
|
||||
let mut mapping = serde_json::json!({
|
||||
"container_port": port.container,
|
||||
"host_port": port.host,
|
||||
"protocol": protocol,
|
||||
});
|
||||
if !port.bind.is_empty() {
|
||||
mapping["host_ip"] = serde_json::json!(port.bind);
|
||||
}
|
||||
port_mappings.push(mapping);
|
||||
port_mappings.push(podman_publish_mapping(port));
|
||||
}
|
||||
|
||||
let mut mounts = Vec::new();
|
||||
@@ -751,6 +699,25 @@ pub fn image_uses_insecure_registry(image: &str) -> bool {
|
||||
.is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host))
|
||||
}
|
||||
|
||||
// Keep the explicitly declared bind and transport identical to Quadlet. The
|
||||
// app gate owns external listeners; container publication must not bypass it.
|
||||
fn podman_publish_mapping(port: &crate::manifest::PortMapping) -> serde_json::Value {
|
||||
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
|
||||
"udp" => "udp",
|
||||
"sctp" => "sctp",
|
||||
_ => "tcp",
|
||||
};
|
||||
let mut mapping = serde_json::json!({
|
||||
"container_port": port.container,
|
||||
"host_port": port.host,
|
||||
"protocol": protocol,
|
||||
});
|
||||
if !port.bind.is_empty() {
|
||||
mapping["host_ip"] = serde_json::json!(port.bind);
|
||||
}
|
||||
mapping
|
||||
}
|
||||
|
||||
fn podman_network_settings(
|
||||
network: Option<&str>,
|
||||
network_policy: &str,
|
||||
@@ -1110,6 +1077,24 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portainer_manifest_keeps_private_network_and_loopback_api_publication() {
|
||||
let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
|
||||
assert_eq!(
|
||||
podman_network_settings(
|
||||
m.app.container.network.as_deref(),
|
||||
&m.app.security.network_policy
|
||||
),
|
||||
("slirp4netns", None)
|
||||
);
|
||||
assert_eq!(
|
||||
podman_publish_mapping(&m.app.ports[0]),
|
||||
serde_json::json!({
|
||||
"container_port": 9000, "host_port": 9000, "protocol": "tcp", "host_ip": "127.0.0.1"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn podman_network_settings_uses_networks_map_for_custom_networks() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -618,6 +618,46 @@ impl DockerRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
// Docker is a development fallback. Refuse Podman-only network modes instead
|
||||
// of silently installing a different topology; still honor binds for other apps.
|
||||
fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<String>> {
|
||||
let network = manifest
|
||||
.app
|
||||
.container
|
||||
.network
|
||||
.as_deref()
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or(&manifest.app.security.network_policy);
|
||||
if matches!(network, "slirp4netns" | "pasta") {
|
||||
anyhow::bail!("this app requires rootless Podman networking ({network})");
|
||||
}
|
||||
let mut args = Vec::new();
|
||||
if !network.is_empty() && network != "isolated" {
|
||||
args.extend(["--network".to_owned(), network.to_owned()]);
|
||||
}
|
||||
for port in &manifest.app.ports {
|
||||
let host = port
|
||||
.host
|
||||
.checked_add(offset)
|
||||
.context("published port offset overflow")?;
|
||||
let bind = if port.bind.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}:", port.bind)
|
||||
};
|
||||
let protocol = if port.protocol.is_empty() {
|
||||
"tcp"
|
||||
} else {
|
||||
&port.protocol
|
||||
};
|
||||
args.extend([
|
||||
"-p".to_owned(),
|
||||
format!("{bind}{host}:{}/{protocol}", port.container),
|
||||
]);
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ContainerRuntime for DockerRuntime {
|
||||
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
|
||||
@@ -657,25 +697,7 @@ impl ContainerRuntime for DockerRuntime {
|
||||
cmd.arg("--read-only");
|
||||
}
|
||||
|
||||
match manifest.app.security.network_policy.as_str() {
|
||||
"host" => {
|
||||
cmd.arg("--network").arg("host");
|
||||
}
|
||||
"isolated" => {
|
||||
// Docker uses bridge network by default
|
||||
}
|
||||
_ => {
|
||||
cmd.arg("--network")
|
||||
.arg(&manifest.app.security.network_policy);
|
||||
}
|
||||
}
|
||||
|
||||
// Port mappings with offset
|
||||
for port in &manifest.app.ports {
|
||||
let host_port = port.host + port_offset;
|
||||
cmd.arg("-p")
|
||||
.arg(format!("{}:{}", host_port, port.container));
|
||||
}
|
||||
cmd.args(docker_network_and_ports(manifest, port_offset)?);
|
||||
|
||||
// Volumes
|
||||
for volume in &manifest.app.volumes {
|
||||
@@ -1035,6 +1057,21 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn docker_fallback_rejects_rootless_only_topology_and_preserves_bind_protocol() {
|
||||
let mut m =
|
||||
AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
|
||||
assert!(docker_network_and_ports(&m, 0).is_err());
|
||||
m.app.container.network = Some("bridge".into());
|
||||
m.app.ports[0].protocol = "udp".into();
|
||||
let args = docker_network_and_ports(&m, 1).unwrap();
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["--network", "bridge", "-p", "127.0.0.1:9001:9000/udp"]
|
||||
);
|
||||
assert!(docker_network_and_ports(&m, u16::MAX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_container_classifier_covers_podman5_phrasings() {
|
||||
// podman 5.x `inspect` phrasing for a missing container.
|
||||
|
||||
@@ -16,9 +16,11 @@ lookup relays from the defaults. It does not replace GitWorkshop's NIP-34,
|
||||
GRASP, repository browser, issue, pull-request, or review interfaces.
|
||||
|
||||
The separate dependency patch refreshes the npm lockfile and moves `fflate` to
|
||||
0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. The resulting clean
|
||||
install reports zero npm advisories; its type-check, 152 unit tests, and
|
||||
Archipelago subpath production build pass. Keeping this mechanical security
|
||||
0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. On 2026-09-30 the lockfile was refreshed again for `brace-expansion`
|
||||
1.1.21/5.0.12, `fast-uri` 3.1.8 and `ip-address` 10.7.2 after fresh node
|
||||
installs failed the retained dependency audit. The resulting clean install
|
||||
reports zero npm advisories; its type-check, 152 unit tests, and Archipelago
|
||||
subpath production build pass. The complete image also builds on the X250. Keeping this mechanical security
|
||||
update separate makes both the upstream integration and future dependency
|
||||
refreshes auditable.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/package-lock.json b/package-lock.json
|
||||
index 20631bb..0933917 100644
|
||||
index 20631bb..86b6f86 100644
|
||||
--- a/package-lock.json
|
||||
+++ b/package-lock.json
|
||||
@@ -63,7 +63,7 @@
|
||||
@@ -495,9 +495,9 @@ index 20631bb..0933917 100644
|
||||
- "version": "5.0.7",
|
||||
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
+ "version": "5.0.9",
|
||||
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
+ "version": "5.0.12",
|
||||
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -678,9 +678,9 @@ index 20631bb..0933917 100644
|
||||
- "version": "1.1.15",
|
||||
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
||||
+ "version": "1.1.18",
|
||||
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
+ "version": "1.1.21",
|
||||
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -833,9 +833,9 @@ index 20631bb..0933917 100644
|
||||
- "version": "3.1.3",
|
||||
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||
- "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||
+ "version": "3.1.7",
|
||||
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
+ "version": "3.1.8",
|
||||
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -859,9 +859,9 @@ index 20631bb..0933917 100644
|
||||
- "version": "5.0.7",
|
||||
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
+ "version": "5.0.9",
|
||||
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
+ "version": "5.0.12",
|
||||
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -893,9 +893,9 @@ index 20631bb..0933917 100644
|
||||
- "version": "10.2.0",
|
||||
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
- "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
+ "version": "10.7.0",
|
||||
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
|
||||
+ "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
|
||||
+ "version": "10.7.2",
|
||||
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||
+ "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -1445,4 +1445,3 @@ index bd7190c..6aa5a6f 100644
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Mock window.matchMedia
|
||||
|
||||
|
||||
+60
-18
@@ -989,7 +989,7 @@
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────
|
||||
let unit = 'sats';
|
||||
let state = { info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
|
||||
let state = { readiness: null, info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
|
||||
let peerSort = { col: 'peer', dir: 1 };
|
||||
let activityFilter = 'all';
|
||||
let logsLoaded = false;
|
||||
@@ -1142,9 +1142,19 @@
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
if (state.refreshing) return;
|
||||
state.refreshing = true;
|
||||
const icon = document.getElementById('refreshIcon');
|
||||
if (icon) icon.classList.add('animate-spin-slow');
|
||||
try {
|
||||
state.readiness = await lndSafe('/archy-status', null);
|
||||
if (state.readiness && state.readiness.state.startsWith('waiting_')) {
|
||||
state.info = null;
|
||||
state.onchainStale = true;
|
||||
state.chanbalStale = true;
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
const [info, channels, pending, peers, fees, graph, payments, invoices, txns] = await Promise.all([
|
||||
lndSafe('/v1/getinfo', null),
|
||||
lndSafe('/v1/channels', { channels: [] }),
|
||||
@@ -1166,10 +1176,17 @@
|
||||
state.invoices = (invoices && invoices.invoices) || [];
|
||||
state.txns = (txns && txns.transactions) || [];
|
||||
|
||||
// Balances are separate so one failing endpoint can't blank the rest.
|
||||
state.onchain = await lndSafe('/v1/balance/blockchain', null);
|
||||
state.chanbal = await lndSafe('/v1/balance/channels', null);
|
||||
// Preserve known balances on outage; never decode an error as zero.
|
||||
const [onchain, chanbal] = await Promise.all([
|
||||
lndSafe('/v1/balance/blockchain', null),
|
||||
lndSafe('/v1/balance/channels', null),
|
||||
]);
|
||||
state.onchainStale = !validBalance(onchain && (onchain.confirmed_balance ?? onchain.total_balance));
|
||||
state.chanbalStale = !validBalance(chanbal && (chanbal.local_balance?.sat ?? chanbal.balance));
|
||||
if (!state.onchainStale) state.onchain = onchain;
|
||||
if (!state.chanbalStale) state.chanbal = chanbal;
|
||||
} finally {
|
||||
state.refreshing = false;
|
||||
if (icon) icon.classList.remove('animate-spin-slow');
|
||||
}
|
||||
renderAll();
|
||||
@@ -1192,11 +1209,17 @@
|
||||
const pill = document.getElementById('headerStatusPill');
|
||||
const dot = document.getElementById('headerStatusDot');
|
||||
|
||||
if (!g) {
|
||||
setText('headerStatusText', 'Unreachable');
|
||||
pill.className = 'pill bad';
|
||||
dot.className = 'status-dot-sm bg-red';
|
||||
document.getElementById('syncCard').style.display = 'none';
|
||||
const waiting = state.readiness && state.readiness.state.startsWith('waiting_');
|
||||
if (!g || waiting) {
|
||||
setText('headerStatusText', waiting ? state.readiness.message : 'Connecting to LND');
|
||||
pill.className = 'pill warn';
|
||||
dot.className = 'status-dot-sm bg-yellow';
|
||||
document.getElementById('syncCard').style.display = '';
|
||||
setText('syncSubtitle', waiting ? state.readiness.message + '. Lightning will become available automatically.' : 'Checking Lightning availability. Retrying automatically.');
|
||||
setText('syncBlockLabel', '');
|
||||
setText('syncPercent', '');
|
||||
document.getElementById('syncProgressBar').style.width = '0%';
|
||||
for (const id of ['syncChain', 'syncGraph', 'syncHeight', 'syncPeers']) setText(id, '—');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1237,6 +1260,11 @@
|
||||
}
|
||||
|
||||
// ── Balances ────────────────────────────────────────────────────
|
||||
function validBalance(value) {
|
||||
return (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)))
|
||||
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
function renderBalances() {
|
||||
const onchainConfirmed = num(state.onchain && (state.onchain.confirmed_balance ?? state.onchain.total_balance));
|
||||
const onchainUnconfirmed = num(state.onchain && state.onchain.unconfirmed_balance);
|
||||
@@ -1253,22 +1281,23 @@
|
||||
const haveOnchain = !!state.onchain;
|
||||
const haveChan = !!cb;
|
||||
|
||||
setBalance('balTotal', haveOnchain || haveChan ? onchainConfirmed + lnLocal : null);
|
||||
setText('balTotalSub', haveOnchain || haveChan ? 'on-chain + lightning' : 'waiting for LND');
|
||||
setBalance('balTotal', haveOnchain && haveChan ? onchainConfirmed + lnLocal : null);
|
||||
setText('balTotalSub', state.onchainStale || state.chanbalStale ? 'balance unavailable · last known values' : haveOnchain && haveChan ? 'on-chain + lightning' : 'waiting for LND');
|
||||
setBalance('balLightning', haveChan ? lnLocal : null);
|
||||
setText('balLightningSub', !haveChan ? 'waiting for LND'
|
||||
setText('balLightningSub', !haveChan ? 'waiting for LND' : state.chanbalStale ? 'last known balance'
|
||||
: lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
|
||||
setBalance('balOnchain', haveOnchain ? onchainConfirmed : null);
|
||||
setText('balOnchainSub', !haveOnchain ? 'waiting for LND'
|
||||
setText('balOnchainSub', !haveOnchain ? 'waiting for LND' : state.onchainStale ? 'last known balance'
|
||||
: onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
|
||||
|
||||
setText('liqLocal', fmtAmount(lnLocal));
|
||||
setText('liqRemote', fmtAmount(lnRemote));
|
||||
const liquidityReady = haveChan && !state.chanbalStale && !!state.info;
|
||||
setText('liqLocal', liquidityReady ? fmtAmount(lnLocal) : '—');
|
||||
setText('liqRemote', liquidityReady ? fmtAmount(lnRemote) : '—');
|
||||
const total = lnLocal + lnRemote;
|
||||
const localPct = total > 0 ? (lnLocal / total) * 100 : 50;
|
||||
document.getElementById('liqBarLocal').style.width = localPct + '%';
|
||||
document.getElementById('liqBarRemote').style.width = (100 - localPct) + '%';
|
||||
setText('liqHint', total > 0
|
||||
document.getElementById('liqBarLocal').style.width = (liquidityReady ? localPct : 0) + '%';
|
||||
document.getElementById('liqBarRemote').style.width = (liquidityReady ? 100 - localPct : 0) + '%';
|
||||
setText('liqHint', !liquidityReady ? 'Channel capacity is unavailable while waiting for LND.' : total > 0
|
||||
? Math.round(localPct) + '% of your channel capacity is outbound (sendable).'
|
||||
: 'Open a channel to start sending and receiving over Lightning.');
|
||||
}
|
||||
@@ -1284,6 +1313,15 @@
|
||||
|
||||
function renderSummary() {
|
||||
const g = state.info;
|
||||
if (!g) {
|
||||
for (const id of ['statPeers', 'statActiveChannels', 'statCapacity', 'statRoutingMonth', 'healthHeight', 'healthPending', 'chActive', 'chInactive', 'chPending', 'chCapacity']) setText(id, '—');
|
||||
for (const id of ['statChannelsSub', 'channelsLinkSub']) setText(id, 'Waiting for LND');
|
||||
for (const id of ['healthChain', 'healthGraph']) {
|
||||
const pill = document.getElementById(id);
|
||||
pill.textContent = '—'; pill.className = 'pill warn';
|
||||
}
|
||||
return;
|
||||
}
|
||||
const chans = state.channels;
|
||||
const active = chans.filter(c => c.active).length;
|
||||
const inactive = chans.length - active;
|
||||
@@ -1321,6 +1359,10 @@
|
||||
function renderChannels() {
|
||||
const el = document.getElementById('channelList');
|
||||
if (!el) return;
|
||||
if (!state.info) {
|
||||
el.innerHTML = '<div class="empty-state">Waiting for LND. Existing channels will appear when it is ready.</div>';
|
||||
return;
|
||||
}
|
||||
const q = (document.getElementById('channelFilter').value || '').toLowerCase();
|
||||
let list = state.channels.slice();
|
||||
if (q) list = list.filter(c => String(c.remote_pubkey || '').toLowerCase().includes(q) || String(c.chan_id || '').includes(q));
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# Archipelago mempool frontend — adds a resilient nginx backend proxy.
|
||||
#
|
||||
# The only delta vs the upstream image is /patch/entrypoint.sh, which rewrites
|
||||
# the generated nginx-mempool.conf to use `resolver` + a variable proxy_pass so
|
||||
# the frontend re-resolves the backend (mempool-api) via DNS on every request.
|
||||
# Without this, nginx pins the backend IP at startup and serves 502 / "offline"
|
||||
# after any backend restart (podman reassigns the IP). See the script header.
|
||||
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.0
|
||||
# Keep the upstream startup logic; repair its rendered proxy configuration.
|
||||
# Publish this derived image under an Archipelago-specific tag, never the
|
||||
# upstream version tag that the registry mirror can overwrite.
|
||||
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend@sha256:d63498a109622475c913db4e3199d893f2440a451450e542923d2e55a38407a0
|
||||
FROM ${BASE}
|
||||
|
||||
# --chmod keeps the exec bit (build runs as USER 1000, plain COPY lands root:0644
|
||||
# → "not executable"). Base USER/ENTRYPOINT/CMD (1000 / /patch/entrypoint.sh /
|
||||
# nginx -g "daemon off;") are inherited unchanged.
|
||||
COPY --chmod=0755 entrypoint.sh /patch/entrypoint.sh
|
||||
RUN cp /patch/entrypoint.sh /patch/upstream-entrypoint.sh
|
||||
COPY --chmod=0755 entrypoint.sh start-nginx.sh repair-nginx.sh /patch/
|
||||
|
||||
@@ -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
|
||||
__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__=${BACKEND_MAINNET_HTTP_HOST:=127.0.0.1}
|
||||
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999}
|
||||
__MEMPOOL_FRONTEND_HTTP_PORT__=${FRONTEND_HTTP_PORT:=8080}
|
||||
|
||||
CONF=/etc/nginx/conf.d/nginx-mempool.conf
|
||||
|
||||
# ─── archipelago patch ────────────────────────────────────────────────────
|
||||
# The stock frontend writes `proxy_pass http://<backend>:8999` with a literal
|
||||
# hostname and NO resolver, so nginx resolves the backend IP ONCE at worker
|
||||
# start and caches it for the process lifetime. Podman reassigns the backend
|
||||
# container's IP whenever it is restarted/recreated (gate, OTA, crash, reboot
|
||||
# re-IPAM), after which nginx keeps proxying to the dead IP → /api hangs, the
|
||||
# websocket 502s, and the mempool UI shows "offline" until nginx is reloaded.
|
||||
#
|
||||
# Fix: force per-request DNS re-resolution via `resolver` + a variable in
|
||||
# proxy_pass. Because a variable in proxy_pass disables nginx's automatic
|
||||
# location→URI rewriting, each block is rewritten to preserve its original
|
||||
# path mapping exactly:
|
||||
# /api/v1/ws, /ws → "/" (var + "/" replaces the whole URI)
|
||||
# /api/v1 → identity (no-URI proxy_pass passes $uri unchanged)
|
||||
# /api/ → /api/v1/$1 (explicit rewrite, then no-URI proxy_pass)
|
||||
# Operates on the __PLACEHOLDER__ tokens so the host/port sed below fills in
|
||||
# the concrete values (incl. the `set $mp_backend` line). Idempotent.
|
||||
# Resolver address: podman's aardvark-dns answers on the network gateway
|
||||
# (e.g. 10.89.0.1), NOT Docker's 127.0.0.11. Read it from resolv.conf so this
|
||||
# works on any podman network/subnet (and still falls back for Docker).
|
||||
ARCHY_RESOLVER=$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null)
|
||||
ARCHY_RESOLVER=${ARCHY_RESOLVER:-127.0.0.11}
|
||||
|
||||
if ! grep -q 'set \$mp_backend' "$CONF"; then
|
||||
awk -v res_addr="$ARCHY_RESOLVER" '
|
||||
BEGIN { res = 0 }
|
||||
/^[[:space:]]*location / && res == 0 {
|
||||
print "\tresolver " res_addr " valid=10s ipv6=off;"
|
||||
res = 1
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/;"
|
||||
next
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1\/;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\trewrite ^/api/(.*)$ /api/v1/$1 break;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
|
||||
next
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$CONF" > "$CONF.archy" && mv "$CONF.archy" "$CONF"
|
||||
fi
|
||||
# ─── end archipelago patch ────────────────────────────────────────────────
|
||||
|
||||
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__/${__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__}/g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/${__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__}/g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
|
||||
cp /etc/nginx/nginx.conf /patch/nginx.conf
|
||||
sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf
|
||||
cat /patch/nginx.conf > /etc/nginx/nginx.conf
|
||||
|
||||
if [ "${LIGHTNING_DETECTED_PORT}" != "" ];then
|
||||
export LIGHTNING=true
|
||||
fi
|
||||
|
||||
# Runtime overrides - read env vars defined in docker compose
|
||||
|
||||
__MAINNET_ENABLED__=${MAINNET_ENABLED:=true}
|
||||
__TESTNET_ENABLED__=${TESTNET_ENABLED:=false}
|
||||
__TESTNET4_ENABLED__=${TESTNET_ENABLED:=false}
|
||||
__SIGNET_ENABLED__=${SIGNET_ENABLED:=false}
|
||||
__LIQUID_ENABLED__=${LIQUID_ENABLED:=false}
|
||||
__LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false}
|
||||
__ITEMS_PER_PAGE__=${ITEMS_PER_PAGE:=10}
|
||||
__KEEP_BLOCKS_AMOUNT__=${KEEP_BLOCKS_AMOUNT:=8}
|
||||
__NGINX_PROTOCOL__=${NGINX_PROTOCOL:=http}
|
||||
__NGINX_HOSTNAME__=${NGINX_HOSTNAME:=localhost}
|
||||
__NGINX_PORT__=${NGINX_PORT:=8999}
|
||||
__BLOCK_WEIGHT_UNITS__=${BLOCK_WEIGHT_UNITS:=4000000}
|
||||
__MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_BLOCKS_AMOUNT:=8}
|
||||
__BASE_MODULE__=${BASE_MODULE:=mempool}
|
||||
__ROOT_NETWORK__=${ROOT_NETWORK:=}
|
||||
__MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://mempool.space}
|
||||
__LIQUID_WEBSITE_URL__=${LIQUID_WEBSITE_URL:=https://liquid.network}
|
||||
__MINING_DASHBOARD__=${MINING_DASHBOARD:=true}
|
||||
__LIGHTNING__=${LIGHTNING:=false}
|
||||
__AUDIT__=${AUDIT:=false}
|
||||
__MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__ACCELERATOR__=${ACCELERATOR:=false}
|
||||
__ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true}
|
||||
__SERVICES_API__=${SERVICES_API:=https://mempool.space/api/v1/services}
|
||||
__PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false}
|
||||
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
|
||||
__ADDITIONAL_CURRENCIES__=${ADDITIONAL_CURRENCIES:=false}
|
||||
|
||||
# Export as environment variables to be used by envsubst
|
||||
export __MAINNET_ENABLED__
|
||||
export __TESTNET_ENABLED__
|
||||
export __TESTNET4_ENABLED__
|
||||
export __SIGNET_ENABLED__
|
||||
export __LIQUID_ENABLED__
|
||||
export __LIQUID_TESTNET_ENABLED__
|
||||
export __ITEMS_PER_PAGE__
|
||||
export __KEEP_BLOCKS_AMOUNT__
|
||||
export __NGINX_PROTOCOL__
|
||||
export __NGINX_HOSTNAME__
|
||||
export __NGINX_PORT__
|
||||
export __BLOCK_WEIGHT_UNITS__
|
||||
export __MEMPOOL_BLOCKS_AMOUNT__
|
||||
export __BASE_MODULE__
|
||||
export __ROOT_NETWORK__
|
||||
export __MEMPOOL_WEBSITE_URL__
|
||||
export __LIQUID_WEBSITE_URL__
|
||||
export __MINING_DASHBOARD__
|
||||
export __LIGHTNING__
|
||||
export __AUDIT__
|
||||
export __MAINNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __TESTNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __SIGNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __ACCELERATOR__
|
||||
export __ACCELERATOR_BUTTON__
|
||||
export __SERVICES_API__
|
||||
export __PUBLIC_ACCELERATIONS__
|
||||
export __HISTORICAL_PRICE__
|
||||
export __ADDITIONAL_CURRENCIES__
|
||||
|
||||
folder=$(find /var/www/mempool -name "config.js" | xargs dirname)
|
||||
echo ${folder}
|
||||
envsubst < ${folder}/config.template.js > ${folder}/config.js
|
||||
|
||||
exec "$@"
|
||||
set -eu
|
||||
# Preserve the pinned upstream entrypoint (including new runtime options).
|
||||
# Apply our DNS repair only after it has rendered the nginx configuration.
|
||||
exec /patch/upstream-entrypoint.sh /patch/start-nginx.sh "$@"
|
||||
|
||||
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 "$@"
|
||||
@@ -3,6 +3,94 @@
|
||||
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
||||
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
||||
|
||||
## Framework incident — closed with operator acceptance
|
||||
|
||||
- **CLOSED WITH OPERATOR ACCEPTANCE (2026-09-30): Framework LND startup /
|
||||
missing Receive address / false zero balance.** Startup, native balances,
|
||||
Cashu address and source integration were verified; the operator accepted the
|
||||
remaining display check and authorized release. See the incident record for evidence.
|
||||
See [incident evidence and closure criteria](incident-framework-lnd-startup.md)
|
||||
and the repository `AGENTS.md` session-start instructions.
|
||||
|
||||
## Next release after 1.8.21 — reported 2026-09-30
|
||||
|
||||
Release status and acceptance gates: [execution checklist](next-release-20260930.md).
|
||||
|
||||
- [ ] **Release blocker: Gitea → Portainer repository integration.** Diagnose
|
||||
smart-HTTP reachability from Portainer's actual request namespace, then provide
|
||||
one declarative topology and idempotent migration for fresh installs and
|
||||
existing nodes. Preserve gate/auth boundaries, operator configuration,
|
||||
repository/key/database mounts and Portainer stacks. Cover install order,
|
||||
lifecycle/reboot/update convergence, clone/push and source-branch/Compose-file
|
||||
acceptance with a disposable integration setup. Ship in both OTA and ISO;
|
||||
a healthy Gitea root page is insufficient. Operator supplied a private handover;
|
||||
deployment addresses and credentials must not be committed.
|
||||
|
||||
- [ ] **New X250: GitWorkshop failed at 70%; slow Nginx installation.** Missing
|
||||
ISO build contexts restored on-node; package staging/smoke checks added.
|
||||
GitWorkshop dependency audit refreshed and build/HTTP recovery verified;
|
||||
Nginx was a slow successful image pull. Aggregate progress label corrected.
|
||||
Include the validated repair in the next OTA/ISO. See lifecycle evidence.
|
||||
|
||||
- [ ] **Angor indexer service in the app store**, requested after the other
|
||||
current repair/review work (2026-09-30). Follow the repository's app-development
|
||||
and packaging documentation; treat it as a headless service unless upstream
|
||||
documentation establishes a UI. Verify Bitcoin/Mempool requirements, decide
|
||||
whether an existing first-class relay meets Angor's requirements or a relay
|
||||
must be packaged with the indexer, and use the Angor logo from angor.io for its
|
||||
service icon. Official current deployment documentation located and reviewed: stock Mempool
|
||||
plus an optional strfry relay. Both headless services and the dependency guard
|
||||
are implemented; API outage/recovery and five relay lifecycle cycles passed.
|
||||
Final candidate install/lifecycle checks and signed delivery remain pending.
|
||||
Install on the development box; Bitcoin must finish syncing for indexed queries.
|
||||
|
||||
- [ ] **App lifecycle: keep installed apps visible through restart and hard
|
||||
refresh; gate embedded/browser launches on actual web and listener readiness.**
|
||||
Source repair and scoped live acceptance passed; full release gate pending.
|
||||
Includes durable inventory reconstruction,
|
||||
concurrent inventory writes, stale scan/lifecycle updates, delayed HTTP startup,
|
||||
and the app gate's post-install listener delay. See
|
||||
[app lifecycle repair evidence](app-lifecycle-repair-20260930.md).
|
||||
|
||||
- [x] Review and repair open paid-download PRs #161 and #162, refresh both
|
||||
branches from main, run independent and combined isolated suites, and verify
|
||||
rootless file permissions in disposable scratch storage. Combined result:
|
||||
1,585 passed, zero failed, four existing tests ignored. See the
|
||||
[review evidence and remaining acceptance work](pr-review-20260930.md).
|
||||
- [x] Integrate the reviewed PR branches into the next release and run funded
|
||||
candidate acceptance, including Tor-only transport and payments with change.
|
||||
Operator authorized completing the normal merge/closure workflow on
|
||||
2026-09-30. Both PRs are now merged and closed through Gitea; integrate
|
||||
local repair commits and sync git/ngit before release. The combined candidate
|
||||
is deployed on both test endpoints. Funded Tor-only purchase with change,
|
||||
confirmed refund, exact Files bytes and zero-cost repeat delivery passed.
|
||||
The updated source still needs inclusion in signed OTA/ISO artifacts.
|
||||
- [ ] Design durable recovery for an accepted payment whose response is lost.
|
||||
Preserve the truthful unconfirmed-refund warning and prevent automatic
|
||||
duplicate payment while that recovery work is outstanding.
|
||||
- [x] **ThinkPad X250 kiosk: Bitcoin version choices readable above pruning.**
|
||||
Replaced the native popup with inline radio choices. Actual Chromium 152 kiosk
|
||||
assertions and screenshot verify white-on-dark choices, selection changes and
|
||||
layout above pruning controls. Focused component tests pass. Included in the
|
||||
next-release source; published 1.8.21 artifacts remain unchanged.
|
||||
|
||||
## 1.8.21 repair and release tasks — completed 2026-09-30
|
||||
|
||||
See the [execution record](repair-release-20260929.md) for evidence and limits.
|
||||
|
||||
- [x] Fix Cashu paid-file redemption between dev and Shorty; test keyset IDs,
|
||||
mint errors, fees, and refund reporting before live validation.
|
||||
- [x] Record Framework verification and the operator's acceptance of the
|
||||
remaining display check before release.
|
||||
- [x] Replace the unavailable tx1138.com explorer default with mempool.space;
|
||||
migrate the old default with fresh consent and preserve custom/local explorers.
|
||||
- [x] Offer pruning in the Bitcoin installation version modal, using the same
|
||||
pruning settings as automatic pruning even on large disks.
|
||||
- [x] Explain Bitcoin warmup without raw RPC errors; gate LND unlock on Bitcoin
|
||||
RPC readiness and show install/start/sync waiting states with automatic recovery.
|
||||
- [x] Test the completed changes on this development box, then publish a new
|
||||
signed OTA and raw ISO release. Record any remaining verification gaps.
|
||||
|
||||
## Dev & build process (priority)
|
||||
|
||||
- Formalize the contributor workflow: releases, CI, maintainers, automated
|
||||
|
||||
@@ -765,3 +765,13 @@ Every supported app must satisfy the lifecycle contract:
|
||||
For apps with special dependencies, launch must explain dependency wait states instead of showing a dead iframe. Examples include Bitcoin sync/IBD, Lightning wallet readiness, Nostr signer bridge injection, Tailscale login/auth, and app-specific setup screens.
|
||||
|
||||
Runtime changes should be validated with focused tests first, then the release lifecycle harness on the validation host when host access is intentionally resumed.
|
||||
|
||||
### Adapters for shared services
|
||||
|
||||
A service that reuses an installed stack can declare `install_prerequisites`
|
||||
with the required component app ids and keep the runtime relationship in
|
||||
`dependencies`. This refuses an incomplete installation before creating the
|
||||
adapter instead of reporting a successful installation with no usable backend.
|
||||
For example, Angor Indexer requires `mempool-api` (shown to users as its owning
|
||||
Mempool app), shares that index and declares only an `api` interface. API-only
|
||||
interfaces belong in Services and do not generate browser launch buttons.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# App lifecycle repair — 2026-09-30
|
||||
|
||||
Status: source repairs, optimized build, new-node recovery and scoped live
|
||||
lifecycle acceptance verified. Full release gate remains pending.
|
||||
These are next-release changes. Published 1.8.21 artifacts remain unchanged.
|
||||
|
||||
## Report
|
||||
|
||||
The operator reports that restarting an app can make it disappear, and a hard
|
||||
refresh offers installation again. Newly installed apps sometimes fail to
|
||||
connect in both embedded views and browser tabs. The new X250 additionally reproduced GitWorkshop disappearing during install
|
||||
and Nginx Proxy Manager spending approximately 14 minutes at 70%. A disposable
|
||||
app on the dev box exposed a separate restart failure.
|
||||
|
||||
## Findings and repairs
|
||||
|
||||
- Quadlet removes containers during stop/restart. The scanner protected existing
|
||||
in-memory entries but did not reconstruct an absent app on a fresh daemon.
|
||||
It now synthesizes stopped entries from the durable installed set, respecting
|
||||
uninstall records, normalizing container prefixes, and preserving cached
|
||||
metadata. Absence does not establish an image version or available update.
|
||||
- Concurrent read/modify/write operations could lose installed-app records;
|
||||
in-place writes could expose truncated JSON to readers. Serialize writers,
|
||||
publish by atomic rename, and sync the file and parent directory. Legacy
|
||||
package install/uninstall success paths update the durable record too.
|
||||
- Scans and lifecycle/progress operations could replace a newer model from an
|
||||
older snapshot. Use locked mutations for lifecycle/progress, and merge scan
|
||||
results only into entries unchanged since the scan's merge snapshot.
|
||||
- Container running state and TCP accept alone did not establish HTTP readiness.
|
||||
Add explicit `ui-ready` based on bounded HTTP probes of the loopback upstream;
|
||||
reject connection failures and server errors, accept normal redirects and
|
||||
authentication challenges, and do not follow redirects or send credentials.
|
||||
Self-signed HTTPS apps are probed locally without certificate validation.
|
||||
- The app gate swept new listeners only every 60 seconds. Wake that sweep
|
||||
immediately for a ready upstream whose declared gate port is not yet claimed,
|
||||
and withhold readiness until external and Tor listener claims exist.
|
||||
- Fixed launch URLs could bypass suppressed runtime URLs. Enforce readiness in
|
||||
app cards, details, centralized embedded/browser launchers, and session frames.
|
||||
Starting/restarting clears readiness immediately. A waiting frame does not
|
||||
load an iframe and resumes when the backend reports readiness.
|
||||
|
||||
### New X250 findings
|
||||
|
||||
- The published ISO copied only `bitcoin-ui`, `lnd-ui` and `electrs-ui` build
|
||||
directories. GitWorkshop failed because `/opt/archipelago/docker/archipelago-source`
|
||||
was missing. Copy the complete docker source tree for bundled and unbundled
|
||||
ISOs, matching OTA packaging. Validate every manifest build context and
|
||||
Dockerfile in OTA staging, ISO staging and the mounted ISO smoke test.
|
||||
- After restoring the omitted contexts, GitWorkshop's retained npm audit rejected
|
||||
newly reported brace-expansion, fast-uri and ip-address vulnerabilities.
|
||||
Refresh the existing pinned dependency patch, keeping the audit enabled.
|
||||
Clean install/audit (zero advisories), type-check, 152 upstream tests and
|
||||
subpath production build pass. The image builds on the X250 and `/healthz`
|
||||
returns 200. No wallet or Bitcoin container restart was needed.
|
||||
- Nginx was receiving data, not frozen: over 1 GB read during the pull. It
|
||||
completed at 12:40:46 UTC after starting at 12:26:27; its web endpoint returns
|
||||
200. The orchestrated path previously labelled the entire download/build/start
|
||||
operation "Creating container" at 70%. Give that aggregate operation its own
|
||||
truthful label and earlier phase; no byte-level download estimate is claimed.
|
||||
- Restore install progress immediately from an already-loaded server snapshot,
|
||||
so a new store created after hard refresh does not wait for another mutation.
|
||||
- Replace the install modal's native version popup with inline radio choices.
|
||||
On this actual X250's Chromium 152 kiosk renderer, selection changes work,
|
||||
options have white text on dark backgrounds, and remain above pruning controls.
|
||||
Screenshot and browser assertions captured; no install confirmation was clicked.
|
||||
|
||||
### Restart safety
|
||||
|
||||
The disposable fixture restart at 12:38:05 UTC stopped its container, then
|
||||
`ss | kill` in runtime port cleanup sent SIGTERM to the management daemon at
|
||||
12:38:35. The daemon owned the gate listener on the same port at other addresses.
|
||||
Systemd restarted management; Bitcoin and LND container IDs/start times were
|
||||
unchanged. Remove port-owner kills and broad `pkill` patterns from restart,
|
||||
install recovery and Grafana preparation. Recovery now uses the existing
|
||||
container-ID-aware ghost reaper: absent container ownership must be established
|
||||
before a process is terminated. A real listening-socket regression checks that
|
||||
conflict cleanup preserves the host listener. App-gate manifest lookup now honors
|
||||
`ARCHIPELAGO_APPS_DIR`, matching the orchestrator's configured manifest root.
|
||||
|
||||
## Validation
|
||||
|
||||
- Full frontend suite: 139 files, 1,126 tests passed; final focused kiosk/store
|
||||
checks: nine passed. Production frontend build passed.
|
||||
- Final isolated backend suite: 1,567 passed, zero failed, four existing ignored
|
||||
tests. Optimized backend build passed and was deployed to the development node.
|
||||
- Tests cover empty runtime inventory, alias deduplication, uninstall exclusion,
|
||||
concurrent durable writes, concurrent state changes, stale scan publication,
|
||||
TCP-without-HTTP, HTTP statuses including 502/503, and gate listener claims.
|
||||
- Live disposable Node fixture delayed HTTP startup by 25 seconds. Desktop and
|
||||
mobile retained the waiting screen through hard refresh without mounting an
|
||||
iframe, then opened the exact fixture page automatically when ready.
|
||||
- Restart retained the app in both state APIs throughout and returned to ready;
|
||||
the management PID did not change. Stopping removed the Quadlet container;
|
||||
restarting management reconstructed its installed/stopped entry without a
|
||||
false update offer. Starting it again succeeded. Desktop and mobile continued
|
||||
to show the installed app after hard refresh.
|
||||
- LAN access required node authentication and returned exact fixture bytes after
|
||||
authentication. The fixture was uninstalled through the package lifecycle API;
|
||||
its temporary manifest root and service override were removed.
|
||||
- Bitcoin and LND container IDs and start times stayed unchanged through all
|
||||
scoped checks and management restarts. No wallet data was used by the fixture.
|
||||
- X250 kiosk checks also opened the repaired GitWorkshop and Nginx Proxy Manager
|
||||
pages successfully, with no failed local resource loads.
|
||||
|
||||
## Limits
|
||||
|
||||
This prevents the identified lifecycle/readiness failures; it cannot guarantee
|
||||
that an app or network never fails after a successful readiness check. Actual
|
||||
application failures must remain visible rather than being labelled successful.
|
||||
The full lifecycle/reboot release gate and funded acceptance of the reviewed
|
||||
paid-download PRs remain pending. The X250 kiosk fix has live rendering evidence.
|
||||
@@ -290,3 +290,35 @@ app:
|
||||
Validate with `scripts/validate-app-manifest.sh` and regenerate the catalog
|
||||
with `scripts/generate-app-catalog.py` (drift-checked in CI by
|
||||
`scripts/check-app-catalog-drift.py`).
|
||||
|
||||
### Persistent-state backup for runtime repairs
|
||||
|
||||
`app.backup_before_runtime_change: true` opts an app into a stopped-state snapshot
|
||||
before reconciliation changes a service’s network, ports, security settings,
|
||||
command or health configuration. Image-upgrade backup policy remains separate. The orchestrator
|
||||
archives writable persistent bind mounts under the node data directory, collapses
|
||||
nested mounts, excludes the runtime Podman socket, and preserves the previous
|
||||
Quadlet definition for rollback. Named volumes, outside-data-root state and
|
||||
symlinked mount roots fail closed rather than silently producing an incomplete
|
||||
backup. A failed snapshot resumes the original service and leaves migration
|
||||
pending. Private archives are retained under `migration-backups/`; fresh installs
|
||||
and unchanged runtime configurations do not create migration snapshots.
|
||||
|
||||
Catalog generation preserves the previously published base manifest for older
|
||||
daemons and puts opted-in network changes in a signed `manifest_variants` entry
|
||||
requiring `runtime-migration-backup-v1`. New runtimes select only variants whose
|
||||
complete requirement list they support. Supply `BASE_CATALOG` when generating
|
||||
against a different reviewed pre-migration catalog. This keeps catalog refresh
|
||||
from applying a migration before the matching OTA code is installed.
|
||||
|
||||
### Existing shared-service prerequisites
|
||||
|
||||
`app.install_prerequisites` is an optional list of existing app ids, for example
|
||||
`[mempool-api]` for a headless indexer adapter. The runtime checks their manifest
|
||||
container names before recording installation or changing any dependency. If one
|
||||
is missing, installation refuses with its owning app's title and removes the
|
||||
optimistic install tile. Runtime observation errors fail closed. This does not
|
||||
automatically install dependencies, alter Bitcoin pruning, or require a synced
|
||||
backend merely to recognize an already-installed service. Declare ongoing
|
||||
relationships separately in `dependencies`; use the app health check for actual
|
||||
API readiness. Self-dependencies and malformed ids are invalid.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# Same-node Gitea sources in Portainer
|
||||
|
||||
Status: root cause reproduced and network repair verified in disposable and actual
|
||||
production Portainer instances; final migration integration and release acceptance remain in progress.
|
||||
This change belongs to the next signed catalog, OTA and ISO. It does not modify
|
||||
published 1.8.21 artifacts.
|
||||
|
||||
## Confirmed cause
|
||||
|
||||
On the affected X250, Gitea 1.27.3 and Portainer 2.45.0 run in rootless Podman
|
||||
5.4.2, managed by user Quadlet services. Gitea publishes HTTP on loopback and the
|
||||
Archipelago app gate serves its public port. Gitea's public ROOT_URL already
|
||||
matches that gate URL.
|
||||
|
||||
Portainer had no explicit network selection and Podman selected pasta. Its
|
||||
network namespace contained the host's LAN address. A Git request to that same
|
||||
LAN address therefore reached Portainer's namespace rather than the host gate:
|
||||
connection refused before authentication. The exact smart-HTTP request from the
|
||||
host returned 200 with `application/x-git-upload-pack-advertisement`. From
|
||||
Portainer's actual namespace the LAN request was refused, while its host mapping
|
||||
returned a Git advertisement and the expected branch tip. Direct container-IP
|
||||
requests timed out. Container health and host-only HTTP checks missed the defect.
|
||||
|
||||
A disposable Portainer using `slirp4netns` successfully created a Source through
|
||||
Portainer's own API, using the original LAN clone URL. Returning that fixture to
|
||||
pasta reproduced the refusal; recreating with slirp repaired it while preserving
|
||||
its account and saved Source. Restart also passed. The requested branch tip and
|
||||
Compose file were read from that actual Portainer network namespace. No user
|
||||
stack was deployed. Deployment addresses and repository details are kept outside
|
||||
this public record.
|
||||
|
||||
## Source changes
|
||||
|
||||
- Declare Portainer's rootless `slirp4netns` mode in its manifest. No shared static
|
||||
container IP, host networking, all-interface backend publication or auth bypass.
|
||||
- Keep Gitea's loopback HTTP backend and gate port; machine Git uses Gitea's
|
||||
authentication. Remove obsolete port-3000 nginx metadata/template and the old
|
||||
best-effort installer commands which silently rewrote app.ini and falsely
|
||||
claimed success. Gitea owns first-run setup and operator configuration.
|
||||
- Gitea SSH also failed before authentication: OpenSSH logged a denied
|
||||
`chroot("/var/empty")` because the manifest dropped `SYS_CHROOT`. Add that
|
||||
specific sandbox capability and reconcile security-directive changes. A
|
||||
disposable fixture then passed SSH clone/push with host-key checking enabled.
|
||||
- Existing Quadlet reconciliation applies Network= drift. Record a durable
|
||||
pending restart before updating the unit and clear it only after a successful
|
||||
restart, so failed reloads/restarts and management interruptions retry.
|
||||
- Detect explicit rootless network-mode drift in the older Podman runtime too.
|
||||
Unspecified networks do not trigger inferred changes to unrelated apps.
|
||||
- Portainer and Gitea opt into `backup_before_runtime_change`. Before recreation, gracefully
|
||||
stop the app and archive its writable persistent bind mounts, including nested
|
||||
Compose state, once each. Runtime sockets are excluded. Save the previous
|
||||
Quadlet definition, where present. Archives live under the node data directory's
|
||||
private `migration-backups/<id>/` directory; state is never deleted. Backup
|
||||
failures resume the original service and fail the migration visibly.
|
||||
- Keep Podman API and Quadlet bind/network behavior covered by actual-manifest
|
||||
tests. Docker remains a development fallback: it now preserves bind/protocol
|
||||
declarations and rejects Podman-only networking instead of silently changing it.
|
||||
|
||||
## Operator use and diagnostics
|
||||
|
||||
Use Gitea's advertised HTTP(S) clone URL in Portainer Sources, with the Gitea
|
||||
username and token in the credential fields. On first-run Gitea setup, the public
|
||||
base URL must match the origin opened through Archipelago (including its port).
|
||||
Keep a deliberately configured HTTPS/domain origin when one exists. Do not use a
|
||||
container IP or put a token into the URL. A private repository requires repository
|
||||
read permission. A successful Source check fetches Git refs; it does not deploy
|
||||
a stack or establish that a Compose build uses a desired application revision.
|
||||
|
||||
`scripts/check-portainer-git-source.py` calls Portainer's own read-only Source
|
||||
connection test. Supply a private mode-600 JSON credential file containing
|
||||
`api_key` or `jwt`, and optionally `git: {username, password}`. Pass
|
||||
`--portainer-url`, `--repository-url` and `--credentials-file`. It does not create
|
||||
Sources or stacks and prints no credentials or raw server errors. It distinguishes
|
||||
Portainer login/API failures from Git connection refusal, timeout, DNS/TLS
|
||||
failure, HTML/login interception and repository authentication failure. TLS
|
||||
verification stays enabled and API redirects are refused.
|
||||
|
||||
## Upgrade and rollback
|
||||
|
||||
The signed catalog embeds manifests and overrides installed disk copies.
|
||||
Capability-gated manifest variants keep the previous Portainer manifest as the
|
||||
base for older daemons; only daemons supporting `runtime-migration-backup-v1`
|
||||
select the network repair. This prevents catalog refresh from triggering an
|
||||
unbacked recreation before the OTA is installed. A disk
|
||||
edit alone cannot deliver this fix. Publish the matching catalog with the tested
|
||||
runtime, then verify the generated unit, actual network mode and Source API.
|
||||
Expect a Portainer interruption while the snapshot and recreation run; duration
|
||||
depends on its saved state size.
|
||||
The Portainer routing repair does not require a Gitea configuration change.
|
||||
The separate SSH capability repair does recreate Gitea, preserving and snapshotting
|
||||
both data/config mounts first. Supported systemd drop-in overrides remain intact.
|
||||
|
||||
Keep the previous trusted catalog/runtime for rollback. Restore that catalog
|
||||
before restoring the saved `previous.container`, reloading user systemd and
|
||||
starting Portainer; otherwise reconciliation will correctly reapply the new
|
||||
manifest. The archive is a stopped-state emergency backup, not an instruction to
|
||||
roll back a live database automatically. Restore it only with Portainer stopped
|
||||
and after preserving any newer state. Do not replace Gitea data/config, keys,
|
||||
repositories or the production Portainer database with disposable test data.
|
||||
|
||||
## Validation and remaining gates
|
||||
|
||||
- Disposable X250 Portainer Source API: old mode refuses; repaired mode succeeds;
|
||||
saved account/Source survive recreation; restart succeeds.
|
||||
- Invalid Git credentials produce a repository-authentication error, distinct
|
||||
from TCP refusal. Requested branch and Compose file read from Portainer context.
|
||||
- Combined backend suite including the reviewed paid-download PRs and catalog
|
||||
rollout guard: 1,605 passed, zero failed, four existing ignored
|
||||
tests, including stopped-state archive round trips and failure preservation. Container runtime suite: 78 passed.
|
||||
Five diagnostic regression tests passed; catalog regeneration is idempotent
|
||||
and the generated catalog has zero manifest metadata drift.
|
||||
- Fresh managed Gitea and Portainer fixtures: authenticated private Source
|
||||
creation, invalid-token rejection, workstation clone/push and exact branch
|
||||
lookup from Portainer namespace passed. LFS batch/upload/download and OCI
|
||||
registry authentication/blob/manifest round trips passed. Desktop and mobile
|
||||
login/private-repository/assets/hard-refresh checks passed.
|
||||
- Still required before release: live automatic migration with the new runtime,
|
||||
snapshot/rollback verification and reversed install-order acceptance,
|
||||
lifecycle/reboot convergence, and signed-catalog delivery to the existing app.
|
||||
Record LFS/registry/SSH/browser checks and actual hardware/runtime coverage.
|
||||
|
||||
### Affected X250: production routing repair verified
|
||||
|
||||
Applied the tested rootless network setting to the actual installed Portainer
|
||||
through a persistent Quadlet drop-in, after gracefully stopping it and creating a
|
||||
private archive of its database and Compose directory. Compared the archive
|
||||
against the stopped original before changing configuration; retained the original
|
||||
unit and a rollback path. A verification helper initially compared mount list
|
||||
order rather than mount identity and safely rolled back; the corrected check
|
||||
compares sorted source/destination/write-mode tuples and passed.
|
||||
|
||||
The actual production Portainer namespace reproduced connection refusal before
|
||||
repair. After repair it received a Git smart-HTTP advertisement, fetched the
|
||||
requested branch at its current tip and read its Compose file. Repeating these
|
||||
checks after restarting the managed Portainer service passed. All original data
|
||||
and socket mounts and the loopback-only HTTP binding are retained. Gitea,
|
||||
Bitcoin and the wallet container IDs and start times were unchanged. No stack
|
||||
was deployed and no repository credential was changed.
|
||||
|
||||
This establishes the routing repair on the affected hardware. A logged-in
|
||||
production Portainer Source UI/API acceptance has not yet been recorded; the
|
||||
corresponding API checks passed on disposable instances as documented above.
|
||||
The installed-node drop-in persists through service restart/reboot but is not the
|
||||
fleet delivery mechanism. Automatic migration and signed catalog/OTA/ISO release
|
||||
validation remain pending; the source manifest declares the same network mode.
|
||||
Private deployment addresses, branch details and state archives are not committed.
|
||||
|
||||
### Managed automatic migration and archive restore
|
||||
|
||||
The new runtime candidate migrated an existing managed fixture from pasta to
|
||||
slirp without a manual unit edit. It preserved the account, saved Source and
|
||||
mount set, saved a private stopped-state archive plus the previous unit, restored
|
||||
Source API access, and cleared the pending restart marker. A management-service
|
||||
restart preserved the new container identity/start time and did not create
|
||||
another archive. The archive extracted into an isolated scratch directory and
|
||||
compared cleanly, including the database and Compose directory. Rootless archive
|
||||
ownership required scratch cleanup inside `podman unshare`; no production data
|
||||
was overwritten. Native Bitcoin and LND IDs/start times remained unchanged.
|
||||
|
||||
This optimized candidate predates the final bounded backup-retry guard; that
|
||||
latest source passed the isolated 1,605-test suite and must also be exercised in
|
||||
the final release build. A fixture-only systemd start failure was then injected during a security
|
||||
directive migration. The failure retained the durable restart marker. After
|
||||
removing the injected failure, the reconciler restarted the service without a
|
||||
manual container start, restored Source API access and cleared the marker.
|
||||
Reverse install order, final-build retry-budget coverage, full reboot and
|
||||
signed delivery remain open.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Incident — 2026-09-15: Minibits Cashu claim stuck retrying an already-redeemed token
|
||||
|
||||
## Report
|
||||
|
||||
User: "The cashu server is unable to get it's tokens from nostr on
|
||||
[affected node]" — clarified as the Cashu **client wallet**
|
||||
(Minibits `@minibits.cash` Lightning-address receive flow), not a mint
|
||||
server. UI showed: *"a payment arrived but couldn't be redeemed yet (1)"*.
|
||||
|
||||
## Root cause
|
||||
|
||||
`wallet::minibits::claim_and_redeem` (`core/archipelago/src/wallet/minibits.rs`)
|
||||
polls Nostr relays for NIP-04-encrypted Cashu tokens sent to the node's
|
||||
`@minibits.cash` address, decrypts them, and redeems them at the mint. A
|
||||
token that fails to redeem is kept in `MinibitsState.pending_claims` and
|
||||
retried on the next poll — by design, so a *transient* failure (mint briefly
|
||||
down, decrypt hiccup) never drops real money.
|
||||
|
||||
But one queued claim had already been redeemed (mint error **11001 "Token
|
||||
Already Spent"** — most likely double-delivered by the relay, or redeemed
|
||||
by an earlier run before a crash lost track of it). That's a *terminal*
|
||||
condition, not a transient one: the code didn't distinguish the two, so it
|
||||
retried the same dead claim every ~6 seconds forever:
|
||||
|
||||
```
|
||||
WARN archipelago::wallet::ecash: Failed to swap proofs from mint https://mint.minibits.cash/Bitcoin:
|
||||
This ecash has already been redeemed — it can't be claimed twice.: {"code":11001,"detail":"Token Already Spent"}
|
||||
WARN archipelago::wallet::minibits: Minibits claim decrypted but failed to redeem (...); will retry next poll
|
||||
```
|
||||
|
||||
Confirmed via `sudo journalctl -u archipelago.service` on the affected node,
|
||||
and via `/var/lib/archipelago/wallet/minibits.json`, which had exactly one
|
||||
`pending_claims` entry. Each poll also unconditionally queried all three
|
||||
`CLAIM_RELAY_URLS` (`relay.minibits.cash`, `relay.damus.io`, `nos.lol`)
|
||||
instead of the primary relay only, adding needless churn and leaking the
|
||||
wallet's Nostr pubkey to two relays it didn't need to touch — `relay.damus.io`
|
||||
was additionally failing NIP-42 auth / 503ing on every poll.
|
||||
|
||||
**No funds were at risk** — an already-redeemed token has zero remaining
|
||||
value. The only symptom was a permanently stuck "couldn't be redeemed yet"
|
||||
banner and wasted relay connections.
|
||||
|
||||
### Why this had already been "fixed" once and came back
|
||||
|
||||
This exact bug (terminal-11001 handling + relay-query reduction) was fixed
|
||||
on 2026-09-09 on branch `feat/minibits-lnurl-receive` (commits `4e410d7`,
|
||||
`489995c`) and pushed to `origin`. **That branch was never merged into
|
||||
`main`.** `main` carries its own, independently-diverged rewrite of
|
||||
`minibits.rs` that never got those two hardening fixes. The affected node
|
||||
OTA'd to `1.8.16-alpha` (built from `main`) earlier on 2026-09-15, so the bug
|
||||
resurfaced on the first replayed/double-delivered claim after that update.
|
||||
|
||||
## Fix
|
||||
|
||||
Two parts:
|
||||
|
||||
### 1. Immediate unstick (affected node, operational, no code change)
|
||||
|
||||
- Backed up `/var/lib/archipelago/wallet/minibits.json`.
|
||||
- Stopped `archipelago.service`, emptied `pending_claims` (`[]`) in the
|
||||
state file, restarted the service.
|
||||
- Verified via `journalctl` that polling resumed cleanly with no further
|
||||
"already been redeemed" warnings.
|
||||
|
||||
### 2. Code fix, ported into `main`
|
||||
|
||||
- **`core/archipelago/src/wallet/mint_client.rs`**: exposed the existing
|
||||
NUT error-code-11001 translation as a public constant,
|
||||
`ALREADY_REDEEMED_MSG`, and a typed `AlreadyRedeemed` condition identified
|
||||
only by the structured mint error code. Remote text cannot impersonate it.
|
||||
- **`core/archipelago/src/wallet/minibits.rs`**:
|
||||
- Added `is_already_redeemed(&anyhow::Error) -> bool`, checking the error
|
||||
chain for the typed `AlreadyRedeemed` condition. The ecash receive path
|
||||
preserves it only when all failed mint entries report already-spent proofs;
|
||||
mixed terminal/transient failures remain retryable.
|
||||
- In the claim redeem loop, a redeem failure matching
|
||||
`is_already_redeemed` is now dropped (logged at `info!`, not retried)
|
||||
instead of being pushed back onto `pending_claims`. Every other failure
|
||||
still retries next poll, unchanged.
|
||||
- `fetch_relay_dms` now connects to `RELAY_URL` (the Minibits relay)
|
||||
alone first via `try_connect_relay`, and only adds the two public
|
||||
fallback relays (`relay.damus.io`, `nos.lol`) if that primary relay is
|
||||
unreachable. Also paginates the DM fetch (200/page, capped at 5 pages)
|
||||
backward with an inclusive `until` boundary. The cursor persists across
|
||||
polls when capped or interrupted, independently of the forward watermark.
|
||||
A full same-second boundary is fetched with a larger limit rather than
|
||||
skipped, so multiple payments sharing a timestamp remain reachable.
|
||||
|
||||
Deliberately **not** ported from the unmerged branch: its `STATE_LOCK`
|
||||
skip-if-busy guard and per-claim attempt-count backstop. `main`'s existing
|
||||
`MINIBITS_STATE_LOCK` already fully serializes claim polls (blocks rather
|
||||
than skips — a different but equally valid way to close the same race), and
|
||||
an attempt-count backstop would have required reshaping the `PendingClaim`
|
||||
enum for marginal extra protection beyond what the 11001 fix already covers.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo build -p archipelago` — clean, no new warnings.
|
||||
- `cargo test -p archipelago --bin archipelago wallet::minibits` — existing
|
||||
suite still green (see PR/commit for the run).
|
||||
- Live on the affected node: claim poll loop confirmed quiet post-unstick
|
||||
(only `relay.minibits.cash` connects logged, no redeem-failure warnings).
|
||||
|
||||
## Lesson (recorded in memory)
|
||||
|
||||
A fix that lives only on an unmerged feature branch is not a fix that's
|
||||
actually deployed. Before trusting a memory or changelog claim that
|
||||
something "shipped," check which branch the running/released build was
|
||||
built from (`git log <branch>..main` / `main..<branch>`) rather than
|
||||
assuming a pushed branch was merged.
|
||||
|
||||
## Pre-merge review regressions
|
||||
|
||||
- A 450-event newest-first backlog is completely fetched.
|
||||
- 250 distinct payments sharing one timestamp are preserved.
|
||||
- A 1,300-event backlog resumes after the five-page cap and a state reload.
|
||||
- An interrupted relay fetch retains its unfinished cursor.
|
||||
- Only structured error 11001 is terminal, including when errors are wrapped;
|
||||
remote free text and mixed mint failures cannot discard a retryable claim.
|
||||
@@ -0,0 +1,405 @@
|
||||
# Framework: LND startup, missing Receive address, false zero balance
|
||||
|
||||
**Status: CLOSED WITH OPERATOR ACCEPTANCE — startup, native balances, Cashu address and source integration verified; user accepted the remaining display check and authorized release on 2026-09-30.**
|
||||
|
||||
Reported: 2026-09-15. Source inspected: main at `3b9b74da` (v1.8.17-alpha publication).
|
||||
The Framework's installed version and exact incident time have not been verified.
|
||||
|
||||
## Mandatory priority across sessions
|
||||
|
||||
The user explicitly requested that this be investigated and fixed on the node
|
||||
before resuming unrelated work in later sessions. `AGENTS.md` in the repository
|
||||
and `/home/archipelago/.codex/AGENTS.md` carry this session-start priority.
|
||||
Only live verification below, or an explicit user change of priority, clears it.
|
||||
|
||||
## Reported observations
|
||||
|
||||
- Framework stopped showing its Lightning address in Receive.
|
||||
- After a restart, LND did not initialize and the UI displayed a balance of zero.
|
||||
- Manually restarting LND restored operation.
|
||||
- Node access will be supplied later. No Framework connection, restart, wallet
|
||||
operation, or deployment was performed during this offline investigation.
|
||||
- Still clarify whether the restart was a full reboot or management-service
|
||||
restart, and which Receive item vanished: a Lightning invoice, an on-chain
|
||||
address, or the Cashu tab's `@minibits.cash` address.
|
||||
|
||||
A successful manual restart is a workaround, not a root cause or durable fix.
|
||||
The zero display does not establish that any funds were lost. Its relation to
|
||||
v1.8.17-alpha is unknown; do not infer a release regression from timing alone.
|
||||
|
||||
## Confirmed source findings
|
||||
|
||||
### 1. LND errors can be presented as successful zero balances
|
||||
|
||||
`core/archipelago/src/api/rpc/lnd/info.rs`, `handle_lnd_getinfo`:
|
||||
|
||||
- `/v1/getinfo` is decoded without checking HTTP success. Its response fields are
|
||||
optional, so an error object such as `{"code":14,"message":"wallet not ready"}`
|
||||
can deserialize with every expected field absent instead of rejecting the call.
|
||||
- Channel and blockchain balance requests suppress connection/JSON failures and
|
||||
substitute responses with absent balances. HTTP status is not checked here either.
|
||||
- Missing or unparsable balances become `0` through `unwrap_or(0)`.
|
||||
- `neode-ui/src/views/Home.vue`, `loadWeb5Status`, treats this RPC response as
|
||||
success, sets the wallet connected flag, overwrites prior balances, and can
|
||||
persist the false zero in the wallet snapshot. Its existing failure handling
|
||||
preserves prior balances only when the RPC actually rejects.
|
||||
|
||||
This is a confirmed code defect and a plausible explanation for the reported
|
||||
display. It is not proof of the Framework's failure sequence.
|
||||
|
||||
Required fix: reject unsuccessful/incomplete LND balance responses or model
|
||||
availability explicitly end to end. Never translate unavailable data into a
|
||||
verified zero. Preserve known balances with a clear unavailable/stale indication;
|
||||
show an unknown state when no valid balance is known. Genuine successful zeros
|
||||
must still render as zero. Cover outage, partial failure, cold load, and recovery.
|
||||
|
||||
### 2. Startup readiness and wallet unlock need live evidence
|
||||
|
||||
- `main.rs` runs crash/container boot recovery before starting the reconciler.
|
||||
- `crash_recovery.rs` can start existing containers directly.
|
||||
- `container/prod_orchestrator.rs` runs LND post-start hooks on explicit restart
|
||||
and on normal reconciliation of already-running containers. Therefore it is
|
||||
incorrect to conclude that running containers categorically skip unlock.
|
||||
- `container/lnd.rs::ensure_wallet_initialized` checks wallet existence and
|
||||
`/v1/getinfo`, then attempts unlock. Its unlock wait budget is approximately ten
|
||||
minutes; per-request timeouts can extend elapsed time. Historical comments
|
||||
describe slow database startup and restart loops, but that is not Framework evidence.
|
||||
- `health_monitor.rs` models LND's Bitcoin dependency. Container-running state
|
||||
alone is not proof of wallet readiness, Bitcoin connectivity, or invoice readiness.
|
||||
|
||||
Investigate boot ordering, Bitcoin readiness, listener/port mapping, wallet unlock,
|
||||
mount availability, stopped markers, restart counters, and actual reconcile logs.
|
||||
|
||||
### 3. Destructive automatic recovery exists; exclude it from diagnosis
|
||||
|
||||
`container/lnd.rs::ensure_wallet_initialized` calls
|
||||
`recreate_wallet_destructively` when all candidate passwords are rejected. That
|
||||
function can delete the LND chain and graph data directories. Its comment assumes
|
||||
alpha wallets hold no real funds; that assumption must not guide this investigation.
|
||||
|
||||
No evidence establishes that it ran on Framework. Preserve the original wallet
|
||||
and channels; rejected passwords must lead to a recoverable error, not automatic
|
||||
wallet deletion. Review and disable this destructive fallback before using a
|
||||
modified initialization path as a repair. The existing
|
||||
`unlock_existing_wallet_no_wipe` demonstrates the non-destructive error behavior.
|
||||
|
||||
### 4. The missing address must be identified precisely
|
||||
|
||||
`ReceiveBitcoinModal.vue` generates Lightning invoices using `lnd.createinvoice`
|
||||
after a readiness check, and Bitcoin addresses using `lnd.newaddress`. Its Cashu
|
||||
Lightning address uses `wallet.ecash-lnaddress` and the Minibits service separately.
|
||||
Do not assume the Minibits address disappears because LND is down. Trace the actual
|
||||
tab and response once the user clarifies and the node can be inspected.
|
||||
|
||||
## Next session: live investigation order
|
||||
|
||||
1. Request Framework access and verify node identity without publishing its hostname,
|
||||
address, credentials, or wallet identifiers. Do not substitute the development box.
|
||||
2. Record installed backend/image versions, boot and incident timestamps, and exact
|
||||
restart/action sequence. Capture current and previous-boot management/LND logs
|
||||
before another restart can obscure evidence. Keep raw logs private and redact
|
||||
secrets, invoices, wallet identifiers, and personally identifying data in summaries.
|
||||
3. Read container/service state, restart counters, mounts, stopped markers, listener
|
||||
mappings, Bitcoin readiness, LND wallet state, and authenticated API results.
|
||||
Never dump container environments, macaroons, passwords, seeds, or wallet databases.
|
||||
4. Compare HTTP status and data from LND getinfo/balance endpoints with the RPC and
|
||||
visible Receive/balance state. Distinguish unavailable data, locked wallet,
|
||||
syncing wallet, and genuine zero. Preserve last-known balance evidence privately.
|
||||
5. Establish whether the manual restart ran a missing/failed hook, waited out a
|
||||
dependency, refreshed networking/credentials, or masked another failure.
|
||||
6. Implement the evidenced startup repair and unavailable-balance handling with
|
||||
regressions. Preserve wallet/channel state and arrange recovery access before
|
||||
deploying or deliberately rebooting the node.
|
||||
|
||||
## Acceptance criteria — all required to close
|
||||
|
||||
- [x] Root cause of Framework startup failure supported by node evidence.
|
||||
- [x] Fix implemented and focused regression tests pass.
|
||||
- [ ] Failed, locked, delayed, and partial LND responses never masquerade as a
|
||||
fresh zero balance; genuine zero remains correct.
|
||||
- [x] Existing wallet identity and channel state preserved through the repair.
|
||||
- [x] Framework starts LND and reaches usable wallet readiness after a controlled
|
||||
full reboot, without manually restarting LND.
|
||||
- [ ] The originally affected Receive flow works after boot and after recovery;
|
||||
outages show an actionable state and recover without requiring a page reload.
|
||||
- [ ] Display confirmation pending; authenticated LND balances match pre-reboot values.
|
||||
- [x] LND logs show no restart loop, repeated unlock failure, or wallet-recreation path.
|
||||
- [ ] Evidence, tested versions, deployment, and limitations recorded here; user
|
||||
informed of live results. Only then set status RESOLVED and clear the blockers.
|
||||
|
||||
## Work completed so far
|
||||
|
||||
2026-09-15: source investigation and persistent session-start instructions only.
|
||||
No code fix, release, node deployment, or live reproduction for this incident yet.
|
||||
|
||||
## Live evidence captured 2026-09-15
|
||||
|
||||
Access was provided during the same session. Read-only inspection confirmed:
|
||||
|
||||
- Framework runs `1.8.17-alpha-dev`; the current full boot began at 18:40:09 UTC.
|
||||
- LND opened its databases in 6.7 seconds and requested its wallet password at
|
||||
18:40:20. It then rejected GetInfo/ChannelBalance/WalletBalance as wallet locked.
|
||||
- The management service's first sequential reconcile pass was occupied by
|
||||
unrelated image recovery, including a missing voice image from 18:40:24 and
|
||||
later a missing Core Lightning image. Manifests are iterated from a HashMap;
|
||||
wallet readiness has no initial priority. Boot recovery itself completed at
|
||||
18:40:18; the first full app-reconcile report appeared at 18:44:34.
|
||||
- The user's manual LND restart was recorded at 18:42:33. The replacement LND
|
||||
process started at 18:42:40, requested its password at 18:43:05, and unlocked
|
||||
at 18:43:07 through the explicit restart hook. This supports delayed unlock
|
||||
behind unrelated recovery, rather than a missing wallet or bad password.
|
||||
- At inspection, `/v1/state` reports SERVER_ACTIVE; getinfo reports chain and
|
||||
graph sync and two active channels. Both authenticated balance endpoints
|
||||
report nonzero balances. No wallet-recreation event was found in captured logs.
|
||||
- The Minibits RPC separately fails with “The ecash wallet has no seed yet”.
|
||||
`wallet/cashu_seed.json` and `wallet/minibits.json` are absent. The existing
|
||||
ecash wallet is present with proofs and an August modification timestamp.
|
||||
Do not overwrite it or generate an unrelated recovery identity. Still identify
|
||||
which Receive item the user meant before declaring this part repaired.
|
||||
|
||||
Private raw evidence: `/home/archipelago/.local/state/archy-incidents/framework-lnd-20260915/`.
|
||||
Files have mode 0600 and the directory 0700. Do not commit or publish raw logs.
|
||||
|
||||
Candidate changes on `investigate/framework-lnd-startup`:
|
||||
|
||||
- Run Bitcoin and LND reconciliation before unrelated image pulls/builds.
|
||||
- Reject failed/incomplete LND balance responses instead of manufacturing zeros.
|
||||
- Preserve known Home balances on invalid responses, visibly label unavailable
|
||||
balances, and clear the warning after a successful refresh.
|
||||
- Remove automatic destructive wallet recreation; failed unlock preserves data.
|
||||
- Add backend outage/zero/ordering regressions and UI failure/recovery coverage.
|
||||
|
||||
These changes are not yet deployed or verified through a Framework reboot.
|
||||
|
||||
### Candidate validation and staging
|
||||
|
||||
Source fix commit: `4237fb5e` on `investigate/framework-lnd-startup`.
|
||||
|
||||
- 44 focused backend tests passed (including LND errors, genuine zero, startup ordering).
|
||||
- 58 additional reconciliation/update tests passed.
|
||||
- 12 Home UI tests passed, including outage/partial response/cold-load/recovery cases.
|
||||
- Rust formatting, frontend type checking and production build passed.
|
||||
- Optimized backend build passed in 8m02s.
|
||||
- Both candidate artifacts were copied to Framework and SHA-256 matched locally.
|
||||
- Private on-node baseline and static channel backup are under
|
||||
`/var/lib/archipelago/support/framework-lnd-20260915/`, along with the previous
|
||||
backend, dashboard, and `rollback.sh`. This directory is root-only.
|
||||
- Candidate staged at `/tmp/archy-framework-candidate/`; not applied yet.
|
||||
- A timing confirmation for the maintenance restart/full reboot was requested
|
||||
because it interrupts all node services. Do not reboot while that is pending.
|
||||
- SSH works through the temporary control socket
|
||||
`/tmp/archy-framework-connection/control`. No SSH password was saved to disk.
|
||||
- The supplied SSH password did not authenticate to the dashboard. Do not guess
|
||||
additional passwords or alter dashboard authentication. Native LND diagnostics
|
||||
are authenticated using its existing local macaroon without printing it.
|
||||
|
||||
Status remains OPEN until deployment and live boot/Receive/balance verification.
|
||||
|
||||
### Authorized deployment and full reboot — 2026-09-15
|
||||
|
||||
The user answered “yes please” to applying the staged fix and rebooting. Timing
|
||||
approval is no longer pending. Applied the staged backend and dashboard after
|
||||
rechecking both checksums and rollback copies. There were no pending channel
|
||||
HTLCs at reboot. No wallet data, secrets, or recovery identities were replaced.
|
||||
|
||||
Live results:
|
||||
|
||||
- A different boot ID confirms a full reboot occurred.
|
||||
- Running backend on disk matches candidate SHA-256
|
||||
`5a354f76ebe619561eef0d318e4f41f177d04004682504d7434d632733f8e298`.
|
||||
- Management service started around 19:23:57 UTC; LND asked for its wallet
|
||||
password at 19:24:10 and logged automatic unlock at 19:24:18. No manual LND
|
||||
restart or interactive unlock was used after this reboot.
|
||||
- LND reports SERVER_ACTIVE and chain sync. Its identity and channel-point set
|
||||
are identical to the private pre-reboot baseline; both channels are active.
|
||||
- On-chain and Lightning balances exactly equal the pre-reboot values.
|
||||
- LND container and systemd restart counts are zero after recovery.
|
||||
- Public HTTP checks on the node returned 200 for the dashboard index and new
|
||||
Home bundle; their bytes match the installed candidate, including the new
|
||||
unavailable-balance notice.
|
||||
- Captured post-reboot management and LND journals in the private local evidence
|
||||
directory. Detailed before/after identity, channel, and balance records remain
|
||||
in the root-only support directory on Framework.
|
||||
|
||||
The user was asked to refresh the dashboard and confirm the originally missing
|
||||
Receive item and displayed balances. Keep OPEN until that reply is assessed;
|
||||
Minibits seed absence was a separate finding and must not be mistaken for an
|
||||
LND startup failure. Candidate is a direct node deployment, not a newly signed
|
||||
fleet release. The source branch must be integrated before a subsequent release
|
||||
can preserve this fix across the fleet.
|
||||
|
||||
### Cashu Receive follow-up
|
||||
|
||||
The user confirmed that the remaining error is specifically on the Ecash tab:
|
||||
“Lightning address unavailable — you can still paste a token below.”
|
||||
|
||||
Read-only checks confirm Framework has an encrypted node master seed, existing
|
||||
Cashu proofs, and neither `wallet/cashu_seed.json` nor `wallet/minibits.json`.
|
||||
The existing Minibits handler requires an ecash seed, but setup was available
|
||||
only through the Settings backup screen; Receive hid the actionable cause.
|
||||
|
||||
UI fix commit: `a3b64670`.
|
||||
|
||||
- Receive checks the non-secret seed status when registration fails.
|
||||
- Unseeded wallets get the existing password/TOTP/backup-passphrase-verified setup
|
||||
component directly in Receive, with import/restore controls excluded from this
|
||||
focused setup screen. Setup derives from the saved node seed when present.
|
||||
- The recovery words stay in the existing local reveal UI, are cleared on Done,
|
||||
and are never emitted to Receive. Receive retries registration after Done.
|
||||
- Seeded wallets with service outages get Retry, without offering a new identity.
|
||||
- Ten focused Receive/backup tests and the production UI build passed.
|
||||
- Deployed the dashboard change without restarting services; live HTTP index and
|
||||
setup bundle returned 200 and byte-matched the candidate.
|
||||
- Backed up original Cashu proofs to the root-only support directory as
|
||||
`ecash-before-address-setup.json`. No seed or proof mutation was performed by
|
||||
the assistant. Prior LND-fixed dashboard is also backed up there.
|
||||
|
||||
The user was asked to refresh Receive → Ecash → Set up address, authenticate in
|
||||
that node UI, and click Done. Dashboard password is required to decrypt the node
|
||||
seed; the SSH password did not authenticate to the dashboard. Do not request or
|
||||
print recovery words, bypass authentication, or create an unrelated random seed.
|
||||
After completion, verify saved seed/profile presence, registration success,
|
||||
address display, and unchanged original proofs before closing the incident.
|
||||
|
||||
### Cashu setup completed and verified — 2026-09-15
|
||||
|
||||
The user initially reported a forgotten passphrase, then said “did it now”. No
|
||||
independent-seed fallback was implemented or used. The user completed the existing
|
||||
password-verified setup themselves; the assistant did not receive recovery words.
|
||||
|
||||
Read-only node verification confirmed:
|
||||
|
||||
- `wallet/cashu_seed.json` exists, is nonempty, and records source `node-seed`.
|
||||
- `wallet/minibits.json` exists with a `@minibits.cash` address and no pending claims.
|
||||
- The original ecash wallet file is byte-for-byte unchanged from the protected
|
||||
pre-setup copy; every original proof is preserved.
|
||||
- The registered address's public LNURL-pay metadata returns HTTP 200, tag
|
||||
`payRequest`, an HTTPS callback, and a valid amount range. No invoice was paid
|
||||
and no funded payment test was performed.
|
||||
|
||||
LND automatic startup and native balances were already verified after the full
|
||||
reboot. Cashu setup and address registration are now also verified on Framework.
|
||||
Do not ask for the forgotten passphrase again or propose a replacement Cashu seed.
|
||||
|
||||
Remaining: integrate the tested source branch before the next fleet release;
|
||||
record final human confirmation of the rendered dashboard balance (native balances
|
||||
match exactly, and UI failure/recovery regressions pass). Keep this follow-up
|
||||
visible across sessions; do not rebuild/reboot/reinitialize a working wallet just
|
||||
to repeat already completed checks.
|
||||
|
||||
### Backup copy and layout — 2026-09-15
|
||||
|
||||
At the user's request, shortened the ecash backup explanations and stacked each
|
||||
card section's text and full-width action vertically. Kept the distinction
|
||||
between node-derived and separate phrases, and the warning that a newly created
|
||||
phrase covers future coins rather than existing legacy coins.
|
||||
|
||||
All 10 Receive/backup tests and the production UI build pass. Deployed the UI to
|
||||
Framework without a restart; served index and backup-component bundle match the
|
||||
build byte-for-byte. The prior UI is saved as `web-ui-before-backup-copy` in the
|
||||
protected incident directory. Source integration and final rendered dashboard
|
||||
balance confirmation remain pending as above.
|
||||
|
||||
### LNURL comment-length report — 2026-09-15
|
||||
|
||||
User reports a maximum-comment-length error in some sending wallets. Live
|
||||
Framework address metadata advertises integer `commentAllowed: 100`. The QR
|
||||
contains the address only; Archy's Receive UI does not add a comment. The
|
||||
Minibits-hosted callback returned invoices for omitted/empty comments, 100 ASCII
|
||||
characters, 101 ASCII characters, and 100 accented characters. These were unpaid
|
||||
invoice requests at the advertised minimum amount; no funds were sent.
|
||||
|
||||
The callback did not reproduce the error, including beyond its advertised limit.
|
||||
Sending-wallet validation against the advertised 100-character limit is therefore
|
||||
a hypothesis, not a confirmed root cause. Asked which wallets fail and whether
|
||||
an empty comment also fails. Need that result before selecting a code fix.
|
||||
The service controls the advertised limit; changing local Receive text or QR
|
||||
cannot raise it for other wallets.
|
||||
|
||||
### Primal Spark: automatic recipient note exceeds the address limit
|
||||
|
||||
User clarified that no comment was entered and the sender is Primal Spark.
|
||||
Checked Framework's management journal over the preceding 20 minutes: no
|
||||
comment-length errors, service active, and zero pending Minibits claims. Recent
|
||||
claim polling connected to and disconnected from the relay normally. Historical
|
||||
seed-authentication failures preceded the successful setup already documented.
|
||||
|
||||
The live address's Minibits `text/plain` description is **101 ASCII characters**,
|
||||
while `commentAllowed` is **100**. Description template (address redacted):
|
||||
`Pay to [ADDRESS] with Lightning. Receiver will receive ecash into Minibits Wallet.`
|
||||
|
||||
Primal Android source at `36939db97213e7f8eeefaa4adaf125d839fc662e`:
|
||||
- `WalletTextParserImpl.handleLnUrlText` assigns the parsed description to
|
||||
`DraftTx.noteRecipient`, including for Lightning-address input.
|
||||
- `TransactionEditor` initializes its editable recipient note from that value.
|
||||
- `SparkWalletServiceImpl` passes it untrimmed to `PrepareLnurlPayRequest.comment`.
|
||||
- Breez Spark source at `8bb38ec292a590907360c4e7f2a4134b8f09de9e`,
|
||||
`common/src/lnurl/pay.rs::validate_user_input`, rejects a comment exceeding the
|
||||
limit with the exact reported error before requesting the callback.
|
||||
|
||||
This identifies a concrete compatibility failure: the address description can
|
||||
become an automatic over-limit comment without the sender typing anything.
|
||||
The user confirmed that explicitly clearing the prefilled recipient note made
|
||||
the payment work, and supplied the same description observed in live metadata.
|
||||
This confirms the automatic-comment compatibility failure. The installed Primal
|
||||
platform/version was not captured. Node logs alone cannot show sender-side
|
||||
validation or requests to the external Minibits callback.
|
||||
|
||||
Durable upstream correction: Primal should keep receiver metadata separate from
|
||||
the sender's comment and enforce the limit on actual user comments. Minibits can
|
||||
also shorten its description or raise its advertised comment limit. Archy does
|
||||
not serve this external LNURL metadata; do not rename an existing wallet address,
|
||||
rotate its seed, or claim that a local dashboard edit fixes this sender behavior.
|
||||
|
||||
### Primal workaround confirmed by user
|
||||
|
||||
The user confirmed successful payment after removing the automatic description.
|
||||
The permanent sender-side correction is to leave the recipient comment empty by
|
||||
default and retain receiver metadata only as display text. In Primal Android,
|
||||
remove the assignment of the LNURL description to the draft recipient note in
|
||||
`WalletTextParserImpl.handleLnUrlText`; also validate explicitly entered comments
|
||||
against the endpoint's limit. No upstream change has been submitted or deployed.
|
||||
Existing Framework addresses and wallet identities remain unchanged.
|
||||
|
||||
### Can Archy shorten the current address description?
|
||||
|
||||
Inspected Minibits' public wallet client (`src/services/minibitsService.ts`,
|
||||
`updateWalletProfile`) and `WalletProfileRecord`. The supported profile update
|
||||
fields are name, lud16, and avatar; there is no exposed LNURL description or
|
||||
comment-limit setting. Its public web repository also contains no implementation
|
||||
of the LNURL metadata endpoint or description template.
|
||||
|
||||
For the existing `@minibits.cash` address, no supported client-side mechanism
|
||||
to shorten this text was found. Do not send guessed profile-update fields or
|
||||
rename the address to disguise the problem. A Minibits server change could use
|
||||
`Pay to [ADDRESS]`, well below the current limit. Controlling this metadata in
|
||||
Archy would instead require an Archy-hosted LNURL service/address and correct
|
||||
invoice metadata binding; rewriting the QR label or only proxying edited metadata
|
||||
is insufficient. No wallet/profile mutations were made during this investigation.
|
||||
|
||||
### Source integration confirmed — 2026-09-29
|
||||
|
||||
`git merge-base --is-ancestor 4237fb5e HEAD` succeeds on main at
|
||||
`540639d2`. The previously tested startup ordering, safe unlock, and unavailable
|
||||
balance fixes are integrated and included in the intervening releases. The
|
||||
earlier “source integration pending” notes above are historical, not current.
|
||||
The user reports no further Framework incidents. Requested final confirmation
|
||||
of rendered balances and Receive; do not mark closed without that response.
|
||||
|
||||
A separate startup failure was observed on the development box today when Core
|
||||
was installed against existing block data: Core made steady replay progress,
|
||||
while LND exited on its short “bitcoind start timeout”. Candidate work defers
|
||||
unlock until authenticated Bitcoin RPC answers, with dependency waiting states
|
||||
in the LND UI. This is not evidence of a new failure on Framework.
|
||||
|
||||
### Operator acceptance and release authorization — 2026-09-30
|
||||
|
||||
After being told that final rendered balance/Receive confirmation remained and
|
||||
SSH access was unavailable, the user replied: “that's fine I believe it'd fixed,
|
||||
please release”. This explicitly accepts proceeding past the remaining human
|
||||
display check. Close this incident with operator acceptance based on the earlier
|
||||
controlled reboot, preserved identity/channels/native balances, working Receive
|
||||
address/payment, source integration, and the user's report of no further issues.
|
||||
No new direct Framework inspection or on-screen verification is claimed today.
|
||||
Reopen investigation if the original startup, Receive, or false-zero symptom
|
||||
recurs; preserve the wallet and channels.
|
||||
@@ -0,0 +1,336 @@
|
||||
# Next OTA and raw ISO after 1.8.21
|
||||
|
||||
**Status: implementation and acceptance in progress; NOT ready to release.**
|
||||
|
||||
This is the consolidated execution checklist for the operator's chat requests.
|
||||
A targeted node repair is not completion of the release. Finish the remaining
|
||||
acceptance gates, preserve live wallets and app data, and publish both artifacts
|
||||
through git and ngit. No universal absence of future failures is claimed.
|
||||
|
||||
## Changes already shipped in 1.8.21 or earlier
|
||||
|
||||
Keep these fixes in the next build and include relevant regressions:
|
||||
|
||||
- Mempool image/catalog version agreement and update-button behavior.
|
||||
- Minibits integration; Framework automatic LND startup and safe unavailable
|
||||
balances. Framework incident closed with operator acceptance.
|
||||
- Shorter, single-column ecash backup messaging.
|
||||
- AIUI transparent background on desktop/mobile.
|
||||
- Cashu paid-file keyset/mint/error/refund corrections, with live purchases.
|
||||
- mempool.space explorer fallback, preserving local/custom explorer settings.
|
||||
- Bitcoin install pruning choice and matching automatic-pruning behavior.
|
||||
- Friendly Bitcoin warmup and LND install/start/sync waiting states.
|
||||
- Raw ISO publishing and upload support.
|
||||
|
||||
The Primal automatic LNURL comment problem was traced to sender behavior and
|
||||
Minibits metadata. The user accepted clearing the sender's automatic comment;
|
||||
no unsupported local metadata rewrite or wallet-identity replacement is planned.
|
||||
See the Framework incident and 1.8.21 execution records for evidence/limits.
|
||||
|
||||
## New release scope and gates
|
||||
|
||||
| Task | Implemented/verified | Remaining before release |
|
||||
| --- | --- | --- |
|
||||
| X250 Bitcoin picker | Inline choices; actual Chromium kiosk selection, readability and pruning layout passed | Include in final UI/build checks |
|
||||
| App disappearance/readiness | Durable inventory and safe lifecycle repair; delayed HTTP and desktop/mobile hard-refresh checks passed | Final lifecycle/reboot gate on candidate |
|
||||
| X250 GitWorkshop/Nginx | Missing build contexts restored, dependency/build checks and live UI passed; Nginx slow pull diagnosed; truthful progress label | Verify both artifact payloads contain all build contexts |
|
||||
| PRs 161/162 | Reviewed, repaired, merged/closed normally; combined regression suite passed | Funded Tor-only candidate purchase, retained change, refund, Files bytes and cached repeat passed; include in signed artifacts |
|
||||
| Gitea/Portainer | Root cause confirmed; source network/backup/retry/catalog changes; real X250 routing repair and restart verified; private Git, SSH, LFS, registry and browser fixture checks passed | Automatic migration, scratch restore, failed-start recovery and reverse installation order passed. Operator confirms production site works through Portainer; production host reboot also preserved network/Git/Compose access; final candidate delivery and release checks remain |
|
||||
| Angor headless store service | Implemented standard Mempool adapter and separate optional relay, official logo, headless store entries and declarative dependency guard. API security/outage/DNS tests and five relay lifecycle cycles passed | Final candidate prerequisite/install acceptance, management restart/reboot checks and signed catalog delivery; real indexing on dev waits for Bitcoin sync |
|
||||
|
||||
Durable payment receipts after a lost seller response remain a separately
|
||||
recorded design follow-up. Preserve the truthful unconfirmed-refund warning and
|
||||
prevent duplicate automatic payment; do not describe an unconfirmed refund as
|
||||
completed. See PR review for the accepted scope and coverage limits.
|
||||
|
||||
## Final release checklist
|
||||
|
||||
- [ ] Finish all new-scope implementation and specific acceptance above.
|
||||
- [x] Remove disposable fixtures and temporary test overrides; verify native
|
||||
Bitcoin/LND identity and start-state baselines remain protected.
|
||||
- [x] Commit and push completed source changes to git and ngit.
|
||||
- [ ] Run final backend/UI/regression/release gates on the final source; inspect
|
||||
skipped tests and report actual hardware/runtime coverage.
|
||||
- [ ] Prepare compatible signed app catalog; old runtimes must not apply a
|
||||
migration before they have backup/recovery support.
|
||||
- [ ] Version/changelog and OTA payload prepared, validated and signed by user.
|
||||
- [ ] Raw ISO built; payload hashes/content verified; installer boot tested.
|
||||
- [ ] User signs ISO checksums; publish OTA and ISO plus verification files on
|
||||
git and ngit; independently read back hashes and update discovery.
|
||||
- [ ] Provide LAN scp command for the new raw ISO.
|
||||
|
||||
Latest backend source verification: 1,609 passed, zero failed, four existing
|
||||
ignored tests. This is one layer of evidence, not a substitute for live gates.
|
||||
|
||||
## Angor verification — 2026-09-30
|
||||
|
||||
- Isolated backend suite: 1,606 passed, four existing ignored; container suite:
|
||||
79 passed. Frontend: 140 files / 1,130 tests passed; production build passed.
|
||||
- Disposable rootless API gateway: versioned and legacy API paths, query/body
|
||||
forwarding, transaction-only POST, method/body limits, CORS, removal of
|
||||
dashboard credentials, read-only non-root operation, truthful backend outage
|
||||
and DNS recovery after backend recreation passed. No real transaction broadcast.
|
||||
- Dedicated relay: NIP-11, signed event publish/read, invalid signature rejection
|
||||
and event/config persistence across five managed stop/start/restart cycles
|
||||
passed. Internal relay identity and start time stayed unchanged. Follow-up
|
||||
acknowledgement samples were 2–9 ms through both backend and app gate.
|
||||
- Published adapter 1.0.1 and relay 1.1.2 to the authenticated maintainer namespace.
|
||||
Anonymous registry readback succeeded. Adapter digest:
|
||||
`sha256:997be611700b55c521ad801fa92daaca2ae6951ac71407434c85eb9603f77c38`;
|
||||
relay mirror digest:
|
||||
`sha256:80444ad1304a0e504948b48ea1550c091b18b9f10757f07ce9a68fc261b8f6c1`.
|
||||
- Delivery target is the development box, as clarified by the operator. Do not
|
||||
install Angor on the separate Portainer node. Full indexer availability still
|
||||
requires the dev box's Bitcoin sync and Mempool/Electrum indexing to finish.
|
||||
- Funded PR acceptance passed after the operator funded the dev Cashu wallet
|
||||
with 16 sats. Exact net payment was 1 sat; underpayment refunded in full;
|
||||
repeat delivery cost zero. Both endpoints ran the combined candidate.
|
||||
No spent proofs were reactivated and no native Bitcoin/LND funds were moved.
|
||||
|
||||
## Development candidate and cleanup
|
||||
|
||||
The combined optimized backend and production UI are deployed on the development
|
||||
box with a private rollback copy. Native Bitcoin/LND containers were unchanged
|
||||
during deployment. The operator separately uninstalled/reinstalled Bitcoin Core
|
||||
to select an unpruned node; RPC confirmed `pruned=false`, and a separate baseline
|
||||
was recorded after that operator action. Do not compare subsequent checks with
|
||||
the pre-reinstall container start times.
|
||||
|
||||
Completed Gitea setup/private-repository and Portainer integration fixtures were
|
||||
uninstalled through the supported lifecycle and removed from installed inventory.
|
||||
Their private evidence/data were retained outside the active manifests. The old
|
||||
Cuprate UI review container was also removed. Active Angor acceptance fixtures
|
||||
must be removed on completion; the requested Angor services remain installed.
|
||||
|
||||
Funded acceptance used Tor-only peer-file transport, verified exact delivery
|
||||
bytes and compatibility response fields, and read the result back through
|
||||
FileBrowser. The original transport preference was restored, and temporary
|
||||
seller catalog entries/files and the exact buyer test document were removed.
|
||||
Financial receipt history was retained.
|
||||
|
||||
The final managed-install fixture exposed a separate Quadlet quoting defect:
|
||||
whitespace-free command arguments containing apostrophes lost those characters
|
||||
in the generated service. The renderer now quotes these arguments and
|
||||
environment values; the updated isolated backend suite passed (1,607 passed, four opt-in tests
|
||||
ignored), and the final candidate rebuild is in progress. Do not tag a release before this live regression is verified.
|
||||
|
||||
The production Portainer host subsequently rebooted after the routing repair.
|
||||
A post-boot probe from the actual Portainer namespace again verified the Git
|
||||
smart-HTTP response type, current branch ref and Compose contents. Its
|
||||
slirp4netns route and all production app containers survived. The temporary
|
||||
Portainer fixture was absent. This verifies the repaired production route
|
||||
across reboot; it does not substitute for final new-runtime delivery checks.
|
||||
|
||||
## Follow-up acceptance: app cards and Angor icon
|
||||
|
||||
- Mempool duplicate traced to `archy-mempool-web` durable inventory alias being
|
||||
restored beside the real `mempool` frontend. Shared scanner canonicalization
|
||||
fixes live and absent-container paths without deleting installed markers.
|
||||
Frontend suppresses aliases only while a canonical tile exists.
|
||||
- Readiness text names the app and condition: “Web UI not ready: Gitea”. It shares the status row,
|
||||
with full text available through its title; card actions use bottom alignment.
|
||||
- Angor uses the operator-supplied dark-mode icon with green outer corners.
|
||||
Built-in imagegen prompt: fill transparent/white corners with the existing
|
||||
flat green, preserve the black symbol, square opaque PNG, no added details.
|
||||
- Backend alias suite: 1608 passed, 4 ignored. Focused readiness/frame UI tests:
|
||||
30 passed. Production UI build passed and is live on dev. Browser checks at
|
||||
1440 and 1024 pixels verified named waiting text, bottom-aligned actions,
|
||||
equal row heights, no overflow and one Mempool card after hard refresh.
|
||||
The 390-pixel mobile icon layout also passed hard refresh. Final-source
|
||||
isolated Mempool alias regression passed after the scanner simplification.
|
||||
- Managed Angor adapter acceptance: five stop/start/restart cycles, missing
|
||||
prerequisite refusal, management restart and cleanup all passed. Both temporary
|
||||
fixtures and their network were removed. Actual dev API verification remains
|
||||
pending after removing an incomplete legacy-created adapter.
|
||||
|
||||
## Startup manifest reload race
|
||||
|
||||
Live Angor acceptance exposed a separate startup race: runtime asset bootstrap
|
||||
cleared and copied `/opt/archipelago/apps` in the background while the startup
|
||||
catalog refresh reloaded it. The daemon logged 62 loaded manifests followed by
|
||||
54 and then rejected the new disk-only app as unknown. A stable manifest snapshot
|
||||
confirmed the diagnosis: supported uninstall/reinstall produced the correct
|
||||
rootless Quadlet service with its declared port and network.
|
||||
|
||||
Runtime promotion and the legacy installer-directory repair now finish before
|
||||
orchestrator construction. The background doctor no longer changes that tree.
|
||||
The final source backend suite passed 1,608 tests (four existing opt-in tests
|
||||
ignored). Optimized build and normal-path live startup/restart verification have now passed (see final follow-up below).
|
||||
|
||||
Actual dev Angor acceptance passed managed service identity, no capabilities,
|
||||
UID 101:101, archy-net, public block height, CORS and both fee URL forms. During
|
||||
Bitcoin initial sync, the real Mempool fee API returns 503; the adapter faithfully
|
||||
returns the same status and body. Full-sync fee availability remains unverified;
|
||||
ready-backend API and failure/recovery behavior passed the isolated live fixture.
|
||||
The temporary `/run/archy-candidate-manifests` snapshot override and snapshot
|
||||
are now removed; normal startup/reload verification passed.
|
||||
|
||||
The latest complete UI suite passed 140 files / 1,132 tests. Release preflight
|
||||
passed all static, manifest, catalog, type and UI gates. The requested named
|
||||
waiting message, compact card layout and green Angor icon are deployed to dev;
|
||||
desktop 1440/1024 and mobile 390 browser checks passed after hard refresh.
|
||||
The startup-order optimized build and normal-path startup checks are complete.
|
||||
The later dashboard-address candidate is now deployed with rollback; see the
|
||||
final live follow-up below. Do not rerun the earlier deployment helper: its
|
||||
temporary override has already been removed. No new release version/tag, OTA
|
||||
or ISO has been created.
|
||||
|
||||
## Mempool and dashboard follow-up
|
||||
|
||||
- Deployed the Mempool alias and runtime-promotion-order backend to dev. Real
|
||||
server state contains one healthy `mempool`; the stale `mempool-web` record
|
||||
is gone. Real-data browser checks at 1440/390 pixels found exactly one tile
|
||||
before and after hard refresh. Bitcoin/LND identities/start times unchanged.
|
||||
- Removed the candidate manifest override. Normal management startup passed
|
||||
two full cycles with 62 manifests retained through both initial catalog
|
||||
refreshes. The next cycle hit a single readiness assertion; a subsequent
|
||||
read-only check found Angor healthy and the manifest count intact. Remaining
|
||||
repeat coverage should use bounded polling to distinguish transient request
|
||||
failures from loss of app definitions; do not report five cycles passed yet.
|
||||
- Bitcoin Core's dashboard was serving HTTP 200 on 8334 while readiness checked
|
||||
RPC 8332. Companion URL selection now takes priority over protocol sockets
|
||||
for Core/Knots and Electrum aliases, with a regression preserving allocated
|
||||
UI ports for other apps. Backend suite: 1,609 passed, four opt-in ignored.
|
||||
Optimized build is `/tmp/archy-dashboard-address-build.log`; deployment and
|
||||
live IBD verification helper: `/tmp/archy-dashboard-address-deploy.py`.
|
||||
- Phoenixd has no browser UI. Headless services now omit web-readiness messages;
|
||||
actual browser apps name their web interface rather than waiting for
|
||||
themselves. Focused 23 UI tests and production build passed; deployed to dev.
|
||||
|
||||
## Final live follow-up: all three reported readiness/display defects fixed
|
||||
|
||||
- Final optimized backend is deployed on dev. Bitcoin Core's launch address is
|
||||
`http://localhost:8334` and `ui-ready` is true during initial block download.
|
||||
Live verification recorded height 293,855 with `initialblockdownload=true`.
|
||||
Chromium at 1440 and 390 pixels opened the embedded dashboard, read a numeric
|
||||
current block height, and repeated that check after hard refresh.
|
||||
- One healthy Mempool remains in server state and in desktop/mobile My Apps
|
||||
after hard refresh. Its durable install markers were preserved.
|
||||
- Phoenixd remains a running headless service without a web launcher or false
|
||||
web-readiness message. Desktop/mobile browser checks passed. For actual web
|
||||
apps, the compact copy is “Web UI not ready: [app]”; the reason comes first so
|
||||
narrower cards do not truncate it into a misleading self-dependency.
|
||||
- Five normal management startup and managed Angor restart cycles passed
|
||||
across the two acceptance logs. The retry harness uses bounded readiness
|
||||
polling; it does not accept a running container alone as API readiness.
|
||||
Disk + catalog manifest count remained 62 across startup refreshes, replacing
|
||||
the previous 62-to-54 failure. Temporary override and snapshot are removed.
|
||||
- Final backend tests: 1,609 passed, zero failed, four existing opt-in ignored.
|
||||
Final UI tests: 140 files / 1,133 passed. Production UI build passed and is live.
|
||||
Bitcoin/LND container identities and start timestamps stayed unchanged.
|
||||
- Evidence: `/tmp/archy-dashboard-address-deploy.log`,
|
||||
`/tmp/archy-bitcoin-ibd-browser.log`, `/tmp/archy-mempool-live-browser.log`,
|
||||
`/tmp/archy-service-readiness-browser.log`,
|
||||
`/tmp/archy-runtime-order-remaining-cycles.log`, and
|
||||
`/tmp/archy-readiness-final-ui-tests.log`.
|
||||
- These are live development fixes. The new signed catalog, versioned OTA and
|
||||
raw ISO still need preparation, artifact verification, signing and publication.
|
||||
|
||||
### X250 Nginx Proxy Manager tunnel repair (2026-09-30)
|
||||
|
||||
A further live report was a real startup failure, separate from the earlier slow
|
||||
image pull. An operator-specific Quadlet `web-tunnel.conf` published NPM's HTTP
|
||||
listener on tunnel port 18080. LND subsequently occupied 18080 on all addresses;
|
||||
pasta failed before NPM could start, with more than 1,400 systemd retries. The
|
||||
standard NPM manifest only publishes admin port 8081 and did not introduce this
|
||||
extra mapping. Changing the standard manifest would not repair this override.
|
||||
|
||||
The node's override now uses free tunnel-local port 18081. Its persistent nftables
|
||||
configuration redirects only HTTP arriving from the configured WireGuard peer on
|
||||
the original tunnel destination to that port. The peer/public routing is unchanged;
|
||||
the input rule accepts the translated port and retains the existing interface,
|
||||
peer and forwarding restrictions. LND's REST port and native processes were not
|
||||
changed. This deployment-specific topology must not be copied into global app
|
||||
manifests or applied indiscriminately to other nodes.
|
||||
|
||||
An abandoned certificate request also left an unreferenced database record and
|
||||
a temporary nginx challenge server for the same hostname. After backing up the
|
||||
entire NPM data directory and both configuration files, the unused failed record
|
||||
was soft-deleted and the stale challenge file archived. The referenced, valid
|
||||
certificate, proxy host, keys and user accounts were preserved.
|
||||
|
||||
Live checks: NPM admin and API HTTP 200; nginx configuration validation with no
|
||||
duplicate-host warning; public HTTP redirects to HTTPS; valid public TLS reaches
|
||||
the site's existing authentication response, matching its direct upstream. NPM
|
||||
starts with zero automatic restarts and no missing-certificate renewal error.
|
||||
Bitcoin, LND and the production site container identities/start times were
|
||||
unchanged by the port repair. Rollback copies and the data archive are retained
|
||||
in the node's private support directory. No global OTA or ISO was published by
|
||||
this repair; the remaining release gates above still apply.
|
||||
|
||||
#### Follow-up: fleet delivery and false health failures
|
||||
|
||||
A longer observation exposed a second, generic defect after the port conflict
|
||||
was repaired: the health monitor probed all published ports at `127.0.0.1`,
|
||||
including NPM's tunnel-only listeners. Every monitor interval could therefore
|
||||
restart a healthy app. The short initial restart check did not catch this.
|
||||
|
||||
The next backend now probes the actual `host_ip` from Podman; only wildcard
|
||||
addresses map to the corresponding loopback family. Regression tests cover
|
||||
explicit IPv4/IPv6 binds, wildcards, UDP/unpublished/invalid entries, and a real
|
||||
listener on a different loopback address. NPM's manifest now checks its internal
|
||||
admin HTTP API. The same check is deployed as a persistent Quadlet drop-in on
|
||||
the affected node so its older backend stops making false recovery attempts.
|
||||
|
||||
The backend embeds `scripts/repair-npm-tunnel.py` and runs it before app
|
||||
reconciliation, after runtime asset promotion. This makes the targeted legacy
|
||||
port migration available to both OTA and ISO installations without relying on
|
||||
an independently installed script. Standard fresh installs are a no-op. Only
|
||||
the recognized legacy tunnel/firewall profile is migrated; unknown operator
|
||||
routing, occupied replacement ports and live-only firewall changes fail closed
|
||||
with a startup warning. Configuration backups, an interrupted-migration journal,
|
||||
atomic nft transactions and rollback protect the existing routing. Native wallet
|
||||
services and certificate databases are never modified by this fleet migration.
|
||||
|
||||
The Python migration tests run in the release gate. The unsigned next catalog
|
||||
was regenerated successfully with the new NPM HTTP health check. These changes
|
||||
are prepared for the next release; existing published OTA/ISO artifacts remain
|
||||
unchanged and the new signed artifacts still require the release gates above.
|
||||
|
||||
Verification for this follow-up: 18 migration tests passed; 43 health-monitor
|
||||
backend tests passed through the isolated runner. A disposable network-namespace
|
||||
regression exercised actual peer traffic through the nft redirect while a
|
||||
separate simulated LND listener retained port 18080. The generated rules also
|
||||
passed nft validation and atomic replacement. Run that regression with
|
||||
`sudo unshare --net python3 tests/regression/npm-tunnel-network.py`; it refuses
|
||||
to run in the host network namespace. The migration is a verified no-op on the
|
||||
already repaired node and on a standard development install without the override.
|
||||
|
||||
After deploying the API health check, a 270-second live observation crossed
|
||||
multiple health-monitor intervals: NPM stayed healthy with the same container
|
||||
ID/start time, every API probe returned success, and Bitcoin/LND/production-site
|
||||
container IDs/start times were unchanged. This supersedes the initial short
|
||||
restart-only acceptance recorded above. The generic backend fix is committed
|
||||
for release, while the live node uses the equivalent internal NPM health check.
|
||||
|
||||
### Final-gate Angor health-check correction
|
||||
|
||||
Final release observation found the adapter healthy over IPv4 but marked
|
||||
unhealthy by its in-container BusyBox wget: `localhost` resolved to `::1`, where
|
||||
nginx does not listen. Its manifest now explicitly probes `127.0.0.1`. The live
|
||||
managed service was refreshed and its real Podman health check passed. The
|
||||
rootless gateway integration now runs the manifest's health check inside the
|
||||
actual image, in addition to endpoint/security/outage/DNS recovery assertions;
|
||||
all passed. A metadata regression covers the address-family requirement. Test
|
||||
containers and their network were removed by the fixture cleanup.
|
||||
|
||||
## 1.8.22-alpha release preparation
|
||||
|
||||
Final implementation gate passed: 1,612 isolated backend tests, zero failures,
|
||||
four existing opt-in tests ignored; 140 frontend files / 1,133 tests; frontend
|
||||
type check and production build; static/catalog/trust/build-context checks.
|
||||
The four exclusions require external AI backends, Reticulum subprocess/live
|
||||
transport, physical RNode hardware, or creation of a live Minibits profile.
|
||||
They are not claimed as executed by the isolated suite. Existing funded
|
||||
Cashu/Minibits and Framework acceptance remains recorded above.
|
||||
|
||||
The real dev Angor stack now returns HTTP 200 fee estimates through both API
|
||||
forms; Bitcoin is still in initial sync, so full-chain completion remains an
|
||||
operational prerequisite rather than a completed test. The image-level health
|
||||
probe, outage/recovery and live native-state preservation checks passed after
|
||||
reloading the corrected manifest. Production Portainer again fetched the exact
|
||||
repository branch and Compose content from its own network namespace.
|
||||
|
||||
Version preparation is 1.8.22-alpha. No new release tag or fleet-visible update
|
||||
manifest is published by the version commit. Optimized candidate deployment,
|
||||
artifact inspection, ISO smoke/boot checks and offline signatures follow.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Paid-download PR review — 2026-09-30
|
||||
|
||||
## Scope and result
|
||||
|
||||
Reviewed both open PRs from the repository pull-request list: [#161](https://source.archipelago-foundation.org/lfg2025/archy/pulls/161)
|
||||
and [#162](https://source.archipelago-foundation.org/lfg2025/archy/pulls/162).
|
||||
Both branches were updated from main, repaired and tested independently and
|
||||
together. Their existing remote branches were advanced without rewriting the
|
||||
contributors' history. Both were subsequently merged and closed and are now
|
||||
integrated on main. Candidate live-wallet acceptance remains pending.
|
||||
The signed 1.8.21 artifacts are unchanged.
|
||||
|
||||
| Candidate | Tested commit | Isolated backend result |
|
||||
| --- | --- | --- |
|
||||
| PR #161 | `971d4777` | 1,576 passed, 0 failed, 4 existing tests ignored |
|
||||
| PR #162 | `0677924a` | 1,568 passed, 0 failed, 4 existing tests ignored |
|
||||
| Both together | `4bf4bf1a` | 1,585 passed, 0 failed, 4 existing tests ignored |
|
||||
|
||||
Both individual branches also passed production `cargo check`, with the
|
||||
repository's existing 16 warnings. The combined merge required no conflict
|
||||
resolution. Backend tests ran through `scripts/test-backend-isolated.sh` so they
|
||||
could not access host wallets, native services or production container storage.
|
||||
|
||||
## Findings and repairs
|
||||
|
||||
### #161 — payment delivery and file readability
|
||||
|
||||
- The branch conflicted with newer mint-fee, keyset-ID and truthful refund
|
||||
reporting fixes. Preserve those implementations from main; do not reintroduce
|
||||
its older unconditional “refunded” messages or duplicate keyset resolution.
|
||||
- Opening a file before charging, then reopening/reading it afterward, still
|
||||
permits a read failure after payment. Prepare the complete requested bytes
|
||||
before redemption, including ranged reads. Tests delete or alter the backing
|
||||
file during payment verification and still receive the prepared original data.
|
||||
- Empty/out-of-bounds/reversed ranges could fail after redemption, and empty
|
||||
files could underflow the range calculation. Validate ranges before charging
|
||||
and return HTTP 416 when unsatisfiable.
|
||||
- `chmod a+r` unnecessarily changed the permissions of shared paid/private
|
||||
files. Read restricted FileBrowser files through the rootless namespace while
|
||||
retaining their mode. Scope the fallback to regular files canonically inside
|
||||
FileBrowser storage, and reject unauthorized peers before reading.
|
||||
- A single-delivery flag must also prevent redirects and ambiguous transport
|
||||
retries. Payment-bearing GET and POST requests now retain the first HTTP
|
||||
response and do not retry after timeouts or disconnects that might follow
|
||||
delivery. Refused connections and normal nonpayment browsing retain the
|
||||
appropriate retry behavior.
|
||||
- Interrupted response bodies now report the outcome using the actual local
|
||||
refund result. Seller explanations are bounded, stripped of control
|
||||
characters and explicitly identified as peer text.
|
||||
- Original permission tests silently returned when run as root. Replacement
|
||||
tests inject read/payment boundary failures, exercise them under the isolated
|
||||
runner, and assert that read failures never invoke redemption.
|
||||
|
||||
### #162 — saving purchases in Files
|
||||
|
||||
- Its host-permission repair overlapped 1.8.21's authenticated Files API path.
|
||||
Review of [FileBrowser v2.63.23's resource handler](https://github.com/filebrowser/filebrowser/blob/v2.63.23/http/resource.go)
|
||||
showed that `override=false` checks for existence separately from opening
|
||||
with truncation. It does not guarantee no overwrites under concurrent saves.
|
||||
- The proposed direct path exposed the final filename before the write
|
||||
completed. Both direct and namespace paths now finish a private temporary
|
||||
file and publish it through a no-clobber hard link, retrying numbered names.
|
||||
- Plain `ln` could place a temporary file inside an existing directory instead
|
||||
of treating the destination as a collision. Use `ln -T`; existing directories
|
||||
and dangling symlinks are conflicts, never replacement targets.
|
||||
- Add filename and destination checks, unique temporary names, bounded name
|
||||
retries, synchronization before publication, and exact input-length checks.
|
||||
Truncated pipe input cannot become a completed purchased file.
|
||||
- Files storage remains optional. An unavailable copy destination does not
|
||||
undo the purchase or create a fake FileBrowser installation; the durable
|
||||
purchased-content cache remains primary.
|
||||
|
||||
## Additional verification on the development node
|
||||
|
||||
Used disposable scratch directories only, then removed them:
|
||||
|
||||
- Reproduced a FileBrowser-style rootless-owned 0640 upload. The host backend
|
||||
UID could not read it. `podman unshare cat` returned identical bytes without
|
||||
changing its 0640 mode.
|
||||
- Ran the exact namespace writer script with four concurrent writers against
|
||||
a directory owned by the container UID range. Every file had unique naming,
|
||||
exact bytes, the expected owner and mode, and no remaining temporary file.
|
||||
- Sent truncated input to the namespace writer and verified refusal, no final
|
||||
file and temporary-file cleanup.
|
||||
|
||||
The isolated tests additionally exercised 24 simultaneous direct writes,
|
||||
existing-file preservation, symlink/directory conflicts, collision exhaustion,
|
||||
root-independent permission failures, read-before-redemption ordering,
|
||||
authorization, redirects and peer disconnects.
|
||||
|
||||
Logs on the development box:
|
||||
`/tmp/archy-pr161-tests.log`, `/tmp/archy-pr162-tests.log`,
|
||||
`/tmp/archy-pr-integration-tests.log`, `/tmp/archy-pr161-check.log`,
|
||||
`/tmp/archy-pr162-check.log`, `/tmp/archy-pr-userns-scratch-test.log`.
|
||||
|
||||
## Next-release acceptance and limits
|
||||
|
||||
- Both reviewed branches are integrated on main alongside the lifecycle fixes.
|
||||
Repeat release gates against the final release commit after remaining changes.
|
||||
- Perform funded peer-to-peer acceptance on the candidate build, including a
|
||||
Tor-only purchase and a purchase requiring change, before the next release.
|
||||
The new review branches were not deployed to funded live wallets here.
|
||||
- These PRs do not implement durable payment receipts. If a seller redeems a
|
||||
payment and the connection subsequently loses the response, the buyer may
|
||||
receive an unconfirmed-refund warning. Do not represent that warning as proof
|
||||
of a refund or automatically charge the buyer again. Receipt-based recovery
|
||||
remains separate follow-up work.
|
||||
- Abrupt process termination can leave a hidden namespace temporary file;
|
||||
ordinary write failures and truncated input are tested to clean up. The final
|
||||
filename is published only after complete input, and existing files remain
|
||||
protected.
|
||||
- The separately reported X250 kiosk selector is fixed and verified on the
|
||||
actual kiosk; see the lifecycle evidence and consolidated release checklist.
|
||||
|
||||
## Authorized merge — 2026-09-30
|
||||
|
||||
The operator explicitly requested normal merged/closed PR status after review.
|
||||
Re-read both PRs and verified their heads still exactly matched the reviewed
|
||||
commits. Changes from the integration-test base to main were documentation only.
|
||||
Gitea normal merges completed and read-back confirmed `merged=true`, `state=closed`:
|
||||
|
||||
- #161: `3daea6623be3e2c7222101b8e6ac411423c7e16c`.
|
||||
- #162: `b02ba4100d922dd1b75c6a78121ef446c2159a54`.
|
||||
|
||||
Local next-release lifecycle work was integrated with main at `d69e8452`. Funded release acceptance and the documented delivery-receipt
|
||||
limitation remain as recorded above; merging does not claim a new release.
|
||||
@@ -0,0 +1,400 @@
|
||||
# Repair and release execution — 2026-09-29
|
||||
|
||||
**Status: 1.8.21 PUBLISHED — see the completion record at the end.**
|
||||
|
||||
The next release is tracked in [the current execution checklist](next-release-20260930.md).
|
||||
The dated entries below preserve the investigation history.
|
||||
|
||||
User requires all tasks completed and tested on the development box before the
|
||||
next OTA and raw ISO. Passing unit tests alone does not establish live correctness.
|
||||
|
||||
## Confirmed evidence
|
||||
|
||||
- Dev-to-Shorty 100-sat Cashu file purchases failed twice. Both sellers' and
|
||||
buyers' accepted mints match. Shorty's mint swap returned HTTP 422; both
|
||||
attempted purchases were refunded 100 sats. The old message guessed a mint
|
||||
mismatch without evidence.
|
||||
- Wallet import repaired truncated V2 keyset IDs, while paid-content redemption
|
||||
bypassed that repair. Central swap repair and protocol-level regression tests now pass.
|
||||
- Core installation on dev reused existing chain data. At 17:42 UTC it was
|
||||
advancing through block replay with no Core container restarts. At 17:49 UTC
|
||||
it had connected to peers and started transaction-index synchronization.
|
||||
- LND exited repeatedly with `bitcoind start timeout` while Core loaded. After
|
||||
Core became available LND stayed running and reported waiting for backend sync.
|
||||
- Framework source fix 4237fb5e is already an ancestor of main. Existing live
|
||||
reboot/native balance evidence is in the incident document. Final display
|
||||
confirmation remains pending.
|
||||
|
||||
## Changes under validation
|
||||
|
||||
- Cashu V4/V2 ID expansion at every swap; fee-aware underpayment rejection;
|
||||
single-mint/sat-only/cryptographic paid tokens; no false mint-mismatch or
|
||||
unconditional refund claims. Missing content checked before redemption.
|
||||
- mempool.space default; migrate old tx1138 default with fresh consent, retain
|
||||
local explorer priority and custom preferences.
|
||||
- Core/Knots optional pruning on the version modal and app detail install path;
|
||||
persist choice across runtime restarts; use identical 50,000 MiB automatic
|
||||
pruning entrypoint behavior on large and small disks.
|
||||
- Plain Bitcoin block-index startup message; defer LND wallet initialization or
|
||||
unlock until Bitcoin RPC is usable; authenticated dependency status and LND UI
|
||||
waiting states; no partial total displayed as a complete balance.
|
||||
|
||||
## Validation and release gates
|
||||
|
||||
- [x] Final backend regression suite passes (including mock mint HTTP and real
|
||||
curve signatures, v1/full-v2/truncated-v2, fees, errors, duplicate redemption).
|
||||
- [x] Initial explorer and pruning modal tests pass: 15 tests.
|
||||
- [x] Both actual manifest entrypoints tested with isolated fake bitcoind across
|
||||
6 disk/choice combinations each. No existing chain pruned for this test.
|
||||
- [x] Initial LND UI install/start/sync/recovery and invalid-balance tests pass.
|
||||
- [x] Frontend production build and relevant existing wallet tests pass (34
|
||||
focused tests, including 12 Home failure/recovery checks). Final UI suite: 1,120 passed; production build passed. Full release harness and final frontend follow-up passed.
|
||||
- [x] Fault tests and final source review complete.
|
||||
- [x] Candidate deployed with rollback to dev and Shorty; hashes verified.
|
||||
- [x] Live paid-file purchase succeeds; failed purchase/refund behavior verified.
|
||||
- [x] Live waiting/UI verified on dev; recovery covered by deterministic tests.
|
||||
- [x] Framework operator acceptance and authorization to release recorded.
|
||||
- [x] Release version/changelog, catalog/image implications, signing prepared.
|
||||
- [x] Signed OTA built, tested, published to git and ngit.
|
||||
- [x] Raw ISO built, boot-tested, signed and published; download command supplied.
|
||||
|
||||
Tests must not wipe/recreate wallets, prune the operator's existing full chain,
|
||||
or claim that arbitrary failures can never happen. Record material gaps before
|
||||
release. Signing keys remain with the user; prepare concrete artifacts first.
|
||||
|
||||
### Further startup findings
|
||||
|
||||
Live dev `/v1/state` returned `RPC_ACTIVE` while `/v1/getinfo` timed out during
|
||||
Bitcoin initial sync. Candidate startup now recognizes the already-unlocked
|
||||
state instead of repeating unlock attempts for ten minutes. The health watchdog
|
||||
also now excludes Bitcoin initial sync, warmup, unavailable/stale status and
|
||||
LND height progress from its restart criteria. A later observed `podman restart`
|
||||
was externally initiated; its precise caller has not yet been established, so
|
||||
the watchdog defect is a source finding rather than a confirmed attribution.
|
||||
|
||||
Framework SSH rejected the previously provided login on 2026-09-29. No password
|
||||
was saved and no wallet changes were attempted. The human display-confirmation
|
||||
question remains pending. Do not repeat a Framework reboot to reconfirm old work.
|
||||
|
||||
LND UI waiting-state, stale-balance, partial-failure/recovery and prompt-render
|
||||
tests pass (4 Node tests). Waiting states avoid calls to LND endpoints that block
|
||||
until sync, and prevent overlapping refreshes.
|
||||
|
||||
### Final source validation
|
||||
|
||||
The final backend suite passed: 1,548 passed, zero failed, four existing ignored
|
||||
live/hardware tests. Includes saved pruning preference, rejecting an old catalog
|
||||
that cannot honor explicit pruning, and all nine paid-Cashu protocol tests.
|
||||
Unsigned candidate catalog passes strict drift and fleet registry trust checks.
|
||||
The release gate caught a missing What's New entry; generated it from the curated
|
||||
changelog and reran the frontend gate/build. No public release has been changed.
|
||||
|
||||
At 18:23 UTC dev Bitcoin exited with status 137 and restarted; current container
|
||||
is not marked OOM-killed and no kernel/oomd record identified the cause. Bitcoin
|
||||
is replaying blocks again (height 482071 at 18:31 UTC). Installed old LND continues
|
||||
to time out while Bitcoin RPC warms up. Candidate is not deployed yet; verify its
|
||||
readiness deferral live before declaring this fixed. Do not attribute the Bitcoin
|
||||
exit to a specific actor without evidence.
|
||||
|
||||
### Doctor restart cause established and repaired
|
||||
|
||||
Full system journal identifies container-doctor at 18:23:21 UTC issuing raw
|
||||
`podman restart bitcoin-core` for an allegedly missing 8333 listener. The same
|
||||
script restarted LND at 17:57:48 and 18:23:35 UTC. The port was actually listening.
|
||||
Reproduced the original `ss | awk | grep -q` pipeline returning `0 141 0`: grep
|
||||
exits after its match, awk gets SIGPIPE, and pipefail falsely reports no listener.
|
||||
The raw restart also enforces a short stop timeout and races Quadlet cleanup.
|
||||
|
||||
The repaired check consumes the entire socket snapshot, distinguishes inspection
|
||||
failure from a missing port, and leaves containers running when inspection fails.
|
||||
Necessary restarts use their managed systemd units and shutdown timeouts; unmanaged
|
||||
Bitcoin/LND fallback receives 600/330-second grace respectively. Regression uses
|
||||
20,000 socket rows plus mocked service/container commands and passes. Thirty
|
||||
read-only checks of the actual Bitcoin listener pass. Script deployed to dev and
|
||||
Shorty with root-only rollback copies. OTA runtime payload includes scripts/.
|
||||
This evidence supersedes the earlier unknown-caller/unknown-exit attribution.
|
||||
|
||||
### Initial candidate live validation — 18:48 UTC
|
||||
|
||||
Source 0f85f588, optimized backend SHA256
|
||||
84434c495c5f8472cf6bfcb6c65e762502c74718ad88271619373335c0054bb6,
|
||||
deployed to dev and Shorty with matching hashes and rollback copies. Both
|
||||
management services restarted; wallets/channels were not reset. Old embedded
|
||||
runtime assets restored the old doctor on backend startup; updated the live
|
||||
script AND embedded runtime copy on both nodes. Final OTA will contain the new
|
||||
script directly.
|
||||
|
||||
Authenticated dev readiness transitioned from waiting_start to waiting_sync.
|
||||
Real Chromium at 1440px and 390px showed Waiting for Bitcoin to sync, an unknown
|
||||
balance, and no blocked native LND calls. Screenshot review also caught invented
|
||||
zero capacity/channel counts during waiting: corrected them and the empty-channel
|
||||
recommendation; five UI regression tests now pass.
|
||||
|
||||
Real Minibits Cashu purchase from dev to Shorty succeeded for one sat and returned
|
||||
the expected 44 bytes. A rejected one-sat underpayment was refunded exactly, and
|
||||
two cached downloads charged zero. Temporary seller files/catalog entries removed.
|
||||
The first test runner expected data_base64 while the first-purchase API returns
|
||||
data; cached responses use data_base64. Existing purchase clients only consume
|
||||
data, so a follow-up normalizes both response variants to both fields.
|
||||
|
||||
The optional Files copy failed because FileBrowser owns host paths as mapped UID
|
||||
100000. Follow-up uses its authenticated API with override=false and collision
|
||||
suffixes. A live API probe succeeded, refused overwrite with HTTP409, preserved
|
||||
original bytes, and cleaned up. New protocol tests cover folder creation, escaped
|
||||
names, collisions, authentication failure, disk-full, and unavailable service.
|
||||
Full backend suite for these follow-ups is running; do not package the earlier
|
||||
backend as final.
|
||||
|
||||
### Follow-up validation and OTA delivery check
|
||||
|
||||
Paid-response and Files API regressions passed in the full backend run: 1,552
|
||||
passed, zero failed, four existing ignored tests. Live browser waiting checks
|
||||
passed again after removing invented zero capacity and channel counts.
|
||||
|
||||
OTA inspection found that companion image :local (created by old installers and
|
||||
used on dev) bypassed both source-staleness detection and rebuilding. The earlier
|
||||
assumption that build-context detection covered these nodes was incorrect.
|
||||
Follow-up applies the existing source-mtime/stamp checks to both :local and
|
||||
:latest, preserving the existing tag and rebuilding only stale source. Existing
|
||||
image-ID comparison then restarts the UI companion onto the new image. This does
|
||||
not restart LND itself. Regression covers every companion's two local tags; final
|
||||
backend suite is running. Verify the resulting live rebuilt image before release.
|
||||
|
||||
### Test isolation finding — release remains blocked
|
||||
|
||||
The next full run passed 1,552 tests but one existing boot-loop timing test failed.
|
||||
Its output and node logs exposed an independent test defect: MockRuntime tests
|
||||
still invoked real Quadlet service operations and Podman socket recovery. These
|
||||
caused further LND/companion restarts during unrestricted unit runs. They were not
|
||||
a recurrence of the repaired doctor port check. Stopped unrestricted testing;
|
||||
LND has remained running since 19:02:46 UTC during isolated test execution.
|
||||
|
||||
New isolated runner hides live wallets, service buses, container storage and host
|
||||
process IDs, supplies a private network and temporary writable fixture paths,
|
||||
and keeps host filesystems read-only. An independent boundary probe passed.
|
||||
Test-only service helpers use a temporary Quadlet directory and simulated service
|
||||
results; mocked runtimes skip real Podman socket/network provisioning. Host file
|
||||
helpers require the isolated-runner marker and execute inside the namespace
|
||||
instead of escaping through sudo/systemd-run. Release harness and AGENTS now
|
||||
require this runner. Initial isolation trials correctly blocked host operations
|
||||
and exposed fixture permission assumptions; final runner compiles and executes
|
||||
the full suite with those fixture paths isolated. No final pass claimed yet.
|
||||
|
||||
Main dashboard candidate and AIUI build at b634f41a are now deployed on dev; served
|
||||
index SHA matches the build. Live package.versions returns bitcoinPrune=false
|
||||
for Core and Knots, preserving current automatic mode. Existing full chain stays
|
||||
unpruned. Final backend (Files/cached response/legacy UI delivery follow-ups) is
|
||||
not yet deployed; earlier 0f85f588 backend remains live on both nodes.
|
||||
|
||||
Final isolated backend run: **1,553 passed, zero failed, four existing ignored**
|
||||
in 13 seconds after compilation. Boundary probe confirms no host service buses,
|
||||
live wallet data, host process IDs, or external network. Bitcoin/LND start times
|
||||
remained unchanged during isolated execution. Production helpers are unchanged;
|
||||
the namespace-specific command behavior is compiled only into unit tests.
|
||||
Release and ISO gates now use the isolated runner.
|
||||
|
||||
### Final backend deployment and App Store follow-up — 19:36 UTC
|
||||
|
||||
Full release harness passed: static/catalog checks, frontend type-check and
|
||||
1,117 frontend tests, cargo-check, and isolated backend suite (1,553 passed,
|
||||
four existing ignored). Final optimized backend built successfully; SHA256
|
||||
16a173129672cbb40c250446ec52ba4a9bd1974cbb3a4988f90c6f3187b7a1f7.
|
||||
Deployed to dev. Legacy :local LND companion automatically rebuilt at 19:35 UTC
|
||||
and restarted onto image 702c0cd88fb5c8a561c76dabdb96c40648dd62d401c78f2e10d4318b06f02abe.
|
||||
Served UI bytes match candidate source. Native Bitcoin/LND start times unchanged.
|
||||
|
||||
Actual desktop pruning screenshot exposed horizontal overflow; moved the
|
||||
explanation below the app header. The App Store uses Marketplace.vue, a separate
|
||||
install path from Discover.vue. Its first Install button bypassed the version
|
||||
modal. The browser check therefore sent an unintended Knots install request at
|
||||
19:28 UTC. Core remained running, no Knots container was created, and the full
|
||||
chain was not pruned. Removed only the newly created Knots installed-app record
|
||||
and newly created version config; preserved root-only rollback copies.
|
||||
|
||||
Marketplace now uses the shared version/pruning modal. Added integration tests
|
||||
for both Core and Knots: no install request until confirmation, selected version
|
||||
and pruning forwarded, cancellation sends no install request. Four Marketplace
|
||||
tests pass (three new plus existing refresh check). Further browser checks block
|
||||
package.install requests at their network boundary. Final frontend rebuild and
|
||||
post-fix live checks remain pending. Final paid-file follow-up is still pending.
|
||||
|
||||
### Unsigned release candidate ready — 19:46 UTC
|
||||
|
||||
Final frontend source/build attribution: 3612458e. Production dashboard and AIUI
|
||||
builds passed. Final frontend suite: 1,120 tests across 139 files passed.
|
||||
Desktop 1280px and mobile 390px browser checks passed for the app detail pruning
|
||||
choice and App Store version modal; no horizontal overflow and no installation
|
||||
request. Screenshot review confirms readable controls and explanation. Browser
|
||||
installation requests are blocked during these selection-only checks.
|
||||
|
||||
Final backend SHA above matches both dev and Shorty. A fresh one-sat purchase
|
||||
passed on those exact binaries: correct file bytes, both response field aliases,
|
||||
exact one-sat refund on underpayment, zero-charge cached repeat, and exact Files
|
||||
copy. Temporary seller entries/files and Files test copy removed; transaction
|
||||
audit and owned cache retained. Total net transfer during the two live purchase
|
||||
rounds: two sats from dev to Shorty. Desktop/mobile LND waiting checks passed
|
||||
again on the automatically rebuilt companion. Native Bitcoin and LND stayed up.
|
||||
|
||||
Prepared, unsigned files:
|
||||
- releases/pending/v1.8.20-alpha/app-catalog.json
|
||||
- releases/pending/v1.8.20-alpha/manifest.json
|
||||
|
||||
Staged OTA backend: 64,716,656 bytes, SHA256
|
||||
16a173129672cbb40c250446ec52ba4a9bd1974cbb3a4988f90c6f3187b7a1f7.
|
||||
Frontend archive: 97,152,297 bytes, SHA256
|
||||
658b78fce0dfa20a627c987dd153b24cbac15adbde905cc6518744c637e12802.
|
||||
Artifact sizes/hashes/release notes validate. Checked actual archive: flat
|
||||
layout, readable root permissions, exact doctor/LND UI source bytes, and fresh
|
||||
AIUI attribution. Catalog has zero metadata drift and passes fleet registry trust.
|
||||
|
||||
Remaining: user-local release-root signatures, Framework's final display
|
||||
confirmation, signed publication to git/ngit, then raw ISO build/boot test/signing
|
||||
and publication. No v1.8.20 public release or tag exists yet. Four pre-existing
|
||||
hardware/live tests remain ignored. Bitcoin sync-to-ready recovery is covered
|
||||
by deterministic tests; the live node remains in initial sync. Do not describe
|
||||
these checks as proof against every possible network/payment failure.
|
||||
|
||||
### Signing and release authorization — 2026-09-30
|
||||
|
||||
Both catalog and OTA signatures verify against the pinned release root. Staged
|
||||
artifact hash/size checks and catalog drift/trust checks pass. User accepted the
|
||||
remaining Framework display check and explicitly authorized release. Publication
|
||||
and ISO build may proceed; do not regenerate the signed manifest or artifacts.
|
||||
|
||||
### Published OTA; ISO withheld after live shutdown defect — 2026-09-30
|
||||
|
||||
Signed 1.8.20 OTA/catalog published to git and ngit, with public asset hashes
|
||||
verified. Catalog rollout triggered a Bitcoin command update at 08:34 UTC.
|
||||
Although the orchestrator allowed a long stop, Quadlet's generated Podman removal
|
||||
still used its ten-second default and killed Bitcoin. Core replayed its block
|
||||
index; LND later lost its connection to the previous Bitcoin container IP.
|
||||
|
||||
Stopped the ISO build and queued boot check; any partial 1.8.20 ISO is invalid
|
||||
and must not be published. Preparing 1.8.21 to supersede the immutable signed OTA.
|
||||
Installed explicit graceful-stop systemd overrides on dev and Shorty without
|
||||
restarting native services. Candidate Quadlet fix adds per-app container, systemd,
|
||||
and command-wait budgets, including existing containers and uninstall fallback.
|
||||
Focused 43 tests pass, including actual Quadlet generator stop-before-remove order.
|
||||
Full tests, disposable slow-stop verification, build and deployment remain pending.
|
||||
|
||||
Disposable live regression passed: started an Alpine container with its legacy
|
||||
ten-second stop setting, rewrote and reloaded its Quadlet with explicit twenty-
|
||||
second graceful stop, verified the same container ID and old internal timeout
|
||||
remained running, then stopped it. Its twelve-second shutdown handler completed
|
||||
in 12.6 seconds, emitted the completion marker, and exited without SIGKILL/137.
|
||||
Fixture had no network or wallet mounts and was removed afterward.
|
||||
|
||||
Core finished index loading and resumed unpruned initial sync. LND automatically
|
||||
unlocked at 08:47 UTC. The existing backend-address cascade then performed a
|
||||
graceful LND restart at 08:57 UTC after Bitcoin reconciliation completed; LND
|
||||
automatically unlocked again and reached chain-sync waiting. No manual wallet
|
||||
unlock or restart was used for this recovery.
|
||||
|
||||
### False dependency restart exposed during monitoring — 09:08 UTC
|
||||
|
||||
The initial 1.8.21 candidate passed all 1,557 isolated backend tests and 1,120
|
||||
frontend tests. Monitoring nevertheless found another managed LND restart at
|
||||
09:08:32 while Bitcoin's container/start timestamp remained unchanged. Management
|
||||
logs explicitly attribute it to the backend-address cascade. This also makes
|
||||
the earlier 08:57 cascade suspect; it must not be described as a proven necessary
|
||||
restart. These service restarts preceded the isolated test executable, whose
|
||||
namespace boundaries remain intact.
|
||||
|
||||
The cascade trusted Started/Installed action reports. A failed runtime inspection
|
||||
followed by successful systemctl start of an already active unit can produce
|
||||
Started without changing Bitcoin. Dependency restarts now require observed
|
||||
container-ID, running-state, or start-time changes. Failed observations remain
|
||||
unknown, not absence; a known absent backend becoming running still qualifies.
|
||||
Actual exec-drift restarts are recognized even when their outer report is NoOp.
|
||||
Stopped/lifecycle-in-flight dependents remain excluded, and user stop markers
|
||||
are re-read after the potentially slow pass. Added runtime-observation and
|
||||
false-action/real-exec-drift regression cases; full isolated rerun pending.
|
||||
Stopped the first optimized build and preparing new artifacts from this correction.
|
||||
|
||||
### Final 1.8.21 artifacts and live verification — 2026-09-30
|
||||
|
||||
Source and frontend/AIUI attribution: c993d9dd. Full isolated backend suite:
|
||||
1,559 passed, zero failed, four existing hardware/live tests ignored. Frontend
|
||||
suite: 1,120 passed; final production type-check/build passed after the last
|
||||
release-note-only edit. Optimized backend built in 13m22s.
|
||||
|
||||
Staged unsigned 1.8.21 OTA manifest and artifacts:
|
||||
- Backend: 64,748,176 bytes; SHA256
|
||||
ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb.
|
||||
- Frontend archive: 97,152,546 bytes; SHA256
|
||||
6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620.
|
||||
|
||||
Artifact validator passed. Actual archive has flat paths, readable root index,
|
||||
and exact fresh AIUI, doctor and LND UI payload bytes. Exact files deployed to
|
||||
dev at 09:32 UTC and Shorty at 09:35 UTC; rollback binaries and dashboards under
|
||||
root-only /var/lib/archipelago/support/release-1821 on each node. Only management
|
||||
services restarted. Existing Bitcoin/Core-or-Knots and native LND container IDs
|
||||
and start times were preserved. Correct generated graceful-stop commands are
|
||||
present before forced removal on both nodes; temporary grace overrides removed.
|
||||
Dev systemd deadlines are 615 seconds for Bitcoin and 345 seconds for LND.
|
||||
|
||||
Desktop/mobile Lightning UI checks passed again: waiting for Bitcoin sync,
|
||||
unknown balance, no unavailable native RPC requests. Served dashboard and AIUI
|
||||
attribution bytes match the release. Native LND states: dev RPC_ACTIVE while
|
||||
Bitcoin syncs; Shorty SERVER_ACTIVE. Dev completed full reconciliation passes
|
||||
at 09:34:21 and 09:36:40 with Bitcoin/LND NoOp, and no dependency restart.
|
||||
Shorty's first full pass completed 09:36:55 with Knots/LND NoOp.
|
||||
|
||||
Final paid-file check on these exact binaries passed: fresh one-sat dev-to-Shorty
|
||||
purchase, exact one-sat refund on underpayment, identical response aliases,
|
||||
correct Files copy, and zero-charge cached repeat. Removed temporary seller
|
||||
entries/files and Files copy; retained purchase audit and owned cache. Total net
|
||||
transfer across all three live payment rounds in this repair session: three sats.
|
||||
|
||||
Remaining: finish Shorty observation and remove temporary diagnostic logging;
|
||||
user-local 1.8.21 OTA signature (existing catalog signature remains valid),
|
||||
publish git/ngit, build/boot-test/sign and publish the raw 1.8.21 ISO.
|
||||
No 1.8.21 release tag or public OTA yet. Do not publish the quarantined partial
|
||||
1.8.20 ISO. The existing 1.8.20 git/ngit release notes now explain the withheld ISO
|
||||
and pending hotfix; signed 1.8.20 assets remain immutable.
|
||||
|
||||
Shorty's second clean full pass completed at 09:38:06 UTC. Removed temporary
|
||||
diagnostic logging on both nodes and restarted only management again; native
|
||||
Bitcoin and LND IDs/start times remained unchanged, with generated stop settings
|
||||
still verified. No temporary graceful-stop overrides remain. Catalog signature
|
||||
verifies against the pinned release root; final 1.8.21 artifact validator passes.
|
||||
The candidate is ready for the user's local OTA signing ceremony.
|
||||
|
||||
### 1.8.21 publication completed — 2026-09-30
|
||||
|
||||
The operator signed the OTA manifest and subsequently the ISO checksum JSON.
|
||||
Both signatures verified against the pinned release root. The signed OTA was
|
||||
published on git/ngit, and the operator confirmed that Framework could see the
|
||||
update. Source main and the annotated `v1.8.21-alpha` tag were published.
|
||||
|
||||
Raw ISO:
|
||||
`archipelago-installer-1.8.21-alpha-unbundled-x86_64_RC1.iso`
|
||||
|
||||
- Size: 2,682,419,200 bytes.
|
||||
- SHA256: `8667b5522c476a40e29abba19df4180086191527a194a88765aa70ed527f9406`.
|
||||
- Build and ISO smoke checks passed. The mounted backend matched the staged
|
||||
OTA backend hash, and the full dashboard/AIUI tree matched the fresh build.
|
||||
- An isolated UEFI QEMU guest, with no network or host disks attached, booted to
|
||||
the installer prompt. The VM was stopped and the ISO unmounted afterward.
|
||||
This was an installer boot check, not a full installation onto hardware.
|
||||
- Uploaded the raw ISO, plain SHA256 sidecar and signed checksum JSON to the
|
||||
[1.8.21 release](https://source.archipelago-foundation.org/lfg2025/archy/releases/tag/v1.8.21-alpha).
|
||||
The stored server file hashes matched, the public ISO headers and first/last
|
||||
byte samples matched, and both public checksum files matched byte-for-byte.
|
||||
- The ngit downloader's full-ISO acquisition exceeded its fixed 30-minute
|
||||
deadline on the available connection. Published Nostr asset records using
|
||||
the already verified hashes, sizes and public URLs with the existing ngit
|
||||
signer; both repository relays acknowledged them. Ngit then accepted those
|
||||
records and final readback resolved all five release assets with the expected
|
||||
hashes and sizes. No new release-root signing was performed by the assistant.
|
||||
|
||||
Final publication evidence: `/tmp/archy-1821-finish-events.log`,
|
||||
`/tmp/archy-ngit-1821-complete-view.json`, and
|
||||
`/tmp/archy-1821-verified-asset-events.log` on the development box.
|
||||
|
||||
Subsequent review of PRs #161/#162 found additional delivery and concurrent
|
||||
file-save edge cases. Their repaired, tested branches are recorded in
|
||||
[the next-release review](pr-review-20260930.md); those changes are not in the
|
||||
signed 1.8.21 artifacts. The X250 kiosk selector report is also tracked for the
|
||||
next release. No claim of exhaustive hardware or network-failure coverage is
|
||||
made for this release.
|
||||
@@ -2607,21 +2607,14 @@ if [ -f "$SCRIPT_DIR/../../scripts/image-versions.sh" ]; then
|
||||
echo " ✅ Bundled image-versions.sh"
|
||||
fi
|
||||
|
||||
# Bundle docker UI source files for building custom UIs on first boot
|
||||
# Always bundle — these are tiny HTML/CSS files, not container images
|
||||
if true; then
|
||||
DOCKER_UI_DIR="$SCRIPT_DIR/../../docker"
|
||||
if [ -d "$DOCKER_UI_DIR" ]; then
|
||||
echo " Bundling docker UI source files..."
|
||||
mkdir -p "$ARCH_DIR/docker"
|
||||
for ui_dir in bitcoin-ui lnd-ui electrs-ui; do
|
||||
if [ -d "$DOCKER_UI_DIR/$ui_dir" ]; then
|
||||
cp -r "$DOCKER_UI_DIR/$ui_dir" "$ARCH_DIR/docker/"
|
||||
echo " ✅ Bundled $ui_dir source"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
# Build-source apps need their complete contexts even on unbundled ISOs.
|
||||
# Keep this identical to the OTA runtime payload; a per-app allowlist silently
|
||||
# omitted GitWorkshop, FIPS and Cuprate and made fresh installs fail at 70%.
|
||||
DOCKER_UI_DIR="$SCRIPT_DIR/../../docker"
|
||||
[ -d "$DOCKER_UI_DIR" ] || { echo "Missing docker build sources" >&2; exit 1; }
|
||||
mkdir -p "$ARCH_DIR/docker"
|
||||
cp -a "$DOCKER_UI_DIR/." "$ARCH_DIR/docker/"
|
||||
python3 "$SCRIPT_DIR/../../scripts/check-app-build-contexts.py" "$ARCH_DIR"
|
||||
|
||||
if [ "$UNBUNDLED" = "1" ]; then
|
||||
echo " ✅ Unbundled build ready (Tor setup included, no container images)"
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Gitea iframe proxy — strips X-Frame-Options so Gitea works in Archipelago iframe.
|
||||
# Gitea container binds to port 3001, this proxy listens on port 3000 (the public port).
|
||||
# Deployed to /etc/nginx/conf.d/gitea-iframe.conf
|
||||
server {
|
||||
listen 3000;
|
||||
server_name _;
|
||||
client_max_body_size 1G;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.13-alpha",
|
||||
"version": "1.8.22-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.13-alpha",
|
||||
"version": "1.8.22-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.13-alpha",
|
||||
"version": "1.8.22-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 948 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 24 KiB |
@@ -378,13 +378,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
@@ -644,6 +644,35 @@
|
||||
"/var/lib/archipelago/vaultwarden:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "angor-indexer",
|
||||
"title": "Angor Indexer",
|
||||
"version": "1.0.1",
|
||||
"description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1",
|
||||
"author": "Angor / Archipelago",
|
||||
"requires": [
|
||||
"Mempool API",
|
||||
"Unpruned Bitcoin"
|
||||
],
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"icon": "/assets/img/app-icons/angor-green.png",
|
||||
"repoUrl": "https://github.com/block-core/angor"
|
||||
},
|
||||
{
|
||||
"id": "angor-relay",
|
||||
"title": "Angor Relay",
|
||||
"version": "1.1.2",
|
||||
"description": "Optional dedicated Nostr relay for Angor project metadata. Separate storage and access settings keep the node’s internal relay private. Add this service’s address to Angor’s relay settings; use WSS for browser clients.",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/angor-relay:1.1.2",
|
||||
"author": "Angor / Archipelago",
|
||||
"requires": [],
|
||||
"category": "nostr",
|
||||
"tier": "optional",
|
||||
"icon": "/assets/img/app-icons/angor-green.png",
|
||||
"repoUrl": "https://github.com/hoytech/strfry"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface PackageVersionsResponse {
|
||||
pinnedVersion: string | null
|
||||
autoUpdate: boolean
|
||||
versions: CatalogVersionInfo[]
|
||||
bitcoinPrune?: boolean | null
|
||||
}
|
||||
|
||||
export interface AppGatePortStatus {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="mt-5 space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model" type="checkbox" class="accent-orange-400" />
|
||||
Prune Bitcoin to save disk space
|
||||
</label>
|
||||
<p class="text-xs text-white/50">
|
||||
Keeps about 50 GB of recent blocks, using the same settings as automatic
|
||||
pruning on smaller disks. All blocks are still downloaded and verified.
|
||||
Mempool and other apps that need the full blockchain won’t be available.
|
||||
Turning pruning off later requires downloading the blockchain again.
|
||||
</p>
|
||||
<p v-if="!model" class="text-xs text-white/50">
|
||||
Automatic pruning still applies on disks smaller than 1 TB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user