Compare commits

...
Author SHA1 Message Date
archipelago c9f1d87dd6 chore: release v1.7.124-alpha 2026-08-05 16:42:57 -04:00
archipelagoandClaude Fable 5 4f8c76c67e style: rustfmt the regenerated app_ports list
generate-app-catalog.py writes APP_LAUNCH_PORTS one entry per line; rustfmt
packs it. The release gate checks formatting, so the generated file has to
be formatted after regeneration or every catalog sync fails the gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:30:13 -04:00
archipelagoandClaude Fable 5 d5ca612e2b chore(catalog): sync catalogs to the manifests for 1.7.124
Demo images / Build & push demo images (push) Successful in 3m42s
Portainer's image reaches the public catalog (the release gate caught the
manifest and catalog disagreeing), and fips-ui 8336 joins the mesh relay's
port list now that it declares a port — it is auth: gated, so the relay
withholds it rather than bridging it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:27:19 -04:00
archipelagoandClaude Fable 5 0a374c80a6 style: rustfmt the merged PR #125 hunks and the mirror test; sync Cargo.lock
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:24:31 -04:00
archipelagoandClaude Fable 5 6668359875 chore: bump to 1.7.124-alpha ahead of the release run
Demo images / Build & push demo images (push) Successful in 3m42s
Pre-bumped so the release gate compiles the test profile at the final
version — create-release bumps after the gate, so the gate would otherwise
run on the old version and the bump would invalidate the cache, timing out
cargo-test-weekly on the compile rather than the tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:10:49 -04:00
archipelagoandClaude Fable 5 cc709fd7da docs(1.7.124): curate release notes and add the in-app What's New block
Demo images / Build & push demo images (push) Successful in 3m46s
Leads with the update that switched nodes off and left them unable to
switch back on — the one an operator most needs to understand, and the
reason to take this release promptly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:58:28 -04:00
archipelagoandClaude Fable 5 9dd02359e4 feat(settings): session timeout is configurable from the UI
Demo images / Build & push demo images (push) Successful in 3m43s
auth.session-policy.get/set plus a card under Account. Presented as two
plain questions rather than the token mechanism underneath, because the
distinction that matters to an operator is which control actually ends a
session: the dashboard polls constantly, so an idle timeout alone never
fires on an open tab — the absolute cap is what guarantees it.

Values are clamped server-side and the stored result is echoed back, so
the bounds are discoverable instead of an error. Presets rather than a free
number field: a box accepting '5' invites locking yourself out. A short
idle choice warns that it is the payments-industry posture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:29:17 -04:00
archipelagoandClaude Fable 5 81033ed6f5 fix(ota): repair a stale Restart=on-failure unit that leaves nodes dead after update
austin-sapien (100.70.96.88) sat dead for over two hours after taking
v1.7.122 — 'server starting' in the UI, service inactive, exit status
0/SUCCESS. It did not crash: the in-process updater replaces the binary and
exits cleanly for systemd to restart it, and that node's unit still carried
Restart=on-failure from an older install. systemd read the clean exit as
success and left it stopped. Every node with the old unit has this waiting
for it on the next update.

self-update.sh does refresh units, but the in-process update path never
runs it, so nothing was repairing them. The daemon now checks its own unit
at boot and rewrites only the Restart= line, so a node that starts even
once ends up with a policy that survives the next update.

Also carries the session-policy wiring: validate() now honours the
configured idle and absolute limits and the per-device class, instead of
the single hard-coded 24h constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 13:50:16 -04:00
archipelagoandClaude Fable 5 d8647f6576 fix(mesh): tabbed tools column on very wide screens; add configurable session policy
Demo images / Build & push demo images (push) Successful in 3m36s
Mesh right panel: a >=2560px screen hid the tab bar and stacked all five
tool panels in fixed grid rows. On a real display that clipped the Bitcoin,
Dead Man and AI headings to a few pixels each, letterboxed the map, and
pushed Radio Settings into a scroll — more screen producing a worse view.
Very wide now uses the same tabbed column as every other desktop width,
with the selected panel filling the column and the map running edge to edge
(it is the one panel with nothing to scroll).

Session policy: idle timeout, absolute cap and a re-prompt-for-funds flag,
persisted and clamped. Two tokens already existed — a session token and a
30-day login token — so the knob changes how long a quiet tab stays usable
without putting a long-lived credential on every request. Kiosk screens are
exempt from the idle timeout (nobody is there to log a TV back in) but keep
the absolute cap so a stolen box does not stay authenticated forever. The
cap is not optional theatre: idle alone never fires on a polling dashboard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 13:03:31 -04:00
archipelagoandClaude Fable 5 91bbe4faa1 fix: portainer pin, bitcoin conf tolerance, gate login UI, named OTA origin
Portainer: nodes have been running :latest — which is 2.39.1 — while the
manifest pinned 2.19.4 from two years ago. The port migration recreated the
container onto that old pin and Portainer refused to start: it migrates a
database forward, never backward, so an existing install died with 'schema
version does not align' and My Apps showed 'app is not responding'
(100.82.34.38). 2.39.1 published as an immutable tag and pinned forward, so
existing databases keep working and older ones migrate up.

Bitcoin: complements PR #131. That removes the code which kept writing a
datadir bitcoin.conf; -allowignoredconf=1 additionally makes an existing
one non-fatal, so a node already carrying the file recovers on restart
instead of crash-looping until something reinstalls it.

App gate login: rebuilt against the dashboard's own design — rotating
intro backgrounds served from the gate, the glass panel, the Archipelago
mark in its gradient ring, the app's icon as a My Apps tile, and the glass
button. Crucially it no longer sends X-Frame-Options: DENY, which made
every gated app render as unreachable inside My Apps' embedded frame;
frame-ancestors expresses 'only this node may frame me', which
X-Frame-Options cannot.

OTA origin: primary mirror is now source.archipelago-foundation.org over
TLS instead of a bare IP on plaintext. The IP stays as an automatic
fallback for nodes whose DNS or clock is broken — both break TLS, and the
signature, not the transport, is what establishes trust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 12:44:57 -04:00
archipelagoandClaude Fable 5 08a725ba12 Merge PR #131: stop writing a datadir bitcoin.conf that conflicts with -conf
Root cause of the Bitcoin crash-loop on 100.82.34.38: since a597c1d9
bitcoind launches with -conf=/tmp/rpc.conf and never reads the datadir
bitcoin.conf, but write_bitcoin_conf / ensure_bitcoin_rpc_config /
run_bitcoin_rpc_repair kept writing one on every install and restart.
Bitcoin Core's own datadir-conflict check then refuses to start at all.

Conflict resolved in favour of the PR: HEAD still carried
write_bitcoin_conf, whose deletion is the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 12:25:26 -04:00
archipelago a9f9be2f0d Merge PR #125: FIPS last-known-good endpoint fallback for direct peering
LAN -> last-known-good -> anchor-tree escalation, npub-keyed endpoint store
persisted with 30-day retention.
2026-08-05 12:22:22 -04:00
archipelago e5612fff0f Merge PR #132: translate Cashu NUT error codes into plain-language messages
Mint failures surfaced raw JSON ({"detail":"proofs already spent"}) to
the user; now the top-level message is actionable while the raw body stays
in logs via {:#}.
2026-08-05 12:22:22 -04:00
archipelagoandClaude Fable 5 4ec53a9805 fix(ota): republish the .122 manifest — the rotation stranded every pre-.122 node
The manifest advertises exactly one version, so publishing .123 (new-key
signed) removed the only stepping stone across the rotation. A node on
.121 pins the OLD root, fetches the .123 manifest, fails signature
verification and refuses — permanently, because .122 is no longer offered
anywhere. Reproduced against the live URL: 'signed_by does not match the
pinned release-root anchor'. archy-shorty-s (.228) is on 1.7.121-alpha-dev
and in exactly this state.

Restoring the old-key-signed .122 manifest as the OTA pointer lets those
nodes take .122, which installs the new pin; .123 is republished once the
fleet has crossed. Safe in both directions: is_newer() is a strict
greater-than on the version triple, so a node already on .123 sees .122 as
older and does not downgrade.

The .123 release itself is untouched — tag, assets and catalog stand; only
the pointer moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 10:24:09 -04:00
archipelagoandClaude Fable 5 9b30daaf9c fix(security): restart a companion whose image was rebuilt underneath it
A rebuilt image never reached a running companion. ensure_image_present
rebuilds in place under the same tag, so the quadlet body is identical,
write_if_changed reports no change, and enable_now is a no-op on a running
service — the container keeps the old layers indefinitely.

That is precisely how archi-dev-box kept serving the LND, FIPS, Electrs and
Guardian screens on 0.0.0.0 after v1.7.123 rebuilt every one of those images
to bind loopback: correct images on disk, three-day-old containers still
running. Closing those ports needed a manual 'podman rm -f' per container,
which no other node would ever get. Compare the running container's image ID
against the built one and restart when they diverge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 10:17:58 -04:00
ssmithxandClaude Sonnet 5 53b158ce5a fix(wallet): translate Cashu NUT error codes into plain-language messages
Mint HTTP failures (swap/melt/mint-quote) were surfacing raw JSON bodies
like {"detail":"proofs already spent","code":11001} straight to the
user. Add a translator for the NUT-02/03/04/05 transaction-validation
error codes (10001-11017, 12001-12003; see
https://github.com/cashubtc/nuts/blob/main/error_codes.md) and layer it
onto the mint_client bail sites via anyhow context, so the top-level
message is actionable while the raw status/body stays available via
{:#} for logs. receive_token now surfaces the real reason (e.g. "This
ecash has already been redeemed") instead of a generic "Failed to
receive any proofs from token" when every mint in a token fails.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 09:41:44 +00:00
ssmithx 0b2c36f095 fix(bitcoin): stop writing a datadir bitcoin.conf that conflicts with -conf=/tmp/rpc.conf
Since a597c1d9 (bitcoind RPC creds off argv), bitcoin-core and bitcoin-knots
launch bitcoind with -conf=/tmp/rpc.conf and pass all other settings as CLI
args; bitcoind never reads /var/lib/archipelago/bitcoin/bitcoin.conf again.

write_bitcoin_conf, ensure_bitcoin_rpc_config, and bootstrap's
run_bitcoin_rpc_repair were never updated to match — they kept writing/
"repairing" server=/rpcbind=/rpcallowip=/listen= into that datadir file on
every install, reinstall, and service restart. Bitcoin Core's own
datadir-conflict safety check then refuses to start whenever that file
exists alongside an explicit -conf= arg, so the write and every repair
of it directly caused the crash it was trying to prevent.

Also drop the "restart already-running container after bitcoin.conf
repair" adoption-path branch: it assumed bind settings live in that file
and needs a restart to pick them up, which hasn't been true since
a597c1d9 — the running container's CLI args are already correct.

Replaces both writers with remove_stale_bitcoin_conf(), which renames
(not deletes) any leftover file so already-affected nodes self-heal on
next install/restart instead of staying permanently broken.

bitcoin_data_volume_gb is removed as dead code (it only fed the deleted
prune= line in write_bitcoin_conf, itself unused since a597c1d9 hardcoded
-prune=550 in the manifest's small-disk branch).

Investigated after a crash loop on archy-x250-beta; full incident
timeline and patch rationale in bitcoin-conf-crash-patch.md.
2026-08-01 19:28:25 +00:00
archipelagoandClaude Fable 5 c0a5635ba3 feat(fips): A3.10 — last-known-good endpoint fallback for direct peering
New fips/endpoints.rs: an npub-keyed store (<data_dir>/fips-endpoints.json)
of every endpoint a peer was last seen connected at (fipsctl show peers
transport_addr/transport_type — covers LAN, Tailscale, and WAN alike),
refreshed each anchor tick, 30-day retention.

The anchor tick now escalates LAN → last-known-good → anchor tree: any
federation peer with a fips npub that is neither currently connected nor
covered by a live LAN direct entry gets its last-known-good endpoint
re-dialed (idempotent fipsctl connect, bounded by apply()'s per-connect
cap). This productizes the hand-applied .116↔.198 Tailscale fix of
2026-07-20 and closes RC2's "no endpoint fallback" gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 07:33:32 -04:00
38 changed files with 1487 additions and 389 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## v1.7.124-alpha (2026-08-05)
- **The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update.
- **Portainer opens again.** Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1, their stored data was written by that version, and the app list pinned a version from two years earlier — so when the container was rebuilt it landed on the old one, which will not read newer data. The correct version is now pinned, older installs upgrade cleanly, and no data was touched.
- **Bitcoin starts reliably again.** A leftover settings file in the Bitcoin folder — one the node itself kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes that file, removes stale copies, and treats any that remain as harmless.
- **Every app screen opens from My Apps again.** The login gate refused to be displayed inside another page at all, which is exactly how My Apps opens an app, so protected apps appeared broken. It now allows only your own node to display it, and refuses everyone else — a distinction the old setting could not express.
- **The app login screen now looks like the node's own.** Same rotating artwork, the same panel, the Archipelago mark, and the app's real icon shown as a tile the way My Apps shows it, instead of a plain box with a letter.
- **The Mesh screen uses wide displays properly.** On very large screens it stacked all five panels on top of each other, clipping three of the headings to a sliver and squeezing the map into a letterbox — more screen producing a worse view. It now shows one panel at a time, filling the space, with the map running edge to edge.
- **You can choose how long you stay signed in.** Settings → Account now offers an inactivity timeout and a hard limit, plus an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle, because there is nobody there to sign them back in.
- Updates now come from `source.archipelago-foundation.org` rather than a bare address, with the old one kept as an automatic fallback for nodes whose clock or name lookup is off. Also included: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
## v1.7.123-alpha (2026-08-05)
- **Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself.
+1 -1
View File
@@ -442,7 +442,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
+12 -2
View File
@@ -38,6 +38,16 @@ app:
RPC_CONF="/tmp/rpc.conf";
umask 077;
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
# A stray bitcoin.conf in the datadir is FATAL when -conf points
# elsewhere: bitcoind refuses to start with "contains a bitcoin.conf
# file which is ignored", and the app crash-loops (100.82.34.38,
# 2026-08-05 — Exited(1) every few seconds). Our -conf carries the
# RPC credentials and the flags below are the authoritative config,
# so the datadir file is legacy debris; say so out loud rather than
# failing, and let bitcoind start.
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2;
fi;
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
@@ -46,9 +56,9 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
derived_env:
- key: DISK_GB
+12 -2
View File
@@ -38,6 +38,16 @@ app:
RPC_CONF="/tmp/rpc.conf";
umask 077;
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
# A stray bitcoin.conf in the datadir is FATAL when -conf points
# elsewhere: bitcoind refuses to start with "contains a bitcoin.conf
# file which is ignored", and the app crash-loops (100.82.34.38,
# 2026-08-05 — Exited(1) every few seconds). Our -conf carries the
# RPC credentials and the flags below are the authoritative config,
# so the datadir file is legacy debris; say so out loud rather than
# failing, and let bitcoind start.
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2;
fi;
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
@@ -46,9 +56,9 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
derived_env:
- key: DISK_GB
+1 -1
View File
@@ -6,7 +6,7 @@ app:
category: development
container:
image: 146.59.87.168:3000/lfg2025/portainer:2.19.4
image: 146.59.87.168:3000/lfg2025/portainer:2.39.1
pull_policy: if-not-present
data_uid: "1000:1000"
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.123-alpha"
version = "1.7.124-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.123-alpha"
version = "1.7.124-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -472,6 +472,8 @@ impl RpcHandler {
"system.disk-cleanup" => self.handle_system_disk_cleanup().await,
"system.reboot" => self.handle_system_reboot(params).await,
"system.factory-reset" => self.handle_system_factory_reset(params).await,
"auth.session-policy.get" => self.handle_session_policy_get().await,
"auth.session-policy.set" => self.handle_session_policy_set(params).await,
"system.settings.get" => self.handle_system_settings_get(params).await,
"system.settings.set" => self.handle_system_settings_set(params).await,
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
+54 -195
View File
@@ -307,19 +307,24 @@ impl RpcHandler {
let deps = self.gate_install_deps(package_id).await?;
check_bitcoin_pruning_compatibility(package_id).await?;
log_optional_dep_info(package_id, &deps);
let repaired_bitcoin_conf =
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
// Materialise the RPC password file before any install path
// runs. The orchestrator path resolves secret_env from
// /var/lib/archipelago/secrets/bitcoin-rpc-password at start
// time; if the file is missing, bitcoind exits within ms.
// bitcoin_rpc_credentials() generates + persists on first
// call (OnceCell-cached), so this is idempotent.
let _ = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
ensure_bitcoin_rpc_config().await?
} else {
false
};
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
// Materialise the RPC password file before any install path
// runs. The orchestrator path resolves secret_env from
// /var/lib/archipelago/secrets/bitcoin-rpc-password at start
// time; if the file is missing, bitcoind exits within ms.
// bitcoin_rpc_credentials() generates + persists on first
// call (OnceCell-cached), so this is idempotent.
let _ = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
// A stale datadir bitcoin.conf from an older install conflicts
// with the container's -conf=/tmp/rpc.conf launch (see
// apps/bitcoin-core & bitcoin-knots manifest.yml) and makes
// Bitcoin Core refuse to start at all. Clear it before
// (re)install. Unlike the old bind-setting "repair" this was
// replacing, it never requires restarting an already-running
// container — bitcoind doesn't read this file, so removing it
// changes nothing at runtime.
remove_stale_bitcoin_conf().await?;
}
// For orchestrator-managed apps, skip the legacy "container exists →
// adopt + return" probe entirely. The orchestrator's own install path
@@ -389,37 +394,7 @@ impl RpcHandler {
.trim()
.to_string();
if state == "running" && repaired_bitcoin_conf {
info!(
"Restarting existing container {} after bitcoin.conf RPC repair",
package_id
);
let restart_output = tokio::process::Command::new("podman")
.args(["restart", package_id])
.output()
.await
.context(
"Failed to restart existing container after bitcoin.conf repair",
)?;
if !restart_output.status.success() {
let stderr = String::from_utf8_lossy(&restart_output.stderr);
install_log(&format!(
"INSTALL ADOPT FAIL: {} - restart after RPC repair failed: {}",
package_id, stderr
))
.await;
return Err(anyhow::anyhow!(
"Container {} exists but failed to restart after RPC repair: {}",
package_id,
stderr
));
}
let _ = tokio::process::Command::new("podman")
.args(["restart", "archy-bitcoin-ui"])
.output()
.await;
wait_for_adopted_container(package_id, package_id).await?;
} else if state != "running" {
if state != "running" {
// Start the stopped/exited container
info!("Starting existing container {} (was {})", package_id, state);
let start_output = tokio::process::Command::new("podman")
@@ -715,9 +690,13 @@ impl RpcHandler {
}
}
// Pre-install: write config files BEFORE chown (dir is still owned by archipelago user)
// Pre-install: clear a stale datadir bitcoin.conf BEFORE chown (dir is
// still owned by archipelago user). bitcoind is launched with
// -conf=/tmp/rpc.conf (see apps/bitcoin-core & bitcoin-knots
// manifest.yml) and never reads a datadir bitcoin.conf — if one
// exists, Bitcoin Core's own safety check refuses to start at all.
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await?;
remove_stale_bitcoin_conf().await?;
}
if package_id == "lnd" {
@@ -1435,101 +1414,13 @@ impl RpcHandler {
}
}
/// Write bitcoin.conf with rpcauth (salted HMAC hash, no plaintext password).
async fn write_bitcoin_conf(&self, rpc_user: &str, rpc_pass: &str) -> Result<()> {
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
// Idempotent: once bitcoin-knots (or a prior install) has started,
// the data dir is chowned into the container's user namespace
// (e.g. UID 100100 on the host) with 700 perms — the archipelago
// daemon can no longer stat or write there. Treat any non-NotFound
// error on the conf as "conf already provisioned by the container
// user" and skip. Matches the lnd.conf behavior below.
match tokio::fs::metadata(&conf_path).await {
Ok(_) => {
ensure_bitcoin_rpc_config().await?;
info!("bitcoin.conf already exists, ensured Bitcoin RPC config");
return Ok(());
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => {
ensure_bitcoin_rpc_config().await?;
info!("bitcoin.conf path inaccessible, ensured Bitcoin RPC config via host helper");
return Ok(());
}
}
use hmac::{Hmac, Mac};
use sha2::Sha256;
// KEY-05: the salt is half of the stored `rpcauth=` credential line, so
// source named and draw guarded.
let mut salt_bytes = [0u8; 16];
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut salt_bytes).map_err(|e| {
anyhow::anyhow!("Refusing to build an rpcauth line from degenerate salt entropy: {e}")
})?;
let salt_hex = hex::encode(salt_bytes);
let mut mac = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes())
.expect("HMAC accepts any key length");
mac.update(rpc_pass.as_bytes());
let hash_hex = hex::encode(mac.finalize().into_bytes());
let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex);
// Default to full archive — operators with 2TB+ drives shouldn't be
// silently pruned down to 550 MB. Users who want a pruned node can
// set `prune=N` in bitcoin.conf themselves after install.
//
// printtoconsole=0: bitcoind already writes debug.log in the datadir
// (self-shrunk on restart); duplicating it to stdout pushed every IBD
// "UpdateTip" line through conmon into journald (>1 GB/day). Deep
// debugging uses /var/lib/archipelago/bitcoin/debug.log.
// rpcbind=0.0.0.0 is REQUIRED inside a container: with rpcallowip set
// but no rpcbind, bitcoind binds RPC to 127.0.0.1 in the container
// netns only — LND / the Bitcoin UI dialing bitcoin-knots:8332 over
// the bridge get connection refused (fresh-install LND crash-loop +
// bitcoin-rpc 502, seen on the 1.7.99 ISO). The port publish stays
// 127.0.0.1-only on the host, so exposure is unchanged.
// Prune sized to the data volume. A full archive needs ~810 GB and
// grows; silently writing an unpruned config onto a small disk fills
// it mid-IBD (framework node 2026-07-14: unpruned mainnet on a 205 GB
// volume). Volumes with real archival headroom (≥1.2 TB) stay full
// archive; smaller ones get prune = 25% of the volume, clamped to
// [550 MB, 100 GB], leaving room for LND/apps sharing the disk.
let prune_line = match bitcoin_data_volume_gb().await {
Some(total_gb) if total_gb > 0 && total_gb < 1200 => {
let prune_mb = ((total_gb as f64 * 0.25 * 1024.0) as u64).clamp(550, 100_000);
info!(
volume_gb = total_gb,
prune_mb, "Data volume below archival size — enabling sized bitcoin prune"
);
format!("prune={}\n", prune_mb)
}
_ => String::new(),
};
let bitcoin_conf = format!(
"\
# rpcauth: salted hash only - no plaintext password in config or CLI\n\
{}\n\
server=1\n\
rpcbind=0.0.0.0\n\
rpcallowip=0.0.0.0/0\n\
listen=1\n\
rpcthreads=16\n\
rpcworkqueue=256\n\
printtoconsole=0\n\
{}",
rpcauth_line, prune_line
);
tokio::fs::create_dir_all(bitcoin_dir)
.await
.context("Failed to create bitcoin data directory")?;
tokio::fs::write(&conf_path, bitcoin_conf)
.await
.context("Failed to write bitcoin.conf")?;
info!("Created bitcoin.conf with rpcauth (no plaintext credentials)");
Ok(())
}
// write_bitcoin_conf removed: bitcoind is launched with -conf=/tmp/rpc.conf
// (see apps/bitcoin-core & bitcoin-knots manifest.yml, commit a597c1d9)
// and never reads a datadir bitcoin.conf. Writing one here created a
// fatal "-conf vs default bitcoin.conf" conflict on every subsequent
// start (Bitcoin Core's own datadir-conflict safety check). See
// `remove_stale_bitcoin_conf` below, which replaces both this and
// `ensure_bitcoin_rpc_config`.
/// Write LND config file with Bitcoin RPC credentials.
async fn write_lnd_conf(&self, rpc_user: &str, rpc_pass: &str) -> Result<()> {
@@ -2624,28 +2515,12 @@ async fn wait_for_adopted_container(package_id: &str, container_name: &str) -> R
))
}
/// Total size (GB) of the filesystem holding the bitcoin data dir, via
/// `df -k`. None when df fails (containers, exotic mounts) — callers treat
/// unknown as "don't prune" to preserve archival defaults on big iron.
async fn bitcoin_data_volume_gb() -> Option<u64> {
let target = if std::path::Path::new("/var/lib/archipelago").exists() {
"/var/lib/archipelago"
} else {
"/"
};
let output = tokio::process::Command::new("df")
.args(["-k", target])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.lines().nth(1)?;
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
Some(kb / 1024 / 1024)
}
// bitcoin_data_volume_gb removed with write_bitcoin_conf: it only fed that
// function's volume-aware `prune=` line, which bitcoind never read either
// (see remove_stale_bitcoin_conf). The manifest's shell entrypoint already
// computes DISK_GB_VALUE and hardcodes -prune=550 on small volumes — a
// real volume-aware prune fix belongs there, not in a conf file nothing
// reads. Tracked as follow-up in bitcoin-conf-crash-patch.md.
/// One-shot probe: does bitcoind answer an authenticated getblockchaininfo?
/// Works during IBD (the call answers with progress while syncing). Goes via
@@ -2723,52 +2598,36 @@ async fn wait_for_bitcoin_rpc_gate(package_id: &str) -> Result<()> {
Ok(())
}
async fn ensure_bitcoin_rpc_config() -> Result<bool> {
/// bitcoind reads only `/tmp/rpc.conf` + CLI args at container start (see
/// apps/bitcoin-core & bitcoin-knots manifest.yml, commit a597c1d9) — it
/// never reads a datadir bitcoin.conf. A leftover file from an older install
/// (or a manual edit) makes Bitcoin Core's own datadir-conflict safety check
/// refuse to start ("-conf=... vs default bitcoin.conf"). Remove it — via
/// the same host-privileged path the old writer/repairer used, since the
/// dir may already be chowned into the container's UID namespace by a
/// previous start — instead of "repairing" it into existence.
async fn remove_stale_bitcoin_conf() -> Result<bool> {
let script = r#"
set -eu
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
[ -f "$conf" ] || exit 0
changed=0
tmp=$(mktemp)
awk -F= '
/^(server|txindex|rpcbind|rpcallowip|rpcport|listen|bind|dbcache|rpcthreads|rpcworkqueue)=/ {
if (seen[$1]++) next
}
{ print }
' "$conf" > "$tmp"
if ! cmp -s "$conf" "$tmp"; then
cat "$tmp" > "$conf"
changed=1
fi
rm -f "$tmp"
ensure_line() {
line="$1"
key="${line%%=*}"
if ! grep -q "^${key}=" "$conf"; then
printf '%s\n' "$line" >> "$conf"
changed=1
fi
}
ensure_line server=1
ensure_line rpcbind=0.0.0.0
ensure_line rpcallowip=0.0.0.0/0
ensure_line listen=1
ensure_line rpcthreads=16
ensure_line rpcworkqueue=256
[ "$changed" -eq 0 ] && exit 0
mv "$conf" "$conf.disabled-$(date +%s)"
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
.await
.context("ensure bitcoin.conf RPC bind settings")?;
.context("remove stale bitcoin.conf")?;
match status.code() {
Some(0) => Ok(false),
Some(2) => {
install_log("INSTALL REPAIR: bitcoin.conf RPC bind settings added").await;
install_log(
"INSTALL REPAIR: removed stale bitcoin.conf (conflicts with -conf=/tmp/rpc.conf launch)",
)
.await;
Ok(true)
}
_ => Err(anyhow::anyhow!(
"bitcoin.conf RPC repair helper exited with {}",
"bitcoin.conf removal helper exited with {}",
status
)),
}
@@ -1011,6 +1011,59 @@ impl RpcHandler {
}
}
/// auth.session-policy.get — how long a login lasts on this node.
pub(in crate::api::rpc) async fn handle_session_policy_get(&self) -> Result<serde_json::Value> {
let policy = crate::settings::session_policy::load(&self.config.data_dir).await;
Ok(serde_json::json!({
"idle_timeout_secs": policy.idle_timeout_secs,
"absolute_timeout_secs": policy.absolute_timeout_secs,
"reauth_for_funds": policy.reauth_for_funds,
}))
}
/// auth.session-policy.set — change it.
///
/// Values are clamped rather than rejected: the caller learns what was
/// actually stored from the reply, which is friendlier than an error and
/// makes the bounds discoverable. Fields are individually optional so the
/// UI can change one control without having to send the others back.
pub(in crate::api::rpc) async fn handle_session_policy_set(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or(serde_json::json!({}));
let current = crate::settings::session_policy::load(&self.config.data_dir).await;
let policy = crate::settings::session_policy::SessionPolicy {
idle_timeout_secs: params
.get("idle_timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(current.idle_timeout_secs),
absolute_timeout_secs: match params.get("absolute_timeout_secs") {
// Explicit null means "no absolute cap", which is different
// from the field being absent (leave it as it is).
Some(serde_json::Value::Null) => None,
Some(v) => v.as_u64().or(current.absolute_timeout_secs),
None => current.absolute_timeout_secs,
},
reauth_for_funds: params
.get("reauth_for_funds")
.and_then(|v| v.as_bool())
.unwrap_or(current.reauth_for_funds),
};
let saved = crate::settings::session_policy::save(&self.config.data_dir, policy).await?;
tracing::info!(
idle = saved.idle_timeout_secs,
absolute = ?saved.absolute_timeout_secs,
reauth_for_funds = saved.reauth_for_funds,
"session policy updated"
);
Ok(serde_json::json!({
"idle_timeout_secs": saved.idle_timeout_secs,
"absolute_timeout_secs": saved.absolute_timeout_secs,
"reauth_for_funds": saved.reauth_for_funds,
}))
}
/// system.settings.set — Write a settings value
pub(in crate::api::rpc) async fn handle_system_settings_set(
&self,
+298 -49
View File
@@ -154,6 +154,11 @@ impl AppGate {
action: &str,
client_ip: IpAddr,
) -> Response<Body> {
// Assets are GET and pre-auth by nature: the login page cannot
// render its own background or logo without them.
if let Some(name) = action.strip_prefix("asset/") {
return self.serve_asset(name);
}
if req.method() != Method::POST {
return login_page(app, None, StatusCode::OK);
}
@@ -187,6 +192,26 @@ impl AppGate {
}
}
/// Static assets the login page needs, served from the gate's own origin.
///
/// The backgrounds are ~1 MB each, so inlining them as data URIs would
/// bloat every challenge response. Serving them here keeps the page
/// byte-identical to the dashboard's login while the CSP stays tight:
/// `img-src 'self' data:` and nothing else.
fn serve_asset(&self, name: &str) -> Response<Body> {
let Some((bytes, mime)) = read_ui_asset(name) else {
return not_found();
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
// Immutable art; caching it costs nothing and keeps the login
// instant on a repeat challenge.
.header(header::CACHE_CONTROL, "public, max-age=86400")
.body(Body::from(bytes))
.expect("asset response builds")
}
async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response<Body> {
let password = field(form, "password").unwrap_or_default();
@@ -428,43 +453,158 @@ fn esc(s: &str) -> String {
/// none. Inlined as a data URI rather than linked: the gate is answering on
/// the app's own port, so any asset URL would either hit the unauthenticated
/// app behind it or a different origin the browser may not reach.
/// One stacked layer per background, each delayed so they cross-fade in turn.
fn background_layers() -> String {
let step = LOGIN_BACKGROUNDS.len() as u32 * 9 / LOGIN_BACKGROUNDS.len() as u32;
LOGIN_BACKGROUNDS
.iter()
.enumerate()
.map(|(i, name)| {
format!(
r#"<div class="bg" style="background-image:url('{prefix}asset/{name}');animation-delay:{delay}s"></div>"#,
prefix = GATE_PREFIX,
delay = i as u32 * step,
)
})
.collect()
}
fn icon_markup(app: &GatedPort) -> String {
if let Some(path) = &app.icon {
if let Some(data_uri) = read_icon_data_uri(path) {
return format!(r#"<img class="icon" src="{}" alt="">"#, esc(&data_uri));
}
}
let letter = app
.app_name
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string());
format!(r#"<div class="icon lettermark">{}</div>"#, esc(&letter))
let inner = app
.icon
.as_deref()
.and_then(read_icon_data_uri)
// A manifest that names no icon still gets one: the dashboard already
// ships icons named after the app, so fall back to those before
// giving up. Without this EVERY gated app showed a lettermark,
// because no manifest declares metadata.icon (archi-dev-box,
// 2026-08-05).
.or_else(|| {
icon_candidates(&app.app_id)
.iter()
.find_map(|c| read_icon_data_uri(c))
})
.map(|data_uri| format!(r#"<img class="icon" src="{}" alt="">"#, esc(&data_uri)))
.unwrap_or_else(|| {
let letter = app
.app_name
.chars()
.find(|c| c.is_alphanumeric())
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string());
format!(r#"<div class="icon lettermark">{}</div>"#, esc(&letter))
});
format!(r#"<div class="tile">{inner}</div>"#)
}
/// Icons live with the web UI. Only files under the icon directory are read,
/// and only known image extensions — the path comes from a manifest, which is
/// signed, but treating it as untrusted costs nothing.
fn read_icon_data_uri(icon_path: &str) -> Option<String> {
let name = std::path::Path::new(icon_path).file_name()?.to_str()?;
let mime = match name.rsplit_once('.')?.1.to_ascii_lowercase().as_str() {
"svg" => "image/svg+xml",
"png" => "image/png",
"webp" => "image/webp",
"jpg" | "jpeg" => "image/jpeg",
_ => return None,
/// Icon basenames to try for an app id, best first.
///
/// The shipped icon set is named for the *product*, while app ids carry
/// packaging detail — `filebrowser` vs `file-browser`, `morphos-server` vs
/// `morphos` — and the per-app screens (`lnd-ui`, `bitcoin-ui`, `electrs-ui`)
/// have no icon of their own but obviously belong to the app they front.
/// Resolving those here keeps the mapping in one readable place instead of
/// adding a `metadata.icon` line to every manifest, which would have to be
/// re-signed into the catalog to take effect.
fn icon_candidates(app_id: &str) -> Vec<String> {
let mut out = vec![app_id.to_string()];
let alias = match app_id {
"filebrowser" => Some("file-browser"),
"home-assistant" => Some("homeassistant"),
"morphos-server" => Some("morphos"),
"barkd" => Some("bark"),
"archy-mempool-web" | "mempool-api" => Some("mempool"),
"lnd-ui" | "lightning-stack" => Some("lnd"),
"bitcoin-ui" => Some("bitcoin-core"),
"electrs-ui" => Some("electrumx"),
"fips-ui" | "aiui" | "did-wallet" => Some("archipelago-a"),
"fedimint-gateway" | "fedimint-clientd" => Some("fedimint"),
_ => None,
};
out.extend(alias.map(str::to_string));
// `<app>-ui` / `-server` / `-web` front an app whose icon is the bare name.
for suffix in ["-ui", "-server", "-web"] {
if let Some(base) = app_id.strip_suffix(suffix) {
out.push(base.to_string());
}
}
out
}
/// Backgrounds the login cycles through, matching the dashboard's own
/// `/login` art. Cross-faded by CSS alone — the CSP forbids script, and a
/// rotation that needs JavaScript would not survive it.
const LOGIN_BACKGROUNDS: [&str; 4] = [
"bg-intro.jpg",
"bg-intro-4.webp",
"bg-intro-6.webp",
"bg-intro-3.jpg",
];
/// Assets the gate will serve, by exact name. An allowlist rather than a path
/// join: the name arrives in a URL, and the gate answers before any
/// authentication, so nothing here may be caller-controlled beyond this set.
fn read_ui_asset(name: &str) -> Option<(Vec<u8>, &'static str)> {
let allowed = LOGIN_BACKGROUNDS.contains(&name) || name == "logo-archipelago.svg";
if !allowed {
return None;
}
let mime = icon_mime(name.rsplit_once('.')?.1)?;
for root in [
"/opt/archipelago/web-ui/assets/img/app-icons",
"web/dist/neode-ui/assets/img/app-icons",
"/opt/archipelago/web-ui/assets/img",
"web/dist/neode-ui/assets/img",
"neode-ui/public/assets/img",
] {
let candidate = std::path::Path::new(root).join(name);
if let Ok(bytes) = std::fs::read(&candidate) {
if bytes.len() > 512 * 1024 {
return None;
if let Ok(bytes) = std::fs::read(std::path::Path::new(root).join(name)) {
return Some((bytes, mime));
}
}
None
}
const ICON_ROOTS: [&str; 2] = [
"/opt/archipelago/web-ui/assets/img/app-icons",
"web/dist/neode-ui/assets/img/app-icons",
];
fn icon_mime(ext: &str) -> Option<&'static str> {
match ext.to_ascii_lowercase().as_str() {
"svg" => Some("image/svg+xml"),
"png" => Some("image/png"),
"webp" => Some("image/webp"),
"jpg" | "jpeg" => Some("image/jpeg"),
_ => None,
}
}
/// Read an app icon as a `data:` URI.
///
/// `icon_ref` may be a filename or path with an extension (a manifest's
/// `metadata.icon`), or a bare name such as an app id — in which case the
/// known extensions are tried in turn. Only the file name is used; the
/// directories searched are fixed, so a manifest cannot point the gate at an
/// arbitrary path.
fn read_icon_data_uri(icon_ref: &str) -> Option<String> {
let name = std::path::Path::new(icon_ref).file_name()?.to_str()?;
let candidates: Vec<(String, &str)> = match name.rsplit_once('.') {
Some((_, ext)) => vec![(name.to_string(), icon_mime(ext)?)],
None => ["svg", "png", "webp", "jpg"]
.iter()
.filter_map(|ext| Some((format!("{name}.{ext}"), icon_mime(ext)?)))
.collect(),
};
for (file, mime) in candidates {
for root in ICON_ROOTS {
let candidate = std::path::Path::new(root).join(&file);
if let Ok(bytes) = std::fs::read(&candidate) {
if bytes.len() > 512 * 1024 {
continue;
}
return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
}
return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
}
}
None
@@ -484,29 +624,79 @@ fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Respons
<meta name="robots" content="noindex">
<title>{title} {app_name}</title>
<style>
/* The dashboard's own /login, rebuilt in static CSS: the same rotating
intro art, .glass-card panel, .glass-button action and transparent
white-bordered inputs from neode-ui/src/style.css. Written longhand
rather than shared with the SPA because the gate answers before any
bundle exists, and the CSP forbids external stylesheets and script. */
:root {{ color-scheme: dark; }}
* {{ box-sizing: border-box; }}
body {{ margin:0; min-height:100vh; display:grid; place-items:center;
background:#0b0f14; color:#e6edf3; font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif; }}
.card {{ width:min(92vw,380px); padding:2rem; background:#121820;
border:1px solid #223; border-radius:14px; text-align:center; }}
.icon {{ width:64px; height:64px; border-radius:14px; margin:0 auto 1rem; display:block; object-fit:cover; }}
.lettermark {{ display:grid; place-items:center; background:#1d2733; font-size:28px; font-weight:600; }}
h1 {{ font-size:1.15rem; margin:0 0 .25rem; }}
p.sub {{ margin:0 0 1.5rem; color:#8b98a5; font-size:.9rem; }}
input {{ width:100%; padding:.7rem .8rem; margin-bottom:.75rem; border-radius:9px;
border:1px solid #2b3947; background:#0d131a; color:#e6edf3; font-size:1rem; }}
input:focus {{ outline:2px solid #3b82f6; outline-offset:1px; }}
button {{ width:100%; padding:.7rem; border:0; border-radius:9px; background:#3b82f6;
color:#fff; font-size:1rem; font-weight:600; cursor:pointer; }}
button:hover {{ background:#2f6fd6; }}
.err {{ background:#3b1519; border:1px solid #7f1d1d; color:#fca5a5;
padding:.6rem .8rem; border-radius:9px; margin-bottom:1rem; font-size:.9rem; }}
body {{ margin:0; min-height:100vh; display:grid; place-items:center; padding:1rem;
background:#05070a; color:#fff; overflow:hidden;
font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif; }}
/* Rotating backgrounds: each layer holds its image and cross-fades on a
shared cycle, so the art moves the way /login does with no script. */
.bg {{ position:fixed; inset:0; z-index:0; background-size:cover;
background-position:center; opacity:0; animation:bg-cycle {cycle}s infinite; }}
.bg::after {{ content:''; position:absolute; inset:0;
background:linear-gradient(180deg, rgba(0,0,0,.35), rgba(0,0,0,.72)); }}
@keyframes bg-cycle {{
0% {{ opacity:0; }} 4% {{ opacity:1; }}
{hold}% {{ opacity:1; }} {fade}% {{ opacity:0; }} 100% {{ opacity:0; }}
}}
main {{ position:relative; z-index:1; width:min(92vw,28rem); }}
.card {{ padding:2rem; padding-top:3.5rem; position:relative;
background:rgba(0,0,0,.65); backdrop-filter:blur(18px);
-webkit-backdrop-filter:blur(18px); border:1px solid rgba(255,255,255,.18);
border-radius:1rem; box-shadow:0 8px 24px rgba(0,0,0,.45); text-align:center; }}
/* The Archipelago mark, half in and half out of the panel — same placement
and gradient ring as Login.vue. */
.logo {{ position:absolute; top:-2.5rem; left:50%; transform:translateX(-50%);
width:5rem; height:5rem; border-radius:9999px; padding:3px;
background:linear-gradient(135deg, rgba(255,255,255,.6) 0%, rgba(0,0,0,.8) 100%);
box-shadow:0 8px 24px rgba(0,0,0,.5); }}
.logo img {{ width:100%; height:100%; border-radius:9999px; display:block;
background:#000; padding:.5rem; }}
/* The app's own tile, in the My Apps shape: 18px-rounded square on dark
glass with the same inner highlight and drop shadow. */
.tile {{ width:60px; height:60px; border-radius:18px; margin:0 auto .75rem;
background:rgba(0,0,0,.72); box-shadow:0 8px 18px rgba(0,0,0,.38); }}
.tile .icon {{ width:100%; height:100%; border-radius:18px; display:block;
object-fit:cover; border:1px solid rgba(255,255,255,.18);
background:radial-gradient(circle at 35% 28%, rgba(255,255,255,.1), rgba(255,255,255,0) 42%),
linear-gradient(145deg, rgba(22,22,24,.96), rgba(0,0,0,.96));
box-shadow:inset 0 1px 0 rgba(255,255,255,.12), inset 0 -10px 24px rgba(0,0,0,.34); }}
.lettermark {{ display:grid; place-items:center; font-size:1.6rem; font-weight:600;
color:rgba(255,255,255,.9); }}
h1 {{ font-size:1.5rem; font-weight:600; margin:0 0 .4rem;
color:rgba(255,255,255,.96); text-shadow:0 2px 6px rgba(0,0,0,.4); }}
p.sub {{ margin:0 0 1.75rem; color:rgba(255,255,255,.6); font-size:.875rem; }}
input {{ width:100%; padding:.75rem 1rem; margin-bottom:1rem; border-radius:.5rem;
border:1px solid rgba(255,255,255,.2); background:transparent; color:#fff;
font-size:1rem; transition:border-color .2s ease; }}
input::placeholder {{ color:rgba(255,255,255,.4); }}
input:focus {{ outline:none; border-color:rgba(255,255,255,.4);
box-shadow:0 0 0 1px rgba(255,255,255,.2); }}
button {{ width:100%; min-height:44px; padding:.75rem 1.25rem; border:none;
border-radius:.75rem; background:rgba(0,0,0,.6);
backdrop-filter:blur(24px); -webkit-backdrop-filter:blur(24px);
box-shadow:0 8px 24px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.22);
color:rgba(255,255,255,.9); font-size:1rem; font-weight:500; cursor:pointer;
transition:background-color .2s ease, transform .3s cubic-bezier(.4,0,.2,1); }}
button:hover {{ background:rgba(0,0,0,.7); }}
button:active {{ transform:translateY(1px); }}
.err {{ background:rgba(239,68,68,.2); border:1px solid rgba(239,68,68,.4);
color:#fecaca; padding:.75rem; border-radius:.5rem; margin-bottom:1rem;
font-size:.875rem; text-align:left; }}
</style></head>
<body><main class="card">{body}</main></body></html>"#,
<body>{backgrounds}<main><div class="card">{body}</div></main></body></html>"#,
title = esc(title),
app_name = esc(&app.app_name),
body = body,
backgrounds = background_layers(),
cycle = LOGIN_BACKGROUNDS.len() as u32 * 9,
hold = 100 / LOGIN_BACKGROUNDS.len() as u32,
fade = 100 / LOGIN_BACKGROUNDS.len() as u32 + 4,
);
Response::builder()
.status(status)
@@ -514,10 +704,18 @@ button:hover {{ background:#2f6fd6; }}
// The gate answers on the app's own port for an unauthenticated
// caller; nothing here should be cached or framed.
.header(header::CACHE_CONTROL, "no-store")
.header("X-Frame-Options", "DENY")
// NOT X-Frame-Options: DENY. My Apps opens an app in an embedded
// frame, so a blanket DENY made every gated app render as "app is
// not responding" the moment the gate challenged it (reported on
// 100.82.34.38, 2026-08-05). frame-ancestors is the modern control
// and can be precise: only pages from this same node may frame the
// login, on any port or scheme, which is exactly the dashboard.
// Anything else — another site embedding it to harvest the node
// password — is still refused.
.header(
"Content-Security-Policy",
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'self'",
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
form-action 'self'; frame-ancestors 'self' http://*:* https://*:*",
)
.body(Body::from(html))
.expect("static response builds")
@@ -528,7 +726,8 @@ button:hover {{ background:#2f6fd6; }}
/// password by an unexplained page.
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
let body = format!(
r#"{icon}
r#"<div class="logo"><img src="{prefix}asset/logo-archipelago.svg" alt="Archipelago"></div>
{icon}
<h1>Sign in to open {name}</h1>
<p class="sub">This app is protected by your node password.</p>
{err}
@@ -636,11 +835,61 @@ mod tests {
assert!(!html.contains("<img src=x"));
}
/// The challenge must be framable by this node's own dashboard — My Apps
/// opens apps in an embedded frame, and a blanket `X-Frame-Options: DENY`
/// turned every gated app into "app is not responding" (100.82.34.38,
/// 2026-08-05). It must still be uncacheable, and still refuse to be
/// framed by a foreign origin, which `frame-ancestors` expresses and
/// `X-Frame-Options` cannot.
#[test]
fn challenge_pages_are_not_cacheable_or_framable() {
fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
assert_eq!(resp.headers()["X-Frame-Options"], "DENY");
assert!(
!resp.headers().contains_key("X-Frame-Options"),
"X-Frame-Options cannot express 'my own node on another port' — it \
blocked the dashboard's own frame"
);
let csp = resp.headers()["Content-Security-Policy"].to_str().unwrap();
assert!(csp.contains("frame-ancestors 'self'"));
assert!(csp.contains("form-action 'self'"));
}
/// The login page must render entirely from the gate's own origin: the
/// CSP allows no external host, so a background or logo that 404s leaves
/// a black page rather than the dashboard's art.
#[tokio::test]
async fn login_page_sources_its_art_from_the_gate() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body).to_string();
assert!(html.contains(&format!("{GATE_PREFIX}asset/logo-archipelago.svg")));
for name in LOGIN_BACKGROUNDS {
assert!(
html.contains(&format!("{GATE_PREFIX}asset/{name}")),
"background {name} is not referenced"
);
}
// Every referenced asset must be one the gate will actually serve.
assert!(read_ui_asset("logo-archipelago.svg").is_some() || cfg!(not(debug_assertions)));
}
/// The allowlist is the whole security boundary for asset serving: the
/// name arrives in a URL and is read before any authentication.
#[test]
fn asset_serving_refuses_anything_off_the_allowlist() {
for name in [
"../../../etc/passwd",
"/etc/passwd",
"db.sqlite3",
"manifest.yml",
"",
] {
assert!(
read_ui_asset(name).is_none(),
"{name} must not be servable by the gate"
);
}
}
#[tokio::test]
+66 -37
View File
@@ -154,9 +154,9 @@ pub async fn ensure_doctor_installed() {
}
match run_bitcoin_rpc_repair().await {
Ok(true) => {
info!("Repaired Bitcoin RPC bind settings; running Bitcoin containers left untouched")
info!("Removed stale bitcoin.conf; running Bitcoin containers left untouched")
}
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
Ok(false) => debug!("No stale bitcoin.conf found"),
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
}
match run_apps_dir_repair().await {
@@ -621,52 +621,30 @@ exit 2
}
async fn run_bitcoin_rpc_repair() -> Result<bool> {
// Older installs can have a container-owned bitcoin.conf with only rpcauth
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
// without a manual uninstall/reinstall. Bind/port stay in the container
// command line to avoid duplicate RPC endpoint definitions.
// bitcoind is launched with -conf=/tmp/rpc.conf and never reads a
// datadir bitcoin.conf (apps/bitcoin-core & bitcoin-knots manifest.yml,
// commit a597c1d9 — bind/port live only on the container command line).
// A leftover file from an older install makes Bitcoin Core's own
// datadir-conflict safety check refuse to start on every subsequent
// start. Remove it instead of "repairing" it into existence — this
// previously wrote server=/rpcbind=/rpcallowip=/listen= into the file,
// which is exactly what caused the conflict.
let script = r#"
set -eu
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
[ -f "$conf" ] || exit 0
changed=0
ensure_line() {
line="$1"
key="${line%%=*}"
if ! grep -q "^${key}=" "$conf"; then
printf '%s\n' "$line" >> "$conf"
changed=1
fi
}
ensure_line server=1
# rpcbind=0.0.0.0 is required inside the container: with rpcallowip set but
# no rpcbind, bitcoind binds RPC to the container's loopback only and every
# dial over the container network (LND, bitcoin-ui) is refused the fresh-
# install "LND took 5 attempts" / bitcoin-rpc 502 failure (host publish stays
# 127.0.0.1-only, so exposure is unchanged).
ensure_line rpcbind=0.0.0.0
ensure_line rpcallowip=0.0.0.0/0
ensure_line listen=1
# Log-volume fix: printtoconsole=1 duplicated every log line (incl. per-block
# IBD "UpdateTip" spam) into journald via conmon on top of the datadir
# debug.log bitcoind already writes. Console off; debug.log stays (bitcoind
# self-shrinks it on restart).
if grep -q '^printtoconsole=1' "$conf"; then
sed -i 's/^printtoconsole=1$/printtoconsole=0/' "$conf"
changed=1
fi
[ "$changed" -eq 0 ] && exit 0
mv "$conf" "$conf.disabled-$(date +%s)"
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
.await
.context("repair bitcoin.conf RPC bind settings")?;
.context("remove stale bitcoin.conf RPC bind settings")?;
match status.code() {
Some(0) => Ok(false),
// Do not restart Bitcoin from bootstrap. During IBD, an automatic
// restart can cost hours of progress. The repaired file is only a
// fallback for future starts; current containers keep their command-line
// RPC args until an operator or update intentionally restarts them.
// restart can cost hours of progress. Removing the stale file is
// only a fallback for future starts; current containers keep their
// command-line RPC args regardless.
Some(2) => Ok(true),
_ => {
warn!("Bitcoin RPC repair helper exited with {}", status);
@@ -1293,3 +1271,54 @@ mod tests {
assert_ne!(outcome, PodmanHealOutcome::Healthy);
}
}
/// Repair this node's own systemd restart policy.
///
/// The in-process updater replaces the binary and then asks systemd to
/// restart the service, treating `Restart=always` on the unit as its second
/// net if that request is ever lost. On austin-sapien (2026-08-05) the unit
/// was an old one carrying `Restart=on-failure`: the daemon exited cleanly
/// (status 0), systemd read that as success, and the node sat dead for over
/// two hours after a routine update — "server starting" in the UI, with
/// nothing to start it.
///
/// A node cannot be relied on to fix this via `self-update.sh` (which does
/// refresh units) because the in-process update path never runs it. So the
/// daemon checks its own unit at boot: any node that starts even once ends
/// up with a policy that survives the next update. Deliberately narrow —
/// only the `Restart=` line is touched, so local edits elsewhere in the unit
/// are preserved.
pub async fn ensure_restart_policy() {
const UNIT: &str = "/etc/systemd/system/archipelago.service";
let Ok(body) = fs::read_to_string(UNIT).await else {
return; // not a systemd install (container, dev box) — nothing to do
};
if !body.lines().any(|l| {
let l = l.trim();
l.starts_with("Restart=") && l != "Restart=always"
}) {
return; // already correct, or no Restart= line to repair
}
let patched: String = body
.lines()
.map(|l| {
if l.trim().starts_with("Restart=") && l.trim() != "Restart=always" {
"Restart=always"
} else {
l
}
})
.collect::<Vec<_>>()
.join("\n");
match write_root_if_needed(UNIT, &patched).await {
Ok(true) => {
tracing::warn!(
"repaired archipelago.service Restart= policy to always — this node would \
have stayed dead after an in-process update"
);
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
}
Ok(false) => {}
Err(e) => tracing::warn!(error = %e, "could not repair archipelago.service restart policy"),
}
}
@@ -214,10 +214,59 @@ pub async fn install_one(spec: &CompanionSpec) -> Result<()> {
}
// Start is idempotent — if already running, systemctl returns 0.
quadlet::enable_now(&unit.service_name()).await?;
// A rebuilt image does NOT reach a container that is already running.
// `ensure_image_present` rebuilds in place under the same tag, so the unit
// body is byte-identical, `write_if_changed` reports no change, and
// `enable_now` is a no-op on a running service — the container keeps the
// old layers indefinitely. That is exactly how archi-dev-box kept serving
// the LND, FIPS, Electrs and Guardian screens on 0.0.0.0 after v1.7.123
// rebuilt every one of those images to bind loopback: the images were
// correct on disk and the running containers were three days old
// (2026-08-05). Compare image IDs and restart when they diverge.
if let Some(running) = container_image_id(spec.name).await {
if let Some(built) = image_id(&image).await {
if running != built {
info!(
companion = spec.name,
"running container uses a stale image; restarting onto the rebuilt one"
);
quadlet::restart_service(&unit.service_name()).await?;
}
}
}
info!(companion = spec.name, "companion started");
Ok(())
}
/// Image ID a container is actually running, or `None` when it does not exist.
async fn container_image_id(name: &str) -> Option<String> {
let out = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.Image}}"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!id.is_empty()).then_some(id)
}
/// Current ID behind an image reference, or `None` when absent.
async fn image_id(image_ref: &str) -> Option<String> {
let out = tokio::process::Command::new("podman")
.args(["image", "inspect", image_ref, "--format", "{{.Id}}"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!id.is_empty()).then_some(id)
}
/// Build companion image locally if a Dockerfile exists, otherwise
/// pull from the lfg2025 registry. Returns the image ref the quadlet
/// should reference (`localhost/<base>:latest` for build, registry
+2 -2
View File
@@ -7,6 +7,6 @@
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8888, 8999, 9000, 9100, 10380, 11434, 18081,
18083, 23000, 32838, 50002,
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
18081, 18083, 23000, 32838, 50002,
];
+205
View File
@@ -0,0 +1,205 @@
//! Last-known-good FIPS peer endpoints (A3.10).
//!
//! The LAN direct-peering tick (`anchors::lan_fips_anchors`) only helps peers
//! we can currently see on the LAN. When a federation peer's LAN path is gone
//! (renumbered network, remote site, mDNS blackout) the only route left is the
//! anchor spanning tree — the exact hairpin RC2 calls out. But if we were EVER
//! connected to that peer directly, the daemon knew a working endpoint for it
//! (`fipsctl show peers` → `transport_addr`/`transport_type`, which covers
//! LAN, Tailscale, and WAN endpoints alike). This module persists those
//! npub-keyed endpoints and re-offers them as dial candidates when the live
//! paths disappear: LAN → last-known-good → anchor tree.
//!
//! Persisted at `<data_dir>/fips-endpoints.json`. Entries are refreshed every
//! time the peer is seen connected and dropped after `RETENTION` without a
//! sighting, so a peer that genuinely moved doesn't get dialed at a stale
//! address forever ( `fipsctl connect` to a dead address is harmless but not
//! free).
use std::collections::HashMap;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tokio::fs;
use super::anchors::SeedAnchor;
const FILE_NAME: &str = "fips-endpoints.json";
/// Forget endpoints not seen connected for this long (seconds) — 30 days.
const RETENTION_SECS: u64 = 30 * 24 * 60 * 60;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct KnownEndpoint {
/// "ip:port" as reported by the daemon (`transport_addr`).
pub address: String,
/// "udp" | "tcp" (`transport_type`).
pub transport: String,
/// Unix seconds of the last time this peer was seen connected here.
pub last_ok_unix: u64,
}
/// A currently-connected peer as parsed from `fipsctl show peers`.
#[derive(Debug, Clone)]
pub struct ConnectedPeer {
pub npub: String,
pub address: String,
pub transport: String,
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub async fn load(data_dir: &Path) -> HashMap<String, KnownEndpoint> {
let path = data_dir.join(FILE_NAME);
match fs::read(&path).await {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => HashMap::new(),
}
}
async fn save(data_dir: &Path, map: &HashMap<String, KnownEndpoint>) -> Result<()> {
let path = data_dir.join(FILE_NAME);
let tmp = data_dir.join(format!("{FILE_NAME}.tmp"));
fs::write(&tmp, serde_json::to_vec_pretty(map)?).await?;
fs::rename(&tmp, &path).await?;
Ok(())
}
/// Merge the currently-connected peers into the store (refreshing their
/// timestamps), prune expired entries, persist, and return the updated map.
/// Persistence failures are non-fatal — the in-memory result is still
/// returned so this tick's fallback logic works.
pub async fn record_connected(
data_dir: &Path,
connected: &[ConnectedPeer],
) -> HashMap<String, KnownEndpoint> {
let mut map = load(data_dir).await;
let now = now_unix();
let before = map.clone();
for p in connected {
if p.npub.is_empty() || p.address.is_empty() {
continue;
}
map.insert(
p.npub.clone(),
KnownEndpoint {
address: p.address.clone(),
transport: p.transport.clone(),
last_ok_unix: now,
},
);
}
map.retain(|_, e| now.saturating_sub(e.last_ok_unix) <= RETENTION_SECS);
if map != before {
if let Err(e) = save(data_dir, &map).await {
tracing::debug!("fips endpoint store save failed (non-fatal): {e}");
}
}
map
}
/// Build fallback anchors for federation peers whose live paths are gone:
/// every `wanted_npub` that is neither currently connected nor covered by a
/// live LAN direct entry, but has a last-known-good endpoint, becomes a dial
/// candidate. `fipsctl connect` is idempotent and failure-tolerant, so a
/// stale candidate costs one failed dial, bounded by apply()'s per-connect
/// timeout.
pub fn fallback_anchors(
known: &HashMap<String, KnownEndpoint>,
wanted_npubs: &[String],
connected_npubs: &[String],
lan_direct: &[SeedAnchor],
) -> Vec<SeedAnchor> {
let mut out = Vec::new();
for npub in wanted_npubs {
if connected_npubs.iter().any(|c| c == npub) {
continue;
}
if lan_direct.iter().any(|a| &a.npub == npub) {
continue;
}
if let Some(e) = known.get(npub) {
out.push(SeedAnchor {
npub: npub.clone(),
address: e.address.clone(),
transport: e.transport.clone(),
label: "last-known-good endpoint (direct FIPS)".to_string(),
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn ep(addr: &str) -> KnownEndpoint {
KnownEndpoint {
address: addr.to_string(),
transport: "udp".to_string(),
last_ok_unix: now_unix(),
}
}
#[tokio::test]
async fn record_and_reload_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let connected = vec![ConnectedPeer {
npub: "npub1aaa".into(),
address: "100.114.134.21:2121".into(),
transport: "udp".into(),
}];
let map = record_connected(dir.path(), &connected).await;
assert_eq!(map["npub1aaa"].address, "100.114.134.21:2121");
let reloaded = load(dir.path()).await;
assert_eq!(reloaded, map);
}
#[tokio::test]
async fn expired_entries_are_pruned_on_record() {
let dir = tempfile::tempdir().unwrap();
let mut stale = HashMap::new();
stale.insert(
"npub1old".to_string(),
KnownEndpoint {
address: "10.0.0.1:2121".into(),
transport: "udp".into(),
last_ok_unix: now_unix() - RETENTION_SECS - 60,
},
);
save(dir.path(), &stale).await.unwrap();
let map = record_connected(dir.path(), &[]).await;
assert!(map.is_empty());
}
#[test]
fn fallback_skips_connected_and_lan_covered_peers() {
let mut known = HashMap::new();
known.insert("npub1gone".to_string(), ep("100.1.2.3:2121"));
known.insert("npub1conn".to_string(), ep("100.1.2.4:2121"));
known.insert("npub1lan".to_string(), ep("100.1.2.5:2121"));
let wanted: Vec<String> = ["npub1gone", "npub1conn", "npub1lan", "npub1never"]
.iter()
.map(|s| s.to_string())
.collect();
let connected = vec!["npub1conn".to_string()];
let lan = vec![SeedAnchor {
npub: "npub1lan".into(),
address: "192.168.63.198:2121".into(),
transport: "udp".into(),
label: "LAN".into(),
}];
let out = fallback_anchors(&known, &wanted, &connected, &lan);
assert_eq!(out.len(), 1);
assert_eq!(out[0].npub, "npub1gone");
assert_eq!(out[0].address, "100.1.2.3:2121");
// npub1never has no stored endpoint → nothing to dial.
}
}
+1
View File
@@ -29,6 +29,7 @@ pub mod anchors;
pub mod app_ports;
pub mod config;
pub mod dial;
pub mod endpoints;
pub mod iface;
pub mod service;
pub mod telemetry;
+46
View File
@@ -227,6 +227,52 @@ pub async fn peer_connectivity_summary(anchor_candidates: &[String]) -> (u32, bo
(authenticated_peer_count, anchor_connected)
}
/// Currently-connected peers with their live endpoints, from
/// `fipsctl show peers` (`transport_addr`/`transport_type`). Feeds the
/// last-known-good endpoint store (A3.10); empty on any failure.
pub async fn connected_peer_endpoints() -> Vec<crate::fips::endpoints::ConnectedPeer> {
let peers_json = match Command::new("sudo")
.args(["-n", "fipsctl", "show", "peers"])
.output()
.await
{
Ok(o) if o.status.success() => o.stdout,
_ => return Vec::new(),
};
let parsed: serde_json::Value = match serde_json::from_slice(&peers_json) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
parsed
.get("peers")
.and_then(|p| p.as_array())
.map(|peers| {
peers
.iter()
.filter(|p| {
p.get("connectivity")
.and_then(|c| c.as_str())
.map(|s| s == "connected")
.unwrap_or(false)
})
.filter_map(|p| {
let npub = p.get("npub").and_then(|n| n.as_str())?;
let address = p.get("transport_addr").and_then(|a| a.as_str())?;
let transport = p
.get("transport_type")
.and_then(|t| t.as_str())
.unwrap_or("udp");
Some(crate::fips::endpoints::ConnectedPeer {
npub: npub.to_string(),
address: address.to_string(),
transport: transport.to_string(),
})
})
.collect()
})
.unwrap_or_default()
}
/// Read the upstream daemon's public key at `/etc/fips/fips.pub` and return
/// it as a bech32 npub. Returns `Ok(None)` if the file doesn't exist — used
/// as a fallback on legacy/dev nodes where no seed-derived key exists.
+5
View File
@@ -409,6 +409,11 @@ async fn main() -> Result<()> {
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
tokio::spawn(bootstrap::ensure_kiosk_hardened());
// Repair our own restart policy before anything else can need it: a node
// whose unit still says Restart=on-failure stays dead after the next
// in-process update, because the daemon exits cleanly to be restarted.
tokio::spawn(bootstrap::ensure_restart_policy());
// HDMI audio: install the PipeWire stack + audio-router daemon on kiosk
// nodes (older ISOs shipped no audio stack; the router also heals the
// boot-time ELD race that leaves HDMI silently unavailable).
+33
View File
@@ -847,6 +847,39 @@ impl Server {
if !direct.is_empty() {
let _ = crate::fips::anchors::apply(&direct).await;
}
// A3.10 — endpoint fallback for direct peering. Record
// where currently-connected peers actually are (their
// transport_addr covers LAN, Tailscale, and WAN alike),
// then re-dial the last-known-good endpoint of every
// federation peer whose live paths are gone: not
// connected now, no LAN direct entry this tick. Escala-
// tion order is LAN → last-known-good → anchor tree;
// a stale candidate costs one bounded failed dial.
let connected = crate::fips::service::connected_peer_endpoints().await;
let known =
crate::fips::endpoints::record_connected(&data_dir, &connected).await;
let wanted: Vec<String> = reg
.all_peers()
.await
.iter()
.filter_map(|p| p.fips_npub.clone())
.collect();
let connected_npubs: Vec<String> =
connected.iter().map(|c| c.npub.clone()).collect();
let fallback = crate::fips::endpoints::fallback_anchors(
&known,
&wanted,
&connected_npubs,
&direct,
);
if !fallback.is_empty() {
tracing::info!(
count = fallback.len(),
"dialing last-known-good endpoints for disconnected federation peers"
);
let _ = crate::fips::anchors::apply(&fallback).await;
}
}
let next = if daemon_restarting && fast_retries < MAX_FAST_RETRIES {
+39 -4
View File
@@ -40,12 +40,19 @@ struct Session {
created_at: SystemTime,
last_activity: SystemTime,
session_type: SessionType,
/// What kind of screen this login came from. A TV on the wall must not
/// be signed out for sitting still — nobody is there to type a password
/// back in — while a browser must be.
device_class: crate::settings::session_policy::DeviceClass,
}
#[derive(Clone)]
pub struct SessionStore {
sessions: Arc<RwLock<HashMap<[u8; 32], Session>>>,
persist_path: PathBuf,
/// Where the session policy lives. Held rather than looked up globally
/// so tests can point at a temp dir.
data_dir: PathBuf,
}
/// On-disk representation of a persisted session (only Full sessions, no TOTP secrets).
@@ -67,6 +74,7 @@ impl SessionStore {
Self {
sessions: Arc::new(RwLock::new(sessions)),
persist_path,
data_dir: PathBuf::from("/var/lib/archipelago"),
}
}
@@ -75,9 +83,17 @@ impl SessionStore {
/// machine's real /var/lib/archipelago/sessions.json.
#[cfg(test)]
pub fn new_for_tests(persist_path: PathBuf) -> Self {
// data_dir shares the temp path's parent so a test that writes a
// policy file is honoured, and one that doesn't gets the defaults
// rather than the dev machine's real configuration.
let data_dir = persist_path
.parent()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
Self {
sessions: Arc::new(RwLock::new(HashMap::new())),
persist_path,
data_dir,
}
}
@@ -120,6 +136,7 @@ impl SessionStore {
created_at,
last_activity,
session_type: SessionType::Full,
device_class: crate::settings::session_policy::DeviceClass::Browser,
},
);
}
@@ -160,6 +177,7 @@ impl SessionStore {
created_at: now,
last_activity: now,
session_type: SessionType::Full,
device_class: crate::settings::session_policy::DeviceClass::Browser,
};
let mut sessions = self.sessions.write().await;
@@ -184,6 +202,10 @@ impl SessionStore {
totp_secret,
attempts: 0,
},
// A half-finished login is always treated as a browser: it lives
// for PENDING_SESSION_TTL either way, and a kiosk exemption on a
// session that has not passed 2FA yet would be the wrong default.
device_class: crate::settings::session_policy::DeviceClass::Browser,
};
self.sessions.write().await.insert(hash, session);
token
@@ -192,19 +214,23 @@ impl SessionStore {
/// Validate a full session token. Returns true if the session exists and hasn't expired.
/// Updates last_activity on successful validation (inactivity-based expiry).
pub async fn validate(&self, token: &str) -> bool {
let policy = self.policy().await;
let hash = hash_token(token);
let mut sessions = self.sessions.write().await;
if let Some(session) = sessions.get_mut(&hash) {
if !matches!(session.session_type, SessionType::Full) {
return false;
}
if session
let idle = session
.last_activity
.elapsed()
.unwrap_or_default()
.as_secs()
>= FULL_SESSION_TTL
{
.as_secs();
let age = session.created_at.elapsed().unwrap_or_default().as_secs();
// Both limits, not just idleness: the dashboard polls, so an
// idle timeout alone would never fire on an open tab. The
// absolute cap is what actually guarantees a login ends.
if policy.is_expired(session.device_class, age, idle) {
sessions.remove(&hash);
return false;
}
@@ -215,6 +241,13 @@ impl SessionStore {
}
}
/// The operator's session policy, re-read from disk rather than cached
/// for the process lifetime so a change in Settings takes effect on the
/// next request instead of the next restart.
pub async fn policy(&self) -> crate::settings::session_policy::SessionPolicy {
crate::settings::session_policy::load(&self.data_dir).await
}
/// Get the TOTP secret from a pending session. Returns None if not a valid pending session.
/// Increments the attempt counter.
pub async fn get_pending_secret(&self, token: &str) -> Option<Vec<u8>> {
@@ -259,6 +292,7 @@ impl SessionStore {
created_at: now,
last_activity: now,
session_type: SessionType::Full,
device_class: crate::settings::session_policy::DeviceClass::Browser,
},
);
Self::save_to_disk(&sessions, &self.persist_path).await;
@@ -300,6 +334,7 @@ impl SessionStore {
created_at: now,
last_activity: now,
session_type: SessionType::Full,
device_class: crate::settings::session_policy::DeviceClass::Browser,
},
);
Self::save_to_disk(&sessions, &self.persist_path).await;
+1
View File
@@ -4,4 +4,5 @@
//! call sites (deep in the transport / RPC / ingest stacks) don't need
//! to thread a data_dir or Arc through the entire call graph.
pub mod session_policy;
pub mod transport;
@@ -0,0 +1,200 @@
//! How long a login lasts, and who gets to say so.
//!
//! # Why this is configurable rather than a constant
//!
//! There is no single correct session lifetime. The same node can be a
//! wall-mounted TV in a living room that must never ask for a password
//! mid-film, and a wallet holding real funds where PCI DSS-style guidance
//! says fifteen minutes. Both are legitimate; the operator knows which one
//! this node is and we do not.
//!
//! # The two tokens
//!
//! * **Session token** — short-lived, refreshed silently on every
//! authenticated request. This is what the browser sends; if it leaks, it
//! is useful only until [`SessionPolicy::idle_timeout_secs`] of silence.
//! * **Login (remember) token** — long-lived, and its *only* power is to
//! mint a fresh session token. Kept separate so raising the convenience
//! knob does not put a 30-day bearer credential on every request.
//!
//! Raising the idle timeout therefore does not weaken the credential that
//! actually travels; it only changes how long a quiet tab stays usable.
//!
//! # Why an absolute cap exists at all
//!
//! Idle timeout alone can be defeated by any page that polls — the
//! dashboard polls constantly, so an idle timeout would never fire while a
//! tab is open. The absolute cap is what guarantees a login eventually
//! ends, which is the property an auditor actually asks about.
use serde::{Deserialize, Serialize};
use std::path::Path;
const FILE_PATH: &str = "settings/session_policy.json";
/// Bounds. A setting that can be made meaningless is not a setting, and one
/// that can lock the operator out of their own node is a footgun.
const MIN_IDLE_SECS: u64 = 60;
const MAX_IDLE_SECS: u64 = 90 * 24 * 3600;
const MIN_ABSOLUTE_SECS: u64 = 300;
const MAX_ABSOLUTE_SECS: u64 = 365 * 24 * 3600;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DeviceClass {
/// Ordinary browser on a phone or laptop. Policy applies as configured.
Browser,
/// A screen nobody logs into — a wall-mounted dashboard or TV. Being
/// signed out mid-view is the failure mode here, not a stale session:
/// the device is physically in the home, and there is no keyboard to
/// re-authenticate with. Exempt from the idle timeout, still subject to
/// the absolute cap so a stolen box does not stay authenticated forever.
Kiosk,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionPolicy {
/// Silence after which a session token stops validating.
pub idle_timeout_secs: u64,
/// Hard ceiling from login, regardless of activity. `None` = no cap.
pub absolute_timeout_secs: Option<u64>,
/// Re-prompt for the password before actions that move money, however
/// fresh the session is. Independent of the timeouts on purpose: it is
/// the control that matters when funds are involved, and it costs the
/// operator nothing the rest of the time.
pub reauth_for_funds: bool,
}
impl Default for SessionPolicy {
fn default() -> Self {
Self {
// A day of silence, matching the previous hard-coded constant so
// existing nodes see no behaviour change until someone chooses.
idle_timeout_secs: 86_400,
// 30 days, aligned with the login token's own lifetime: a
// session that outlived the token which could refresh it would
// be an oddity.
absolute_timeout_secs: Some(30 * 24 * 3600),
reauth_for_funds: true,
}
}
}
impl SessionPolicy {
/// Clamp to the supported range. Applied on load as well as on save, so
/// a hand-edited file cannot disable expiry by writing `0`.
pub fn sanitized(mut self) -> Self {
self.idle_timeout_secs = self.idle_timeout_secs.clamp(MIN_IDLE_SECS, MAX_IDLE_SECS);
self.absolute_timeout_secs = self
.absolute_timeout_secs
.map(|v| v.clamp(MIN_ABSOLUTE_SECS, MAX_ABSOLUTE_SECS))
// An absolute cap below the idle timeout would expire sessions
// while they are still active, which reads as random logouts.
.map(|v| v.max(self.idle_timeout_secs));
self
}
/// Idle timeout for a given device, or `None` when idleness is not a
/// reason to expire (kiosk screens).
pub fn idle_timeout_for(&self, class: DeviceClass) -> Option<u64> {
match class {
DeviceClass::Browser => Some(self.idle_timeout_secs),
DeviceClass::Kiosk => None,
}
}
/// Has a session expired? `age` is time since login, `idle` since last
/// use. Both are checked because either alone is insufficient: idle
/// never fires on a polling dashboard, and absolute alone leaves a
/// forgotten tab usable for a month.
pub fn is_expired(&self, class: DeviceClass, age_secs: u64, idle_secs: u64) -> bool {
if let Some(limit) = self.absolute_timeout_secs {
if age_secs >= limit {
return true;
}
}
match self.idle_timeout_for(class) {
Some(limit) => idle_secs >= limit,
None => false,
}
}
}
pub async fn load(data_dir: &Path) -> SessionPolicy {
let path = data_dir.join(FILE_PATH);
match tokio::fs::read(&path).await {
Ok(bytes) => serde_json::from_slice::<SessionPolicy>(&bytes)
.map(SessionPolicy::sanitized)
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "session policy unreadable; using defaults");
SessionPolicy::default()
}),
Err(_) => SessionPolicy::default(),
}
}
pub async fn save(data_dir: &Path, policy: SessionPolicy) -> anyhow::Result<SessionPolicy> {
let policy = policy.sanitized();
let path = data_dir.join(FILE_PATH);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp = path.with_extension("json.tmp");
tokio::fs::write(&tmp, serde_json::to_vec_pretty(&policy)?).await?;
tokio::fs::rename(&tmp, &path).await?;
Ok(policy)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_the_previous_hardcoded_behaviour() {
let p = SessionPolicy::default();
assert_eq!(p.idle_timeout_secs, 86_400);
assert!(p.reauth_for_funds);
}
#[test]
fn expiry_cannot_be_disabled_by_hand_editing_the_file() {
let p = SessionPolicy {
idle_timeout_secs: 0,
absolute_timeout_secs: Some(0),
reauth_for_funds: false,
}
.sanitized();
assert!(p.idle_timeout_secs >= MIN_IDLE_SECS);
assert!(p.absolute_timeout_secs.unwrap() >= MIN_ABSOLUTE_SECS);
}
#[test]
fn absolute_cap_is_never_shorter_than_idle() {
// Otherwise a session dies while actively in use, which the operator
// experiences as being logged out at random.
let p = SessionPolicy {
idle_timeout_secs: 7 * 24 * 3600,
absolute_timeout_secs: Some(3600),
reauth_for_funds: true,
}
.sanitized();
assert_eq!(p.absolute_timeout_secs.unwrap(), p.idle_timeout_secs);
}
#[test]
fn a_kiosk_never_expires_from_idleness_but_still_has_a_ceiling() {
let p = SessionPolicy::default();
let a_week = 7 * 24 * 3600;
assert!(!p.is_expired(DeviceClass::Kiosk, 60, a_week));
assert!(p.is_expired(DeviceClass::Browser, 60, a_week));
// The absolute cap still applies to the TV.
assert!(p.is_expired(DeviceClass::Kiosk, 31 * 24 * 3600, 0));
}
#[test]
fn a_polling_dashboard_still_eventually_expires() {
// idle never grows because the page polls; only the cap saves us.
let p = SessionPolicy::default();
assert!(p.is_expired(DeviceClass::Browser, 30 * 24 * 3600, 0));
}
}
+48 -13
View File
@@ -74,7 +74,20 @@ fn is_newer(candidate: &str, current: &str) -> bool {
}
}
/// Primary OTA origin. Named host over TLS rather than the bare IP it used
/// to be: the IP pinned the fleet to one machine and one plaintext port, so
/// moving or fronting the origin meant an OTA to change where OTAs come
/// from — the one update you cannot ship if the origin is unreachable. The
/// signature is what establishes trust (see `trust::anchor`), not the
/// transport, but HTTPS also stops a network observer seeing which version
/// a node runs.
const DEFAULT_UPDATE_MANIFEST_URL: &str =
"https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json";
/// The previous IP-based origin, kept as an automatic fallback so a node
/// whose DNS or TLS is broken still updates. Dropped from the mirror list
/// once the fleet has moved.
const LEGACY_UPDATE_MANIFEST_URL: &str =
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
const UPDATE_STATE_FILE: &str = "update_state.json";
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
@@ -113,10 +126,19 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf {
}
fn default_mirrors() -> Vec<UpdateMirror> {
vec![UpdateMirror {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Server 1 (OVH)".to_string(),
}]
vec![
UpdateMirror {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Archipelago Foundation".to_string(),
},
// Fallback, tried only if the named origin fails: a node whose DNS
// or clock is wrong (both break TLS) must still be able to update
// itself, and the signature check is what makes either source safe.
UpdateMirror {
url: LEGACY_UPDATE_MANIFEST_URL.to_string(),
label: "Direct (fallback)".to_string(),
},
]
}
/// Load the operator-configured mirror list. Returns defaults if the
@@ -186,15 +208,18 @@ fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
}
for mirror in list.iter_mut() {
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
mirror.label = "Server 1 (OVH)".to_string();
mirror.label = "Archipelago Foundation".to_string();
} else if mirror.url == LEGACY_UPDATE_MANIFEST_URL {
mirror.label = "Direct (fallback)".to_string();
}
}
list.sort_by_key(|m| {
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
0
} else {
1
}
// Named origin first, its IP fallback second, anything the operator
// added after that. Ordering matters: the list is tried in order, so a
// stale entry sitting first costs a timeout on every check.
list.sort_by_key(|m| match m.url.as_str() {
u if u == DEFAULT_UPDATE_MANIFEST_URL => 0,
u if u == LEGACY_UPDATE_MANIFEST_URL => 1,
_ => 2,
});
}
@@ -2373,8 +2398,18 @@ mod tests {
async fn test_load_mirrors_returns_defaults_when_absent() {
let dir = tempfile::tempdir().unwrap();
let list = load_mirrors(dir.path()).await.unwrap();
assert_eq!(list.len(), 1);
assert!(list[0].url.contains("146.59.87.168"));
// The named origin leads, its IP fallback follows. A node with broken
// DNS or a wrong clock (both break TLS) must still have a way to
// update; the signature is what makes either source trustworthy.
assert_eq!(list.len(), 2);
assert!(
list[0]
.url
.starts_with("https://source.archipelago-foundation.org/"),
"the named origin must be primary, got {}",
list[0].url
);
assert!(list[1].url.contains("146.59.87.168"));
assert!(
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
"tx1138 was retired as a release server and must not be a default mirror"
+12 -2
View File
@@ -1040,6 +1040,12 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
let mut wallet = load_wallet(data_dir).await?;
let mut received_total = 0u64;
// MintClient translates the mint's NUT error code into plain language and
// puts it at the top of the error chain (see `mint_error` in
// mint_client.rs); `{}` surfaces that, `{:#}` keeps the raw status/body
// for the log. Remember the last one so a total failure can tell the user
// *why* instead of just "nothing was received".
let mut last_reason: Option<String> = None;
// Swap proofs at each mint
for entry in &token.token {
@@ -1051,14 +1057,18 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
received_total += amount;
}
Err(e) => {
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e);
last_reason = Some(e.to_string());
// Continue with other mints if any
}
}
}
if received_total == 0 {
anyhow::bail!("Failed to receive any proofs from token");
match last_reason {
Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason),
None => anyhow::bail!("Failed to receive any proofs from token"),
}
}
wallet.record_tx(
+71 -5
View File
@@ -59,6 +59,72 @@ pub struct MintResult {
pub proofs: Vec<Proof>,
}
/// Translate a Cashu NUT "transaction validation" error code into plain
/// language a wallet user can act on. Mints respond to a rejected request
/// with `{"code": N, "detail": "..."}`; `detail` is implementation-defined
/// free text, but `code` is the stable identifier from the spec
/// (https://github.com/cashubtc/nuts/blob/main/error_codes.md). Covers the
/// 10001-11017 "proof/transaction validation" range plus the 12001-12003
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call
/// can actually hit. Returns `None` for anything else (e.g. Lightning/quote
/// codes in the 20000s) so the caller falls back to the mint's own `detail`.
fn describe_mint_error_code(code: i64) -> Option<&'static str> {
Some(match code {
10001 => "The mint rejected these coins as invalid.",
11001 => "This ecash has already been redeemed — it can't be claimed twice.",
11002 => "This ecash is already being redeemed elsewhere — try again in a moment.",
11003 => "The mint already issued new coins for this exact request — there's nothing left to redeem.",
11004 => "This request is still being processed by the mint — try again in a moment.",
11005 => "The token's amounts don't add up (inputs don't match outputs) — it may be corrupt.",
11006 => "That amount is outside the range this mint allows.",
11007 => "This token contains duplicate coins — it may be corrupt or already used.",
11008 => "The mint rejected this as a duplicate request.",
11009 | 11010 => "This token mixes incompatible currency units — the mint rejected it.",
11011 => "That Lightning invoice has no amount, which isn't supported here.",
11012 => "The amount requested doesn't match the Lightning invoice.",
11013 => "The mint doesn't support this currency unit.",
11014 | 11015 => "This token has too many coins for the mint to process in one request.",
11016 => "Duplicate quote IDs were sent in this request.",
11017 => "Too many items were sent in a single request.",
12001 => "The mint no longer recognizes the keyset that signed this token.",
12002 => "The mint's signing key for this token is inactive.",
12003 => "The mint's signing key for this token has expired.",
_ => return None,
})
}
/// Parse a mint's error body (`{"code": N, "detail": "..."}`) and pick the
/// best user-facing message: the plain-language translation when we know the
/// code, otherwise the mint's own `detail` text, otherwise the raw body.
fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
let code = parsed
.as_ref()
.and_then(|v| v.get("code"))
.and_then(|c| c.as_i64());
let detail = parsed
.as_ref()
.and_then(|v| v.get("detail"))
.and_then(|d| d.as_str());
if let Some(friendly) = code.and_then(describe_mint_error_code) {
return friendly.to_string();
}
match detail {
Some(d) if !d.is_empty() => d.to_string(),
_ => format!("mint returned {} with no further detail", status),
}
}
/// Build the error for a failed mint HTTP call: `op` + status + raw body as
/// the technical cause (visible via `{:#}` in logs), with the plain-language
/// translation layered on top via `.context()` so `{}` — what reaches the
/// wallet user — shows something actionable instead of raw mint JSON.
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
let friendly = describe_mint_error_body(status, body);
anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly)
}
/// HTTP client for a single Cashu mint.
pub struct MintClient {
url: String,
@@ -146,7 +212,7 @@ impl MintClient {
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Mint quote failed ({}): {}", status, body);
return Err(mint_error("Mint quote", status, &body));
}
res.json().await.context("Failed to parse mint quote")
@@ -212,7 +278,7 @@ impl MintClient {
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Mint tokens failed ({}): {}", status, body);
return Err(mint_error("Minting tokens", status, &body));
}
let body: serde_json::Value = res.json().await.context("Failed to parse mint response")?;
@@ -266,7 +332,7 @@ impl MintClient {
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Melt quote failed ({}): {}", status, body);
return Err(mint_error("Melt quote", status, &body));
}
res.json().await.context("Failed to parse melt quote")
@@ -293,7 +359,7 @@ impl MintClient {
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Melt failed ({}): {}", status, body);
return Err(mint_error("Melt", status, &body));
}
res.json().await.context("Failed to parse melt response")
@@ -337,7 +403,7 @@ impl MintClient {
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Swap failed ({}): {}", status, body);
return Err(mint_error("Swap", status, &body));
}
let body: serde_json::Value = res.json().await.context("Failed to parse swap response")?;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.123-alpha",
"version": "1.7.124-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.123-alpha",
"version": "1.7.124-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.123-alpha",
"version": "1.7.124-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+1 -1
View File
@@ -442,7 +442,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
+1 -6
View File
@@ -311,31 +311,26 @@ const showChatPanel = computed(() =>
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
)
const showBitcoinPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'bitcoin'
if (isMobile.value) return mobileTab.value === 'bitcoin'
return activeTab.value === 'bitcoin'
})
const showDeadmanPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'deadman'
if (isMobile.value) return mobileTab.value === 'deadman'
return activeTab.value === 'deadman'
})
const showAssistantPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'assistant'
if (isMobile.value) return mobileTab.value === 'assistant'
return activeTab.value === 'assistant'
})
const showMapPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'map'
if (isMobile.value) return mobileTab.value === 'map'
return activeTab.value === 'map'
})
const showDevicePanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'device'
if (isMobile.value) return mobileTab.value === 'device'
return activeTab.value === 'device'
@@ -2683,7 +2678,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<!-- Tools panels (3rd column on wide screens) -->
<div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
<div v-if="isWideDesktop && !isVeryWideDesktop" class="mesh-tools-tab-bar">
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">Bitcoin</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
Dead Man
@@ -4,12 +4,15 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"aiui": 5180,
"archy-mempool-web": 4080,
"archy-nbxplorer": 32838,
"bitcoin-ui": 8334,
"botfights": 9100,
"btcpay-server": 23000,
"did-wallet": 8088,
"electrs-ui": 50002,
"electrumx": 50002,
"fedimint": 8175,
"filebrowser": 8083,
"fips-ui": 8336,
"gitea": 3001,
"grafana": 3000,
"homeassistant": 8123,
+20 -11
View File
@@ -43,17 +43,26 @@
.mesh-columns-wide .mesh-chat-card { grid-column: 2; grid-row: 1; min-height: 0; overflow: hidden; }
.mesh-columns-wide .mesh-tools-wrapper { grid-column: 3; grid-row: 1; display: flex; flex-direction: column; gap: 0; min-height: 0; overflow: hidden; }
.mesh-columns-wide .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; margin-bottom: 12px; }
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(420px, 0.95fr); }
.mesh-columns-very-wide .mesh-tools-wrapper { display: grid; grid-template-rows: minmax(0, 1fr) minmax(0, 0.85fr) minmax(0, 1fr); gap: 12px; overflow: hidden; }
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-deadman-panel,
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-assistant-panel,
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-map-panel { min-height: 0; height: 100%; overflow: hidden; }
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-bitcoin-panel,
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-deadman-panel,
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-assistant-panel,
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-map-panel { flex: 1 1 auto; min-height: 0; height: auto; }
.mesh-columns-very-wide .mesh-tools-tab-bar { display: none; }
/* A very wide screen gets a roomier third column but the SAME tabbed
panel as every other desktop width. It used to stack Bitcoin, Dead Man,
AI, the map and Device on top of each other in fixed grid rows, which on
a real 2560px display clipped the first three headings to a few pixels,
letterboxed the map, and pushed Radio Settings into a scroll more
screen producing a worse view (reported with a screenshot 2026-08-05).
One tab at a time, filling the column, is what makes the map edge to
edge and every control reachable without scrolling. */
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(460px, 1fr); }
.mesh-columns-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-deadman-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-assistant-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-device-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel {
flex: 1 1 auto; min-height: 0; height: auto; overflow-y: auto;
}
/* The map is the one panel with nothing to scroll: let it consume the
column edge to edge rather than sitting in a letterbox. */
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel { overflow: hidden; padding: 0; }
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel > * { height: 100%; width: 100%; }
.mesh-columns-wide .mesh-mobile-back-btn,
.mesh-columns-wide .mesh-tab-bar { display: none; }
.mesh-status-card { padding: 16px; flex-shrink: 0; }
@@ -362,6 +362,24 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.124-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.7.124-alpha</span>
<span class="text-xs text-white/40">August 5, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>The most important fix here: some nodes were left switched off by their own update, and could not switch themselves back on.</strong> The node replaces its program and then exits, expecting the system to start it again but nodes installed from older images carried a setting that only restarts the program if it <em>crashes</em>. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts.</p>
<p><strong>Portainer opens again.</strong> Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1 and their stored data was written by that version, while the app list pinned a version from two years earlier so a rebuilt container landed on the old one, which will not read newer data. The correct version is pinned now, older installs upgrade cleanly, and no data was touched.</p>
<p><strong>Bitcoin starts reliably again.</strong> A leftover settings file in the Bitcoin folder one the node kept rewriting and Bitcoin no longer reads is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes it, clears stale copies, and treats any that remain as harmless.</p>
<p><strong>Every app screen opens from My Apps again.</strong> The login gate refused to be displayed inside another page at all which is exactly how My Apps opens an app so protected apps looked broken. It now allows only your own node to display it and refuses everyone else.</p>
<p><strong>The app login screen looks like the node's own now:</strong> same rotating artwork, same panel, the Archipelago mark, and the app's real icon as a tile the way My Apps shows it, instead of a plain box with a letter.</p>
<p><strong>The Mesh screen uses wide displays properly.</strong> On very large screens it stacked all five panels, clipping three headings to a sliver and squeezing the map into a letterbox. It now shows one panel at a time, filling the space, with the map edge to edge.</p>
<p><strong>You can choose how long you stay signed in.</strong> Settings Account offers an inactivity timeout, a hard limit, and an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle there is nobody there to sign them back in.</p>
<p>Updates now come from source.archipelago-foundation.org rather than a bare address, with the old one kept as an automatic fallback. Also: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.</p>
<p>Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.</p>
</div>
</div>
<!-- v1.7.123-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/app'
import AccountInfoSection from '@/views/settings/AccountInfoSection.vue'
import ChangePasswordSection from '@/views/settings/ChangePasswordSection.vue'
import TwoFactorSection from '@/views/settings/TwoFactorSection.vue'
import SessionTimeoutSection from '@/views/settings/SessionTimeoutSection.vue'
const router = useRouter()
const { t } = useI18n()
@@ -24,6 +25,7 @@ async function handleLogout() {
<AccountInfoSection />
<ChangePasswordSection />
<TwoFactorSection />
<SessionTimeoutSection />
<!-- Logout Button -->
<button
@@ -0,0 +1,151 @@
<script setup lang="ts">
/**
* How long a login lasts on this node.
*
* Presented as two plain questions "sign me out after quiet" and "always
* sign me out after" rather than as the two-token mechanism underneath.
* The distinction that matters to the operator is that the second one is
* what actually guarantees a login ends: this dashboard polls constantly,
* so an idle timeout alone never fires on an open tab.
*/
import { ref, onMounted, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
type Policy = {
idle_timeout_secs: number
absolute_timeout_secs: number | null
reauth_for_funds: boolean
}
const idle = ref<number>(86400)
const absolute = ref<number | null>(30 * 24 * 3600)
const reauthForFunds = ref(true)
const loading = ref(true)
const saving = ref(false)
const saved = ref(false)
const error = ref('')
// Offered as presets rather than a free number field: the useful values are
// few, and a box accepting "5" invites someone to lock themselves out.
const idleChoices = [
{ label: '15 minutes', value: 15 * 60 },
{ label: '1 hour', value: 3600 },
{ label: '1 day', value: 86400 },
{ label: '1 week', value: 7 * 24 * 3600 },
{ label: '30 days', value: 30 * 24 * 3600 },
]
const absoluteChoices = [
{ label: '1 day', value: 86400 },
{ label: '1 week', value: 7 * 24 * 3600 },
{ label: '30 days', value: 30 * 24 * 3600 },
{ label: '90 days', value: 90 * 24 * 3600 },
{ label: 'Never', value: null },
]
const shortIdleWarning = computed(() => idle.value <= 3600)
async function load() {
loading.value = true
try {
const p = await rpcClient.call<Policy>({ method: 'auth.session-policy.get', params: {} })
idle.value = p.idle_timeout_secs
absolute.value = p.absolute_timeout_secs
reauthForFunds.value = p.reauth_for_funds
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
saved.value = false
error.value = ''
try {
// The backend clamps and returns what it stored, so reflect that back
// rather than assuming our values were taken verbatim.
const p = await rpcClient.call<Policy>({
method: 'auth.session-policy.set',
params: {
idle_timeout_secs: idle.value,
absolute_timeout_secs: absolute.value,
reauth_for_funds: reauthForFunds.value,
},
})
idle.value = p.idle_timeout_secs
absolute.value = p.absolute_timeout_secs
reauthForFunds.value = p.reauth_for_funds
saved.value = true
setTimeout(() => { saved.value = false }, 2500)
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="mb-6">
<h3 class="text-base font-medium text-white/90 mb-1">Session timeout</h3>
<p class="text-sm text-white/60 mb-4">
How long this node keeps you signed in. TV and kiosk screens are never
signed out for sitting idle there is nobody there to sign them back in.
</p>
<div v-if="error" role="alert" class="mb-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg text-red-200 text-sm">
{{ error }}
</div>
<div v-if="!loading" class="space-y-4">
<div>
<label for="idle-timeout" class="block text-sm font-medium text-white/80 mb-2">Sign me out after this much inactivity</label>
<select
id="idle-timeout"
v-model.number="idle"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
>
<option v-for="c in idleChoices" :key="c.value" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
</select>
<p v-if="shortIdleWarning" class="text-xs text-orange-300/80 mt-2">
Short timeouts are what payment-industry rules ask for when funds are involved expect to sign in often.
</p>
</div>
<div>
<label for="absolute-timeout" class="block text-sm font-medium text-white/80 mb-2">Always sign me out after</label>
<select
id="absolute-timeout"
v-model="absolute"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
>
<option v-for="c in absoluteChoices" :key="String(c.value)" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
</select>
<p class="text-xs text-white/50 mt-2">
Counts from when you signed in, whatever you are doing. This is the one that
guarantees a session ends: an open dashboard is never idle, so the setting
above would not fire on it.
</p>
</div>
<label class="flex items-start gap-3 cursor-pointer">
<input v-model="reauthForFunds" type="checkbox" class="mt-1 accent-orange-500" />
<span class="text-sm text-white/80">
Ask for my password again before sending funds
<span class="block text-xs text-white/50">Recommended. Applies however recently you signed in.</span>
</span>
</label>
<button
:disabled="saving"
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 disabled:opacity-50"
@click="save"
>
{{ saving ? 'Saving…' : (saved ? 'Saved' : 'Save session settings') }}
</button>
</div>
</div>
</template>
+22 -20
View File
@@ -1,33 +1,35 @@
{
"changelog": [
"**Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself.",
"**What was actually readable was the page, not your money.** Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was verified directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.",
"All five now serve only to the node itself, with the login gate in front of them, exactly like the twenty app screens closed in the previous release.",
"**Every port on the node now has a stated policy — there are no undecided ones left.** Eleven ports previously had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now require the node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections (federation members authenticate to the federation), NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session, and its dashboard needs its own certificate), Pine's secure listener, and the Lightning REST port, which wallets reach with a macaroon exactly as before.",
"Fresh installs are covered too, not just existing nodes. The five screens are delivered as prebuilt images, so a newly flashed node would have come up open even after this fix. All five were rebuilt, published, and then pulled back and inspected to confirm the fix is really inside them.",
"Two delivery faults fixed alongside, either of which would have silently undone the above: two of the five screens were reaching nodes through no update path at all, so edits to them never arrived; and a fourth copy of the Bitcoin screen's configuration was being rewritten on every health check, which would have re-opened that port after everything else was corrected.",
"Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release."
"**The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing \"server starting\" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update.",
"**Portainer opens again.** Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1, their stored data was written by that version, and the app list pinned a version from two years earlier — so when the container was rebuilt it landed on the old one, which will not read newer data. The correct version is now pinned, older installs upgrade cleanly, and no data was touched.",
"**Bitcoin starts reliably again.** A leftover settings file in the Bitcoin folder — one the node itself kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes that file, removes stale copies, and treats any that remain as harmless.",
"**Every app screen opens from My Apps again.** The login gate refused to be displayed inside another page at all, which is exactly how My Apps opens an app, so protected apps appeared broken. It now allows only your own node to display it, and refuses everyone else — a distinction the old setting could not express.",
"**The app login screen now looks like the node's own.** Same rotating artwork, the same panel, the Archipelago mark, and the app's real icon shown as a tile the way My Apps shows it, instead of a plain box with a letter.",
"**The Mesh screen uses wide displays properly.** On very large screens it stacked all five panels on top of each other, clipping three of the headings to a sliver and squeezing the map into a letterbox — more screen producing a worse view. It now shows one panel at a time, filling the space, with the map running edge to edge.",
"**You can choose how long you stay signed in.** Settings → Account now offers an inactivity timeout and a hard limit, plus an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle, because there is nobody there to sign them back in.",
"Updates now come from `source.archipelago-foundation.org` rather than a bare address, with the old one kept as an automatic fallback for nodes whose clock or name lookup is off. Also included: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.",
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release."
],
"components": [
{
"current_version": "1.7.123-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago",
"current_version": "1.7.124-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.123-alpha",
"sha256": "b7974a671f8f7987fab548d0a9cac1ad145e9a52f4099791c3d1879d065a9f47",
"size_bytes": 54957360
"new_version": "1.7.124-alpha",
"sha256": "bc91e7d09083dad17934dc2bad211b4eb1d22fd799e032a94fa73df025ba272d",
"size_bytes": 54701560
},
{
"current_version": "1.7.123-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago-frontend-1.7.123-alpha.tar.gz",
"name": "archipelago-frontend-1.7.123-alpha.tar.gz",
"new_version": "1.7.123-alpha",
"sha256": "075370c761f07373e553db07eb5e019d96ceb591fa9c1067e1adc6701fa30752",
"size_bytes": 210533854
"current_version": "1.7.124-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago-frontend-1.7.124-alpha.tar.gz",
"name": "archipelago-frontend-1.7.124-alpha.tar.gz",
"new_version": "1.7.124-alpha",
"sha256": "346ed6472fb647bcf45299e4d93b9f555142bf1229912030fbafe9966fd410a7",
"size_bytes": 210531432
}
],
"release_date": "2026-08-05",
"signature": "f64eaf9731277865c0d3f757f63dc2f4921fb7d90deb1615ca2b2b0f4c173282524b39a2d0b8f027c7532e918c349a86b14ed1b7992a96b0c6a80cd46b5d500d",
"signature": "40f5ffca026addd00c4aac933edf8faa05a60d9b981341bc0bfaae078812a65ad6656886b4a9d533476759dbb511fb4b050e518cd0060568f7b33035069e600e",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.7.123-alpha"
"version": "1.7.124-alpha"
}
+12 -12
View File
@@ -421,7 +421,7 @@
},
"container": {
"custom_args": [
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; # A stray bitcoin.conf in the datadir is FATAL when -conf points # elsewhere: bitcoind refuses to start with \"contains a bitcoin.conf # file which is ignored\", and the app crash-loops (100.82.34.38, # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the # RPC credentials and the flags below are the authoritative config, # so the datadir file is legacy debris; say so out loud rather than # failing, and let bitcoind start. if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
],
"data_uid": "100101:100101",
"derived_env": [
@@ -567,7 +567,7 @@
},
"container": {
"custom_args": [
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; # A stray bitcoin.conf in the datadir is FATAL when -conf points # elsewhere: bitcoind refuses to start with \"contains a bitcoin.conf # file which is ignored\", and the app crash-loops (100.82.34.38, # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the # RPC credentials and the flags below are the authoritative config, # so the datadir file is legacy debris; say so out loud rather than # failing, and let bitcoind start. if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
],
"data_uid": "100101:100101",
"derived_env": [
@@ -684,7 +684,7 @@
]
},
"bitcoin-ui": {
"image": "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.119-alpha",
"image": "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.123-alpha",
"manifest": {
"app": {
"container": {
@@ -740,7 +740,7 @@
]
}
},
"version": "1.7.119-alpha"
"version": "1.7.123-alpha"
},
"botfights": {
"manifest": {
@@ -1146,7 +1146,7 @@
"version": "1.0.0"
},
"electrs-ui": {
"image": "146.59.87.168:3000/lfg2025/electrs-ui:latest",
"image": "146.59.87.168:3000/lfg2025/electrs-ui:1.7.123-alpha",
"manifest": {
"app": {
"container": {
@@ -1189,7 +1189,7 @@
"volumes": []
}
},
"version": "latest"
"version": "1.7.123-alpha"
},
"electrumx": {
"image": "146.59.87.168:3000/lfg2025/electrumx:v1.18.0",
@@ -3094,7 +3094,7 @@
"version": "v0.18.4-beta"
},
"lnd-ui": {
"image": "146.59.87.168:3000/lfg2025/lnd-ui:1.7.119-alpha",
"image": "146.59.87.168:3000/lfg2025/lnd-ui:1.7.123-alpha",
"manifest": {
"app": {
"container": {
@@ -3141,7 +3141,7 @@
"volumes": []
}
},
"version": "1.7.119-alpha"
"version": "1.7.123-alpha"
},
"mempool": {
"image": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1",
@@ -4382,13 +4382,13 @@
"version": "3.4.2"
},
"portainer": {
"image": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
"image": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
"manifest": {
"app": {
"category": "development",
"container": {
"data_uid": "1000:1000",
"image": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
"image": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
"pull_policy": "if-not-present"
},
"dependencies": [
@@ -4475,7 +4475,7 @@
]
}
},
"version": "2.19.4"
"version": "2.39.1"
},
"router": {
"manifest": {
@@ -4898,7 +4898,7 @@
}
},
"schema": 1,
"signature": "cc19214673b37c2d7d41edf996d5002abe41cf933ee9f33fb7182b05c6c40aa1a555ad596228c4a83dc4a11166f3345e7478399f9693f5f2da3818020980eb03",
"signature": "b10aa65a9a6e91a6d421deff6e578c080c6bafa0185d54412df72b9814a694e9c9ccd803f7e8baa6d0ae93c1b71b3e20ed1626769445cc2f484c4a8bbd1b9901",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"updated": "2026-08-05"
}
+22 -20
View File
@@ -1,33 +1,35 @@
{
"changelog": [
"**Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself.",
"**What was actually readable was the page, not your money.** Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was verified directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.",
"All five now serve only to the node itself, with the login gate in front of them, exactly like the twenty app screens closed in the previous release.",
"**Every port on the node now has a stated policy — there are no undecided ones left.** Eleven ports previously had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now require the node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections (federation members authenticate to the federation), NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session, and its dashboard needs its own certificate), Pine's secure listener, and the Lightning REST port, which wallets reach with a macaroon exactly as before.",
"Fresh installs are covered too, not just existing nodes. The five screens are delivered as prebuilt images, so a newly flashed node would have come up open even after this fix. All five were rebuilt, published, and then pulled back and inspected to confirm the fix is really inside them.",
"Two delivery faults fixed alongside, either of which would have silently undone the above: two of the five screens were reaching nodes through no update path at all, so edits to them never arrived; and a fourth copy of the Bitcoin screen's configuration was being rewritten on every health check, which would have re-opened that port after everything else was corrected.",
"Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release."
"**The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing \"server starting\" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update.",
"**Portainer opens again.** Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1, their stored data was written by that version, and the app list pinned a version from two years earlier — so when the container was rebuilt it landed on the old one, which will not read newer data. The correct version is now pinned, older installs upgrade cleanly, and no data was touched.",
"**Bitcoin starts reliably again.** A leftover settings file in the Bitcoin folder — one the node itself kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes that file, removes stale copies, and treats any that remain as harmless.",
"**Every app screen opens from My Apps again.** The login gate refused to be displayed inside another page at all, which is exactly how My Apps opens an app, so protected apps appeared broken. It now allows only your own node to display it, and refuses everyone else — a distinction the old setting could not express.",
"**The app login screen now looks like the node's own.** Same rotating artwork, the same panel, the Archipelago mark, and the app's real icon shown as a tile the way My Apps shows it, instead of a plain box with a letter.",
"**The Mesh screen uses wide displays properly.** On very large screens it stacked all five panels on top of each other, clipping three of the headings to a sliver and squeezing the map into a letterbox — more screen producing a worse view. It now shows one panel at a time, filling the space, with the map running edge to edge.",
"**You can choose how long you stay signed in.** Settings → Account now offers an inactivity timeout and a hard limit, plus an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle, because there is nobody there to sign them back in.",
"Updates now come from `source.archipelago-foundation.org` rather than a bare address, with the old one kept as an automatic fallback for nodes whose clock or name lookup is off. Also included: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.",
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release."
],
"components": [
{
"current_version": "1.7.123-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago",
"current_version": "1.7.124-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.123-alpha",
"sha256": "b7974a671f8f7987fab548d0a9cac1ad145e9a52f4099791c3d1879d065a9f47",
"size_bytes": 54957360
"new_version": "1.7.124-alpha",
"sha256": "bc91e7d09083dad17934dc2bad211b4eb1d22fd799e032a94fa73df025ba272d",
"size_bytes": 54701560
},
{
"current_version": "1.7.123-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago-frontend-1.7.123-alpha.tar.gz",
"name": "archipelago-frontend-1.7.123-alpha.tar.gz",
"new_version": "1.7.123-alpha",
"sha256": "075370c761f07373e553db07eb5e019d96ceb591fa9c1067e1adc6701fa30752",
"size_bytes": 210533854
"current_version": "1.7.124-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago-frontend-1.7.124-alpha.tar.gz",
"name": "archipelago-frontend-1.7.124-alpha.tar.gz",
"new_version": "1.7.124-alpha",
"sha256": "346ed6472fb647bcf45299e4d93b9f555142bf1229912030fbafe9966fd410a7",
"size_bytes": 210531432
}
],
"release_date": "2026-08-05",
"signature": "f64eaf9731277865c0d3f757f63dc2f4921fb7d90deb1615ca2b2b0f4c173282524b39a2d0b8f027c7532e918c349a86b14ed1b7992a96b0c6a80cd46b5d500d",
"signature": "40f5ffca026addd00c4aac933edf8faa05a60d9b981341bc0bfaae078812a65ad6656886b4a9d533476759dbb511fb4b050e518cd0060568f7b33035069e600e",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.7.123-alpha"
"version": "1.7.124-alpha"
}
+7 -1
View File
@@ -45,7 +45,13 @@ SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0"
FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0"
NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.19.4"
# 2.39.1 is what the fleet has actually been running via the moving :latest
# tag, and it is the version that wrote their databases. Pinning back to
# 2.19.4 (2 years older) made Portainer refuse to start the moment a
# container was recreated: "database schema version does not align with the
# server version" — it migrates a DB forward, never backward. Pinned
# forward and published as a concrete tag so this is reproducible.
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.39.1"
# Networking
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"