Compare commits

...
Author SHA1 Message Date
archipelago e2c2f942c2 chore: release v1.7.95-alpha 2026-06-15 08:48:22 -04:00
archipelagoandClaude Opus 4.8 937ba7e115 chore: sync core/Cargo.lock to 1.7.94-alpha (release leftover)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:09:55 -04:00
archipelagoandClaude Opus 4.8 e056c2477b fix(fips,federation,ui): mesh content browse, removed-node tombstones, modal sizing
FIPS peer content browse over the mesh was failing with "Peer returned
error: 404 Not Found" and never falling back to Tor. `is_peer_allowed_path`
only allowed `/content/<id>` (item fetches) — the catalog endpoint is
exactly `/content` (no trailing slash), so it 404'd over the FIPS peer
listener. A FIPS 404 was also treated as a successful response, so the dial
never retried Tor. Fixes: allow `/content` over the mesh; add
`fips_should_fall_back()` so a FIPS 404/5xx in Auto mode falls back to Tor
(handles version-skew peers reaching a different route). Also correct the
reconnect hint text — the public anchor is TCP/8443, not UDP/8668.

Federation: deleted nodes reappeared because transitive discovery
(`merge` of a peer's advertised trusted peers) re-added any unknown DID.
Add a tombstone store (`removed-nodes.json`): remove_node tombstones the
DID, transitive merge skips tombstoned DIDs, and a remote-triggered
peer-joined is ignored for a removed DID. Explicit local re-add (add_node)
clears the tombstone.

UI: the app credentials modal panel stretched edge-to-edge (height:100%,
max-width:none, items-stretch overlay). Constrain it to a centered card
(max-width 34rem, rounded, dimmed full-screen backdrop) matching the
AppIconGrid / wallet-receive modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:09:26 -04:00
archipelago 7bd22f1f80 chore: release v1.7.94-alpha 2026-06-15 07:09:58 -04:00
archipelagoandClaude Opus 4.8 cfb0e4735a chore: sync What's New modal for v1.7.94-alpha
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:43:20 -04:00
archipelagoandClaude Opus 4.8 95f9a805b1 feat(fips): connect to public mesh anchor over TCP + wire daemon updates
The whole fleet was silently never reaching the FIPS mesh: the default
public anchor was configured as fips.v0l.io:8668/udp, but the anchor only
answers on TCP/8443. Fix the default to 185.18.221.160:8443/tcp (IPv4
literal — the hostname resolves IPv6-first and the daemon binds v4-only,
which fails the handshake with EAFNOSUPPORT), and auto-seed it in
anchors::load() so every node dials it without operator action (removal
still persists). Proven live on .116: cold start → anchor_connected in
~400ms, anchor became mesh parent.

Wire fips::update::apply() against upstream GitHub releases (stable
channel only): resolve /releases/latest → SHA256-verify the .deb against
checksums-linux.txt → install → restart. dpkg runs via `systemd-run` to
escape archipelago's ProtectSystem=strict sandbox (else /var/lib/dpkg is
read-only), with --force-confold (archipelago manages /etc/fips conffiles)
and --force-downgrade (dev builds sort newer than the stable tag).
Validated live: .116 upgraded 0.3.0-dev -> stable v0.3.0.

Also: standalone fips-ui dashboard app (apps/fips-ui + docker/fips-ui,
static nginx proxying /rpc/v1 same-origin, copiable own-anchor address);
reserve UI port 8336; register fips/fips-ui as platform-managed. Includes
the Lightning wallet cross-origin (CORS) + LND proxy auth + nginx
self-healer fix so the wallet screen connects instead of "failed to fetch".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:41:48 -04:00
archipelagoandClaude Opus 4.8 640dc87a5f chore: sync core/Cargo.lock to 1.7.93-alpha (release leftover)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:21:07 -04:00
archipelago 327a4e34dd chore: release v1.7.93-alpha 2026-06-14 15:18:34 -04:00
archipelagoandClaude Opus 4.8 bf2793be7b chore: sync What's New modal for v1.7.93-alpha
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:45:56 -04:00
archipelagoandClaude Opus 4.8 1973d76427 style: rustfmt lnd migrate_locked_wallet matches! call
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:41:40 -04:00
archipelagoandClaude Opus 4.8 403fa6eff3 docs: changelog for v1.7.93-alpha (LND wallet self-heal)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:38:57 -04:00
archipelagoandClaude Opus 4.8 3214d6aff3 fix(lnd): self-heal unrecoverable locked wallet via wipe+recreate
When an existing LND wallet is locked and none of the candidate passwords
(per-node secret, legacy constant) open it, the node can never auto-unlock
unattended. unlock_existing_wallet now returns Ok(false) for "all candidates
actively rejected" (vs Err for transient "LND not ready"), and
ensure_wallet_initialized responds by recreating the wallet:

  - mark the lnd container user-stopped so the health monitor won't
    re-launch it (and re-open the wallet) mid-wipe,
  - stop lnd, delete its wallet/chain/graph state as root,
  - start lnd, wait for NON_EXISTING, re-init a fresh wallet on the
    per-node secret, then clear the user-stopped flag.

LND runs as a plain bridge-network podman container (not a Quadlet unit),
so it is restarted via `systemd-run --user --scope podman`, matching the
orchestrator/health-monitor path.

Alpha nodes hold no funds and a wallet locked with an unknown password is
already inaccessible, so the wipe loses nothing reachable. Completes the
forward fix from 91adc281 for nodes whose wallet pre-dates the per-node
secret and whose password is unrecorded (e.g. .116/.228).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:08:33 -04:00
archipelagoandClaude Opus 4.8 459046b21c docs: resume notes for LND wallet fix (in-progress, branch lnd-wallet-password-fix)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:26:10 -04:00
archipelagoandClaude Opus 4.8 91adc281ca fix(lnd): per-node wallet password + locked-wallet self-heal on login
Replaces the fleet-wide hardcoded WALLET_PASSWORD='hellohello' that left wallets
LOCKED after OTA/reboot (auto-unlock used the wrong password fleet-wide).

Forward fix (both init paths unified, validated cargo check + LND REST mechanics
on a scratch wallet):
- Per-node random 256-bit secret in secrets/lnd-wallet-password (0600), mirroring
  secrets/bitcoin-rpc-password. read_wallet_password (no-gen) vs
  ensure_wallet_password (gen at init only).
- container/lnd.rs init AND api/rpc/lnd/wallet.rs seed-derived init both use the
  per-node secret (wallet.rs keeps recoverable derived entropy; password unified).
- Unlock tries [per-node secret, legacy 'hellohello']; single-attempt primitive
  distinguishes invalid-passphrase (fail fast, try next) from not-ready (retry),
  so a wrong password no longer hangs the boot path ~60s.

Migration (candidate-unlock + rotate, best-effort at login):
- change_wallet_password (WalletUnlocker.ChangePassword) + migrate_locked_wallet:
  if LOCKED, try candidates as current pw and ChangePassword onto the per-node
  secret so future boots auto-unlock. Hooked into auth.login (non-blocking) with
  the just-verified password as the candidate.

NOT YET: seed-recovery fallback for wallets where no candidate matches (e.g.
.116/.228) — destructive, needs entropy-source/funds-safety handling; next pass.
NOT shipped: pending end-to-end validation on a real node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:19:56 -04:00
archipelagoandClaude Opus 4.8 a9c4e54023 chore: sync core/Cargo.lock to 1.7.92-alpha (release leftover)
create-release.sh bumps Cargo.toml but not the lock's archipelago version line;
the cargo build regenerates it post-commit. Same as the 1.7.91 leftover — worth
fixing create-release.sh to stage Cargo.lock, tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:42:13 -04:00
archipelagoandClaude Opus 4.8 8c8e4d7a29 test: gate that LND wallet is unlocked after restart (catches fleet-wide lock)
A wrong/locked LND wallet password leaves the wallet LOCKED after every
restart/OTA, breaking all Bitcoin-receive + Lightning ops fleet-wide — and the
harness was blind to it: live-lnd-address-type treats 'wallet locked' as PASS,
os-audit treated lnd-unreachable as WARN, and the archipelago lnd.getinfo RPC
masks a locked wallet (returns all-zero success).

- tests/release/run.sh: new 'live-lnd-unlocked' stage polls LND's unauth
  /v1/state and FAILs if still LOCKED after a 60s grace window.
- tests/lifecycle/os-audit.sh: probe lnd.newaddress (the real receive path,
  which surfaces LND_WALLET_LOCKED) instead of lnd.getinfo; locked = hard FAIL,
  not-installed = WARN.

Proven on .116 (genuinely locked): os-audit now reports
'[FAIL] lnd wallet unlocked (lnd.newaddress) wallet LOCKED'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:36:12 -04:00
archipelagoandClaude Opus 4.8 9d3347463a docs: record v1.7.91 + v1.7.92 published; What's New gate; .116 nginx fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:20:35 -04:00
archipelago d462e44453 chore: release v1.7.92-alpha 2026-06-14 09:09:57 -04:00
archipelagoandClaude Opus 4.8 1af583e1ab docs: add third v1.7.92 changelog bullet (What's New backfill) + sync modal
create-release staging requires >=3 curated release-note bullets. The What's
New restoration is itself user-facing, so it's an honest third note; mirror it
into the modal's v1.7.92 block via sync-whats-new.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:03:18 -04:00
archipelagoandClaude Opus 4.8 2fac63e58c feat(release): gate that Settings 'What's New' modal stays in sync with CHANGELOG
The What's New modal (AccountInfoSection.vue) hardcodes one block per release
and had silently drifted: it sat at v1.7.84 while the fleet shipped through
v1.7.92, so eight releases of notes never reached users in Settings.

- scripts/sync-whats-new.py: renders a modal block from each CHANGELOG version
  that's missing one (curated bullets, dev-process 'Validation…' lines dropped),
  inserts newest-first; never touches older hand-written pre-CHANGELOG history.
  --check mode lists anything missing and exits non-zero.
- tests/release/run.sh: new 'whats-new-sync' static gate runs --check, so a
  release with an un-surfaced CHANGELOG entry fails before shipping.
- Backfilled the eight missing blocks (v1.7.85 … v1.7.92) into the modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:31:43 -04:00
archipelagoandClaude Opus 4.8 2999ab62ea docs: changelog for v1.7.92-alpha
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:04:13 -04:00
archipelagoandClaude Opus 4.8 5b052372b7 test(resilience): gate host-reboot batch on os-audit (L3 per-boot health)
batch_host_reboot previously asserted only container-set equality after the
reboot. Add the os-audit.sh per-boot health gate: after rpc_login succeeds
post-reboot, run os-audit against the target (ARCHY_LOCAL=0, https) and record
host_reboot_osaudit PASS/FAIL. This asserts the node is actually healthy after
a reboot — RPC up, OTA not wedged (FM12), every app reachable with valid launch
metadata, FM-guards green — not just that the right containers exist. Validated
green on .116 (11 pass / 0 fail / 0 warn).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:01:30 -04:00
archipelagoandClaude Opus 4.8 4232424b23 fix(ui): suppress app-unreachable overlay while ElectrumX sync screen shows
When ElectrumX is still building its index (or waiting on the Bitcoin node),
AppSessionFrame shows a sync 'pre UI'. The iframe-blocked fallback ('App not
reachable / retrying') was not gated on electrsSync, so it painted over the
sync screen and read as a hard connection error. Gate it on !electrsSync,
mirroring the iframe's own guard.

Also harden the lifecycle health probe: container_health used jq '// "unknown"',
which only catches null/false — an empty-string health (a brief window under
load) rendered as a blank 'bad health: X is '. Map empty to 'unknown' so the
retry loop keeps waiting instead of failing on a transient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 07:58:24 -04:00
archipelagoandClaude Opus 4.8 60fe761def chore: sync core/Cargo.lock to 1.7.91-alpha (release leftover)
create-release.sh bumps Cargo.toml; the lock's archipelago version line is
regenerated by the subsequent cargo build and was left uncommitted after the
v1.7.91-alpha release commit. The shipped binary is built from the bumped
Cargo.toml, so this is bookkeeping only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 07:58:03 -04:00
archipelago 9b9fa9cdee chore: release v1.7.91-alpha 2026-06-14 05:32:38 -04:00
archipelagoandClaude Opus 4.8 329e7811eb test(lifecycle): add os-audit OS-wide health gate; docs: v1.7.91 resume notes
os-audit.sh: one non-destructive scorecard tying backend/RPC health, the
all-apps lifecycle audit (delegates to remote-lifecycle.sh), and the FM-guards
(port-drift, secret-completeness, orphan-container sweep, OTA-wedge). The
per-boot building block for the reboot-survival loop. FM12 check uses jq has()
not // (// treats a legit false as empty). Section A validated all-PASS on .116.

docs: v1.7.91 release-pass resume notes + the bitcoinReceive blocker writeup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 04:36:06 -04:00
archipelagoandClaude Opus 4.8 21aaacc8b4 fix(ui): guard receive-code index access — unblocks v1.7.91 frontend build
codeMatch[1] is string|undefined under noUncheckedIndexedAccess; using it
directly as an index into RECEIVE_CODE_MESSAGES failed vue-tsc (TS2538) and
aborted create-release.sh at the frontend build step. Bind to a const and
narrow before indexing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 04:35:21 -04:00
archipelagoandClaude Opus 4.8 ab85827187 docs: changelog for v1.7.91-alpha
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:59:49 -04:00
archipelagoandClaude Opus 4.8 bea745047d docs: record F1 live validation on .116 (green)
Before/after on the live node confirms the launch_url_port fix:
jellyfin/btcpay/fedimint/gitea/portainer/botfights all went from
lan_address=None to a resolved http://localhost:PORT/ URL; harness
focused audit passed, exit 0. Also documents that archipelago.service
restarts are safe on .116 (containers run in the user-1000 slice, a
different cgroup, and survived the restart).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:55:58 -04:00
archipelagoandClaude Opus 4.8 a483fe4baa fix: derive launch port from URL authority, not naive rsplit
reachable_lan_address() parsed the launch port with url.rsplit(':')
which yields "8096/" for manifest interfaces.main URLs that carry a
path (http://localhost:8096/). That fails to parse and silently drops
a perfectly reachable launch URL, so apps like jellyfin, btcpay-server,
fedimint, gitea, nextcloud and portainer showed running with no launch
link in the UI. New launch_url_port() reads digits after the final
colon (mirroring port_from_url in the RPC layer) and tolerates a
trailing path. Adds regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:35:19 -04:00
archipelagoandClaude Opus 4.8 0ed892a412 fix: wallet receive reliability, bitcoin install self-heal, ElectrumX app tile
Fixes three Bitcoin/wallet failures observed across the fleet on v1.7.90-alpha
(all nodes were already on the latest build — these were live bugs, not stale
builds), plus the missing ElectrumX tile, and adds automated coverage so each
can't regress silently.

Receive address (".116 receive fails", ".228 false 'wallet is locked'"):
- LND publishes its REST API on a host port that can drift from the manifest
  (a container created when the mapping was 8080 kept publishing 8080 after the
  manifest moved to 18080). The in-process client connects to the manifest port,
  gets connection-refused, and wallet init fails forever while the container
  looks "Up". Add published-port drift detection to the reconciler
  (container_ports_drifted / host_port_bindings_drifted) that recreates a
  drifted backend even for restart-sensitive apps — a drifted container is
  already broken, so leaving it "untouched" only perpetuates the failure.
- Receive errors now carry a stable [CODE] token (REST_UNREACHABLE, WALLET_LOCKED,
  WALLET_UNINITIALIZED, SYNCING) and always start with "Bitcoin address" so they
  survive the RPC error sanitizer instead of collapsing to the generic
  "Operation failed". The UI maps the code instead of guessing wallet state from
  substrings — so an unreachable REST endpoint is no longer mislabelled "locked".

Bitcoin install (".198 bitcoin gone / reinstall just stops"):
- bitcoin-knots requires the secret bitcoin-rpc-txrelay-rpcauth, which was only
  generated by the tx-relay flow. Nodes that never used tx-relay lacked it, so
  secret resolution hard-failed and the whole Bitcoin stack cascaded. Generate
  it idempotently before bitcoin starts (ensure_app_secrets, reusing
  ensure_txrelay_credentials), and name the missing secret in the error so a
  genuine gap is actionable instead of a bare "IO error".

ElectrumX app tile missing on every node with it installed:
- The catalog generator dropped electrumx because the manifest had no
  interfaces.main block, so the tile had no launch URL and was hidden. Declare
  the companion UI port (50002) in the manifest, regenerate the catalog, and let
  an app with a known launch URL stay launchable while its backend is still
  "starting" (ElectrumX indexes for 10m+).

Test harness:
- New lifecycle bats suites: bitcoin-receive, port-drift, secret-completeness
  (validated live; port-drift catches the real .116 drift).
- Rust unit tests for drift detection, the receive reason-code classifier, and
  the named-missing-secret error; vitest for the UI code mapping.
- create-release.sh now runs tests/release/run.sh and aborts the release on
  failure — previously it ran no tests at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:12:56 -04:00
archipelago bb808df89a chore: release v1.7.90-alpha 2026-06-13 05:05:14 -04:00
archipelagoandClaude Opus 4.8 c800293f1f fix: bitcoin receive, AIUI pointer input, electrs self-heal, OTA timeout
- LND wallet: request correct address type so receive-address generation
  no longer 400s
- AIUI/app session: on-screen pointer can click + type into app content
  (incl. app store search); "open in new tab" opens the phone browser;
  mobile credential modal centered instead of full-height
  (remote-relay.ts, AppSession.vue, AppSessionFrame.vue, AppIconGrid.vue,
  openExternal.ts, WebViewScreen.kt) + remote-relay tests
- health_monitor: electrs auto-recovers from a corrupt index and shows a
  percent/block-height progress screen while reindexing (useElectrsSync.ts)
- update.rs: drop retired tx1138 secondary mirror (one-time migration);
  longer download timeout for slow connections
- CHANGELOG: v1.7.90-alpha notes
- tests/release/run.sh: harness tweaks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 04:49:32 -04:00
archipelago 340b981b79 chore: release v1.7.89-alpha 2026-06-13 01:34:11 -04:00
archipelagoandClaude Opus 4.8 c49e8fcacd fix: harden OTA updates, AIUI desktop gap, LND no-proxy
- update.rs: post-OTA probe falls back to http://127.0.0.1/ on connect
  error (nginx binds :80, not :443) so good updates are no longer rolled
  back; recover stuck update_in_progress; avoid ETXTBSY on running binary
- LND: REST client bypasses proxy, GET newaddress p2wkh, wallet
  readiness/unlock after restart
- Dashboard.vue: chat route back to plain h-full (desktop bottom-gap fix)
- vite.config.ts: dev-only /aiui proxy
- tests/release/run.sh: release gate harness (static+frontend+backend)
- CHANGELOG: v1.7.89-alpha notes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:23:32 -04:00
archipelago 495b90782a fix: restore AIUI mobile layout 2026-06-12 06:01:24 -04:00
archipelago 0cfb4dc81c chore: release v1.7.88-alpha 2026-06-12 05:12:52 -04:00
archipelago b8ac68d844 fix: restore aiui and bitcoin receive before release 2026-06-12 05:10:03 -04:00
archipelago eaf13effd5 fix: restore fast AIUI launch 2026-06-12 05:04:42 -04:00
archipelago 0339268c43 chore: sync cargo lock for v1.7.87-alpha 2026-06-12 04:55:09 -04:00
archipelago 6fd1cf9ba7 chore: release v1.7.87-alpha 2026-06-12 04:49:58 -04:00
archipelago 8d4b309753 fix: patch bitcoin receive and full-screen launch overlays 2026-06-12 04:42:23 -04:00
archipelago b11c6c17d1 chore: release v1.7.86-alpha 2026-06-12 04:21:18 -04:00
archipelago e474a2b4c9 chore: sync generated release artifacts 2026-06-12 03:15:24 -04:00
archipelago 00c32688f8 chore: release v1.7.85-alpha 2026-06-12 03:14:59 -04:00
archipelago d6f108d818 chore: snapshot release workspace 2026-06-12 03:00:15 -04:00
archipelago 6a30ff11bd chore: release v1.7.84-alpha 2026-06-11 04:44:58 -04:00
145 changed files with 5621 additions and 4090 deletions
@@ -137,7 +137,11 @@ fun WebViewScreen(
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
)
).apply {
// Required when launching from a non-Activity/binder
// thread (the JS bridge below runs off the UI thread).
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (_: Exception) {}
}
@@ -169,8 +173,29 @@ fun WebViewScreen(
allowContentAccess = true
allowFileAccess = false
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
// Let JS open windows without a synchronous user-gesture
// chain; without this, window.open() from a Vue click
// handler silently no-ops and "Open in new tab" dies.
javaScriptCanOpenWindowsAutomatically = true
}
// Deterministic bridge for "open in the phone's browser".
// The web UI calls window.ArchipelagoNative.openExternal(url)
// when present (companion app), falling back to window.open
// in a plain mobile browser. This avoids relying on the
// window.open → onCreateWindow path, which noopener/noreferrer
// can suppress in the WebView.
val webViewRef = this
addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun openExternal(url: String) {
webViewRef.post { openExternalUrl(url) }
}
},
"ArchipelagoNative",
)
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
isLoading = true
+91
View File
@@ -1,5 +1,96 @@
# Changelog
## v1.7.95-alpha (2026-06-15)
- Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic "Operation failed" message because the request for their file list wasn't permitted over the mesh and came back as "not found" — and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up.
- Nodes you remove from your federation now stay removed. Previously a deleted node could quietly come back the next time you synced with another node that still listed it. Removed nodes are now remembered as removed and won't reappear on their own — only if you add them back yourself.
- The app credentials pop-up now appears as a normal centred box with a dimmed background over the whole screen, instead of stretching to fill the entire screen.
## v1.7.94-alpha (2026-06-15)
- Your node now joins the private encrypted mesh network on its own. A wrong built-in setting meant nodes were quietly never reaching the shared mesh meeting point, so everything between nodes fell back to the slower Tor network. Every node now connects to the mesh automatically on startup, so node-to-node features like file sharing use the faster encrypted mesh first and only fall back to Tor when a peer is genuinely offline. (Confirmed live: a node with its mesh setting wiped re-connected to the mesh by itself within a second of starting.)
- You can now bring the mesh networking software up to the latest stable version straight from the node, with one action — it fetches the new version, checks it's genuine before installing, and restarts the mesh on its own. (Confirmed live end to end: a node on an older build was upgraded to the current stable release and rejoined the mesh automatically.)
- The Lightning wallet screen connects again on nodes where it was showing a "failed to fetch" error instead of your balance and channels. The wallet app and the node now talk to each other correctly, and the connection quietly repairs itself if its details drift after a restart.
## v1.7.93-alpha (2026-06-14)
- Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so "generate a receive address" kept failing with a "wallet is locked" message that nothing could clear. The node now detects this and repairs itself automatically.
- Each node now secures its Lightning wallet with its own unique, randomly generated password instead of a shared built-in one, and remembers it safely so the wallet unlocks on its own after every restart or update — no more getting stuck locked.
- If a wallet is found locked with an unrecoverable password, the node rebuilds it cleanly so Bitcoin and Lightning start working again. (On these early-access nodes the wallet holds no funds, so nothing is lost — a wallet locked with an unknown password was already inaccessible.)
- The self-repair was validated end to end on live nodes: a stuck, locked wallet was detected, rebuilt, and came back unlocked on its own, and stayed unlocked across restarts.
## v1.7.92-alpha (2026-06-14)
- The Electrum server app no longer flashes a "can't connect, try again" error over its loading screen while it's still catching up. If ElectrumX is building its index or waiting on the Bitcoin node, you now just see the sync progress, and the app opens on its own once it's ready.
- Behind the scenes, the reboot-survival test now confirms the whole system is genuinely healthy after a restart — every app reachable, updates not stuck, core services answering — instead of only checking that containers came back, so update-related problems are caught before shipping.
- Settings → What's New now lists the notes for every recent release again. The screen had quietly fallen several versions behind, so the last eight releases of changes weren't showing up there — they're all back now, and a release check keeps it from drifting again.
## v1.7.91-alpha (2026-06-14)
- Apps you've installed now reliably show their "Open" button again. Some apps — including Jellyfin, BTCPay Server, Fedimint, Gitea and Portainer — were running fine but their launch link sometimes went missing, so there was no way to open them from the home screen. They now open correctly.
- Receiving Bitcoin is more dependable: if the wallet's internal connection details drift after a restart, it now repairs them on its own, and any error it does hit is reported clearly instead of as a generic failure or a misleading "wallet locked" message.
- Installing Bitcoin now sets itself up correctly without manual help — a security credential that could previously be missing and stop Bitcoin from starting is created automatically before it launches.
- The Electrum server app is back on the home screen and can be launched again.
- Behind the scenes, the release now runs an expanded automated test suite before shipping, so these kinds of issues are caught earlier.
## v1.7.90-alpha (2026-06-13)
- Generating a Bitcoin receive address works again — the wallet now requests the correct address type, fixing the "400 Bad Request" error when creating an address.
- In the companion app, the on-screen pointer can now click into apps and type — including the app store search box — instead of clicks and keystrokes not reaching app content.
- "Open in a new tab" from the companion app now opens the app in your phone's browser, instead of doing nothing. The normal mobile browser keeps working as before.
- The login/credentials pop-up on phones is once again a centered, properly sized window rather than stretching the full height of the screen.
- The Electrum server now recovers on its own if its index ever gets corrupted, and shows a clear progress screen (with percent complete and block height) while it builds its index, instead of a blank or broken page.
- Software updates are more reliable on slow internet connections — downloads are given much more time to finish before giving up.
## v1.7.89-alpha (2026-06-12)
- The AI assistant looks the way it always did again: no extra back button or close button on phones, and the desktop view fills the whole screen without a gap at the bottom.
- System updates are much more reliable: updates that previously got stuck partway or failed to install now complete cleanly, and a failed update can no longer block all future updates.
- After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone.
- Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way.
- The Lightning wallet now recovers and unlocks itself properly after restarts.
## v1.7.88-alpha (2026-06-12)
- AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression.
- Bitcoin receive now uses LND's GET-based newaddress flow with the native SegWit address type, fixing the `501 Method Not Allowed` response from the previous POST attempt.
- Validation pending on the AIUI rollback; the rest of the release train remains unchanged.
## v1.7.87-alpha (2026-06-12)
- Bitcoin receive now calls LND's on-chain address endpoint with the correct REST method, and backend failures keep the specific address-generation error instead of collapsing into the generic operation-failed message.
- App launch credential interstitials now render as true full-screen overlays, and the launcher loading indicator uses the neutral brand palette instead of a blue spinner.
- Validation passed with `git diff --check`, `npm run type-check`, and the focused frontend tests for `bitcoinReceive` and `AppIconGrid`.
## v1.7.86-alpha (2026-06-12)
- Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans.
- Connected nodes and identities now reuse their last loaded data instead of reloading the visible list every time the user revisits the tab.
- The Fleet matrix and detail views now show actual node names and host information instead of raw node id prefixes.
- The network map only redraws when its graph data actually changes, which stops the D3 scene from visually resetting on every refresh tick.
- Mobile federation and system-update actions now stack full width, and the ElectrumX app health check allows a long startup window so slow sync nodes do not restart mid-index.
- Validation passed with `git diff --check`, focused frontend tests, and `npm run type-check`.
## v1.7.85-alpha (2026-06-12)
- ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.
- Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.
- LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.
- Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.
- Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.
- Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting.
## v1.7.84-alpha (2026-06-11)
- Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist.
- Kiosk mode now includes a browser safe-area path for HDMI displays that crop edges, and self-update refreshes kiosk launcher/systemd files so display fixes ship to existing nodes. The experimental X11 scaling safe-area is opt-in to avoid stretching TV output.
- Wi-Fi setup now reports scan errors instead of showing an empty network list, supports retrying scans from the modal, parses escaped `nmcli` SSIDs correctly, and can join open networks without forcing a WPA password.
- Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and transaction broadcast whitelist.
- The restricted Bitcoin relay whitelist now includes `submitpackage` and `gettxout`, covering newer wallet/package-relay broadcast flows without opening wallet/admin RPC.
- The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates.
- Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state.
- Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path.
## v1.7.83-alpha (2026-06-11)
- App launch metadata now derives more consistently from app manifests, with typed launch interfaces and catalog generation updates that keep packaged apps aligned with their runtime ports and launch surfaces.
+1 -1
View File
@@ -122,7 +122,7 @@ echo ""
# Install custom app dependencies
echo "Installing custom app dependencies..."
for app in did-wallet endurain morphos-server router web5-dwn; do
for app in did-wallet endurain morphos-server router; do
if [ -d "apps/$app" ]; then
echo " - Installing $app dependencies..."
cd "apps/$app"
+2 -2
View File
@@ -20,8 +20,8 @@
- **Mempool** block explorer and fee estimator
- **Fedimint** federation guardian and gateway
### Self-Hosted Apps (30)
Bitcoin (ThunderHub), Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
### Self-Hosted Apps (29)
Bitcoin, Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
### Decentralized Identity
- Ed25519 node identity with DID Documents (did:key)
+1 -1
View File
@@ -425,7 +425,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:latest",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
+1 -2
View File
@@ -8,7 +8,6 @@
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
| btcpay-server | 23000 (HTTP) | v1.13.5 |
| thunderhub | 3010 (HTTP) | v0.13.31 |
| mempool | 4080 (HTTP) | v2.5.0 |
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
@@ -43,7 +42,7 @@ cd apps
./build.sh <app-id> # Build specific app
```
Custom apps with local source: `router`, `did-wallet`, `web5-dwn`. All other apps use official container images.
Custom apps with local source: `router`, `did-wallet`. All other apps use official container images.
## App Structure
-2
View File
@@ -24,7 +24,6 @@ This document lists all port assignments for Archipelago apps.
| strfry | 8082 | TCP | HTTP/WebSocket | 18082 |
| did-wallet | 8083 | TCP | Web UI | 18083 |
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
| web5-dwn | 3000 | TCP | HTTP API | 13000 |
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
## Development Ports (Offset: +10000)
@@ -53,7 +52,6 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
| Strfry | http://localhost:18082 |
| DID Wallet | http://localhost:18083 |
| Router | http://localhost:18084 |
| Web5 DWN | http://localhost:13000 |
| Meshtastic | http://localhost:14403 |
## Port Conflict Resolution
+2 -4
View File
@@ -30,14 +30,13 @@ cd apps
./build.sh
```
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet, web5-dwn) will be built from source.
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet) will be built from source.
### Build Specific App
```bash
./build.sh router
./build.sh did-wallet
./build.sh web5-dwn
```
## Running Apps via Archipelago
@@ -64,7 +63,6 @@ In development mode, apps are accessible on offset ports:
- **Router**: http://localhost:18084
- **DID Wallet**: http://localhost:18083
- **Web5 DWN**: http://localhost:13000
- **Nostr RS Relay**: http://localhost:18081
- **Strfry**: http://localhost:18082
@@ -72,7 +70,7 @@ See [PORTS.md](./PORTS.md) for complete port mapping.
## Development Workflow
### For Custom Apps (router, did-wallet, web5-dwn)
### For Custom Apps (router, did-wallet)
1. **Make changes** to source code in `apps/<app-id>/src/`
2. **Rebuild** the container:
-2
View File
@@ -8,7 +8,6 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
- **bitcoin-knots** — Full Bitcoin node (v28.1)
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
- **btcpay-server** — Payment processor (v1.13.5)
- **thunderhub** — Lightning management UI (v0.13.31)
- **mempool** — Block explorer and fee estimator (v2.5.0)
- **electrumx** — Electrum server
- **fedimint** — Federated Bitcoin minting (v0.10.0)
@@ -18,7 +17,6 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
- **nostrudel** — Nostr web client (v0.40.0)
### Web5 & Identity
- **web5-dwn** — Decentralized Web Node (v0.4.0)
- **did-wallet** — Web5 DID Wallet
### Self-Hosted Services
+14 -3
View File
@@ -26,10 +26,19 @@ app:
echo "bitcoind not found in image" >&2;
exit 127;
fi;
if [ "${DISK_GB:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -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 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
RPC_USER="$(printenv BITCOIN_RPC_USER)";
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
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
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 -noconf -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 -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -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 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -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 -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
fi
derived_env:
- key: DISK_GB
@@ -37,6 +46,8 @@ app:
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
secret_file: bitcoin-rpc-txrelay-rpcauth
data_uid: "100101:100101"
dependencies:
+1 -1
View File
@@ -33,7 +33,7 @@ app:
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblockheader,getrawtransaction,decoderawtransaction,decodescript,estimatesmartfee";
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 -noconf -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 -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
-3
View File
@@ -10,8 +10,6 @@ app:
pull_policy: if-not-present
dependencies:
- app_id: web5-dwn
version: ">=1.0.0"
- storage: 2Gi
resources:
@@ -40,7 +38,6 @@ app:
options: [rw]
environment:
- DWN_ENDPOINT=http://web5-dwn:3000
- WALLET_STORAGE=/app/wallet
health_check:
-1
View File
@@ -34,5 +34,4 @@ app.post('/api/wallet/did/create', async (req, res) => {
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`DID Wallet listening on port ${port}`);
console.log(`DWN endpoint: ${process.env.DWN_ENDPOINT || 'http://web5-dwn:3000'}`);
});
+17 -2
View File
@@ -25,7 +25,7 @@ app:
resources:
cpu_limit: 0
memory_limit: 4Gi
memory_limit: 6Gi
disk_limit: 50Gi
security:
@@ -48,15 +48,30 @@ app:
- COIN=Bitcoin
- DB_DIRECTORY=/data
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
- CACHE_MB=3072
- CACHE_MB=1024
- MAX_SEND=10000000
# The ElectrumX dashboard tile is served by the host-networked companion UI
# (archy-electrs-ui) on port 50002, NOT by this container. Declaring it here
# lets the catalog generator emit electrumx -> 50002 into GENERATED_APP_PORTS
# so the tile resolves a launch URL without relying on the hand-maintained
# override in appSessionConfig.ts (which the generator can clobber). The
# backend only validates this block — it does not proxy/health-check it.
interfaces:
main:
name: Web UI
description: ElectrumX server status and connection details
type: ui
port: 50002
protocol: http
health_check:
type: tcp
endpoint: localhost:50001
interval: 30s
timeout: 5s
retries: 3
start_period: 10m
bitcoin_integration:
rpc_access: read-only
+42
View File
@@ -0,0 +1,42 @@
app:
id: fips-ui
name: FIPS Mesh
version: 1.0.0
description: |
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
inside a container with host networking, serves a static dashboard on
:8336, and reverse-proxies /rpc/v1 to the archipelago backend on
127.0.0.1:5678. All FIPS controls (status, seed anchors, reconnect,
restart, and stable-channel daemon updates) go through the existing
fips.* RPC methods, authenticated by the browser's own archipelago
session — there is no separate secret to manage.
container:
build:
context: /opt/archipelago/docker/fips-ui
dockerfile: Dockerfile
tag: localhost/fips-ui:local
resources:
memory_limit: 128Mi
security:
readonly_root: false
network_policy: host
# Host networking: nginx listens on 8336 directly on the host IP and
# proxies to 127.0.0.1:5678 (the archipelago RPC). `ports:` is
# intentionally empty because host networking bypasses port mapping.
ports: []
volumes: []
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:8336
path: /
interval: 30s
timeout: 5s
retries: 3
+1 -1
View File
@@ -6,7 +6,7 @@ app:
category: development
container:
image: 146.59.87.168:3000/lfg2025/portainer:latest
image: 146.59.87.168:3000/lfg2025/portainer:2.19.4
pull_policy: if-not-present
data_uid: "1000:1000"
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md
-38
View File
@@ -1,38 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/data && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 3000
ENV DWN_STORAGE_PATH=/app/data
ENV DID_METHOD=key
CMD ["node", "dist/index.js"]
-35
View File
@@ -1,35 +0,0 @@
# Web5 DWN (Decentralized Web Node)
Personal data store for Web5. Store and sync your decentralized data across devices.
## Building
```bash
# From the apps directory
./build.sh web5-dwn
# Or manually
cd web5-dwn
docker build -t archipelago/web5-dwn:latest .
```
## Development
```bash
cd web5-dwn
npm install
npm run dev
```
## Ports
- **3000**: HTTP API (dev: 13000)
## Running Locally
```bash
docker run -p 3000:3000 \
-v /tmp/archipelago-dev/web5-dwn:/app/data \
-e DWN_STORAGE_PATH=/app/data \
archipelago/web5-dwn:latest
```
-55
View File
@@ -1,55 +0,0 @@
app:
id: web5-dwn
name: Decentralized Web Node
version: 1.0.0
description: Personal data store for Web5. Store and sync your decentralized data across devices.
container:
image: archipelago/web5-dwn:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 5Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: web5-dwn
ports:
- host: 3000
container: 3000
protocol: tcp # HTTP API
volumes:
- type: bind
source: /var/lib/archipelago/web5-dwn
target: /app/data
options: [rw]
environment:
- DWN_STORAGE_PATH=/app/data
- DID_METHOD=key
health_check:
type: http
endpoint: http://localhost:3000
path: /health
interval: 30s
timeout: 5s
retries: 3
web5_integration:
did_support: true
dwn_protocol: true
sync_enabled: true
-2747
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
{
"name": "web5-dwn",
"version": "1.0.0",
"description": "Decentralized Web Node for Web5",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2",
"@web5/api": "^0.9.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}
-34
View File
@@ -1,34 +0,0 @@
import express from 'express';
const app = express();
const port = 3000;
// Middleware
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'web5-dwn' });
});
// DWN API endpoints
app.post('/dwn', async (req, res) => {
// Placeholder for DWN protocol implementation
res.json({
status: 'ok',
message: 'DWN protocol endpoint (placeholder)'
});
});
app.get('/dwn', async (req, res) => {
res.json({
status: 'ok',
message: 'DWN query endpoint (placeholder)'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`Web5 DWN listening on port ${port}`);
console.log(`Storage path: ${process.env.DWN_STORAGE_PATH || '/app/data'}`);
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.83-alpha"
version = "1.7.94-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.83-alpha"
version = "1.7.95-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+46 -5
View File
@@ -256,6 +256,45 @@ impl ApiHandler {
}
}
/// CORS origin to echo for same-node app → backend calls (e.g. the LND
/// wallet UI, served on its own APP_PORTS port). Such apps share the node's
/// host but use a different port, so the strict allowlist (`host_ip`, no
/// port) rejects them and the browser gets no `Access-Control-Allow-Origin`
/// header ("blocked by CORS policy"). Reflect the Origin when its host
/// matches the request's own `Host` header — i.e. the app lives on the same
/// address the node is being reached by, which transparently covers the LAN
/// IP, the Tailscale IP, localhost, and the `.onion` address without needing
/// to enumerate them. Auth is still enforced by the session cookie; this
/// only authorizes the browser to *read* the reply. Returns "" (no echoed
/// origin) when there is no match.
fn app_cors_origin(&self, headers: &hyper::HeaderMap) -> String {
if let Some(origin) = self.validate_origin(headers) {
return origin;
}
let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) else {
return String::new();
};
// host portion (no scheme, no port) of an `scheme://host[:port]` value
let host_of = |s: &str| -> Option<String> {
let after_scheme = s.split_once("://").map(|(_, r)| r).unwrap_or(s);
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
let host = host_port
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(host_port);
(!host.is_empty()).then(|| host.to_string())
};
let origin_host = host_of(origin);
let req_host = headers
.get(hyper::header::HOST)
.and_then(|v| v.to_str().ok())
.and_then(host_of);
match (origin_host, req_host) {
(Some(o), Some(r)) if o == r => origin.to_string(),
_ => String::new(),
}
}
pub async fn handle_request(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
let path = req.uri().path().to_string();
let method = req.method().clone();
@@ -265,9 +304,10 @@ impl ApiHandler {
let mut builder = Response::builder()
.status(StatusCode::NO_CONTENT)
.header("Vary", "Origin");
if let Some(origin) = self.validate_origin(req.headers()) {
let preflight_origin = self.app_cors_origin(req.headers());
if !preflight_origin.is_empty() {
builder = builder
.header("Access-Control-Allow-Origin", &origin)
.header("Access-Control-Allow-Origin", &preflight_origin)
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
.header("Access-Control-Allow-Credentials", "true");
@@ -448,7 +488,8 @@ impl ApiHandler {
// No backend auth check here because the LND UI iframe fetches this
// endpoint and the session cookie flow is validated at the nginx layer.
(Method::GET, "/lnd-connect-info") => {
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
let origin = self.app_cors_origin(&headers);
Self::handle_lnd_connect_info(self.rpc_handler.clone(), &origin).await
}
// Container logs — requires session
@@ -465,8 +506,8 @@ impl ApiHandler {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
let origin = self.validate_origin(&headers).unwrap_or_default();
Self::handle_lnd_proxy(path, &origin).await
let origin = self.app_cors_origin(&headers);
Self::handle_lnd_proxy(self.rpc_handler.clone(), path, &origin).await
}
// DWN health — unauthenticated
+41 -13
View File
@@ -99,33 +99,61 @@ impl ApiHandler {
pub(super) async fn handle_lnd_connect_info(
rpc: std::sync::Arc<super::super::rpc::RpcHandler>,
cors_origin: &str,
) -> Result<Response<hyper::Body>> {
// The LND wallet UI is served on its own APP_PORTS origin and fetches
// this cross-origin, so it needs the CORS headers echoed back.
let cors = |builder: hyper::http::response::Builder| {
builder
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
};
match rpc.handle_lnd_connect_info().await {
Ok(val) => {
let body = serde_json::to_vec(&val).unwrap_or_default();
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(body),
))
Ok(cors(
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json"),
)
.body(hyper::Body::from(body))
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
}
Err(e) => Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json")
.body(hyper::Body::from(
serde_json::json!({"error": e.to_string()}).to_string(),
))
.unwrap()),
Err(e) => Ok(cors(
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json"),
)
.body(hyper::Body::from(
serde_json::json!({"error": e.to_string()}).to_string(),
))
.unwrap()),
}
}
pub(super) async fn handle_lnd_proxy(
rpc: Arc<RpcHandler>,
path: &str,
cors_origin: &str,
) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
let url = format!("{LND_REST_BASE_URL}{suffix}");
match reqwest::get(&url).await {
// LND REST serves a self-signed cert and requires the admin macaroon.
// A bare reqwest::get() uses the default client, which rejects the
// self-signed cert (TLS verify error -> 502 "failing to fetch") and
// sends no macaroon. Use the shared authenticated client instead — the
// same one lnd.getinfo and the wallet RPCs use.
let request = match rpc.lnd_client().await {
Ok((client, macaroon_hex)) => client
.get(&url)
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.map_err(anyhow::Error::from),
Err(e) => Err(e),
};
match request {
Ok(resp) => {
let status = resp.status().as_u16();
let headers = resp.headers().clone();
+1 -1
View File
@@ -1,4 +1,4 @@
mod handler;
mod rpc;
pub(crate) mod rpc;
pub use handler::ApiHandler;
+24
View File
@@ -227,6 +227,9 @@ impl RpcHandler {
let report = serde_json::json!({
"node_id": node_id,
"node_name": data.server_info.name.clone().filter(|n| !n.trim().is_empty()),
"hostname": system_hostname().await,
"server_url": local_server_url(&self.config.host_ip),
"version": data.server_info.version,
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
@@ -507,3 +510,24 @@ impl RpcHandler {
}))
}
}
async fn system_hostname() -> Option<String> {
let output = tokio::process::Command::new("hostname")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!hostname.is_empty()).then_some(hostname)
}
fn local_server_url(host_ip: &str) -> Option<String> {
let host_ip = host_ip.trim();
if host_ip.is_empty() || host_ip == "127.0.0.1" {
None
} else {
Some(format!("https://{host_ip}"))
}
}
+13
View File
@@ -33,6 +33,19 @@ impl RpcHandler {
tracing::info!("[onboarding] login successful");
// Best-effort: heal a LOCKED LND wallet created with an unknown/legacy
// password by rotating it onto the per-node secret, using the password
// the user just authenticated with as a candidate. Non-blocking so login
// is never slowed or broken when LND isn't installed / already unlocked.
let candidate = password.to_string();
tokio::spawn(async move {
match crate::container::lnd::migrate_locked_wallet(&[candidate]).await {
Ok(true) => tracing::info!("[login] LND wallet healed / auto-unlocked"),
Ok(false) => {} // not locked, or seed-recovery required
Err(e) => tracing::debug!("[login] LND wallet migration skipped: {e}"),
}
});
// Ensure NostrVPN config exists — covers the case where onboardingComplete
// was never called (e.g., user took the "already set up" shortcut).
let data_dir = self.config.data_dir.clone();
+76 -7
View File
@@ -3,6 +3,7 @@ use crate::container::docker_packages;
use crate::data_model::{Notification, NotificationLevel};
use crate::{bitcoin_status, identity, peers};
use anyhow::{Context, Result};
use archipelago_container::ContainerState;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use hmac::{Hmac, Mac};
use rand::RngCore;
@@ -106,7 +107,7 @@ struct TrustedRelayPeer {
}
#[derive(Debug, Clone)]
struct TxRelayCredentials {
pub(crate) struct TxRelayCredentials {
username: String,
password: String,
}
@@ -164,7 +165,11 @@ impl RpcHandler {
update_endpoint(&params, "tor_endpoint", &mut state.settings.tor_endpoint)?;
if state.settings.enabled_for_peers {
let credentials_were_ready = txrelay_credentials_available(&self.config.data_dir).await;
ensure_txrelay_credentials(&self.config.data_dir).await?;
if !credentials_were_ready {
self.restart_bitcoin_backends_for_txrelay().await;
}
}
if params.get("selected_peer_pubkey").is_some() {
@@ -354,7 +359,11 @@ impl RpcHandler {
);
}
let credentials = if status == RelayRequestStatus::Approved {
Some(ensure_txrelay_credentials(&self.config.data_dir).await?)
let credentials = ensure_txrelay_credentials(&self.config.data_dir).await?;
if request_direction == RelayRequestDirection::Incoming {
self.restart_bitcoin_backends_for_txrelay().await;
}
Some(credentials)
} else {
None
};
@@ -476,6 +485,34 @@ impl RpcHandler {
}
self.state_manager.update_data(data).await;
}
async fn restart_bitcoin_backends_for_txrelay(&self) {
let Some(orchestrator) = self.orchestrator.as_ref().cloned() else {
tracing::debug!("Skipping txrelay backend restart; orchestrator unavailable");
return;
};
tokio::spawn(async move {
for app_id in ["bitcoin-knots", "bitcoin-core"] {
let Ok(status) = orchestrator.status(app_id).await else {
continue;
};
if status.state != ContainerState::Running {
continue;
}
match orchestrator.restart(app_id).await {
Ok(()) => tracing::info!(
app_id,
"Restarted Bitcoin backend to load txrelay RPC credentials"
),
Err(e) => tracing::warn!(
app_id,
error = %e,
"Failed to restart Bitcoin backend after txrelay credential update"
),
}
}
});
}
}
pub(crate) async fn record_incoming_relay_message(
@@ -588,22 +625,36 @@ fn trusted_relay_peers(
}
async fn txrelay_credential_status(data_dir: &Path) -> serde_json::Value {
let credentials_available = txrelay_credentials_available(data_dir).await;
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
let password_available = fs::metadata(&password_path).await.is_ok();
let rpcauth_available = fs::metadata(&rpcauth_path).await.is_ok();
let client_env_available = fs::metadata(&client_env_path).await.is_ok();
json!({
"username": TXRELAY_USER,
"available": password_available && rpcauth_available && client_env_available,
"available": credentials_available,
"password_available": password_available,
"rpcauth_available": rpcauth_available,
"client_env_available": client_env_available,
"client_env_path": client_env_path.display().to_string(),
"restart_hint": "If this was just generated, restart Bitcoin Core/Knots so bitcoind loads the txrelay rpcauth whitelist.",
"restart_hint": "Archipelago restarts the active Bitcoin backend after generating txrelay credentials so bitcoind loads the restricted rpcauth whitelist.",
})
}
async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
async fn txrelay_credentials_available(data_dir: &Path) -> bool {
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
fs::metadata(&password_path).await.is_ok()
&& fs::metadata(&rpcauth_path).await.is_ok()
&& fs::metadata(&client_env_path).await.is_ok()
}
/// Idempotently ensure the tx-relay credential trio exists in the secrets dir:
/// the random password, its derived `rpcauth` line, and the client env file.
/// Bitcoin backend manifests reference `bitcoin-rpc-txrelay-rpcauth` as a
/// required `secret_env`, so this must run before bitcoind starts — otherwise
/// secret resolution hard-fails and the whole Bitcoin stack cascades (the .198
/// failure). Safe to call repeatedly; it only writes what's missing or stale.
pub(crate) async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
let password = match read_trimmed(&password_path).await {
Some(value) => value,
@@ -614,8 +665,8 @@ async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredential
}
};
let rpcauth = match read_trimmed(&rpcauth_path).await {
Some(value) => value,
None => {
Some(value) if rpcauth_matches_password(&value, TXRELAY_USER, &password) => value,
_ => {
let generated = generate_rpcauth(TXRELAY_USER, &password);
write_secret_file(&rpcauth_path, &generated).await?;
generated
@@ -684,6 +735,24 @@ fn generate_rpcauth(username: &str, password: &str) -> String {
format!("{username}:{salt_hex}${hash_hex}")
}
fn rpcauth_matches_password(rpcauth: &str, username: &str, password: &str) -> bool {
let Some(rest) = rpcauth.strip_prefix(&format!("{username}:")) else {
return false;
};
let Some((salt_hex, expected_hash)) = rest.split_once('$') else {
return false;
};
if salt_hex.is_empty() || expected_hash.is_empty() {
return false;
}
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()) else {
return false;
};
mac.update(password.as_bytes());
let hash_hex = hex::encode(mac.finalize().into_bytes());
hash_hex.eq_ignore_ascii_case(expected_hash)
}
fn preferred_endpoint(settings: &BitcoinRelaySettings) -> Option<String> {
if settings.allow_https {
if let Some(endpoint) = settings.https_endpoint.clone() {
@@ -731,7 +731,6 @@ fn health_probe_url_for_app(app_id: &str) -> Option<String> {
"bitcoin-ui" => 8334,
"botfights" => 9100,
"btcpay-server" | "btcpay" | "btcpayserver" => 23000,
"dwn" => 3100,
"electrumx" | "electrs" | "mempool-electrs" | "electrs-ui" => 50002,
"fedimint" | "fedimintd" => 8175,
"filebrowser" => 8083,
@@ -533,6 +533,19 @@ impl RpcHandler {
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
}
// Respect operator removal: a peer the operator deleted must not
// silently re-join via a stale invite. The tombstone is only cleared
// by an explicit local action (manually adding the node or accepting
// an incoming invite) — not by a remote-triggered join.
if federation::load_removed_dids(&self.config.data_dir)
.await
.unwrap_or_default()
.contains(did)
{
info!(peer_did = %did, "Ignoring peer-joined for a removed (tombstoned) DID");
return Ok(serde_json::json!({ "accepted": false, "removed": true }));
}
let node = FederatedNode {
did: did.to_string(),
pubkey: pubkey.to_string(),
+8 -6
View File
@@ -115,10 +115,12 @@ impl RpcHandler {
} else if !after.key_present {
"no_seed_key"
} else if after.authenticated_peer_count == 0 {
// Daemon is up with a key but hasn't authenticated any
// peers — almost always outbound UDP/8668 dropped by the
// local firewall/router, or the anchor itself being down.
"no_outbound_udp_or_anchor_down"
// Daemon is up with a key but hasn't authenticated any peers —
// almost always the outbound connection to the anchor being
// dropped by the local firewall/router, or the anchor itself
// being down. The public anchor is reached over TCP/8443 (not
// UDP/8668 — that endpoint is dead).
"no_outbound_or_anchor_down"
} else {
"peers_but_no_anchor"
};
@@ -126,8 +128,8 @@ impl RpcHandler {
"connected" => "An anchor is reachable.",
"daemon_down" => "The FIPS daemon didn't come back up — check the FIPS service on this host.",
"no_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step.",
"no_outbound_udp_or_anchor_down" =>
"Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or every configured anchor could be down. Add a reachable peer in Seed Anchors.",
"no_outbound_or_anchor_down" =>
"Daemon is running but no peers handshook. Your router or ISP may be blocking the outbound connection to the mesh anchor (TCP port 8443), or every configured anchor is down. The public anchor is added automatically — if it still won't connect, add another reachable peer in Seed Anchors.",
"peers_but_no_anchor" =>
"Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors.",
_ => "",
+45 -22
View File
@@ -31,7 +31,7 @@ impl RpcHandler {
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?;
.unwrap_or("");
// Validate SSID (prevent command injection)
if ssid.len() > 64 || ssid.contains('\0') {
@@ -284,7 +284,7 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
let networks: Vec<serde_json::Value> = stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(3, ':').collect();
let parts = split_nmcli_escaped(line, 3);
if parts.len() < 3 {
return None;
}
@@ -305,6 +305,28 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
Ok(networks)
}
fn split_nmcli_escaped(line: &str, limit: usize) -> Vec<String> {
let mut fields = Vec::new();
let mut current = String::new();
let mut chars = line.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
if let Some(next) = chars.next() {
current.push(next);
}
} else if ch == ':' && fields.len() + 1 < limit {
fields.push(current);
current = String::new();
} else {
current.push(ch);
}
}
fields.push(current);
fields
}
/// Connect to a WiFi network using nmcli.
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
let conn_name = format!("archipelago-wifi-{ssid}");
@@ -321,27 +343,28 @@ async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
.output()
.await;
let mut args = vec![
"connection",
"add",
"type",
"wifi",
"con-name",
&conn_name,
"ifname",
"*",
"ssid",
ssid,
"ipv4.method",
"auto",
"ipv6.method",
"auto",
];
if !password.is_empty() {
args.extend(["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password]);
}
let output = tokio::process::Command::new("nmcli")
.args([
"connection",
"add",
"type",
"wifi",
"con-name",
&conn_name,
"ifname",
"*",
"ssid",
ssid,
"wifi-sec.key-mgmt",
"wpa-psk",
"wifi-sec.psk",
password,
"ipv4.method",
"auto",
"ipv6.method",
"auto",
])
.args(args)
.output()
.await
.context("Failed to run nmcli wifi profile create")?;
+2
View File
@@ -38,6 +38,7 @@ impl RpcHandler {
let macaroon_hex = hex::encode(&macaroon_bytes);
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(10))
.danger_accept_invalid_certs(true)
.build()
@@ -180,6 +181,7 @@ impl RpcHandler {
let macaroon_hex = hex::encode(&macaroon_bytes);
let client = reqwest::Client::builder()
.no_proxy()
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(10))
.build()
+1
View File
@@ -63,6 +63,7 @@ impl RpcHandler {
let macaroon_bytes = read_lnd_admin_macaroon().await?;
let macaroon_hex = hex::encode(&macaroon_bytes);
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
.danger_accept_invalid_certs(true)
.build()
+206 -10
View File
@@ -9,29 +9,77 @@ use super::LND_REST_BASE_URL;
impl RpcHandler {
/// Generate a new on-chain Bitcoin address.
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
let (client, macaroon_hex) = self.lnd_client().await?;
let (client, macaroon_hex) = self.lnd_client().await.map_err(|e| {
tracing::warn!(error = %format!("{e:#}"), "LND newaddress: client/macaroon unavailable");
receive_error(
RECEIVE_WALLET_UNINITIALIZED,
"The Lightning wallet isn't set up on this node yet. Finish wallet setup, then try again.",
)
})?;
let resp = client
let resp = match client
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
// LND's REST gateway parses `type` as the AddressType enum by its
// proto name (or integer), NOT the lncli aliases. "p2wkh" is not a
// valid enum value and returns 400 "parsing field type"; the native
// SegWit (bech32) variant is WITNESS_PUBKEY_HASH (= 0).
.query(&[("type", "WITNESS_PUBKEY_HASH")])
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?;
{
Ok(resp) => resp,
Err(e) => {
// The .116 case: LND container is up but its REST endpoint isn't
// reachable on the expected port (e.g. published-port drift), so
// the connection is refused. This is NOT a locked wallet — emit a
// distinct code so the UI stops mislabelling it.
tracing::warn!(error = %format!("{e:#}"), "LND newaddress: REST connection failed");
return Err(receive_error(
RECEIVE_REST_UNREACHABLE,
"The Lightning wallet service isn't reachable yet. It may be starting up or recovering — please try again in a moment.",
));
}
};
let body: serde_json::Value = resp
.json()
let status = resp.status();
let raw_body = resp
.text()
.await
.context("Failed to parse newaddress response")?;
.context("Bitcoin address response could not be read")?;
let body: serde_json::Value = serde_json::from_str(&raw_body).unwrap_or_else(|_| {
serde_json::json!({
"raw": raw_body,
})
});
if let Some(error) = body.get("error").and_then(|v| v.as_str()) {
anyhow::bail!("LND could not generate an address: {}", error);
if !status.is_success() {
let message = lnd_error_message(&body);
let code = classify_lnd_address_error(&message);
tracing::warn!(%status, lnd_message = %message, code, "LND newaddress returned an error");
return Err(receive_error(code, default_receive_detail(code)));
}
if let Some(error) = body
.get("error")
.or_else(|| body.get("message"))
.and_then(|v| v.as_str())
{
let code = classify_lnd_address_error(error);
tracing::warn!(lnd_message = %error, code, "LND newaddress returned an error body");
return Err(receive_error(code, default_receive_detail(code)));
}
let address = body
.get("address")
.and_then(|v| v.as_str())
.filter(|addr| !addr.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("LND did not return a Bitcoin address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync."))?
.ok_or_else(|| {
receive_error(
RECEIVE_WALLET_UNINITIALIZED,
"The wallet didn't return an address yet. It may still be unlocking or waiting for Bitcoin to sync — please try again shortly.",
)
})?
.to_string();
Ok(serde_json::json!({ "address": address }))
@@ -504,12 +552,20 @@ impl RpcHandler {
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(entropy);
entropy.zeroize();
// Use the per-node secret as the LND wallet password (NOT the
// caller-supplied one) so the unattended boot path can auto-unlock this
// wallet. The wallet stays recoverable from the Archipelago seed via the
// derived entropy above. This unifies both init paths on one password
// source — the divergence here is what left wallets locked fleet-wide.
let _ = wallet_password; // accepted for API compat; superseded by the per-node secret
let node_wallet_pw = crate::container::lnd::ensure_wallet_password().await?;
let wallet_password_b64 =
base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes());
base64::engine::general_purpose::STANDARD.encode(node_wallet_pw.as_bytes());
// Call LND REST API to initialize wallet with derived entropy.
// LND must be running but NOT yet initialized (no existing wallet).
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(30))
.danger_accept_invalid_certs(true)
.build()
@@ -548,3 +604,143 @@ impl RpcHandler {
}))
}
}
fn lnd_error_message(body: &serde_json::Value) -> String {
body.get("message")
.or_else(|| body.get("error"))
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.unwrap_or("unknown LND error")
.to_string()
}
// Stable, machine-readable reason codes for receive-address failures. They are
// embedded in the error message as a `[CODE]` token so the frontend
// (neode-ui/src/utils/bitcoinReceive.ts) can show an accurate explanation
// instead of guessing wallet state by substring-matching — which is what made
// .228 report "wallet is locked" when LND's REST was merely unreachable.
//
// Every receive error string starts with "Bitcoin address" so it survives the
// RPC error sanitizer (api/rpc/middleware.rs) unchanged rather than being
// flattened to the generic "Operation failed" message (the .116 symptom).
pub(crate) const RECEIVE_REST_UNREACHABLE: &str = "LND_REST_UNREACHABLE";
pub(crate) const RECEIVE_WALLET_LOCKED: &str = "LND_WALLET_LOCKED";
pub(crate) const RECEIVE_WALLET_UNINITIALIZED: &str = "LND_WALLET_UNINITIALIZED";
pub(crate) const RECEIVE_SYNCING: &str = "LND_SYNCING";
pub(crate) const RECEIVE_LND_ERROR: &str = "LND_ERROR";
/// Build a receive-address error carrying a reason code the UI can map.
fn receive_error(code: &str, detail: &str) -> anyhow::Error {
anyhow::anyhow!("Bitcoin address unavailable [{code}]: {detail}")
}
/// A sensible default human message per code (used for non-UI callers and logs;
/// the frontend renders its own copy from the code).
fn default_receive_detail(code: &str) -> &'static str {
match code {
RECEIVE_REST_UNREACHABLE => {
"The Lightning wallet service isn't reachable yet. It may be starting up or recovering — please try again in a moment."
}
RECEIVE_WALLET_LOCKED => {
"The Lightning wallet is locked. Unlock it (or finish wallet setup), then try again."
}
RECEIVE_WALLET_UNINITIALIZED => {
"The Lightning wallet isn't set up yet. Finish wallet setup, then try again."
}
RECEIVE_SYNCING => {
"The wallet is still syncing with the Bitcoin network. Please try again once it has caught up."
}
_ => "Couldn't generate a Bitcoin address right now. Please try again shortly.",
}
}
/// Classify a non-2xx LND error body/message into a reason code. The wording of
/// LND's REST errors is stable enough to bucket: a locked wallet, an
/// uninitialized wallet, a syncing chain, or some other failure.
fn classify_lnd_address_error(message: &str) -> &'static str {
let m = message.to_lowercase();
if m.contains("locked") || m.contains("unlock") {
RECEIVE_WALLET_LOCKED
} else if m.contains("synchroniz")
|| m.contains("syncing")
|| m.contains("not yet ready")
|| m.contains("in the process of starting")
{
RECEIVE_SYNCING
} else if m.contains("wallet not found")
|| m.contains("not exist")
|| m.contains("uninitialized")
|| m.contains("not initialized")
|| m.contains("create a wallet")
|| m.contains("no wallet")
{
RECEIVE_WALLET_UNINITIALIZED
} else {
RECEIVE_LND_ERROR
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lnd_error_message_prefers_message_field() {
let body = serde_json::json!({
"error": "grpc proxy error",
"message": "wallet locked",
});
assert_eq!(lnd_error_message(&body), "wallet locked");
}
#[test]
fn lnd_error_message_falls_back_to_unknown() {
assert_eq!(
lnd_error_message(&serde_json::json!({})),
"unknown LND error"
);
}
#[test]
fn classify_locked_wallet() {
assert_eq!(
classify_lnd_address_error("wallet locked, please unlock"),
RECEIVE_WALLET_LOCKED
);
}
#[test]
fn classify_uninitialized_wallet() {
assert_eq!(
classify_lnd_address_error("wallet not found, create a wallet first"),
RECEIVE_WALLET_UNINITIALIZED
);
}
#[test]
fn classify_syncing() {
assert_eq!(
classify_lnd_address_error("server is still in the process of starting"),
RECEIVE_SYNCING
);
}
#[test]
fn classify_unknown_is_generic_error() {
assert_eq!(
classify_lnd_address_error("some other failure"),
RECEIVE_LND_ERROR
);
}
#[test]
fn receive_error_starts_with_sanitizer_safe_prefix_and_embeds_code() {
// Must start with "Bitcoin address" (survives the RPC error sanitizer)
// and carry the [CODE] token the frontend parses.
let err = receive_error(RECEIVE_REST_UNREACHABLE, "unreachable");
let s = format!("{err}");
assert!(s.starts_with("Bitcoin address"), "got: {s}");
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
}
}
@@ -63,6 +63,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Failed to start",
"Container",
"Image",
"Bitcoin address",
];
for prefix in &user_facing_prefixes {
if msg.starts_with(prefix) {
+9 -27
View File
@@ -32,6 +32,8 @@ fn is_platform_managed_app(app_id: &str) -> bool {
| "fedimint-gateway"
| "indeedhub"
| "immich"
| "fips"
| "fips-ui"
)
}
@@ -312,11 +314,6 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"30s",
"3",
),
"dwn" => (
"curl -sf http://localhost:3000/health || exit 1",
"30s",
"3",
),
"portainer" => return vec![],
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
@@ -360,10 +357,10 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
// floor; ideally this would be host-RAM aware (next pass).
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
// ElectrumX: large cache materially speeds initial history indexing.
// CACHE_MB=3072 below needs container headroom for Python, rocksdb,
// socket buffers, and reorg/indexing spikes.
"electrumx" | "mempool-electrs" | "electrs" => "4g",
// ElectrumX indexing spikes above its cache size due Python,
// RocksDB, socket buffers, and reorg/history work. Keep cache
// conservative and give the process headroom to avoid restart loops.
"electrumx" | "mempool-electrs" | "electrs" => "6g",
"cryptpad" => "512m",
"ollama" => "4g",
// Medium apps
@@ -384,7 +381,6 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
"uptime-kuma" => "256m",
"filebrowser" => "256m",
"searxng" => "512m",
"dwn" => "256m",
"portainer" => "256m",
"nostr-rs-relay" | "nostr-relay" => "256m",
"routstr" => "512m",
@@ -789,11 +785,9 @@ pub(super) async fn get_app_config(
"COIN=Bitcoin".to_string(),
"DB_DIRECTORY=/data".to_string(),
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
// Sync-speed: bigger LRU/write cache during initial
// history index. Default is 1200MB; the container gets
// 4g (config.rs::get_memory_limit) so 3072 fits with
// headroom.
"CACHE_MB=3072".to_string(),
// Keep cache below the container limit; high values
// have caused OOM/restart loops during catch-up.
"CACHE_MB=1024".to_string(),
// Block-fetcher concurrency — defaults are conservative
// for shared hosts; 4 is plenty for one bitcoind backend.
"MAX_SEND=10000000".to_string(),
@@ -1129,18 +1123,6 @@ pub(super) async fn get_app_config(
None,
)
}
"dwn" => (
vec!["3100:3000".to_string()],
vec!["/var/lib/archipelago/dwn:/dwn/data".to_string()],
vec![
"DS_PORT=3000".to_string(),
"DS_MESSAGES_STORE_URI=level://data/messages".to_string(),
"DS_DATA_STORE_URI=level://data/data".to_string(),
"DS_EVENT_LOG_URI=level://data/events".to_string(),
],
None,
None,
),
"botfights" => {
let jwt_secret = read_or_generate_secret("botfights-jwt").await;
(
+4
View File
@@ -133,6 +133,10 @@ impl RpcHandler {
/// Apply git-based update: runs self-update.sh which pulls, builds, and restarts.
pub(super) async fn handle_update_git_apply(&self) -> Result<serde_json::Value> {
if std::env::var("ARCHIPELAGO_GIT_UPDATES").is_err() {
anyhow::bail!("git/self-build updates are disabled; use manifest OTA updates instead");
}
let script = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
)
+27 -1
View File
@@ -41,6 +41,13 @@ const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backe
const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n proxy_pass http://127.0.0.1:5678/bitcoin-status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// Inserted into every server block that lacks the `/proxy/lnd/` proxy. Nodes
/// flashed before 2026-04-10 shipped an nginx config without this block, so the
/// browser's wallet fetches to `/proxy/lnd/*` fell through to the SPA
/// index.html and got HTML back instead of JSON ("failing to fetch"). Kept in
/// sync with the canonical block in image-recipe/configs/nginx-archipelago.conf.
const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles auth + CORS\n location /proxy/lnd/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// Entry point called from main startup. Never returns an error to the caller —
/// failing to bootstrap host artifacts must not prevent the backend from serving.
pub async fn ensure_doctor_installed() {
@@ -520,12 +527,31 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
.with_context(|| format!("read {}", path))?;
let missing_app_catalog = !content.contains("location /api/app-catalog");
let missing_bitcoin_status = !content.contains("location /bitcoin-status");
if !missing_app_catalog && !missing_bitcoin_status {
let missing_lnd_proxy = !content.contains("location /proxy/lnd/");
if !missing_app_catalog && !missing_bitcoin_status && !missing_lnd_proxy {
return Ok(false);
}
let mut patched = content.clone();
if missing_lnd_proxy {
// Prefer the `/lnd-connect-info` anchor (present since 2026-03-17); fall
// back to `/electrs-status` (since 2026-03-08) for even older configs.
// Both appear once per archipelago server block, so the block is added
// to every server block that proxies to the backend.
let anchor = if patched.contains(" location /lnd-connect-info {") {
" location /lnd-connect-info {"
} else {
" location /electrs-status {"
};
if !patched.contains(anchor) {
warn!("nginx conf missing lnd-connect-info/electrs-status anchor — skipping /proxy/lnd patch");
} else {
let replacement = format!("{}{}", NGINX_LND_PROXY_BLOCK, anchor);
patched = patched.replace(anchor, &replacement);
}
}
if missing_bitcoin_status {
let anchor = " location /electrs-status {";
if !patched.contains(anchor) {
@@ -276,7 +276,6 @@ fn get_app_tier(app_id: &str) -> &'static str {
"core"
}
"btcpay" | "btcpay-server" | "btcpayserver" => "core",
"dwn" => "core",
"filebrowser" => "core",
// Recommended: enhanced functionality
"fedimint" | "fedimint-gateway" => "recommended",
@@ -518,13 +517,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/indeedhub/indeedhub".to_string(),
tier: "",
},
"dwn" => AppMetadata {
title: "Decentralized Web Node".to_string(),
description: "Store and sync personal data with DID-based access control".to_string(),
icon: "/assets/img/app-icons/dwn.svg".to_string(),
repo: "https://github.com/TBD54566975/dwn-server".to_string(),
tier: "",
},
"tor" | "archy-tor" => AppMetadata {
title: "Tor".to_string(),
description: "Anonymous overlay network for privacy".to_string(),
@@ -707,7 +699,7 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
if !requires_reachable_launch(app_id) {
return Some(url);
}
let Some(port) = url.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()) else {
let Some(port) = launch_url_port(&url) else {
return None;
};
if launch_port_reachable(port).await {
@@ -718,6 +710,23 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
}
}
/// Extract the TCP port from a launch URL's authority.
///
/// The candidate URL can carry a path when it comes from a manifest
/// `interfaces.main` declaration (e.g. `http://localhost:8096/`). A naive
/// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently
/// drops a reachable launch URL. Reading digits after the final colon mirrors
/// `port_from_url` in the RPC layer and tolerates a trailing path.
fn launch_url_port(url: &str) -> Option<u16> {
let after_colon = url.rsplit_once(':')?.1;
after_colon
.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u16>()
.ok()
}
async fn launch_port_reachable(port: u16) -> bool {
matches!(
tokio::time::timeout(
@@ -796,3 +805,26 @@ fn package_state_str(state: &PackageState) -> &str {
PackageState::Updating => "updating",
}
}
#[cfg(test)]
mod launch_url_port_tests {
use super::launch_url_port;
#[test]
fn parses_port_with_trailing_path() {
// Regression: manifest interfaces.main yields a path-suffixed URL.
// The old rsplit(':') parse produced "8096/" and dropped the URL.
assert_eq!(launch_url_port("http://localhost:8096/"), Some(8096));
assert_eq!(launch_url_port("http://localhost:8175/admin"), Some(8175));
}
#[test]
fn parses_bare_authority_port() {
assert_eq!(launch_url_port("http://localhost:8083"), Some(8083));
}
#[test]
fn rejects_url_without_port() {
assert_eq!(launch_url_port("http://localhost/"), None);
}
}
@@ -187,9 +187,6 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
// Penpot (primary = frontend)
"penpot" | "penpot-frontend" => Some("PENPOT_FRONTEND_IMAGE"),
// DWN
"dwn" => Some("DWN_SERVER_IMAGE"),
// AI
"routstr" => Some("ROUTSTR_IMAGE"),
+349 -58
View File
@@ -11,7 +11,16 @@ use crate::update::host_sudo;
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf";
const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
pub const WALLET_PASSWORD: &str = "hellohello";
/// Per-node LND wallet password file (random, 0600). Replaces the old
/// fleet-wide hardcoded constant: each node's wallet password is now unique,
/// high-entropy, and recorded here so the unattended boot path can auto-unlock.
const WALLET_PASSWORD_SECRET: &str = "/var/lib/archipelago/secrets/lnd-wallet-password";
/// Legacy fleet-wide wallet password (builds that hardcoded it). Kept ONLY as an
/// unlock fallback so wallets created by those builds still open; new wallets
/// never use it, and the login-path migration rotates away from it.
const LEGACY_WALLET_PASSWORD: &str = "hellohello";
#[derive(Debug, Clone)]
pub struct EnsurePaths {
@@ -76,18 +85,128 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
if file_exists_as_root(wallet_db).await {
if file_exists_as_root(admin_macaroon).await {
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
return Ok(());
}
unlock_existing_wallet().await?;
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
match unlock_existing_wallet().await? {
true => {
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
}
false => {
// Every candidate password was actively rejected: this wallet was
// created with a password this node no longer has, so it can never
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
// locked with an unknown password is already inaccessible, so wipe +
// recreate it on the per-node secret to self-heal at boot.
recreate_wallet_destructively().await?;
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
}
}
}
init_wallet_via_rest().await?;
wait_for_admin_macaroon(admin_macaroon).await
}
/// LND data subdirectories holding wallet + channel + graph state. Removing them
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
/// so deletion is destructive — only done once the wallet is already unrecoverable.
const LND_STATE_DIRS: &[&str] = &[
"/var/lib/archipelago/lnd/data/chain",
"/var/lib/archipelago/lnd/data/graph",
];
/// Podman container name for the core LND app (see `compute_container_name`:
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
const LND_CONTAINER: &str = "lnd";
/// Archipelago data dir (default; not overridden in prod). Holds the
/// `user-stopped.json` that gates health-monitor auto-restart.
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
/// candidate password can open the existing wallet.
async fn recreate_wallet_destructively() -> Result<()> {
tracing::warn!(
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
);
// The health monitor restarts any container it sees stopped; mark LND
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
// Always cleared below so LND auto-recovers normally afterwards.
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
let result = wipe_and_reinit_wallet().await;
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
result
}
async fn wipe_and_reinit_wallet() -> Result<()> {
podman_user_scoped(&["stop", LND_CONTAINER])
.await
.context("stopping lnd before wallet wipe")?;
for dir in LND_STATE_DIRS {
let status = host_sudo(&["rm", "-rf", dir])
.await
.with_context(|| format!("removing {dir}"))?;
if !status.success() {
anyhow::bail!("removing {dir} exited with {status}");
}
}
podman_user_scoped(&["start", LND_CONTAINER])
.await
.context("restarting lnd after wallet wipe")?;
wait_for_wallet_state("NON_EXISTING").await?;
init_wallet_via_rest().await
}
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
/// how the orchestrator/health-monitor manage rootless containers (keeps the
/// container out of the archipelago service's cgroup).
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
let out = tokio::process::Command::new("systemd-run")
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
.args(args)
.output()
.await
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
if !out.status.success() {
anyhow::bail!(
"podman {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
async fn wait_for_wallet_state(target: &str) -> Result<()> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(5))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client")?;
for _ in 0..120 {
if wallet_state(&client).await.as_deref() == Some(target) {
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!("LND did not reach state {target} after wallet wipe")
}
async fn file_exists_as_root(path: &str) -> bool {
if std::path::Path::new(path).exists() {
return true;
@@ -121,68 +240,227 @@ async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
}
}
async fn unlock_existing_wallet() -> Result<()> {
/// Read the per-node wallet password from the secrets file, if present.
/// Never generates one — absence means "fall back to legacy / not set yet".
async fn read_wallet_password() -> Option<String> {
let bytes = fs::read(WALLET_PASSWORD_SECRET).await.ok()?;
let pw = String::from_utf8_lossy(&bytes).trim().to_string();
(!pw.is_empty()).then_some(pw)
}
/// Return the per-node wallet password, generating and persisting a fresh
/// 256-bit one (base64, 0600) if none exists. Use ONLY when creating a NEW
/// wallet — calling it merely to unlock an existing wallet would record a
/// password that doesn't match it.
pub(crate) async fn ensure_wallet_password() -> Result<String> {
if let Some(pw) = read_wallet_password().await {
return Ok(pw);
}
use rand::RngCore;
let mut raw = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut raw);
let pw = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
let path = std::path::Path::new(WALLET_PASSWORD_SECRET);
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)
.await
.with_context(|| format!("creating {}", dir.display()))?;
}
fs::write(path, &pw)
.await
.with_context(|| format!("writing {WALLET_PASSWORD_SECRET}"))?;
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await;
Ok(pw)
}
/// Candidate passwords to try when unlocking an EXISTING wallet, in order: the
/// per-node secret (current scheme) first, then the legacy constant so wallets
/// created by older builds still open.
async fn unlock_password_candidates() -> Vec<String> {
let mut v = Vec::new();
if let Some(pw) = read_wallet_password().await {
v.push(pw);
}
v.push(LEGACY_WALLET_PASSWORD.to_string());
v
}
/// Outcome of a single unlock attempt — lets the caller fail fast on a wrong
/// password (no point retrying) vs keep waiting for LND to come up.
enum UnlockAttempt {
Unlocked,
WrongPassword,
NotReady,
}
/// One unlock POST, no internal retry. Distinguishes "invalid passphrase"
/// (WrongPassword — try the next candidate, don't retry) from transient
/// not-ready / connection errors (NotReady — worth retrying).
async fn try_unlock_once(client: &reqwest::Client, password: &str) -> UnlockAttempt {
let body = serde_json::json!({
"wallet_password": base64::engine::general_purpose::STANDARD.encode(password)
});
match client
.post(format!("{LND_REST_BASE_URL}/v1/unlockwallet"))
.json(&body)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if status.is_success() || text.contains("already unlocked") {
UnlockAttempt::Unlocked
} else if text.contains("invalid passphrase") {
UnlockAttempt::WrongPassword
} else {
UnlockAttempt::NotReady
}
}
Err(_) => UnlockAttempt::NotReady,
}
}
/// Unlock an existing wallet. Ok(true) = unlocked; Ok(false) = every candidate
/// password was actively rejected (unrecoverable — caller should recreate);
/// Err = transient (LND not ready / timeout — caller should retry, NOT wipe).
async fn unlock_existing_wallet() -> Result<bool> {
unlock_existing_wallet_via_rest().await
}
async fn unlock_existing_wallet_via_rest() -> Result<()> {
async fn unlock_existing_wallet_via_rest() -> Result<bool> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(20))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client")?;
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
match post_lnd_unlocker_json::<serde_json::Value>(
&client,
"/v1/unlockwallet",
serde_json::json!({ "wallet_password": wallet_password }),
)
.await
.context("unlocking existing LND wallet")?
{
UnlockerResponse::Value(_) | UnlockerResponse::WalletAlreadyExists => Ok(()),
let candidates = unlock_password_candidates().await;
// Retry only while LND's unlocker isn't ready yet. If every candidate is
// *actively rejected* (invalid passphrase), retrying can't help — fail fast
// with a clear message instead of hanging the boot path for 60s+ (the wallet
// was created with a password this node doesn't have → migration/recovery).
for _ in 0..60 {
let mut all_rejected = true;
for pw in &candidates {
match try_unlock_once(&client, pw).await {
UnlockAttempt::Unlocked => return Ok(true),
UnlockAttempt::WrongPassword => {}
UnlockAttempt::NotReady => all_rejected = false,
}
}
if all_rejected {
tracing::warn!(
"[lnd] none of the {} candidate password(s) unlock the wallet — it was created \
with a password this node does not have",
candidates.len()
);
return Ok(false);
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!("LND wallet unlock timed out waiting for the unlocker to become ready")
}
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/state"))
.send()
.await
.ok()?;
let v: serde_json::Value = resp.json().await.ok()?;
v.get("state")
.and_then(|s| s.as_str())
.map(|s| s.to_string())
}
/// ChangePassword via WalletUnlocker (wallet must be LOCKED). Both passwords are
/// base64-encoded. Ok(true) = current accepted and rotated; Ok(false) = current
/// rejected (wrong password — try the next candidate); Err = transport/other.
async fn change_wallet_password(
client: &reqwest::Client,
current: &str,
new: &str,
) -> Result<bool> {
let body = serde_json::json!({
"current_password": base64::engine::general_purpose::STANDARD.encode(current),
"new_password": base64::engine::general_purpose::STANDARD.encode(new),
});
let resp = client
.post(format!("{LND_REST_BASE_URL}/v1/changepassword"))
.json(&body)
.send()
.await
.context("calling LND changepassword")?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if status.is_success() {
Ok(true)
} else if text.contains("invalid passphrase") {
Ok(false)
} else {
anyhow::bail!("LND changepassword returned {status}: {text}")
}
}
#[allow(dead_code)]
async fn unlock_existing_wallet_via_lncli() -> Result<()> {
let mut last_err = None;
for _ in 0..60 {
let mut cmd = tokio::process::Command::new("podman");
cmd.args(["exec", "-i", "lnd", "lncli", "unlock", "--stdin"]);
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
/// Best-effort migration of a LOCKED wallet onto the per-node secret. Called at
/// login, when the onboarding password is available as a candidate. If the
/// per-node secret already opens the wallet, just unlock. Otherwise try each
/// candidate as the CURRENT password and ChangePassword it to a fresh per-node
/// secret so all future boots auto-unlock. Ok(true) = healed/unlocked;
/// Ok(false) = not locked, or no candidate worked (seed-recovery required).
pub(crate) async fn migrate_locked_wallet(candidates: &[String]) -> Result<bool> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(20))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client")?;
let mut child = cmd.spawn().context("spawning lncli wallet unlock")?;
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
stdin
.write_all(format!("{}\n", WALLET_PASSWORD).as_bytes())
.await
.context("writing lncli password")?;
}
let out = child
.wait_with_output()
.await
.context("waiting for lncli")?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
let msg = format!("{stderr}{stdout}");
if msg.contains("wallet already unlocked") || msg.contains("already unlocked") {
return Ok(());
}
last_err = Some(msg);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// Only act on a wallet that is actually LOCKED.
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
return Ok(false);
}
anyhow::bail!(
"lncli wallet unlock failed: {}",
last_err.unwrap_or_else(|| "unknown error".to_string())
)
// If the per-node secret already opens it, nothing to rotate — just unlock.
if let Some(secret) = read_wallet_password().await {
if matches!(
try_unlock_once(&client, &secret).await,
UnlockAttempt::Unlocked
) {
return Ok(true);
}
}
// The wallet's new password becomes the per-node secret (generate if absent).
let new_secret = ensure_wallet_password().await?;
// ChangePassword requires LOCKED; bail out if a prior step already unlocked.
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
return Ok(true);
}
for cand in candidates {
if cand.is_empty() || *cand == new_secret {
continue;
}
match change_wallet_password(&client, cand, &new_secret).await {
Ok(true) => {
tracing::info!("[lnd-migrate] rotated locked wallet onto the per-node secret");
return Ok(true);
}
Ok(false) => continue, // wrong current password — try next candidate
Err(e) => tracing::debug!("[lnd-migrate] changepassword error: {e}"),
}
}
tracing::warn!(
"[lnd-migrate] no candidate password opened the wallet — seed-recovery required"
);
Ok(false)
}
#[derive(Debug, Deserialize)]
@@ -204,6 +482,7 @@ struct InitWalletRequest {
async fn init_wallet_via_rest() -> Result<()> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(20))
.danger_accept_invalid_certs(true)
.build()
@@ -223,7 +502,8 @@ async fn init_wallet_via_rest() -> Result<()> {
anyhow::bail!("LND genseed returned no seed words");
}
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
let wallet_password =
base64::engine::general_purpose::STANDARD.encode(ensure_wallet_password().await?);
let req = InitWalletRequest {
wallet_password,
cipher_seed_mnemonic: seed.cipher_seed_mnemonic,
@@ -237,7 +517,9 @@ async fn init_wallet_via_rest() -> Result<()> {
.context("initializing LND wallet")?
{
UnlockerResponse::Value(_) => {}
UnlockerResponse::WalletAlreadyExists => unlock_existing_wallet().await?,
UnlockerResponse::WalletAlreadyExists => {
unlock_existing_wallet().await?;
}
}
Ok(())
@@ -305,12 +587,12 @@ async fn decode_lnd_unlocker_response<T: for<'de> Deserialize<'de>>(
anyhow::bail!("LND REST {path} returned {status}: {text}")
}
#[allow(dead_code)]
async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
let Ok(macaroon) = read_file_as_root(admin_macaroon).await else {
return false;
};
let Ok(client) = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(5))
.danger_accept_invalid_certs(true)
.build()
@@ -448,7 +730,16 @@ mod tests {
}
#[test]
fn wallet_password_is_valid_for_lncli() {
assert!(WALLET_PASSWORD.len() > 8);
fn legacy_wallet_password_is_valid_for_lncli() {
// Legacy fallback must still be a valid lncli passphrase (>8 chars).
assert!(LEGACY_WALLET_PASSWORD.len() > 8);
}
#[tokio::test]
async fn unlock_candidates_always_include_legacy_fallback() {
// With no per-node secret on disk in the test env, candidates fall back
// to the legacy constant so old wallets still open.
let cands = unlock_password_candidates().await;
assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD));
}
}
@@ -297,6 +297,53 @@ async fn wait_for_manifest_host_ports(manifest: &AppManifest, timeout_secs: u64)
Ok(())
}
/// Pure published-port drift check. `port_bindings_json` is the JSON that
/// `podman inspect --format '{{json .HostConfig.PortBindings}}'` emits, e.g.
/// `{"8080/tcp":[{"HostIp":"","HostPort":"18080"}]}`. Returns true only when a
/// manifest container-port is positively published to a *different* host port
/// than the manifest now asks for. Absence of a binding is deliberately NOT
/// treated as drift here — that case is handled by the host-port repair/restart
/// path and by host-networked apps that publish nothing — so we never trigger a
/// destructive recreate on a false positive.
fn host_port_bindings_drifted(
port_bindings_json: &str,
manifest_ports: &[archipelago_container::manifest::PortMapping],
) -> bool {
let parsed: serde_json::Value = match serde_json::from_str(port_bindings_json) {
Ok(v) => v,
Err(_) => return false,
};
let Some(map) = parsed.as_object() else {
return false;
};
for port in manifest_ports {
let proto = if port.protocol.is_empty() {
"tcp"
} else {
port.protocol.as_str()
};
let key = format!("{}/{}", port.container, proto);
let Some(bindings) = map.get(&key).and_then(|b| b.as_array()) else {
// Container-port not currently published — not our case.
continue;
};
if bindings.is_empty() {
continue;
}
let expected = port.host.to_string();
let matches_expected = bindings.iter().any(|b| {
b.get("HostPort")
.and_then(|h| h.as_str())
.map(|h| h == expected)
.unwrap_or(false)
});
if !matches_expected {
return true;
}
}
false
}
async fn ensure_user_podman_socket() -> Result<()> {
let socket_path = "/run/user/1000/podman/podman.sock";
if podman_socket_accepts_connections(socket_path).await {
@@ -734,8 +781,19 @@ struct FileSecretsProvider {
impl SecretsProvider for FileSecretsProvider {
fn read(&self, name: &str) -> std::result::Result<String, ManifestError> {
let path = self.root.join(name);
let data = std::fs::read_to_string(&path).map_err(ManifestError::Io)?;
Ok(data.trim().to_string())
match std::fs::read_to_string(&path) {
Ok(data) => Ok(data.trim().to_string()),
// Name the missing secret explicitly so the failure is actionable
// instead of a bare "IO error: No such file or directory" that hides
// which secret (and which app) is blocked.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err(ManifestError::Invalid(format!(
"required secret '{name}' is missing (expected at {}); the app cannot start until it is generated",
path.display()
)))
}
Err(e) => Err(ManifestError::Io(e)),
}
}
}
@@ -1054,6 +1112,7 @@ impl ProdContainerOrchestrator {
let lock = self.app_lock(&app_id).await;
let _guard = lock.lock().await;
self.ensure_app_secrets(&app_id).await?;
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
let name = compute_container_name(&lm.manifest);
@@ -1094,6 +1153,22 @@ impl ProdContainerOrchestrator {
self.run_post_start_hooks(&app_id).await?;
return Ok(ReconcileAction::Started);
}
// Published-port drift means the container exists but maps
// its ports to the wrong host ports (e.g. lnd REST stuck on
// host 8080 while the manifest/clients expect 18080, as seen
// on .116). The container is already non-functional, so
// recreate it even for restart-sensitive apps during boot —
// leaving it "untouched" would perpetuate the breakage.
if self
.container_ports_drifted(&name, &resolved_manifest)
.await
{
tracing::info!(app_id = %app_id, container = %name, "container published-port drift detected — recreating");
let _ = self.runtime.stop_container(&name).await;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
}
if self.container_env_drifted(&name, &resolved_manifest).await {
if mode == ReconcileMode::ExistingOnly
&& is_restart_sensitive_app(&app_id)
@@ -1154,8 +1229,12 @@ impl ProdContainerOrchestrator {
// reading it. A Rewritten outcome is fine here — we're
// about to start from a stopped state anyway.
self.prepare_for_start(&resolved_manifest).await?;
if self.container_env_drifted(&name, &resolved_manifest).await {
tracing::info!(app_id = %app_id, container = %name, "stopped container env drift detected — recreating");
if self.container_env_drifted(&name, &resolved_manifest).await
|| self
.container_ports_drifted(&name, &resolved_manifest)
.await
{
tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating");
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
@@ -1297,6 +1376,7 @@ impl ProdContainerOrchestrator {
/// Build-or-pull, create, start. Assumes the per-app mutex is already held.
async fn install_fresh(&self, lm: &LoadedManifest) -> Result<()> {
self.ensure_app_secrets(&lm.manifest.app.id).await?;
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
@@ -2300,6 +2380,25 @@ impl ProdContainerOrchestrator {
Self::detect_disk_gb()
}
/// Ensure app-specific secrets exist *before* env resolution. The Bitcoin
/// backends reference `bitcoin-rpc-txrelay-rpcauth` as a required
/// `secret_env`; it is normally created by the tx-relay flow, so nodes that
/// never used tx-relay lack it and `resolve_secret_env` hard-fails — taking
/// bitcoind (and everything that depends on it) down. Generating it here,
/// idempotently, lets reconcile/install self-heal that state (the .198 case)
/// instead of cascading. Must be called before every `resolve_dynamic_env`.
async fn ensure_app_secrets(&self, app_id: &str) -> Result<()> {
if cfg!(test) {
return Ok(());
}
if matches!(app_id, "bitcoin-knots" | "bitcoin-core" | "bitcoin") {
crate::api::rpc::bitcoin_relay::ensure_txrelay_credentials(&self.data_dir)
.await
.context("ensuring bitcoin tx-relay credentials")?;
}
Ok(())
}
fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
let facts = self.detect_host_facts();
let mut env = manifest.app.environment.clone();
@@ -2443,6 +2542,42 @@ impl ProdContainerOrchestrator {
false
}
/// True when a *running* container publishes a manifest container-port to a
/// different host port than the manifest now asks for (published-port
/// drift). This catches the class of failure seen on .116, where `lnd` was
/// created mapping host 8080 -> container 8080 but the current manifest maps
/// host 18080 -> container 8080, so every in-process REST client (which
/// connects to the manifest port) gets connection-refused forever while the
/// container looks "Up". `container_env_drifted` never inspects ports, and
/// `wait_for_manifest_host_ports` only restarts the stale container (which
/// republishes the wrong mapping), so without this check the drift is
/// self-perpetuating.
async fn container_ports_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
if cfg!(test) {
return false;
}
if manifest.app.ports.is_empty() {
return false;
}
let inspect = tokio::process::Command::new("podman")
.args([
"inspect",
name,
"--format",
"{{json .HostConfig.PortBindings}}",
])
.output()
.await;
let Ok(output) = inspect else {
return false;
};
if !output.status.success() {
return false;
}
let bindings = String::from_utf8_lossy(&output.stdout);
host_port_bindings_drifted(&bindings, &manifest.app.ports)
}
async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> {
let Some(uid_gid) = manifest.app.container.data_uid.as_ref() else {
return Ok(());
@@ -2752,6 +2887,7 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let lm = self.loaded(app_id).await?;
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
self.ensure_app_secrets(app_id).await?;
let name = compute_container_name(&lm.manifest);
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
@@ -2913,6 +3049,61 @@ mod tests {
use async_trait::async_trait;
use std::sync::Mutex as StdMutex;
fn port(host: u16, container: u16) -> archipelago_container::manifest::PortMapping {
archipelago_container::manifest::PortMapping {
host,
container,
protocol: "tcp".to_string(),
}
}
#[test]
fn port_drift_detected_when_host_port_differs() {
// The .116 case: container publishes container-port 8080 on host 8080,
// but the manifest now asks for host 18080.
let bindings = r#"{"8080/tcp":[{"HostIp":"","HostPort":"8080"}]}"#;
assert!(host_port_bindings_drifted(bindings, &[port(18080, 8080)]));
}
#[test]
fn no_drift_when_host_port_matches() {
let bindings = r#"{"8080/tcp":[{"HostIp":"0.0.0.0","HostPort":"18080"}]}"#;
assert!(!host_port_bindings_drifted(bindings, &[port(18080, 8080)]));
}
#[test]
fn no_drift_when_binding_absent() {
// Absence is handled elsewhere (host-port repair / host-networked apps);
// never treat it as drift to avoid a destructive recreate on a false
// positive.
assert!(!host_port_bindings_drifted("{}", &[port(18080, 8080)]));
assert!(!host_port_bindings_drifted("null", &[port(18080, 8080)]));
}
#[test]
fn no_drift_on_unparseable_bindings() {
assert!(!host_port_bindings_drifted(
"not json",
&[port(18080, 8080)]
));
}
#[test]
fn missing_secret_error_names_the_secret() {
use archipelago_container::manifest::SecretsProvider;
let provider = FileSecretsProvider {
root: PathBuf::from("/nonexistent-secrets-dir-xyz"),
};
let err = provider
.read("bitcoin-rpc-txrelay-rpcauth")
.expect_err("missing secret must error");
let msg = err.to_string();
assert!(
msg.contains("bitcoin-rpc-txrelay-rpcauth"),
"error should name the missing secret, got: {msg}"
);
}
/// Instrumented in-memory runtime. Every call is recorded so tests can assert
/// the exact sequence of side effects.
#[derive(Default)]
@@ -3220,11 +3411,11 @@ app:
- /data/.filebrowser.json
volumes:
- type: bind
source: /tmp/filebrowser-srv
source: /var/lib/archipelago/filebrowser-srv
target: /srv
options: [rw]
- type: bind
source: /tmp/filebrowser-data
source: /var/lib/archipelago/filebrowser-data
target: /data
options: [rw]
"#;
@@ -3244,7 +3435,7 @@ app:
secret_file: bitcoin-rpc-password
volumes:
- type: bind
source: /tmp/lnd
source: /var/lib/archipelago/lnd
target: /root/.lnd
"#;
AppManifest::parse(yaml).unwrap()
+2 -2
View File
@@ -800,7 +800,7 @@ mod tests {
QuadletUnit {
name: "archy-bitcoin-ui".into(),
description: "Bitcoin RPC UI proxy".into(),
image: "146.59.87.168:3000/lfg2025/bitcoin-ui:latest".into(),
image: "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.84-alpha".into(),
network: NetworkMode::Host,
user: Some("0:0".into()),
memory_mb: Some(128),
@@ -828,7 +828,7 @@ mod tests {
let s = sample_unit().render();
assert!(s.contains("[Container]"));
assert!(s.contains("ContainerName=archy-bitcoin-ui"));
assert!(s.contains("Image=146.59.87.168:3000/lfg2025/bitcoin-ui:latest"));
assert!(s.contains("Image=146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.84-alpha"));
assert!(s.contains("Pull=never"));
assert!(s.contains("Network=host"));
assert!(s.contains("DropCapability=ALL"));
+2 -2
View File
@@ -14,8 +14,8 @@ mod types;
pub use invites::{accept_invite, create_invite};
#[allow(unused_imports)]
pub use storage::{
add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes,
set_trust_level, update_node,
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
remove_node, save_nodes, set_trust_level, update_node,
};
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
+108
View File
@@ -10,6 +10,9 @@ use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLeve
pub(crate) const FEDERATION_DIR: &str = "federation";
pub(crate) const NODES_FILE: &str = "nodes.json";
pub(crate) const INVITES_FILE: &str = "invites.json";
/// Tombstones: DIDs the operator explicitly removed. Kept so transitive
/// federation discovery can't silently re-add a peer they deleted.
pub(crate) const REMOVED_FILE: &str = "removed-nodes.json";
/// Top-level file structures.
#[derive(Debug, Default, Serialize, Deserialize)]
@@ -17,6 +20,17 @@ pub(crate) struct NodesFile {
pub(crate) nodes: Vec<FederatedNode>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub(crate) struct RemovedFile {
pub(crate) removed: Vec<RemovedNode>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RemovedNode {
pub(crate) did: String,
pub(crate) removed_at: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub(crate) struct InvitesFile {
pub(crate) outgoing: Vec<FederationInvite>,
@@ -114,6 +128,9 @@ pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<Federa
if exists {
anyhow::bail!("Node with DID {} is already federated", node.did);
}
// Explicitly (re-)adding a node clears any prior tombstone so the
// operator can intentionally bring back a previously removed peer.
let _ = untombstone_did(data_dir, &node.did).await;
nodes.push(node);
save_nodes(data_dir, &nodes).await?;
Ok(nodes)
@@ -127,9 +144,70 @@ pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode
anyhow::bail!("No federated node with DID {}", did);
}
save_nodes(data_dir, &nodes).await?;
// Tombstone the DID so transitive federation discovery (a still-federated
// peer advertising this DID as one of *its* trusted peers) can't silently
// re-add it. Best-effort: a failed tombstone write must not fail the
// remove the operator asked for.
let _ = tombstone_did(data_dir, did).await;
Ok(nodes)
}
/// Load the set of tombstoned (operator-removed) DIDs.
pub async fn load_removed_dids(data_dir: &Path) -> Result<std::collections::HashSet<String>> {
let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE);
if !path.exists() {
return Ok(std::collections::HashSet::new());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read removed nodes")?;
let file: RemovedFile = serde_json::from_str(&content).unwrap_or_default();
Ok(file.removed.into_iter().map(|r| r.did).collect())
}
/// Record a DID as removed. Idempotent.
pub async fn tombstone_did(data_dir: &Path, did: &str) -> Result<()> {
let dir = ensure_dir(data_dir).await?;
let path = dir.join(REMOVED_FILE);
let mut file: RemovedFile = if path.exists() {
serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default())
.unwrap_or_default()
} else {
RemovedFile::default()
};
if !file.removed.iter().any(|r| r.did == did) {
file.removed.push(RemovedNode {
did: did.to_string(),
removed_at: chrono::Utc::now().to_rfc3339(),
});
let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?;
fs::write(&path, content)
.await
.context("Failed to write removed nodes")?;
}
Ok(())
}
/// Clear a DID's tombstone (operator explicitly re-added it).
pub async fn untombstone_did(data_dir: &Path, did: &str) -> Result<()> {
let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE);
if !path.exists() {
return Ok(());
}
let mut file: RemovedFile =
serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default())
.unwrap_or_default();
let before = file.removed.len();
file.removed.retain(|r| r.did != did);
if file.removed.len() != before {
let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?;
fs::write(&path, content)
.await
.context("Failed to write removed nodes")?;
}
Ok(())
}
pub async fn set_trust_level(
data_dir: &Path,
did: &str,
@@ -287,6 +365,36 @@ mod tests {
assert!(result.is_err());
}
#[tokio::test]
async fn test_remove_tombstones_and_readd_clears_it() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
// No tombstones yet.
assert!(load_removed_dids(dir.path()).await.unwrap().is_empty());
// Removing tombstones the DID so transitive discovery won't re-add it.
remove_node(dir.path(), "did:key:z1").await.unwrap();
let removed = load_removed_dids(dir.path()).await.unwrap();
assert!(
removed.contains("did:key:z1"),
"removed DID must be tombstoned"
);
// Explicitly re-adding clears the tombstone (intentional re-federate).
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
assert!(
!load_removed_dids(dir.path())
.await
.unwrap()
.contains("did:key:z1"),
"explicit re-add must clear the tombstone"
);
}
#[tokio::test]
async fn test_set_trust_level() {
let dir = tempfile::tempdir().unwrap();
+82 -3
View File
@@ -66,7 +66,9 @@ pub async fn sync_with_peer(
// hop. Only runs when the source is Trusted — Observer-level peers
// don't get to expand our federation on their own authority.
if peer.trust_level == TrustLevel::Trusted {
if let Err(e) = merge_transitive_peers(data_dir, &peer.did, &state.federated_peers).await {
if let Err(e) =
merge_transitive_peers(data_dir, &peer.did, local_did, &state.federated_peers).await
{
tracing::warn!(
peer_did = %peer.did,
error = %e,
@@ -109,18 +111,30 @@ pub async fn sync_with_peer_by_did(data_dir: &Path, peer_did: &str) -> Result<No
async fn merge_transitive_peers(
data_dir: &std::path::Path,
source_did: &str,
local_did: &str,
hints: &[FederationPeerHint],
) -> Result<()> {
if hints.is_empty() {
return Ok(());
}
let mut nodes = super::storage::load_nodes(data_dir).await?;
// Tombstoned DIDs: peers the operator explicitly removed. Never re-add
// them via transitive discovery, or deleted (e.g. stale test) nodes
// reappear on the next sync with any peer that still lists them.
let removed = super::storage::load_removed_dids(data_dir)
.await
.unwrap_or_default();
let mut added = 0u32;
let mut refreshed = 0u32;
for hint in hints {
// Don't import our own DID (a peer advertising us back).
if hint.did == source_did {
// Don't import the source peer advertising itself, or our own DID
// when the source advertises us back as one of its trusted peers.
if hint.did == source_did || hint.did == local_did {
continue;
}
// Skip anything the operator deliberately removed.
if removed.contains(&hint.did) {
continue;
}
if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) {
@@ -359,4 +373,69 @@ mod tests {
Some("npub1a")
);
}
#[tokio::test]
async fn merge_transitive_peers_skips_source_and_local_node() {
let dir = tempfile::tempdir().unwrap();
super::super::storage::save_nodes(
dir.path(),
&[FederatedNode {
did: "did:key:zSource".into(),
pubkey: "aa".into(),
onion: "source.onion".into(),
name: Some("Source".into()),
trust_level: TrustLevel::Trusted,
added_at: "now".into(),
last_seen: None,
last_state: None,
fips_npub: None,
last_transport: None,
last_transport_at: None,
}],
)
.await
.unwrap();
merge_transitive_peers(
dir.path(),
"did:key:zSource",
"did:key:zLocal",
&[
FederationPeerHint {
did: "did:key:zSource".into(),
pubkey: "aa".into(),
onion: "source.onion".into(),
name: Some("Source".into()),
fips_npub: None,
},
FederationPeerHint {
did: "did:key:zLocal".into(),
pubkey: "bb".into(),
onion: "local.onion".into(),
name: Some("Local".into()),
fips_npub: None,
},
FederationPeerHint {
did: "did:key:zPeer".into(),
pubkey: "cc".into(),
onion: "peer.onion".into(),
name: Some("Kitchen".into()),
fips_npub: Some("npub1peer".into()),
},
],
)
.await
.unwrap();
let nodes = super::super::storage::load_nodes(dir.path()).await.unwrap();
assert_eq!(nodes.len(), 2);
assert!(nodes.iter().all(|n| n.did != "did:key:zLocal"));
let peer = nodes
.iter()
.find(|n| n.did == "did:key:zPeer")
.expect("trusted transitive peer should be added");
assert_eq!(peer.name.as_deref(), Some("Kitchen"));
assert_eq!(peer.trust_level, TrustLevel::Trusted);
assert_eq!(peer.fips_npub.as_deref(), Some("npub1peer"));
}
}
+76 -13
View File
@@ -28,12 +28,38 @@ use tokio::process::Command;
/// On-disk filename under `data_dir/`.
const SEED_ANCHORS_FILE: &str = "seed-anchors.json";
/// Public anchor (`fips.v0l.io`) carried as a default seed for fresh
/// installs — the one the upstream daemon dials anyway. Operators can
/// remove it from the UI once their own cluster has independent anchors.
/// Public anchor (`fips.v0l.io`) carried as a default seed for every
/// node — it bootstraps DHT routing so a fresh node isn't isolated.
/// Operators can remove it from the UI once their own cluster has
/// independent anchors (removal persists, see `load`/`remove`).
///
/// IMPORTANT transport details, learned the hard way (see git history /
/// the 2026-06-15 debugging on .116):
/// - The anchor answers ONLY on **TCP port 8443**. UDP 8668 is dead
/// (host pings on both IP families but never completes a UDP FIPS
/// handshake). `fips/config.rs` always knew this; the old default
/// here (`fips.v0l.io:8668`/udp) silently never connected fleet-wide.
/// - We use the **IPv4 literal** rather than the `fips.v0l.io` hostname
/// on purpose: the hostname resolves IPv6-first, but the daemon binds
/// its transports IPv4-only (`0.0.0.0:8443`), so a v6 target makes the
/// daemon fail to send the handshake with `EAFNOSUPPORT (os error 97)`.
/// An IPv4 literal sidesteps the resolver entirely.
pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "fips.v0l.io:8668";
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443";
pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp";
/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried
/// implicitly by `load()` on nodes that have never edited their anchor
/// list, so every node dials it without operator action.
pub fn default_public_anchor() -> SeedAnchor {
SeedAnchor {
npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(),
address: DEFAULT_PUBLIC_ANCHOR_ADDR.to_string(),
transport: DEFAULT_PUBLIC_ANCHOR_TRANSPORT.to_string(),
label: "Public anchor (fips.v0l.io)".to_string(),
}
}
/// One seed-anchor entry. `address` must be directly dialable (IP or
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
@@ -60,12 +86,15 @@ fn anchors_path(data_dir: &Path) -> PathBuf {
data_dir.join(SEED_ANCHORS_FILE)
}
/// Load the seed-anchor list. Returns an empty list if the file
/// doesn't exist yet — a first-boot node with no operator config.
/// Load the seed-anchor list. A node that has never edited its anchor
/// list (no file yet) gets the default public anchor so it can bootstrap
/// the mesh out of the box. Once the operator edits anchors — including
/// removing the default — a file exists and is authoritative, so removal
/// persists and we never silently re-add it.
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
let path = anchors_path(data_dir);
if !path.exists() {
return Ok(Vec::new());
return Ok(vec![default_public_anchor()]);
}
let bytes = tokio::fs::read(&path)
.await
@@ -121,11 +150,27 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
/// `fipsctl connect` is idempotent-ish: calling it for an already-
/// connected peer is a no-op at the protocol layer, so re-applying on
/// a timer is safe. Returns a list of per-anchor results for logging.
///
/// Invoked through `sudo -n`: the upstream daemon's control socket
/// (`/run/fips/control.sock`) is owned `root:fips` 0660, and the
/// archipelago service user is not in the `fips` group, so a bare
/// `fipsctl connect` fails with EACCES. This matches the privileged
/// `sudo -n fipsctl show peers` call in `service::peer_connectivity_summary`.
/// Without it, seed anchors persist to disk but never actually dial,
/// leaving `anchor_connected=false` and every peer dial falling back to
/// a slow Tor timeout.
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
let mut results = Vec::with_capacity(anchors.len());
for anchor in anchors {
let out = Command::new("fipsctl")
.args(["connect", &anchor.npub, &anchor.address, &anchor.transport])
let out = Command::new("sudo")
.args([
"-n",
"fipsctl",
"connect",
&anchor.npub,
&anchor.address,
&anchor.transport,
])
.output()
.await;
let result = match out {
@@ -138,7 +183,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
npub: anchor.npub.clone(),
ok: false,
message: format!(
"fipsctl exited {}: {}",
"sudo fipsctl connect exited {}: {}",
o.status,
String::from_utf8_lossy(&o.stderr).trim()
),
@@ -146,7 +191,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
Err(e) => ApplyResult {
npub: anchor.npub.clone(),
ok: false,
message: format!("fipsctl launch failed: {}", e),
message: format!("sudo fipsctl launch failed: {}", e),
},
};
if result.ok {
@@ -185,10 +230,28 @@ mod tests {
}
#[tokio::test]
async fn load_missing_returns_empty() {
async fn load_missing_seeds_default_public_anchor() {
// A node that has never edited its anchor list should still get
// the public anchor so it can bootstrap the mesh out of the box.
let dir = tempfile::tempdir().unwrap();
let got = load(dir.path()).await.unwrap();
assert!(got.is_empty());
assert_eq!(got, vec![default_public_anchor()]);
// ...and the default must be the TCP/8443 form, not the dead udp:8668.
assert_eq!(got[0].transport, "tcp");
assert!(got[0].address.ends_with(":8443"));
}
#[tokio::test]
async fn removing_default_persists_as_empty() {
// Once the operator removes the default, a file exists and is
// authoritative — we must not silently re-seed it on next load.
let dir = tempfile::tempdir().unwrap();
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
.await
.unwrap();
assert!(list.is_empty());
let got = load(dir.path()).await.unwrap();
assert!(got.is_empty(), "default must stay removed once edited");
}
#[tokio::test]
+40 -14
View File
@@ -34,6 +34,17 @@ use tokio::net::UdpSocket;
/// path filter can restrict the exposed surface.
pub const PEER_PORT: u16 = 5679;
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
/// doesn't expose that path (e.g. a peer on an older build with a stricter
/// `is_peer_allowed_path`), and `5xx` is a server-side error — both are
/// worth retrying over Tor, which reaches a different (less-filtered) route.
/// Success, redirects, and other 4xx (auth / bad request) are authoritative
/// and are returned as-is so we neither mask real errors nor double latency.
fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
}
/// DNS suffix appended to a peer's bech32 npub.
pub const FIPS_DNS_SUFFIX: &str = "fips";
@@ -294,13 +305,22 @@ impl<'a> PeerRequest<'a> {
let pref = self.preference().await;
// FIPS-only or Auto: try FIPS first.
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
if let Some(resp) = self.try_fips_post_json(body).await? {
return Ok((resp, crate::transport::TransportKind::Fips));
}
if pref == TransportPref::Fips {
anyhow::bail!(
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
);
match self.try_fips_post_json(body).await? {
Some(resp) => {
// Use the FIPS reply unless it's one a Tor retry could
// fix (404 path-not-served / 5xx) and we're allowed to
// fall back. FIPS-only never falls back.
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
return Ok((resp, crate::transport::TransportKind::Fips));
}
}
None => {
if pref == TransportPref::Fips {
anyhow::bail!(
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
);
}
}
}
}
let resp = self.send_tor_post_json(body).await?;
@@ -312,13 +332,19 @@ impl<'a> PeerRequest<'a> {
use crate::settings::transport::TransportPref;
let pref = self.preference().await;
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
if let Some(resp) = self.try_fips_get().await? {
return Ok((resp, crate::transport::TransportKind::Fips));
}
if pref == TransportPref::Fips {
anyhow::bail!(
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
);
match self.try_fips_get().await? {
Some(resp) => {
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
return Ok((resp, crate::transport::TransportKind::Fips));
}
}
None => {
if pref == TransportPref::Fips {
anyhow::bail!(
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
);
}
}
}
}
let resp = self.send_tor_get().await?;
+332 -87
View File
@@ -1,156 +1,401 @@
//! User-triggered FIPS upgrade from the upstream default branch.
//! User-triggered FIPS upgrade from upstream GitHub releases.
//!
//! Flow (no auto-update, no background polling — user clicks a button):
//! 1. Query GitHub for the upstream repo's default branch, then the
//! latest commit on it. (jmcorgan/fips default is `master`, not
//! `main` — we resolve it dynamically so a future rename Just Works.)
//! 2. Compare with the installed daemon version reported by
//! `fipsctl --version`. If identical, report "up to date".
//! 3. Fetch the built .deb artefact for that commit + its SHA256.
//! 4. SHA256-verify the download.
//! 5. `sudo dpkg -i` the .deb, `sudo systemctl restart` the service.
//! 1. Query GitHub for the latest *stable* release of `jmcorgan/fips`
//! (`/releases/latest` returns the newest non-prerelease, non-draft
//! tag, so release candidates like `v0.4.0-rc1` are skipped).
//! 2. Compare its tag (e.g. `v0.3.0`) with the installed daemon version
//! reported by `fipsctl --version`. A dev/pre-release build of the
//! same number (`0.3.0-dev`) counts as older than the released tag.
//! 3. Pick the Debian package asset matching the host architecture
//! (`fips_<ver>_amd64.deb` / `_arm64.deb`) plus `checksums-linux.txt`.
//! 4. Download both, SHA256-verify the .deb against the checksums file.
//! 5. `sudo dpkg -i` the verified .deb, then restart the active fips unit.
//!
//! The artefact URL / SHA256 source is not yet fixed — upstream doesn't
//! publish stable release assets for per-commit builds. This module
//! currently implements steps 12 (the "is there anything newer?" query)
//! and stubs out 35 so the RPC/UI can wire through. The apply path
//! returns a clear "not yet available" error until the artefact source
//! is decided.
//! Upstream began publishing tagged releases with `.deb` artefacts and
//! `checksums-linux.txt` (verified present as of v0.1.0 → v0.4.0-rc1), so
//! the apply path is fully wired against those assets.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::{service, UPSTREAM_REPO};
const GITHUB_API: &str = "https://api.github.com";
const USER_AGENT: &str = "archipelago-fips-updater";
/// Result of `check_update()` — what the dashboard renders.
/// Result of `check()` — what the dashboard renders.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateCheck {
/// Currently installed daemon version (from `fipsctl --version`).
pub current: Option<String>,
/// Short SHA of the latest commit on upstream `main`.
pub latest_commit: String,
/// True when the installed version string does not mention the latest SHA.
/// Tag of the latest stable upstream release, e.g. `v0.3.0`.
pub latest_version: String,
/// True when the installed version is older than `latest_version`.
pub update_available: bool,
/// Release channel this check tracked. Currently always "stable".
pub channel: String,
/// Browser download URL of the architecture-matched .deb for the
/// latest release, when one exists (informational; apply() re-resolves).
pub asset_url: Option<String>,
/// Human-readable note for the UI.
pub notes: String,
}
/// Query GitHub for the latest commit on the upstream default branch and
/// compare to the installed version. Never errors on "no package installed"
/// — that is itself a valid state where an update is available.
/// One GitHub release as we consume it.
#[derive(Debug, Clone, Deserialize)]
struct Release {
tag_name: String,
#[serde(default)]
prerelease: bool,
#[serde(default)]
draft: bool,
#[serde(default)]
assets: Vec<Asset>,
}
#[derive(Debug, Clone, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
fn http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Build HTTP client")
}
/// Debian architecture string for the host (`amd64` / `arm64`). Returns
/// the raw `std::env::consts::ARCH` for anything we don't map, so the
/// asset lookup simply finds nothing and surfaces a clear error.
fn deb_arch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
other => other,
}
}
/// Query GitHub for the latest stable release and compare to the installed
/// version. Never errors on "no package installed" — that is itself a valid
/// state where an update is available.
pub async fn check() -> Result<UpdateCheck> {
let current = service::daemon_version().await.ok();
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(std::time::Duration::from_secs(15))
.build()
.context("Build HTTP client")?;
let branch = fetch_default_branch(&client).await?;
let latest = fetch_head_sha(&client, &branch).await?;
let short = latest.chars().take(7).collect::<String>();
let client = http_client()?;
let release = fetch_latest_stable(&client).await?;
let update_available = match &current {
Some(v) => !v.contains(&short),
Some(v) => version_is_older(v, &release.tag_name),
None => true,
};
let asset_url = release
.assets
.iter()
.find(|a| is_deb_for_arch(&a.name))
.map(|a| a.browser_download_url.clone());
let notes = if update_available {
format!(
"Upstream {} is at {}; installed: {}",
branch,
short,
"Update available: {} (installed: {})",
release.tag_name,
current.as_deref().unwrap_or("not installed")
)
} else {
format!("Up to date ({} @ {})", branch, short)
format!("Up to date ({})", release.tag_name)
};
Ok(UpdateCheck {
current,
latest_commit: short,
latest_version: release.tag_name,
update_available,
channel: "stable".to_string(),
asset_url,
notes,
})
}
/// Apply the update. Stubbed pending a stable artefact source for
/// per-commit builds of the `fips` debian package. When this is wired
/// up it must: download → SHA256-verify → `sudo dpkg -i` → restart.
/// Download, verify, and install the latest stable FIPS release, then
/// restart the daemon. Steps: resolve release → match .deb for this arch
/// → download .deb + checksums → SHA256-verify → `sudo dpkg -i` → restart.
pub async fn apply() -> Result<()> {
anyhow::bail!(
"FIPS auto-apply not yet wired — upstream does not publish stable \
per-commit .deb artefacts for main. Upgrade manually for now: \
`git pull && cargo deb && sudo dpkg -i target/debian/fips_*.deb`."
)
}
let client = http_client()?;
let release = fetch_latest_stable(&client).await?;
async fn fetch_default_branch(client: &reqwest::Client) -> Result<String> {
let url = format!("{}/repos/{}", GITHUB_API, UPSTREAM_REPO);
let resp = client
.get(&url)
.header("Accept", "application/vnd.github+json")
let deb = release
.assets
.iter()
.find(|a| is_deb_for_arch(&a.name))
.ok_or_else(|| {
anyhow::anyhow!(
"release {} has no .deb for architecture {}",
release.tag_name,
deb_arch()
)
})?;
let checksums = release
.assets
.iter()
.find(|a| a.name == "checksums-linux.txt")
.ok_or_else(|| {
anyhow::anyhow!("release {} has no checksums-linux.txt", release.tag_name)
})?;
// Download the .deb (bytes) and the checksums (text).
let deb_bytes = client
.get(&deb.browser_download_url)
.send()
.await
.context("GitHub repo API")?;
if !resp.status().is_success() {
anyhow::bail!("GitHub repo API returned {}", resp.status());
}
let body: serde_json::Value = resp.json().await.context("Parse repo JSON")?;
body.get("default_branch")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("GitHub repo response missing default_branch"))
}
async fn fetch_head_sha(client: &reqwest::Client, branch: &str) -> Result<String> {
let url = format!("{}/repos/{}/commits/{}", GITHUB_API, UPSTREAM_REPO, branch);
let resp = client
.get(&url)
.header("Accept", "application/vnd.github+json")
.context("download .deb")?
.error_for_status()
.context(".deb download HTTP error")?
.bytes()
.await
.context("read .deb body")?;
let checksums_text = client
.get(&checksums.browser_download_url)
.send()
.await
.context("GitHub commits API")?;
if !resp.status().is_success() {
.context("download checksums")?
.error_for_status()
.context("checksums download HTTP error")?
.text()
.await
.context("read checksums body")?;
// Verify SHA256 against the checksums manifest (sha256sum format:
// "<hex>␠␠<filename>"). The filename column may include a leading
// "*" (binary mode) or a path prefix, so match on the basename.
let expected = checksums_text
.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let hash = parts.next()?;
let name = parts.next()?.trim_start_matches('*');
let base = name.rsplit('/').next().unwrap_or(name);
(base == deb.name).then(|| hash.to_lowercase())
})
.next()
.ok_or_else(|| anyhow::anyhow!("checksums-linux.txt has no entry for {}", deb.name))?;
let actual = {
let mut hasher = Sha256::new();
hasher.update(&deb_bytes);
hex::encode(hasher.finalize())
};
if actual != expected {
anyhow::bail!(
"GitHub commits API returned {} for branch {}",
resp.status(),
branch
"SHA256 mismatch for {}: expected {}, got {}",
deb.name,
expected,
actual
);
}
let body: serde_json::Value = resp.json().await.context("Parse commits JSON")?;
body.get("sha")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field"))
// Stage the verified .deb in /tmp (shared with the host — the
// service runs with PrivateTmp=no) and install it.
let dest = std::env::temp_dir().join(&deb.name);
tokio::fs::write(&dest, &deb_bytes)
.await
.with_context(|| format!("write {}", dest.display()))?;
// Run dpkg via `systemd-run` rather than `sudo dpkg` directly. The
// archipelago service runs under `ProtectSystem=strict`, so `/usr`
// and `/var/lib/dpkg` are read-only *inside the service's mount
// namespace* — and a `sudo` child inherits that namespace, so a
// bare `sudo dpkg -i` fails with "Read-only file system" on the
// dpkg database. `systemd-run` asks PID 1 to launch the command in
// a fresh transient scope outside our sandbox, where the real
// (writable) host filesystem is visible. `--wait` blocks until it
// finishes and propagates the exit status; `--pipe` forwards
// dpkg's output; `--collect` reaps the unit even on failure.
//
// dpkg flags, both load-bearing for this package specifically:
// --force-confold: the fips package ships conffiles under
// /etc/fips that archipelago rewrites at install time, so dpkg
// hits an interactive "keep/replace?" conffile prompt. With our
// closed stdin that aborts the configure step ("EOF on stdin at
// conffile prompt") and leaves the package half-unpacked
// (status `iU`), which `fips.status` then reports as
// `installed:false`. confold = keep our managed config, no prompt.
// --force-downgrade: ISO/dev nodes carry `0.3.0-dev-1`, which dpkg
// orders as NEWER than the stable tag `0.3.0` (a trailing
// `-dev` sorts above the bare release). Moving a dev build onto
// the stable line is therefore a dpkg "downgrade"; without this
// flag dpkg warns and exits non-zero. Our own version_is_older()
// gate already decided this is the wanted direction.
// DEBIAN_FRONTEND=noninteractive belt-and-suspenders against any
// other maintainer-script prompt.
let dpkg = tokio::process::Command::new("sudo")
.args([
"-n",
"systemd-run",
"--collect",
"--wait",
"--quiet",
"--pipe",
"--",
"env",
"DEBIAN_FRONTEND=noninteractive",
"dpkg",
"--force-confold",
"--force-downgrade",
"-i",
])
.arg(&dest)
.output()
.await
.context("sudo systemd-run dpkg -i failed to launch")?;
// Best-effort cleanup regardless of dpkg result.
let _ = tokio::fs::remove_file(&dest).await;
if !dpkg.status.success() {
anyhow::bail!(
"dpkg -i {} exited {}: {}",
deb.name,
dpkg.status,
String::from_utf8_lossy(&dpkg.stderr).trim()
);
}
// Restart whichever fips unit is supervising the daemon so the new
// binary takes over.
let unit = service::active_unit().await;
service::restart(unit)
.await
.with_context(|| format!("restart {} after install", unit))?;
Ok(())
}
/// `/releases/latest` returns the most recent non-prerelease, non-draft
/// release. We still re-check the flags defensively in case the endpoint
/// or repo settings change.
async fn fetch_latest_stable(client: &reqwest::Client) -> Result<Release> {
let url = format!("{}/repos/{}/releases/latest", GITHUB_API, UPSTREAM_REPO);
let resp = client
.get(&url)
.header("Accept", "application/vnd.github+json")
.send()
.await
.context("GitHub releases/latest API")?;
if !resp.status().is_success() {
anyhow::bail!("GitHub releases/latest API returned {}", resp.status());
}
let release: Release = resp.json().await.context("Parse release JSON")?;
if release.draft || release.prerelease {
anyhow::bail!(
"releases/latest returned a {} release ({})",
if release.draft { "draft" } else { "prerelease" },
release.tag_name
);
}
Ok(release)
}
fn is_deb_for_arch(name: &str) -> bool {
name.starts_with("fips_") && name.ends_with(&format!("_{}.deb", deb_arch()))
}
/// Parse the leading `MAJOR.MINOR.PATCH` triple from a version string,
/// plus whether a pre-release suffix (`-dev`, `-rc1`, …) follows it.
fn parse_version(s: &str) -> Option<((u64, u64, u64), bool)> {
// Take the first whitespace token, drop a leading 'v'.
let tok = s.split_whitespace().next().unwrap_or(s);
let tok = tok.strip_prefix('v').unwrap_or(tok);
// Split off any pre-release / build suffix.
let (core, rest) = match tok.find(|c: char| c == '-' || c == '+') {
Some(i) => (&tok[..i], &tok[i..]),
None => (tok, ""),
};
let mut it = core.split('.');
let major = it.next()?.parse::<u64>().ok()?;
let minor = it.next().unwrap_or("0").parse::<u64>().ok()?;
let patch = it.next().unwrap_or("0").parse::<u64>().ok()?;
let has_prerelease = rest.starts_with('-');
Some(((major, minor, patch), has_prerelease))
}
/// True when `installed` is strictly older than release tag `latest`.
/// Same numeric triple but `installed` carries a pre-release suffix while
/// `latest` doesn't ⇒ installed is older (e.g. `0.3.0-dev` < `v0.3.0`).
/// If either side can't be parsed, fall back to "differs ⇒ update".
fn version_is_older(installed: &str, latest: &str) -> bool {
match (parse_version(installed), parse_version(latest)) {
(Some((ic, ipre)), Some((lc, lpre))) => {
if ic != lc {
ic < lc
} else {
// Equal cores: a pre-release is older than the final release.
ipre && !lpre
}
}
_ => {
// Unparseable: be conservative — offer the update unless the
// installed string already mentions the latest tag.
!installed.contains(latest.trim_start_matches('v'))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_apply_returns_clear_stub_error() {
let err = apply().await.unwrap_err().to_string();
assert!(
err.contains("not yet wired"),
"apply() should return an explicit not-yet-wired error, got: {}",
err
);
#[test]
fn test_deb_arch_maps_known() {
// On the host running tests this is whatever the test arch is;
// just assert it returns a non-empty, lowercase token.
let a = deb_arch();
assert!(!a.is_empty());
assert_eq!(a, a.to_lowercase());
}
#[test]
fn test_version_older() {
assert!(version_is_older("0.3.0-dev (rev abc123)", "v0.3.0"));
assert!(version_is_older("0.2.1", "v0.3.0"));
assert!(version_is_older("0.3.0-rc1", "v0.3.0"));
assert!(!version_is_older("0.3.0", "v0.3.0"));
assert!(!version_is_older("0.4.0", "v0.3.0"));
assert!(!version_is_older("0.3.1", "v0.3.0"));
}
#[test]
fn test_parse_version() {
assert_eq!(parse_version("v0.3.0"), Some(((0, 3, 0), false)));
assert_eq!(parse_version("0.3.0-dev (rev x)"), Some(((0, 3, 0), true)));
assert_eq!(parse_version("0.4.0-rc1"), Some(((0, 4, 0), true)));
assert_eq!(parse_version("1.2"), Some(((1, 2, 0), false)));
}
#[test]
fn test_is_deb_for_arch() {
let arch = deb_arch();
assert!(is_deb_for_arch(&format!("fips_0.3.0_{}.deb", arch)));
assert!(!is_deb_for_arch("fips_0.3.0_someotherarch.deb"));
assert!(!is_deb_for_arch("checksums-linux.txt"));
assert!(!is_deb_for_arch(&format!(
"fips-0.3.0-linux-{}.tar.gz",
arch
)));
}
#[test]
fn test_update_check_serialises() {
let uc = UpdateCheck {
current: Some("0.2.0-abc1234".to_string()),
latest_commit: "def5678".to_string(),
current: Some("0.3.0-dev".to_string()),
latest_version: "v0.3.0".to_string(),
update_available: true,
channel: "stable".to_string(),
asset_url: Some("https://example/fips_0.3.0_amd64.deb".to_string()),
notes: "test".to_string(),
};
let json = serde_json::to_string(&uc).unwrap();
assert!(json.contains("latest_commit"));
assert!(json.contains("latest_version"));
assert!(json.contains("update_available"));
assert!(json.contains("stable"));
}
}
+119
View File
@@ -731,6 +731,89 @@ async fn restart_container(name: &str, state: &str) -> bool {
}
}
/// ElectrumX/electrs on-disk data dir. Wiped to force a clean resync when its
/// LevelDB is detected corrupt (see `maybe_recover_corrupt_electrumx`).
const ELECTRUMX_DATA_DIR: &str = "/var/lib/archipelago/electrumx";
/// Restart attempt at which we check for — and recover from — a corrupt
/// ElectrumX database. Late enough that a transient restart won't trigger a
/// destructive resync, early enough to self-heal before MAX_RESTART_ATTEMPTS.
const ELECTRUMX_DB_RESET_ATTEMPT: u32 = 3;
fn is_electrumx(name: &str) -> bool {
let id = name.strip_prefix("archy-").unwrap_or(name);
matches!(id, "electrumx" | "electrs" | "mempool-electrs")
}
/// True when a container's logs show the specific corrupt-LevelDB signature.
/// ElectrumX exit-loops with a plyvel error when its `hist`/`utxo` LevelDB
/// loses its CURRENT/MANIFEST pointer — typically after an unclean SIGKILL
/// (e.g. the cgroup cascade on a service restart). We match the exact failure
/// so a normal restart never triggers the destructive resync below.
fn looks_like_corrupt_electrumx_db(logs: &str) -> bool {
logs.contains("create_if_missing is false")
|| logs.contains("Corruption:")
|| (logs.contains("plyvel") && logs.contains("does not exist"))
}
async fn electrumx_db_corrupt(name: &str) -> bool {
let out = tokio::time::timeout(
std::time::Duration::from_secs(15),
tokio::process::Command::new("podman")
.args(["logs", "--tail", "60", name])
.output(),
)
.await;
match out {
Ok(Ok(output)) => {
let logs = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
looks_like_corrupt_electrumx_db(&logs)
}
_ => false,
}
}
/// Wipe the ElectrumX LevelDB stores so the next start resyncs from scratch.
/// Files are owned by the container's mapped UID, so removal needs host sudo.
/// The mount point itself is preserved (the container expects it to exist).
/// Returns true if the reset succeeded.
async fn reset_electrumx_data() -> bool {
let rm = format!(
"rm -rf {dir}/hist {dir}/utxo {dir}/meta {dir}/COIN",
dir = ELECTRUMX_DATA_DIR,
);
matches!(
crate::update::host_sudo(&["sh", "-c", &rm]).await,
Ok(status) if status.success()
)
}
/// Self-heal a wedged ElectrumX: if it's exit-looping on a corrupt database,
/// wipe its data dir once (at `ELECTRUMX_DB_RESET_ATTEMPT`) so the impending
/// restart resyncs cleanly. Bounded to electrs containers, gated on the exact
/// corruption signature, and fired once per failure streak — when the resync
/// stabilises the restart tracker clears, so a future corruption can heal too.
async fn maybe_recover_corrupt_electrumx(name: &str, attempt: u32) {
if attempt != ELECTRUMX_DB_RESET_ATTEMPT || !is_electrumx(name) {
return;
}
if !electrumx_db_corrupt(name).await {
return;
}
warn!(
"ElectrumX {} is exit-looping on a corrupt database — resetting its data dir to force a clean resync",
name
);
if reset_electrumx_data().await {
info!("ElectrumX data dir reset; a fresh resync will begin on restart");
} else {
warn!("Failed to reset ElectrumX data dir (host sudo rm failed) — manual recovery may be needed");
}
}
/// Spawn the health monitor background task.
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
tokio::spawn(async move {
@@ -993,6 +1076,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
.unwrap_or(&90)
);
// Before restarting, self-heal a corrupt ElectrumX DB so
// the restart resyncs cleanly instead of crash-looping.
maybe_recover_corrupt_electrumx(&container.name, attempt).await;
let restarted = restart_container(&container.name, &container.state).await;
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
@@ -1471,4 +1558,36 @@ mod tests {
]
);
}
#[test]
fn is_electrumx_matches_electrs_variants_only() {
assert!(is_electrumx("electrumx"));
assert!(is_electrumx("archy-electrumx"));
assert!(is_electrumx("electrs"));
assert!(is_electrumx("mempool-electrs"));
assert!(!is_electrumx("bitcoin-knots"));
assert!(!is_electrumx("lnd"));
assert!(!is_electrumx("electrs-ui")); // the web UI, not the indexer
}
#[test]
fn corrupt_db_detection_matches_plyvel_signature() {
// The exact line ElectrumX exit-loops on (observed on .116).
let corrupt = "plyvel._plyvel.Error: b'Invalid argument: hist: does not exist (create_if_missing is false)'";
assert!(looks_like_corrupt_electrumx_db(corrupt));
assert!(looks_like_corrupt_electrumx_db(
"Corruption: bad block in hist"
));
}
#[test]
fn corrupt_db_detection_ignores_healthy_logs() {
let healthy = "INFO:BlockProcessor:our height: 117,009 daemon: 953,480 UTXOs 28MB\n\
INFO:SessionManager:RPC server listening on 0.0.0.0:8000";
assert!(!looks_like_corrupt_electrumx_db(healthy));
// "catching up" / normal restart noise must not trigger a destructive wipe.
assert!(!looks_like_corrupt_electrumx_db(
"Prefetcher:catching up to daemon height 953,480"
));
}
}
+40 -1
View File
@@ -71,7 +71,7 @@ async fn build_telemetry_report(
data_dir: &std::path::Path,
) -> anyhow::Result<serde_json::Value> {
// Anonymous node ID — truncated SHA-256 hash of pubkey
let (node_id, version, container_count, running_count, peer_count, containers) =
let (node_id, node_name, version, container_count, running_count, peer_count, containers) =
if let Some(ref sm) = state {
let (data, _) = sm.get_snapshot().await;
let id = {
@@ -98,6 +98,10 @@ async fn build_telemetry_report(
.count();
(
id,
data.server_info
.name
.clone()
.filter(|n| !n.trim().is_empty()),
data.server_info.version.clone(),
data.package_data.len(),
running,
@@ -107,6 +111,7 @@ async fn build_telemetry_report(
} else {
(
"unknown".to_string(),
None,
"unknown".to_string(),
0,
0,
@@ -125,6 +130,8 @@ async fn build_telemetry_report(
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
.map(|f| f as u64)
.unwrap_or(0);
let hostname = system_hostname().await;
let server_url = local_server_url(data_dir).await;
// Latest metrics snapshot
let latest = store.latest().await;
@@ -166,6 +173,9 @@ async fn build_telemetry_report(
Ok(serde_json::json!({
"node_id": node_id,
"node_name": node_name,
"hostname": hostname,
"server_url": server_url,
"version": version,
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
@@ -181,6 +191,35 @@ async fn build_telemetry_report(
}))
}
async fn system_hostname() -> Option<String> {
let output = tokio::process::Command::new("hostname")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!hostname.is_empty()).then_some(hostname)
}
async fn local_server_url(data_dir: &std::path::Path) -> Option<String> {
let _ = data_dir;
let output = tokio::process::Command::new("hostname")
.arg("-I")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let ip = String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.find(|ip| !ip.starts_with("127.") && ip.contains('.'))?
.to_string();
Some(format!("https://{ip}"))
}
/// POST a telemetry report to the central collector.
async fn post_telemetry_report(url: &str, report: &serde_json::Value) -> anyhow::Result<()> {
let client = reqwest::Client::builder()
+1
View File
@@ -28,6 +28,7 @@ const RESERVED_PORTS: &[u16] = &[
8888, // SearXNG
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
8443, // FIPS TCP fallback
8336, // FIPS UI (fips-ui)
];
/// Start of range for allocating web app ports when preferred is taken.
+52 -8
View File
@@ -15,6 +15,7 @@ use hyper::server::conn::Http;
use hyper::service::service_fn;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -28,6 +29,25 @@ pub struct Server {
_state_manager: Arc<StateManager>,
}
struct ContainerScanGuard<'a> {
scanning: &'a AtomicBool,
}
impl<'a> ContainerScanGuard<'a> {
fn try_acquire(scanning: &'a AtomicBool) -> Option<Self> {
scanning
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.ok()
.map(|_| Self { scanning })
}
}
impl Drop for ContainerScanGuard<'_> {
fn drop(&mut self) {
self.scanning.store(false, Ordering::Release);
}
}
impl Server {
pub async fn new(
config: Config,
@@ -362,7 +382,7 @@ impl Server {
// Skip missed ticks instead of catching up — prevents burst of scans
// after a slow podman response (which causes DB lock storms)
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let scanning = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let scanning = std::sync::Arc::new(AtomicBool::new(false));
loop {
tokio::select! {
_ = interval.tick() => {}
@@ -377,13 +397,12 @@ impl Server {
continue;
}
}
if scanning.load(std::sync::atomic::Ordering::Relaxed) {
let Some(_scan_guard) = ContainerScanGuard::try_acquire(&scanning) else {
debug!("Skipping container scan — previous scan still in progress");
scan_tick.send_modify(|n| *n = n.wrapping_add(1));
continue;
}
scanning.store(true, std::sync::atomic::Ordering::Relaxed);
if let Err(e) = scan_and_update_packages(
};
let scan_result = scan_and_update_packages(
&scanner,
&state,
identity_clone.as_ref(),
@@ -391,8 +410,8 @@ impl Server {
&mut absence_tracker,
&mut transitional_since,
)
.await
{
.await;
if let Err(e) = scan_result {
error!("Failed to update containers: {}", e);
if is_podman_scan_timeout(&e) {
scan_backoff_until = Some(Instant::now() + Duration::from_secs(30));
@@ -402,7 +421,6 @@ impl Server {
scan_backoff_until = None;
}
scan_tick.send_modify(|n| *n = n.wrapping_add(1));
scanning.store(false, std::sync::atomic::Ordering::Relaxed);
}
});
}
@@ -751,6 +769,13 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
| "/archipelago/mesh-typed"
| "/dwn"
| "/transport/inbox"
// Content *catalog* — the peer-browse entry point. This is the
// exact path `/content` (no trailing slash); the prefix match
// below only covers `/content/<id>` item fetches, so without
// this the catalog 404s over the mesh and `content.browse-peer`
// fails with "Peer returned error: 404 Not Found" (and never
// falls back to Tor, since a 404 is a successful HTTP exchange).
| "/content"
)
// Prefix-matched content endpoints (peer file browse + fetch)
|| path.starts_with("/content/")
@@ -1360,6 +1385,25 @@ mod merge_tests {
}
}
#[test]
fn peer_path_filter_allows_content_catalog_and_items() {
// Regression: the content *catalog* is exactly "/content" (no trailing
// slash). It must be reachable over the peer (FIPS) listener, else
// `content.browse-peer` 404s over the mesh. Item fetches are
// "/content/<id>".
assert!(is_peer_allowed_path("/content"), "catalog must be allowed");
assert!(
is_peer_allowed_path("/content/abc123"),
"items must be allowed"
);
assert!(is_peer_allowed_path("/rpc/v1"));
assert!(is_peer_allowed_path("/health"));
// Not on the allow-list → rejected (no broad surface over the mesh).
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/"));
assert!(!is_peer_allowed_path("/rpc/v2"));
}
#[test]
fn preserves_transitional_state_on_merge() {
// existing: user initiated a stop, spawn_transitional set Stopping.
+157 -50
View File
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use tokio::fs;
use tracing::{debug, info};
use tracing::{debug, info, warn};
/// Live download progress counters. Updated by download_component_resumable
/// as bytes arrive and read by the update.status RPC so the UI can show
@@ -66,10 +66,6 @@ fn is_newer(candidate: &str, current: &str) -> bool {
const DEFAULT_UPDATE_MANIFEST_URL: &str =
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
/// Secondary mirror on tx1138 gitea — independent network path so a
/// single-provider outage doesn't knock out both mirrors.
const DEFAULT_SECONDARY_MIRROR_URL: &str =
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json";
const UPDATE_STATE_FILE: &str = "update_state.json";
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
/// Marker written by apply_update() just before the service restart and
@@ -107,16 +103,10 @@ 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(),
},
UpdateMirror {
url: DEFAULT_SECONDARY_MIRROR_URL.to_string(),
label: "Server 2 (tx1138)".to_string(),
},
]
vec![UpdateMirror {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Server 1 (OVH)".to_string(),
}]
}
/// Load the operator-configured mirror list. Returns defaults if the
@@ -144,14 +134,17 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
return Ok(default_mirrors());
}
// One-time migration: the Hetzner VPS at 23.182.128.160 was
// decommissioned 2026-04-23. Existing nodes have it baked into their
// saved mirror list (was the original Server 1). Strip it on load so
// we don't spend seconds per install timing out against a dead host.
// Exception to the usual "explicit removals stick" rule: the user
// never chose to add this — it was a default.
// One-time migrations: drop decommissioned release servers that may be
// baked into existing nodes' saved mirror lists. Strip them on load so
// we don't spend seconds per install timing out against a dead/stale host.
// - 23.182.128.160: Hetzner VPS, decommissioned 2026-04-23.
// - git.tx1138.com: retired as a release server 2026-06-13 — its main
// branch had diverged and stopped receiving releases, so it only
// ever served a stale manifest as the secondary mirror.
// Exception to the usual "explicit removals stick" rule: the user never
// chose to add these — they were defaults.
let before = list.len();
list.retain(|m| !m.url.contains("23.182.128.160"));
list.retain(|m| !m.url.contains("23.182.128.160") && !m.url.contains("git.tx1138.com"));
let mut changed = list.len() != before;
// Merge in any default URLs the saved config is missing.
@@ -182,17 +175,13 @@ 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();
} else if mirror.url == DEFAULT_SECONDARY_MIRROR_URL {
mirror.label = "Server 2 (tx1138)".to_string();
}
}
list.sort_by_key(|m| {
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
0
} else if m.url == DEFAULT_SECONDARY_MIRROR_URL {
1
} else {
2
1
}
});
}
@@ -367,12 +356,20 @@ async fn probe_frontend_once() -> Result<()> {
.context("build probe client")?;
// Prefer HTTPS since that's the failure mode we're catching (nginx
// 500 on the PWA). HTTP usually redirects to HTTPS and would mask
// the bug.
let resp = client
.get("https://127.0.0.1/")
.send()
.await
.context("probe GET https://127.0.0.1/")?;
// the bug. BUT not every node binds 443 on loopback (.116 serves
// plain HTTP; 443 there belongs to tailscale) — on a *connect*
// error, fall back to HTTP so a healthy node isn't "verified" into
// a rollback. An HTTP error status stays fatal on whichever scheme
// answered.
let resp = match client.get("https://127.0.0.1/").send().await {
Ok(resp) => resp,
Err(e) if e.is_connect() => client
.get("http://127.0.0.1/")
.send()
.await
.context("probe GET http://127.0.0.1/ (https not bound on loopback)")?,
Err(e) => return Err(e).context("probe GET https://127.0.0.1/"),
};
let status = resp.status();
if status.is_success() || status.is_redirection() {
return Ok(());
@@ -502,6 +499,8 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
.context("Reading update state")?;
let mut state: UpdateState = serde_json::from_str(&data).context("Parsing update state")?;
let mut changed = false;
// Keep current_version in sync with the binary. Sideloaded nodes
// (ssh + cp /usr/local/bin/archipelago) don't touch the state file,
// so without this the running 1.7.0-alpha binary would keep seeing
@@ -517,11 +516,36 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
// if there's genuinely something newer.
state.available_update = None;
state.manifest_mirror = None;
changed = true;
}
// `update_in_progress` means a manifest OTA is downloaded and staged,
// ready for apply. Older git/self-build update paths could leave this
// flag stuck true without a staging directory, which traps the UI in an
// unrecoverable state. Heal that on every state load.
if state.update_in_progress && !has_staged_update(data_dir).await {
warn!(
staging = %data_dir.join("update-staging").display(),
"Clearing stale update_in_progress without staged OTA files"
);
state.update_in_progress = false;
changed = true;
}
if changed {
save_state(data_dir, &state).await?;
}
Ok(state)
}
async fn has_staged_update(data_dir: &Path) -> bool {
let staging_dir = data_dir.join("update-staging");
let Ok(mut entries) = fs::read_dir(&staging_dir).await else {
return false;
};
matches!(entries.next_entry().await, Ok(Some(_)))
}
pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {
let path = data_dir.join(UPDATE_STATE_FILE);
let data = serde_json::to_string_pretty(state)?;
@@ -728,10 +752,15 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
.context("Failed to create staging dir")?;
let client = reqwest::Client::builder()
// Per-request budget; each attempt gets the full hour. A retry
// restarts the budget cleanly.
.timeout(std::time::Duration::from_secs(3600))
.connect_timeout(std::time::Duration::from_secs(30))
// Per-request budget; each attempt gets the full window, and a retry
// resumes via Range from the partial file (download_component_resumable),
// so this is an upper bound per attempt, not the whole download. Sized
// generously for slow machines on slow links: a ~200MB release at a
// crawling ~50KB/s is ~70min, which the old 1h budget could cut off
// mid-attempt. 3h leaves ample headroom; raising it cannot slow down or
// break a fast download (those finish well inside the old limit).
.timeout(std::time::Duration::from_secs(10800))
.connect_timeout(std::time::Duration::from_secs(60))
.build()
.context("Failed to create HTTP client")?;
@@ -1051,6 +1080,17 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
let current_binary = Path::new("/usr/local/bin/archipelago");
if current_binary.exists() {
let backup_path = backup_dir.join("archipelago");
// A leftover backup from an earlier rollback can be root-owned
// (rollback used to chown it in place), and fs::copy O_TRUNCs the
// existing file — EACCES as the service user, wedging every apply
// (seen on .116, v1.7.86 OTA). Unlink first; the dir is
// service-owned so unlink works even when the file isn't ours.
if backup_path.exists() {
if let Err(e) = fs::remove_file(&backup_path).await {
tracing::warn!(error = %e, "unlink of stale binary backup failed, retrying via host_sudo");
let _ = host_sudo(&["rm", "-f", &backup_path.to_string_lossy()]).await;
}
}
fs::copy(current_binary, &backup_path)
.await
.context("Failed to backup current binary")?;
@@ -1388,21 +1428,38 @@ pub async fn rollback_update(data_dir: &Path) -> Result<()> {
let backup_binary = backup_dir.join("archipelago");
if backup_binary.exists() {
// Use host_sudo + mv so we escape the archipelago service's
// ProtectSystem=strict mount namespace. A plain fs::copy or
// `sudo cp` from inside the service hits EROFS on /usr/local/bin,
// which would silently orphan the rollback — exactly the
// opposite of what auto-rollback is for. Pattern matches
// apply_update()'s binary swap above.
// Same two namespace gotchas as apply_update()'s binary swap:
// `cp` straight onto the running binary is O_TRUNC and fails
// ETXTBSY (exit 1 — exactly what broke the .116 rollback), and
// plain sudo inherits ProtectSystem=strict, so everything goes
// through host_sudo. Copy to a temp name on the same filesystem,
// fix ownership on the TEMP file (never the stored backup — an
// in-place chown is what later wedged apply_update), then mv,
// which is an atomic rename and tolerates a busy destination.
let backup_str = backup_binary.to_string_lossy().to_string();
let _ = host_sudo(&["chmod", "0755", &backup_str]).await;
let _ = host_sudo(&["chown", "root:root", &backup_str]).await;
let status = host_sudo(&["cp", &backup_str, "/usr/local/bin/archipelago"])
let tmp = format!(
"/usr/local/bin/.archipelago.rollback.{}",
chrono::Utc::now().timestamp_millis()
);
let copy = host_sudo(&["cp", &backup_str, &tmp])
.await
.context("Failed to stage backup binary via host_sudo")?;
if !copy.success() {
anyhow::bail!(
"cp backup binary to {} failed (exit {:?})",
tmp,
copy.code()
);
}
let _ = host_sudo(&["chmod", "0755", &tmp]).await;
let _ = host_sudo(&["chown", "root:root", &tmp]).await;
let status = host_sudo(&["mv", &tmp, "/usr/local/bin/archipelago"])
.await
.context("Failed to restore backup binary via host_sudo")?;
if !status.success() {
let _ = host_sudo(&["rm", "-f", &tmp]).await;
anyhow::bail!(
"cp backup binary into /usr/local/bin failed (exit {:?})",
"mv backup binary into /usr/local/bin failed (exit {:?})",
status.code()
);
}
@@ -1620,9 +1677,38 @@ 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(), 2);
assert_eq!(list.len(), 1);
assert!(list[0].url.contains("146.59.87.168"));
assert!(list[1].url.contains("git.tx1138.com"));
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"
);
}
#[tokio::test]
async fn test_load_mirrors_strips_retired_tx1138_mirror() {
// A node that was running before tx1138 was retired has it baked
// into its saved mirror list. load_mirrors must strip it on load.
let dir = tempfile::tempdir().unwrap();
let saved = vec![
UpdateMirror {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Server 1 (OVH)".to_string(),
},
UpdateMirror {
url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
.to_string(),
label: "Server 2 (tx1138)".to_string(),
},
];
save_mirrors(dir.path(), &saved).await.unwrap();
let list = load_mirrors(dir.path()).await.unwrap();
assert!(
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
"retired tx1138 mirror should be stripped on load; got {:?}",
list
);
assert!(list.iter().any(|m| m.url.contains("146.59.87.168")));
}
#[tokio::test]
@@ -1636,7 +1722,7 @@ mod tests {
let back = load_mirrors(dir.path()).await.unwrap();
// load_mirrors merges in any missing default mirrors so a node
// that explicitly added a single custom mirror still gets the
// built-in OVH + tx1138 fallbacks. The custom mirror is preserved.
// built-in OVH default. The custom mirror is preserved.
assert!(
back.iter().any(|m| m.url == "https://example.com/m.json"),
"custom mirror should round-trip; got {:?}",
@@ -1764,6 +1850,11 @@ mod tests {
#[tokio::test]
async fn test_save_and_load_state_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("update-staging");
tokio::fs::create_dir_all(&staging).await.unwrap();
tokio::fs::write(staging.join("archipelago"), b"staged")
.await
.unwrap();
let state = UpdateState {
current_version: "1.0.0".to_string(),
last_check: Some("2025-06-15T12:00:00Z".to_string()),
@@ -1800,6 +1891,22 @@ mod tests {
assert!(loaded.available_update.is_none());
}
#[tokio::test]
async fn test_load_state_clears_stale_in_progress_without_staging() {
let dir = tempfile::tempdir().unwrap();
let state = UpdateState {
update_in_progress: true,
..UpdateState::default()
};
save_state(dir.path(), &state).await.unwrap();
let loaded = load_state(dir.path()).await.unwrap();
assert!(!loaded.update_in_progress);
let persisted = load_state(dir.path()).await.unwrap();
assert!(!persisted.update_in_progress);
}
#[tokio::test]
async fn test_dismiss_update_clears_available() {
let dir = tempfile::tempdir().unwrap();
-1
View File
@@ -123,7 +123,6 @@ impl PodmanClient {
"immich_server" | "immich" => "http://localhost:2283",
"nginx-proxy-manager" => "http://localhost:8081",
"fedimint-gateway" => "http://localhost:8176",
"dwn" => "http://localhost:3100",
"endurain" => "http://localhost:8080",
"netbird" => "http://localhost:8087",
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
+19
View File
@@ -0,0 +1,19 @@
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
# Static site content.
COPY index.html /usr/share/nginx/html/
#
# FIPS UI talks only to the archipelago RPC on 127.0.0.1:5678, using the
# browser's own archipelago session — there is NO per-node secret to
# substitute, so (unlike bitcoin-ui) the nginx config is baked straight
# into the image rather than bind-mounted/rendered at container-create.
COPY nginx.conf /etc/nginx/conf.d/default.conf
#
# Run nginx as root to avoid chown failures in rootless Podman user
# namespaces. The rest of the nginx image is unchanged.
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
/var/cache/nginx/scgi_temp
EXPOSE 8336
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]
+478
View File
@@ -0,0 +1,478 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FIPS Mesh</title>
<style>
:root {
--bg: #0e1116;
--panel: #161b22;
--panel-2: #1c232c;
--border: #2a323d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #2f81f7;
--ok: #2ea043;
--warn: #d29922;
--bad: #f85149;
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.wrap { max-width: 860px; margin: 0 auto; padding: 24px 18px 64px; }
header { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
header h1 { font-size: 20px; margin: 0; font-weight: 600; }
.sub { color: var(--muted); margin: 0 0 20px; font-size: 13px; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
margin-bottom: 16px;
}
.card h2 {
font-size: 13px; text-transform: uppercase; letter-spacing: .04em;
color: var(--muted); margin: 0 0 14px; font-weight: 600;
}
.row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; gap: 12px; }
.row + .row { border-top: 1px solid var(--border); }
.row .k { color: var(--muted); }
.row .v { font-variant-numeric: tabular-nums; text-align: right; word-break: break-all; }
.pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
border: 1px solid transparent;
}
.pill::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.pill.ok { color: var(--ok); background: rgba(46,160,67,.12); }
.pill.warn { color: var(--warn); background: rgba(210,153,34,.12); }
.pill.bad { color: var(--bad); background: rgba(248,81,73,.12); }
.pill.muted { color: var(--muted); background: rgba(139,148,158,.12); }
.btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
button {
font: inherit; font-weight: 600; cursor: pointer;
background: var(--panel-2); color: var(--text);
border: 1px solid var(--border); border-radius: 8px; padding: 8px 14px;
}
button:hover:not(:disabled) { border-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.danger { color: var(--bad); }
button:disabled { opacity: .5; cursor: default; }
input, select {
font: inherit; background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; width: 100%;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.grid .full { grid-column: 1 / -1; }
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.anchor-item { padding: 10px 0; }
.anchor-item + .anchor-item { border-top: 1px solid var(--border); }
.anchor-item .top { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
.anchor-item .addr { color: var(--muted); font-size: 12px; word-break: break-all; }
.anchor-item .npub { font-size: 12px; color: var(--muted); word-break: break-all; }
.notice { padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-top: 10px; display: none; }
.notice.show { display: block; }
.notice.info { background: rgba(47,129,247,.12); color: var(--accent); }
.notice.good { background: rgba(46,160,67,.12); color: var(--ok); }
.notice.error { background: rgba(248,81,73,.12); color: var(--bad); }
.spin { display: inline-block; width: 13px; height: 13px; border: 2px solid currentColor;
border-right-color: transparent; border-radius: 50%; animation: r .7s linear infinite; vertical-align: -2px; }
@keyframes r { to { transform: rotate(360deg); } }
.muted-note { color: var(--muted); font-size: 12px; margin-top: 8px; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>FIPS Mesh</h1>
<span id="anchorPill" class="pill muted">Loading…</span>
</header>
<p class="sub">Encrypted mesh transport. This node reaches the network through seed anchors; a connected anchor keeps FIPS routing fast instead of degrading to Tor.</p>
<div class="card">
<h2>Status</h2>
<div class="row"><span class="k">Daemon installed</span><span class="v" id="sInstalled"></span></div>
<div class="row"><span class="k">Version</span><span class="v" id="sVersion"></span></div>
<div class="row"><span class="k">Service</span><span class="v" id="sService"></span></div>
<div class="row"><span class="k">Seed key present</span><span class="v" id="sKey"></span></div>
<div class="row"><span class="k">Authenticated peers</span><span class="v" id="sPeers"></span></div>
<div class="row"><span class="k">Anchor connected</span><span class="v" id="sAnchor"></span></div>
<div class="row"><span class="k">This node's npub</span><span class="v" id="sNpub"></span></div>
<div class="btns">
<button id="btnRefresh">Refresh</button>
<button id="btnReconnect">Reconnect</button>
<button id="btnRestart">Restart daemon</button>
<button id="btnInstall">Install / repair</button>
</div>
<div id="actionNotice" class="notice"></div>
</div>
<div class="card">
<h2>This node as an anchor</h2>
<p class="muted-note" style="margin-top:0">Share these with another node's operator so they can add this node as a seed anchor (their <em>Seed Anchors → Add</em> form). The address is whatever host you reached this dashboard at, so it's reachable the same way you got here.</p>
<div class="row">
<span class="k">npub</span>
<span class="v" style="display:flex;gap:8px;align-items:center">
<code id="ownNpub" style="font-size:12px"></code>
<button data-copy="ownNpub">Copy</button>
</span>
</div>
<div class="row">
<span class="k">Address</span>
<span class="v" style="display:flex;gap:8px;align-items:center">
<code id="ownAddr" style="font-size:12px"></code>
<button data-copy="ownAddr">Copy</button>
</span>
</div>
<div id="ownReach" class="muted-note"></div>
<div id="copyNotice" class="notice"></div>
</div>
<div class="card">
<h2>Updates · stable channel</h2>
<div class="row"><span class="k">Installed</span><span class="v" id="uCurrent"></span></div>
<div class="row"><span class="k">Latest stable</span><span class="v" id="uLatest"></span></div>
<div class="row"><span class="k">Status</span><span class="v" id="uStatus"></span></div>
<div class="btns">
<button id="btnCheck">Check for updates</button>
<button id="btnApply" class="primary" disabled>Apply update</button>
</div>
<div id="updateNotice" class="notice"></div>
<p class="muted-note">Updates download the signed <code>.deb</code> from the upstream <code>jmcorgan/fips</code> releases, verify its SHA-256 against the published checksums, install it, and restart the daemon.</p>
</div>
<div class="card">
<h2>Seed Anchors</h2>
<div id="anchorList"><p class="muted-note">Loading…</p></div>
<div style="margin-top:14px">
<div class="grid">
<div class="full"><label>npub</label><input id="aNpub" placeholder="npub1…" autocomplete="off" spellcheck="false" /></div>
<div><label>Address (host:port)</label><input id="aAddr" placeholder="192.168.1.116:8668" autocomplete="off" spellcheck="false" /></div>
<div><label>Transport</label><select id="aTransport"><option value="udp">udp</option><option value="tcp">tcp</option></select></div>
<div class="full"><label>Label (optional)</label><input id="aLabel" placeholder="Home anchor" autocomplete="off" /></div>
</div>
<div class="btns">
<button id="btnAddAnchor" class="primary">Add anchor</button>
<button id="btnApplyAnchors">Re-apply all</button>
</div>
<div id="anchorNotice" class="notice"></div>
</div>
</div>
</div>
<script>
const RPC_ENDPOINT = '/rpc/v1';
function cookieValue(name) {
return document.cookie
.split('; ')
.find(row => row.startsWith(`${name}=`))
?.split('=').slice(1).join('=') || '';
}
async function rpc(method, params = {}) {
const headers = { 'Content-Type': 'application/json' };
const csrf = cookieValue('csrf_token');
if (csrf) headers['X-CSRF-Token'] = decodeURIComponent(csrf);
const res = await fetch(RPC_ENDPOINT, {
method: 'POST',
headers,
credentials: 'include',
cache: 'no-store',
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body.error) {
throw new Error(body.error?.message || `RPC ${method} failed (${res.status})`);
}
return body.result;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function setText(id, v, fallback = '—') {
const el = document.getElementById(id);
if (el) el.textContent = (v === null || v === undefined || v === '') ? fallback : v;
}
function pill(state, text) {
return `<span class="pill ${state}">${escapeHtml(text)}</span>`;
}
function setHtml(id, html) {
const el = document.getElementById(id);
if (el) el.innerHTML = html;
}
function notice(id, kind, msg) {
const el = document.getElementById(id);
if (!el) return;
if (!msg) { el.className = 'notice'; el.textContent = ''; return; }
el.className = `notice show ${kind}`;
el.innerHTML = msg;
}
function busy(btn, on, label) {
if (!btn) return;
if (on) {
btn.dataset.label = btn.dataset.label || btn.textContent;
btn.disabled = true;
btn.innerHTML = `<span class="spin"></span> ${escapeHtml(label || btn.dataset.label)}`;
} else {
btn.disabled = false;
btn.textContent = btn.dataset.label || btn.textContent;
}
}
function renderStatus(s) {
setText('sInstalled', s.installed ? 'Yes' : 'No');
setText('sVersion', s.version);
const active = s.service_active;
setHtml('sService', active
? pill('ok', s.service_state || 'active')
: pill('bad', s.service_state || 'inactive'));
setHtml('sKey', s.key_present ? pill('ok', 'present') : pill('warn', 'missing'));
const peers = s.authenticated_peer_count || 0;
setHtml('sPeers', peers > 0 ? pill('ok', String(peers)) : pill('warn', '0 — isolated'));
setHtml('sAnchor', s.anchor_connected ? pill('ok', 'connected') : pill('bad', 'unreachable'));
setText('sNpub', s.npub);
if (s.npub) document.getElementById('ownNpub').textContent = s.npub;
const ap = document.getElementById('anchorPill');
if (s.anchor_connected) { ap.className = 'pill ok'; ap.textContent = 'Anchor connected'; }
else if (peers > 0) { ap.className = 'pill warn'; ap.textContent = 'Peers, no anchor'; }
else if (s.service_active) { ap.className = 'pill bad'; ap.textContent = 'Isolated'; }
else { ap.className = 'pill muted'; ap.textContent = 'Daemon down'; }
}
async function loadStatus() {
try {
const s = await rpc('fips.status');
renderStatus(s);
setText('uCurrent', s.version, 'not installed');
} catch (e) {
notice('actionNotice', 'error', escapeHtml(e.message));
}
}
function renderAnchors(list) {
if (!list || list.length === 0) {
setHtml('anchorList', '<p class="muted-note">No seed anchors configured. Add one below so this node can reach the mesh.</p>');
return;
}
const html = list.map(a => `
<div class="anchor-item">
<div class="top">
<strong>${escapeHtml(a.label || a.address)}</strong>
<button class="danger" data-remove="${escapeHtml(a.npub)}">Remove</button>
</div>
<div class="addr">${escapeHtml(a.address)} · ${escapeHtml(a.transport || 'udp')}</div>
<div class="npub">${escapeHtml(a.npub)}</div>
</div>`).join('');
setHtml('anchorList', html);
document.querySelectorAll('[data-remove]').forEach(btn => {
btn.addEventListener('click', () => removeAnchor(btn.dataset.remove, btn));
});
}
async function loadAnchors() {
try {
const r = await rpc('fips.list-seed-anchors');
renderAnchors(r.seed_anchors);
} catch (e) {
setHtml('anchorList', `<p class="muted-note">${escapeHtml(e.message)}</p>`);
}
}
async function removeAnchor(npub, btn) {
busy(btn, true, 'Removing');
notice('anchorNotice', '', '');
try {
const r = await rpc('fips.remove-seed-anchor', { npub });
renderAnchors(r.seed_anchors);
notice('anchorNotice', 'good', 'Anchor removed.');
} catch (e) {
notice('anchorNotice', 'error', escapeHtml(e.message));
busy(btn, false);
}
}
// --- wire up buttons ---
document.getElementById('btnRefresh').addEventListener('click', loadStatus);
document.getElementById('btnReconnect').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Reconnecting…');
notice('actionNotice', 'info', 'Restarting the daemon and waiting for an anchor — this takes about 20 seconds…');
try {
const r = await rpc('fips.reconnect');
renderStatus(r.after);
const kind = r.after.anchor_connected ? 'good' : 'error';
notice('actionNotice', kind, `${escapeHtml(r.hint || r.likely_cause)}`);
} catch (err) {
notice('actionNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnRestart').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Restarting…');
notice('actionNotice', '', '');
try {
const r = await rpc('fips.restart');
notice('actionNotice', 'good', `Restarted ${escapeHtml(r.unit || 'fips service')}.`);
await loadStatus();
} catch (err) {
notice('actionNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnInstall').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Installing…');
notice('actionNotice', '', '');
try {
const s = await rpc('fips.install');
renderStatus(s);
notice('actionNotice', 'good', 'Config and key re-materialised; service activated.');
} catch (err) {
notice('actionNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnCheck').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Checking…');
notice('updateNotice', '', '');
const applyBtn = document.getElementById('btnApply');
try {
const c = await rpc('fips.check-update');
setText('uCurrent', c.current, 'not installed');
setText('uLatest', c.latest_version);
setHtml('uStatus', c.update_available ? pill('warn', 'update available') : pill('ok', 'up to date'));
applyBtn.disabled = !c.update_available;
notice('updateNotice', c.update_available ? 'info' : 'good', escapeHtml(c.notes || ''));
} catch (err) {
notice('updateNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnApply').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Updating…');
notice('updateNotice', 'info', 'Downloading, verifying, and installing the new FIPS daemon, then restarting it…');
try {
await rpc('fips.apply-update');
notice('updateNotice', 'good', 'Update installed and daemon restarted.');
btn.disabled = true;
await loadStatus();
await rpc('fips.check-update').then(c => {
setText('uLatest', c.latest_version);
setHtml('uStatus', c.update_available ? pill('warn', 'update available') : pill('ok', 'up to date'));
}).catch(() => {});
} catch (err) {
notice('updateNotice', 'error', escapeHtml(err.message));
busy(btn, false);
}
});
document.getElementById('btnAddAnchor').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const npub = document.getElementById('aNpub').value.trim();
const address = document.getElementById('aAddr').value.trim();
const transport = document.getElementById('aTransport').value;
const label = document.getElementById('aLabel').value.trim();
if (!npub.startsWith('npub1')) { notice('anchorNotice', 'error', 'npub must start with npub1…'); return; }
if (!address.includes(':')) { notice('anchorNotice', 'error', 'Address must be host:port (e.g. 192.168.1.116:8668).'); return; }
busy(btn, true, 'Adding…');
notice('anchorNotice', '', '');
try {
const r = await rpc('fips.add-seed-anchor', { npub, address, transport, label });
renderAnchors(r.seed_anchors);
const applied = (r.apply || []).find(x => x.npub === npub);
const ok = applied ? applied.ok : true;
notice('anchorNotice', ok ? 'good' : 'info',
ok ? 'Anchor added and pushed to the running daemon.' : `Anchor saved. Daemon push: ${escapeHtml(applied?.message || 'pending')}`);
['aNpub','aAddr','aLabel'].forEach(id => document.getElementById(id).value = '');
await loadStatus();
} catch (err) {
notice('anchorNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnApplyAnchors').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Applying…');
notice('anchorNotice', '', '');
try {
const r = await rpc('fips.apply-seed-anchors');
const okCount = (r.results || []).filter(x => x.ok).length;
notice('anchorNotice', 'good', `Re-applied ${okCount}/${r.applied || 0} anchors to the daemon.`);
await loadStatus();
} catch (err) {
notice('anchorNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
// This node's own anchor address: the host the operator reached the
// dashboard at is, by definition, reachable for them — so it's the
// right dial hint. FIPS listens on UDP 8668.
const FIPS_PORT = 8668;
function isPrivateLan(host) {
return /^10\./.test(host)
|| /^192\.168\./.test(host)
|| /^172\.(1[6-9]|2\d|3[01])\./.test(host)
|| host === 'localhost' || host === '127.0.0.1';
}
function populateOwnAnchor() {
const host = window.location.hostname;
const addr = `${host}:${FIPS_PORT}`;
document.getElementById('ownAddr').textContent = addr;
const reach = document.getElementById('ownReach');
if (/^100\./.test(host)) {
reach.innerHTML = 'This is a Tailscale address — reachable by any node on your tailnet, including over the internet.';
} else if (isPrivateLan(host)) {
reach.innerHTML = '⚠ This is a private LAN address — it only works for nodes on the same local network. For a node across the internet, share this nodes Tailscale (100.x) or public IP with UDP 8668 reachable, or have both nodes use a common public anchor instead.';
} else {
reach.innerHTML = 'This looks like a public address — reachable over the internet if UDP 8668 is open/forwarded to this node.';
}
}
document.querySelectorAll('[data-copy]').forEach(btn => {
btn.addEventListener('click', async () => {
const text = document.getElementById(btn.dataset.copy)?.textContent || '';
if (!text || text === '—') return;
try {
await navigator.clipboard.writeText(text);
notice('copyNotice', 'good', 'Copied.');
setTimeout(() => notice('copyNotice', '', ''), 1500);
} catch {
notice('copyNotice', 'error', `Copy failed — select manually: ${escapeHtml(text)}`);
}
});
});
// initial load
populateOwnAnchor();
loadStatus();
loadAnchors();
</script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
server {
listen 8336;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy archipelago RPC same-origin so the browser never makes a
# cross-origin request (no CORS needed). The FIPS app is served on
# this node's :8336; cookies are scoped by host (not port), so the
# browser already carries the `session` (HttpOnly) and `csrf_token`
# cookies set by the main UI on :80. We forward both, plus the
# X-CSRF-Token header the app derives from the readable csrf_token
# cookie, to the backend RPC on 127.0.0.1:5678.
#
# Unlike bitcoin-ui this config is fully static (baked into the
# image) there is no upstream secret to substitute; the browser's
# own archipelago session is the credential.
location /rpc/v1 {
proxy_pass http://127.0.0.1:5678/rpc/v1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-CSRF-Token $http_x_csrf_token;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
add_header Cache-Control "no-store";
}
location / {
try_files $uri $uri/ /index.html;
}
}
+17 -2
View File
@@ -1,6 +1,6 @@
# 1.8-alpha Improvements Tracker
Last updated: 2026-06-11 00:17 EDT
Last updated: 2026-06-12 01:15 EDT
This tracks the user-facing improvement list that must land with the `1.8-alpha`
container migration release and the next ISO cut produced from that release. It
@@ -116,6 +116,19 @@ header gap; and removed the My Apps desktop category dropdown. Focused
Marketplace/App config tests, type-check, and scoped `git diff --check` passed.
Browser smoke against the already-running local Vite/mock session is still next.
Active-session update, 2026-06-12 01:15 EDT: system update UX hardening landed
locally. `load_state()` now clears stale `update_in_progress` when no staged OTA
files exist, so failed legacy update attempts cannot leave the update screen
permanently stuck. Direct `update.git-apply` is gated behind
`ARCHIPELAGO_GIT_UPDATES`, preventing production nodes from accidentally entering
the local git/self-build path that requires `cargo`. `.116` was recovered from a
failed self-build attempt by applying its already-staged manifest OTA; it is now
on `1.7.84-alpha`, backend health is OK, nginx is active/config-valid, HTTP UI
returns `200`, `update_in_progress=false`, and staging was removed. Validation:
`cargo fmt --check`, `cargo check -p archipelago`, and scoped `git diff --check`
passed; focused `cargo test` was blocked by a local `rust-lld` undefined hidden
symbol linker failure unrelated to the updater patch.
Done criteria for this tracker:
- Code/UI items: implemented, covered by targeted test or manual smoke check,
@@ -148,6 +161,7 @@ Done criteria for this tracker:
| Fix BTCPay issue from desktop file "BTCPay Issues" | blocked | Need file contents or path to that desktop artifact. |
| Check Nostr Discoverable Nodes and get it working correctly | in-progress | Discover modal now keeps discovered rows visible during relay refresh/failure and shows `Searching relays...` instead of dropping to an empty state. Covered by `DiscoverModal.test.ts`, local type-check, and `git diff --check`. Needs live relay/trust validation before marking done. |
| Make sure update password is working properly | done | Backend now returns separate SSH update status so a successful web password change is not reported as a full failure when optional SSH password update fails. Settings modal shows success plus SSH warning and stays open for review. Covered by local type-check, focused modal/RPC tests, auth unit test, `cargo check -p archipelago`, and `git diff --check`. |
| Prevent System Update screen from getting permanently stuck | done | Update state loading now reconciles `update_in_progress` with the actual manifest OTA staging directory and clears stale stuck state when no staged files exist. Direct git/self-build apply is disabled unless `ARCHIPELAGO_GIT_UPDATES` is explicitly set, so production nodes cannot fall into the old `self-update.sh` path that requires local `cargo`. `.116` was recovered by applying its valid staged manifest OTA and verified on `1.7.84-alpha` with backend health OK, nginx active/config-valid, HTTP UI `200`, `update_in_progress=false`, and staging removed. Validated locally with `cargo fmt --check`, `cargo check -p archipelago`, and scoped `git diff --check`; focused `cargo test` was blocked by a local `rust-lld` linker artifact failure unrelated to the updater patch. |
| Do UI performance and general performance improvements | todo | Needs profiling target; start with obvious loading/render issues. |
| Make sure companion app is all working well, had issues with tab apps | in-progress | Mobile app-session now keeps apps that require a new tab inside the session fallback instead of auto-opening an external tab and closing immediately. Covered by `AppSessionMobileNewTab.test.ts`, existing app-session config tests, app launcher tests, local type-check, and `git diff --check`. Broader companion smoke test still needed before marking done. |
| Even though performance is better, on reboot/restart backend/update show checking-containers notification instead of no apps | done | My Apps now shows a dedicated `Checking containers` card when initial backend data has loaded but `server-info.status-info.containers-scanned` is still false and no apps are ready to render, instead of falling through to the no-apps empty state. A follow-up UI pass preserves the last known app list when a later scanner/backoff update reports an empty package map with `containers-scanned=false`, and shows a refresh status banner above the grid. Validated by local type-check, targeted tests, and `git diff --check`; follow-up validation passed `npm test -- --run src/views/apps/__tests__/appPackageCache.test.ts` and `npm run type-check`. |
@@ -194,7 +208,7 @@ Done criteria for this tracker:
| Work on setup screens function and flows | in-progress | Onboarding setup choice now shows only usable paths: Fresh Start and Restore from Seed. Removed the disabled `Connect Existing (Coming Soon)` option, and covered default Fresh routing plus Restore routing with `OnboardingOptions.test.ts`; `useOnboarding.test.ts`, local type-check, and `git diff --check` passed. Broader onboarding/setup audit still needed before marking done. |
| Work on Easy Mode experience | in-progress | Easy Mode goal configure steps now route to their owning app/screen instead of silently completing without navigation; verify steps now expose a `Check & Continue` action; configure/info/verify actions start goal progress before completing the active step. Covered by `goalStepActions.test.ts`, existing goal store tests, local type-check, and `git diff --check`. Broader Easy Mode product scope still needed before marking done. |
| Update My Apps homescreen to show most-used apps instead of hardcoded | done | App launches are recorded locally through the app launcher, and the Home My Apps card now shows the top three installed user apps by launch count/recency with a running-app/name fallback when there is no history. Covered by `appUsage.test.ts`, existing app launcher tests, local type-check, targeted tests, and `git diff --check`. |
| Improve Full Archive Node dependent apps UX | todo | Already partly represented by Bitcoin-pruned install block; needs broader dependency UX. |
| Improve Full Archive Node dependent apps UX | in-progress | Electrum-style apps already block install on pruned Bitcoin nodes; Marketplace/App Store cards now surface an inline warning that a full archive Bitcoin node is required instead of only showing a terse `Bitcoin Pruned` button. Covered by `MarketplaceAppCard.test.ts` and local type-check. Broader dependency UX remains. |
| Fix incorrect modals that are wrong color and are not full-screen overlay | done | Custom Teleport modals that still used the old light `bg-black/10` overlay now use the same full-screen `bg-black/60` overlay treatment as BaseModal/newer modals. Verified no fixed modal overlays retain `bg-black/10`; validated by local type-check, targeted tests, and `git diff --check`. |
| Prevent modals from allowing background scroll | done | Added shared scroll-lock composable, root-level body lock, wheel/touch containment, and explicit dashboard route-panel locking. User validated the background no longer scrolls behind modal overlays. |
| Look over gamepad navigation | todo | Needs focused controller-nav pass. |
@@ -206,6 +220,7 @@ Done criteria for this tracker:
| Delete app data option and uninstall warning | done | Uninstall dialogs in My Apps and App Details now include a clear warning plus a `Delete app data and reset it` choice. Leaving it off preserves app data for later reinstall; checking it passes `preserve_data=false` through `package.uninstall` so the app is fully reset. Covered by `AppsUninstallModal.test.ts`, `rpc-client.test.ts`, local type-check, targeted tests, and `git diff --check`. |
| Add App Store container with recommended apps that change to Home Screen | done | Home now shows up to three uninstalled core/recommended App Store apps and routes clicks through the existing Marketplace App Details handoff. Installed aliases are honored, so recommendations disappear once the app is installed and the app moves into normal My Apps/Home behavior. Follow-up layout polish moved Cloud back into the second card slot, moved Recommended Apps into Cloud's previous slot, and placed Quick Start inside the grid next to Wallet to avoid an odd-width row. Covered by `homeRecommendations.test.ts`, local type-check, `git diff --check`, and Playwright Home dashboard smoke against local Vite/mock backend. |
| Add QR code to download mobile companion app in login-triggered modal and improve modal | done | Companion intro modal now renders a QR code on desktop and a direct download button on mobile. It reads `VITE_COMPANION_APK_URL` and falls back to `/packages/archipelago-companion.apk.zip`; the APK zip is now published at `neode-ui/public/packages/archipelago-companion.apk.zip` so the modal can serve it immediately. Covered by local type-check, `git diff --check`, and manual file placement verification. |
| Fix TV HDMI overscan clipping in kiosk mode | in-progress | Kiosk launcher now passes a browser safe-area fallback through `/kiosk?safe_area=...`; `/kiosk` now persists the safe-area value during redirect; self-update and deploy paths refresh kiosk launcher/services. The X11 safe-area attempt is opt-in because it stretched the live TV output on `100.66.157.120`. Wi-Fi UI fixes are included in the same OTA patch: scan errors are visible, scans can be retried, escaped SSIDs parse correctly, and open networks do not require a password. Needs live validation on HDMI node `100.66.157.120` after applying the visible OTA update. |
| Video calling Picture-in-Picture | blocked | Need referenced document or desired provider/library. |
| Card-based loading visuals on App Store pages | done | Discover and Marketplace now show app-card skeleton grids while community/Nostr catalog data is loading and no cards are available yet, instead of a centered spinner/empty state. Validated by local type-check, targeted tests, and `git diff --check`. |
+148 -1
View File
@@ -1,6 +1,153 @@
# Migration Status Report
Last updated: 2026-06-11
Last updated: 2026-06-14
## RESUME CHECKPOINT (2026-06-14, after SSH drop)
State right now, so any disconnect resumes cleanly:
- **`main` = `a483fe4b`** = the other agent's 4 fixes (`0ed892a4`: wallet receive / bitcoin
install self-heal / ElectrumX tile / extended test gate) + **my F1 fix committed on top**
(`launch_url_port` in `docker_packages.rs` + 3 regression tests). Tree is clean (only two
untracked `docs/*.md` tracking files remain). Not pushed.
- The old isolated `archy-f1` worktree was **removed** — built the combined tree in-place.
- ✅ **DONE — combined backend release build** (`cd core && TMPDIR=/home/archipelago/.buildtmp
cargo build --release -p archipelago`, 7m46s, exit 0). `/tmp` is a full tmpfs so `TMPDIR`
MUST point at `/home/archipelago/.buildtmp`.
- ✅ **DONE — sideloaded + restarted on `.116`.** Backed up old binary to
`/usr/local/bin/archipelago.pre-f1.bak`, `install`ed new binary (root:root 755),
`sudo systemctl restart archipelago` (new MainPID 2885863).
- ✅ **F1 VALIDATED LIVE on `.116` (2026-06-14).** See "FINDING F1" below — before/after proves
the fix. Harness focused audit `jellyfin,filebrowser`**all checks passed, exit 0**.
- **IMPORTANT — restart is SAFE on this node:** containers run rootless under
`user-1000.slice/user@1000.service/app.slice`, a DIFFERENT cgroup from
`/system.slice/archipelago.service`. They survived both the 01:47 and this restart
(bitcoin/lnd/btcpay/immich/indeedhub all intact, count stayed 36). The
`feedback_no_systemctl_deploy_until_quadlet` cgroup-cascade warning does NOT apply to `.116`'s
current config. (The reconciler does recreate a few app containers like jellyfin/fedimint on
adoption — normal level-triggered behavior, not casualties.)
- **RELEASE IN PROGRESS — v1.7.91-alpha (user approved 2026-06-14).** Bundles the other agent's
4 fixes (`0ed892a4`) + F1 (`a483fe4b`) + changelog (`ab858271`). Steps:
1. ✅ Freed `/tmp` (removed stale published frontend tarballs 1.7.83→1.7.89; ~1.1G free) —
`create-release.sh` writes the 184MB frontend tarball to `/tmp` (hardcoded, NOT TMPDIR).
2. ✅ `cargo fmt -p archipelago --check` clean; curated layman changelog added + committed.
3. 🔄 `TMPDIR=/home/archipelago/.buildtmp scripts/create-release.sh 1.7.91-alpha`
(runs `tests/release/run.sh` gate → bumps Cargo.toml/package.json → builds backend+frontend
→ manifest → commit "chore: release v1.7.91-alpha" → tag `v1.7.91-alpha`). MUST set TMPDIR
or cargo's ring C-build fails on the full `/tmp` tmpfs.
- **AFTER create-release.sh:** `scripts/publish-release-assets.sh 1.7.91-alpha gitea-vps2`
`git push origin main && git push gitea-local main``git push --tags` (origin+gitea-local).
Ship target per memory: vps2 (146.59.87.168) is PRIMARY OTA manifest; tx1138 RETIRED.
- Verify packaged tarball actually contains the new version string before trusting the build
(npm run build can silently produce stale dist — see `feedback_frontend_build_verify`).
## Validation node (ACTIVE)
As of 2026-06-14 the app-migration lifecycle validation moves from `.198` (remote, OVH) to
**`.116` — the local dev node (`archi-thinkpad`, `192.168.1.116`)** because it is the machine
this session runs on, so the harness drives it over loopback instead of SSH (much faster, no
network latency). A separate agent owns OS-level fixes + its own test harness; this track owns
the **app-packaging migration** lifecycle validation only.
How to drive the harness against `.116` (local):
```bash
ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=http ARCHY_PASSWORD='ThisIsWeb54321@' \
ARCHY_APPS='meshtastic,jellyfin,filebrowser,uptime-kuma' \
tests/lifecycle/remote-lifecycle.sh # focused, audit-only (non-destructive)
```
- `.116` serves nginx on **:80 only** (443 is tailscale's) → use `ARCHY_SCHEME=http`, `ARCHY_HOST=127.0.0.1`.
- Local node is healthy: `update_state.json.current_version == 1.7.90-alpha`, `update_in_progress=false`
(the OTA self-heal that was a follow-up gap in PROGRESS_MEMORY is now confirmed resolved on .116).
- Login password for `.116`: `ThisIsWeb54321@` (verified against `auth.login`). Note: auth.login
has a login rate-limiter — avoid rapid repeated attempts.
- `.198` results below remain the prior baseline; new results are tagged `[.116]`.
### [.116] audit log (newest first)
- **2026-06-14 — focused audit `meshtastic,jellyfin,filebrowser,uptime-kuma` (audit-only, non-destructive):**
harness exit 1, FAILED checks: 1.
- `filebrowser` — running, pass (also passed a standalone single-app smoke run).
- `uptime-kuma` — running, pass.
- `meshtastic``state=absent`. Not installed on `.116` (was installed/validated on `.198`).
Not a regression; just node state. To exercise meshtastic here, install it first (it needs
`/dev/ttyUSB0`, which `.116` may not have) or drop it from the focused set on this node.
- `jellyfin` — **running but FAILED: "launch metadata missing: jellyfin has no lan_address".**
**ROOT-CAUSED 2026-06-14 — real, current bug in the working tree (a regression).** See
"FINDING F1" below.
### [.116] FINDING F1 — manifest launch URLs with a path are silently dropped (OPEN, fix pending)
**Symptom:** `jellyfin` is `running` and genuinely serving (`curl 127.0.0.1:8096/` → 302), but
`container-list` reports `lan_address: null`, so the UI/harness sees no launch URL.
**Root cause:** `core/archipelago/src/container/docker_packages.rs::reachable_lan_address()` parses
the port out of the candidate URL with `url.rsplit(':').next()`. When the candidate comes from the
manifest `interfaces.main` (via `PodmanClient::lan_address_for`
`core/container/src/podman_client.rs::manifest_primary_interface_url`), the URL **includes the
manifest `path`** — e.g. jellyfin → `http://localhost:8096/`. Then `rsplit(':').next()` yields
`"8096/"`, which **fails to `parse::<u16>()`**, so the function hits its `else { return None }`
branch and drops a perfectly reachable launch URL. (Diagnostic tell: the dropped-at-parse path
emits **no** log, whereas a genuine unreachable port logs "suppressing unreachable launch URL".
jellyfin has no such log; uptime-kuma — whose candidate `…:3002` has no path — does.)
**Why it's a regression:** the old `extract_lan_address(ports)` produced `http://localhost:PORT`
(no path), which parsed fine. The newer manifest-interface feature appends the declared `path`,
so any app routed through `lan_address_for` now yields `…:PORT/` and trips the parser.
**Blast radius (apps in `requires_reachable_launch` whose `interfaces.main.path` = `/`):**
`botfights`, `btcpay-server`, `fedimint`, `jellyfin`, `gitea`, `nextcloud`, `portainer`.
(`filebrowser`/`nextcloud`/`nginx-proxy-manager`/`vaultwarden` are in `uses_allocated_launch_port`
so they hit `extract_lan_address` first and dodge it; `grafana`/`mempool`/`uptime-kuma`/`searxng`
have no manifest `interfaces.main` path.) On `.198` this likely went unnoticed because those apps
weren't all running during the launch-metadata assertion, or predated the interfaces.main addition.
**Fix (IMPLEMENTED in working tree, uncommitted):**
`docker_packages.rs::reachable_lan_address` now parses the port via a new `launch_url_port()`
helper that reads digits after the final colon (`take_while(is_ascii_digit)`), mirroring the
RPC-layer `port_from_url`, so `http://localhost:8096/``Some(8096)`. Added unit tests
(`launch_url_port_tests`) covering the trailing-path regression, the bare-authority case, and a
no-port reject. The existing `lan_address_prefers_manifest_main_interface` test only exercised
`lan_address_for` (which always returned `…:8175/`) and never the `reachable_lan_address` wrapper,
which is why the bug slipped through.
**Unit validation: GREEN (2026-06-14).** `cargo test -p archipelago --bin archipelago launch_url_port`
→ 3 passed / 0 failed (trailing-path, bare-authority, no-port-reject); crate compiles clean.
**Coordination note (shared tree):** the repo is on branch `fix/wallet-receive-portdrift-secrets`
at commit `bb808df8` (= the deployed 1.7.90-alpha). A parallel agent has uncommitted changes here
(lnd `wallet.rs`, `bitcoin_relay.rs`, `prod_orchestrator.rs`, electrumx manifest, neode-ui, new
bats). To validate F1 in isolation (and NOT deploy their in-flight work onto the live node, nor
disturb their tree), the live-validation build is done in a detached git worktree at
`/home/archipelago/archy-f1` = clean `bb808df8` + only the F1 `docker_packages.rs` change. Build:
`cd /home/archipelago/archy-f1/core && TMPDIR=/home/archipelago/.buildtmp cargo build --release -p archipelago`
(`.116`'s `/tmp` is a 7.7G tmpfs that runs 100% full → the ring crate's C compile fails with
"No space left on device"; redirect `TMPDIR` to `/` which has ~399G). After validation the
worktree is removed (`git worktree remove`). NOTE: sideloading replaces the OTA-managed
`/usr/local/bin/archipelago` with a local 1.7.90-alpha+F1 build until the next OTA — back up the
current binary first (`/usr/local/bin/archipelago.pre-f1.bak`).
**Live validation status — ✅ GREEN on `.116` (2026-06-14).** Built combined tree (`a483fe4b`),
sideloaded, restarted `archipelago.service`. Before/after on the live node (old buggy binary → new):
| app | OLD lan_address | NEW lan_address |
|---|---|---|
| jellyfin | `None` ❌ | `http://localhost:8096/` ✅ |
| btcpay-server | `None` ❌ | `http://localhost:23000/` ✅ |
| fedimint | `None` ❌ | `http://localhost:8175/` ✅ |
| gitea | `None` ❌ | `http://localhost:3001/` ✅ |
| portainer | `None` ❌ | `http://localhost:9000/` ✅ |
| botfights | `None` ❌ | `http://localhost:9100/` ✅ |
| nextcloud | `:8085` ✓ | `:8085` (unchanged — allocated-port path) |
| filebrowser | `:8083` ✓ | `:8083` (unchanged) |
Harness focused audit `jellyfin,filebrowser`**all checks passed, exit 0**. Unit tests green.
No container casualties (all 36 survived; see RESUME CHECKPOINT for the cgroup detail).
NOTE: Do NOT run the prod binary directly to "check a version" —
`/usr/local/bin/archipelago <anyflag>` boots a whole second node instance (learned the hard way
2026-06-14; it exited without leaving a stray, but don't repeat).
## Goal
+44
View File
@@ -0,0 +1,44 @@
# Progress Memory
Last updated: 2026-06-13
## Current State
- `v1.7.90-alpha` release is complete, tagged, pushed, uploaded, and verified on vps2.
- Release commit: `bb808df8` (chore: release v1.7.90-alpha).
- Feature commit: `c800293f` (fix: bitcoin receive, AIUI pointer input, electrs self-heal, OTA timeout).
- Gitea tag: `v1.7.90-alpha` (on origin/gitea-vps2).
- Live OTA manifest on the update host (146.59.87.168) now resolves to `1.7.90-alpha`; both
artifact download URLs (binary + frontend tarball) return HTTP 200.
- v1.7.89-alpha was already fully shipped before this session.
## What shipped in v1.7.90-alpha
- Bitcoin receive address generation fixed (correct address type, no more 400).
- AIUI/app session: on-screen pointer can click + type into app content (incl. app store
search); "open in new tab" opens the phone browser; mobile credential modal centered.
- Electrs self-heals from a corrupt index and shows a percent/block-height progress screen.
- update.rs: retired tx1138 secondary mirror dropped (one-time migration); longer download
timeout for slow connections.
## Verification
- Full release harness green (8 stages): git-diff, cargo-fmt, catalog-drift, release-manifest,
ui-type-check, ui-unit-tests (80 files / 655 tests), cargo-check, cargo-test-weekly.
- Freshly built binary embeds `1.7.90-alpha` (no stale 1.7.89); frontend dist rebuilt fresh
(new AppSession bundle); manifest sha256 + size match on-disk artifacts.
## Known gaps / follow-ups
- `gitea-local` (localhost:3000) push FAILS from this node — redirects to /login (auth).
The v1.7.88 and v1.7.89 tags were also already missing there, so this is a pre-existing
condition on this node, not a v1.7.90 regression. vps2 is the primary OTA mirror and is fine.
- OTA self-update verification on THIS node (.116) not yet observed this session — the node
should auto-apply from the live 1.7.90-alpha manifest; confirm
`update_state.json.current_version == 1.7.90-alpha` after the scheduler runs.
## Resume Context
- If a later session resumes, continue from the next active product/release task, not this
finished release.
- Broader context: docs/WEEKLY_RELEASE_TRACKER.md, docs/RESUME.md, docs/NEXT_TERMINAL_HANDOFF.md
+288
View File
@@ -0,0 +1,288 @@
# Weekly Release Tracker
Last updated: 2026-06-14 (session on node .116 / archi-thinkpad)
---
# ▶ IN PROGRESS — LND wallet auto-unlock fix (2026-06-14)
## RESUME PROMPT (paste into a fresh session, on .116 / archi-thinkpad, tree at /home/archipelago/Projects/archy)
> Resume the LND wallet-password fix. Read memory `project_lnd_wallet_password.md` FIRST (full
> root-cause + design + validated facts). Work is on branch `lnd-wallet-password-fix` (pushed to
> gitea-vps2, commit 91adc281, NOT merged to main, NOT shipped). Bug: hardcoded
> `WALLET_PASSWORD="hellohello"` left LND wallets LOCKED fleet-wide after OTA → Bitcoin-receive
> shows "wallet is locked" on every updated node. DONE + cargo-checked: per-node random secret
> (secrets/lnd-wallet-password), both init paths unified, candidate-unlock with fail-fast,
> login-time candidate-migration (ChangePassword). DETECTION GATE already shipped on main
> (commit 8c8e4d7a). DECISION: alpha, NO funds on nodes → destructive wipe+recreate is OK and
> wanted UNATTENDED for ALL nodes in the next update. A wallet locked with an unknown password is
> already inaccessible, so wiping loses nothing reachable.
## EXACT NEXT STEPS — LND fix (in order)
1. **Finish seed/fresh recovery** (REMAINING piece): in `container/lnd.rs ensure_wallet_initialized`,
when wallet.db exists but ALL unlock candidates fail → wipe wallet.db (+ macaroons + graph/chain
mainnet state, as root via host_sudo) and re-init fresh (random genseed + per-node secret) so the
node self-heals unattended at boot. (Login-time candidate-migration already handles nodes whose
pw matches.) Validate the wipe→reinit mechanic on the scratch LND first (see below).
2. **Scratch validation** (was in progress, .249 unreachable from .116's subnet → use a throwaway
`lnd-scratch` podman container on .116, regtest/neutrino, REST :18099 — already proven for
init/unlock/ChangePassword). Test: init(passA) → restart→LOCKED → delete wallet.db while locked →
confirm /v1/state→NON_EXISTING (may need container restart) → genseed+initwallet fresh → unlock.
NOTE: scratch wallet.db lives at the container's LND data dir (regtest), `podman exec lnd-scratch
find / -name wallet.db`. CLEAN UP: `podman rm -f lnd-scratch` when done.
3. `cargo check -p archipelago` (on .116 ~15-30s incremental; full test compile ~9min).
4. **End-to-end on .228** (reachable 192.168.1.x, SSH pw `archipelago`, UI pw unknown, NO funds —
has a locked unknown-pw wallet = perfect auto-recreate test): build binary
(`ARCHIPELAGO_TARGET=archipelago@192.168.1.228 scripts/deploy-to-target.sh` or per
reference_deploy_to_nodes), deploy, restart, confirm wallet auto-recreates+unlocks, lncli state
RPC_ACTIVE, lnd.newaddress returns an address. Run os-audit against .228 → lnd check PASS.
5. Merge `lnd-wallet-password-fix` → main, then **cut + publish v1.7.93-alpha** (carries the LND
fix). Ship ritual: create-release.sh 1.7.93-alpha → add CHANGELOG (≥3 layman bullets) → run
sync-whats-new.py (the new What's-New gate will require it) → publish-release-assets.sh gitea-vps2
→ push origin/gitea-vps2 + tags → verify live manifest==1.7.93-alpha. Heads-up: create-release
leaves core/Cargo.lock version-bump uncommitted (commit it as a chore, both .91 and .92 hit this).
## Context: how we got here (this session, all on node .116)
- Shipped **v1.7.91-alpha** (bitcoinReceive TS2538 build fix) and **v1.7.92-alpha** (ElectrumX
overlay-during-sync fix; L3 reboot os-audit gate; What's-New sync gate + 8-version backfill) —
both LIVE on vps2. Restored .116-local nginx `/lnd-connect-info` route (was dropped 2026-06-10).
- Triaged user symptoms: ElectrumX "can't connect" = electrs syncing / Bitcoin verifying (not a
regression); .228 "5/14 apps after reboot" = normal ~5min staggered startup (all 14 came up).
- LND lock bug found + detection gate shipped + forward fix & migration implemented (this section).
---
# ✔ DONE PASS — v1.7.91-alpha + v1.7.92-alpha (2026-06-14)
## Outcome (both releases PUBLISHED + LIVE on vps2)
- **v1.7.91-alpha** — bitcoinReceive.ts TS2538 build-blocker fixed; cut, published, verified
live (`manifest.version==1.7.91-alpha`), tag `v1.7.91-alpha` on vps2. The fleet OTA'd to it
(confirmed on .116 + .198).
- **v1.7.92-alpha** — cut, published, verified live (`manifest.version==1.7.92-alpha`), tag on
vps2, main@d462e444. Carries:
- `fix(ui)` ElectrumX **overlay-during-sync** bug — the "App not reachable / retry" overlay
no longer paints over the ElectrumX sync screen (AppSessionFrame.vue gated on `!electrsSync`).
- `test(resilience)` **L3 per-boot health gate**`batch_host_reboot` now runs os-audit.sh
after reboot (RPC/OTA/all-apps/FM-guards), not just container-set equality. os-audit validated
11/0/0 green on .116.
- `feat(release)` **What's New sync gate**`scripts/sync-whats-new.py` + `whats-new-sync`
stage in tests/release/run.sh. Backfilled the 8 missing modal blocks (v1.7.85→.92); the gate
fails any release whose CHANGELOG version isn't in the Settings modal.
- **.116 node fix (not shipped — local config)**: restored the `/lnd-connect-info` nginx proxy
route that a 2026-06-10 "before-116-routing" change had dropped (fell through to SPA). Backup at
`/etc/nginx/conf.d/rpc.tx1138.com.conf.bak-lndconnect-*`. Shipped template already has the route.
- **User symptoms triaged (none were .91/.92 regressions)**: receive-generate "unchanged" = .91's
receive change was a behavior-preserving build guard; ElectrumX "can't connect" on .198 = Bitcoin
node mid-"Verifying blocks…" (-28) so electrs was "waiting for Bitcoin node"; on .116 electrs was
~59% mid-sync. The overlay UX bug is fixed regardless.
## Known follow-ups (not blockers)
- **gitea-local mirror push fails** (`localhost:3000` → redirect to `/login`, token auth). vps2 is
the OTA source and is fine; gitea-local secondary mirror is stale. Diagnose the local Gitea token.
- `sync-whats-new.py` only **inserts missing** versions; it does not rewrite a block when CHANGELOG
bullets for an already-present version change (had to delete+resync the .92 block by hand to pick
up its 3rd bullet). Fine for the forward case; enhance to idempotently re-render if needed.
## What happened this session
- `scripts/create-release.sh 1.7.91-alpha` was running; its release gate PASSED all 7 checks,
backend built clean (7m22s), then it **FAILED at step [4/8] frontend build** with:
`src/utils/bitcoinReceive.ts(23,24): error TS2538: Type 'undefined' cannot be used as an index type.`
Cause: `noUncheckedIndexedAccess``codeMatch[1]` is `string | undefined` and was used directly
to index `RECEIVE_CODE_MESSAGES`. **FIXED**`const code = message.match(/\[([A-Z_]+)\]/)?.[1]`
then `if (code && RECEIVE_CODE_MESSAGES[code])`. `npx vue-tsc --noEmit` is now clean (exit 0).
The failed run aborted BEFORE bumping the manifest (still 1.7.90) or tagging (no v1.7.91 tag),
but it HAD already partial-bumped Cargo.toml/package.json/locks to 1.7.91 — those partial bumps
are reverted (create-release.sh re-owns the bump); only the genuine TS fix + harness are committed.
- Built a new OS-wide health harness `tests/lifecycle/os-audit.sh` (non-destructive, one scorecard):
Section A backend/RPC health, Section B all-apps lifecycle audit (delegates to remote-lifecycle.sh),
Section C FM-guards (port-drift + secret-completeness bats, orphan-container sweep). Section A
validated all-PASS on .116. Fixed a jq bug in the FM12 OTA-wedge check: `//` treats a legit
`false` as empty and fell through to "unknown" — now uses `has()`. Section B is slow (~3 min) and
opaque while running because output is captured (`out=$(...)`) not streamed — minor wart, TODO.
## EXACT NEXT STEPS — v1.7.91 (in order)
1. Confirm clean tree + on main (`git status`; create-release.sh requires `git diff --quiet HEAD`).
The TS fix + os-audit.sh are committed & pushed; version-bump artifacts reverted to 1.7.90.
2. Re-run the release: `scripts/create-release.sh 1.7.91-alpha`. Backend is cached (only a .ts
changed) so it's fast; the frontend build now passes. It bumps versions, builds, writes
releases/manifest.json (→1.7.91-alpha), commits, and tags v1.7.91-alpha.
- Memory guards: grep the staged frontend tarball for "1.7.91-alpha" before shipping (silent
vue-tsc failures); tarball must be flat (`tar -C web/dist/neode-ui .`).
3. Publish: `scripts/publish-release-assets.sh 1.7.91-alpha gitea-vps2`, then
`git push origin main && git push origin --tags` (origin pushes to BOTH gitea-local + vps2).
4. Verify manifest LIVE (this is "published"):
`curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json | jq .version`
must show `1.7.91-alpha`. **Then notify the user — they asked to be told when 1.7.91 publishes.**
5. os-audit harness: run a full green pass on .116
(`ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=http ARCHY_PASSWORD='ThisIsWeb54321@' tests/lifecycle/os-audit.sh`),
confirm Section A FM12 now reads `update_in_progress=false` (PASS not WARN), review B + C findings,
then wire os-audit.sh into the reboot-survival (L3) loop as the per-boot gate.
---
# ─ HISTORY — v1.7.89-alpha pass (2026-06-12), superseded ─
Last updated: 2026-06-12 ~17:45 EDT (session on node .116)
## RESUME PROMPT (paste into a fresh session)
> Continue the v1.7.89-alpha release pass from /home/archipelago/Projects/archy on node .116.
> Read docs/WEEKLY_RELEASE_TRACKER.md fully first — it has root causes, fixes already made,
> and exact next steps. Do NOT redo: AIUI revert (done, validated), updater fixes in
> core/archipelago/src/update.rs (done, uncommitted), .116 OTA unwedge (done). Resume at
> "EXACT NEXT STEPS" below.
## EXACT NEXT STEPS (in order)
1. Backend focused tests were running in background:
`cd core && timeout 1500 cargo test -p archipelago -- update:: lnd container::image_versions scanner`
(log: /tmp/claude-.../tasks/bds4jk19e.output — if lost, just rerun the command; first
attempt died at 400s timeout during test compile, 1500s is the right budget).
Need: all green.
2. RESOLVED before session end: vitest recheck passed clean — EXIT=0, 79 files / 645 tests,
even while cargo test was compiling. The earlier harness ui-unit-tests FAIL was load/flake
(machine saturated by the parallel cargo test compile), not a real failure. On resume just
rerun `tests/release/run.sh --quick` WITHOUT a parallel cargo build to confirm green;
if it ever fails again, the failing test name is in the stage output (drop `--silent`).
3. Run full harness: `tests/release/run.sh` (static+frontend+backend). Then commit ALL
working-tree changes (one commit, e.g. "fix: harden OTA updates, AIUI desktop gap, LND
no-proxy" — CHANGELOG v1.7.89 section is already curated).
4. Cut release: `scripts/create-release.sh 1.7.89-alpha` (needs clean tree, on main,
validates CHANGELOG section exists — it does). Then
`tests/release/run.sh --manifest` should pass, and grep the staged frontend tarball
for 1.7.89-alpha (memory: silent build failures).
5. Publish: `scripts/publish-release-assets.sh 1.7.89-alpha gitea-vps2`, then
`git push origin main && git push origin --tags` and push gitea-local + tags too.
Verify manifest live on http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json
6. Verify OTA on THIS node (.116): schedule is auto_apply; either wait for the scheduler
or trigger via UI. Confirm /var/lib/archipelago/update_state.json current_version
becomes 1.7.89-alpha, `update_in_progress` returns to false, web-ui + binary versions
MATCH (this node currently has web-ui 1.7.84 / binary 1.7.85 mismatch — the OTA heals it),
and journalctl shows "Post-OTA verification succeeded" (the new probe falls back to
http://127.0.0.1/ which is what .116 serves).
7. Update this tracker + docs/PROGRESS_MEMORY.md, mark tasks done.
Purpose: live tracker for this pass — test everything shipped this week (v1.7.83→v1.7.89),
build the release test harness, fix OTA updates on .116, make updates bulletproof, cut v1.7.89-alpha.
If the session is cut off, resume from here.
## Task status
| # | Task | Status |
|---|------|--------|
| 1 | AIUI revert (mobile back/close gone, desktop gap fixed) | DONE — validated |
| 2 | Dev server on :8100 with embedded AIUI | DONE — see below |
| 3 | Inventory this week's release-log items | DONE — see checklist |
| 4 | Test harness covering this week + seed of system-wide harness | IN PROGRESS |
| 5 | Fix OTA updates on .116 + bulletproof updates | IN PROGRESS — diagnosis below |
| 6 | Cut v1.7.89-alpha release | PENDING (gates: 4, 5) |
## State of the working tree
- HEAD = 495b9078 (v1.7.89 changelog + AIUI mobile restore committed).
- Uncommitted, intended for v1.7.89-alpha:
- `neode-ui/src/views/Dashboard.vue` — chat route back to plain `h-full` (desktop bottom-gap fix). Validated.
- `core/.../rpc/lnd/*` + `container/lnd.rs` — LND REST no-proxy + wallet readiness/unlock fixes.
- Version bumps to 1.7.89-alpha (Cargo.toml, package.json, locks), CHANGELOG entry.
- `neode-ui/vite.config.ts` — added `/aiui` dev proxy (keep; dev-only convenience).
## AIUI validation (task 1) — DONE
- HEAD already removed the mobile back button and restored `hideClose=true` (495b9078).
- Working-tree Dashboard.vue removes `dashboard-scroll-panel mobile-scroll-pad` from the chat
route (that padding caused the desktop bottom gap); mesh keeps its styling.
- Chat CSS verified byte-identical to last-good 34c4e87d (May 20).
- Playwright check (desktop 1440x900, mobile 390x844): chat fills full viewport, no bottom gap,
no mobile back/close. `npm run type-check` + focused route tests + full vitest (645/645) pass.
## Dev server on :8100 (task 2) — DONE
- Running: `BACKEND_URL=http://127.0.0.1:5678 VITE_AIUI_URL=/aiui/ npx vite --host 0.0.0.0 --port 8100`
from `neode-ui/` (real local backend on 5678).
- AIUI now embeds in /dashboard/chat via new vite proxy `/aiui``http://127.0.0.1:80`
(the node's deployed AIUI), same-origin like production.
- Secondary throwaway instance for automated checks: :8101 against mock backend
(`node mock-backend.js` on 5959, password `password123`).
## This week's shipped items (v1.7.83 → v1.7.89) — test checklist
### Frontend (vitest/type-check/build cover most; full suite 645/645 green 2026-06-12)
- [x] AIUI fast launch, no availability probe (v1.7.88) — covered by visual check + Chat.vue tests
- [x] AIUI mobile layout restore (v1.7.89) — playwright visual check
- [x] App-session launch metadata from manifests / typed interfaces (v1.7.83) — appSessionConfig tests
- [x] OnlyOffice + Saleor removal (v1.7.83) — catalog tests
- [ ] Bitcoin receive UI flow end-to-end (v1.7.87/88) — needs live LND node check
- [ ] Fleet tab keeps node list/alerts during refresh, names not hashes (v1.7.85/86) — store tests?
- [ ] Credential interstitial full-screen overlay (v1.7.87) — visual
- [ ] Mobile federation/system-update buttons full width (v1.7.86) — visual
### Backend (cargo)
- [ ] LND REST no-proxy client + GET newaddress p2wkh (v1.7.88/89) — unit tests + live check
- [ ] LND wallet readiness/unlock after restart (v1.7.89) — unit + live
- [ ] Bitcoin trusted-node relay rpcauth/txrelay (v1.7.84) — unit tests exist? check
- [ ] Container scanner RAII in-flight guard (v1.7.84) — cargo test
- [ ] ElectrumX health-check startup window + cache tuning (v1.7.85/86)
- [ ] Portainer pin 2.19.4 / bitcoin-ui image pin (v1.7.84/85) — image-versions tests
- [ ] Fleet telemetry name/hostname/URL fields (v1.7.85)
- [ ] Federation no self-import (v1.7.85)
- [ ] Kiosk safe-area + self-update refreshes kiosk files (v1.7.84)
- [ ] Wi-Fi scan error/retry/escaped SSID/open networks (v1.7.84)
### OTA / updates (task 5)
- [ ] .116 stuck: current 1.7.85-alpha, `update_in_progress: true` since 1.7.88 attempt — diagnose+fix
- [ ] Updater hardening: stuck-in-progress recovery, resumable/atomic apply, verify post-restart version
## OTA diagnosis on .116 — ROOT CAUSES FOUND + FIXED (code staged for v1.7.89)
Four bugs, all reproduced from the journal (Jun 12 03:4504:33):
1. Post-OTA probe only tries `https://127.0.0.1/`; .116's nginx binds only :80 (443 is
tailscale's) → connection refused × 18 → a GOOD 1.7.85 update was "rolled back".
FIX: probe falls back to `http://127.0.0.1/` on connect error (update.rs probe_frontend_once).
2. That rollback's binary restore did `host_sudo cp` onto the RUNNING binary → ETXTBSY exit 1
→ binary stayed 1.7.85 while web-ui rolled back to 1.7.84 (mismatch confirmed live).
FIX: rollback now cp→tmp→atomic mv, same pattern as apply (update.rs rollback_update).
3. The rollback chown'd `update-backup/archipelago` root:root IN PLACE → next apply's
fs::copy (as service user) hit EACCES → "Failed to backup current binary" × 3 → 1.7.86/88
never applied. FIX: apply unlinks stale backup first; rollback chowns only its temp copy.
4. Failed apply left `update_in_progress: true` wedged (staging still populated so the
stale-flag guard never fires). Unwedged operationally; fixed structurally by 13.
Operational cleanup DONE on .116 (2026-06-12 17:15): removed root-owned
`update-backup/archipelago`, stale `update-staging/` (1.7.86), and the stale
`update-pending-verify.json`. Next state load clears `update_in_progress`.
NOTE: live web-ui is 1.7.84 / binary 1.7.85 (mismatch from bug 2). Not hand-patched —
the v1.7.89 OTA will resync both. Good 1.7.85 frontend is quarantined at
`/opt/archipelago/web-ui.failed.1781250438247`.
Verification plan: after v1.7.89 release, watch .116 auto-apply (schedule auto_apply),
confirm `update_state.json.current_version == 1.7.89-alpha` and web-ui version matches.
## Test harness (task 4) — CREATED at tests/release/run.sh
- Stages: static (git diff --check, cargo fmt, catalog drift, optional --manifest),
frontend (type-check, full vitest), optional --with-build (build + grep dist for version),
backend (cargo check + focused cargo test: update:: lnd container::image_versions scanner,
all wrapped in `timeout`), optional --live URL smoke (/, /aiui/, /rpc/v1).
- Results so far (2026-06-12): type-check PASS, full vitest 645/645 PASS, cargo fmt PASS,
cargo check PASS, catalog drift PASS (3 pre-existing MISSING_CATALOG warnings, exit 0,
identical on HEAD). Focused backend cargo tests running (first run hit the known slow
test-compile on .116 at 400s timeout; rerunning with 1500s).
- AIUI embed verified end-to-end via playwright on :8101 (mock backend): iframe loads,
`ready` handshake clears the loading overlay, hideClose honored.
- Release flow confirmed: commit all → `scripts/create-release.sh 1.7.89-alpha` (validates
curated CHANGELOG section, builds, manifests, commits, tags) →
`scripts/publish-release-assets.sh 1.7.89-alpha gitea-vps2` → push origin main + tags.
Tarball layout/perms safety is already inside create-release-manifest.sh.
- CHANGELOG v1.7.89 section rewritten layman-readable (updater fixes added).
## Release gates for v1.7.89-alpha (task 6)
1. All harness stages green locally.
2. OTA fix for stuck `update_in_progress` included + .116 updates successfully to the new release.
3. Frontend build: grep packaged tarball for "1.7.89-alpha" before shipping (memory: silent vue-tsc failures).
4. Flat tarball layout (`tar -C web/dist/neode-ui .`).
5. Commit, tag `v1.7.89-alpha`, push origin + gitea-local + tags, publish release assets, verify
manifest + node OTA picks it up.
+12 -1
View File
@@ -27,6 +27,7 @@ Allowed RPC methods:
```text
sendrawtransaction
submitpackage
testmempoolaccept
getmempoolinfo
getrawmempool
@@ -37,6 +38,7 @@ getblockcount
getblockhash
getblockheader
getrawtransaction
gettxout
decoderawtransaction
decodescript
estimatesmartfee
@@ -113,7 +115,7 @@ The Bitcoin Knots app should add the restricted user only when the secret exists
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0"
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblockheader,getrawtransaction,decoderawtransaction,decodescript,estimatesmartfee"
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
```
@@ -245,6 +247,15 @@ curl -sS --user "$BITCOIN_RPC_TXRELAY_USER:$BITCOIN_RPC_TXRELAY_PASSWORD" \
Expected result is a Bitcoin RPC validation error such as `TX decode failed`,
which confirms the request reached `sendrawtransaction`.
If a wallet verifies the connection but reports `RPC Forbidden` during
broadcast, the credentials authenticated but the broadcast method was outside
the loaded `txrelay` whitelist. Restart the active Bitcoin backend after
updating the whitelist, then test both `sendrawtransaction` and, for newer
package-relay clients, `submitpackage`. Also confirm the public reverse proxy
passes the wallet's `Authorization` header through to `127.0.0.1:8332`; do not
point public wallet traffic at the Bitcoin UI `/bitcoin-rpc/` helper, because
that helper injects the local dashboard credential.
Check that wallet/admin RPC is blocked:
```sh
@@ -1,37 +1,87 @@
#!/bin/bash
# Start X server in the background
/usr/bin/Xorg :0 -nocursor vt1 -nolisten tcp -keeptty &
XPID=$!
sleep 2
# Check if X started
if ! kill -0 $XPID 2>/dev/null; then
echo 'ERROR: Xorg failed to start'
exit 1
fi
# Start a dedicated X server for the attached kiosk display.
/usr/bin/Xorg :0 vt1 -nolisten tcp -keeptty &
XPID=$!
export DISPLAY=:0
export HOME=/home/archipelago
# Allow archipelago user to connect
xhost +SI:localuser:archipelago 2>/dev/null
X_READY=false
for _ in $(seq 1 30); do
if kill -0 "$XPID" 2>/dev/null && xrandr --query >/tmp/archipelago-kiosk-xrandr.txt 2>/dev/null; then
X_READY=true
break
fi
sleep 0.5
done
# Disable screen blanking
xset s off 2>/dev/null
xset -dpms 2>/dev/null
xset s noblank 2>/dev/null
if [ "$X_READY" != "true" ]; then
echo 'ERROR: Xorg failed to become ready'
kill "$XPID" 2>/dev/null || true
exit 1
fi
# Hide cursor
unclutter -idle 3 -root &
KIOSK_SAFE_AREA_X_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_X_PX:-}
KIOSK_SAFE_AREA_Y_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_Y_PX:-}
# Kill any stale Chromium instances before starting
pkill -u archipelago -f 'chromium.*kiosk' 2>/dev/null
configure_display() {
command -v xrandr >/dev/null 2>&1 || return 0
local output mode internal width height
output=$(awk '/ connected/ && $1 !~ /^eDP|^LVDS/{print $1; exit}' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$output" ] || output=$(awk '/ connected/{print $1; exit}' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$output" ] || return 0
mode=$(awk -v out="$output" '
$1 == out { active = 1; next }
active && /^[[:space:]]+[0-9]+x[0-9]+/ {
if ($0 ~ /\*/) { print $1; exit }
if (!first) first = $1
}
active && /^[^[:space:]]/ { active = 0 }
END { if (first) print first }
' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$mode" ] || mode=1920x1080
# Kiosk should use one native output. A spanning desktop makes Chromium land
# on the laptop panel or stretch across both outputs.
for internal in $(awk '/ connected/ && $1 ~ /^eDP|^LVDS/{print $1}' /tmp/archipelago-kiosk-xrandr.txt); do
[ "$internal" = "$output" ] || xrandr --output "$internal" --off 2>/dev/null || true
done
xrandr --output "$output" \
--primary \
--mode "$mode" \
--pos 0x0 \
--scale 1x1 \
--panning 0x0 \
--transform none 2>/dev/null || true
width=${mode%x*}
height=${mode#*x}
case "$width:$height" in *[!0-9:]*|:*) width=1920; height=1080 ;; esac
# Browser safe-area fallback for TVs that crop edges. Driver underscan is
# preferable, but many Intel HDMI outputs do not expose that property.
KIOSK_SAFE_AREA_X_PX=${KIOSK_SAFE_AREA_X_PX:-$((width * 3 / 100))}
KIOSK_SAFE_AREA_Y_PX=${KIOSK_SAFE_AREA_Y_PX:-$((height * 3 / 100))}
}
configure_display
xhost +SI:localuser:archipelago 2>/dev/null || true
xsetroot -solid black 2>/dev/null || true
xset s off 2>/dev/null || true
xset -dpms 2>/dev/null || true
xset s noblank 2>/dev/null || true
pkill -u archipelago -f 'chromium.*localhost' 2>/dev/null || true
sleep 1
# Run Chromium as archipelago user in a restart loop
while true; do
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago chromium --kiosk \
--app=http://localhost/kiosk \
--app=http://localhost/kiosk?safe_area_x=${KIOSK_SAFE_AREA_X_PX:-0}\&safe_area_y=${KIOSK_SAFE_AREA_Y_PX:-0} \
--noerrdialogs \
--disable-infobars \
--disable-translate \
@@ -45,9 +95,10 @@ while true; do
--enable-gpu-rasterization \
--num-raster-threads=2 \
--renderer-process-limit=2 \
--window-size=9999,9999 \
--window-size=1920,1080 \
--window-position=0,0 \
--start-fullscreen \
--force-device-scale-factor=1 \
--disable-background-networking \
--disable-background-timer-throttling \
--disable-backgrounding-occluded-windows \
@@ -60,5 +111,4 @@ while true; do
sleep 3
done
# Cleanup
kill $XPID 2>/dev/null
kill "$XPID" 2>/dev/null || true
+1 -3
View File
@@ -110,7 +110,7 @@ tail -f /tmp/neode-dev.log
- **Docker Optional** - Apps run for real if Docker/Podman is available, otherwise simulated
- **Auto-Detection** - Automatically detects container runtime and adapts
- **WebSocket Support** - Real-time state updates via JSON patches
- **Pre-loaded Apps** - 8 apps always visible in My Apps
- **Pre-loaded Apps** - 7 apps always visible in My Apps
### Pre-installed Apps (always running in mock mode)
- `bitcoin` - Bitcoin Core (port 8332)
@@ -119,7 +119,6 @@ tail -f /tmp/neode-dev.log
- `mempool` - Blockchain explorer (port 4080)
- `filebrowser` - Web file manager (port 8083)
- `lorabell` - LoRa doorbell (no UI port)
- `thunderhub` - Lightning node management (port 3010)
- `fedimint` - Federated Bitcoin mint (port 8175)
Additional apps can be installed from the Marketplace (30+ available).
@@ -226,4 +225,3 @@ The warning is non-fatal - Vite still works, but upgrading is recommended.
---
Happy coding! 🎨⚡
+1 -1
View File
@@ -55,7 +55,7 @@ The mock backend supports multiple startup modes via `VITE_DEV_MODE`:
The mock backend (`mock-backend.js`) simulates the full Rust backend for local development:
**Pre-installed apps** (always visible in My Apps):
- Bitcoin Core, LND, Electrs, Mempool, FileBrowser, LoraBell, ThunderHub, Fedimint
- Bitcoin Core, LND, Electrs, Mempool, FileBrowser, LoraBell, Fedimint
**Marketplace**: 30+ curated apps with Docker images, install/uninstall simulation
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.83-alpha",
"version": "1.7.95-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.83-alpha",
"version": "1.7.95-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.83-alpha",
"version": "1.7.95-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+1 -1
View File
@@ -425,7 +425,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:latest",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
+24
View File
@@ -102,6 +102,26 @@ const toastMessage = messageToast.toastMessage
useControllerNav()
function syncKioskSafeArea() {
if (typeof document === 'undefined') return
const isKiosk = localStorage.getItem('kiosk') === 'true'
|| new URLSearchParams(window.location.search).has('kiosk')
const rawSafeArea = localStorage.getItem('archipelago_kiosk_safe_area_px') || '0'
const safeArea = /^\d{1,3}$/.test(rawSafeArea) ? Number(rawSafeArea) : 0
const rawSafeAreaX = localStorage.getItem('archipelago_kiosk_safe_area_x_px') || rawSafeArea
const rawSafeAreaY = localStorage.getItem('archipelago_kiosk_safe_area_y_px') || rawSafeArea
const safeAreaX = /^\d{1,3}$/.test(rawSafeAreaX) ? Number(rawSafeAreaX) : safeArea
const safeAreaY = /^\d{1,3}$/.test(rawSafeAreaY) ? Number(rawSafeAreaY) : safeArea
document.documentElement.classList.toggle('kiosk-safe-area', isKiosk && (safeAreaX > 0 || safeAreaY > 0))
if (isKiosk && (safeAreaX > 0 || safeAreaY > 0)) {
document.documentElement.style.setProperty('--kiosk-safe-area-x', `${safeAreaX}px`)
document.documentElement.style.setProperty('--kiosk-safe-area-y', `${safeAreaY}px`)
} else {
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
}
}
// Start/stop message polling and remote relay when auth state changes
watch(() => appStore.isAuthenticated, (authenticated) => {
if (authenticated) {
@@ -330,6 +350,7 @@ function onVisibilityChange() {
}
onMounted(async () => {
syncKioskSafeArea()
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('mousemove', onUserActivity)
@@ -393,6 +414,9 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
document.documentElement.classList.remove('kiosk-safe-area')
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('mousemove', onUserActivity)
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { isTextField, typeKeyIntoField } from '../remote-relay'
/**
* Companion-cursor text entry. Synthetic KeyboardEvents do NOT mutate input
* values in the browser, so the relay edits `.value` at the caret directly and
* fires an `input` event. These tests lock in that behaviour so a regression
* (like the old "type goes to document, nothing happens" bug) is caught before
* release rather than by a user with a companion controller.
*/
describe('isTextField', () => {
it('accepts text-like inputs and textareas', () => {
const text = document.createElement('input')
text.type = 'text'
const search = document.createElement('input')
search.type = 'search'
const area = document.createElement('textarea')
expect(isTextField(text)).toBe(true)
expect(isTextField(search)).toBe(true)
expect(isTextField(area)).toBe(true)
})
it('rejects non-text controls and null', () => {
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
expect(isTextField(checkbox)).toBe(false)
expect(isTextField(document.createElement('button'))).toBe(false)
expect(isTextField(document.createElement('div'))).toBe(false)
expect(isTextField(null)).toBe(false)
})
})
describe('typeKeyIntoField', () => {
let input: HTMLInputElement
let inputEvents: number
beforeEach(() => {
input = document.createElement('input')
input.type = 'search'
document.body.appendChild(input)
inputEvents = 0
input.addEventListener('input', () => { inputEvents++ })
})
it('inserts printable characters at the caret and fires input', () => {
typeKeyIntoField(input, 'b')
typeKeyIntoField(input, 't')
typeKeyIntoField(input, 'c')
expect(input.value).toBe('btc')
expect(input.selectionStart).toBe(3)
expect(inputEvents).toBe(3)
})
it('inserts a character in the middle of existing text', () => {
input.value = 'bc'
input.selectionStart = input.selectionEnd = 1
typeKeyIntoField(input, 't')
expect(input.value).toBe('btc')
expect(input.selectionStart).toBe(2)
})
it('backspace deletes the char before the caret', () => {
input.value = 'btc'
input.selectionStart = input.selectionEnd = 3
typeKeyIntoField(input, 'Backspace')
expect(input.value).toBe('bt')
expect(input.selectionStart).toBe(2)
})
it('backspace removes the active selection', () => {
input.value = 'bitcoin'
input.selectionStart = 0
input.selectionEnd = 3
typeKeyIntoField(input, 'Backspace')
expect(input.value).toBe('coin')
expect(input.selectionStart).toBe(0)
})
it('arrow keys move the caret without changing the value', () => {
input.value = 'abc'
input.selectionStart = input.selectionEnd = 3
typeKeyIntoField(input, 'ArrowLeft')
expect(input.selectionStart).toBe(2)
expect(input.value).toBe('abc')
})
it('Enter on a single-line input is left for the app to handle', () => {
input.value = 'query'
input.selectionStart = input.selectionEnd = 5
const consumed = typeKeyIntoField(input, 'Enter')
expect(consumed).toBe(false)
expect(input.value).toBe('query')
})
it('Enter inserts a newline in a textarea', () => {
const area = document.createElement('textarea')
area.value = 'a'
area.selectionStart = area.selectionEnd = 1
expect(typeKeyIntoField(area, 'Enter')).toBe(true)
expect(area.value).toBe('a\n')
})
it('non-text keys are not consumed as editing', () => {
input.value = 'x'
input.selectionStart = input.selectionEnd = 1
expect(typeKeyIntoField(input, 'Escape')).toBe(false)
expect(typeKeyIntoField(input, 'Tab')).toBe(false)
expect(input.value).toBe('x')
})
})
+125 -7
View File
@@ -98,6 +98,103 @@ function mapKey(xdotoolKey: string): string {
return KEY_MAP[xdotoolKey] ?? xdotoolKey
}
/** <input> types that accept free-text entry (so we should type into them). */
const TEXT_INPUT_TYPES = new Set([
'text', 'search', 'url', 'tel', 'password', 'email', 'number', '',
])
export function isTextField(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement {
if (!el) return false
if (el.tagName === 'TEXTAREA') return true
if (el.tagName === 'INPUT') {
const type = ((el as HTMLInputElement).type || 'text').toLowerCase()
return TEXT_INPUT_TYPES.has(type)
}
return false
}
/**
* elementFromPoint that descends through SAME-ORIGIN iframes, so the cursor
* can target elements *inside* embedded apps (gitea, uptime-kuma, AIUI any
* app served same-origin via /app/ or /aiui/). Cross-origin iframes (apps on
* direct ports) are opaque to the parent by browser security policy, so the
* deepest reachable element there is the <iframe> itself.
*/
function deepElementFromPoint(x: number, y: number): Element | null {
let cx = x
let cy = y
let el = document.elementFromPoint(cx, cy)
let guard = 0
while (el && el.tagName === 'IFRAME' && guard++ < 5) {
let doc: Document | null = null
try { doc = (el as HTMLIFrameElement).contentDocument } catch { break }
if (!doc) break
const rect = el.getBoundingClientRect()
cx -= rect.left
cy -= rect.top
const inner = doc.elementFromPoint(cx, cy)
if (!inner || inner === el) break
el = inner
}
return el
}
/** The actually-focused element, descending through same-origin iframes. */
function deepActiveElement(): Element | null {
let el: Element | null = document.activeElement
let guard = 0
while (el && el.tagName === 'IFRAME' && guard++ < 5) {
let doc: Document | null = null
try { doc = (el as HTMLIFrameElement).contentDocument } catch { break }
if (!doc || !doc.activeElement || doc.activeElement === doc.body) break
el = doc.activeElement
}
return el
}
/**
* Apply a key to a focused text field. Synthetic KeyboardEvents do NOT mutate
* input values (browser security), so we edit `.value` at the caret directly
* and fire an `input` event so Vue v-model / reactive search pick it up.
* Returns true if the key was consumed as text editing.
*/
export function typeKeyIntoField(el: HTMLInputElement | HTMLTextAreaElement, key: string): boolean {
const value = el.value
const start = el.selectionStart ?? value.length
const end = el.selectionEnd ?? value.length
const setCaret = (pos: number) => { try { el.selectionStart = el.selectionEnd = pos } catch { /* e.g. number inputs */ } }
const replaceSelection = (text: string) => {
el.value = value.slice(0, start) + text + value.slice(end)
setCaret(start + text.length)
}
if (key === 'Backspace') {
if (start !== end) { el.value = value.slice(0, start) + value.slice(end); setCaret(start) }
else if (start > 0) { el.value = value.slice(0, start - 1) + value.slice(end); setCaret(start - 1) }
else return true
} else if (key === 'Delete') {
if (start !== end) { el.value = value.slice(0, start) + value.slice(end); setCaret(start) }
else { el.value = value.slice(0, start) + value.slice(start + 1); setCaret(start) }
} else if (key === 'ArrowLeft') {
setCaret(Math.max(0, start - 1))
} else if (key === 'ArrowRight') {
setCaret(Math.min(value.length, end + 1))
} else if (key === 'Home') {
setCaret(0)
} else if (key === 'End') {
setCaret(value.length)
} else if (key === 'Enter') {
if (el.tagName === 'TEXTAREA') replaceSelection('\n')
else return false // let the app's keydown handler act (e.g. search submit)
} else if (key.length === 1) {
replaceSelection(key) // printable character
} else {
return false // Tab / Escape / F-keys / etc. — not text editing
}
el.dispatchEvent(new Event('input', { bubbles: true }))
return true
}
function handleMessage(data: string) {
let msg: { t: string; k?: string; x?: number; y?: number; b?: number; p?: number }
try {
@@ -125,9 +222,17 @@ function handleMessage(data: string) {
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({ type: 'arcade-input', key, player, action: 'down' }, '*')
}
// Keep existing keydown/keyup for backward compat with non-arcade UI navigation
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
document.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
// Deliver the key to the actually-focused element (descending into
// same-origin iframes) so it reaches embedded-app inputs and search
// boxes, not just the top-level document.
const focused = deepActiveElement()
const keyTarget: EventTarget = focused ?? document
keyTarget.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
// Synthetic key events never insert text, so edit the field directly.
if (isTextField(focused)) {
typeKeyIntoField(focused, key)
}
keyTarget.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
break
}
case 'm': {
@@ -135,16 +240,29 @@ function handleMessage(data: string) {
break
}
case 'c': {
const target = document.elementFromPoint(cursorX, cursorY)
const target = deepElementFromPoint(cursorX, cursorY)
if (target) {
if (cursorEl) {
cursorEl.style.background = 'rgba(247, 147, 26, 1)'
setTimeout(() => { if (cursorEl) cursorEl.style.background = 'rgba(247, 147, 26, 0.7)' }, 150)
}
target.dispatchEvent(new MouseEvent('click', {
bubbles: true, cancelable: true,
const eventInit: MouseEventInit = {
bubbles: true, cancelable: true, view: window,
clientX: cursorX, clientY: cursorY,
}))
}
target.dispatchEvent(new MouseEvent('mousedown', eventInit))
target.dispatchEvent(new MouseEvent('mouseup', eventInit))
target.dispatchEvent(new MouseEvent('click', eventInit))
// A synthetic click does NOT move keyboard focus the way a real click
// does, so the app-store search box (and any input) would stay
// unfocused and untypable. Explicitly focus the nearest focusable
// element — for same-origin iframe targets this focuses inside the app.
const focusable = (target.closest?.(
'input, textarea, select, button, a[href], [contenteditable], [tabindex]',
) ?? target) as HTMLElement
if (typeof focusable.focus === 'function') {
focusable.focus({ preventScroll: true })
}
}
break
}
@@ -3,16 +3,17 @@
<Transition name="app-launcher">
<div
v-if="store.isOpen"
class="fixed inset-0 z-[2400] flex items-center justify-center p-0 md:p-10"
class="fixed inset-0 z-[2400] flex items-stretch justify-stretch p-0"
@click.self="store.close()"
>
<!-- Backdrop - blur like spotlight -->
<div class="app-launcher-backdrop absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<div class="app-launcher-backdrop absolute inset-0 bg-black/75 backdrop-blur-md"></div>
<!-- Panel - inset with margins, glass style like spotlight -->
<!-- Panel - full-screen overlay -->
<div
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-none md:rounded-2xl shadow-2xl"
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-none shadow-2xl"
:class="panelClasses"
style="border-radius: 0;"
>
<!-- Header bar - sticky on mobile -->
<div class="sticky top-0 z-10 flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none">
@@ -69,7 +70,7 @@
<!-- Loading indicator -->
<Transition name="content-fade">
<div v-if="iframeLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
<svg class="animate-spin h-8 w-8 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<svg class="animate-spin h-8 w-8 text-white/70" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@@ -398,7 +399,7 @@ function injectScrollbarHideIfSameOrigin() {
const panelClasses = [
'glass-card',
'w-full h-full',
'md:max-w-[calc(100vw-5rem)] md:max-h-[calc(100vh-5rem)]',
'max-w-none max-h-none',
]
function onKeyDown(e: KeyboardEvent) {
@@ -5,7 +5,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import * as d3 from 'd3'
interface MapNode {
@@ -36,6 +36,11 @@ type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; tar
let simulation: d3.Simulation<SimNode, SimLink> | null = null
let resizeObserver: ResizeObserver | null = null
const graphSignature = computed(() => JSON.stringify({
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
links: props.links.map(l => [l.source, l.target]),
}))
function trustColor(level: string): string {
switch (level) {
case 'trusted': return '#4ade80'
@@ -50,6 +55,7 @@ function nodeRadius(n: MapNode): number {
}
function render() {
simulation?.stop()
const svg = d3.select(svgRef.value!)
svg.selectAll('*').remove()
@@ -160,7 +166,7 @@ onUnmounted(() => {
resizeObserver?.disconnect()
})
watch(() => [props.nodes, props.links], () => render(), { deep: true })
watch(graphSignature, () => render())
</script>
<style scoped>
@@ -0,0 +1,57 @@
import { ref, computed, onUnmounted } from 'vue'
/** Shape of GET /electrs-status (see core electrs_status.rs ElectrsSyncStatus). */
export interface ElectrsSyncStatus {
indexed_height: number
bitcoin_height: number
network_height: number
progress_pct: number
status: string // "starting" | "waiting" | "syncing" | "indexing" | "synced" | "error"
stale: boolean
error: string | null
index_size: string | null
tor_onion: string | null
}
/**
* Polls GET /electrs-status while active. Used to show an ElectrumX sync screen
* *before* the real Electrum UI the Electrum server only accepts client
* connections (and the UI only works) once the on-chain index is built, which
* is a long initial process.
*
* Fails OPEN: if the status can't be fetched we report not-syncing, so a status
* outage never blocks the normal iframe path. We only gate the UI when we
* positively know the index is still being built (status !== "synced").
*/
export function useElectrsSync() {
const status = ref<ElectrsSyncStatus | null>(null)
let timer: ReturnType<typeof setInterval> | null = null
async function poll() {
try {
const res = await fetch('/electrs-status', { cache: 'no-store' })
if (res.ok) status.value = (await res.json()) as ElectrsSyncStatus
} catch {
/* keep last known value; fail open */
}
}
function start() {
if (timer) return
void poll()
timer = setInterval(() => void poll(), 8000)
}
function stop() {
if (timer) {
clearInterval(timer)
timer = null
}
}
/** True only when we know ElectrumX is still building its index. */
const syncing = computed(() => !!status.value && status.value.status !== 'synced')
onUnmounted(stop)
return { status, syncing, start, stop }
}
+1 -3
View File
@@ -463,9 +463,6 @@
"noIdentities": "No identities yet",
"createFirstIdentity": "Create your first sovereign digital identity.",
"deleting": "Deleting...",
"decentralizedWebNode": "Decentralized Web Node",
"dwnDescription": "Personal data store with DID-based access control",
"manageDwn": "Manage DWN",
"syncing": "Syncing...",
"syncNow": "Sync Now",
"verifiableCredentials": "Verifiable Credentials",
@@ -590,6 +587,7 @@
},
"marketplaceDetails": {
"backToStore": "Back to App Store",
"backToHome": "Back to Home",
"screenshots": "Screenshots",
"screenshotPlaceholder": "Screenshot placeholders - images coming soon",
"about": "About {name}",
+1 -3
View File
@@ -463,9 +463,6 @@
"noIdentities": "A\u00fan no hay identidades",
"createFirstIdentity": "Cree su primera identidad digital soberana.",
"deleting": "Eliminando...",
"decentralizedWebNode": "Nodo web descentralizado",
"dwnDescription": "Almac\u00e9n de datos personal con control de acceso basado en DID",
"manageDwn": "Administrar DWN",
"syncing": "Sincronizando...",
"syncNow": "Sincronizar ahora",
"verifiableCredentials": "Credenciales verificables",
@@ -589,6 +586,7 @@
},
"marketplaceDetails": {
"backToStore": "Volver a la tienda",
"backToHome": "Volver al inicio",
"screenshots": "Capturas de pantalla",
"screenshotPlaceholder": "Capturas de pantalla de ejemplo \u2014 im\u00e1genes disponibles pronto",
"about": "Acerca de {name}",
+17 -3
View File
@@ -86,11 +86,26 @@ const router = createRouter({
{
path: '/kiosk',
name: 'kiosk',
redirect: '/',
beforeEnter: () => {
component: () => import('../views/Kiosk.vue'),
beforeEnter: (to) => {
// Persist kiosk mode before redirect so App.vue can skip the remote relay
// (relay duplicates xdotool input on the kiosk display)
localStorage.setItem('kiosk', 'true')
const safeArea = to.query.safe_area
const safeAreaPx = Array.isArray(safeArea) ? safeArea[0] : safeArea
if (safeAreaPx && /^\d{1,3}$/.test(safeAreaPx)) {
localStorage.setItem('archipelago_kiosk_safe_area_px', safeAreaPx)
}
const safeAreaX = to.query.safe_area_x
const safeAreaXPx = Array.isArray(safeAreaX) ? safeAreaX[0] : safeAreaX
if (safeAreaXPx && /^\d{1,3}$/.test(safeAreaXPx)) {
localStorage.setItem('archipelago_kiosk_safe_area_x_px', safeAreaXPx)
}
const safeAreaY = to.query.safe_area_y
const safeAreaYPx = Array.isArray(safeAreaY) ? safeAreaY[0] : safeAreaY
if (safeAreaYPx && /^\d{1,3}$/.test(safeAreaYPx)) {
localStorage.setItem('archipelago_kiosk_safe_area_y_px', safeAreaYPx)
}
},
},
{
@@ -386,4 +401,3 @@ router.afterEach((to) => {
})
export default router
-2
View File
@@ -111,10 +111,8 @@ const PORT_TO_APP_ID: Record<string, string> = {
'4080': 'mempool',
'8175': 'fedimint',
'8176': 'fedimint-gateway',
'3100': 'dwn',
'7778': 'indeedhub',
'50002': 'electrumx',
'3010': 'thunderhub',
}
const APP_ID_TO_PORT: Record<string, string> = {
+15
View File
@@ -1294,6 +1294,21 @@ body {
min-height: 100dvh;
}
html.kiosk-safe-area,
html.kiosk-safe-area body {
width: 100vw;
height: 100vh;
overflow: hidden;
background: #000;
}
html.kiosk-safe-area #app {
width: 100vw;
height: 100vh;
min-height: 0;
overflow: hidden;
}
/* Custom scrollbar for glass containers */
.custom-scrollbar::-webkit-scrollbar {
width: 10px;
@@ -17,4 +17,44 @@ describe('explainReceiveAddressFailure', () => {
it('explains lnd transport failures', () => {
expect(explainReceiveAddressFailure(new Error('LND REST connection failed'))).toContain('not responding cleanly')
})
it('keeps bitcoin address generation failures visible', () => {
expect(explainReceiveAddressFailure(new Error('Bitcoin address generation failed: LND is not ready'))).toContain('Bitcoin address generation failed')
})
describe('structured reason codes (backend [CODE] token)', () => {
it('maps an unreachable REST endpoint to a starting/recovering message — NOT locked (.228 regression)', () => {
const msg = explainReceiveAddressFailure(
new Error('Bitcoin address unavailable [LND_REST_UNREACHABLE]: service not reachable'),
)
expect(msg).toContain('starting up or recovering')
expect(msg.toLowerCase()).not.toContain('locked')
})
it('maps a genuinely locked wallet to the locked message', () => {
expect(
explainReceiveAddressFailure(new Error('Bitcoin address unavailable [LND_WALLET_LOCKED]: locked')),
).toContain('locked')
})
it('maps an uninitialized wallet to the setup message', () => {
expect(
explainReceiveAddressFailure(new Error('Bitcoin address unavailable [LND_WALLET_UNINITIALIZED]: x')),
).toContain('has not been set up')
})
it('maps a syncing wallet to the syncing message', () => {
expect(
explainReceiveAddressFailure(new Error('Bitcoin address unavailable [LND_SYNCING]: x')),
).toContain('syncing')
})
it('prefers the code over substrings even when the detail text is misleading', () => {
// Detail mentions neither "locked" nor "unlock"; code must still win.
const msg = explainReceiveAddressFailure(
new Error('Bitcoin address unavailable [LND_REST_UNREACHABLE]: wallet service down'),
)
expect(msg).toContain('starting up or recovering')
})
})
})
+24
View File
@@ -1,5 +1,29 @@
// Machine-readable reason codes the backend embeds as a `[CODE]` token in
// receive-address errors (see core .../api/rpc/lnd/wallet.rs). Mapping the code
// directly is precise — unlike the substring heuristics below, it cannot
// mislabel an unreachable-REST failure as "wallet is locked" (the .228 bug).
const RECEIVE_CODE_MESSAGES: Record<string, string> = {
LND_REST_UNREACHABLE:
'Bitcoin address is not ready yet because the Lightning wallet service is still starting up or recovering. Please try again in a moment.',
LND_WALLET_LOCKED:
'Bitcoin address is not ready because the Lightning wallet is locked. Unlock or initialize LND first.',
LND_WALLET_UNINITIALIZED:
'Bitcoin address is not ready because the Lightning wallet has not been set up yet. Finish wallet setup, then try again.',
LND_SYNCING:
'Bitcoin address is not ready while the wallet is still syncing with the Bitcoin network. Try again once sync has progressed.',
LND_ERROR:
'Bitcoin address is not ready yet. Check that the Lightning app is healthy, then try again.',
}
export function explainReceiveAddressFailure(error: unknown): string {
const message = error instanceof Error ? error.message : String(error || '')
// Prefer the structured reason code when present.
const code = message.match(/\[([A-Z_]+)\]/)?.[1]
if (code && RECEIVE_CODE_MESSAGES[code]) {
return RECEIVE_CODE_MESSAGES[code]
}
const lower = message.toLowerCase()
if (lower.includes('wallet') && (lower.includes('locked') || lower.includes('unlock'))) {
+25
View File
@@ -0,0 +1,25 @@
/**
* Open a URL in the device's real browser.
*
* In a normal mobile/desktop browser this is just `window.open(_blank)`. Inside
* the Android companion app the page runs in a WebView where `window.open` is
* unreliable (noopener/noreferrer can suppress onCreateWindow), so the native
* shell injects a `window.ArchipelagoNative.openExternal(url)` bridge that hands
* the URL to an ACTION_VIEW intent. We prefer the bridge when present and fall
* back to `window.open` otherwise so the working mobile-browser path is
* untouched.
*/
interface ArchipelagoNativeBridge {
openExternal?: (url: string) => void
}
export function openExternalUrl(url: string): void {
if (!url) return
const native = (window as unknown as { ArchipelagoNative?: ArchipelagoNativeBridge })
.ArchipelagoNative
if (native && typeof native.openExternal === 'function') {
native.openExternal(url)
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
+3 -3
View File
@@ -19,11 +19,11 @@
<!-- Registry list -->
<div class="glass-card p-6 mb-6">
<div class="flex items-start justify-between gap-4 mb-2">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between mb-2">
<h2 class="text-lg font-semibold text-white">Registries</h2>
<button
type="button"
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors w-full sm:w-auto"
@click="openAddRegistry"
>+ Add registry</button>
</div>
@@ -118,7 +118,7 @@
<!-- Back link -->
<RouterLink
to="/dashboard/settings"
class="glass-button rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center gap-2"
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center justify-center gap-2 sm:w-auto"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
+22 -2
View File
@@ -34,6 +34,7 @@
:refresh-key="refreshKey"
:blocked-reason="blockedReason"
:blocked-title="blockedTitle"
:electrs-sync="electrsSync"
@iframe-load="onLoad"
@iframe-error="onError"
@refresh="refresh"
@@ -106,6 +107,8 @@ import {
import { launchBlockedReason } from './apps/appsConfig'
import { useAppIdentity } from './appSession/useAppIdentity'
import { useNostrBridge } from './appSession/useNostrBridge'
import { openExternalUrl } from '@/utils/openExternal'
import { useElectrsSync } from '@/composables/useElectrsSync'
const props = defineProps<{
appIdProp?: string
@@ -155,6 +158,23 @@ const blockedReason = computed(() => launchBlockedReason(appId.value, packageEnt
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
// ElectrumX shows a sync screen before its real UI (the Electrum server only
// serves clients once its index is built). Poll /electrs-status while this is
// the Electrum app; pass the status to the frame only while still syncing.
const isElectrsApp = computed(() =>
['electrumx', 'electrs-ui', 'archy-electrs-ui'].includes(appId.value)
)
const { status: electrsStatus, syncing: electrsSyncing, start: startElectrsPoll, stop: stopElectrsPoll } =
useElectrsSync()
const electrsSync = computed(() =>
isElectrsApp.value && electrsSyncing.value ? electrsStatus.value : null
)
watch(
isElectrsApp,
(on) => { if (on) startElectrsPoll(); else stopElectrsPoll() },
{ immediate: true }
)
const screensaverReason = computed(() => `app-session:${appId.value}`)
const screensaverSuppressedApps = new Set([
'indeedhub',
@@ -294,12 +314,12 @@ function startLoadTimeout() {
}
function openNewTabAndBack() {
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
if (appUrl.value) openExternalUrl(appUrl.value)
closeSession()
}
function openNewTab() {
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
if (appUrl.value) openExternalUrl(appUrl.value)
}
function iframeGoBack() {
+22 -15
View File
@@ -244,10 +244,10 @@
<Transition name="fade">
<div
v-if="credentialModal.show"
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6"
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4"
@click.self="closeCredentialModal"
>
<div class="sideload-modal credential-modal">
<div class="credential-modal-panel">
<div class="flex items-start justify-between gap-4 mb-5">
<div>
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
@@ -259,7 +259,7 @@
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div class="flex items-center justify-between gap-3 mb-1">
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
</div>
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
</div>
@@ -802,15 +802,29 @@ async function submitSideload() {
}
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
.credential-modal {
.credential-modal-panel {
display: flex;
flex-direction: column;
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem);
border-radius: 1.25rem;
padding-bottom: 1.25rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
width: 100%;
max-width: 34rem;
/* Centered card that never exceeds the visible viewport (minus safe areas),
matching the wallet receive modal / AppIconGrid credential modal. The body
scrolls if content overflows rather than the panel stretching edge-to-edge. */
max-height: calc(
100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) -
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem
);
min-height: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 1.5rem;
background: rgba(8, 10, 18, 0.98);
padding: 1.25rem;
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
}
.credential-modal-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
@@ -818,11 +832,4 @@ async function submitSideload() {
.credential-modal-actions {
flex-shrink: 0;
}
@media (min-width: 768px) {
.sideload-modal {
border-radius: 1.25rem;
padding: 1.5rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
}
}
</style>
+17 -35
View File
@@ -1,6 +1,6 @@
<template>
<div class="chat-fullscreen">
<!-- Close button (desktop: top-right pill) -->
<!-- Close button + connection indicator (desktop: top-right pill) -->
<div class="chat-mode-pill hidden md:flex">
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -8,11 +8,16 @@
</svg>
<span class="text-xs font-medium">{{ t('chat.close') }}</span>
</button>
<div
v-if="aiuiConnected"
class="w-2 h-2 rounded-full bg-green-400 ml-2 shadow-[0_0_6px_rgba(74,222,128,0.5)]"
:title="t('chat.aiuiConnected')"
/>
</div>
<!-- Loading indicator while checking availability -->
<!-- Loading indicator while iframe loads -->
<Transition name="fade">
<div v-if="aiuiAvailable === null" class="chat-loading" role="status" aria-live="polite">
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading" role="status" aria-live="polite">
<div class="glass-card p-8 flex flex-col items-center gap-4">
<div class="chat-loading-spinner" aria-hidden="true" />
<p class="text-sm text-white/60">{{ t('chat.loadingAssistant') }}</p>
@@ -27,24 +32,23 @@
:src="aiuiUrl"
:title="t('chat.aiAssistant')"
class="chat-iframe chat-iframe-mobile"
allow="microphone"
style="background: transparent"
/>
<!-- Fallback when AIUI is not deployed -->
<div v-else-if="aiuiAvailable === false" class="chat-placeholder">
<div class="chat-placeholder-inner glass-card p-8">
<!-- Fallback when no AIUI URL configured -->
<div v-else class="chat-placeholder">
<div class="chat-placeholder-inner">
<div class="chat-placeholder-icon">
<svg class="w-8 h-8 text-white/40" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('chat.aiAssistant') }}</h2>
<p class="text-white/80 mb-4 leading-relaxed">
<p class="text-white/60 mb-4 leading-relaxed">
{{ t('chat.notConfigured') }}
</p>
<p class="text-sm text-white/50">
<p class="text-xs text-white/30">
{{ t('chat.deployCta') }}
</p>
</div>
@@ -63,37 +67,16 @@ const { t } = useI18n()
const router = useRouter()
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
const aiuiConnected = ref(false)
let broker: ContextBroker | null = null
const aiuiAvailable = ref<boolean | null>(null) // null = checking, true/false = result
const aiuiUrl = computed(() => {
const envUrl = import.meta.env.VITE_AIUI_URL
if (envUrl) return `${envUrl}?embedded=true&hideClose=true`
// In production, only return the URL if we've confirmed AIUI files exist
if (import.meta.env.PROD && aiuiAvailable.value === true) return `/aiui/?embedded=true&hideClose=true&v=${Date.now()}`
if (import.meta.env.PROD) return '/aiui/?embedded=true&hideClose=true'
return ''
})
/** Check if AIUI is actually deployed by fetching its index.html */
async function checkAiuiAvailable() {
if (import.meta.env.VITE_AIUI_URL) {
aiuiAvailable.value = true
return
}
if (!import.meta.env.PROD) {
aiuiAvailable.value = false
return
}
try {
const res = await fetch('/aiui/', { method: 'HEAD' })
// If we get HTML back (200), AIUI is deployed. If 404/403, it's not.
aiuiAvailable.value = res.ok
} catch {
aiuiAvailable.value = false
}
}
function closeChat() {
if (window.history.length > 1) {
router.back()
@@ -111,13 +94,12 @@ function onAiuiMessage(event: MessageEvent) {
} catch { return }
// Listen for ready messages from AIUI iframe
if (event.data?.type === 'ready') {
// AIUI connected - could use for future features
aiuiConnected.value = true
}
}
onMounted(async () => {
onMounted(() => {
window.addEventListener('message', onAiuiMessage)
await checkAiuiAvailable()
if (aiuiUrl.value) {
broker = new ContextBroker(aiuiFrame, aiuiUrl.value)
broker.start()
+2 -2
View File
@@ -86,8 +86,8 @@
<div
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
:class="[
'h-full dashboard-scroll-panel mobile-scroll-pad',
route.path === '/dashboard/mesh' ? 'mesh-dashboard-panel' : '',
'h-full',
route.path === '/dashboard/mesh' ? 'dashboard-scroll-panel mobile-scroll-pad mesh-dashboard-panel' : '',
mobileTabPaddingTop ? 'overflow-y-auto' : ''
]"
:style="{ paddingTop: mobileTabPaddingTop ? (mobileTabPaddingTop + 16) + 'px' : undefined }"
+2 -2
View File
@@ -416,8 +416,8 @@ function isStartingUp(appId: string): boolean {
}
function getAppTier(appId: string): string {
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'filebrowser']
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
if (core.includes(appId)) return 'core'
if (recommended.includes(appId)) return 'recommended'
return 'optional'
+65 -66
View File
@@ -125,55 +125,21 @@
</div>
</div>
<!-- App Store Recommendations -->
<div
v-if="homeRecommendedApps.length > 0"
data-controller-container
tabindex="0"
class="home-card controller-focusable"
:class="{ 'home-card-animate': animateCards }"
style="--card-stagger: 2"
>
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.recommendedApps') }}</h2>
<p class="text-sm text-white/70">{{ t('home.recommendedAppsDesc') }}</p>
</div>
<RouterLink to="/dashboard/marketplace" :aria-label="t('home.goToAppStore')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</RouterLink>
</div>
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
<button
v-for="app in homeRecommendedApps"
:key="app.id"
type="button"
class="w-full flex items-center gap-3 p-3 bg-white/5 rounded-lg text-left transition-colors hover:bg-white/10"
@click="viewRecommendedApp(app)"
>
<img
v-if="app.icon"
:src="app.icon"
:alt="app.title || app.id"
class="w-10 h-10 rounded-lg archy-app-icon shrink-0"
@error="handleImageError"
/>
<div v-else class="w-10 h-10 rounded-lg bg-white/10 shrink-0"></div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white truncate">{{ app.title || app.id }}</p>
<p class="text-xs text-white/55 truncate">{{ marketplaceDescription(app) }}</p>
</div>
<span class="text-xs text-white/45 capitalize">{{ getAppTier(app.id) }}</span>
</button>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.browseStore') }}</RouterLink>
</div>
</div>
</div>
</div>
<!-- Wallet Overview -->
<HomeWalletCard
:animate="animateCards"
:wallet-connected="walletConnected"
:wallet-onchain="walletOnchain"
:wallet-lightning="walletLightning"
:wallet-ecash="walletEcash"
:wallet-transactions="walletTransactions"
:is-dev="isDev"
@show-send="showSendModal = true"
@show-receive="showReceiveModal = true"
@show-transactions="showTransactionsModal = true"
@faucet="devFaucet"
@open-in-mempool="openInMempool"
/>
<!-- Network Overview -->
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 3">
@@ -213,21 +179,54 @@
</div>
</div>
<!-- Wallet Overview -->
<HomeWalletCard
:animate="animateCards"
:wallet-connected="walletConnected"
:wallet-onchain="walletOnchain"
:wallet-lightning="walletLightning"
:wallet-ecash="walletEcash"
:wallet-transactions="walletTransactions"
:is-dev="isDev"
@show-send="showSendModal = true"
@show-receive="showReceiveModal = true"
@show-transactions="showTransactionsModal = true"
@faucet="devFaucet"
@open-in-mempool="openInMempool"
/>
<!-- App Store Recommendations -->
<div
v-if="homeRecommendedApps.length > 0"
data-controller-container
tabindex="0"
class="home-card controller-focusable lg:col-span-2"
:class="{ 'home-card-animate': animateCards }"
style="--card-stagger: 4"
>
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.recommendedApps') }}</h2>
<p class="text-sm text-white/70">{{ t('home.recommendedAppsDesc') }}</p>
</div>
<RouterLink to="/dashboard/marketplace" :aria-label="t('home.goToAppStore')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</RouterLink>
</div>
<div class="home-card-stats grid grid-cols-1 md:grid-cols-3 gap-3 mb-4 flex-1 min-h-0">
<button
v-for="app in homeRecommendedApps"
:key="app.id"
type="button"
class="w-full flex items-center gap-3 p-3 bg-white/5 rounded-lg text-left transition-colors hover:bg-white/10"
@click="viewRecommendedApp(app)"
>
<img
v-if="app.icon"
:src="app.icon"
:alt="app.title || app.id"
class="w-10 h-10 rounded-lg archy-app-icon shrink-0"
@error="handleImageError"
/>
<div v-else class="w-10 h-10 rounded-lg bg-white/10 shrink-0"></div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white truncate">{{ app.title || app.id }}</p>
<p class="text-xs text-white/55 truncate">{{ marketplaceDescription(app) }}</p>
</div>
</button>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.browseStore') }}</RouterLink>
</div>
</div>
</div>
</div>
<!-- Quick Start Goals -->
<div
@@ -299,7 +298,7 @@ import { rpcClient } from '@/api/rpc-client'
import { getAppUsage } from '@/utils/appUsage'
import { handleImageError, isServicePackage, isWebsitePackage, resolveAppIcon } from './apps/appsConfig'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { getAppTier, getCuratedAppList, type MarketplaceApp } from './marketplace/marketplaceData'
import { getCuratedAppList, type MarketplaceApp } from './marketplace/marketplaceData'
import { getHomeRecommendedApps } from './home/homeRecommendations'
import HomeWalletCard from './home/HomeWalletCard.vue'
import HomeSystemCard from './home/HomeSystemCard.vue'
@@ -389,7 +388,7 @@ const homeRecommendedApps = computed(() => getHomeRecommendedApps(getCuratedAppL
function viewRecommendedApp(app: MarketplaceApp) {
setCurrentApp(app)
router.push({ name: 'marketplace-app-detail', params: { id: app.id } }).catch(() => {})
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'home' } }).catch(() => {})
}
function marketplaceDescription(app: MarketplaceApp) {
+7 -4
View File
@@ -107,7 +107,6 @@ const launchableApps = computed<KioskApp[]>(() => {
'tailscale': '/app/tailscale/',
'fedimint': '/app/fedimint/',
'fedimint-gateway': '/app/fedimint-gateway/',
'dwn': '/app/dwn/',
'indeedhub': 'http://localhost:7778',
'botfights': 'http://localhost:9100',
'nwnn': 'https://nwnn.l484.com',
@@ -172,7 +171,10 @@ onUnmounted(() => {
<style scoped>
.kiosk-root {
position: fixed;
inset: 0;
left: var(--kiosk-safe-area-x, 0px);
top: var(--kiosk-safe-area-y, 0px);
width: calc(100vw - (var(--kiosk-safe-area-x, 0px) * 2));
height: calc(100vh - (var(--kiosk-safe-area-y, 0px) * 2));
background: #000;
outline: none;
overflow: hidden;
@@ -180,11 +182,12 @@ onUnmounted(() => {
}
.kiosk-launcher {
height: 100vh;
height: 100%;
display: flex;
flex-direction: column;
padding: 2rem 3rem;
padding: clamp(1rem, 3vh, 2rem) clamp(1.5rem, 4vw, 3rem);
background: linear-gradient(180deg, #0a0a12 0%, #000 100%);
box-sizing: border-box;
}
.kiosk-header {
+6 -3
View File
@@ -5,7 +5,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ t('marketplaceDetails.backToStore') }}
{{ backButtonLabel }}
</button>
<!-- Mobile Full-Width Back Button -->
@@ -20,7 +20,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>{{ t('marketplaceDetails.backToStore') }}</span>
<span>{{ backButtonLabel }}</span>
</button>
<Transition name="content-fade" mode="out-in">
@@ -397,6 +397,7 @@ const installingDeps = ref(false)
const installError = ref<string | null>(null)
const loading = ref(true)
const bitcoinPruned = ref(false)
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
const appId = computed(() => route.params.id as string)
@@ -550,7 +551,9 @@ onBeforeUnmount(() => {
})
function goBack() {
if (route.query.from === 'discover') {
if (route.query.from === 'home') {
router.push('/dashboard').catch(() => {})
} else if (route.query.from === 'discover') {
router.push('/dashboard/discover').catch(() => {})
} else {
router.push('/dashboard/marketplace').catch(() => {})
+26 -5
View File
@@ -347,6 +347,7 @@
:wifi-submitting="wifiSubmitting"
:wifi-selected-ssid="wifiSelectedSsid"
:wifi-error="wifiError"
:wifi-scan-error="wifiScanError"
:dns-selected-provider="dnsSelectedProvider"
:dns-servers="networkData.dnsServers"
:dns-applying="dnsApplying"
@@ -358,6 +359,7 @@
@close-wifi="showWifiModal = false"
@select-wifi="selectWifi"
@connect-wifi="connectToWifi"
@scan-wifi="scanWifi"
@cancel-wifi-connect="wifiConnecting = false; wifiPassword = ''; wifiError = ''"
@close-dns="showDnsModal = false; dnsError = ''"
@select-dns-provider="(v: string) => { dnsSelectedProvider = v }"
@@ -564,6 +566,7 @@ const wifiSubmitting = ref(false)
const wifiSelectedSsid = ref('')
const wifiPassword = ref('')
const wifiError = ref('')
const wifiScanError = ref('')
// DNS
const showDnsModal = ref(false)
@@ -610,15 +613,33 @@ async function loadInterfaces() {
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false }
}
async function scanWifi() {
wifiScanning.value = true; wifiNetworks.value = []
try { const res = await rpcClient.call<{ networks: WifiNetwork[] }>({ method: 'network.scan-wifi' }); wifiNetworks.value = res.networks } catch { wifiNetworks.value = [] } finally { wifiScanning.value = false }
function wifiRequiresPassword(network: WifiNetwork | undefined): boolean {
const security = (network?.security || '').trim().toLowerCase()
return security.length > 0 && security !== '--' && security !== 'none' && security !== 'open'
}
function selectWifi(ssid: string) { wifiSelectedSsid.value = ssid; wifiPassword.value = ''; wifiConnecting.value = true }
async function scanWifi() {
wifiScanning.value = true; wifiNetworks.value = []; wifiScanError.value = ''; wifiError.value = ''
try {
const res = await rpcClient.call<{ networks: WifiNetwork[] }>({ method: 'network.scan-wifi' })
wifiNetworks.value = res.networks
} catch (e) {
wifiNetworks.value = []
wifiScanError.value = e instanceof Error ? e.message : 'WiFi scan failed.'
} finally { wifiScanning.value = false }
}
function selectWifi(network: WifiNetwork) {
wifiSelectedSsid.value = network.ssid; wifiPassword.value = ''; wifiError.value = ''
if (wifiRequiresPassword(network)) {
wifiConnecting.value = true
} else {
connectToWifi('')
}
}
async function connectToWifi(password: string) {
if (!password || !wifiSelectedSsid.value) return
if (!wifiSelectedSsid.value) return
wifiError.value = ''; wifiSubmitting.value = true
try {
await rpcClient.call({ method: 'network.configure-wifi', params: { ssid: wifiSelectedSsid.value, password } })
+56 -14
View File
@@ -69,12 +69,12 @@
</div>
<!-- Actions -->
<div class="flex gap-3">
<div class="flex flex-col gap-3 sm:flex-row">
<!-- Git path: one-shot pull+rebuild+restart -->
<button
v-if="updateMethod === 'git' && !applying"
@click="requestGitApply"
class="glass-button rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
class="glass-button w-full rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 sm:w-auto"
>
{{ t('systemUpdate.pullAndRebuild') }}
</button>
@@ -82,14 +82,14 @@
<button
v-if="updateMethod !== 'git' && !downloading && !applying && !downloaded"
@click="downloadUpdate"
class="glass-button rounded-lg px-6 py-2 text-sm font-medium"
class="glass-button w-full rounded-lg px-6 py-2 text-sm font-medium sm:w-auto"
>
{{ t('systemUpdate.downloadUpdate') }}
</button>
<button
v-if="updateMethod !== 'git' && downloaded && !applying"
@click="requestApply"
class="glass-button rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
class="glass-button w-full rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 sm:w-auto"
>
{{ t('systemUpdate.applyUpdate') }}
</button>
@@ -145,8 +145,16 @@
<!-- Applying -->
<div v-if="applying" class="glass-card p-6 mb-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.applying') }}</h2>
<div class="flex items-center gap-3">
<div class="w-5 h-5 border-2 border-orange-400 border-t-transparent rounded-full animate-spin"></div>
<div class="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
<svg
class="w-5 h-5 shrink-0 animate-spin text-orange-400"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.2"></circle>
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<p class="text-sm text-white/70">{{ t('systemUpdate.applyWarning') }}</p>
</div>
</div>
@@ -262,22 +270,25 @@
<!-- Actions row -->
<div class="glass-card p-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2>
<div class="flex flex-wrap gap-3">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<button
@click="checkForUpdates"
:disabled="loading"
class="glass-button rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40 sm:w-auto"
>
{{ loading ? t('systemUpdate.checking') : t('systemUpdate.checkForUpdates') }}
</button>
<button
v-if="rollbackAvailable"
@click="requestRollback"
class="glass-button rounded-lg px-5 py-2 text-sm font-medium bg-red-500/10 border-red-400/20"
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium bg-red-500/10 border-red-400/20 sm:w-auto"
>
{{ t('systemUpdate.rollback') }}
</button>
<RouterLink to="/dashboard/settings" class="glass-button rounded-lg px-5 py-2 text-sm font-medium text-center">
<RouterLink
to="/dashboard/settings"
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium text-center sm:w-auto"
>
{{ t('systemUpdate.backToSettings') }}
</RouterLink>
</div>
@@ -650,12 +661,28 @@ const installStartedAt = ref<number>(0)
const installElapsedSec = ref(0)
let installPollTimer: ReturnType<typeof setInterval> | null = null
let installElapsedTimer: ReturnType<typeof setInterval> | null = null
let installReadyTimer: ReturnType<typeof setTimeout> | null = null
const installElapsedLabel = computed(() => {
const s = installElapsedSec.value
if (s < 60) return `Elapsed: ${s}s`
return `Elapsed: ${Math.floor(s / 60)}m${s % 60 < 10 ? '0' : ''}${s % 60}s`
})
function clearInstallTimers() {
if (installPollTimer) {
clearInterval(installPollTimer)
installPollTimer = null
}
if (installElapsedTimer) {
clearInterval(installElapsedTimer)
installElapsedTimer = null
}
if (installReadyTimer) {
clearTimeout(installReadyTimer)
installReadyTimer = null
}
}
function startInstallOverlay(targetVersion: string) {
clearInstallTimers()
installing.value = true
installStage.value = 'applying'
installTargetVersion.value = targetVersion
@@ -672,7 +699,7 @@ function startInstallOverlay(targetVersion: string) {
// Start polling /health after a short delay the backend restarts 2s
// after replying to update.apply, so an immediate poll would see the
// old backend and conclude nothing happened.
setTimeout(() => {
installReadyTimer = setTimeout(() => {
installStage.value = 'restarting'
installPollTimer = setInterval(pollHealth, 1500)
}, 2500)
@@ -687,22 +714,37 @@ async function pollHealth() {
installStage.value = 'ready'
if (installPollTimer) { clearInterval(installPollTimer); installPollTimer = null }
// Brief pause so the user sees the "Ready" state before the reload.
setTimeout(() => { window.location.reload() }, 1200)
installReadyTimer = setTimeout(() => { window.location.reload() }, 1200)
} else {
// Backend is up but still reporting the old version frontend
// and backend are mid-swap. Signal to the user.
installStage.value = 'reconnecting'
void confirmBackendUpdateSettled()
}
} catch {
// Fetch fails while the server is mid-restart. Stay in 'restarting'.
}
}
async function confirmBackendUpdateSettled() {
try {
const res = await rpcClient.call<{
current_version: string
update_in_progress: boolean
}>({ method: 'update.status' })
if (!res.update_in_progress && installStage.value !== 'ready' && installStage.value !== 'stalled') {
installStage.value = 'ready'
if (installPollTimer) { clearInterval(installPollTimer); installPollTimer = null }
installReadyTimer = setTimeout(() => { window.location.reload() }, 800)
}
} catch {
// Keep waiting on /health.
}
}
function reloadNow() { window.location.reload() }
// Cleanup if the component is torn down mid-install (unlikely but safe).
import { onBeforeUnmount } from 'vue'
onBeforeUnmount(() => {
if (installPollTimer) clearInterval(installPollTimer)
if (installElapsedTimer) clearInterval(installElapsedTimer)
clearInstallTimers()
})
const lastCheckDisplay = computed(() => {
@@ -9,8 +9,40 @@
</div>
</Transition>
<!-- ElectrumX sync screen shown before the real UI while the on-chain
index is still being built (the Electrum server can't serve clients
until then). Mirrors the Fedimint Guardian "wait page" design. -->
<Transition name="content-fade">
<div v-if="electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8 w-full max-w-md">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-orange-300 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V7M4 7l8 5 8-5M4 7l8-4 8 4" />
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">{{ appTitle }} is syncing</h3>
<p class="text-white/50 text-sm mb-5">
ElectrumX is building its index from the blockchain. The UI opens
automatically once it's ready you can keep using the rest of Archipelago.
</p>
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden mb-2">
<div
class="h-full bg-orange-400/80 transition-all duration-700"
:style="{ width: `${Math.min(100, Math.max(2, electrsSync.progress_pct)).toFixed(1)}%` }"
></div>
</div>
<p class="text-white/70 text-sm font-medium mb-1">{{ electrsSync.progress_pct.toFixed(1) }}%</p>
<p class="text-white/40 text-xs">
Block {{ electrsSync.indexed_height.toLocaleString() }} of {{ electrsSync.network_height.toLocaleString() }}
<template v-if="electrsSync.index_size"> · {{ electrsSync.index_size }} indexed</template>
</p>
<p v-if="electrsSync.stale" class="text-yellow-400/70 text-xs mt-2">Reconnecting to ElectrumX</p>
</div>
</div>
</Transition>
<div
v-if="appUrl && !iframeBlocked"
v-if="appUrl && !iframeBlocked && !electrsSync"
class="absolute inset-0 app-session-frame-scroll-host"
tabindex="-1"
@pointerdown="focusIframe"
@@ -28,9 +60,12 @@
/>
</div>
<!-- Iframe blocked fallback -->
<!-- Iframe blocked fallback. Suppressed while the ElectrumX sync screen
(the "pre UI") is showing: a still-syncing Electrum server isn't
reachable yet, so the "App not reachable / retry" overlay would just
paint over the sync progress and read as a hard error. -->
<Transition name="content-fade">
<div v-if="iframeBlocked" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -79,6 +114,7 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
const props = defineProps<{
appUrl: string
@@ -91,6 +127,9 @@ const props = defineProps<{
refreshKey: number
blockedReason?: string
blockedTitle?: string
// Non-null only for ElectrumX while its index is still building shows the
// sync screen and gates the iframe until status flips to "synced".
electrsSync?: ElectrsSyncStatus | null
}>()
const emit = defineEmits<{

Some files were not shown because too many files have changed in this diff Show More