Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fdadad89d | ||
|
|
f4d3455496 | ||
|
|
227174e541 | ||
|
|
2e72b38778 | ||
|
|
0be7aee49d | ||
|
|
6d5f3ffb85 | ||
|
|
d1bc1273d4 | ||
|
|
96fb5a4f19 | ||
|
|
f91c1f33db | ||
|
|
02b840f2d1 | ||
|
|
f992780957 | ||
|
|
c82c1eee98 | ||
|
|
2992443d5d | ||
|
|
259c353147 | ||
|
|
1724ea05d1 | ||
|
|
c1e20a71ae | ||
|
|
bf56956790 | ||
|
|
2f1a3ade07 | ||
|
|
ef8254272c | ||
|
|
d50be13232 | ||
|
|
439b55a236 | ||
|
|
5ab65f7581 | ||
|
|
169bf77de6 | ||
|
|
7c4169867c | ||
|
|
acf544500f | ||
|
|
7d767c8cb0 | ||
|
|
eb3ccfa00b | ||
|
|
d69e845216 | ||
|
|
dc962c53b0 | ||
|
|
b02ba4100d | ||
|
|
3daea6623b | ||
|
|
0677924a64 | ||
|
|
971d477795 | ||
|
|
33477f284b | ||
|
|
03e38d1ca3 | ||
|
|
e5fc99d66c | ||
|
|
8b74803290 |
@@ -29,3 +29,14 @@ 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.
|
||||
|
||||
## Active release regression checklist
|
||||
|
||||
Before resuming release work, read
|
||||
`docs/post-1.8.22-regressions-20261001.md` and retain its unfinished tasks.
|
||||
The operator requested that every reported issue be tracked, fixed and tested
|
||||
before another OTA/ISO. Keep source/unit-test results separate from actual-node
|
||||
acceptance. In particular, paid-file recovery must not send another payment,
|
||||
and app cleanup must preserve wallets, persistent data and uninstall decisions.
|
||||
Do not mark the new paid-file incident resolved merely because the earlier
|
||||
Framework LND startup incident was closed.
|
||||
|
||||
+29
-1
@@ -1,6 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## v1.8.22-alpha (2026-09-30)
|
||||
|
||||
- Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.
|
||||
|
||||
- Network diagnostic failures no longer stop all apps or rebuild shared container networking.
|
||||
- Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.
|
||||
- Fixed companion dashboard builds still referencing a retired image registry.
|
||||
|
||||
- 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
+15
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,7 +22,7 @@ app:
|
||||
data_uid: "1000:1000"
|
||||
|
||||
# Snapshot state before an upgrade recreates this app with new networking.
|
||||
backup_on_network_change: true
|
||||
backup_before_runtime_change: true
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.21-alpha"
|
||||
version = "1.8.22-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.21-alpha"
|
||||
version = "1.8.22-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -74,7 +74,7 @@ impl ApiHandler {
|
||||
let invoice_hash = headers
|
||||
.get("x-invoice-hash")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("x-onchain-address")
|
||||
@@ -98,6 +98,46 @@ impl ApiHandler {
|
||||
None => false,
|
||||
};
|
||||
|
||||
// Payment settlement is verified on the seller even when no status
|
||||
// poll preceded this download (e.g. direct payment from another node).
|
||||
let requires_payment = if !owner_session && headers.contains_key("x-invoice-hash") {
|
||||
content_server::load_catalog(&config.data_dir)
|
||||
.await?
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| {
|
||||
item.id == content_id
|
||||
&& matches!(item.access, content_server::AccessControl::Paid { .. })
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if requires_payment {
|
||||
if let Some(hash) = headers.get("x-invoice-hash").and_then(|v| v.to_str().ok()) {
|
||||
if hash.len() != 64 || !hash.bytes().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid payment hash"),
|
||||
));
|
||||
}
|
||||
if let Err(error) = self
|
||||
.rpc_handler
|
||||
.settle_content_invoice(hash, content_id)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Cannot verify peer-file invoice settlement: {error:#}");
|
||||
return Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"Payment verification is temporarily unavailable. Retry the download without paying again."}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Range header for streaming support
|
||||
let range = headers
|
||||
.get("range")
|
||||
@@ -162,11 +202,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"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +289,13 @@ impl ApiHandler {
|
||||
.await
|
||||
{
|
||||
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
|
||||
crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await;
|
||||
crate::content_invoice::record_pending(
|
||||
&self.config.data_dir,
|
||||
&payment_hash,
|
||||
content_id,
|
||||
price_sats,
|
||||
)
|
||||
.await?;
|
||||
let body = serde_json::json!({
|
||||
"bolt11": bolt11,
|
||||
"payment_hash": payment_hash,
|
||||
@@ -287,26 +355,10 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
|
||||
// The hash must be one we issued for exactly this content item.
|
||||
match crate::content_invoice::lookup(payment_hash).await {
|
||||
Some((cid, _)) if cid == content_id => {}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Already paid? Otherwise ask our LND and persist the result.
|
||||
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
|
||||
if !paid {
|
||||
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
|
||||
crate::content_invoice::mark_paid(payment_hash).await;
|
||||
paid = true;
|
||||
}
|
||||
}
|
||||
let paid = self
|
||||
.rpc_handler
|
||||
.settle_content_invoice(payment_hash, content_id)
|
||||
.await?;
|
||||
|
||||
let body = serde_json::json!({ "paid": paid });
|
||||
Ok(build_response(
|
||||
@@ -367,7 +419,13 @@ impl ApiHandler {
|
||||
|
||||
match self.rpc_handler.new_onchain_address().await {
|
||||
Ok(address) if !address.is_empty() => {
|
||||
crate::content_invoice::record_pending(&address, content_id, price_sats).await;
|
||||
crate::content_invoice::record_pending(
|
||||
&self.config.data_dir,
|
||||
&address,
|
||||
content_id,
|
||||
price_sats,
|
||||
)
|
||||
.await?;
|
||||
let body = serde_json::json!({
|
||||
"address": address,
|
||||
"amount_sats": price_sats,
|
||||
@@ -417,7 +475,7 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
// The address must be one we issued for exactly this content item.
|
||||
let price = match crate::content_invoice::lookup(address).await {
|
||||
let price = match crate::content_invoice::lookup(&self.config.data_dir, address).await? {
|
||||
Some((cid, price)) if cid == content_id => price,
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
@@ -428,10 +486,11 @@ impl ApiHandler {
|
||||
}
|
||||
};
|
||||
|
||||
let mut paid = crate::content_invoice::is_paid_for(address, content_id).await;
|
||||
let mut paid =
|
||||
crate::content_invoice::is_paid_for(&self.config.data_dir, address, content_id).await;
|
||||
if !paid {
|
||||
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
|
||||
crate::content_invoice::mark_paid(address).await;
|
||||
crate::content_invoice::mark_paid(&self.config.data_dir, address).await?;
|
||||
paid = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,25 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -54,13 +73,20 @@ fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json
|
||||
})
|
||||
}
|
||||
|
||||
/// FileBrowser owns its files through a rootless UID mapping. Use its authenticated
|
||||
/// API rather than writing host paths with the backend's unrelated UID. Its
|
||||
/// override=false upload atomically refuses existing names, including races.
|
||||
// Updated clients open the persisted file through the Range-capable HTTP
|
||||
// endpoint. Avoid putting two base64 copies of a large video in a JSON reply.
|
||||
// Keep older clients compatible until both sides have upgraded.
|
||||
fn invoice_download_response(bytes: &[u8], mime: &str, cache_only: bool) -> serde_json::Value {
|
||||
if cache_only {
|
||||
serde_json::json!({ "owned": true, "mime_type": mime, "size_bytes": bytes.len() })
|
||||
} else {
|
||||
paid_content_response(bytes, mime, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// File purchases through an atomic no-clobber write in Files' own namespace.
|
||||
async fn file_purchase_in_files(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
data_dir: &std::path::Path,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
bytes: &[u8],
|
||||
@@ -72,59 +98,24 @@ async fn file_purchase_in_files(
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let mut folder_url = reqwest::Url::parse(base_url)?;
|
||||
folder_url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid Files URL"))?
|
||||
.extend(["api", "resources", folder, ""]);
|
||||
let response = client
|
||||
.get(folder_url.clone())
|
||||
.header("X-Auth", token)
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
let response = client
|
||||
.post(folder_url.clone())
|
||||
.header("X-Auth", token)
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() != reqwest::StatusCode::CONFLICT {
|
||||
response.error_for_status()?;
|
||||
}
|
||||
} else {
|
||||
response.error_for_status()?;
|
||||
}
|
||||
let base = std::path::Path::new(filename)
|
||||
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 (stem, extension) = match base.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")),
|
||||
_ => (base, String::new()),
|
||||
};
|
||||
for attempt in 1..=100 {
|
||||
let name = if attempt == 1 {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{stem} ({attempt}){extension}")
|
||||
};
|
||||
let mut url = folder_url.clone();
|
||||
url.path_segments_mut().unwrap().pop_if_empty().push(&name);
|
||||
url.query_pairs_mut().append_pair("override", "false");
|
||||
let response = client
|
||||
.post(url)
|
||||
.header("X-Auth", token)
|
||||
.body(bytes.to_vec())
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() == reqwest::StatusCode::CONFLICT {
|
||||
continue;
|
||||
}
|
||||
response.error_for_status()?;
|
||||
return Ok(format!("{folder}/{name}"));
|
||||
}
|
||||
anyhow::bail!("Too many existing copies; purchased file remains in the purchase cache")
|
||||
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 {
|
||||
@@ -623,13 +614,14 @@ 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.
|
||||
// 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
|
||||
@@ -642,7 +634,7 @@ impl RpcHandler {
|
||||
let refund =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Could not reach the peer over mesh or Tor. {refund}")
|
||||
"error": format!("The purchase could not be completed. {refund}")
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -679,7 +671,7 @@ impl RpcHandler {
|
||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||
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}). {refund}")
|
||||
"error": format!("{} {refund}", seller_error_message(status, &body))
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -693,10 +685,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
|
||||
@@ -728,28 +727,8 @@ impl RpcHandler {
|
||||
|
||||
// The durable purchased-content cache above is primary. A Files copy
|
||||
// remains optional: a stopped FileBrowser must not undo a paid download.
|
||||
let filed = async {
|
||||
let auth = self.handle_filebrowser_token().await?;
|
||||
let token = auth
|
||||
.get("token")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("FileBrowser omitted its authentication token")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
file_purchase_in_files(
|
||||
&client,
|
||||
"http://127.0.0.1:8083",
|
||||
token,
|
||||
&filename,
|
||||
&mime_type,
|
||||
&bytes,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
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!(
|
||||
@@ -902,10 +881,29 @@ impl RpcHandler {
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
if payment_hash.is_empty() || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid payment_hash"));
|
||||
}
|
||||
|
||||
let cache_only = params
|
||||
.get("cache_only")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if let Some((mime, bytes)) =
|
||||
crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await
|
||||
{
|
||||
return Ok(invoice_download_response(&bytes, &mime, cache_only));
|
||||
}
|
||||
// Older sellers only mark settlement during status polling. Always
|
||||
// perform that handshake before requesting bytes; retries never pay.
|
||||
// The download gate remains authoritative: a file may have become
|
||||
// free, and newer sellers verify directly if status polling fails.
|
||||
let _ = self
|
||||
.handle_content_invoice_status(Some(serde_json::json!({
|
||||
"onion": onion, "content_id": content_id, "payment_hash": payment_hash,
|
||||
})))
|
||||
.await;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
@@ -944,7 +942,7 @@ impl RpcHandler {
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Seller has not registered this payment yet — wait for settlement and retry."
|
||||
"error": "The seller has not confirmed access yet. Retry the download without paying again."
|
||||
}));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
@@ -953,16 +951,45 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
|
||||
let mime = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
}))
|
||||
.context("Paid file transfer interrupted; retry the download without paying again")?;
|
||||
let filename = params
|
||||
.get("filename")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(content_id);
|
||||
crate::content_owned::record_purchase(
|
||||
&self.config.data_dir,
|
||||
onion,
|
||||
content_id,
|
||||
filename,
|
||||
&mime,
|
||||
&bytes,
|
||||
params
|
||||
.get("price_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
"lightning",
|
||||
&chrono::Utc::now().to_rfc3339(),
|
||||
)
|
||||
.await
|
||||
.context("Paid file could not be saved; retry the download without paying again")?;
|
||||
if let Err(error) =
|
||||
file_purchase_in_files(&self.config.data_dir, filename, &mime, &bytes).await
|
||||
{
|
||||
tracing::warn!("Lightning purchase cached; optional Files copy failed: {error:#}");
|
||||
}
|
||||
Ok(invoice_download_response(&bytes, &mime, cache_only))
|
||||
}
|
||||
|
||||
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
|
||||
@@ -1437,3 +1464,19 @@ impl RpcHandler {
|
||||
#[cfg(test)]
|
||||
#[path = "content_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod invoice_delivery_response_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn cached_delivery_avoids_base64_but_keeps_old_clients_compatible() {
|
||||
let cached = invoice_download_response(b"paid bytes", "video/mp4", true);
|
||||
assert_eq!(cached["owned"], true);
|
||||
assert_eq!(cached["size_bytes"], 10);
|
||||
assert!(cached.get("data").is_none());
|
||||
assert!(cached.get("data_base64").is_none());
|
||||
let legacy = invoice_download_response(b"paid bytes", "video/mp4", false);
|
||||
assert_eq!(legacy["data"], "cGFpZCBieXRlcw==");
|
||||
assert_eq!(legacy["data"], legacy["data_base64"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,4 @@
|
||||
use super::*;
|
||||
use hyper::{
|
||||
service::{make_service_fn, service_fn},
|
||||
Body, Response, Server,
|
||||
};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
convert::Infallible,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
struct FilesApi {
|
||||
url: String,
|
||||
seen: Arc<Mutex<Vec<(String, String, Vec<u8>)>>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Drop for FilesApi {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
fn files_api(statuses: Vec<u16>) -> FilesApi {
|
||||
let statuses = Arc::new(Mutex::new(VecDeque::from(statuses)));
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let history = seen.clone();
|
||||
let server = Server::bind(&([127, 0, 0, 1], 0).into());
|
||||
let address = server.local_addr();
|
||||
let service = make_service_fn(move |_| {
|
||||
let statuses = statuses.clone();
|
||||
let seen = history.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
|
||||
let statuses = statuses.clone();
|
||||
let seen = seen.clone();
|
||||
async move {
|
||||
assert_eq!(request.headers().get("X-Auth").unwrap(), "test-session");
|
||||
let method = request.method().to_string();
|
||||
let uri = request.uri().to_string();
|
||||
let body = hyper::body::to_bytes(request.into_body())
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
seen.lock().unwrap().push((method, uri, body));
|
||||
let status = statuses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("unexpected extra Files request");
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
FilesApi {
|
||||
url: format!("http://{address}"),
|
||||
seen,
|
||||
task: tokio::spawn(async move {
|
||||
server.serve(service).await.unwrap();
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
||||
@@ -85,80 +20,54 @@ fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_uses_authenticated_api_and_preserves_existing_names() {
|
||||
let api = files_api(vec![200, 409, 200]);
|
||||
let client = reqwest::Client::new();
|
||||
let path = file_purchase_in_files(
|
||||
&client,
|
||||
&api.url,
|
||||
"test-session",
|
||||
"../my #file?.txt",
|
||||
"text/plain",
|
||||
b"paid bytes",
|
||||
)
|
||||
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();
|
||||
assert_eq!(path, "Documents/my #file? (2).txt");
|
||||
let seen = api.seen.lock().unwrap();
|
||||
assert_eq!(seen[0].0, "GET");
|
||||
assert_eq!(seen[0].1, "/api/resources/Documents/");
|
||||
assert_eq!(seen.len(), 3);
|
||||
for (_, uri, body) in &seen[1..] {
|
||||
assert!(uri.contains("override=false"));
|
||||
assert!(uri.contains("%23file%3F"));
|
||||
assert!(!uri.contains("../"));
|
||||
assert_eq!(body, b"paid bytes");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_creates_missing_media_folder() {
|
||||
for (mime, folder) in [
|
||||
("image/png", "Photos"),
|
||||
("video/mp4", "Photos"),
|
||||
("audio/ogg", "Music"),
|
||||
("audio/mpeg", "Music"),
|
||||
("text/plain", "Documents"),
|
||||
] {
|
||||
let api = files_api(vec![404, 200, 200]);
|
||||
let path = file_purchase_in_files(
|
||||
&reqwest::Client::new(),
|
||||
&api.url,
|
||||
"test-session",
|
||||
"file",
|
||||
mime,
|
||||
b"bytes",
|
||||
)
|
||||
let relative = file_purchase_in_files(dir.path(), "../name #?.bin", mime, b"paid")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(path, format!("{folder}/file"));
|
||||
let seen = api.seen.lock().unwrap();
|
||||
assert_eq!(seen[1].0, "POST");
|
||||
assert!(seen[1].1.ends_with('/'));
|
||||
assert!(seen[1].2.is_empty());
|
||||
assert_eq!(seen[2].2, b"bytes");
|
||||
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 files_copy_fails_without_overwriting_or_claiming_success_on_errors() {
|
||||
for statuses in [
|
||||
vec![401],
|
||||
vec![503],
|
||||
vec![404, 500],
|
||||
vec![200, 507],
|
||||
vec![200, 403],
|
||||
] {
|
||||
let expected = statuses.len();
|
||||
let api = files_api(statuses);
|
||||
assert!(file_purchase_in_files(
|
||||
&reqwest::Client::new(),
|
||||
&api.url,
|
||||
"test-session",
|
||||
"file.txt",
|
||||
"text/plain",
|
||||
b"bytes"
|
||||
)
|
||||
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_eq!(api.seen.lock().unwrap().len(), expected);
|
||||
.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)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +473,7 @@ impl RpcHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let fee_query = close_channel_fee_query(¶ms)?;
|
||||
let force = params
|
||||
.get("force")
|
||||
.and_then(|v| v.as_bool())
|
||||
@@ -498,13 +499,11 @@ impl RpcHandler {
|
||||
.build()
|
||||
.context("Failed to create streaming HTTP client")?;
|
||||
|
||||
let url = format!(
|
||||
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
||||
parts[0], parts[1], force
|
||||
);
|
||||
let url = format!("{LND_REST_BASE_URL}/v1/channels/{}/{}", parts[0], parts[1]);
|
||||
|
||||
let mut resp = client
|
||||
.delete(&url)
|
||||
.query(&fee_query)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -572,3 +571,101 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LND's CloseChannel REST endpoint takes fee selection as query parameters.
|
||||
/// With neither parameter LND uses a lax target; keep legacy clients on our
|
||||
/// explicit Standard target rather than silently accepting that default.
|
||||
fn close_channel_fee_query(params: &serde_json::Value) -> Result<Vec<(&'static str, String)>> {
|
||||
let force = match params.get("force") {
|
||||
None | Some(serde_json::Value::Null) => false,
|
||||
Some(value) => value
|
||||
.as_bool()
|
||||
.ok_or_else(|| anyhow::anyhow!("force must be a boolean"))?,
|
||||
};
|
||||
let integer = |key: &str, max: u64| -> Result<Option<u64>> {
|
||||
match params.get(key) {
|
||||
None | Some(serde_json::Value::Null) => Ok(None),
|
||||
Some(value) => {
|
||||
let n = value
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("{key} must be a positive whole number"))?;
|
||||
anyhow::ensure!((1..=max).contains(&n), "{key} must be between 1 and {max}");
|
||||
Ok(Some(n))
|
||||
}
|
||||
}
|
||||
};
|
||||
let target = integer("target_conf", 1008)?;
|
||||
let rate = integer("sat_per_vbyte", 5000)?;
|
||||
anyhow::ensure!(
|
||||
target.is_none() || rate.is_none(),
|
||||
"Specify either target_conf or sat_per_vbyte, not both"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!force || (target.is_none() && rate.is_none()),
|
||||
"Closing fee selection requires a cooperative close"
|
||||
);
|
||||
let mut query = vec![("force", force.to_string())];
|
||||
if !force {
|
||||
if let Some(rate) = rate {
|
||||
query.push(("sat_per_vbyte", rate.to_string()));
|
||||
} else {
|
||||
query.push(("target_conf", target.unwrap_or(6).to_string()));
|
||||
}
|
||||
}
|
||||
Ok(query)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod close_fee_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn close_fee_query_forwards_presets_custom_and_legacy_default() {
|
||||
for target in [1, 3, 6, 1008] {
|
||||
assert_eq!(
|
||||
close_channel_fee_query(&serde_json::json!({"target_conf":target})).unwrap(),
|
||||
vec![
|
||||
("force", "false".into()),
|
||||
("target_conf", target.to_string())
|
||||
]
|
||||
);
|
||||
}
|
||||
for rate in [1, 25, 5000] {
|
||||
let query =
|
||||
close_channel_fee_query(&serde_json::json!({"sat_per_vbyte":rate})).unwrap();
|
||||
let request = reqwest::Client::new()
|
||||
.delete("http://localhost/v1/channels/test/0")
|
||||
.query(&query)
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(request.method(), reqwest::Method::DELETE);
|
||||
assert_eq!(
|
||||
request.url().query(),
|
||||
Some(format!("force=false&sat_per_vbyte={rate}").as_str())
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
close_channel_fee_query(&serde_json::json!({})).unwrap(),
|
||||
vec![("force", "false".into()), ("target_conf", "6".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
close_channel_fee_query(&serde_json::json!({"force":true})).unwrap(),
|
||||
vec![("force", "true".into())]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_or_conflicting_close_fees_fail_before_wallet_access() {
|
||||
for params in [
|
||||
serde_json::json!({"target_conf":1,"sat_per_vbyte":2}),
|
||||
serde_json::json!({"force":true,"target_conf":1}),
|
||||
serde_json::json!({"force":"false"}),
|
||||
serde_json::json!({"target_conf":0}),
|
||||
serde_json::json!({"target_conf":1009}),
|
||||
serde_json::json!({"sat_per_vbyte":5001}),
|
||||
serde_json::json!({"sat_per_vbyte":-1}),
|
||||
serde_json::json!({"sat_per_vbyte":1.5}),
|
||||
serde_json::json!({"sat_per_vbyte":"25"}),
|
||||
] {
|
||||
assert!(close_channel_fee_query(¶ms).is_err(), "{params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,6 +453,56 @@ impl RpcHandler {
|
||||
Ok(settled)
|
||||
}
|
||||
|
||||
/// Verify against LND at download time, rather than relying on a browser
|
||||
/// having polled first. The memo/amount also recover pre-upgrade in-memory
|
||||
/// entitlements after restart; unrelated invoices never unlock a file.
|
||||
pub(crate) async fn settle_content_invoice(
|
||||
&self,
|
||||
hash: &str,
|
||||
content_id: &str,
|
||||
) -> Result<bool> {
|
||||
anyhow::ensure!(
|
||||
hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit()),
|
||||
"Invalid payment hash"
|
||||
);
|
||||
let hash = hash.to_ascii_lowercase();
|
||||
let existing = crate::content_invoice::lookup(&self.config.data_dir, &hash).await?;
|
||||
if let Some((id, _)) = &existing {
|
||||
if id != content_id {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
if crate::content_invoice::is_paid_for(&self.config.data_dir, &hash, content_id).await {
|
||||
return Ok(true);
|
||||
}
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let response = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{hash}"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Ok(false);
|
||||
}
|
||||
let body: serde_json::Value = response.error_for_status()?.json().await?;
|
||||
let Some(price) = content_invoice_amount(&body, content_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if existing
|
||||
.as_ref()
|
||||
.is_some_and(|(_, expected)| *expected != price)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
crate::content_invoice::record_pending(&self.config.data_dir, &hash, content_id, price)
|
||||
.await?;
|
||||
let settled = content_invoice_fully_settled(&body, price);
|
||||
if settled {
|
||||
crate::content_invoice::mark_paid(&self.config.data_dir, &hash).await?;
|
||||
}
|
||||
Ok(settled)
|
||||
}
|
||||
|
||||
/// Generate a fresh on-chain receive address (seller side, #46).
|
||||
pub(crate) async fn new_onchain_address(&self) -> Result<String> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
@@ -1444,3 +1494,81 @@ mod tests {
|
||||
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
// LND REST uses decimal strings for int64 fields. Match the complete seller
|
||||
// memo, not a substring supplied by a buyer or an arbitrary settled invoice.
|
||||
fn json_u64(value: &serde_json::Value) -> Option<u64> {
|
||||
value.as_u64().or_else(|| value.as_str()?.parse().ok())
|
||||
}
|
||||
fn content_invoice_fully_settled(body: &serde_json::Value, price: u64) -> bool {
|
||||
let settled = match body.get("state").and_then(|v| v.as_str()) {
|
||||
Some(state) => state == "SETTLED",
|
||||
None => body.get("settled").and_then(|v| v.as_bool()) == Some(true),
|
||||
};
|
||||
settled
|
||||
&& price > 0
|
||||
&& body
|
||||
.get("amt_paid_sat")
|
||||
.and_then(json_u64)
|
||||
.is_some_and(|paid| paid >= price)
|
||||
}
|
||||
fn content_invoice_amount(body: &serde_json::Value, content_id: &str) -> Option<u64> {
|
||||
if body.get("memo")?.as_str()? != format!("Archipelago peer file {content_id}") {
|
||||
return None;
|
||||
}
|
||||
body.get("value").and_then(json_u64).filter(|v| *v > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod peer_file_invoice_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn settlement_requires_terminal_state_and_full_amount() {
|
||||
for state in ["OPEN", "ACCEPTED", "CANCELED", "unknown"] {
|
||||
assert!(!content_invoice_fully_settled(
|
||||
&serde_json::json!({"state":state,"settled":true,"amt_paid_sat":"100"}),
|
||||
7
|
||||
));
|
||||
}
|
||||
for amount in [
|
||||
serde_json::json!(6),
|
||||
serde_json::json!("-1"),
|
||||
serde_json::json!(null),
|
||||
serde_json::json!("bad"),
|
||||
] {
|
||||
assert!(!content_invoice_fully_settled(
|
||||
&serde_json::json!({"state":"SETTLED","amt_paid_sat":amount}),
|
||||
7
|
||||
));
|
||||
}
|
||||
for amount in [serde_json::json!(7), serde_json::json!("8")] {
|
||||
assert!(content_invoice_fully_settled(
|
||||
&serde_json::json!({"state":"SETTLED","amt_paid_sat":amount}),
|
||||
7
|
||||
));
|
||||
}
|
||||
assert!(content_invoice_fully_settled(
|
||||
&serde_json::json!({"settled":true,"amt_paid_sat":"7"}),
|
||||
7
|
||||
));
|
||||
assert!(!content_invoice_fully_settled(
|
||||
&serde_json::json!({"state":"SETTLED","amt_paid_sat":"7"}),
|
||||
0
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn legacy_recovery_requires_exact_file_memo_and_positive_amount() {
|
||||
let invoice = serde_json::json!({"memo":"Archipelago peer file file-1", "value":"7"});
|
||||
assert_eq!(content_invoice_amount(&invoice, "file-1"), Some(7));
|
||||
assert_eq!(content_invoice_amount(&invoice, "file-2"), None);
|
||||
for value in [
|
||||
serde_json::json!("-1"),
|
||||
serde_json::json!(0),
|
||||
serde_json::json!("bad"),
|
||||
] {
|
||||
let mut invalid = invoice.clone();
|
||||
invalid["value"] = value;
|
||||
assert_eq!(content_invoice_amount(&invalid, "file-1"), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -573,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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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#"{
|
||||
|
||||
@@ -103,6 +103,15 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Missing companion UIs are provisioned here, never by snapshot recovery.
|
||||
/// A stale running-container snapshot must not resurrect an orphaned UI.
|
||||
pub fn is_companion_app(app_id: &str) -> bool {
|
||||
ALL_COMPANIONS
|
||||
.iter()
|
||||
.flat_map(|specs| specs.iter())
|
||||
.any(|spec| spec.image_base == app_id)
|
||||
}
|
||||
|
||||
/// Every companion this build knows how to provision. Kept beside
|
||||
/// `companions_for` — a new companion must be added to both, or the reaper
|
||||
/// will not recognise it as one of ours and will leave it running forever.
|
||||
|
||||
@@ -16,6 +16,18 @@ 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-server" => "immich",
|
||||
"immich-postgres" => "immich_postgres",
|
||||
"immich-redis" => "immich_redis",
|
||||
"mempool-web" | "mempool-frontend" => "mempool",
|
||||
name => name,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DockerPackageScanner {
|
||||
runtime: Arc<dyn ContainerRuntimeTrait>,
|
||||
}
|
||||
@@ -99,24 +111,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()) {
|
||||
@@ -178,13 +174,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
|
||||
};
|
||||
|
||||
@@ -388,21 +382,16 @@ fn restore_absent_installed(
|
||||
installed: &std::collections::HashSet<String>,
|
||||
uninstalled: &std::collections::HashSet<String>,
|
||||
) {
|
||||
fn canonical(name: &str) -> &str {
|
||||
let name = name.strip_prefix("archy-").unwrap_or(name);
|
||||
match name {
|
||||
"immich_server" => "immich",
|
||||
_ => name,
|
||||
}
|
||||
}
|
||||
let mut present: std::collections::HashSet<String> = containers
|
||||
.iter()
|
||||
.map(|c| canonical(&c.name).to_owned())
|
||||
.map(|c| canonical_package_id(&c.name).to_owned())
|
||||
.collect();
|
||||
let removed: std::collections::HashSet<_> = uninstalled
|
||||
.iter()
|
||||
.map(|id| canonical_package_id(id))
|
||||
.collect();
|
||||
let removed: std::collections::HashSet<_> =
|
||||
uninstalled.iter().map(|id| canonical(id)).collect();
|
||||
for name in installed {
|
||||
let id = canonical(name);
|
||||
let id = canonical_package_id(name);
|
||||
if removed.contains(id) || !present.insert(id.to_owned()) {
|
||||
continue;
|
||||
}
|
||||
@@ -456,6 +445,19 @@ mod lifecycle_regression_tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[test]
|
||||
fn immich_dependency_aliases_share_the_hidden_component_ids() {
|
||||
for id in [
|
||||
"immich-postgres",
|
||||
"immich_postgres",
|
||||
"archy-immich-postgres",
|
||||
] {
|
||||
assert_eq!(canonical_package_id(id), "immich_postgres");
|
||||
}
|
||||
assert_eq!(canonical_package_id("immich-redis"), "immich_redis");
|
||||
assert_eq!(canonical_package_id("immich-server"), "immich");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_survives_empty_runtime_and_deduplicates_aliases() {
|
||||
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
|
||||
@@ -475,6 +477,33 @@ mod lifecycle_regression_tests {
|
||||
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 [
|
||||
@@ -964,14 +993,20 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
|
||||
let mut first_candidate = None;
|
||||
for port_str in ports {
|
||||
// Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp"
|
||||
let Some(public_part) = port_str.split("->").next() else {
|
||||
let Some((public_part, _)) = port_str.split_once("->") else {
|
||||
continue;
|
||||
};
|
||||
let Some(port_part) = public_part.split(':').nth(1) else {
|
||||
let Some((_, port_part)) = public_part.rsplit_once(':') else {
|
||||
continue;
|
||||
};
|
||||
// Extract just the first port if it's a range (e.g., "18443-18444" -> "18443")
|
||||
let host_port = port_part.split('-').next().unwrap_or(port_part);
|
||||
let Ok(host_port) = host_port.parse::<u16>() else {
|
||||
continue;
|
||||
};
|
||||
if host_port == 0 {
|
||||
continue;
|
||||
}
|
||||
let candidate = format!("http://localhost:{}", host_port);
|
||||
if first_candidate.is_none() {
|
||||
first_candidate = Some(candidate.clone());
|
||||
@@ -1089,6 +1124,46 @@ 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 app_id == "nginx-proxy-manager" {
|
||||
// 80/443 serve users' proxy hosts; only container port 81 serves the
|
||||
// admin UI. Podman's binding order is unstable across recreation.
|
||||
// Resolve its actual host allocation rather than guessing the first
|
||||
// HTTP port or hardcoding the default host port 8081.
|
||||
let admin_ports: Vec<String> = ports
|
||||
.iter()
|
||||
.filter(|port| {
|
||||
port.split_once("->")
|
||||
.is_some_and(|(_, target)| target == "81/tcp")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
// With published bindings, a missing admin mapping is not evidence
|
||||
// that some unrelated service on the default host port is this UI.
|
||||
return extract_lan_address(&admin_ports).or_else(|| {
|
||||
if ports.is_empty() {
|
||||
known
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
}
|
||||
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,
|
||||
@@ -1173,7 +1248,134 @@ 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 npm_admin_launch_is_independent_of_proxy_binding_order() {
|
||||
let mappings = [
|
||||
"10.77.0.2:18081->80/tcp",
|
||||
"10.77.0.2:18443->443/tcp",
|
||||
"127.0.0.1:8081->81/tcp",
|
||||
];
|
||||
for order in [
|
||||
[0, 1, 2],
|
||||
[0, 2, 1],
|
||||
[1, 0, 2],
|
||||
[1, 2, 0],
|
||||
[2, 0, 1],
|
||||
[2, 1, 0],
|
||||
] {
|
||||
let ports: Vec<String> = order.iter().map(|&i| mappings[i].into()).collect();
|
||||
assert_eq!(
|
||||
package_launch_candidate(
|
||||
"nginx-proxy-manager",
|
||||
&ports,
|
||||
Some("http://localhost:8081/".into())
|
||||
)
|
||||
.as_deref(),
|
||||
Some("http://localhost:8081")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_admin_launch_respects_host_allocation_and_ipv6_bindings() {
|
||||
for binding in ["127.0.0.1", "0.0.0.0", "[::1]", "[::]"] {
|
||||
let ports = vec![
|
||||
"10.77.0.2:18081->80/tcp".into(),
|
||||
format!("{binding}:28081->81/tcp"),
|
||||
];
|
||||
assert_eq!(
|
||||
package_launch_candidate(
|
||||
"nginx-proxy-manager",
|
||||
&ports,
|
||||
Some("http://localhost:8081/".into())
|
||||
)
|
||||
.as_deref(),
|
||||
Some("http://localhost:28081")
|
||||
);
|
||||
}
|
||||
let proxies = vec![
|
||||
"10.77.0.2:18081->80/tcp".into(),
|
||||
"10.77.0.2:18443->443/tcp".into(),
|
||||
];
|
||||
assert_eq!(
|
||||
package_launch_candidate("nginx-proxy-manager", &proxies, None),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
package_launch_candidate(
|
||||
"nginx-proxy-manager",
|
||||
&proxies,
|
||||
Some("http://localhost:8081/".into())
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_without_port_information_uses_declared_admin_url() {
|
||||
assert_eq!(
|
||||
package_launch_candidate(
|
||||
"nginx-proxy-manager",
|
||||
&[],
|
||||
Some("http://localhost:8081/".into())
|
||||
)
|
||||
.as_deref(),
|
||||
Some("http://localhost:8081/")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_published_ports_do_not_become_launch_urls() {
|
||||
for port in [
|
||||
"81/tcp",
|
||||
"127.0.0.1:bad->81/tcp",
|
||||
"[::1]:0->81/tcp",
|
||||
"[::]:65536->81/tcp",
|
||||
"127.0.0.1:8081->81/udp",
|
||||
] {
|
||||
assert_eq!(
|
||||
package_launch_candidate("nginx-proxy-manager", &[port.into()], None),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
//! Consistent, private snapshots for declaratively opted-in network migrations.
|
||||
//! 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_on_network_change") {
|
||||
match manifest.app.extensions.get("backup_before_runtime_change") {
|
||||
None => Ok(false),
|
||||
Some(value) => value
|
||||
.as_bool()
|
||||
.context("backup_on_network_change must be boolean"),
|
||||
.context("backup_before_runtime_change must be boolean"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,18 +24,21 @@ fn relative_sources(manifest: &AppManifest, data_dir: &Path) -> Result<Vec<PathB
|
||||
continue;
|
||||
}
|
||||
if volume.volume_type != "bind" {
|
||||
bail!("network migration backup requires bind-mounted persistent state");
|
||||
bail!("runtime migration backup requires bind-mounted persistent state");
|
||||
}
|
||||
let path = Path::new(&volume.source);
|
||||
let relative = path
|
||||
.strip_prefix(data_dir)
|
||||
.context("network migration state must be inside the node data directory")?;
|
||||
.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 network migration state path");
|
||||
bail!("invalid runtime migration state path");
|
||||
}
|
||||
sources.push(relative.to_path_buf());
|
||||
}
|
||||
@@ -48,7 +51,7 @@ fn relative_sources(manifest: &AppManifest, data_dir: &Path) -> Result<Vec<PathB
|
||||
}
|
||||
}
|
||||
if roots.is_empty() {
|
||||
bail!("network migration backup has no persistent state mounts");
|
||||
bail!("runtime migration backup has no persistent state mounts");
|
||||
}
|
||||
Ok(roots)
|
||||
}
|
||||
@@ -80,11 +83,11 @@ async fn snapshot_with_command(
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
{
|
||||
bail!("network migration state mount is a symlink; explicit backup required");
|
||||
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!("network migration state path resolves outside node data directory");
|
||||
bail!("runtime migration state path resolves outside node data directory");
|
||||
}
|
||||
}
|
||||
let root = data_dir.join("migration-backups");
|
||||
@@ -126,7 +129,7 @@ async fn snapshot_with_command(
|
||||
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, "sources": sources});
|
||||
"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)?,
|
||||
|
||||
@@ -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;
|
||||
@@ -99,6 +104,15 @@ fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool {
|
||||
&& 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")
|
||||
}
|
||||
@@ -2057,6 +2071,10 @@ impl ProdContainerOrchestrator {
|
||||
Ok(ReconcileAction::Left(reason))
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
&& reason == "absent"
|
||||
// companion.rs owns missing UI provisioning/removal.
|
||||
// Never resurrect an orphan from a stale snapshot.
|
||||
// Existing UIs still pass through security config repair.
|
||||
&& !super::companion::is_companion_app(&app_id)
|
||||
&& (was_running.contains(&compute_container_name(&lm.manifest))
|
||||
// The durable answer, and the one that does not
|
||||
// erode. `was_running` only records what was
|
||||
@@ -2472,6 +2490,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?;
|
||||
@@ -2507,7 +2527,8 @@ impl ProdContainerOrchestrator {
|
||||
return Ok(ReconcileAction::NoOp);
|
||||
}
|
||||
tracing::info!(app_id = %app_id, container = %name, "container env drift detected — recreating");
|
||||
self.backup_network_change(&name, &resolved_manifest).await?;
|
||||
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?;
|
||||
@@ -2564,7 +2585,8 @@ impl ProdContainerOrchestrator {
|
||||
.await
|
||||
{
|
||||
tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating");
|
||||
self.backup_network_change(&name, &resolved_manifest).await?;
|
||||
self.backup_runtime_change(&name, &resolved_manifest)
|
||||
.await?;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(lm).await?;
|
||||
return Ok(ReconcileAction::Installed);
|
||||
@@ -2621,6 +2643,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);
|
||||
@@ -3128,11 +3152,18 @@ 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 restart_for_managed_override =
|
||||
quadlet::redundant_managed_network_override(&unit, &unit_dir)
|
||||
.await?
|
||||
.is_some();
|
||||
let needs_restart = restart_required
|
||||
|| restart_for_managed_override
|
||||
|| restart_for_port_change
|
||||
|| restart_for_network_alias_change
|
||||
|| restart_for_exec_change
|
||||
|| restart_for_health_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.
|
||||
@@ -3140,8 +3171,8 @@ impl ProdContainerOrchestrator {
|
||||
if pending.is_pending() {
|
||||
self.ensure_resolved_source_available(lm).await?;
|
||||
}
|
||||
if restart_for_network_alias_change {
|
||||
self.backup_network_change(name, &resolved).await?;
|
||||
if needs_restart {
|
||||
self.backup_runtime_change(name, &resolved).await?;
|
||||
}
|
||||
let changed = quadlet::write_if_changed(&unit, &unit_dir)
|
||||
.await
|
||||
@@ -3870,34 +3901,44 @@ impl ProdContainerOrchestrator {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn backup_network_change(&self, name: &str, manifest: &AppManifest) -> Result<()> {
|
||||
async fn backup_runtime_change(&self, name: &str, manifest: &AppManifest) -> Result<()> {
|
||||
if !crate::container::migration_backup::enabled(manifest)? {
|
||||
return Ok(());
|
||||
}
|
||||
// Only back up an actual network migration, not ordinary env drift.
|
||||
// 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")?;
|
||||
.output()
|
||||
.await
|
||||
.context("inspect network before migration backup")?;
|
||||
let present = if output.status.success() {
|
||||
if !rootless_network_mode_drifted(manifest.app.container.network.as_deref(), &String::from_utf8_lossy(&output.stdout)) {
|
||||
return Ok(());
|
||||
}
|
||||
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?;
|
||||
.args(["container", "exists", name])
|
||||
.status()
|
||||
.await?;
|
||||
if exists.code() != Some(1) {
|
||||
anyhow::bail!("cannot verify existing container before network migration backup");
|
||||
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?)
|
||||
Some(
|
||||
tokio::fs::read(quadlet::unit_dir().await?.join(format!("{name}.container")))
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -3906,9 +3947,15 @@ impl ProdContainerOrchestrator {
|
||||
} else if present {
|
||||
self.runtime.stop_container(name).await?;
|
||||
}
|
||||
match crate::container::migration_backup::snapshot(manifest, &self.data_dir, previous_unit.as_deref()).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 network migration");
|
||||
tracing::info!(container = %name, backup = %archive.display(), "Persistent state saved before runtime migration");
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -3934,18 +3981,47 @@ 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 matches!(manifest.app.container.network.as_deref(), Some("slirp4netns" | "pasta")) {
|
||||
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(
|
||||
if output.status.success()
|
||||
&& rootless_network_mode_drifted(
|
||||
manifest.app.container.network.as_deref(),
|
||||
&String::from_utf8_lossy(&output.stdout),
|
||||
) {
|
||||
)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4517,6 +4593,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);
|
||||
@@ -4543,7 +4658,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
|
||||
@@ -4772,6 +4886,26 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
/// here (production volumes live under `/var/lib/archipelago` — removal is a
|
||||
/// separate operation owned by the data layer, not this orchestrator).
|
||||
async fn remove(&self, app_id: &str, _preserve_data: bool) -> Result<()> {
|
||||
// A removed catalog entry must remain uninstallable. The RPC caller
|
||||
// still removes legacy containers and persists uninstall intent after
|
||||
// confirming they are gone; do not block it on a missing manifest.
|
||||
if !self.state.read().await.manifests.contains_key(app_id) {
|
||||
anyhow::ensure!(
|
||||
!app_id.is_empty()
|
||||
&& app_id.len() <= 128
|
||||
&& app_id
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_')),
|
||||
"Invalid app id"
|
||||
);
|
||||
let lock = self.app_lock(app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
for name in [app_id.to_string(), format!("archy-{app_id}")] {
|
||||
self.remove_quadlet_unit_if_present(&name).await?;
|
||||
}
|
||||
self.state.write().await.disabled.insert(app_id.to_string());
|
||||
return Ok(());
|
||||
}
|
||||
let lm = self.loaded(app_id).await?;
|
||||
let lock = self.app_lock(app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
@@ -4993,12 +5127,73 @@ 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(
|
||||
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"));
|
||||
@@ -5685,6 +5880,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"
|
||||
@@ -7027,6 +7254,52 @@ app:
|
||||
assert!(!calls.iter().any(|c| c.starts_with("start_container:")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_existing_does_not_resurrect_orphaned_companions() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt.clone()).await;
|
||||
orch.set_disk_gb_for_test(500);
|
||||
let companions = [
|
||||
"bitcoin-ui",
|
||||
"electrs-ui",
|
||||
"lnd-ui",
|
||||
"fedimint-ui",
|
||||
"cuprate-ui",
|
||||
];
|
||||
let mut names = Vec::new();
|
||||
for id in companions {
|
||||
let manifest = pull_manifest(id, "localhost/companion:local");
|
||||
names.push(compute_container_name(&manifest));
|
||||
orch.insert_manifest_for_test(manifest, PathBuf::from("/tmp/companion"))
|
||||
.await;
|
||||
}
|
||||
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
|
||||
crate::crash_recovery::save_container_snapshot_for_test(&orch.data_dir, &refs).await;
|
||||
// Repeated passes must leave lifecycle ownership with companion.rs.
|
||||
for _ in 0..3 {
|
||||
let report = orch.reconcile_existing().await;
|
||||
assert_eq!(report.actions.len(), companions.len());
|
||||
assert!(report
|
||||
.actions
|
||||
.iter()
|
||||
.all(|(_, action)| *action == ReconcileAction::Left("absent".into())));
|
||||
assert!(report.failures.is_empty());
|
||||
}
|
||||
let calls = rt.calls();
|
||||
for operation in [
|
||||
"pull_image:",
|
||||
"create_container:",
|
||||
"start_container:",
|
||||
"stop_container:",
|
||||
"remove_container:",
|
||||
] {
|
||||
assert!(
|
||||
!calls.iter().any(|call| call.starts_with(operation)),
|
||||
"{calls:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_existing_self_heals_missing_optional_installed_app() {
|
||||
// A non-baseline app (gitea) self-heals ONLY with installation
|
||||
@@ -7187,6 +7460,19 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_catalog_entry_does_not_block_legacy_uninstall() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
orch.remove("cryptpad", true).await.unwrap();
|
||||
assert!(orch.state.read().await.disabled.contains("cryptpad"));
|
||||
assert!(
|
||||
rt.calls().is_empty(),
|
||||
"legacy RPC teardown owns the actual containers"
|
||||
);
|
||||
assert!(orch.remove("../other", true).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_disables_manifest_so_reconcile_does_not_reinstall() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
|
||||
@@ -390,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('"', "\\\"")
|
||||
@@ -410,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('\\', "\\\\")
|
||||
@@ -710,18 +713,61 @@ pub async fn unit_dir() -> Result<PathBuf> {
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Atomically write `unit` into `dir/<name>.container` if the bytes
|
||||
/// differ from what's already there. Returns true if the file changed.
|
||||
/// The early same-node Portainer repair used a managed Quadlet drop-in. Once
|
||||
/// the manifest supplies slirp, the two Network= entries are additive and
|
||||
/// Podman rejects startup. Retire only that exact redundant managed override;
|
||||
/// arbitrary operator settings must survive reconciliation.
|
||||
pub async fn redundant_managed_network_override(
|
||||
unit: &QuadletUnit,
|
||||
dir: &Path,
|
||||
) -> Result<Option<PathBuf>> {
|
||||
if unit.name != "portainer" || !matches!(unit.network, NetworkMode::Slirp4netns) {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = dir.join("portainer.container.d/archy-same-node-network.conf");
|
||||
let body = match fs::read_to_string(&path).await {
|
||||
Ok(body) => body,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error).context("read managed Portainer network override"),
|
||||
};
|
||||
let lines: Vec<&str> = body
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with(['#', ';']))
|
||||
.collect();
|
||||
Ok((lines == ["[Container]", "Network=slirp4netns"]).then_some(path))
|
||||
}
|
||||
|
||||
async fn retire_managed_network_override(path: &Path) -> Result<()> {
|
||||
let backup = path.with_extension("conf.retired");
|
||||
match fs::hard_link(path, &backup).await {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
anyhow::ensure!(
|
||||
fs::read(path).await? == fs::read(&backup).await?,
|
||||
"Existing Portainer override backup differs; preserve both for operator review"
|
||||
);
|
||||
}
|
||||
Err(error) => return Err(error).context("back up managed Portainer network override"),
|
||||
}
|
||||
fs::remove_file(path)
|
||||
.await
|
||||
.context("retire redundant Portainer network override")?;
|
||||
tracing::info!("Retired redundant managed Portainer network override; backup retained");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically write the manifest unit and retire known redundant managed
|
||||
/// overrides. Returns true whenever systemd needs a daemon-reload.
|
||||
pub async fn write_if_changed(unit: &QuadletUnit, dir: &Path) -> Result<bool> {
|
||||
let path = dir.join(unit.unit_filename());
|
||||
let new_bytes = unit.render();
|
||||
|
||||
if let Ok(old) = fs::read_to_string(&path).await {
|
||||
if old == new_bytes {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
let redundant = redundant_managed_network_override(unit, dir).await?;
|
||||
let changed = fs::read_to_string(&path)
|
||||
.await
|
||||
.map(|old| old != new_bytes)
|
||||
.unwrap_or(true);
|
||||
if changed {
|
||||
fs::create_dir_all(dir)
|
||||
.await
|
||||
.with_context(|| format!("create_dir_all {}", dir.display()))?;
|
||||
@@ -732,7 +778,11 @@ pub async fn write_if_changed(unit: &QuadletUnit, dir: &Path) -> Result<bool> {
|
||||
fs::rename(&tmp, &path)
|
||||
.await
|
||||
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
if let Some(override_path) = &redundant {
|
||||
retire_managed_network_override(override_path).await?;
|
||||
}
|
||||
Ok(changed || redundant.is_some())
|
||||
}
|
||||
|
||||
/// Reload the user systemd manager. Required after any quadlet write
|
||||
@@ -991,6 +1041,24 @@ pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
|
||||
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=");
|
||||
@@ -1376,6 +1444,18 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[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!(
|
||||
@@ -1590,9 +1670,7 @@ app:
|
||||
|
||||
#[test]
|
||||
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
|
||||
let manifest = AppManifest::parse(include_str!(
|
||||
"../../../../apps/portainer/manifest.yml"
|
||||
))
|
||||
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"));
|
||||
@@ -1960,6 +2038,80 @@ app:
|
||||
assert!(!network_aliases_changed(new, new));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn redundant_portainer_override_is_backed_up_and_retired_even_when_base_matches() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manifest =
|
||||
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap();
|
||||
let unit = QuadletUnit::from_manifest(&manifest, "portainer");
|
||||
assert!(write_if_changed(&unit, dir.path()).await.unwrap());
|
||||
let path = dir
|
||||
.path()
|
||||
.join("portainer.container.d/archy-same-node-network.conf");
|
||||
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
|
||||
let old = "[Container]\nNetwork=slirp4netns\n";
|
||||
fs::write(&path, old).await.unwrap();
|
||||
assert!(redundant_managed_network_override(&unit, dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert!(write_if_changed(&unit, dir.path()).await.unwrap());
|
||||
assert!(!path.exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(path.with_extension("conf.retired"))
|
||||
.await
|
||||
.unwrap(),
|
||||
old
|
||||
);
|
||||
assert!(!write_if_changed(&unit, dir.path()).await.unwrap());
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.path().join("portainer.container"))
|
||||
.await
|
||||
.unwrap()
|
||||
.matches("Network=slirp4netns")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn network_override_migration_preserves_operator_customizations_and_failed_backups() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manifest =
|
||||
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap();
|
||||
let mut unit = QuadletUnit::from_manifest(&manifest, "portainer");
|
||||
let path = dir
|
||||
.path()
|
||||
.join("portainer.container.d/archy-same-node-network.conf");
|
||||
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
|
||||
for custom in [
|
||||
"[Container]\nNetwork=custom-net\n",
|
||||
"[Container]\nNetwork=slirp4netns\nEnvironment=OPERATOR_SETTING=1\n",
|
||||
] {
|
||||
fs::write(&path, custom).await.unwrap();
|
||||
assert!(redundant_managed_network_override(&unit, dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
write_if_changed(&unit, dir.path()).await.unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).await.unwrap(), custom);
|
||||
}
|
||||
fs::write(&path, "[Container]\nNetwork=slirp4netns\n")
|
||||
.await
|
||||
.unwrap();
|
||||
unit.network = NetworkMode::Pasta;
|
||||
assert!(redundant_managed_network_override(&unit, dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
unit.network = NetworkMode::Slirp4netns;
|
||||
fs::write(path.with_extension("conf.retired"), "different backup")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(write_if_changed(&unit, dir.path()).await.is_err());
|
||||
assert!(path.exists(), "failure must preserve the active override");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -1973,9 +2125,15 @@ app:
|
||||
// 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");
|
||||
assert!(
|
||||
retry.is_pending(),
|
||||
"matching unit must not discard failed restart"
|
||||
);
|
||||
retry.complete().await.unwrap();
|
||||
assert!(!RestartObligation::prepare(&unit, false).await.unwrap().is_pending());
|
||||
assert!(!RestartObligation::prepare(&unit, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_pending());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1985,10 +2143,28 @@ app:
|
||||
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();
|
||||
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";
|
||||
|
||||
@@ -1,80 +1,155 @@
|
||||
//! Seller-side pending entitlements for Lightning-invoice peer-file sales (#46).
|
||||
//!
|
||||
//! When a buyer asks to pay for a paid catalog item with an external wallet (as
|
||||
//! opposed to the local-ecash fast path), the *selling* node mints a Lightning
|
||||
//! invoice on its own LND and records a pending entitlement here, keyed by the
|
||||
//! invoice's payment hash. The buyer pays the invoice from any wallet and polls
|
||||
//! for settlement; once the seller's LND confirms the invoice is settled we mark
|
||||
//! the entitlement paid, and the content gate (`content_server::serve_content`)
|
||||
//! then releases the file to anyone presenting that payment hash.
|
||||
//!
|
||||
//! State is in-memory and bounded by a TTL. If the seller restarts before the
|
||||
//! buyer pays, the buyer simply requests a fresh invoice — no value is lost
|
||||
//! because an unpaid invoice represents no money.
|
||||
//! Durable seller-side entitlements for peer-file invoices and on-chain sales.
|
||||
//! Payment records must outlive browser polling, process restarts and invoice
|
||||
//! expiry: an invoice can settle while the buyer is disconnected.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::{fs, io::AsyncWriteExt, sync::Mutex};
|
||||
|
||||
/// How long a pending/paid entitlement is retained. Generous enough for a human
|
||||
/// to pay an invoice and download, short enough to keep the map small.
|
||||
const ENTITLEMENT_TTL: Duration = Duration::from_secs(3600); // 1 hour
|
||||
static WRITES: Mutex<()> = Mutex::const_new(());
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct Entitlement {
|
||||
content_id: String,
|
||||
price_sats: u64,
|
||||
paid: bool,
|
||||
created_at: Instant,
|
||||
}
|
||||
|
||||
static ENTITLEMENTS: LazyLock<Mutex<HashMap<String, Entitlement>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Drop expired entries. Caller must hold the lock.
|
||||
fn prune(map: &mut HashMap<String, Entitlement>) {
|
||||
map.retain(|_, e| e.created_at.elapsed() < ENTITLEMENT_TTL);
|
||||
fn path(data_dir: &Path, token: &str) -> PathBuf {
|
||||
data_dir.join("content-entitlements").join(format!(
|
||||
"{}.json",
|
||||
hex::encode(Sha256::digest(token.as_bytes()))
|
||||
))
|
||||
}
|
||||
|
||||
/// Record a freshly-minted invoice as a pending (unpaid) entitlement.
|
||||
pub async fn record_pending(payment_hash: &str, content_id: &str, price_sats: u64) {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.insert(
|
||||
payment_hash.to_string(),
|
||||
Entitlement {
|
||||
content_id: content_id.to_string(),
|
||||
async fn read(data_dir: &Path, token: &str) -> Result<Option<Entitlement>> {
|
||||
match fs::read(path(data_dir, token)).await {
|
||||
Ok(bytes) => Ok(Some(
|
||||
serde_json::from_slice(&bytes).context("Invalid payment entitlement")?,
|
||||
)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e).context("Reading payment entitlement"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write(data_dir: &Path, token: &str, entry: &Entitlement) -> Result<()> {
|
||||
let target = path(data_dir, token);
|
||||
let dir = target.parent().unwrap();
|
||||
fs::create_dir_all(dir).await?;
|
||||
let tmp = target.with_extension("tmp");
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)
|
||||
.await?;
|
||||
file.write_all(&serde_json::to_vec(entry)?).await?;
|
||||
file.sync_all().await?;
|
||||
drop(file);
|
||||
fs::rename(&tmp, &target).await?;
|
||||
fs::File::open(dir).await?.sync_all().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save before exposing an invoice/address to the buyer. Never overwrite an
|
||||
/// existing payment or silently rebind its token to another item or price.
|
||||
pub async fn record_pending(
|
||||
data_dir: &Path,
|
||||
token: &str,
|
||||
content_id: &str,
|
||||
price_sats: u64,
|
||||
) -> Result<()> {
|
||||
let _lock = WRITES.lock().await;
|
||||
if let Some(existing) = read(data_dir, token).await? {
|
||||
anyhow::ensure!(
|
||||
existing.content_id == content_id && existing.price_sats == price_sats,
|
||||
"Payment entitlement mismatch"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
write(
|
||||
data_dir,
|
||||
token,
|
||||
&Entitlement {
|
||||
content_id: content_id.into(),
|
||||
price_sats,
|
||||
paid: false,
|
||||
created_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Mark the entitlement for `payment_hash` paid. No-op if unknown/expired.
|
||||
pub async fn mark_paid(payment_hash: &str) {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
if let Some(e) = map.get_mut(payment_hash) {
|
||||
e.paid = true;
|
||||
}
|
||||
pub async fn mark_paid(data_dir: &Path, token: &str) -> Result<()> {
|
||||
let _lock = WRITES.lock().await;
|
||||
let mut entry = read(data_dir, token)
|
||||
.await?
|
||||
.context("Unknown payment entitlement")?;
|
||||
entry.paid = true;
|
||||
write(data_dir, token, &entry).await
|
||||
}
|
||||
|
||||
/// The content_id + price an entitlement was issued for, if still live.
|
||||
pub async fn lookup(payment_hash: &str) -> Option<(String, u64)> {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.get(payment_hash)
|
||||
.map(|e| (e.content_id.clone(), e.price_sats))
|
||||
pub async fn lookup(data_dir: &Path, token: &str) -> Result<Option<(String, u64)>> {
|
||||
Ok(read(data_dir, token)
|
||||
.await?
|
||||
.map(|e| (e.content_id, e.price_sats)))
|
||||
}
|
||||
|
||||
/// True if `payment_hash` is a paid entitlement for exactly `content_id`.
|
||||
/// This is the gate the content server consults to release a file.
|
||||
pub async fn is_paid_for(payment_hash: &str, content_id: &str) -> bool {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.get(payment_hash)
|
||||
pub async fn is_paid_for(data_dir: &Path, token: &str, content_id: &str) -> bool {
|
||||
read(data_dir, token)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|e| e.paid && e.content_id == content_id)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn paid_entitlement_survives_reload_and_cannot_be_rebound() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
record_pending(dir.path(), "hash", "file", 12)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!is_paid_for(dir.path(), "hash", "file").await);
|
||||
mark_paid(dir.path(), "hash").await.unwrap();
|
||||
// All reads reopen disk; no process-local entitlement map exists.
|
||||
assert!(is_paid_for(dir.path(), "hash", "file").await);
|
||||
assert!(!is_paid_for(dir.path(), "hash", "other").await);
|
||||
record_pending(dir.path(), "hash", "file", 12)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(is_paid_for(dir.path(), "hash", "file").await);
|
||||
assert!(record_pending(dir.path(), "hash", "other", 12)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(record_pending(dir.path(), "hash", "file", 13)
|
||||
.await
|
||||
.is_err());
|
||||
let other = tempfile::tempdir().unwrap();
|
||||
assert!(!is_paid_for(other.path(), "hash", "file").await);
|
||||
assert!(mark_paid(dir.path(), "unknown").await.is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn corrupt_or_unwritable_records_fail_closed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
record_pending(dir.path(), "../../token", "file", 1)
|
||||
.await
|
||||
.unwrap();
|
||||
fs::write(path(dir.path(), "../../token"), b"broken")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(lookup(dir.path(), "../../token").await.is_err());
|
||||
assert!(!is_paid_for(dir.path(), "../../token", "file").await);
|
||||
assert!(record_pending(dir.path(), "../../token", "file", 1)
|
||||
.await
|
||||
.is_err());
|
||||
let file = dir.path().join("not-directory");
|
||||
fs::write(&file, b"x").await.unwrap();
|
||||
assert!(record_pending(&file, "hash", "file", 1).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tokio::{fs, io::AsyncWriteExt, sync::Mutex};
|
||||
|
||||
static PURCHASE_WRITES: Mutex<()> = Mutex::const_new(());
|
||||
|
||||
const OWNED_DIR: &str = "purchased-content";
|
||||
const OWNED_INDEX: &str = "owned.json";
|
||||
@@ -66,20 +68,51 @@ fn bytes_path(data_dir: &Path, onion: &str, content_id: &str) -> PathBuf {
|
||||
.join(sanitize(content_id))
|
||||
}
|
||||
|
||||
async fn load_index(data_dir: &Path) -> OwnedIndex {
|
||||
async fn load_index_checked(data_dir: &Path) -> Result<OwnedIndex> {
|
||||
match fs::read_to_string(index_path(data_dir)).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => OwnedIndex::default(),
|
||||
Ok(s) => serde_json::from_str(&s)
|
||||
.context("Invalid purchase index; existing records were preserved"),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(OwnedIndex::default()),
|
||||
Err(error) => Err(error).context("Reading purchase index"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_index(data_dir: &Path) -> OwnedIndex {
|
||||
load_index_checked(data_dir).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
let parent = path.parent().context("Purchase path has no parent")?;
|
||||
fs::create_dir_all(parent).await?;
|
||||
let temp = parent.join(format!(".purchase-{}.tmp", uuid::Uuid::new_v4()));
|
||||
let result = async {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temp)
|
||||
.await?;
|
||||
file.write_all(bytes).await?;
|
||||
file.sync_all().await?;
|
||||
drop(file);
|
||||
fs::rename(&temp, path).await?;
|
||||
fs::File::open(parent).await?.sync_all().await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temp).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> {
|
||||
let root = owned_root(data_dir);
|
||||
fs::create_dir_all(&root)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", root.display()))?;
|
||||
let content = serde_json::to_string_pretty(index).context("serializing owned index")?;
|
||||
fs::write(index_path(data_dir), content)
|
||||
atomic_write(&index_path(data_dir), content.as_bytes())
|
||||
.await
|
||||
.context("writing owned index")
|
||||
}
|
||||
@@ -98,17 +131,15 @@ pub async fn record_purchase(
|
||||
ecash_backend: &str,
|
||||
purchased_at: &str,
|
||||
) -> Result<()> {
|
||||
// Read-modify-write must be one serialized transaction. Never replace a
|
||||
// damaged index with an empty one, and never expose partially written bytes.
|
||||
let _lock = PURCHASE_WRITES.lock().await;
|
||||
let mut index = load_index_checked(data_dir).await?;
|
||||
let path = bytes_path(data_dir, onion, content_id);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
fs::write(&path, bytes)
|
||||
atomic_write(&path, bytes)
|
||||
.await
|
||||
.with_context(|| format!("writing purchased bytes to {}", path.display()))?;
|
||||
|
||||
let mut index = load_index(data_dir).await;
|
||||
let entry = OwnedItem {
|
||||
onion: onion.to_string(),
|
||||
content_id: content_id.to_string(),
|
||||
@@ -165,3 +196,90 @@ pub async fn read_owned(
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
Some((mime, bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn concurrent_purchases_preserve_every_item_and_exact_bytes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut jobs = tokio::task::JoinSet::new();
|
||||
for n in 0..24 {
|
||||
let root = dir.path().to_path_buf();
|
||||
jobs.spawn(async move {
|
||||
let id = format!("file-{n}");
|
||||
record_purchase(
|
||||
&root,
|
||||
"seller.onion",
|
||||
&id,
|
||||
&id,
|
||||
"text/plain",
|
||||
id.as_bytes(),
|
||||
5,
|
||||
"lightning",
|
||||
"now",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
while let Some(result) = jobs.join_next().await {
|
||||
result.unwrap();
|
||||
}
|
||||
assert_eq!(list_owned(dir.path()).await.len(), 24);
|
||||
for n in 0..24 {
|
||||
let id = format!("file-{n}");
|
||||
assert!(is_owned(dir.path(), "seller.onion", &id).await);
|
||||
let (mime, bytes) = read_owned(dir.path(), "seller.onion", &id).await.unwrap();
|
||||
assert_eq!(mime, "text/plain");
|
||||
assert_eq!(bytes, id.as_bytes());
|
||||
}
|
||||
record_purchase(
|
||||
dir.path(),
|
||||
"seller.onion",
|
||||
"file-0",
|
||||
"file-0",
|
||||
"text/plain",
|
||||
b"updated",
|
||||
5,
|
||||
"lightning",
|
||||
"later",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list_owned(dir.path()).await.len(), 24);
|
||||
assert_eq!(
|
||||
read_owned(dir.path(), "seller.onion", "file-0")
|
||||
.await
|
||||
.unwrap()
|
||||
.1,
|
||||
b"updated"
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn damaged_index_is_preserved_instead_of_erasing_prior_ownership() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(owned_root(dir.path())).await.unwrap();
|
||||
fs::write(index_path(dir.path()), b"damaged but preserve me")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(record_purchase(
|
||||
dir.path(),
|
||||
"seller.onion",
|
||||
"new",
|
||||
"new",
|
||||
"text/plain",
|
||||
b"bytes",
|
||||
5,
|
||||
"lightning",
|
||||
"now"
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
fs::read(index_path(dir.path())).await.unwrap(),
|
||||
b"damaged but preserve me"
|
||||
);
|
||||
assert!(!bytes_path(dir.path(), "seller.onion", "new").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -314,6 +352,29 @@ pub async fn serve_content(
|
||||
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 {
|
||||
@@ -331,7 +392,7 @@ pub async fn serve_content(
|
||||
"fedimint"
|
||||
};
|
||||
if method_accepted(&item.access, method)
|
||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||
&& verify(token.to_owned(), *price_sats).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
@@ -339,7 +400,7 @@ pub async fn serve_content(
|
||||
if !authorized {
|
||||
if let Some(hash) = invoice_hash {
|
||||
if method_accepted(&item.access, "lightning")
|
||||
&& crate::content_invoice::is_paid_for(hash, id).await
|
||||
&& crate::content_invoice::is_paid_for(data_dir, hash, id).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
@@ -358,55 +419,127 @@ pub async fn serve_content(
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&file_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(prepared)
|
||||
}
|
||||
|
||||
let len = (end - start + 1) as usize;
|
||||
async fn prepare_content(
|
||||
data_dir: &Path,
|
||||
path: PathBuf,
|
||||
range: Option<ByteRange>,
|
||||
mime: String,
|
||||
) -> Result<ServeResult> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
let mut file = tokio::fs::File::open(&file_path)
|
||||
let mut file = match fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(&path)
|
||||
.await
|
||||
.context("Failed to open content file")?;
|
||||
file.seek(std::io::SeekFrom::Start(start))
|
||||
{
|
||||
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);
|
||||
}
|
||||
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 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.
|
||||
@@ -729,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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,6 +664,10 @@ pub async fn start_stopped_stack_containers(data_dir: &Path) -> RecoveryReport {
|
||||
start_stopped_app_stacks(data_dir).await
|
||||
}
|
||||
|
||||
fn stack_member_needs_recovery(state: Option<&str>, user_stopped: bool) -> bool {
|
||||
!user_stopped && matches!(state, Some("exited" | "stopped" | "created" | "configured"))
|
||||
}
|
||||
|
||||
async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
||||
let user_stopped = load_user_stopped(data_dir).await;
|
||||
let mut report = RecoveryReport {
|
||||
@@ -677,24 +681,27 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
||||
continue;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Recovering stopped {} stack containers after boot",
|
||||
stack.name
|
||||
);
|
||||
// Healthy members must never acquire a restarting overlay merely
|
||||
// because the periodic recovery scan ran. Queue existing stopped
|
||||
// members only; recheck each immediately before starting below.
|
||||
let mut pending = Vec::new();
|
||||
for container in stack.containers {
|
||||
let state = container_state(container).await;
|
||||
if stack_member_needs_recovery(state.as_deref(), user_stopped.contains(*container)) {
|
||||
pending.push((*container).to_string());
|
||||
}
|
||||
}
|
||||
if pending.is_empty() {
|
||||
continue;
|
||||
}
|
||||
info!("Recovering stopped {} stack containers", stack.name);
|
||||
repair_stack_network_aliases(stack).await;
|
||||
|
||||
// Register the whole stack up front: the per-member dependency waits
|
||||
// below can take minutes, and the UI should say "Restarting", not
|
||||
// "Stopped", for members still queued behind them.
|
||||
pending_boot_starts_add(
|
||||
stack
|
||||
.containers
|
||||
.iter()
|
||||
.filter(|c| !user_stopped.contains(**c))
|
||||
.map(|c| (*c).to_string()),
|
||||
);
|
||||
pending_boot_starts_add(pending.iter().cloned());
|
||||
|
||||
for container in stack.containers {
|
||||
if !pending.iter().any(|name| name.as_str() == *container) {
|
||||
continue;
|
||||
}
|
||||
if user_stopped.contains(*container) {
|
||||
info!("Skipping user-stopped container: {}", container);
|
||||
continue;
|
||||
@@ -706,8 +713,8 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
||||
pending_boot_start_done(container);
|
||||
continue;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
Some(state) if stack_member_needs_recovery(Some(&state), false) => {}
|
||||
_ => {
|
||||
pending_boot_start_done(container);
|
||||
continue;
|
||||
}
|
||||
@@ -1534,3 +1541,24 @@ mod installed_concurrency_tests {
|
||||
assert!(!dir.path().join("installed-apps.json.tmp").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stack_recovery_overlay_tests {
|
||||
use super::stack_member_needs_recovery;
|
||||
#[test]
|
||||
fn only_existing_stopped_members_receive_recovery_overlay() {
|
||||
for state in [
|
||||
None,
|
||||
Some("running"),
|
||||
Some("paused"),
|
||||
Some("restarting"),
|
||||
Some("removing"),
|
||||
] {
|
||||
assert!(!stack_member_needs_recovery(state, false));
|
||||
}
|
||||
for state in ["exited", "stopped", "created", "configured"] {
|
||||
assert!(stack_member_needs_recovery(Some(state), false));
|
||||
assert!(!stack_member_needs_recovery(Some(state), true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
@@ -1750,18 +1777,38 @@ app:
|
||||
// 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, expected, "unauthenticated endpoint set changed; review each exemption");
|
||||
("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, expected,
|
||||
"unauthenticated endpoint set changed; review each exemption"
|
||||
);
|
||||
}
|
||||
|
||||
/// `auth: open` ports are served by the gate WITHOUT its login challenge,
|
||||
@@ -1798,9 +1845,14 @@ 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),
|
||||
@@ -1811,6 +1863,17 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[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
|
||||
|
||||
@@ -1080,10 +1080,19 @@ 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!({
|
||||
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]
|
||||
|
||||
@@ -621,7 +621,11 @@ 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()
|
||||
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") {
|
||||
@@ -632,10 +636,24 @@ fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<S
|
||||
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)]);
|
||||
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)
|
||||
}
|
||||
@@ -1041,12 +1059,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn docker_fallback_rejects_rootless_only_topology_and_preserves_bind_protocol() {
|
||||
let mut m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
|
||||
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_eq!(
|
||||
args,
|
||||
vec!["--network", "bridge", "-p", "127.0.0.1:9001:9000/udp"]
|
||||
);
|
||||
assert!(docker_network_and_ports(&m, u16::MAX).is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
|
||||
# Static site content.
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY tailwind.css /usr/share/nginx/html/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
|
||||
# Static site content.
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY 50x.html /usr/share/nginx/html/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY 50x.html /usr/share/nginx/html/
|
||||
COPY qrcode.js /usr/share/nginx/html/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
|
||||
|
||||
COPY index.html /usr/share/nginx/html/index.html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
|
||||
# Static site content.
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
#
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
|
||||
|
||||
# Copy the HTML file
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
|
||||
+50
-15
@@ -14,30 +14,65 @@ doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
||||
|
||||
## 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).
|
||||
- [ ] Integrate the reviewed PR branches into the next release and run funded
|
||||
- [x] Integrate the reviewed PR branches into the next release and run funded
|
||||
candidate acceptance, including Tor-only transport and payments with change.
|
||||
PRs remain open; the reviewed code has not been deployed to live wallets.
|
||||
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.
|
||||
- [ ] **ThinkPad X250 kiosk: Bitcoin installation version selector is unreadable
|
||||
and appears underneath the pruning information.** Operator reports white
|
||||
styling with invisible text on the actual kiosk; the same flow works in remote
|
||||
Brave. Reproduce on the X250's kiosk engine and record its version, display
|
||||
scale and resolution. Inspect the native `<select>` in
|
||||
`neode-ui/src/components/InstallVersionModal.vue`, its option colors, and the
|
||||
scroll/stacking behavior in `BaseModal.vue`; these are investigation leads,
|
||||
not a confirmed cause. Fix contrast and popup visibility without changing
|
||||
version selection or pruning behavior. Validate Core and Knots, open/closed
|
||||
and scrolled dropdowns, keyboard/touch selection, and pruning on/off on the
|
||||
actual kiosk, with remote Brave and mobile regression checks. Browser mocks
|
||||
alone do not establish that the kiosk rendering is fixed. Track for the next
|
||||
release; the signed 1.8.21 artifacts remain unchanged.
|
||||
- [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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -291,14 +291,34 @@ 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 network migrations
|
||||
### Persistent-state backup for runtime repairs
|
||||
|
||||
`app.backup_on_network_change: true` opts an app into a stopped-state snapshot
|
||||
before an explicitly selected rootless network mode is migrated. The orchestrator
|
||||
`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 network configurations do not create migration snapshots.
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Same-node Gitea sources in Portainer
|
||||
|
||||
Status: root cause reproduced and network repair verified in disposable Portainer
|
||||
instances; final migration integration and release acceptance remain in progress.
|
||||
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.
|
||||
|
||||
@@ -37,12 +37,16 @@ this public record.
|
||||
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 opts into `backup_on_network_change`. Before recreation, gracefully
|
||||
- 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
|
||||
@@ -73,12 +77,18 @@ verification stays enabled and API redirects are refused.
|
||||
|
||||
## Upgrade and rollback
|
||||
|
||||
The signed catalog embeds manifests and overrides installed disk copies. A disk
|
||||
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.
|
||||
Gitea does not need recreation or an app.ini rewrite for this repair.
|
||||
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
|
||||
@@ -94,11 +104,64 @@ repositories or the production Portainer database with disposable test data.
|
||||
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.
|
||||
- Final expanded backend suite: 1,575 passed, zero failed, four existing ignored
|
||||
- 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. Combined tests with the merged
|
||||
paid-download PRs remain pending.
|
||||
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, private-repository and install-order acceptance,
|
||||
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,459 @@
|
||||
# Next OTA and raw ISO after 1.8.21
|
||||
|
||||
**Status: COMPLETE — 1.8.22-alpha OTA, compatible signed app catalog and raw ISO published on Git and ngit on 2026-10-01; artifact signatures, public downloads and fleet feed verified. Angor full-chain indexing still awaits dev Bitcoin synchronization.**
|
||||
|
||||
Current acceptance evidence: [1.8.22 release acceptance](release-1.8.22-acceptance.md).
|
||||
The chronological notes below retain earlier failures and superseded candidates;
|
||||
the final tested source is `6d5f3ffb`.
|
||||
|
||||
This is the consolidated execution checklist for the operator's chat requests.
|
||||
Release acceptance and publication are complete, with live wallet and app data
|
||||
preservation checks documented below. 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 | Final UI/build checks passed |
|
||||
| App disappearance/readiness | Durable inventory and safe lifecycle repair; delayed HTTP and desktop/mobile hard-refresh checks passed | Final candidate lifecycle, hard-refresh and stability checks passed |
|
||||
| X250 GitWorkshop/Nginx | Missing build contexts restored, dependency/build checks and live UI passed; Nginx slow pull diagnosed; truthful progress label | OTA and ISO build-context/content checks passed |
|
||||
| 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; included 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 integration checks passed |
|
||||
| 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/API, lifecycle and catalog checks passed; 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
|
||||
|
||||
- [x] Finish new-scope implementation and release acceptance; full-chain Angor
|
||||
indexing still depends on the dev node finishing initial sync.
|
||||
- [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.
|
||||
- [x] Run final backend/UI/regression/release gates on the final source; inspect
|
||||
skipped tests and report actual hardware/runtime coverage.
|
||||
- [x] Prepare compatible signed app catalog; old runtimes must not apply a
|
||||
migration before they have backup/recovery support.
|
||||
- [x] Version/changelog and OTA payload prepared, validated and signed by user.
|
||||
- [x] Raw ISO built; payload hashes/content verified; full installation and
|
||||
installed-system boot tested in QEMU/KVM without network.
|
||||
- [x] User signs ISO checksums; publish OTA and ISO plus verification files on
|
||||
git and ngit; independently read back hashes and update discovery.
|
||||
- [x] Provide LAN scp command for the new raw ISO.
|
||||
|
||||
Latest backend source verification: 1,617 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.
|
||||
|
||||
### Release blocker discovered during candidate observation: scheduled doctor
|
||||
|
||||
The initial `02b840f2` 1.8.22 candidate is rejected for release. On the X250,
|
||||
2026-09-30 21:10–21:11 UTC, the scheduled `archipelago-doctor.service` explicitly
|
||||
ran `podman stop --all --time 30`, killed rootless network helpers and ran
|
||||
`podman system migrate` after a two-attempt external network probe failed.
|
||||
The journal attributes the stop to that unit, not the app health monitor or a
|
||||
host reboot. All apps restarted, including Bitcoin, LND and the production site.
|
||||
The earlier unchanged-container acceptance applies only to immediate deployment;
|
||||
the later observation failed and must not be represented as a stability pass.
|
||||
No persistent-data loss has been established. Keep this distinct from the closed
|
||||
Framework incident; do not wipe or recreate any wallet as a recovery action.
|
||||
|
||||
Containment: stopped doctor timers on both test boxes, installed a safe diagnostic
|
||||
script into both the executable and runtime payload, and rejected/stopped the
|
||||
old ISO build. Network failure now produces a warning without stopping apps,
|
||||
killing network processes, migrating Podman or deleting network state. Repeated
|
||||
failures remain warnings, never a successful repair/check. Regression cases cover
|
||||
healthy, absent network, non-root invocation, host failure, transient recovery,
|
||||
repeated endpoint failure and namespace access failure, with mutation tripwires.
|
||||
Live scheduled-cycle observation and final rebuilt-artifact acceptance are pending.
|
||||
|
||||
Recovery also exposed retired `git.tx1138.com` nginx base references in six
|
||||
companion UI Dockerfiles. They now use the existing primary registry at the same
|
||||
pinned version. All six images built successfully against that registry; payload
|
||||
validation rejects the retired host before OTA/ISO packaging.
|
||||
|
||||
The post-recovery X250 check passes: Bitcoin authenticated RPC responds and IBD
|
||||
advances; NPM/Gitea/Portainer APIs respond; Portainer's real namespace fetches
|
||||
`demo-portainer` at `3ae171d6b0c728665a860520fe393c0abb772798` and its Compose
|
||||
file; Portainer's original persistent mounts match the earlier backup evidence;
|
||||
LND wallet/channel databases remain present on their persistent mount. No new
|
||||
pre-incident cryptographic wallet-identity baseline was available, so these checks
|
||||
must not be described as an exact identity/balance comparison.
|
||||
|
||||
The dev all-container observation also caught a separate Cuprate UI orphan loop:
|
||||
`companion.rs` removed it because Cuprate was not installed, while generic desired-
|
||||
state recovery resurrected it from an old running snapshot, using a unit without
|
||||
nginx's required capabilities. Generic desired-state recovery now excludes missing companions
|
||||
owned by `companion.rs`; existing companion provisioning/reaping remains the
|
||||
single owner. Running UIs still receive the existing security configuration repairs. Regression runs repeated reconciliation against stale companion
|
||||
snapshots and checks that no image/container lifecycle operations occur.
|
||||
|
||||
Safe-doctor live acceptance: the X250 completed a 12-minute observation with all
|
||||
running container IDs, start times and data mounts unchanged. Its journal records
|
||||
successful doctor runs at 21:22:06, 21:27:51 and 21:33:10 UTC. Both doctor timers
|
||||
are restored with the safe script. Dev's native Bitcoin/LND stayed running;
|
||||
all-container dev acceptance remains pending the companion-loop backend fix.
|
||||
Final-source UI suite: 1,133 passed. Heavy backend compilation is serialized with
|
||||
remaining build steps to reduce memory/IO pressure on the syncing dev node.
|
||||
|
||||
Final source release gates at `96fb5a4f`: 1,613 backend tests passed, zero failed,
|
||||
four explicitly ignored; 1,133 UI tests passed; type-check, production UI build,
|
||||
catalog/trust, shell, pruning, LND readiness, NPM migration and doctor regressions
|
||||
passed. The isolated companion-loop regression passed independently as well.
|
||||
|
||||
ISO cache hardening: the installer now carries the current doctor script and
|
||||
service/timer separately from rootfs.tar and overwrites both historical and active
|
||||
script locations before first boot. This prevents a cached base image restoring
|
||||
the old recovery code. A regression executes the actual installer block against
|
||||
stale disposable files twice and confirms a missing safety payload fails closed.
|
||||
The mounted-ISO smoke test also compares all three overlay files to source.
|
||||
The final ISO build captures the exact newly deployed OTA UI/runtime payload.
|
||||
|
||||
### Final kiosk acceptance found nondeterministic NPM launch selection
|
||||
|
||||
Do not publish the staged `d1bc1273` candidate. NPM itself remains healthy and its
|
||||
API, Portainer integration, site, and native services passed stability checks.
|
||||
However, final kiosk acceptance found its card stuck at "Web UI not ready".
|
||||
The runtime reported bindings in proxy-HTTP, proxy-HTTPS, admin order. The scanner
|
||||
chose the first non-database/SSH binding, then rejected its tunnel-only host port
|
||||
as unreachable on loopback, leaving the launch address empty. Earlier tests had
|
||||
passed with admin first. This is a confirmed order-dependent scanner defect.
|
||||
|
||||
The candidate fix explicitly resolves NPM container port 81 to its actual host
|
||||
allocation. Proxy ports never become the admin URL. Missing/malformed admin
|
||||
bindings do not fall back to another service when published bindings are present.
|
||||
Port parsing handles IPv6 authorities and rejects invalid ports. Regressions
|
||||
cover all six three-port permutations, allocated admin ports, IPv4/IPv6 binding
|
||||
strings, missing admin mappings, missing runtime port information, and malformed
|
||||
or UDP bindings. Full backend regression execution is pending for this change.
|
||||
The ISO build is frozen at installer-environment creation; no release was signed
|
||||
or published. Rebuild/revalidate the OTA and ISO with this correction.
|
||||
|
||||
The companion orphan fix worked live: Cuprate UI was automatically removed and
|
||||
all installed app container IDs remained unchanged. One observation helper raced
|
||||
that expected removal between `podman ps` and `inspect`; it now excludes that
|
||||
known orphan before inspection and repeats the stability check. This was a test
|
||||
snapshot race, not another installed-app restart.
|
||||
|
||||
NPM selector final backend gate passed: 1,617 tests, zero failures, four explicitly
|
||||
ignored, through the isolated runner. This includes all new port-selection cases
|
||||
and the existing companion security/configuration and lifecycle regressions.
|
||||
Rebuild the release binary and UI metadata, deploy those exact OTA bytes to both
|
||||
boxes, and require actual kiosk hard-refresh/Launch acceptance before ISO assembly.
|
||||
|
||||
|
||||
## Final accepted artifacts — 1.8.22-alpha
|
||||
|
||||
Source `6d5f3ffb` passed 1,617 backend tests (four explicit opt-in exclusions),
|
||||
1,133 frontend tests and final release gates. Exact OTA bytes were deployed to
|
||||
both boxes. Actual X250 kiosk NPM Launch, version/pruning, desktop/mobile
|
||||
readiness/AIUI, production Portainer Git/Compose and 12-minute stability checks
|
||||
on both boxes passed. No installed app was restarted by the safe diagnostics,
|
||||
and the Cuprate orphan stayed absent. Native Bitcoin/LND and the production site
|
||||
were preserved during final management deployment.
|
||||
|
||||
The raw ISO passed mounted payload checks and matches all 653 OTA frontend/runtime
|
||||
files plus the backend. Full offline installation and installed UEFI boot to the
|
||||
visible setup screen passed in a disposable QEMU/KVM VM. Both installed doctor
|
||||
paths and the installed backend have the expected hashes. No VM wallet was set up.
|
||||
|
||||
See `release-1.8.22-acceptance.md` for exact artifact hashes, hardware/runtime
|
||||
coverage and limits. Draft upload verification, offline signatures, publication
|
||||
and public readback remain; the fleet still advertises 1.8.21 until those gates
|
||||
finish. Do not confuse a draft asset or source push with completed publication.
|
||||
@@ -0,0 +1,219 @@
|
||||
# Post-1.8.22 regressions and retained release checklist
|
||||
|
||||
Status: OPEN. New regressions reported after publication on 2026-10-01.
|
||||
Do not mark complete from source changes alone. Preserve wallets, app state and
|
||||
operator uninstall decisions. Never send a second payment to recover delivery.
|
||||
The earlier Framework startup incident remains separately closed with operator
|
||||
acceptance; this is a new paid-file incident.
|
||||
|
||||
## Current tasks
|
||||
|
||||
- [ ] Recover the Framework's Lightning paid-file purchase without another payment;
|
||||
inspect buyer/seller evidence and verify delivered bytes.
|
||||
- [ ] Correct seller settlement verification when local-node payment skips polling.
|
||||
- [ ] Durable seller entitlements and safe buyer retry after navigation/restart;
|
||||
do not issue another payment on an uncertain or successful attempt.
|
||||
- [ ] Cache Lightning purchases, preserve ownership, optional Files copy, free repeat.
|
||||
- [ ] Diagnose mobile companion uploads on the affected route/device.
|
||||
- [ ] Real progress in the existing compact upload bar; no increased height.
|
||||
- [ ] Preserve uploads/progress across screens and original batch destination.
|
||||
- [ ] Cancel active transfer and queued files; truthful partial/error/server-save status.
|
||||
- [ ] Transparent transaction-filter container; single horizontal scrolling mobile row.
|
||||
- [ ] Immich displayed as one app, internal components hidden; diagnose restarting services.
|
||||
- [ ] Diagnose unwanted CryptPad after upgrade, failed uninstall, and persistent removal.
|
||||
- [ ] Identify the other removed unexpected service from affected-node records.
|
||||
- [ ] Upgrade regression matrix: installed/stopped/restarting/removed/legacy apps,
|
||||
aliases, dependencies, inventory, desired-state reconciliation and data preservation.
|
||||
|
||||
- [ ] Portainer duplicate-network migration: retire the redundant managed repair
|
||||
override, preserve operator settings/state, verify generated command,
|
||||
actual request namespace, dashboard readiness and repeated reconciliation.
|
||||
|
||||
- [ ] Lightning cooperative-close fees: Standard/Medium/Fast/Custom selection,
|
||||
explicit default target, strict backend validation and forwarding, error
|
||||
handling, mobile layout and no real channel closure during tests.
|
||||
|
||||
- [ ] Apps search clear control: My Apps, Services and App Store, desktop/mobile,
|
||||
existing design tokens, right-aligned icon, no size change, keyboard focus.
|
||||
|
||||
## Retained release work (previous acceptance is not new-regression acceptance)
|
||||
|
||||
- Mempool patched image/catalog version agreement, update-button clearing, one card.
|
||||
- Minibits PR160, Lightning address availability, concise single-column backup copy.
|
||||
- Framework LND startup/Receive and unknown-vs-zero balance behavior.
|
||||
- Friendly Bitcoin warmup; LND waiting for install/sync; Bitcoin UI during IBD;
|
||||
headless Phoenixd without self-waiting or bogus launch action.
|
||||
- Cashu same-mint paid files, exact amounts/change/refund, errors, stored bytes,
|
||||
Files copy and repeat access without re-payment.
|
||||
- mempool.space public explorer fallback, preserving local/custom configuration.
|
||||
- Optional install pruning and consistent automatic-pruning policy.
|
||||
- X250 kiosk version picker layering/contrast and pruning layout.
|
||||
- AIUI single desktop/mobile background, transparent embedded layers,
|
||||
preserved standalone wallpaper.
|
||||
- PR review/fixes/tests and normal merge/closure (160 previously shipped;
|
||||
161/162 merged and included in 1.8.22).
|
||||
- Installed inventory retained during app restart/hard-refresh.
|
||||
- Correct iframe/browser launch readiness, useful errors and delayed startup.
|
||||
- GitWorkshop payload/build contexts, progress and persistence after refresh.
|
||||
- Gitea/Portainer same-server Git from actual request namespace; URLs, auth,
|
||||
fresh installation in either order, migration/rollback, restart/reboot,
|
||||
Git/SSH/LFS/registry/browser compatibility and data/stack preservation.
|
||||
- NPM correct admin port/URL, malformed URL behavior, bind-aware readiness,
|
||||
persistent backed-up tunnel/LND port-conflict repair on OTA and ISO.
|
||||
- Angor headless indexer on DEV BOX only, full unpruned Bitcoin/Mempool/ElectrumX
|
||||
prerequisites, optional separate relay, official icon with green white areas.
|
||||
- Compact named readiness messages and bottom-aligned app-card actions.
|
||||
- Remove unused integration/build fixtures from Apps/Services, preserve app data.
|
||||
- Safe network doctor, no all-app stop/reset on failed egress probe.
|
||||
- No orphan companion resurrection; retain existing companion security repairs.
|
||||
- Current companion image registry, build contexts, runtime asset promotion order,
|
||||
generated-service argument quoting and graceful Bitcoin/LND shutdown.
|
||||
- OTA + RAW ISO, root signatures/catalog compatibility/checksums, independently
|
||||
verified public files, Git/ngit source/releases and fleet discovery.
|
||||
- Correct LAN SCP command for the new ISO.
|
||||
|
||||
## Explicit boundaries/follow-ups
|
||||
|
||||
- Full-chain Angor indexing awaits development Bitcoin IBD.
|
||||
- Primal automatic comment exceeding Minibits metadata limit: previously accepted
|
||||
upstream limitation, no unsupported local identity/metadata rewrite.
|
||||
- Lost-response ecash seller receipt redesign is a separately accepted follow-up;
|
||||
do not claim an uncertain refund completed or automatically pay twice.
|
||||
- Optional external-provider/hardware tests must be labelled if not exercised.
|
||||
|
||||
## Initial source evidence
|
||||
|
||||
`PeerFiles.vue::payWithLightning` immediately downloaded after buyer payment,
|
||||
while only seller `handle_content_invoice_status` marked a pending invoice paid.
|
||||
Seller download checked only that cached flag. This matches the reported error
|
||||
and was confirmed against the live seller: LND retained a settled invoice while
|
||||
the seller invoice-status endpoint returned HTTP 404 after management restart.
|
||||
`content_invoice.rs` stored all entitlements only in process memory with a
|
||||
one-hour TTL, losing both pending and paid access on restart/expiry.
|
||||
Lightning download returned transient base64 without the Cashu ownership cache.
|
||||
CloudFolder's view-local spinner had no byte progress/cancel; batch upload read
|
||||
`currentPath` independently for each file, allowing navigation to move destinations.
|
||||
Immich's underscore dependencies are scanner-excluded; hyphen manifest IDs are
|
||||
not. Live inventory confirmed both hyphenated synthetic entries while the
|
||||
actual underscore-named containers had remained running for nine days.
|
||||
|
||||
## Access / acceptance
|
||||
|
||||
Operator provided updated Framework SSH authentication privately in chat.
|
||||
Do not put credentials or deployment addresses in this public document.
|
||||
Framework was reached over SSH. Native Bitcoin, LND and all three Immich
|
||||
container identities/start times were recorded before candidate deployment.
|
||||
The kiosk is at its login page. Dashboard password authentication succeeds but
|
||||
requires the operator's second factor; normal uninstall acceptance remains pending.
|
||||
|
||||
Confirmed live evidence:
|
||||
|
||||
- A 10,000-sat peer-file invoice settled at 12:19:29 UTC. After the management
|
||||
service restarted at 12:50, invoice-status returned unknown invoice. Buyer
|
||||
identity and confirmation that this is the reported sale remain pending.
|
||||
- The matching item currently allows free access; preserve that operator setting.
|
||||
- CryptPad has no container but remains in installed-apps metadata. Uninstall
|
||||
repeatedly aborts because the removed catalog ID has no manifest.
|
||||
- Immich server/database/cache are running; synthetic hyphenated dependencies
|
||||
appear stopped and the recovery overlay briefly advertises restarting.
|
||||
- The other removed service was Core Lightning; uninstall tombstones exist.
|
||||
- No Android resource-upload POST appears in the inspected recent nginx log.
|
||||
This does not establish why the affected companion failed.
|
||||
|
||||
## Candidate implementation and validation
|
||||
|
||||
Source changes persist seller entitlements with atomic writes, verify settlement
|
||||
at delivery, recover older Lightning entitlements from the seller's LND invoice,
|
||||
perform the status handshake for older sellers, and cache delivered Lightning
|
||||
files. Buyer purchase bytes and the shared ownership index now use atomic,
|
||||
synced writes and a serialized read/modify/write transaction; a corrupt index
|
||||
fails the write instead of silently replacing existing ownership. The browser saves the invoice before payment and retries delivery without
|
||||
another payment. Browser receipts are not yet a node-wide recovery store.
|
||||
|
||||
The upload queue now belongs to the shared Cloud store, captures its original
|
||||
folder, reports actual sent bytes and server completion, and cancels its active
|
||||
XHR and remaining queue. The fixed-height bar remains available across routes.
|
||||
Transaction filters use a transparent container and one scrollable row. Immich
|
||||
aliases normalize to their real component names and internal cards are hidden.
|
||||
Unknown catalog entries no longer prevent the regular uninstall flow.
|
||||
|
||||
Validation so far (additional acceptance still pending):
|
||||
|
||||
- Final isolated backend suite: **1,631 passed**, zero failed, four optional
|
||||
tests ignored. This includes invoice settlement/amount boundaries, durable
|
||||
seller records, concurrent buyer ownership, damaged-index preservation,
|
||||
Portainer override retirement/idempotence/customization/backup failures,
|
||||
recovery overlays and channel-close fee forwarding/validation.
|
||||
- Final frontend suite: **1,157 passed** across 142 files. Production build
|
||||
passed. Six payment-recovery and twelve channel-close tests are included.
|
||||
- Real FileBrowser uploads at 1440px and 390px: exact bytes and original folder
|
||||
verified after navigation, 44px bar, cancellation and queue stop passed.
|
||||
- Mobile viewport acceptance is not physical Android companion acceptance.
|
||||
- An earlier candidate release backend compiled successfully. The final build,
|
||||
including serialized buyer ownership writes, is still in progress. Candidate
|
||||
deployment and another OTA/ISO remain pending. Published 1.8.22 artifacts
|
||||
remain unchanged.
|
||||
|
||||
Release gates still include actual-node payment recovery/delivery, durable
|
||||
CryptPad removal through normal controls, Immich inventory after refresh/restart,
|
||||
physical companion diagnosis, and remaining upgrade regression acceptance.
|
||||
No new payments, native-service restarts or wallet changes were used in testing.
|
||||
|
||||
## Additional live Portainer regression
|
||||
|
||||
The X250 user service exited 125 because the generated command supplied
|
||||
`--network slirp4netns` twice. The manifest already supplies the network, while
|
||||
an older Archipelago-created `archy-same-node-network.conf` drop-in adds it
|
||||
again. Quadlet's Network directives accumulate; they do not override each other.
|
||||
This repair artifact should have been retired when the declarative fix shipped.
|
||||
|
||||
The live repair backed up the override and Portainer state, removed only the
|
||||
exact redundant override, reloaded user systemd and restarted Portainer. API
|
||||
status returned HTTP 200 with version 2.45.0; the actual kiosk's package state
|
||||
reported running and UI-ready. Bitcoin/LND and the production site's container
|
||||
identities/start times remained unchanged. The source migration now detects
|
||||
this exact managed override before preparing the persistent restart obligation,
|
||||
backs up app state, retires the redundant file with a retained copy, and reloads
|
||||
and restarts through normal reconciliation. Custom overrides are preserved.
|
||||
Automated migration coverage passed; final candidate deployment remains pending.
|
||||
|
||||
## Channel-close fee selection
|
||||
|
||||
The existing close UI sent only the channel point, and the backend forwarded
|
||||
only `force=false`. LND therefore used its lax default confirmation target.
|
||||
The candidate reuses the channel-opening fee choices (six/three/one block target,
|
||||
or custom target/rate), explicitly sends six blocks for legacy clients that omit
|
||||
fees, and validates query parameters before accessing the wallet. Cooperative
|
||||
fees are never silently applied to force closes. Close RPC retries are disabled
|
||||
so a timeout cannot silently repeat this mutation.
|
||||
|
||||
Protocol reference: [LND CloseChannel](https://lightning.engineering/api-docs/api/lnd/lightning/close-channel/).
|
||||
Fee targets are estimates, not guaranteed confirmation times. Tests use mocked
|
||||
requests; no production channel is closed to verify the feature.
|
||||
|
||||
Additional browser acceptance:
|
||||
|
||||
- Transaction filters at 390px: computed transparent background, one row and
|
||||
horizontal overflow verified.
|
||||
- Close-channel selector at 1440px and 390px: preset/custom controls visible,
|
||||
no overflow, custom 25 sat/vB forwarded. The close request was intercepted;
|
||||
no real channel closure or wallet mutation occurred.
|
||||
- All three Apps search screens at both widths: clear icon stays inside the
|
||||
field; click/Escape clear; input retains focus; desktop 40px/mobile 52px heights
|
||||
stay unchanged. Shared design-system search-field classes are retained.
|
||||
- Portainer remained active with zero service restarts and no pending marker.
|
||||
Its real network namespace read smart HTTP Git refs and the Compose file.
|
||||
Original persistent mounts were unchanged. The old integration test containers
|
||||
are absent from dev, Framework and X250. One leftover upload-test folder was
|
||||
removed after checking it contained only this task's test files.
|
||||
|
||||
## Build resource observation
|
||||
|
||||
The final optimized compile coincided with heavy memory/disk pressure and local
|
||||
Bitcoin/LND RPC timeouts on the development node. After pausing the compiler,
|
||||
both authenticated RPCs responded again; Bitcoin reported height 506400 and
|
||||
19.6% verification progress, with LND waiting for chain sync. No native service
|
||||
was restarted. Compilation resumed in a separate user scope limited to one CPU,
|
||||
with nice 19 and idle I/O priority. This is evidence of resource contention,
|
||||
not proof of a new wallet or startup defect. Verify native RPC health again
|
||||
before candidate deployment.
|
||||
@@ -6,8 +6,8 @@ Reviewed both open PRs from the repository pull-request list: [#161](https://sou
|
||||
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. They remain open for integration into the release after
|
||||
1.8.21; no reviewed code was merged into main or deployed to a live wallet.
|
||||
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 |
|
||||
@@ -95,8 +95,8 @@ Logs on the development box:
|
||||
|
||||
## Next-release acceptance and limits
|
||||
|
||||
- Integrate the reviewed branches and repeat the release gates against the
|
||||
final release commit if additional code changes land.
|
||||
- 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.
|
||||
@@ -109,5 +109,18 @@ Logs on the development box:
|
||||
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 version-selector rendering issue remains
|
||||
open in `TODO.md` and requires validation on the actual kiosk.
|
||||
- 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,101 @@
|
||||
# Archipelago 1.8.22-alpha acceptance
|
||||
|
||||
Source: `6d5f3ffb850bfd3dcd396bac986ba770935d1daa`.
|
||||
|
||||
## Verified application and runtime changes
|
||||
|
||||
- Full isolated backend suite: 1,617 passed, zero failed, four explicit opt-in exclusions.
|
||||
- Frontend suite: 1,133 passed. Final frontend and AIUI production builds succeeded.
|
||||
- Container suite: 79 passed. Catalog compatibility/trust, release manifest, build contexts, pruning, Lightning readiness, NPM migration, safe doctor, companion recovery and ISO doctor-overlay regressions passed.
|
||||
- Six companion dashboard images built using the current registry.
|
||||
- Final OTA backend SHA-256: `e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b`.
|
||||
- Final OTA frontend SHA-256: `2da485a2da75ff2fbe4aba52d6f217150e303be43a031723480c9c4ff9d43f41`.
|
||||
|
||||
## Live acceptance
|
||||
|
||||
The exact OTA bytes were deployed to the development box and ThinkPad X250.
|
||||
Native Bitcoin/LND and the X250 production site retained their container identity
|
||||
and start time during these final management deployments. Both boxes completed
|
||||
12-minute observations including scheduled diagnostics with running containers
|
||||
and persistent mounts unchanged. The orphaned Cuprate dashboard stayed absent.
|
||||
|
||||
Actual X250 Chromium kiosk: hard refresh, NPM Launch to the correct admin URL,
|
||||
visible login/admin page, readable inline Bitcoin version choices and pruning
|
||||
checkbox passed. No Bitcoin installation was triggered by this test.
|
||||
|
||||
Final desktop/mobile checks passed for Bitcoin's IBD dashboard, one Mempool card,
|
||||
headless Phoenixd, LND waiting/unknown-balance behavior and all five transparent
|
||||
AIUI embedding layers. Standalone AIUI retains its wallpaper.
|
||||
|
||||
Portainer's actual production network namespace fetched Git refs and the Compose
|
||||
file after final deployment. Original mounts were preserved. Earlier disposable
|
||||
fresh/reverse-install and migration/rollback tests, and the production host's
|
||||
operator-initiated reboot check, passed.
|
||||
|
||||
Live Tor-only Cashu paid-file acceptance verified a one-satoshi net purchase,
|
||||
change, rejected-payment refund, exact file bytes, Files access and free repeat
|
||||
delivery. No native Bitcoin/LND funds were moved. PRs 161/162 are merged and
|
||||
closed; the open pull-request list is empty.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Angor's real dev API, fees, block tip, CORS and rootless/headless configuration
|
||||
passed. Full-chain indexing remains dependent on initial Bitcoin sync finishing.
|
||||
- Optional live AI providers, physical RNode hardware, the opt-in Reticulum TCP
|
||||
subprocess test and creation of a production Minibits profile were not run.
|
||||
- The previously recorded unsafe-doctor incident changed X250 container start
|
||||
times before the final fix. Persistent databases were present after recovery,
|
||||
but no pre-incident cryptographic wallet-identity baseline was available.
|
||||
Do not describe recovery evidence as an exact pre-incident balance comparison.
|
||||
- No claim of perfect behavior on every device, network or future failure is made.
|
||||
|
||||
## Raw ISO acceptance
|
||||
|
||||
The raw ISO is 2,755,072,000 bytes. SHA-256:
|
||||
`cf7be6378dcd52f6f62774523341fa75dd453fa73a9cadff5390846f483e0140`.
|
||||
|
||||
Mounted-artifact smoke checks passed, including BIOS/UEFI boot files, live-boot
|
||||
hooks, build contexts, current doctor overlay, crash-capture configuration,
|
||||
version and frontend payload. The ISO backend and all 653 OTA frontend/runtime
|
||||
files match exactly. AIUI metadata names the tested source commit.
|
||||
|
||||
A disposable QEMU/KVM x86_64 VM with UEFI firmware, 3 GiB RAM, two vCPUs, a fresh
|
||||
64 GiB NVMe virtual disk and no network completed the full installation. This
|
||||
covered partitioning, LUKS2 data encryption, swap, system configuration, UEFI
|
||||
bootloader and initramfs generation. Cold boot with the ISO detached reached the
|
||||
visible Welcome to Archipelago setup screen. The installed backend and both
|
||||
historical/current doctor paths matched source hashes. Backend/nginx were active;
|
||||
health reported RPC/sessions ready, crash recovery complete and version 1.8.22.
|
||||
No wallet was initialized in this disposable VM.
|
||||
|
||||
The first automatic VM reboot selected the still-attached installer ISO. That
|
||||
was corrected in the test configuration by detaching the ISO and explicitly
|
||||
booting NVMe. It was not accepted as an installed-system boot. The subsequent
|
||||
cold boot above is the successful acceptance run.
|
||||
|
||||
The dev native Bitcoin/LND identity/start-time baseline also remained unchanged
|
||||
after the ISO build and VM acceptance.
|
||||
|
||||
## Publication verification
|
||||
|
||||
All three operator signatures verify against the pinned release root. The five
|
||||
Gitea assets match independent server-side SHA-256 checks. Both OTA components
|
||||
also passed complete public HTTPS downloads with exact hashes and sizes; the
|
||||
raw ISO passed public size/range checks and both checksum sidecars read back
|
||||
exactly. Only after these checks were the signed OTA manifest and compatible
|
||||
app catalog promoted. The release tag identifies the tested source above.
|
||||
|
||||
Git and ngit publication completed on 2026-10-01. Both repository relays
|
||||
acknowledged the ngit release; independent `release view` resolved all five
|
||||
assets with exact hashes and sizes and no unresolved asset IDs. Main and the
|
||||
release tag were pushed to both remotes.
|
||||
|
||||
Public main-branch OTA manifests and the app catalog read back byte-for-byte
|
||||
and verified cryptographically. Live `update.check` on the accepted dev node
|
||||
reported 1.8.22-alpha with no further update, as expected for the installed
|
||||
release. Discovery on an older production node was not repeated during this
|
||||
publication step.
|
||||
|
||||
- [Release and verification files](https://source.archipelago-foundation.org/lfg2025/archy/releases/tag/v1.8.22-alpha)
|
||||
- [Raw ISO](https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago-installer-1.8.22-alpha-unbundled-x86_64_RC1.iso)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Repair and release execution — 2026-09-29
|
||||
|
||||
**Status: IN PROGRESS. Do not publish an OTA or ISO until the release gates pass.**
|
||||
**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.
|
||||
@@ -52,8 +55,8 @@ next OTA and raw ISO. Passing unit tests alone does not establish live correctne
|
||||
- [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.
|
||||
- [ ] Signed OTA built, tested, published to git and ngit.
|
||||
- [ ] Raw ISO built, boot-tested, signed and published; download command supplied.
|
||||
- [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
|
||||
|
||||
@@ -2607,6 +2607,11 @@ if [ -f "$SCRIPT_DIR/../../scripts/image-versions.sh" ]; then
|
||||
echo " ✅ Bundled image-versions.sh"
|
||||
fi
|
||||
|
||||
# Always overlay the current doctor, including when rootfs.tar is cached.
|
||||
cp "$SCRIPT_DIR/../../scripts/container-doctor.sh" "$ARCH_DIR/scripts/"
|
||||
cp "$SCRIPT_DIR/../configs/archipelago-doctor.service" "$ARCH_DIR/scripts/"
|
||||
cp "$SCRIPT_DIR/../configs/archipelago-doctor.timer" "$ARCH_DIR/scripts/"
|
||||
|
||||
# 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%.
|
||||
@@ -3230,6 +3235,18 @@ for test_script in run-e2e-tests.sh run-post-install-tests.sh; do
|
||||
fi
|
||||
done
|
||||
|
||||
# BEGIN DOCTOR OVERLAY
|
||||
# Replace both the active and historical script locations before first boot.
|
||||
# A cached rootfs can contain the unsafe network recovery implementation.
|
||||
mkdir -p /mnt/target/opt/archipelago/scripts /mnt/target/home/archipelago/archy/scripts
|
||||
for doctor_dir in /mnt/target/opt/archipelago/scripts /mnt/target/home/archipelago/archy/scripts; do
|
||||
install -m 755 "$BOOT_MEDIA/archipelago/scripts/container-doctor.sh" "$doctor_dir/container-doctor.sh" || exit 1
|
||||
done
|
||||
for doctor_unit in archipelago-doctor.service archipelago-doctor.timer; do
|
||||
install -m 644 "$BOOT_MEDIA/archipelago/scripts/$doctor_unit" "/mnt/target/etc/systemd/system/$doctor_unit" || exit 1
|
||||
done
|
||||
# END DOCTOR OVERLAY
|
||||
|
||||
# Copy self-update script
|
||||
if [ -f "$BOOT_MEDIA/archipelago/scripts/self-update.sh" ]; then
|
||||
cp "$BOOT_MEDIA/archipelago/scripts/self-update.sh" /mnt/target/opt/archipelago/scripts/
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.21-alpha",
|
||||
"version": "1.8.22-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.21-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.21-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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
<AppLauncherOverlay />
|
||||
<AppCredentialInterstitial />
|
||||
|
||||
<UploadProgress v-if="route.name !== 'cloud-folder'" floating />
|
||||
|
||||
<!-- Global toast notifications -->
|
||||
<ToastStack />
|
||||
|
||||
@@ -96,6 +98,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import UploadProgress from '@/components/cloud/UploadProgress.vue'
|
||||
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import SplashScreen from './components/SplashScreen.vue'
|
||||
|
||||
@@ -336,3 +336,45 @@ describe('sanitizePath', () => {
|
||||
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload transport progress and cancellation', () => {
|
||||
class UploadXHR {
|
||||
static instances: UploadXHR[] = []
|
||||
upload = { onprogress: null as ((e: { loaded: number }) => void) | null }
|
||||
onload: (() => void) | null = null
|
||||
onabort: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
status = 200
|
||||
withCredentials = false
|
||||
contentType = 'application/json'
|
||||
open = vi.fn(); setRequestHeader = vi.fn(); send = vi.fn()
|
||||
abort = vi.fn(() => this.onabort?.())
|
||||
getResponseHeader() { return this.contentType }
|
||||
constructor() { UploadXHR.instances.push(this) }
|
||||
}
|
||||
beforeEach(() => { setAuthenticated(); UploadXHR.instances = []; vi.stubGlobal('XMLHttpRequest', UploadXHR) })
|
||||
it('sends the file bytes, escapes folder names, and waits for server acceptance after 100%', async () => {
|
||||
const controller = new AbortController(); const onProgress = vi.fn()
|
||||
const file = new File(['data'], 'a #.txt')
|
||||
let complete = false
|
||||
const job = fileBrowserClient.upload('/folder #1', file, { signal: controller.signal, onProgress }).then(() => { complete = true })
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
const xhr = UploadXHR.instances[0]!
|
||||
expect(xhr.open).toHaveBeenCalledWith('POST', expect.stringMatching(/\/app\/filebrowser\/api\/resources\/folder%20%231\/a%20%23.txt\?override=true$/))
|
||||
expect(xhr.send).toHaveBeenCalledWith(file)
|
||||
xhr.upload.onprogress?.({ loaded: 4 }); expect(onProgress).toHaveBeenCalledWith(4)
|
||||
expect(complete).toBe(false)
|
||||
xhr.onload?.(); await job; expect(complete).toBe(true)
|
||||
})
|
||||
it('cancels the actual request and refuses HTML masquerading as upload success', async () => {
|
||||
const controller = new AbortController()
|
||||
const job = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: controller.signal, onProgress: vi.fn() })
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
controller.abort(); await expect(job).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(UploadXHR.instances[0]!.abort).toHaveBeenCalled()
|
||||
const next = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: new AbortController().signal, onProgress: vi.fn() })
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
const xhr = UploadXHR.instances[1]!; xhr.contentType = 'text/html'; xhr.onload?.()
|
||||
await expect(next).rejects.toThrow('login page')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -201,7 +201,43 @@ class FileBrowserClient {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
|
||||
async upload(dirPath: string, file: File): Promise<void> {
|
||||
async upload(dirPath: string, file: File, options?: { signal: AbortSignal; onProgress: (sent: number) => void }): Promise<void> {
|
||||
if (options) {
|
||||
await this.ensureAuth()
|
||||
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
const folder = sanitizePath(dirPath).split('/').map(encodeURIComponent).join('/').replace(/\/$/, '')
|
||||
const url = `${this.baseUrl}/api/resources${folder}/${encodeURIComponent(file.name)}?override=true`
|
||||
const send = () => new Promise<number>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const cleanup = () => options.signal.removeEventListener('abort', abort)
|
||||
const abort = () => { xhr.abort(); cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
|
||||
xhr.open('POST', url)
|
||||
xhr.withCredentials = true
|
||||
for (const [key, value] of Object.entries(this.headers())) xhr.setRequestHeader(key, value)
|
||||
xhr.upload.onprogress = (event) => options.onProgress(Math.min(file.size, event.loaded))
|
||||
xhr.onerror = () => { cleanup(); reject(new Error('Upload connection lost. Keep the companion open and check the server connection.')) }
|
||||
xhr.onabort = () => { cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
|
||||
xhr.onload = () => {
|
||||
cleanup()
|
||||
if (xhr.status === 401) { resolve(401); return }
|
||||
if (xhr.status < 200 || xhr.status >= 300) { reject(new Error(`Upload failed (HTTP ${xhr.status})`)); return }
|
||||
if ((xhr.getResponseHeader('Content-Type') || '').includes('text/html')) {
|
||||
reject(new Error('File Browser returned a login page instead of accepting the upload.')); return
|
||||
}
|
||||
resolve(xhr.status)
|
||||
}
|
||||
options.signal.addEventListener('abort', abort, { once: true })
|
||||
if (options.signal.aborted) { abort(); return }
|
||||
xhr.send(file)
|
||||
})
|
||||
if (await send() === 401) {
|
||||
this._authenticated = false
|
||||
await this.ensureAuth()
|
||||
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
if (await send() === 401) throw new Error('Upload authentication expired. Sign in again.')
|
||||
}
|
||||
return
|
||||
}
|
||||
const sanitized = sanitizePath(dirPath)
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
const encodedName = encodeURIComponent(file.name)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<input
|
||||
ref="input"
|
||||
v-model="query"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
:aria-label="label"
|
||||
data-controller-no-submit
|
||||
class="app-header-search w-full pr-10 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
@keydown.esc.prevent="clear"
|
||||
/>
|
||||
<button
|
||||
v-if="query.length"
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
title="Clear search"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-400/60 transition-colors"
|
||||
@click="clear"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6 6 12 12M6 18 18 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from 'vue'
|
||||
defineProps<{ placeholder: string; label: string }>()
|
||||
const query = defineModel<string>({ default: '' })
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
async function clear() {
|
||||
query.value = ''
|
||||
await nextTick()
|
||||
input.value?.focus()
|
||||
}
|
||||
</script>
|
||||
@@ -353,15 +353,57 @@
|
||||
|
||||
<!-- Close Confirmation Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
|
||||
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="!closingChannel && (closeTarget = null)">
|
||||
<div class="glass-card p-6 w-full max-w-sm mx-4">
|
||||
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
|
||||
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
|
||||
<!-- Fee selection -->
|
||||
<div class="mb-4">
|
||||
<label class="text-white/60 text-sm block mb-1">Fee</label>
|
||||
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="preset in feePresets"
|
||||
:key="preset.key"
|
||||
@click="closeForm.feePreset = preset.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="closeForm.feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ preset.label }}</button>
|
||||
</div>
|
||||
<p v-if="closeForm.feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
|
||||
{{ feePresets.find(p => p.key === closeForm.feePreset)?.hint }}
|
||||
</p>
|
||||
<div v-else class="grid grid-cols-2 gap-3 mt-2">
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Target confirmations</label>
|
||||
<input
|
||||
v-model.number="closeForm.customConfTarget"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1008"
|
||||
placeholder="6"
|
||||
class="w-full input-glass"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
|
||||
<input
|
||||
v-model.number="closeForm.customSatPerVbyte"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5000"
|
||||
placeholder="—"
|
||||
class="w-full input-glass"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-white/40 text-xs col-span-2">Set one — sats per vByte takes precedence when both are set</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="closeError" class="mb-3 alert-error">
|
||||
<p class="text-xs">{{ closeError }}</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||
<button @click="closeTarget = null" :disabled="closingChannel" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||
<button
|
||||
@click="closeChannel"
|
||||
:disabled="closingChannel"
|
||||
@@ -457,8 +499,8 @@ function closeTypeLabel(ch: ClosedChannel): string {
|
||||
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
|
||||
|
||||
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
|
||||
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
|
||||
{ key: 'medium', label: 'Medium', hint: 'Confirms within ~3 blocks (about 30 minutes)', confTarget: 3 },
|
||||
{ key: 'standard', label: 'Standard', hint: 'Targets ~6 blocks (about an hour)', confTarget: 6 },
|
||||
{ key: 'medium', label: 'Medium', hint: 'Targets ~3 blocks (about 30 minutes)', confTarget: 3 },
|
||||
{ key: 'fast', label: 'Fast', hint: 'Targets the next block', confTarget: 1 },
|
||||
{ key: 'custom', label: 'Custom' },
|
||||
]
|
||||
@@ -577,22 +619,24 @@ function loadChannels(): Promise<void> {
|
||||
return main
|
||||
}
|
||||
|
||||
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
|
||||
const form = openForm.value
|
||||
function feeParams(
|
||||
form: { feePreset: FeePreset; customSatPerVbyte: number | null; customConfTarget: number | null } = openForm.value,
|
||||
setError: (message: string) => void = message => { openError.value = message },
|
||||
): { target_conf?: number; sat_per_vbyte?: number } | null {
|
||||
if (form.feePreset !== 'custom') {
|
||||
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 }
|
||||
}
|
||||
const rate = form.customSatPerVbyte
|
||||
const conf = form.customConfTarget
|
||||
if (rate != null && rate !== 0) {
|
||||
if (rate < 1 || rate > 5000) { openError.value = 'Sats per vByte must be between 1 and 5000'; return null }
|
||||
if (!Number.isInteger(rate) || rate < 1 || rate > 5000) { setError('Sats per vByte must be a whole number between 1 and 5000'); return null }
|
||||
return { sat_per_vbyte: Math.floor(rate) }
|
||||
}
|
||||
if (conf != null && conf !== 0) {
|
||||
if (conf < 1 || conf > 1008) { openError.value = 'Target confirmations must be between 1 and 1008'; return null }
|
||||
if (!Number.isInteger(conf) || conf < 1 || conf > 1008) { setError('Target confirmations must be a whole number between 1 and 1008'); return null }
|
||||
return { target_conf: Math.floor(conf) }
|
||||
}
|
||||
openError.value = 'Custom fee requires target confirmations or sats per vByte'
|
||||
setError('Custom fee requires target confirmations or sats per vByte')
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -629,7 +673,12 @@ async function openChannel() {
|
||||
}
|
||||
}
|
||||
|
||||
const defaultCloseForm = () => ({ feePreset: 'standard' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null })
|
||||
const closeForm = ref(defaultCloseForm())
|
||||
|
||||
function confirmClose(ch: Channel) {
|
||||
if (closingChannel.value) return
|
||||
closeForm.value = defaultCloseForm()
|
||||
closeTarget.value = ch
|
||||
closeError.value = null
|
||||
}
|
||||
@@ -637,12 +686,15 @@ function confirmClose(ch: Channel) {
|
||||
async function closeChannel() {
|
||||
if (closingChannel.value || !closeTarget.value) return
|
||||
closeError.value = null
|
||||
const fee = feeParams(closeForm.value, message => { closeError.value = message })
|
||||
if (!fee) return
|
||||
closingChannel.value = true
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'lnd.closechannel',
|
||||
params: { channel_point: closeTarget.value.channel_point },
|
||||
timeout: 30000,
|
||||
params: { channel_point: closeTarget.value.channel_point, ...fee },
|
||||
timeout: 45000,
|
||||
maxRetries: 1,
|
||||
})
|
||||
closeTarget.value = null
|
||||
await loadChannels()
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
<!-- Transparent glass, not a black slab (operator, 2026-08-09): the
|
||||
backdrop blur alone keeps the pinned tabs legible over scrolling
|
||||
rows without painting an opaque container onto the modal. -->
|
||||
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-wrap bg-white/5 backdrop-blur-md">
|
||||
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-nowrap overflow-x-auto bg-transparent backdrop-blur-md">
|
||||
<button
|
||||
v-for="f in filters"
|
||||
:key="f.key"
|
||||
class="px-2.5 py-1 rounded-full text-xs transition-colors"
|
||||
class="shrink-0 whitespace-nowrap px-2.5 py-1 rounded-full text-xs transition-colors"
|
||||
:class="activeFilter === f.key
|
||||
? 'bg-orange-500/25 text-orange-200 border border-orange-400/40'
|
||||
: 'bg-white/5 text-white/50 border border-white/10 hover:text-white/80'"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import LightningChannelsPanel from '../LightningChannelsPanel.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||
vi.mock('@/composables/useTxExplorer', () => ({ useTxExplorer: () => ({ openTx: vi.fn() }) }))
|
||||
vi.mock('@/composables/useCachedResource', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return { useCachedResource: () => ({ data: ref(null), loadState: ref('ready'), error: ref(null), refresh: vi.fn().mockResolvedValue(undefined) }) }
|
||||
})
|
||||
const channel = { chan_id: 'test', remote_pubkey: '02' + 'a'.repeat(64), channel_point: 'b'.repeat(64) + ':0', capacity: 100000, local_balance: 50000, remote_balance: 50000, active: true }
|
||||
function open() {
|
||||
const wrapper = mount(LightningChannelsPanel, { global: { stubs: { Teleport: true } } })
|
||||
const vm = (wrapper.vm as any).$.setupState
|
||||
vm.confirmClose(channel)
|
||||
return { wrapper, vm }
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockResolvedValue({ success: true } as never) })
|
||||
describe('channel closing fee choice', () => {
|
||||
it.each([['standard', 6], ['medium', 3], ['fast', 1]])('forwards %s target and never automatically retries the mutation', async (preset, target) => {
|
||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = preset
|
||||
await vm.closeChannel()
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.closechannel', params: { channel_point: channel.channel_point, target_conf: target }, maxRetries: 1 }))
|
||||
expect(vm.closeTarget).toBeNull(); wrapper.unmount()
|
||||
})
|
||||
it('sends the custom rate instead of a confirmation target', async () => {
|
||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'; vm.closeForm.customSatPerVbyte = 25; vm.closeForm.customConfTarget = 3
|
||||
await vm.closeChannel()
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, sat_per_vbyte: 25 } }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([0.5, -1, 5001, NaN, Infinity])('rejects invalid custom rate %s before RPC', async rate => {
|
||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'; vm.closeForm.customSatPerVbyte = rate
|
||||
await vm.closeChannel(); expect(rpcClient.call).not.toHaveBeenCalled(); expect(vm.closeError).toBeTruthy(); wrapper.unmount()
|
||||
})
|
||||
it('requires a custom value and accepts a custom confirmation target', async () => {
|
||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'
|
||||
await vm.closeChannel(); expect(rpcClient.call).not.toHaveBeenCalled()
|
||||
vm.closeForm.customConfTarget = 2; await vm.closeChannel()
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 2 } })); wrapper.unmount()
|
||||
})
|
||||
it('keeps the chosen fee and error visible when LND rejects a close', async () => {
|
||||
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Peer is offline'))
|
||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'fast'
|
||||
await vm.closeChannel(); expect(vm.closeTarget).not.toBeNull(); expect(vm.closeError).toBe('Peer is offline'); expect(vm.closeForm.feePreset).toBe('fast'); wrapper.unmount()
|
||||
})
|
||||
it('prevents duplicate submits while a close is pending', async () => {
|
||||
let finish!: (value: unknown) => void
|
||||
vi.mocked(rpcClient.call).mockImplementation(() => new Promise(resolve => { finish = resolve }) as never)
|
||||
const { wrapper, vm } = open(); const pending = vm.closeChannel(); await vm.closeChannel()
|
||||
expect(rpcClient.call).toHaveBeenCalledTimes(1); finish({ success: true }); await pending; await flushPromises(); wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -45,7 +45,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="glass-button cloud-toolbar-btn" title="Upload file" @click="triggerUpload">
|
||||
<button class="glass-button cloud-toolbar-btn" title="Upload file" :disabled="uploading" @click="triggerUpload">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
@@ -61,6 +61,7 @@
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:disabled="uploading"
|
||||
class="hidden"
|
||||
multiple
|
||||
@change="handleFileSelect"
|
||||
@@ -74,6 +75,7 @@ import { ref } from 'vue'
|
||||
defineProps<{
|
||||
breadcrumbs: { name: string; path: string }[]
|
||||
viewMode: 'list' | 'grid'
|
||||
uploading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div v-if="task" :class="floating ? 'fixed z-50 top-20 left-4 right-4 md:left-auto md:w-96' : 'mb-3 shrink-0'">
|
||||
<div class="glass-card relative overflow-hidden h-11 px-3 flex items-center gap-2" role="status" aria-live="polite">
|
||||
<div v-if="task.active" class="absolute inset-y-0 left-0 bg-emerald-400/10 transition-[width] duration-200 pointer-events-none" :style="{ width: `${percent}%` }" />
|
||||
<div v-if="task.active" class="absolute bottom-0 left-0 h-0.5 bg-emerald-400 transition-[width] duration-200" :style="{ width: `${percent}%` }" role="progressbar" :aria-valuenow="percent" aria-valuemin="0" aria-valuemax="100" :aria-label="`Uploading ${task.filename}`" />
|
||||
<span class="relative min-w-0 flex-1 text-sm truncate" :class="task.error ? 'text-red-300' : 'text-white/80'" :title="label">{{ label }}</span>
|
||||
<span v-if="task.active" class="relative shrink-0 text-xs tabular-nums text-white/60">{{ percent }}%</span>
|
||||
<button class="relative shrink-0 w-8 h-8 flex items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/10" :aria-label="task.active ? 'Cancel upload' : 'Dismiss upload'" @click="task.active ? store.cancelUpload() : store.dismissUpload()">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-width="2" d="m6 6 12 12M6 18 18 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useCloudStore } from '@/stores/cloud'
|
||||
defineProps<{ floating?: boolean }>()
|
||||
const store = useCloudStore()
|
||||
const task = computed(() => store.upload)
|
||||
const percent = computed(() => !task.value ? 0 : task.value.total ? Math.min(100, Math.floor(task.value.sent * 100 / task.value.total)) : task.value.active ? 0 : 100)
|
||||
const label = computed(() => {
|
||||
const t = task.value
|
||||
if (!t) return ''
|
||||
if (t.error) return t.error
|
||||
if (t.cancelled) return `Upload stopped · ${t.completed}/${t.count} saved`
|
||||
if (!t.active) return `${t.count === 1 ? t.filename : `${t.count} files`} uploaded`
|
||||
return `${percent.value === 100 ? 'Saving' : 'Uploading'} ${t.filename}${t.count > 1 ? ` · ${t.completed + 1}/${t.count}` : ''}`
|
||||
})
|
||||
</script>
|
||||
@@ -231,3 +231,50 @@ describe('useCloudStore', () => {
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistent upload batch', () => {
|
||||
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
|
||||
it('retains the destination and byte progress across folder navigation', async () => {
|
||||
const store = useCloudStore(); store.authenticated = true; store.currentPath = '/original'
|
||||
let release!: () => void
|
||||
mockedClient.upload.mockImplementationOnce(async (_path, _file, options) => {
|
||||
options!.onProgress(2)
|
||||
await new Promise<void>(resolve => { release = resolve })
|
||||
}).mockResolvedValueOnce(undefined)
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
const job = store.uploadFiles([new File(['abcd'], 'one'), new File(['ef'], 'two')])
|
||||
expect(store.upload?.sent).toBe(2)
|
||||
expect(store.upload?.total).toBe(6)
|
||||
await store.navigate('/elsewhere')
|
||||
expect(useCloudStore().upload?.active).toBe(true)
|
||||
release(); await job
|
||||
expect(mockedClient.upload.mock.calls.map(call => call[0])).toEqual(['/original', '/original'])
|
||||
expect(store.currentPath).toBe('/elsewhere')
|
||||
expect(store.upload).toMatchObject({ active: false, sent: 6, completed: 2, error: null })
|
||||
})
|
||||
it('aborts the active request, stops the queue and preserves completed files', async () => {
|
||||
const store = useCloudStore(); store.authenticated = true
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
mockedClient.upload.mockResolvedValueOnce(undefined).mockImplementationOnce((_path, _file, options) => new Promise((_resolve, reject) => {
|
||||
options!.signal.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')))
|
||||
}))
|
||||
const files = ['one', 'two', 'three'].map(name => new File(['abc'], name))
|
||||
const job = store.uploadFiles(files)
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
store.cancelUpload(); await job
|
||||
expect(mockedClient.upload).toHaveBeenCalledTimes(2)
|
||||
expect(store.upload).toMatchObject({ active: false, completed: 1, cancelled: true, error: null })
|
||||
store.dismissUpload(); expect(store.upload).toBeNull()
|
||||
})
|
||||
it('keeps failures visible and does not start a second overlapping batch', async () => {
|
||||
const store = useCloudStore(); store.authenticated = true
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
let fail!: (error: Error) => void
|
||||
mockedClient.upload.mockImplementationOnce(() => new Promise((_resolve, reject) => { fail = reject }))
|
||||
const file = new File(['x'], 'one')
|
||||
const job = store.uploadFiles([file]); await store.uploadFiles([file])
|
||||
expect(mockedClient.upload).toHaveBeenCalledTimes(1)
|
||||
fail(new Error('No space')); await job
|
||||
expect(store.upload).toMatchObject({ active: false, error: 'No space', completed: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,41 @@ export const useCloudStore = defineStore('cloud', () => {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const authenticated = ref(false)
|
||||
const upload = ref<{ active: boolean; filename: string; destination: string; sent: number; total: number; completed: number; count: number; error: string | null; cancelled: boolean } | null>(null)
|
||||
let uploadController: AbortController | null = null
|
||||
function cancelUpload() { uploadController?.abort() }
|
||||
function dismissUpload() { if (!upload.value?.active) upload.value = null }
|
||||
async function uploadFiles(files: File[]) {
|
||||
if (!files.length || upload.value?.active) return
|
||||
const destination = currentPath.value
|
||||
const controller = new AbortController()
|
||||
uploadController = controller
|
||||
const task = { active: true, filename: files[0]!.name, destination, sent: 0, total: files.reduce((n, f) => n + f.size, 0), completed: 0, count: files.length, error: null as string | null, cancelled: false }
|
||||
upload.value = task
|
||||
let completedBytes = 0
|
||||
try {
|
||||
for (const file of files) {
|
||||
if (controller.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
upload.value.filename = file.name
|
||||
await fileBrowserClient.upload(destination, file, { signal: controller.signal, onProgress: (sent) => {
|
||||
if (upload.value?.active) upload.value.sent = completedBytes + sent
|
||||
} })
|
||||
completedBytes += file.size
|
||||
upload.value.sent = completedBytes
|
||||
upload.value.completed++
|
||||
}
|
||||
} catch (error) {
|
||||
upload.value.cancelled = controller.signal.aborted
|
||||
if (!upload.value.cancelled) upload.value.error = error instanceof Error ? error.message : 'Upload failed'
|
||||
} finally {
|
||||
upload.value.active = false
|
||||
uploadController = null
|
||||
// Navigation must never redirect subsequent files into the new folder.
|
||||
pathCache.delete(destination)
|
||||
if (currentPath.value === destination) await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Per-path listing cache: re-entering a folder paints the last listing
|
||||
// immediately (no spinner) while the fresh listing loads behind it.
|
||||
const pathCache = new Map<string, FileBrowserItem[]>()
|
||||
@@ -131,6 +166,7 @@ export const useCloudStore = defineStore('cloud', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
upload, uploadFiles, cancelUpload, dismissUpload,
|
||||
currentPath,
|
||||
items,
|
||||
loading,
|
||||
|
||||
@@ -56,14 +56,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="app-header-search-wrap flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
data-controller-no-submit
|
||||
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
<AppSearchField v-model="searchQuery" :placeholder="t('apps.searchPlaceholder')" :label="t('apps.searchLabel')" />
|
||||
<button
|
||||
type="button"
|
||||
class="sideload-icon-btn"
|
||||
@@ -106,14 +99,7 @@
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
data-controller-no-submit
|
||||
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
<AppSearchField v-model="searchQuery" :placeholder="t('apps.searchPlaceholder')" :label="t('apps.searchLabel')" />
|
||||
<button
|
||||
type="button"
|
||||
class="sideload-icon-btn sideload-icon-btn-mobile"
|
||||
@@ -374,6 +360,7 @@ let appsAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppSearchField from '@/components/AppSearchField.vue'
|
||||
import { computed, ref, watch, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UploadProgress />
|
||||
|
||||
<!-- App Not Installed -->
|
||||
<div v-if="!appRunning" class="glass-card p-12 text-center flex-1 flex flex-col items-center justify-center">
|
||||
<svg class="w-20 h-20 text-white/15 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -75,19 +77,11 @@
|
||||
<p class="text-sm text-white/50">Files will be added to the current folder</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload progress -->
|
||||
<div v-if="uploading" class="glass-card p-3 mb-3 flex items-center gap-3">
|
||||
<div class="w-5 h-5 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
||||
<span class="text-sm text-white/70">Uploading...</span>
|
||||
</div>
|
||||
<div v-if="uploadError" class="glass-card p-3 mb-3 flex items-center gap-3 border border-red-500/30">
|
||||
<span class="text-sm text-red-400">{{ uploadError }}</span>
|
||||
<button class="text-xs text-white/50 hover:text-white ml-auto" @click="uploadError = null">Dismiss</button>
|
||||
</div>
|
||||
|
||||
<CloudToolbar
|
||||
:breadcrumbs="cloudStore.breadcrumbs"
|
||||
:view-mode="viewMode"
|
||||
:uploading="!!cloudStore.upload?.active"
|
||||
@navigate="navigateCloudPath"
|
||||
@refresh="cloudStore.refresh()"
|
||||
@upload="handleUpload"
|
||||
@@ -105,6 +99,7 @@
|
||||
:items="cloudStore.sortedItems"
|
||||
:loading="cloudStore.loading"
|
||||
:view-mode="viewMode"
|
||||
:uploading="!!cloudStore.upload?.active"
|
||||
@navigate="navigateCloudPath"
|
||||
@delete="handleDelete"
|
||||
@play="handlePlay"
|
||||
@@ -159,6 +154,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import UploadProgress from '@/components/cloud/UploadProgress.vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
@@ -193,7 +189,6 @@ watch(() => cloudStore.currentPath, (path) => {
|
||||
})
|
||||
|
||||
const iframeLoaded = ref(false)
|
||||
const uploading = ref(false)
|
||||
const folderId = computed(() => route.params.folderId as string)
|
||||
const routeFolderPath = computed(() => normalizeCloudPath(route.query.path, section.value?.initialPath || '/'))
|
||||
|
||||
@@ -348,7 +343,6 @@ function handleShare(path: string, name: string, isDir: boolean) {
|
||||
shareTarget.value = { path, name, isDir }
|
||||
}
|
||||
|
||||
const uploadError = ref<string | null>(null)
|
||||
const draggingOver = ref(false)
|
||||
let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -372,17 +366,7 @@ function onDrop(e: DragEvent) {
|
||||
}
|
||||
|
||||
async function handleUpload(files: File[]) {
|
||||
uploading.value = true
|
||||
uploadError.value = null
|
||||
try {
|
||||
for (const file of files) {
|
||||
await cloudStore.uploadFile(file)
|
||||
}
|
||||
} catch (e) {
|
||||
uploadError.value = e instanceof Error ? e.message : 'Upload failed'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
await cloudStore.uploadFiles(files)
|
||||
}
|
||||
|
||||
async function handleDelete(path: string) {
|
||||
|
||||
@@ -49,14 +49,7 @@
|
||||
{{ section.name }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
aria-label="Search apps"
|
||||
data-controller-no-submit
|
||||
class="app-header-search text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
<AppSearchField v-model="searchQuery" placeholder="Search apps..." label="Search apps" />
|
||||
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
|
||||
</div>
|
||||
|
||||
@@ -82,14 +75,7 @@
|
||||
type="button"
|
||||
>{{ section.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
aria-label="Search apps"
|
||||
data-controller-no-submit
|
||||
class="app-header-search w-full text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
<AppSearchField v-model="searchQuery" placeholder="Search apps..." label="Search apps" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -345,6 +331,7 @@ let discoverAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppSearchField from '@/components/AppSearchField.vue'
|
||||
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
@@ -396,7 +396,7 @@
|
||||
accepts for this item are offered -->
|
||||
<div v-if="payMode === 'choose'" class="space-y-3">
|
||||
<button
|
||||
v-if="acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint')"
|
||||
v-if="!lnReceipt && (acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint'))"
|
||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||
:disabled="ecashPreparing || downloading === payItem.id"
|
||||
@click="prepareEcashPay"
|
||||
@@ -420,8 +420,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>
|
||||
<span class="block text-base text-white">{{ lnPaying ? 'Paying…' : 'Pay with my Lightning node' }}</span>
|
||||
<span class="block text-sm text-white/50">Pays the seller’s invoice from your node’s Lightning wallet</span>
|
||||
<span class="block text-base text-white">{{ lnPaying ? (lnReceipt ? 'Checking payment…' : 'Paying…') : (lnReceipt ? 'Retry paid download' : 'Pay with my Lightning node') }}</span>
|
||||
<span class="block text-sm text-white/50">{{ lnReceipt ? 'Uses the saved payment; does not send more sats' : 'Pays the seller’s invoice from your node’s Lightning wallet' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -843,6 +843,22 @@ const onchainError = ref('')
|
||||
const onchainCopied = ref(false)
|
||||
const lnPaying = ref(false)
|
||||
const lnError = ref('')
|
||||
type LightningReceipt = { bolt11: string; payment_hash: string; price_sats: number }
|
||||
const lnReceipt = ref<LightningReceipt | null>(null)
|
||||
function receiptKey(onion: string, id: string) { return `peer-file-lightning:${onion}:${id}` }
|
||||
function readReceipt(onion: string, id: string): LightningReceipt | null {
|
||||
const raw = localStorage.getItem(receiptKey(onion, id))
|
||||
if (!raw) return null
|
||||
const receipt = JSON.parse(raw) as LightningReceipt
|
||||
if (!receipt.bolt11 || !/^[a-f0-9]{64}$/i.test(receipt.payment_hash)) throw new Error('Saved payment needs recovery. Do not pay again.')
|
||||
return receipt
|
||||
}
|
||||
function keepReceipt(onion: string, id: string, receipt: LightningReceipt) {
|
||||
// Must succeed before handing an invoice to a payer. A failed transfer or
|
||||
// navigation must never turn Retry into a second payment.
|
||||
localStorage.setItem(receiptKey(onion, id), JSON.stringify(receipt))
|
||||
lnReceipt.value = receipt
|
||||
}
|
||||
const onchainPaying = ref(false)
|
||||
let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -1095,6 +1111,8 @@ function openPayModal(item: CatalogItem) {
|
||||
onchainCopied.value = false
|
||||
lnPaying.value = false
|
||||
lnError.value = ''
|
||||
try { lnReceipt.value = readReceipt(props.peerId || currentPeer.value?.onion || '', item.id) }
|
||||
catch { lnError.value = 'Saved payment could not be read. Do not pay again.' }
|
||||
onchainPaying.value = false
|
||||
}
|
||||
|
||||
@@ -1116,6 +1134,8 @@ function closePayModal() {
|
||||
* immediately for any external wallet).
|
||||
*/
|
||||
function openQrPay() {
|
||||
payMode.value = 'qr'
|
||||
if (lnReceipt.value) { qrTab.value = 'lightning'; void payWithInvoice(); return }
|
||||
payMode.value = 'qr'
|
||||
invoiceData.value = null
|
||||
invoiceQr.value = ''
|
||||
@@ -1139,6 +1159,10 @@ function openQrPay() {
|
||||
* forth doesn't silently stop watching for payment). */
|
||||
function selectQrTab(tab: 'onchain' | 'lightning') {
|
||||
if (qrTab.value === tab) return
|
||||
if (tab === 'onchain' && lnReceipt.value) {
|
||||
invoiceError.value = 'A Lightning payment is saved. Recover it before choosing another payment method.'
|
||||
return
|
||||
}
|
||||
qrTab.value = tab
|
||||
if (tab === 'onchain') {
|
||||
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
|
||||
@@ -1338,20 +1362,27 @@ async function prepareEcashPay() {
|
||||
* mobile companion ("paid but never unlocked"); the viewer's Save button
|
||||
* still offers an explicit download.
|
||||
*/
|
||||
function openPurchased(item: CatalogItem, base64Data: string, mimeType?: string) {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
function openPurchased(item: CatalogItem, base64Data: string | undefined, mimeType?: string, seller?: string) {
|
||||
const onion = seller || props.peerId || currentPeer.value?.onion
|
||||
const url = base64Data !== undefined
|
||||
? URL.createObjectURL(base64ToBlob(base64Data, mimeType || item.mime_type))
|
||||
: `/api/peer-content/${encodeURIComponent(onion || "")}/${encodeURIComponent(item.id)}`
|
||||
if (onion) {
|
||||
try { localStorage.removeItem(receiptKey(onion, item.id)) } catch { /* owned cache remains authoritative */ }
|
||||
lnReceipt.value = null
|
||||
}
|
||||
if (onion) ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
|
||||
const mime = mimeType || item.mime_type
|
||||
if (mime.startsWith('audio/')) {
|
||||
// Straight to the bottom-bar player — the blob URL intentionally stays
|
||||
// alive while the bar owns playback.
|
||||
audioPlayer.play(
|
||||
URL.createObjectURL(base64ToBlob(base64Data, mime)),
|
||||
url,
|
||||
item.filename.split('/').pop() || item.filename,
|
||||
)
|
||||
} else {
|
||||
releaseViewerUrl()
|
||||
viewerUrl.value = URL.createObjectURL(base64ToBlob(base64Data, mime))
|
||||
viewerUrl.value = url
|
||||
viewerMime.value = mime
|
||||
viewerItem.value = item
|
||||
}
|
||||
@@ -1399,7 +1430,7 @@ async function payWithInvoice() {
|
||||
invoiceError.value = ''
|
||||
invoiceWaiting.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
|
||||
const res = readReceipt(onion, item.id) as (LightningReceipt & { error?: string }) | null || await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
|
||||
method: 'content.request-invoice',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 45000,
|
||||
@@ -1410,6 +1441,7 @@ async function payWithInvoice() {
|
||||
return
|
||||
}
|
||||
invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) }
|
||||
keepReceipt(onion, item.id, invoiceData.value)
|
||||
try {
|
||||
invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 })
|
||||
} catch {
|
||||
@@ -1424,54 +1456,46 @@ async function payWithInvoice() {
|
||||
|
||||
/**
|
||||
* Pay the seller's invoice straight from THIS node's Lightning wallet, then
|
||||
* release the file. payLightningInvoice resolves to a real terminal state, so
|
||||
* on success the payment_hash is immediately valid as the download gate token.
|
||||
* release the file. Keep the invoice before payment so uncertain outcomes can
|
||||
* retry seller verification and delivery without sending a second payment.
|
||||
*/
|
||||
async function payWithLightning() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion || lnPaying.value) return
|
||||
|
||||
lnPaying.value = true
|
||||
lnError.value = ''
|
||||
try {
|
||||
// 1. Ask the seller to mint a bolt11 (also records the pending entitlement).
|
||||
const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
||||
method: 'content.request-invoice',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 45000,
|
||||
let inv = readReceipt(onion, item.id)
|
||||
if (!inv) {
|
||||
const result = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
||||
method: 'content.request-invoice', params: { onion, content_id: item.id }, timeout: 45000,
|
||||
})
|
||||
if (!inv?.bolt11 || !inv?.payment_hash) {
|
||||
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
||||
return
|
||||
}
|
||||
// 2. Pay it from our own node. Tracked to a REAL terminal state — a slow
|
||||
// multi-hop route resolves via status polling instead of a false failure.
|
||||
if (!result?.bolt11 || !result.payment_hash) throw new Error(result?.error || 'The seller could not create an invoice.')
|
||||
inv = { bolt11: result.bolt11, payment_hash: result.payment_hash, price_sats: getItemPrice(item.access) }
|
||||
keepReceipt(onion, item.id, inv)
|
||||
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
|
||||
if (pay.status === 'failed') {
|
||||
localStorage.removeItem(receiptKey(onion, item.id)); lnReceipt.value = null
|
||||
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
|
||||
return
|
||||
}
|
||||
if (pay.status === 'pending') {
|
||||
lnError.value = 'Payment is still settling — this can take a few minutes. Check your wallet transactions before paying again.'
|
||||
lnError.value = 'Payment is still settling. Retry checks this payment without sending more sats.'
|
||||
return
|
||||
}
|
||||
// 3. Settled — pull the file using the payment hash as the gate token.
|
||||
const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
|
||||
}
|
||||
lnReceipt.value = inv
|
||||
const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({
|
||||
method: 'content.download-peer-invoice',
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||
timeout: 120000,
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
||||
timeout: 960000,
|
||||
})
|
||||
if (dl?.data) {
|
||||
openPurchased(item, dl.data, dl.mime_type)
|
||||
} else {
|
||||
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
||||
}
|
||||
if (dl?.data !== undefined || dl?.owned === true) openPurchased(item, dl.data, dl.mime_type, onion)
|
||||
else lnError.value = dl?.error || 'Download unavailable. Retry uses this payment without sending more sats.'
|
||||
} catch (e: unknown) {
|
||||
lnError.value = e instanceof Error ? e.message : 'Could not pay from your Lightning node'
|
||||
} finally {
|
||||
lnPaying.value = false
|
||||
}
|
||||
lnError.value = (e instanceof Error ? e.message : 'Payment or download could not be confirmed') + ' Retry checks the saved payment; do not pay again.'
|
||||
} finally { lnPaying.value = false }
|
||||
}
|
||||
|
||||
function scheduleInvoicePoll() {
|
||||
@@ -1487,19 +1511,19 @@ async function pollInvoice() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ paid?: boolean }>({
|
||||
method: 'content.invoice-status',
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
||||
timeout: 30000,
|
||||
})
|
||||
if (res?.paid) {
|
||||
// Settled — pull the file using the payment hash as the gate token.
|
||||
invoiceWaiting.value = false
|
||||
const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
|
||||
const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({
|
||||
method: 'content.download-peer-invoice',
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||
timeout: 120000,
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
||||
timeout: 960000,
|
||||
})
|
||||
if (dl?.data) {
|
||||
openPurchased(item, dl.data, dl.mime_type)
|
||||
if (dl?.data !== undefined || dl?.owned === true) {
|
||||
openPurchased(item, dl.data, dl.mime_type, onion)
|
||||
} else {
|
||||
invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia } from 'pinia'
|
||||
import PeerFiles from '../PeerFiles.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: vi.fn() } }))
|
||||
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) }))
|
||||
const hash = 'a'.repeat(64)
|
||||
const item = { id: 'paid-file', filename: 'bought.txt', mime_type: 'text/plain', size_bytes: 4, description: '', access: { paid: { price_sats: 5, accepted: ['lightning'] } } }
|
||||
const receiptKey = 'peer-file-lightning:peer.onion:paid-file'
|
||||
const download = vi.fn()
|
||||
async function open() {
|
||||
const wrapper = mount(PeerFiles, { props: { peerId: 'peer.onion' }, global: { plugins: [createPinia()], stubs: { Teleport: true } } })
|
||||
await flushPromises()
|
||||
// Drive the actual component payment handlers, asserting RPC effects rather
|
||||
// than a duplicate implementation of the payment state machine.
|
||||
const vm = (wrapper.vm as any).$.setupState
|
||||
vm.openPayModal(item)
|
||||
return { wrapper, vm }
|
||||
}
|
||||
beforeEach(() => {
|
||||
localStorage.clear(); vi.clearAllMocks()
|
||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [] } as never)
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'content.request-invoice') return { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
|
||||
if (method === 'content.download-peer-invoice') return download()
|
||||
return { items: [] }
|
||||
})
|
||||
vi.mocked(rpcClient.payLightningInvoice).mockResolvedValue({ status: 'succeeded' } as never)
|
||||
})
|
||||
describe('Lightning file delivery recovery', () => {
|
||||
it('retries delivery after a seller rejection without paying or requesting another invoice', async () => {
|
||||
download.mockResolvedValue({ error: 'Seller has not registered this payment yet' })
|
||||
const { wrapper, vm } = await open()
|
||||
await vm.payWithLightning()
|
||||
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash })
|
||||
vm.closePayModal(); vm.openPayModal(item)
|
||||
await vm.payWithLightning()
|
||||
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(1)
|
||||
expect(download).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-invoice')![0].params).toMatchObject({ payment_hash: hash, filename: 'bought.txt', price_sats: 5 })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('restores an uncertain payment on a newly mounted page and only checks/downloads', async () => {
|
||||
vi.mocked(rpcClient.payLightningInvoice).mockRejectedValue(new Error('Connection lost'))
|
||||
download.mockResolvedValue({ error: 'Pending' })
|
||||
const first = await open(); await first.vm.payWithLightning(); first.wrapper.unmount()
|
||||
const second = await open(); await second.vm.payWithLightning()
|
||||
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
|
||||
expect(download).toHaveBeenCalledTimes(1)
|
||||
second.wrapper.unmount()
|
||||
})
|
||||
it('opens cached delivery through HTTP without a base64 file in the response', async () => {
|
||||
download.mockResolvedValue({ owned: true, mime_type: 'video/mp4', size_bytes: 206165161 })
|
||||
const { wrapper, vm } = await open()
|
||||
await vm.payWithLightning()
|
||||
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
|
||||
expect(vm.viewerMime).toBe('video/mp4')
|
||||
expect(localStorage.getItem(receiptKey)).toBeNull()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-invoice')![0].params).toMatchObject({ cache_only: true })
|
||||
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('never pays again when the saved receipt is corrupt', async () => {
|
||||
localStorage.setItem(receiptKey, '{broken')
|
||||
const { wrapper, vm } = await open()
|
||||
await vm.payWithLightning()
|
||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps QR recovery on the saved Lightning payment instead of creating another rail', async () => {
|
||||
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }))
|
||||
const { wrapper, vm } = await open()
|
||||
vm.openQrPay(); await flushPromises()
|
||||
expect(vm.payMode).toBe('qr')
|
||||
expect(vm.qrTab).toBe('lightning')
|
||||
vm.selectQrTab('onchain'); await flushPromises()
|
||||
expect(vm.qrTab).toBe('lightning')
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => ['content.request-invoice', 'content.request-onchain'].includes(v.method))).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not send payment if the recovery record cannot be saved', async () => {
|
||||
const { wrapper, vm } = await open()
|
||||
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage full') })
|
||||
await vm.payWithLightning()
|
||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
||||
save.mockRestore(); wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -42,6 +42,8 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||
"aiui": "AI Assistant",
|
||||
"alby-hub": "Alby Hub",
|
||||
"angor-indexer": "Angor Indexer",
|
||||
"angor-relay": "Angor Relay",
|
||||
"archipelago-source": "GitWorkshop",
|
||||
"archy-btcpay-db": "BTCPay Postgres",
|
||||
"archy-mempool-db": "Mempool MariaDB",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
:data-controller-launch="canLaunch(pkg) ? '' : undefined"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
|
||||
class="glass-card flex flex-col h-full p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
|
||||
:class="{ 'card-stagger': showStagger }"
|
||||
:style="{ '--stagger-index': index }"
|
||||
@click="$emit('goToApp', id)"
|
||||
@@ -56,9 +56,9 @@
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<div v-if="!isInstalling && !isUninstalling && pkg.state !== 'installing'" class="flex items-center gap-2">
|
||||
<div v-if="!isInstalling && !isUninstalling && pkg.state !== 'installing'" class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
|
||||
class="shrink-0 inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
|
||||
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
|
||||
>
|
||||
<svg
|
||||
@@ -74,14 +74,14 @@
|
||||
<span v-if="pkg.state === 'running' && pkg.health === 'unhealthy'" class="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"></span>
|
||||
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="blockedReason" class="mt-2 text-xs leading-snug text-yellow-200/80">
|
||||
<p v-if="blockedReason" :title="blockedReason" class="min-w-0 truncate text-xs leading-snug text-yellow-200/80">
|
||||
{{ blockedReason }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions — icon buttons in uniform dark containers -->
|
||||
<!-- Installing progress — replaces action buttons -->
|
||||
<div v-if="isInstalling || pkg.state === 'installing'" class="mt-4">
|
||||
<div v-if="isInstalling || pkg.state === 'installing'" class="mt-auto pt-4">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-xs text-white/70 flex items-center gap-1.5">
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
@@ -101,7 +101,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Uninstalling progress — truthful stage-driven bar (mirrors install) -->
|
||||
<div v-else-if="isUninstalling" class="mt-4">
|
||||
<div v-else-if="isUninstalling" class="mt-auto pt-4">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-xs text-white/70 flex items-center gap-1.5">
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
@@ -120,7 +120,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 flex gap-2">
|
||||
<div v-else class="mt-auto pt-4 flex gap-2">
|
||||
<!-- Update available -->
|
||||
<button
|
||||
v-if="pkg['available-update'] && pkg.state !== 'updating'"
|
||||
@@ -214,7 +214,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import {
|
||||
isWebOnlyApp, opensInTab, canLaunch, launchBlockedReason, resolveAppIcon,
|
||||
isWebOnlyApp, opensInTab, canLaunch, launchBlockedReason, resolveAppIcon, resolveAppTitle,
|
||||
getStatusClass, getStatusLabel, handleImageError,
|
||||
} from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
@@ -255,10 +255,7 @@ const isWebOnly = computed(() => isWebOnlyApp(props.id))
|
||||
|
||||
// Enrich from marketplace when backend data is sparse (e.g. during install)
|
||||
const curated = computed(() => curatedMap.get(props.id))
|
||||
const title = computed(() => {
|
||||
const t = props.pkg.manifest?.title
|
||||
return (t && t !== props.id) ? t : (curated.value?.title || t || props.id)
|
||||
})
|
||||
const title = computed(() => resolveAppTitle(props.id, props.pkg, curated.value?.title))
|
||||
const description = computed(() => {
|
||||
const d = props.pkg.manifest?.description?.short
|
||||
return (d && d !== 'Installing...') ? d : (curated.value?.description || d || '')
|
||||
|
||||
@@ -132,7 +132,7 @@ import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { resolveAppCredentials } from './appCredentials'
|
||||
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
|
||||
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveAppTitle, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
|
||||
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
|
||||
@@ -194,9 +194,7 @@ const pages = computed(() => {
|
||||
})
|
||||
|
||||
function getTitle(id: string, pkg: PackageDataEntry): string {
|
||||
const t = pkg.manifest?.title
|
||||
if (t && t !== id) return t
|
||||
return curatedMap.get(id)?.title || t || id
|
||||
return resolveAppTitle(id, pkg, curatedMap.get(id)?.title)
|
||||
}
|
||||
|
||||
function getIcon(id: string, pkg: PackageDataEntry): string {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { APP_CATEGORY_MAP, canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, isAppReadyForLaunch, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig'
|
||||
import { APP_CATEGORY_MAP, canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, isAppReadyForLaunch, launchBlockedReason, resolveAppIcon, resolveAppTitle, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig'
|
||||
|
||||
function makePkg(id: string, title: string, category: string): PackageDataEntry {
|
||||
return {
|
||||
@@ -25,6 +25,15 @@ function makePkg(id: string, title: string, category: string): PackageDataEntry
|
||||
}
|
||||
|
||||
describe('appsConfig service filtering', () => {
|
||||
it('keeps standalone Angor APIs in Services without a launch button', () => {
|
||||
for (const id of ['angor-indexer', 'angor-relay']) {
|
||||
const pkg = makePkg(id, id, 'money')
|
||||
expect(filterEntriesForTab([[id, pkg]], 'services', 'all')).toHaveLength(1)
|
||||
expect(filterEntriesForTab([[id, pkg]], 'apps', 'all')).toHaveLength(0)
|
||||
expect(canLaunch(pkg)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('treats bitcoin stack UI sidecars as services', () => {
|
||||
expect(isServiceContainer('bitcoin-ui')).toBe(true)
|
||||
expect(isServiceContainer('lnd-ui')).toBe(true)
|
||||
@@ -158,7 +167,7 @@ describe('appsConfig service filtering', () => {
|
||||
pkg.health = null
|
||||
expect(isAppReadyForLaunch(pkg)).toBe(false)
|
||||
expect(canLaunch(pkg)).toBe(false)
|
||||
expect(launchBlockedReason(pkg.manifest.id, pkg)).toContain('Starting up')
|
||||
expect(launchBlockedReason(pkg.manifest.id, pkg)).toBe('Web UI not ready: GitWorkshop')
|
||||
pkg.health = 'healthy'
|
||||
expect(canLaunch(pkg)).toBe(true)
|
||||
})
|
||||
@@ -185,6 +194,18 @@ describe('appsConfig service filtering', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('headless service readiness messages', () => {
|
||||
it('does not treat an API-only service as waiting for its own nonexistent UI', () => {
|
||||
for (const id of ['phoenixd', 'angor-indexer', 'angor-relay', 'custom-api']) {
|
||||
const pkg = makePkg(id, id, 'other')
|
||||
pkg['ui-ready'] = false
|
||||
pkg.health = 'healthy'
|
||||
expect(canLaunch(pkg)).toBe(false)
|
||||
expect(launchBlockedReason(id, pkg)).toBe('')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('HTTP readiness independent of container health', () => {
|
||||
it('blocks fixed launch URLs while the HTTP upstream is unavailable', () => {
|
||||
for (const id of ['gitea', 'filebrowser', 'fedimint', 'lnd']) {
|
||||
@@ -193,7 +214,7 @@ describe('HTTP readiness independent of container health', () => {
|
||||
pkg.health = 'healthy'
|
||||
expect(canLaunch(pkg)).toBe(false)
|
||||
expect(isAppReadyForLaunch(pkg)).toBe(false)
|
||||
expect(launchBlockedReason(id, pkg)).toContain('Waiting')
|
||||
expect(launchBlockedReason(id, pkg)).toBe(`Web UI not ready: ${resolveAppTitle(id, pkg)}`)
|
||||
pkg['ui-ready'] = true
|
||||
expect(isAppReadyForLaunch(pkg)).toBe(true)
|
||||
}
|
||||
@@ -205,3 +226,29 @@ describe('HTTP readiness independent of container health', () => {
|
||||
expect(isAppReadyForLaunch(pkg)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('manifest-generated service names', () => {
|
||||
it('gives a new headless service its declared name without a hardcoded curated entry', () => {
|
||||
const pkg = makePkg('angor-relay', 'angor-relay', 'money')
|
||||
expect(resolveAppTitle('angor-relay', pkg)).toBe('Angor Relay')
|
||||
pkg.manifest.title = 'My project relay'
|
||||
expect(resolveAppTitle('angor-relay', pkg)).toBe('My project relay')
|
||||
expect(resolveAppTitle('unknown-service', makePkg('unknown-service', 'unknown-service', ''))).toBe('unknown-service')
|
||||
})
|
||||
})
|
||||
|
||||
it('does not display a stale Mempool frontend alias beside its live package', () => {
|
||||
const main = makePkg('mempool', 'Mempool', 'money')
|
||||
const alias = makePkg('mempool-web', 'Mempool', 'money')
|
||||
alias.state = PackageState.Restarting
|
||||
const entries: Array<[string, PackageDataEntry]> = [['mempool', main], ['mempool-web', alias]]
|
||||
const shown = [...filterEntriesForTab(entries, 'apps', 'all'), ...filterEntriesForTab(entries, 'services', 'all')]
|
||||
expect(shown.map(([id]) => id)).toEqual(['mempool'])
|
||||
expect(filterEntriesForTab([['mempool-web', alias]], 'apps', 'all').map(([id]) => id)).toEqual(['mempool-web'])
|
||||
})
|
||||
|
||||
it('shows Immich as one app without internal database/cache cards in either tab', () => {
|
||||
const entries: [string, PackageDataEntry][] = ['immich', 'immich-postgres', 'immich-redis', 'immich_postgres', 'immich_redis'].map(id => [id, makePkg(id, id, 'media')])
|
||||
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['immich'])
|
||||
expect(filterEntriesForTab(entries, 'services', 'all')).toEqual([])
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { matchPageScheme, resolveAppUrl } from '../appSession/appSessionConfig'
|
||||
import { GENERATED_APP_TITLES } from '../appSession/generatedAppSessionConfig'
|
||||
import { portIsGateFronted } from '../discover/curatedApps'
|
||||
import { isAutoTabApp } from '@/utils/autoTabApps'
|
||||
import {
|
||||
@@ -13,6 +14,14 @@ import {
|
||||
|
||||
export type AppsTab = 'apps' | 'websites' | 'services'
|
||||
|
||||
/** Use manifest-generated names when a new service has only its ID in runtime state. */
|
||||
export function resolveAppTitle(id: string, pkg: PackageDataEntry, curatedTitle?: string): string {
|
||||
const title = pkg.manifest?.title
|
||||
if (title && title !== id) return title
|
||||
return curatedTitle || GENERATED_APP_TITLES[id] || title || id
|
||||
}
|
||||
|
||||
|
||||
// Re-exported for every existing caller — the canon moved to serviceNames.ts
|
||||
// so the App Store's catalog merge can share it without a circular import.
|
||||
export const SERVICE_NAMES = SHARED_SERVICE_NAMES
|
||||
@@ -20,6 +29,9 @@ export const isServiceContainer = sharedIsServiceContainer
|
||||
|
||||
const INTERNAL_TOOLING_NAMES = new Set([
|
||||
'buildx_buildkit_default',
|
||||
// Stack internals belong to their parent app, including cached inventories
|
||||
// from nodes predating backend alias normalization.
|
||||
'immich-postgres', 'immich-redis', 'immich_postgres', 'immich_redis',
|
||||
// Cuprate's dashboard is bundled as a companion of the primary cuprate
|
||||
// package; showing the generated container as a second Services entry
|
||||
// defeats the one-app presentation.
|
||||
@@ -102,7 +114,12 @@ export function filterEntriesForTab(
|
||||
activeTab: AppsTab,
|
||||
selectedCategory: string,
|
||||
): Array<[string, PackageDataEntry]> {
|
||||
const hasMempool = entries.some(([id]) => id === 'mempool')
|
||||
return entries.filter(([id, pkg]) => {
|
||||
// Older daemons can retain the frontend manifest alias during a restart.
|
||||
// Keep one tile while the updated scanner converges; a legacy-only node
|
||||
// must still be able to see and operate its sole Mempool entry.
|
||||
if (hasMempool && ['mempool-web', 'mempool-frontend', 'archy-mempool-web'].includes(id)) return false
|
||||
if (isInternalToolingPackage(id, pkg)) return false
|
||||
const wantsWebsites = activeTab === 'websites' || activeTab === 'services'
|
||||
const isWebsite = isWebsitePackage(id, pkg)
|
||||
@@ -222,6 +239,7 @@ function serviceParentIcon(id: string): string | undefined {
|
||||
export const DEFAULT_APP_ICON = '/assets/icon/favico-black-v2.svg'
|
||||
|
||||
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
|
||||
if (id === 'angor-indexer' || id === 'angor-relay') return '/assets/img/app-icons/angor-green.png'
|
||||
const rawIcon = (pkg["static-files"]?.icon || "").trim()
|
||||
const icon = rawIcon === '/assets/img/favico.png' ? '' : rawIcon
|
||||
if (
|
||||
@@ -287,9 +305,13 @@ export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
|
||||
|
||||
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
|
||||
const appId = pkg?.manifest?.id || id
|
||||
const title = pkg ? resolveAppTitle(appId, pkg) : id
|
||||
// API-only services have no web interface to wait for. Their health badge
|
||||
// describes service availability; a failed HTML probe is not a dependency.
|
||||
if (pkg && isWebsitePackage(appId, pkg)) return ''
|
||||
if (pkg?.['ui-ready'] === false && !isServicePackage(appId, pkg)) {
|
||||
if (pkg.state === PackageState.Stopped || pkg.state === PackageState.Exited) return 'App is stopped. Start it to open it.'
|
||||
return 'Waiting for the app to be ready…'
|
||||
return `Web UI not ready: ${title}`
|
||||
}
|
||||
if (
|
||||
(appId === 'fedimint' || appId === 'fedimintd') &&
|
||||
@@ -298,7 +320,7 @@ export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null):
|
||||
return 'Guardian opens a wait page until Bitcoin finishes initial sync.'
|
||||
}
|
||||
if (pkg && pkg.state === PackageState.Running && !isAppReadyForLaunch(pkg)) {
|
||||
return 'Starting up — Launch will appear when the app is ready.'
|
||||
return `Web UI not ready: ${title}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { __setSignedCatalogForTests, portAuth, portIsGateFronted, type SignedAppCatalog } from '../curatedApps'
|
||||
import { __setSignedCatalogForTests, signedCatalogToApps, portAuth, portIsGateFronted, type SignedAppCatalog } from '../curatedApps'
|
||||
|
||||
/** Catalog fragments mirroring the live signed catalog's port declarations
|
||||
* (releases/app-catalog.json, 2026-09-01). */
|
||||
@@ -77,3 +77,12 @@ describe('portAuth', () => {
|
||||
expect(portAuth('mempool-web', 4080)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('standalone headless services', () => {
|
||||
it('lists Angor services while keeping shared Mempool and node relay internals hidden', () => {
|
||||
const apps = signedCatalogToApps(catalog(Object.fromEntries(
|
||||
['angor-indexer', 'angor-relay', 'mempool-api', 'strfry'].map(id => [id, { version: '1' }]),
|
||||
)))
|
||||
expect(apps.map(app => app.id)).toEqual(['angor-indexer', 'angor-relay'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -362,6 +362,35 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.8.22-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.22-alpha</span>
|
||||
<span class="text-xs text-white/40">September 30, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.</p>
|
||||
<p>Network diagnostic failures no longer stop all apps or rebuild shared container networking.</p>
|
||||
<p>Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.</p>
|
||||
<p>Fixed companion dashboard builds still referencing a retired image registry.</p>
|
||||
<p>Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.</p>
|
||||
<p>Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.</p>
|
||||
<p>Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.</p>
|
||||
<p>Checked Bitcoin and Electrum companion dashboards instead of backend protocol ports, preserving dashboard access during initial sync.</p>
|
||||
<p>Removed web-interface waiting messages from headless services such as Phoenixd and clarified which interface is unavailable for launchable apps.</p>
|
||||
<p>Finished runtime app-file promotion before manifest loading, preventing startup catalog refresh from forgetting disk-only apps.</p>
|
||||
<p>Named the app in compact readiness messages and kept app-card actions aligned at the bottom.</p>
|
||||
<p>Removed duplicate Mempool cards caused by frontend container aliases in restored inventory.</p>
|
||||
<p>Kept installed apps visible through restarts and hard refreshes, and delayed app launches until their web interface is ready.</p>
|
||||
<p>Made Bitcoin version selection readable and usable in the ThinkPad kiosk, above the pruning settings.</p>
|
||||
<p>Restored GitWorkshop build files in installation/update payloads and made slow image-pull progress clearer.</p>
|
||||
<p>Fixed same-node Gitea access from Portainer, with persistent runtime migration, state backups and recovery after failed restarts.</p>
|
||||
<p>Preserved Gitea configuration and SSH operation during fresh setup and upgrades.</p>
|
||||
<p>Improved paid-file delivery, saved-file permissions and repeat-download compatibility; verified Tor-only payment with change, rejection refunds and free repeat downloads.</p>
|
||||
<p>Added a headless Angor Indexer service using the existing Mempool/ElectrumX stack, and an optional separate Angor relay.</p>
|
||||
<p>Prevented manifest command arguments containing apostrophes from being corrupted in generated services.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.21-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+33
-17
@@ -1,30 +1,46 @@
|
||||
{
|
||||
"changelog": [
|
||||
"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."
|
||||
"Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.",
|
||||
"Network diagnostic failures no longer stop all apps or rebuild shared container networking.",
|
||||
"Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.",
|
||||
"Fixed companion dashboard builds still referencing a retired image registry.",
|
||||
"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."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.21-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
|
||||
"current_version": "1.8.22-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.21-alpha",
|
||||
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
|
||||
"size_bytes": 64748176
|
||||
"new_version": "1.8.22-alpha",
|
||||
"sha256": "e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b",
|
||||
"size_bytes": 65627704
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.21-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||
"new_version": "1.8.21-alpha",
|
||||
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
|
||||
"size_bytes": 97152546
|
||||
"current_version": "1.8.22-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago-frontend-1.8.22-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.22-alpha.tar.gz",
|
||||
"new_version": "1.8.22-alpha",
|
||||
"sha256": "2da485a2da75ff2fbe4aba52d6f217150e303be43a031723480c9c4ff9d43f41",
|
||||
"size_bytes": 98131119
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-30",
|
||||
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
|
||||
"signature": "34e9e3902d5960c977b528c4edbb4366ad862f761076755632296840604c8a84ae1bbb503d8d26b2fe7622ca1eccfaa15cb8d23935bcec6dbecdaf6c53f7f50e",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.21-alpha"
|
||||
"version": "1.8.22-alpha"
|
||||
}
|
||||
|
||||
+437
-4
@@ -141,6 +141,198 @@
|
||||
},
|
||||
"version": "1.23.0"
|
||||
},
|
||||
"angor-indexer": {
|
||||
"manifest": {
|
||||
"app": {
|
||||
"bitcoin_integration": {
|
||||
"pruning_support": false,
|
||||
"rpc_access": "none",
|
||||
"sync_required": true
|
||||
},
|
||||
"category": "money",
|
||||
"container": {
|
||||
"image": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1",
|
||||
"network": "archy-net",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"app_id": "mempool-api",
|
||||
"version": ">=3.0.0"
|
||||
},
|
||||
"bitcoin:archival"
|
||||
],
|
||||
"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.",
|
||||
"health_check": {
|
||||
"endpoint": "http://127.0.0.1:8080",
|
||||
"interval": "30s",
|
||||
"path": "/health",
|
||||
"retries": 3,
|
||||
"timeout": "8s",
|
||||
"type": "http"
|
||||
},
|
||||
"id": "angor-indexer",
|
||||
"install_prerequisites": [
|
||||
"mempool-api"
|
||||
],
|
||||
"interfaces": {
|
||||
"main": {
|
||||
"description": "Use this origin as Angor’s custom mainnet indexer URL. HTTPS is required for browser clients.",
|
||||
"name": "Angor Indexer API",
|
||||
"path": "/",
|
||||
"port": 8998,
|
||||
"protocol": "http",
|
||||
"type": "api"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"features": [
|
||||
"Angor mainnet API",
|
||||
"Reuses existing Mempool indexing",
|
||||
"No separate blockchain database",
|
||||
"Optional independent relay"
|
||||
],
|
||||
"icon": "/assets/img/app-icons/angor-green.png",
|
||||
"repo": "https://github.com/block-core/angor",
|
||||
"tier": "optional"
|
||||
},
|
||||
"name": "Angor Indexer",
|
||||
"ports": [
|
||||
{
|
||||
"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.",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 8998,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_limit": 1,
|
||||
"disk_limit": "128Mi",
|
||||
"memory_limit": "128Mi"
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [],
|
||||
"network_policy": "isolated",
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": true,
|
||||
"user": 101
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "github",
|
||||
"repo": "block-core/angor"
|
||||
},
|
||||
"version": "1.0.1"
|
||||
}
|
||||
},
|
||||
"version": "1.0.1"
|
||||
},
|
||||
"angor-relay": {
|
||||
"manifest": {
|
||||
"app": {
|
||||
"category": "nostr",
|
||||
"container": {
|
||||
"image": "source.archipelago-foundation.org/chaum/angor-relay:1.1.2",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "5Gi"
|
||||
}
|
||||
],
|
||||
"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.",
|
||||
"files": [
|
||||
{
|
||||
"content": "##\n## Default strfry config\n##\n\n# Directory that contains the strfry LMDB database (restart required)\ndb = \"./strfry-db/\"\n\ndbParams {\n # Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required)\n maxreaders = 256\n\n # Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required)\n mapsize = 10995116277760\n\n # Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required)\n noReadAhead = false\n}\n\nevents {\n # Maximum size of normalised JSON, in bytes\n maxEventSize = 65536\n\n # Events newer than this will be rejected\n rejectEventsNewerThanSeconds = 900\n\n # Events older than this will be rejected\n rejectEventsOlderThanSeconds = 94608000\n\n # Ephemeral events older than this will be rejected\n rejectEphemeralEventsOlderThanSeconds = 60\n\n # Ephemeral events will be deleted from the DB when older than this\n ephemeralEventsLifetimeSeconds = 300\n\n # Maximum number of tags allowed\n maxNumTags = 2000\n\n # Maximum size for tag values, in bytes\n maxTagValSize = 1024\n}\n\nrelay {\n # Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required)\n bind = \"0.0.0.0\"\n\n # Port to open for the nostr websocket protocol (restart required)\n port = 7777\n\n # Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)\n nofiles = 0\n\n # HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)\n realIpHeader = \"\"\n\n info {\n # NIP-11: Name of this server. Short/descriptive (< 30 characters)\n name = \"Angor Relay\"\n\n # NIP-11: Detailed information about relay, free-form\n description = \"Dedicated public relay for Angor project metadata.\"\n\n # NIP-11: Administrative nostr pubkey, for contact purposes\n pubkey = \"\"\n\n # NIP-11: Alternative administrative contact (email, website, etc)\n contact = \"\"\n\n # NIP-11: URL pointing to an image to be used as an icon for the relay\n icon = \"\"\n\n # List of supported lists as JSON array, or empty string to use default. Example: \"[1,2]\"\n nips = \"\"\n }\n\n # Maximum accepted incoming websocket frame size (should be larger than max event) (restart required)\n maxWebsocketPayloadSize = 131072\n\n # Maximum number of filters allowed in a REQ\n maxReqFilterSize = 200\n\n # Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required)\n autoPingSeconds = 55\n\n # If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy)\n enableTcpKeepalive = false\n\n # How much uninterrupted CPU time a REQ query should get during its DB scan\n queryTimesliceBudgetMicroseconds = 10000\n\n # Maximum records that can be returned per filter\n maxFilterLimit = 500\n\n # Maximum number of subscriptions (concurrent REQs) a connection can have open at any time\n maxSubsPerConnection = 20\n\n writePolicy {\n # If non-empty, path to an executable script that implements the writePolicy plugin logic\n plugin = \"\"\n }\n\n compression {\n # Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required)\n enabled = true\n\n # Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required)\n slidingWindow = true\n }\n\n logging {\n # Dump all incoming messages\n dumpInAll = false\n\n # Dump all incoming EVENT messages\n dumpInEvents = false\n\n # Dump all incoming REQ/CLOSE messages\n dumpInReqs = false\n\n # Log performance metrics for initial REQ database scans\n dbScanPerf = false\n\n # Log reason for invalid event rejection? Can be disabled to silence excessive logging\n invalidEvents = true\n }\n\n numThreads {\n # Ingester threads: route incoming requests, validate events/sigs (restart required)\n ingester = 3\n\n # reqWorker threads: Handle initial DB scan for events (restart required)\n reqWorker = 3\n\n # reqMonitor threads: Handle filtering of new events (restart required)\n reqMonitor = 3\n\n # negentropy threads: Handle negentropy protocol messages (restart required)\n negentropy = 2\n }\n\n negentropy {\n # Support negentropy protocol messages\n enabled = true\n\n # Maximum records that sync will process before returning an error\n maxSyncEvents = 1000000\n }\n}\n",
|
||||
"overwrite": false,
|
||||
"path": "/var/lib/archipelago/angor-relay-config/angor-relay.conf"
|
||||
}
|
||||
],
|
||||
"health_check": {
|
||||
"endpoint": "http://127.0.0.1:7777",
|
||||
"interval": "30s",
|
||||
"path": "/health",
|
||||
"retries": 3,
|
||||
"timeout": "5s",
|
||||
"type": "http"
|
||||
},
|
||||
"id": "angor-relay",
|
||||
"interfaces": {
|
||||
"main": {
|
||||
"description": "Nostr WebSocket endpoint; use ws:// for LAN or wss:// through your HTTPS domain.",
|
||||
"name": "Angor Relay",
|
||||
"path": "/",
|
||||
"port": 8091,
|
||||
"protocol": "http",
|
||||
"type": "api"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"features": [
|
||||
"Angor project metadata",
|
||||
"Separate from the node relay",
|
||||
"Persistent Nostr event storage"
|
||||
],
|
||||
"icon": "/assets/img/app-icons/angor-green.png",
|
||||
"repo": "https://github.com/hoytech/strfry",
|
||||
"tier": "optional"
|
||||
},
|
||||
"name": "Angor Relay",
|
||||
"nostr_integration": {
|
||||
"monetization_enabled": false,
|
||||
"relay_type": "public"
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"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.",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 7777,
|
||||
"host": 8091,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_limit": 1,
|
||||
"disk_limit": "5Gi",
|
||||
"memory_limit": "512Mi"
|
||||
},
|
||||
"security": {
|
||||
"apparmor_profile": "nostr-relay",
|
||||
"capabilities": [],
|
||||
"network_policy": "isolated",
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": true,
|
||||
"seccomp_profile": "default"
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "github",
|
||||
"repo": "hoytech/strfry"
|
||||
},
|
||||
"version": "1.1.2",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/var/lib/archipelago/angor-relay",
|
||||
"target": "/app/strfry-db",
|
||||
"type": "bind"
|
||||
},
|
||||
{
|
||||
"options": [
|
||||
"ro"
|
||||
],
|
||||
"source": "/var/lib/archipelago/angor-relay-config/angor-relay.conf",
|
||||
"target": "/etc/strfry.conf",
|
||||
"type": "bind"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "1.1.2"
|
||||
},
|
||||
"archipelago-source": {
|
||||
"manifest": {
|
||||
"app": {
|
||||
@@ -2195,6 +2387,142 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"manifest_variants": [
|
||||
{
|
||||
"manifest": {
|
||||
"app": {
|
||||
"backup_before_runtime_change": true,
|
||||
"category": "development",
|
||||
"container": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/gitea:1.27.3",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "50Gi"
|
||||
}
|
||||
],
|
||||
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
|
||||
"environment": [
|
||||
"GITEA__database__DB_TYPE=sqlite3",
|
||||
"GITEA__server__SSH_PORT=2222",
|
||||
"GITEA__server__SSH_LISTEN_PORT=22",
|
||||
"GITEA__server__LFS_START_SERVER=true",
|
||||
"GITEA__packages__ENABLED=true",
|
||||
"GITEA__packages__LIMIT_TOTAL_OWNER_SIZE=-1",
|
||||
"GITEA__packages__LIMIT_SIZE_CONTAINER=-1",
|
||||
"GITEA__repository_0x2Erelease__FILE_MAX_SIZE=10240",
|
||||
"GITEA__repository_0x2Erelease__MAX_FILES=20",
|
||||
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
|
||||
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"content": "[server]\nDOMAIN = {{HOST_IP}}\nSSH_DOMAIN = {{HOST_IP}}\nROOT_URL = http://{{HOST_IP}}:3001/\n",
|
||||
"overwrite": false,
|
||||
"path": "/var/lib/archipelago/gitea/data/gitea/conf/app.ini"
|
||||
}
|
||||
],
|
||||
"health_check": {
|
||||
"endpoint": "http://localhost:3000",
|
||||
"interval": "120s",
|
||||
"path": "/",
|
||||
"retries": 5,
|
||||
"timeout": "30s",
|
||||
"type": "http"
|
||||
},
|
||||
"id": "gitea",
|
||||
"interfaces": {
|
||||
"main": {
|
||||
"description": "Gitea web interface",
|
||||
"name": "Web UI",
|
||||
"path": "/",
|
||||
"port": 3001,
|
||||
"protocol": "http",
|
||||
"type": "ui"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"features": [
|
||||
"Git repositories with web UI",
|
||||
"Built-in container/package registry",
|
||||
"Issue tracking and pull requests",
|
||||
"CI/CD via Gitea Actions",
|
||||
"Lightweight SQLite deployment"
|
||||
],
|
||||
"icon": "/assets/img/app-icons/gitea.svg",
|
||||
"launch": {
|
||||
"open_in_new_tab": true
|
||||
},
|
||||
"repo": "https://gitea.com",
|
||||
"tier": "optional"
|
||||
},
|
||||
"name": "Gitea",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "open",
|
||||
"auth_rationale": "Gitea enforces its own account login on every page and API route; git clients authenticate with basic-auth/tokens and cannot complete a browser login challenge.",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 3000,
|
||||
"host": 3001,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Git over SSH, authenticated by the user's own SSH keypair. Not HTTP, so the gate cannot serve a login page here.",
|
||||
"container": 22,
|
||||
"host": 2222,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"disk_limit": "50Gi",
|
||||
"memory_limit": "256Mi"
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [
|
||||
"CHOWN",
|
||||
"FOWNER",
|
||||
"SETUID",
|
||||
"SETGID",
|
||||
"DAC_OVERRIDE",
|
||||
"NET_BIND_SERVICE",
|
||||
"SYS_CHROOT"
|
||||
],
|
||||
"network_policy": "bridge",
|
||||
"no_new_privileges": false,
|
||||
"readonly_root": false
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "github",
|
||||
"repo": "go-gitea/gitea"
|
||||
},
|
||||
"version": "1.27.3",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/var/lib/archipelago/gitea/data",
|
||||
"target": "/data",
|
||||
"type": "bind"
|
||||
},
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/var/lib/archipelago/gitea/config",
|
||||
"target": "/etc/gitea",
|
||||
"type": "bind"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"requires": [
|
||||
"runtime-migration-backup-v1"
|
||||
]
|
||||
}
|
||||
],
|
||||
"version": "1.27.3"
|
||||
},
|
||||
"grafana": {
|
||||
@@ -4091,11 +4419,11 @@
|
||||
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
|
||||
"environment": [],
|
||||
"health_check": {
|
||||
"endpoint": "localhost:81",
|
||||
"endpoint": "http://127.0.0.1:81/api/",
|
||||
"interval": "30s",
|
||||
"retries": 3,
|
||||
"timeout": "5s",
|
||||
"type": "tcp"
|
||||
"type": "http"
|
||||
},
|
||||
"id": "nginx-proxy-manager",
|
||||
"interfaces": {
|
||||
@@ -4996,6 +5324,111 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"manifest_variants": [
|
||||
{
|
||||
"manifest": {
|
||||
"app": {
|
||||
"backup_before_runtime_change": true,
|
||||
"category": "development",
|
||||
"container": {
|
||||
"data_uid": "1000:1000",
|
||||
"image": "source.archipelago-foundation.org/lfg2025/portainer:2.45.0",
|
||||
"network": "slirp4netns",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "1Gi"
|
||||
}
|
||||
],
|
||||
"description": "Container management web UI for the local Podman socket.",
|
||||
"environment": [],
|
||||
"id": "portainer",
|
||||
"interfaces": {
|
||||
"main": {
|
||||
"description": "Portainer web interface",
|
||||
"name": "Web UI",
|
||||
"path": "/",
|
||||
"port": 9000,
|
||||
"protocol": "http",
|
||||
"type": "ui"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"features": [
|
||||
"Container management dashboard",
|
||||
"Local Podman socket access",
|
||||
"Compose stack storage"
|
||||
],
|
||||
"icon": "/assets/img/app-icons/portainer.webp",
|
||||
"launch": {
|
||||
"open_in_new_tab": true
|
||||
},
|
||||
"tier": "optional"
|
||||
},
|
||||
"name": "Portainer",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 9000,
|
||||
"host": 9000,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"disk_limit": "1Gi",
|
||||
"memory_limit": "256Mi"
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [
|
||||
"CHOWN",
|
||||
"SETUID",
|
||||
"SETGID",
|
||||
"DAC_OVERRIDE"
|
||||
],
|
||||
"network_policy": "isolated",
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": false
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "github",
|
||||
"repo": "portainer/portainer"
|
||||
},
|
||||
"version": "2.45.0",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/var/lib/archipelago/portainer",
|
||||
"target": "/data",
|
||||
"type": "bind"
|
||||
},
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/var/lib/archipelago/portainer/compose",
|
||||
"target": "/data/compose",
|
||||
"type": "bind"
|
||||
},
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/run/user/1000/podman/podman.sock",
|
||||
"target": "/var/run/docker.sock",
|
||||
"type": "bind"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"requires": [
|
||||
"runtime-migration-backup-v1"
|
||||
]
|
||||
}
|
||||
],
|
||||
"version": "2.45.0"
|
||||
},
|
||||
"router": {
|
||||
@@ -5525,7 +5958,7 @@
|
||||
"tag": "NOSTR IDENTITY // YOUR NODE"
|
||||
},
|
||||
"schema": 1,
|
||||
"signature": "bbcc938b855c1cb5d803e4510e1aac3259fbf3eabf6f36294c7773634047a3d5edb5b37a17d01d62d1407e5701c62853e15e20e15cc7f486b8975b22eeb94c07",
|
||||
"signature": "66b78a5bc60992222b01ae901c5f4a40802667332a5ae47bdad7f34e149669261012ff6fd543478a4f318e850b0339be497af71a50266d829395a872ad386704",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"storefront": {
|
||||
"popular": [
|
||||
@@ -5551,5 +5984,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"updated": "2026-09-29"
|
||||
"updated": "2026-09-30"
|
||||
}
|
||||
|
||||
+33
-17
@@ -1,30 +1,46 @@
|
||||
{
|
||||
"changelog": [
|
||||
"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."
|
||||
"Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.",
|
||||
"Network diagnostic failures no longer stop all apps or rebuild shared container networking.",
|
||||
"Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.",
|
||||
"Fixed companion dashboard builds still referencing a retired image registry.",
|
||||
"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."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.21-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
|
||||
"current_version": "1.8.22-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.21-alpha",
|
||||
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
|
||||
"size_bytes": 64748176
|
||||
"new_version": "1.8.22-alpha",
|
||||
"sha256": "e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b",
|
||||
"size_bytes": 65627704
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.21-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||
"new_version": "1.8.21-alpha",
|
||||
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
|
||||
"size_bytes": 97152546
|
||||
"current_version": "1.8.22-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago-frontend-1.8.22-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.22-alpha.tar.gz",
|
||||
"new_version": "1.8.22-alpha",
|
||||
"sha256": "2da485a2da75ff2fbe4aba52d6f217150e303be43a031723480c9c4ff9d43f41",
|
||||
"size_bytes": 98131119
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-30",
|
||||
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
|
||||
"signature": "34e9e3902d5960c977b528c4edbb4366ad862f761076755632296840604c8a84ae1bbb503d8d26b2fe7622ca1eccfaa15cb8d23935bcec6dbecdaf6c53f7f50e",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.21-alpha"
|
||||
"version": "1.8.22-alpha"
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ def check(root: Path) -> int:
|
||||
dockerfile = (context / build.get('dockerfile', 'Dockerfile')).resolve()
|
||||
if not dockerfile.is_relative_to(context) or not dockerfile.is_file():
|
||||
raise ValueError(f'{app["id"]}: missing or out-of-context Dockerfile: {dockerfile}')
|
||||
if 'git.tx1138.com/' in dockerfile.read_text():
|
||||
raise ValueError(f'{app["id"]}: Dockerfile references retired registry git.tx1138.com')
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
@@ -81,6 +81,11 @@ def load_catalog(path: Path) -> dict[str, dict[str, Any]]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
manifest = entry.get("manifest")
|
||||
for variant in reversed(entry.get("manifest_variants", [])):
|
||||
requires = variant.get("requires", [])
|
||||
if requires and all(cap == "runtime-migration-backup-v1" for cap in requires):
|
||||
manifest = variant.get("manifest")
|
||||
break
|
||||
if isinstance(manifest, dict) and isinstance(manifest.get("app"), dict):
|
||||
# Embedded manifest: compare against the same fields the disk
|
||||
# manifests expose, plus the entry's own version.
|
||||
|
||||
+27
-98
@@ -32,6 +32,8 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
FIXES_APPLIED=0
|
||||
CHECKS_PASSED=0
|
||||
CHECKS_WARNED=0
|
||||
WARNING_NAMES=()
|
||||
FIX_NAMES=()
|
||||
|
||||
log() { echo "[$(date +%H:%M:%S)] DOCTOR: $*"; }
|
||||
@@ -83,7 +85,13 @@ run_fix() {
|
||||
FIXES_APPLIED=$((FIXES_APPLIED + 1))
|
||||
FIX_NAMES+=("$name")
|
||||
else
|
||||
local status=$?
|
||||
if [ "$status" = 1 ]; then
|
||||
CHECKS_PASSED=$((CHECKS_PASSED + 1))
|
||||
else
|
||||
CHECKS_WARNED=$((CHECKS_WARNED + 1))
|
||||
WARNING_NAMES+=("$name")
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -446,114 +454,33 @@ print(' '.join(['\"' + a + '\"' if ' ' in a else a for a in args[2:]]))
|
||||
[ ${#fixed_names[@]} -gt 0 ] && return 0 || return 1
|
||||
}
|
||||
|
||||
# ── Fix 8: Rootless netns egress lost ────────────────────────
|
||||
# Rootless podman uses pasta to give containers internet egress. If pasta's
|
||||
# tap vanishes (host link flap, mount churn, pasta dying during a boot-time
|
||||
# restart storm), the rootless-netns keeps inter-container traffic working
|
||||
# but silently loses outbound. Bitcoin IBD stalls at 0 peers; package pulls
|
||||
# fail. The repair must rebuild the netns from scratch: merely cycling the
|
||||
# containers reuses the existing (broken) netns because its holders
|
||||
# (aardvark-dns, podman's pause process) survive — observed on a test node
|
||||
# 2026-07-10, where the old stop/start-only cycle bounced all 35 containers
|
||||
# every timer run for ~an hour without ever restoring egress. So: stop the
|
||||
# containers, kill the netns holders, `podman system migrate`, clear the
|
||||
# stale netns state, then start everything back up.
|
||||
#
|
||||
# Destructive-action latch: cycling the whole fleet is a last resort. After
|
||||
# NETNS_CYCLE_MAX consecutive failed repairs we stop cycling (and log loudly)
|
||||
# until a run observes egress healthy again, which resets the counter.
|
||||
NETNS_CYCLE_STATE="/var/lib/archipelago/doctor-netns-cycle-failures"
|
||||
NETNS_CYCLE_MAX=3
|
||||
fix_rootless_netns_egress() {
|
||||
# Needs root for nsenter. When doctor runs as the rootless container owner,
|
||||
# a failed nsenter probe is a permissions artifact, not evidence of broken
|
||||
# egress; do not cycle the fleet from that context.
|
||||
# ── Check 8: Rootless network egress (diagnostic only) ──────
|
||||
# A single external endpoint or nsenter failure cannot establish that the
|
||||
# containers have lost connectivity. In particular, entering only the network
|
||||
# namespace can fail for rootless user namespaces. Never stop apps, kill network
|
||||
# helpers, migrate Podman, or remove network state in response to this probe.
|
||||
# Return 1 for healthy/not applicable and 2 for an inconclusive warning.
|
||||
check_rootless_netns_egress() {
|
||||
[ "$(id -u)" = "0" ] || return 1
|
||||
|
||||
local archi_uid
|
||||
local archi_uid aardvark_pid
|
||||
archi_uid=$(id -u archipelago 2>/dev/null) || return 1
|
||||
|
||||
# Locate the rootless-netns via aardvark-dns (it lives inside it).
|
||||
local aardvark_pid
|
||||
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
|
||||
[ -z "$aardvark_pid" ] && return 1 # no rootless network active
|
||||
[ -n "$aardvark_pid" ] || return 1
|
||||
|
||||
# Host precheck: if the host itself can't reach the internet, no point
|
||||
# cycling containers — this is an upstream problem.
|
||||
if ! timeout 3 bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||
return 1
|
||||
log "WARNING: host connectivity probe failed; external endpoint may be unavailable. Apps left running."
|
||||
return 2
|
||||
fi
|
||||
|
||||
# Probe egress from inside the rootless-netns. One probe is noisy;
|
||||
# require two consecutive failures 10s apart to rule out transients.
|
||||
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||
rm -f "$NETNS_CYCLE_STATE" # healthy again — re-arm the latch
|
||||
return 1 # first probe succeeded
|
||||
return 1
|
||||
fi
|
||||
sleep 10
|
||||
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
|
||||
[ -z "$aardvark_pid" ] && return 1
|
||||
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||
rm -f "$NETNS_CYCLE_STATE"
|
||||
return 1 # recovered on its own
|
||||
fi
|
||||
|
||||
# Latch: don't keep bouncing the fleet when the rebuild demonstrably
|
||||
# isn't fixing it.
|
||||
local failures
|
||||
failures=$(cat "$NETNS_CYCLE_STATE" 2>/dev/null || echo 0)
|
||||
case "$failures" in *[!0-9]*|"") failures=0;; esac
|
||||
if [ "$failures" -ge "$NETNS_CYCLE_MAX" ]; then
|
||||
log "Rootless-netns egress still broken but $failures rebuilds already failed — NOT cycling again (manual intervention needed; rm $NETNS_CYCLE_STATE to re-arm)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Rootless-netns egress is broken (host online, container netns unreachable) — rebuilding netns"
|
||||
|
||||
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/$archi_uid podman"
|
||||
local running
|
||||
running=$($PODMANCMD ps --format '{{.Names}}' 2>/dev/null)
|
||||
if [ -z "$running" ]; then
|
||||
log " No running containers to cycle — skipping"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local count
|
||||
count=$(echo "$running" | wc -l)
|
||||
log " Stopping $count running containers (graceful, 30s)..."
|
||||
$PODMANCMD stop --all --time 30 >/dev/null 2>&1
|
||||
sleep 5
|
||||
|
||||
# Tear the broken netns down for real: kill its holders and drop the
|
||||
# stale state so the first container start rebuilds pasta + aardvark-dns
|
||||
# from scratch. Without this, podman re-enters the old netns and the
|
||||
# missing pasta tap never comes back.
|
||||
log " Rebuilding rootless netns (killing holders, clearing state)..."
|
||||
pkill -U "$archi_uid" -x aardvark-dns 2>/dev/null
|
||||
pkill -U "$archi_uid" -x pasta 2>/dev/null
|
||||
pkill -U "$archi_uid" -x pasta.avx2 2>/dev/null
|
||||
pkill -U "$archi_uid" -x slirp4netns 2>/dev/null
|
||||
sleep 2
|
||||
$PODMANCMD system migrate >/dev/null 2>&1
|
||||
rm -rf "/run/user/$archi_uid/containers/networks"
|
||||
|
||||
log " Starting containers back up..."
|
||||
for c in $running; do
|
||||
$PODMANCMD start "$c" >/dev/null 2>&1 &
|
||||
done
|
||||
wait
|
||||
sleep 5
|
||||
|
||||
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
|
||||
if [ -n "$aardvark_pid" ] && timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||
log " Rootless-netns egress restored ($count containers cycled)"
|
||||
rm -f "$NETNS_CYCLE_STATE"
|
||||
else
|
||||
failures=$((failures + 1))
|
||||
echo "$failures" > "$NETNS_CYCLE_STATE"
|
||||
log " WARN: egress still broken after rebuild (failure $failures/$NETNS_CYCLE_MAX) — may need manual intervention"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
log "WARNING: rootless network probe inconclusive (endpoint, connectivity, or namespace access). Inspect affected apps before repair. Apps left running."
|
||||
return 2
|
||||
}
|
||||
|
||||
# ── Fix 9: Restart stopped core containers ──────────────────
|
||||
@@ -731,7 +658,7 @@ run_fix "tor-permissions" fix_tor_permissions
|
||||
run_fix "searxng" fix_searxng
|
||||
run_fix "bitcoin-txindex" fix_bitcoin_txindex
|
||||
run_fix "exit-127" fix_exit_127
|
||||
run_fix "netns-egress" fix_rootless_netns_egress
|
||||
run_fix "netns-egress" check_rootless_netns_egress
|
||||
run_fix "stopped-core" fix_stopped_core_containers
|
||||
run_fix "rootless-ports" fix_missing_rootless_ports
|
||||
run_fix "npm-public-hosts" fix_npm_public_hosts
|
||||
@@ -740,7 +667,9 @@ run_fix "catatonit" fix_missing_catatonit
|
||||
run_fix "dialout" fix_archipelago_dialout
|
||||
|
||||
echo ""
|
||||
if [ $FIXES_APPLIED -gt 0 ]; then
|
||||
if [ "$CHECKS_WARNED" -gt 0 ]; then
|
||||
log "Done: $CHECKS_WARNED unresolved warnings (${WARNING_NAMES[*]}), $FIXES_APPLIED fixes applied, $CHECKS_PASSED checks passed"
|
||||
elif [ $FIXES_APPLIED -gt 0 ]; then
|
||||
log "Done: $FIXES_APPLIED fixes applied (${FIX_NAMES[*]}), $CHECKS_PASSED checks passed"
|
||||
else
|
||||
log "Done: all $CHECKS_PASSED checks passed — no fixes needed"
|
||||
|
||||
@@ -72,6 +72,10 @@ def manifest_launch_port(app: dict[str, Any]) -> int | None:
|
||||
return port
|
||||
if isinstance(port, str) and port.isdigit():
|
||||
return int(port)
|
||||
# An explicitly headless API/metrics declaration must not gain a
|
||||
# browser launch button just because it has an HTTP health check.
|
||||
if interfaces:
|
||||
return None
|
||||
|
||||
health_check = app.get("health_check")
|
||||
if not isinstance(health_check, dict) or str(health_check.get("type", "")).lower() != "http":
|
||||
@@ -95,6 +99,20 @@ def manifest_launch_port(app: dict[str, Any]) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def manifest_service_ports(app: dict[str, Any]) -> list[int]:
|
||||
"""Declared API endpoints served by the gate also need mesh reachability."""
|
||||
interfaces = app.get("interfaces") or {}
|
||||
declared = {
|
||||
int(i["port"]) for i in interfaces.values()
|
||||
if isinstance(i, dict) and i.get("type") in ("api", "metrics")
|
||||
and str(i.get("port", "")).isdigit()
|
||||
}
|
||||
return [int(p["host"]) for p in app.get("ports", [])
|
||||
if str(p.get("host", "")).isdigit() and int(p["host"]) in declared
|
||||
and p.get("auth") in ("open", "gated", "session")
|
||||
and p.get("protocol", "tcp") == "tcp"]
|
||||
|
||||
|
||||
def manifest_opens_in_new_tab(app: dict[str, Any]) -> bool:
|
||||
"""Return whether manifest launch metadata opts the app out of iframe launch."""
|
||||
launch = metadata(app).get("launch")
|
||||
@@ -190,6 +208,7 @@ def render_rust_ports(ports: dict[str, int], extra_ports: list[int]) -> str:
|
||||
"//! 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] = &[",
|
||||
]
|
||||
lines.extend(f" {port}," for port in distinct)
|
||||
@@ -274,7 +293,8 @@ def main() -> int:
|
||||
if (port := manifest_launch_port(app))
|
||||
}
|
||||
rust_path = Path(args.rust_app_ports)
|
||||
rust_content = render_rust_ports(ports, RUST_EXTRA_PORTS)
|
||||
service_ports = [p for app in manifests.values() for p in manifest_service_ports(app)]
|
||||
rust_content = render_rust_ports(ports, RUST_EXTRA_PORTS + service_ports)
|
||||
rust_old = rust_path.read_text(encoding="utf-8") if rust_path.exists() else ""
|
||||
if rust_old != rust_content:
|
||||
rust_path.write_text(rust_content, encoding="utf-8")
|
||||
|
||||
@@ -36,11 +36,15 @@ source "$ROOT/scripts/image-versions.sh"
|
||||
set +a
|
||||
|
||||
UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" APPS_DIR="$ROOT/apps" \
|
||||
BASE_CATALOG="${BASE_CATALOG:-$ROOT/releases/app-catalog.json}" \
|
||||
PUBLIC_CATALOG="$ROOT/app-catalog/catalog.json" \
|
||||
EMBED_MANIFESTS="${EMBED_MANIFESTS:-1}" python3 - <<'PY'
|
||||
import glob
|
||||
import json, os
|
||||
|
||||
with open(os.environ["BASE_CATALOG"], encoding="utf-8") as baseline_file:
|
||||
baseline_entries = json.load(baseline_file).get("apps", {})
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
@@ -182,7 +186,17 @@ if os.environ.get("EMBED_MANIFESTS") and apps_dir:
|
||||
continue
|
||||
entry = apps.setdefault(str(app_id), {})
|
||||
entry.setdefault("version", str(app.get("version", "")) or "0")
|
||||
entry["manifest"] = _retarget_registry(data)
|
||||
rendered = _retarget_registry(data)
|
||||
if data["app"].get("backup_before_runtime_change"):
|
||||
baseline = baseline_entries.get(app_id, {}).get("manifest")
|
||||
if not baseline or baseline.get("app", {}).get("backup_before_runtime_change"):
|
||||
raise SystemExit(f"{app_id}: a pre-migration BASE_CATALOG manifest is required for old-node compatibility")
|
||||
entry["manifest"] = baseline
|
||||
entry["manifest_variants"] = [{
|
||||
"requires": ["runtime-migration-backup-v1"], "manifest": rendered,
|
||||
}]
|
||||
else:
|
||||
entry["manifest"] = rendered
|
||||
embedded += 1
|
||||
|
||||
# Multi-version support (docs/bitcoin-multi-version-design.md §3 Phase 1):
|
||||
|
||||
@@ -71,6 +71,25 @@ else
|
||||
bad "incomplete app build payload"
|
||||
fi
|
||||
|
||||
# The cached rootfs must never restore the unsafe historical doctor on boot.
|
||||
for doctor_file in container-doctor.sh archipelago-doctor.service archipelago-doctor.timer; do
|
||||
if [[ "$doctor_file" == container-doctor.sh ]]; then
|
||||
doctor_source="$REPO/scripts/$doctor_file"
|
||||
else
|
||||
doctor_source="$REPO/image-recipe/configs/$doctor_file"
|
||||
fi
|
||||
if cmp -s "$doctor_source" "$MNT/archipelago/scripts/$doctor_file"; then
|
||||
ok "current doctor payload: $doctor_file"
|
||||
else
|
||||
bad "missing/stale doctor overlay: $doctor_file"
|
||||
fi
|
||||
done
|
||||
if grep -Fq '# BEGIN DOCTOR OVERLAY' "$MNT/archipelago/auto-install.sh"; then
|
||||
ok "installer replaces cached doctor before first boot"
|
||||
else
|
||||
bad "installer lacks cached doctor replacement"
|
||||
fi
|
||||
|
||||
# ── GRUB must boot the live system ───────────────────────────────────
|
||||
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
|
||||
ok "grub.cfg has boot=live"
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migrate the known NPM/LND tunnel collision, without touching wallet services.
|
||||
|
||||
Runs as the rootless app owner before orchestrator startup. Only the narrow
|
||||
legacy web-tunnel profile is accepted. Unknown custom routing fails closed.
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def command(*args, input=None):
|
||||
result = subprocess.run(args, input=input, text=True, capture_output=True, timeout=45)
|
||||
if result.returncode:
|
||||
# Commands may read private files. Never print their captured output.
|
||||
raise RuntimeError(f'{args[0]} operation failed (exit {result.returncode})')
|
||||
return result.stdout
|
||||
|
||||
|
||||
def plan(drop, rules):
|
||||
"""Return a conservative migration, or None for absent/already fixed mapping."""
|
||||
matches = re.findall(r'^PublishPort=([0-9.]+):18080:80/tcp$', drop, re.M)
|
||||
if not matches:
|
||||
return None
|
||||
if len(matches) != 1:
|
||||
raise ValueError('ambiguous NPM tunnel mapping')
|
||||
destination = str(ipaddress.IPv4Address(matches[0]))
|
||||
peer_match = re.search(r'ip saddr ([0-9.]+) ip daddr ' + re.escape(destination)
|
||||
+ r' tcp dport \{ 18080, 18443 \} accept', rules)
|
||||
if not peer_match:
|
||||
raise ValueError('unrecognized NPM tunnel firewall; manual review required')
|
||||
peer = str(ipaddress.IPv4Address(peer_match[1]))
|
||||
# Match the entire old profile, not just a substring in an arbitrary firewall.
|
||||
old = f'''table inet web_tunnel {{
|
||||
chain input {{
|
||||
type filter hook input priority -10; policy accept;
|
||||
iifname != "wg-web" return
|
||||
ct state established,related accept
|
||||
ip saddr {peer} icmp type echo-request accept
|
||||
ip saddr {peer} ip daddr {destination} tcp dport {{ 18080, 18443 }} accept
|
||||
counter drop
|
||||
}}
|
||||
chain forward {{
|
||||
type filter hook forward priority -10; policy accept;
|
||||
iifname "wg-web" counter drop
|
||||
oifname "wg-web" counter drop
|
||||
}}
|
||||
}}'''
|
||||
if rules.split() != old.split():
|
||||
raise ValueError('custom NPM tunnel firewall differs; manual review required')
|
||||
if 'PublishPort='+destination+':18081:' in drop:
|
||||
raise ValueError('replacement port already configured')
|
||||
new_rules = rules.replace('table inet web_tunnel {', f'''table inet web_tunnel {{
|
||||
# Preserve incoming HTTP while keeping LND REST's port free.
|
||||
chain prerouting {{
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
iifname "wg-web" ip saddr {peer} ip daddr {destination} tcp dport 18080 redirect to :18081
|
||||
}}''', 1).replace('tcp dport { 18080, 18443 } accept',
|
||||
'tcp dport { 18081, 18443 } accept')
|
||||
return (drop.replace(f'PublishPort={destination}:18080:80/tcp',
|
||||
f'PublishPort={destination}:18081:80/tcp'), new_rules, destination)
|
||||
|
||||
|
||||
def atomic_user(path, content):
|
||||
with tempfile.NamedTemporaryFile(mode='w', dir=path.parent, delete=False) as f:
|
||||
tmp = Path(f.name)
|
||||
os.fchmod(f.fileno(), 0o600)
|
||||
f.write(content)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def root_write(path, content):
|
||||
# Stage next to the destination; rename makes the config update atomic.
|
||||
staged = str(path)+'.archy-npm-migration'
|
||||
command('sudo', '-n', 'tee', staged, input=content)
|
||||
command('sudo', '-n', 'chmod', '600', staged)
|
||||
command('sudo', '-n', 'mv', '--', staged, str(path))
|
||||
|
||||
|
||||
def main():
|
||||
drop = Path.home()/'.config/containers/systemd/nginx-proxy-manager.container.d/web-tunnel.conf'
|
||||
rules_path = Path('/etc/wireguard/wg-web.nft')
|
||||
state = Path.home()/'.local/state/archipelago/npm-tunnel-migration'
|
||||
journal = state/'pending.json'
|
||||
recovered_active = None
|
||||
# Interrupted migrations are completed/rolled back before normal startup.
|
||||
if journal.exists():
|
||||
saved = json.loads(journal.read_text())
|
||||
command('systemctl', '--user', 'stop', 'nginx-proxy-manager.service')
|
||||
atomic_user(drop, saved['drop'])
|
||||
root_write(rules_path, saved['rules'])
|
||||
command('sudo', '-n', 'nft', '-f', '-', input='delete table inet web_tunnel\n'+saved['rules'])
|
||||
command('systemctl', '--user', 'daemon-reload')
|
||||
# Do not restart the colliding old configuration before reapplying.
|
||||
recovered_active = saved.get('was_active')
|
||||
journal.unlink()
|
||||
if not drop.exists():
|
||||
return
|
||||
old_drop = drop.read_text()
|
||||
if not re.search(r'^PublishPort=[0-9.]+:18080:80/tcp$', old_drop, re.M):
|
||||
return
|
||||
old_rules = command('sudo', '-n', 'cat', str(rules_path))
|
||||
new_drop, new_rules, destination = plan(old_drop, old_rules)
|
||||
# A free, assigned replacement is required; do not guess another port.
|
||||
with socket.socket() as probe:
|
||||
probe.bind((destination, 18081))
|
||||
# The route must be persistent and loaded by the tunnel's startup contract.
|
||||
wg = command('sudo', '-n', 'grep', '-E', r'^(PreUp|PostDown)\s*=', '/etc/wireguard/wg-web.conf')
|
||||
if 'PreUp = nft -f /etc/wireguard/wg-web.nft' not in wg or 'PostDown = nft delete table inet web_tunnel' not in wg:
|
||||
raise ValueError('unrecognized tunnel lifecycle; manual review required')
|
||||
active = command('sudo', '-n', 'nft', 'list', 'table', 'inet', 'web_tunnel')
|
||||
# Reject live-only rule changes instead of silently discarding them. nft
|
||||
# canonicalizes priority names and adds counter values when listing rules.
|
||||
def normalized(text):
|
||||
text = re.sub(r'counter packets \d+ bytes \d+', 'counter', text)
|
||||
return text.replace('priority filter - 10', 'priority -10').split()
|
||||
if normalized(active) != normalized(old_rules):
|
||||
raise ValueError('live tunnel rules differ from persistent config; review required')
|
||||
transaction = 'delete table inet web_tunnel\n'+new_rules
|
||||
command('sudo', '-n', 'nft', '--check', '-f', '-', input=transaction)
|
||||
state.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
os.chmod(state, 0o700)
|
||||
was_active = recovered_active or command('systemctl', '--user', 'show', 'nginx-proxy-manager.service', '--property=ActiveState', '--value').strip()
|
||||
saved = json.dumps({'drop': old_drop, 'rules': old_rules, 'was_active': was_active})
|
||||
atomic_user(state/'before.json', saved)
|
||||
atomic_user(journal, saved)
|
||||
try:
|
||||
command('systemctl', '--user', 'stop', 'nginx-proxy-manager.service')
|
||||
atomic_user(drop, new_drop)
|
||||
root_write(rules_path, new_rules)
|
||||
command('sudo', '-n', 'nft', '-f', '-', input=transaction)
|
||||
command('systemctl', '--user', 'daemon-reload')
|
||||
if was_active in ('active', 'activating', 'reloading', 'failed'):
|
||||
command('systemctl', '--user', 'restart', 'nginx-proxy-manager.service')
|
||||
journal.unlink()
|
||||
except Exception:
|
||||
atomic_user(drop, old_drop)
|
||||
root_write(rules_path, old_rules)
|
||||
command('sudo', '-n', 'nft', '-f', '-', input='delete table inet web_tunnel\n'+old_rules)
|
||||
command('systemctl', '--user', 'daemon-reload')
|
||||
# Keep the journal if rollback fails so the next startup retries it.
|
||||
journal.unlink()
|
||||
raise
|
||||
print('NPM tunnel port repaired; original configuration backed up; native services unchanged')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
raise SystemExit('NPM tunnel migration requires attention: '+str(error)) from None
|
||||
@@ -0,0 +1,137 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import tempfile
|
||||
import json
|
||||
|
||||
spec=importlib.util.spec_from_file_location('repair', Path(__file__).parents[1]/'repair-npm-tunnel.py')
|
||||
m=importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
DROP='[Container]\nPublishPort=10.77.0.2:18080:80/tcp\nPublishPort=10.77.0.2:18443:443/tcp\n'
|
||||
RULES='''table inet web_tunnel {
|
||||
chain input {
|
||||
type filter hook input priority -10; policy accept;
|
||||
iifname != "wg-web" return
|
||||
ct state established,related accept
|
||||
ip saddr 10.77.0.1 icmp type echo-request accept
|
||||
ip saddr 10.77.0.1 ip daddr 10.77.0.2 tcp dport { 18080, 18443 } accept
|
||||
counter drop
|
||||
}
|
||||
chain forward {
|
||||
type filter hook forward priority -10; policy accept;
|
||||
iifname "wg-web" counter drop
|
||||
oifname "wg-web" counter drop
|
||||
}
|
||||
}'''
|
||||
|
||||
class Plan(unittest.TestCase):
|
||||
def test_preserves_peer_https_and_restricts_redirect(self):
|
||||
drop,rules,dst=m.plan(DROP,RULES)
|
||||
self.assertEqual(dst,'10.77.0.2')
|
||||
self.assertIn('PublishPort=10.77.0.2:18081:80/tcp',drop)
|
||||
self.assertIn('PublishPort=10.77.0.2:18443:443/tcp',drop)
|
||||
self.assertIn('iifname "wg-web" ip saddr 10.77.0.1 ip daddr 10.77.0.2 tcp dport 18080 redirect to :18081',rules)
|
||||
self.assertNotIn('tcp dport { 18080, 18443 } accept',rules)
|
||||
self.assertIn('iifname "wg-web" counter drop',rules)
|
||||
def test_idempotent(self):
|
||||
d,r,_=m.plan(DROP,RULES)
|
||||
self.assertIsNone(m.plan(d,r))
|
||||
def test_standard_fresh_install_untouched(self):
|
||||
self.assertIsNone(m.plan('[Container]\nPublishPort=127.0.0.1:8081:81/tcp',''))
|
||||
def test_no_hardcoded_deployment_address(self):
|
||||
d,r,dst=m.plan(DROP.replace('10.77.0.','10.55.0.'),RULES.replace('10.77.0.','10.55.0.'))
|
||||
self.assertEqual(dst,'10.55.0.2');self.assertIn('ip saddr 10.55.0.1',r)
|
||||
def test_custom_firewall_preserved(self):
|
||||
for r in [RULES+'\ntable inet extra {}',RULES.replace('counter drop','accept'),RULES.replace('wg-web','wg-custom')]:
|
||||
with self.assertRaises(ValueError): m.plan(DROP,r)
|
||||
def test_ambiguous_mapping(self):
|
||||
with self.assertRaises(ValueError): m.plan(DROP+DROP,RULES)
|
||||
def test_wrong_destination(self):
|
||||
with self.assertRaises(ValueError): m.plan(DROP.replace('10.77.0.2','10.77.0.3'),RULES)
|
||||
def test_already_used_mapping(self):
|
||||
with self.assertRaises(ValueError): m.plan(DROP+'PublishPort=10.77.0.2:18081:80/tcp\n',RULES)
|
||||
|
||||
class Migration(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp=tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.home=Path(self.temp.name)
|
||||
self.drop=self.home/'.config/containers/systemd/nginx-proxy-manager.container.d/web-tunnel.conf'
|
||||
self.drop.parent.mkdir(parents=True)
|
||||
self.drop.write_text(DROP)
|
||||
self.calls=[];self.rules=RULES;self.fail=None
|
||||
self.state=self.home/'.local/state/archipelago/npm-tunnel-migration'
|
||||
def command(self,*args,input=None):
|
||||
self.calls.append((args,input))
|
||||
if self.fail and self.fail(args):
|
||||
self.fail=None
|
||||
raise RuntimeError('injected failure')
|
||||
if 'cat' in args or ('list' in args and 'nft' in args): return self.rules
|
||||
if 'grep' in args: return 'PreUp = nft -f /etc/wireguard/wg-web.nft\nPostDown = nft delete table inet web_tunnel'
|
||||
if 'show' in args: return 'active'
|
||||
return ''
|
||||
def run_migration(self):
|
||||
with patch.object(Path,'home',return_value=self.home),patch.object(m,'command',side_effect=self.command),patch.object(m,'root_write') as write,patch.object(m.socket,'socket'):
|
||||
m.main()
|
||||
return write
|
||||
def test_success_and_second_run_noop(self):
|
||||
write=self.run_migration()
|
||||
self.assertIn(':18081:80/tcp',self.drop.read_text())
|
||||
self.assertEqual(json.loads((self.state/'before.json').read_text())['drop'],DROP)
|
||||
self.assertFalse((self.state/'pending.json').exists())
|
||||
self.assertEqual(write.call_count,1)
|
||||
self.calls.clear();self.run_migration();self.assertEqual(self.calls,[])
|
||||
def test_no_native_service_commands(self):
|
||||
self.run_migration()
|
||||
for args,_ in self.calls:
|
||||
self.assertNotIn('lnd.service',args);self.assertNotIn('bitcoin-core.service',args)
|
||||
def test_validation_failure_does_not_stop_or_write(self):
|
||||
self.fail=lambda a:'--check' in a
|
||||
with self.assertRaises(RuntimeError):self.run_migration()
|
||||
self.assertEqual(self.drop.read_text(),DROP)
|
||||
self.assertFalse(self.state.exists())
|
||||
self.assertFalse(any('stop' in a for a,_ in self.calls))
|
||||
def test_apply_failure_restores_files_and_firewall(self):
|
||||
self.fail=lambda a:'nft' in a and '-f' in a and '--check' not in a
|
||||
with self.assertRaises(RuntimeError):self.run_migration()
|
||||
self.assertEqual(self.drop.read_text(),DROP)
|
||||
self.assertFalse((self.state/'pending.json').exists())
|
||||
self.assertTrue(any(v=='delete table inet web_tunnel\n'+RULES for _,v in self.calls))
|
||||
def test_crash_journal_recovers_and_retries(self):
|
||||
self.state.mkdir(parents=True)
|
||||
(self.state/'pending.json').write_text(json.dumps({'drop':DROP,'rules':RULES,'was_active':'active'}))
|
||||
self.drop.write_text(m.plan(DROP,RULES)[0])
|
||||
self.run_migration()
|
||||
self.assertIn(':18081:80/tcp',self.drop.read_text())
|
||||
self.assertFalse((self.state/'pending.json').exists())
|
||||
def test_fresh_install_executes_no_commands(self):
|
||||
self.drop.unlink();self.run_migration();self.assertEqual(self.calls,[])
|
||||
def test_busy_replacement_port_does_not_mutate(self):
|
||||
with patch.object(Path,'home',return_value=self.home),patch.object(m,'command',side_effect=self.command),patch.object(m.socket,'socket') as socket:
|
||||
socket.return_value.__enter__.return_value.bind.side_effect=OSError('in use')
|
||||
with self.assertRaises(OSError):m.main()
|
||||
self.assertFalse(self.state.exists())
|
||||
self.assertFalse(any('stop' in a for a,_ in self.calls))
|
||||
def test_failed_rollback_keeps_recovery_journal(self):
|
||||
with patch.object(Path,'home',return_value=self.home),patch.object(m,'command',side_effect=self.command),patch.object(m,'root_write',side_effect=RuntimeError('write failed')),patch.object(m.socket,'socket'):
|
||||
with self.assertRaises(RuntimeError):m.main()
|
||||
self.assertTrue((self.state/'pending.json').exists())
|
||||
self.assertEqual(self.drop.read_text(),DROP)
|
||||
def test_stopped_app_is_not_started(self):
|
||||
original=self.command
|
||||
def stopped(*args,input=None):
|
||||
return 'inactive' if 'show' in args else original(*args,input=input)
|
||||
with patch.object(Path,'home',return_value=self.home),patch.object(m,'command',side_effect=stopped),patch.object(m,'root_write'),patch.object(m.socket,'socket'):
|
||||
m.main()
|
||||
self.assertFalse(any('restart' in a for a,_ in self.calls))
|
||||
def test_live_only_firewall_changes_are_not_discarded(self):
|
||||
original=self.command
|
||||
def different(*args,input=None):
|
||||
result=original(*args,input=input)
|
||||
return result+' table inet custom {}' if 'list' in args else result
|
||||
with patch.object(Path,'home',return_value=self.home),patch.object(m,'command',side_effect=different),patch.object(m.socket,'socket'):
|
||||
with self.assertRaises(ValueError):m.main()
|
||||
self.assertEqual(self.drop.read_text(),DROP)
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Opt-in disposable rootless Angor gateway integration checks. No native app changes."""
|
||||
import subprocess,pathlib,json,urllib.request,urllib.error,time,tempfile,os,uuid,shlex
|
||||
import yaml
|
||||
if os.environ.get('ARCHY_ALLOW_DISPOSABLE_CONTAINERS') != '1':
|
||||
raise SystemExit('Set ARCHY_ALLOW_DISPOSABLE_CONTAINERS=1 to run isolated test containers')
|
||||
manifest=yaml.safe_load((pathlib.Path(__file__).resolve().parents[2]/'apps/angor-indexer/manifest.yml').read_text())['app']
|
||||
health=manifest['health_check']
|
||||
health_url=health['endpoint'].rstrip('/')+health.get('path','/')
|
||||
run_id=uuid.uuid4().hex[:12]
|
||||
net='archy-angor-test-'+run_id;backend='angor-test-backend-'+run_id;gateway='angor-test-gateway-'+run_id
|
||||
def run(*a):
|
||||
r=subprocess.run(a,capture_output=True,text=True)
|
||||
if r.returncode:raise RuntimeError(r.stderr)
|
||||
return r.stdout.strip()
|
||||
def req(path,data=None,method=None,headers={}):
|
||||
r=urllib.request.Request('http://127.0.0.1:19098'+path,data=data,method=method,headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(r,timeout=10) as f:return f.status,f.headers,f.read()
|
||||
except urllib.error.HTTPError as e:return e.code,e.headers,e.read()
|
||||
script="""require('http').createServer((q,r)=>{let b='';q.on('data',x=>b+=x);q.on('end',()=>{r.setHeader('Access-Control-Allow-Origin','https://wrong.example');if(q.url==='/api/v1/blocks/tip/height'){r.end('900000');return}r.setHeader('Content-Type','application/json');r.end(JSON.stringify({url:q.url,method:q.method,body:b,cookie:q.headers.cookie||null,auth:q.headers.authorization||null}))})}).listen(8999,'0.0.0.0')"""
|
||||
def start_backend():run('podman','run','-d','--name',backend,'--network',net,'--network-alias','mempool-api','--cap-drop=all','--security-opt=no-new-privileges','docker.io/library/node:24-alpine','node','-e',script)
|
||||
def ready(seconds=40):
|
||||
end=time.monotonic()+seconds
|
||||
while time.monotonic()<end:
|
||||
try:
|
||||
if req('/health')[0]==200:return
|
||||
except OSError:pass
|
||||
time.sleep(1)
|
||||
raise RuntimeError('Gateway readiness did not recover')
|
||||
assert subprocess.run(['podman','network','exists',net]).returncode==1
|
||||
run('podman','network','create',net)
|
||||
try:
|
||||
start_backend()
|
||||
run('podman','run','-d','--name',gateway,'--network',net,'--read-only','--cap-drop=all','--security-opt=no-new-privileges','--memory','128m','--health-cmd','wget -q -T 5 -O /dev/null '+shlex.quote(health_url),'--health-interval','5s','--health-retries','2','-p','127.0.0.1:19098:8080','source.archipelago-foundation.org/chaum/angor-indexer:1.0.1')
|
||||
ready()
|
||||
run('podman','healthcheck','run',gateway)
|
||||
assert json.loads(run('podman','inspect',gateway))[0]['State']['Health']['Status']=='healthy'
|
||||
print('PASS manifest health check inside actual image (including localhost address family)',flush=True)
|
||||
for path in ['/api/v1/address/bc1fixture/txs?after_txid=abc','/api/v1/fees/recommended','/api/tx/fixture/hex']:
|
||||
status,headers,body=req(path,headers={'Cookie':'node-secret=do-not-forward','Authorization':'Bearer do-not-forward'})
|
||||
result=json.loads(body);assert status==200 and result['url']==(path if path.startswith('/api/v1/') else path.replace('/api/','/api/v1/',1)) and result['cookie'] is None and result['auth'] is None
|
||||
assert headers.get_all('Access-Control-Allow-Origin')==['*']
|
||||
assert req('/api/v1/tx',b'deadbeef')[0]==200
|
||||
assert json.loads(req('/api/v1/tx',b'deadbeef')[2])['body']=='deadbeef'
|
||||
assert req('/api/v1/fees/recommended',b'bad')[0]==403
|
||||
assert req('/api/v1/tx',b'bad',method='DELETE')[0]==403
|
||||
assert req('/api/v1/tx',method='OPTIONS')[0]==204
|
||||
assert req('/api/v1/tx',b'x'*(4*1024*1024+1))[0]==413
|
||||
assert req('/unknown')[0]==404
|
||||
d=json.loads(run('podman','inspect',gateway))[0];assert d['Config']['User']=='101:101' and not d['BoundingCaps']
|
||||
print('PASS API paths/query/body, transaction-only POST, method/size limits, CORS, credential stripping and unprivileged read-only image',flush=True)
|
||||
run('podman','stop',backend)
|
||||
status,headers,body=req('/health');assert status==503 and json.loads(body)['status']=='waiting'
|
||||
run('podman','rm',backend);start_backend();ready()
|
||||
print('PASS backend outage returns truthful 503; backend recreation recovers through runtime DNS without gateway restart',flush=True)
|
||||
except BaseException:
|
||||
subprocess.run(['podman','logs','--tail','15',gateway],check=False)
|
||||
raise
|
||||
finally:
|
||||
for name in [gateway,backend]:subprocess.run(['podman','rm','-f','--time','3',name],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
subprocess.run(['podman','network','rm',net],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Headless store apps must be discoverable without acquiring a UI launcher."""
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import unittest
|
||||
import yaml
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location('catalog_generator', ROOT / 'scripts/generate-app-catalog.py')
|
||||
generator = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(generator)
|
||||
|
||||
class ServiceMetadata(unittest.TestCase):
|
||||
def test_headless_services_have_mesh_ports_but_no_browser_launcher(self):
|
||||
for name, port in [('angor-indexer', 8998), ('angor-relay', 8091)]:
|
||||
app = yaml.safe_load((ROOT / 'apps' / name / 'manifest.yml').read_text())['app']
|
||||
self.assertIsNone(generator.manifest_launch_port(app))
|
||||
self.assertEqual(generator.manifest_service_ports(app), [port])
|
||||
self.assertEqual(app['ports'][0]['bind'], '127.0.0.1')
|
||||
self.assertEqual(app['security']['capabilities'], [])
|
||||
|
||||
def test_indexer_health_targets_ipv4_listener(self):
|
||||
app = yaml.safe_load((ROOT / 'apps/angor-indexer/manifest.yml').read_text())['app']
|
||||
self.assertEqual(app['health_check']['endpoint'], 'http://127.0.0.1:8080')
|
||||
self.assertEqual(app['health_check']['path'], '/health')
|
||||
|
||||
def test_host_local_api_never_opens_mesh_port(self):
|
||||
app = {'interfaces': {'main': {'type': 'api', 'port': 8999}},
|
||||
'ports': [{'host': 8999, 'auth': 'local'}],
|
||||
'health_check': {'type': 'http'}}
|
||||
self.assertIsNone(generator.manifest_launch_port(app))
|
||||
self.assertEqual(generator.manifest_service_ports(app), [])
|
||||
|
||||
def test_legacy_ui_fallback_retained(self):
|
||||
self.assertEqual(generator.manifest_launch_port({'ports': [{'host': 8080}],
|
||||
'health_check': {'type': 'http'}}), 8080)
|
||||
|
||||
def test_relay_storage_cannot_share_node_identity_or_database(self):
|
||||
node = yaml.safe_load((ROOT / 'apps/strfry/manifest.yml').read_text())['app']
|
||||
angor = yaml.safe_load((ROOT / 'apps/angor-relay/manifest.yml').read_text())['app']
|
||||
node_paths = {v['source'] for v in node['volumes']}
|
||||
self.assertTrue(node_paths.isdisjoint(v['source'] for v in angor['volumes']))
|
||||
self.assertTrue(all(not f['overwrite'] for f in angor['files']))
|
||||
self.assertEqual(angor['interfaces']['main']['type'], 'api')
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -40,6 +40,12 @@ class BuildPayloadTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, 'out-of-payload'):
|
||||
contexts.check(self.root)
|
||||
|
||||
def test_retired_registry_rejected(self):
|
||||
target = self.root / 'docker/lnd-ui/Dockerfile'
|
||||
target.write_text('FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine\n')
|
||||
with self.assertRaisesRegex(ValueError, 'lnd-ui.*retired registry'):
|
||||
contexts.check(self.root)
|
||||
|
||||
def test_empty_payload_rejected(self):
|
||||
shutil.rmtree(self.root / 'apps')
|
||||
with self.assertRaisesRegex(ValueError, 'No app manifests'):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user