Compare commits

...
71 Commits
Author SHA1 Message Date
archipelago 6b8535fca7 style: format generated app launch ports 2026-09-12 15:51:48 -04:00
archipelago 5441415a3d style(cuprate-ui): anchor desktop details to right edge 2026-09-12 15:37:38 -04:00
archipelago 86426a0253 style(cuprate-ui): right-align desktop header cards 2026-09-12 15:32:07 -04:00
archipelago 9d02517ca8 style(cuprate-ui): improve mobile dashboard layout 2026-09-12 15:31:04 -04:00
archipelago bd36b6fb3d style(cuprate-ui): match bitcoin status cards 2026-09-12 15:27:44 -04:00
archipelago 8d6a2ba923 feat(cuprate-ui): add bitcoin-style dashboard tabs 2026-09-12 15:12:35 -04:00
archipelago 67e9b7d001 feat(cuprate-ui): use app icon and shared dashboard background 2026-09-12 15:08:33 -04:00
archipelago cb567056ff refactor(cuprate-ui): align dashboard with bitcoin UI style 2026-09-12 15:00:15 -04:00
ssmithx eb922f1d72 refactor(cuprate): one CUPRATE_MIN_DISK_GB, manifest matches it (review)
450 existed as two independent Rust constants (RPC gates vs boot
reconciler) linked only by a "keep in lockstep" comment — updating one
would reopen the disk-fill hole. Move it to crate::constants as the
single source of truth both paths import.

Also raise apps/cuprate/manifest.yml storage dependency and disk_limit
from 300Gi to 450Gi so manifest-driven surfaces (store size, pre-checks)
show the number the gate actually enforces — a user provisioning to the
displayed 300 was refused at an unexplained 450. Catalog regenerated
(cuprate entry re-embedded; still unsigned pending sign-catalog.sh).
2026-09-09 13:58:19 +00:00
ssmithx 10b11d29ae fix(cuprate-ui): target_height 0 means synced, not stuck (review)
Monero's get_info returns target_height == 0 when the node is FULLY
SYNCED — the field is the height being caught up to, not the chain tip.
The '??' fallback left 0 in place, so every healthy node rendered
"Syncing — 0.00%, 0 blocks behind" forever. Treat 0/absent as
target = own height, the same sentinel electrs_status.rs branches on.
2026-09-09 13:58:19 +00:00
ssmithx 89f1042b85 fix(cuprate): gate package.restart and package.update too (review)
Restart and update are stop + recreate — a fresh start by another name —
but only start carried the disk gate, so on a disk that shrank below the
floor after install, either action silently resumed the unprunable
Monero sync: the exact failure the gate exists to close.

Both now call check_cuprate_disk_compatibility after validate_app_id and
BEFORE any state mutation (user-stopped clear / Restarting / Updating
flip), matching handle_package_start's fail-clean contract.
2026-09-09 13:58:19 +00:00
ssmithx 732ee5e714 feat(neode-ui): launch cuprate tiles on the Cuprate UI companion
cuprate publishes only raw JSON RPC (18090 restricted, 18183 p2p), so
launches must land on the companion on :18091, never on the running
node's runtimeUrl — same root-path special-case bitcoin uses, with the
dev vite proxy for /app/cuprate-ui/. Alias cuprate -> cuprate-ui so the
port-auth lookup finds the gated launch port on HTTPS nodes; pin the
companion icon to the cuprate mark.
2026-09-09 07:49:30 +00:00
ssmithx 37fde6cfa7 feat(cuprate-ui): companion dashboard for the Cuprate Monero node
Same companion shape as bitcoin-ui/electrs-ui: host-networked nginx
bound to 127.0.0.1:18091 (auth: gated + session_passthrough), serving
a dark glass status page that polls the node's restricted RPC via a
session-gated /cuprate-rpc/ proxy — sync height/target with progress
bar, peers, mempool, chain size and free disk (from get_info), plus a
wallet 'remote node' endpoint. The offline state explains the disk gate
so a refused node says why.

No secret rendering: the restricted RPC is Monero's safe-for-public
subset, so nginx.conf is baked into the image (no pre_start hook, no
bind mount). companion.rs auto-provisions archy-cuprate-ui alongside
cuprate and reaps it when cuprate goes.

Catalog regenerated (cuprate-ui entry + manifest embed, 18091 into the
mesh launch-port list). NOTE: releases/app-catalog.json is UNSIGNED as
committed — run scripts/sign-catalog.sh before publishing.
2026-09-09 07:49:18 +00:00
ssmithx b19f191a92 fix(cuprate): refuse to run on disks too small for the Monero chain
Cuprate has no pruning — verified against upstream main
(binaries/cuprated/src/config.rs): the 'pruning' crate is Monero's p2p
protocol pruning, not on-disk. Unlike the bitcoin apps, which branch on
DISK_GB in their entrypoint and self-prune, a disk-constrained cuprate
can only sync until the filesystem fills and take Archipelago down.

Translate the bitcoin disk-awareness into the only form cuprate can
honor — refuse rather than prune:
- install (sync + async RPC paths) and package.start fail with an
  actionable message below CUPRATE_MIN_DISK_GB (450 GB total: chain
  ~250 GiB + headroom; allows 500 GB-class, refuses the 250 GB VPS)
- boot reconcile skips an already-installed cuprate on a shrunken disk,
  recorded as Left("cuprate-insufficient-disk") before ensure_running
  so desired-state recovery can never undo it (same shape as
  requires-archival-bitcoin)
- df failure fail-opens at install (never block on an unreadable disk),
  fail-closes at boot (never start a doomed sync)

prod_orchestrator also registers cuprate-ui in UI_APP_IDS (its
companion commit follows).
2026-09-09 07:49:04 +00:00
archipelago db52c06a72 chore(catalog): sign Cuprate registry update 2026-09-07 05:12:05 -04:00
archipelago 4b14b62e74 chore: publish release v1.8.11-alpha
Demo images / Build & push demo images (push) Successful in 3m40s
2026-09-07 04:35:48 -04:00
archipelago 5da91e4099 chore: prepare release v1.8.11-alpha 2026-09-07 04:32:02 -04:00
archipelago 62731cc729 test(ui): use shipped app for generated launch port check 2026-09-07 03:30:29 -04:00
archipelago 5e17ace690 style(openwrt): format TollGate installer 2026-09-07 03:26:31 -04:00
archipelago b010471a4a chore(release): prep v1.8.11 notes and link checks 2026-09-07 03:26:01 -04:00
ai c4ede96517 Merge PR #154: docs(openwrt): OpenWrt Gateway setup guide + live-tested fixes
Demo images / Build & push demo images (push) Successful in 3m52s
2026-09-07 07:24:33 +00:00
ai be06e1a502 Merge PR #153: fix(cuprate): enable fast_sync and raise DB cache 2026-09-07 07:24:25 +00:00
ssmithxandClaude Sonnet 5 094f42312c docs(openwrt): document the confirmed working end-to-end install flow
Adds a verification checklist (service running, nodogsplash bound to
br-tollgate not br-lan via the rendered config not just UCI, LAN/SSH
untouched, mint probes succeeding) plus notes on the dev-build test-mint
injection and the default-route race between a router's LAN interface
and the node's other uplinks before the router's own WAN/WISP is live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-07 03:06:58 +00:00
ssmithxandClaude Sonnet 5 da8c3ec193 docs(openwrt): note the Ctrl+T/LuCI workaround for setting the initial root password
Archipelago's Connect form only authenticates with an existing password;
it has no flow for setting one on a fresh, passwordless router. On the
node's kiosk display there's no visible tab bar, so Ctrl+T to open a new
tab to LuCI is the way to set it before Connect will work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-07 02:53:21 +00:00
ssmithxandClaude Sonnet 5 4fdf8e8c58 fix(openwrt): bump pinned TollGate release v0.2.0 -> v0.5.0
The install code was hardcoded to the Oct 2025 v0.2.0 release —
nine releases behind. Its changelog covers exactly the failures hit
live against archy-x250-pa3: a mint with an empty/broken keyset
crash-looped tollgate-wrt forever (v0.5.0 adds "graceful degradation
when Cashu mints fail"), and the bundled captive-portal JS had zero
CBOR support, hard-rejecting the cashuB (NUT-00 V4) tokens modern
wallets like Minibits generate by default.

Also: v0.5.0 publishes native .apk packages for aarch64_cortex-a53
and x86_64. install_tollgate_apk_native now prefers those directly
(apk add handles deps/postinst/uci-defaults itself) instead of always
falling back to the manual ar/tar .ipk extraction dance, which only
exists because earlier releases had no native apk build at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 17:28:20 +00:00
ssmithxandClaude Sonnet 5 61b5d93b11 docs(openwrt): document the transient post-reboot apk-update failure
Observed live on archy-x250-pa3: right after WAN reconnects (fresh
boot or WAN reconfigure), the first Install attempt can fail with
"apk update failed ... router may have no internet access" purely
because the WiFi-uplink STA association hasn't finished yet — it's
not a real error, just retry a few seconds later. Also cross-referenced
the now-fixed /usr/bin/opkg hardcoding bug for anyone hitting it on an
older build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 16:36:53 +00:00
ssmithxandClaude Sonnet 5 be06b3ce2b fix(ui): stop sending an empty ssh_password over the saved router connection
provisionTollgate/saveTollgateConfig/scanWifi/configureWan all fell
back to the Connect form's local refs (host/sshUser/sshPassword) when
connectedParams was null. Those refs only get populated if the form
was actually submitted this session — on a normal page load the
router reconnects via the server-persisted config instead, leaving
sshPassword at its default ''. Sending that as an explicit
(empty-but-present) ssh_password overrides the backend's saved-config
fallback, so every action auths with a blank password instead of the
real saved one.

Added authParams(): omit host/ssh_user/ssh_password entirely unless
connectedParams is actually set, same as the status poll already does.
Caught live: dropbear on archy-x250-pa3's router logged a single bad
password attempt at the exact moment "Install TollGate" was clicked,
sandwiched between periodic status-poll connections succeeding with
the real saved password.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 15:14:07 +00:00
ssmithxandClaude Sonnet 5 f3d96ae2ee fix(openwrt): resolve opkg/apk via $PATH, not a hardcoded /usr/bin path
opkg_check() and every opkg/apk invocation hardcoded /usr/bin/opkg and
/usr/bin/apk. Official OpenWrt images don't all symlink /bin into
/usr/bin — the glinet_gl-mt3000 24.10.2 build keeps them as separate
real directories with opkg living in /bin — so the check silently
missed a perfectly normal install and TollGate provisioning failed
with "this router's firmware may not support package management".

Switched every call to resolve through the router's own $PATH
(command -v / bare opkg / apk) instead. Reproduced and fixed live
against archy-x250-pa3, 2026-09-05.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 15:14:00 +00:00
ssmithxandClaude Sonnet 5 a4ae375617 docs(openwrt): fix TollGate step — install is separate from configure
Step 4 described a single "Provision TollGate" action that prompts for
price/step/mint upfront. The real UI (OpenWrtGateway.vue) doesn't work
that way: "Install TollGate" is a one-click action with no config form
that installs with defaults, and price/step/mint/enabled are only
editable afterward via a separate "Edit" panel. Caught while walking
through a live install.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:32:39 +00:00
ssmithxandClaude Sonnet 5 0646bc4e85 docs(openwrt): add GL.iNet AX3000 → stock OpenWrt flashing steps
Worked example for the Beryl AX (GL-MT3000, mediatek/filogic) verified
against the OpenWrt wiki and firmware selector: exact sysupgrade image
filename, GL.iNet UI / LuCI flash path, post-flash SSH state, and the
U-Boot recovery procedure if the flash goes sideways.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:26:21 +00:00
ssmithxandClaude Sonnet 5 0faaf4577f docs: add OpenWrt Gateway setup guide
Walks a node operator through pairing an OpenWrt router over SSH,
running the WAN/WISP wizard, and provisioning TollGate pay-as-you-go
WiFi — plus an RPC/architecture reference for developers. Distills
the openwrt crate, RPC handlers, and Vue panel into user-facing steps
that didn't exist anywhere in docs/ before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:07:05 +00:00
ssmithxandClaude Sonnet 5 f9a1ef031c fix(cuprate): front the restricted RPC port with a Tor onion
The restricted-RPC port (18090) was `auth: none`, which the app gate
treats as fully exempt — no onion, no takeover, LAN/Tailscale IP only.
Flip it to `auth: open`: the gate still binds the external addresses
and fronts a Tor onion for the port, just without a dashboard login
challenge, since Monero wallet clients (Feather, monero-wallet-rpc,
GUI) speak plain HTTP JSON-RPC and can't hold a session cookie.

P2P (18183) stays `none` — no reason to Tor-front raw gossip.

Regenerated releases/app-catalog.json (unsigned) to embed the updated
manifest; needs scripts/sign-catalog.sh before it takes effect on any
node, since origin (catalog) wins over disk for catalog-covered apps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZnsiMtyJxiJBuvv7yLPUF
2026-09-03 14:51:24 +00:00
ssmithxandClaude Sonnet 5 cf240df4b6 fix(cuprate): enable fast_sync and raise DB cache — sustained 45% CPU
The default manifest baked in the exact broken config found on an
affected fleet node: no fast_sync (defaults false, forcing full ring-sig/
RandomX verification on every block) and target_max_memory capped at
~2.8GiB, which starved cuprated's DB cache into constant eviction/flush
(595GB/24h of block I/O on a node just appending ~2MB blocks every 2
minutes). A reference node with fast_sync = true and an 8GiB cache ran
at 2.8% CPU at the same chain height and block rate.

Set fast_sync = true and target_max_memory = 8GiB to match the healthy
reference config, and raise resources.memory_limit from 4Gi to 10Gi so
the container still has headroom above the larger cache.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RR7jRaicvqsJaqQQ92jpPQ
2026-09-03 08:56:52 +00:00
archipelago d8320896c4 chore: publish release v1.8.10-alpha
Demo images / Build & push demo images (push) Successful in 3m26s
2026-09-01 19:01:54 -04:00
archipelago b87f1f0612 chore: prepare release v1.8.10-alpha 2026-09-01 18:58:33 -04:00
archipelago 1ca002661b fix(lnd): SendPaymentV2 needs an explicit fee budget — absent means ZERO
Demo images / Build & push demo images (push) Successful in 3m28s
v1.8.9's move to Router.SendPaymentV2 shipped without fee_limit_sat,
and the v2 route treats an ABSENT fee limit as zero allowed fees.
Every real route carries a routing fee (the 2-hop route here: 1.5
sats), so the pathfinder rejected them all and the wallet answered
"No route to the recipient" on EVERY send — all day, on healthy
channels with plenty of liquidity both ways.

The router debug log makes it unambiguous:
  wallet payment (v1.8.9 backend): fee_limit=0 mSAT     -> no route
  same payment by hand (lncli --fee_limit=100): fee_limit=100000 mSAT -> settles in 0.65s

My earlier "pipeline verified" claim was wrong — the manual lncli
verification set a fee limit by hand and masked this exact bug. The
400k that succeeded this morning went through the pre-update backend
on the pre-update LND.

Payments now carry lncli's own default budget — the payment amount
(100%), preferring the payer-supplied amount for zero-value invoices
and the invoice's own amount otherwise, with a nominal floor so the
limit can never be zero. Unit-pinned so it cannot regress.
2026-09-01 18:42:37 -04:00
archipelago 0d0e2e243a feat(lnd): channel-peer watchdog — a dropped peer link heals itself
Demo images / Build & push demo images (push) Successful in 3m49s
LND normally reconnects channel peers after a restart, but not reliably:
after long or repeated downtime (an app update, a node reboot,
reconciler churn) the peer link can stay down for hours while BOTH
endpoints keep the channel flagged disabled in the routing graph. The
node looks perfectly healthy, the wallet shows balance, and every
payment in either direction fails "no route to the recipient" —
observed live on framework-pt (2026-09-01): its only channel sat
disabled on both policy sides for ~17 hours after the LND 0.21.2
update, while shorty had 583k spendable and the user was told, by a
mis-mapped modal, that they had 'no payment channel'.

The channel graph is desired state — every open channel should have a
live peer connection. A daemon-side watchdog now enforces it:

- every 2 minutes, list channels + peers over LND REST
- for each channel whose remote peer is not connected, look the peer's
  advertised addresses up in the public graph and dial one
- per-peer retries throttled to 10 minutes so an unreachable peer is
  not hammered; 'already connected' counts as done; a peer with no
  advertised address is logged once per pass (cannot be dialed)
- no-ops quietly on nodes without LND (missing macaroon) and while a
  wallet is locked (503 body has no channels)

Unit tests pin the selection against the live REST shapes
(remote_pubkey in /v1/channels vs pub_key in /v1/peers).

v1.8.10 CHANGELOG + What's New entries staged so the next release run
is clean first time.
2026-09-01 17:51:15 -04:00
archipelago 9c49b502e3 docs: post-1.8.9 verification — pipeline confirmed, routing failure root-caused to framework-pt's disabled channel 2026-09-01 16:36:27 -04:00
archipelago d68a013e35 docs: tracker — v1.8.9 published, NPM live-healed on shorty via the signed catalog; funding-gate fix staged for v1.8.10 2026-09-01 11:43:33 -04:00
archipelago 1464b1b24d fix(wallet): the Lightning funding gate states the node's real channel state
Demo images / Build & push demo images (push) Successful in 3m38s
"LND thinks I do not have a channel" while the wallet showed plenty of
liquidity (framework-pt, 2026-09-01): the send gate sums outbound over
FULLY-OPEN channels only, which is correct — a just-opened channel
sits in LND's pending list until it has ~3 confirmations, and an
open channel can have all its balance on the far side — but the modal
then claimed the node had NO channel at all, in every one of those
states, and pointed the user at opening another one.

The gate already fetched the full channel list; it now records WHY
liquidity is zero and the modal says the truth per state:
- pending channels -> "your new channel is waiting for on-chain
  confirmations, it unlocks automatically, nothing is needed from you"
  (and no "Open a channel" button — that would send the user to fix
  a problem they don't have, possibly opening a second channel)
- open channels, zero on the needed side -> "balance is on the far
  side — you can receive but there's nothing to send right now"
- payment refused with a routing/liquidity error -> says so, instead
  of claiming no channels
- only a genuinely channel-less node keeps the open-one guidance

Eleven unit tests pin the state machine, including the regression
case (pending-only -> 'pending', not 'none') and fail-open on RPC
errors.
2026-09-01 11:40:25 -04:00
archipelago 82001403b4 chore: publish release v1.8.9-alpha 2026-09-01 11:05:55 -04:00
archipelago 81ede159ac chore: prepare release v1.8.9-alpha 2026-09-01 11:02:00 -04:00
archipelago 8e988be853 chore(release): v1.8.9-alpha prep — What's New block + version bumps
Demo images / Build & push demo images (push) Successful in 3m48s
The release gate requires the freshly-built bundle to embed the new
version, and the version reaches the bundle through the What's New
modal in AccountInfoSection — there was no v1.8.9-alpha block yet, so
create-release.sh correctly refused to ship a bundle that looked stale.
This adds the block (the user-facing summary of today's LND/HTTPS/
launcher/NPM/Portainer fixes) and carries the version bumps the
aborted run had already written (Cargo.toml, Cargo.lock, package.json,
package-lock), so the re-run starts from a clean tree.

Verified: npm run build now produces assets containing 1.8.9-alpha
(Settings chunk), i.e. the exact check the script runs passes.
2026-09-01 10:54:47 -04:00
archipelago 210f7f1b12 chore(catalog): re-sign the catalog — NPM letsencrypt mount + NET_BIND_SERVICE
Regenerated from the fixed apps/nginx-proxy-manager/manifest.yml (the
only semantic change vs the previous signed catalog) and signed with
the release-root key. Catalog-covered nodes pick this up on their next
hourly fetch and the NPM start/die loop ends: s6 gets its /etc/letsencrypt
mount back and the internal nginx can bind 80/443/81 again under
--cap-drop=ALL.
2026-09-01 10:37:09 -04:00
archipelago ed49cc974f docs: tracker updated — fixes landed, tests green, remaining steps are the two mnemonic ceremonies + node updates 2026-09-01 10:31:36 -04:00
archipelago 4849186ab9 docs: incident tracker for the 2026-09-01 https/launcher/LND breakage + v1.8.9 notes
Demo images / Build & push demo images (push) Successful in 3m57s
Root-cause table, fix inventory, regression-test inventory and the
deploy/live-verification checklist for today's fleet incident — written
as the working record while the fixes land, so the deploy + verify steps
can be checked off against real nodes rather than memory. CHANGELOG
carries the user-facing notes for the release these fixes ship in.
2026-09-01 10:29:10 -04:00
archipelago 3347b8b8b9 fix(ui): https app launches and the nostr bridge follow the frame's real origin
Three launcher/bridge defects combined to make HTTPS dashboards look
broken while HTTP ones worked:

1. portAuth() looked the launch port up under the name the user clicks
   ('mempool-web', 'lnd', 'bitcoin-knots'…), but the signed catalog
   declares those ports under the manifest id that owns them
   (archy-mempool-web, lnd-ui, bitcoin-ui). The lookup missed,
   portIsGateFronted answered false, and an HTTPS dashboard handed app
   frames http:// URLs — blocked as mixed content: mempool and IndeeHub
   'did not connect', bitcoin knots/core opened http:// in a new tab.
   Resolution now follows launch aliases, then a port-wide catalog scan
   that only answers when every declarer of that port agrees (a port
   any app publishes as plain HTTP is never upgraded to https).

2. The signed-catalog cache was only warmed by the Store/Discover
   views, so a user who went straight to My Apps launched apps with an
   empty cache. Warmed at dashboard mount now — fetchAppCatalog()
   already memoizes with a 1h TTL.

3. The NIP-07 bridge compared event.origin for strict equality with the
   recorded (http) app URL and replied to the recorded URL as the
   postMessage targetOrigin — both break the moment a frame is scheme-
   upgraded (cached HSTS did exactly that): every nostr request was
   silently dropped and replies to the stale origin threw. The bridge
   now matches host+port (scheme deliberately ignored) and always
   replies to event.origin — the frame's real origin.

Unit tests cover alias resolution (incl. bitcoin-knots→8334→https),
the conservative port-scan, and scheme-agnostic sender matching.
2026-09-01 10:29:05 -04:00
archipelago e382e679ae fix(apps): NPM needs /etc/letsencrypt mounted and NET_BIND_SERVICE
Converting Nginx Proxy Manager to a platform manifest (fc68c5b6) dropped
two things its image hard-requires, and the result was an endless
start/die loop — shorty-s watched it restart 3,176 times:

1. /etc/letsencrypt mount: NPM's s6 'prepare' service refuses to boot
   without it ('ERROR: /etc/letsencrypt is not mounted!'). Mounted from
   the same persistent app directory as before
   (/var/lib/archipelago/nginx-proxy-manager/letsencrypt), so existing
   certificates are preserved — no data moves, no migration.

2. NET_BIND_SERVICE: NPM's internal nginx listens on 80, 443 AND 81,
   and the orchestrator runs --cap-drop=ALL. The legacy podman-run path
   defaulted to the full capability set (and the legacy repair path in
   package/config.rs always listed it), which is why this only broke
   once the manifest became the source of truth.

The signed catalog embeds manifests with origin-wins semantics, so the
catalog carries the fix for every catalog-covered node — regenerate it
here (plus the generated store/launcher-port artifacts, which also pick
up drift from bf6ef964's retired apps). Catalog re-signing follows the
usual ceremony.
2026-09-01 10:29:05 -04:00
archipelago 77d0768a21 fix(nginx): stop pinning HSTS — actively clear it instead
The HTTPS server block sent Strict-Transport-Security:
max-age=31536000; includeSubDomains. Browsers that visited HTTPS once
cached the policy and then silently upgraded the still-open HTTP
dashboard's fetches and frames to https — a scheme change is
cross-origin, so every /rpc/v1 call died 'No Access-Control-Allow-
Origin header' while the node was perfectly healthy (framework-pt
2026-09-01: the 'Failed to fetch' storm, dashboard 'not responding',
every app frame mixed-content-blocked).

Plain HTTP is a supported access mode BY DESIGN on this platform: the
node's certificate is optional and self-signed (Settings → Node
certificate, /ca.crt flow), and setup-node-ca.sh deliberately keeps
port 80 serving for devices that haven't installed the CA. So:

- port 80 sends no HSTS at all (with the rationale inline)
- port 443 sends max-age=0, which ACTIVELY DELETES the policy already
  cached by affected browsers — leaving it absent would have kept every
  stranded browser broken for a year

tests/lifecycle/bats/nginx-hsts.bats pins all three properties at the
gate: no live policy on :80, max-age=0 (never 31536000) on :443, and
no long-lived pin anywhere in the deployed config.
2026-09-01 10:28:57 -04:00
archipelago f133d5555a feat(apps): surface Portainer's first-run setup token in the credentials interstitial
Portainer >=2.21 no longer lets whoever loads the page first claim the
admin account: on a fresh install it mints a one-time setup token and
prints it ONLY to the server logs. On an appliance that is a dead end —
'check the Portainer server logs' is exactly what a user cannot follow,
and after the 2.45.0 update it made a freshly restarted Portainer look
broken ('disappeared', then demands a token nobody can find).

package.credentials — the same RPC that powers the login-credentials
card on the app page — now extracts the setup_token line from
portainer's recent container logs and hands it over with the existing
copy-button treatment, titled and explained for a first-time user. The
token stops being printed once setup completes, and any container
recreate drops the log line, so the card disappears on its own and no
dead token lingers. Parsing is a pure, unit-tested scan against the
live-captured 2.45.0 log shape (64 hex chars after setup_token=).
2026-09-01 10:28:57 -04:00
archipelago cbd5314dd9 fix(lnd): pay through Router.SendPaymentV2 — LND 0.21 removed the old route
LND 0.21.2 removed the deprecated Lightning.SendPaymentSync REST route
(/v1/channels/transactions). The backend still called it, so every
Lightning send answered literal HTTP 404 and the wallet UI reported
'Payment failed: Not Found' fleet-wide right after the pin bump —
receive worked, which made it look intermittent.

Pay through the supported Router.SendPaymentV2 route (/v2/router/send)
instead, keeping the existing contract with the UI:
- single-record responses (no_inflight_updates) unwrapped from the
  grpc-gateway result envelope, transport errors from the nested error
- a slow multi-hop payment still resolves as pending + payment hash
  (only LND may declare failure), never a false 'Payment failed'
- LND's failure_reason codes translated to the same plain-language
  advice, invoice-expiry still says 'ask for a fresh invoice'

Guard it at the gate: tests/lifecycle/bats/lnd-api-compat.bats POSTs a
deliberately-invalid invoice to /v2/router/send on the RUNNING LND and
fails if the route answers 404 — the image/backend skew that shipped
silently last time because no test ever spoke the payment endpoint.
Also bumps the stale lnd image expectation in remote-lifecycle.sh.
2026-09-01 10:28:49 -04:00
archipelago 9fb2e1ed9e chore(catalog): sign the Cuprate logging fix 2026-09-01 08:47:29 -04:00
archipelago 7125dea05d Merge PR #152: fix Cuprate logging defaults 2026-09-01 08:39:26 -04:00
ssmithxandClaude Sonnet 5 bcdf2c75be fix(cuprate): file log level should be info, not cuprated's debug default
The previous commit on this branch copied cuprated's raw
--generate-config defaults (stdout=info, file=debug, max_log_files=7)
verbatim. Turns out that's the wrong reference: compared against
ssmithx@archy-dev-pa:/home/ssmithx/cuprate/Cuprated.toml — the actual
dev config this app was built and tested against — file logging is
meant to run at "info" with 14 rotated files, not the binary's raw
"debug"/7. Confirmed live on amishparadise: podman logs (stdout) was
already clean at info, but the on-disk file log
(.local/share/cuprate/logs/<date>) was flooding with per-peer DEBUG
gossip (~400KB in 2 minutes) because it inherited the binary default
instead of the intended one.

Left the resource-tuning knobs in the reference config (8GB
target_max_memory, tokio/rayon thread counts, P2P connection counts,
explicit reader_threads) out of this file — those were sized for
ssmithx's dev box and don't apply here; this manifest's
target_max_memory already stays deliberately under the container's
4Gi memory_limit.

Regenerated releases/app-catalog.json (still unsigned).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ga6N8Jk1YdCTMMX1LjDpAr
2026-09-01 12:27:33 +00:00
ssmithxandClaude Sonnet 5 e77f60085d fix(cuprate): make Cuprated.toml logging levels explicit
apps/cuprate/manifest.yml only ever wrote network/target_max_memory/
rpc.restricted.enable into Cuprated.toml, so the [tracing.stdout] and
[tracing.file] tables were silently absent — cuprated still applied
its built-in info/debug/7 defaults, but nothing on disk showed it.
Verified live on amishparadise 2026-09-01: the deployed 5-line file
had no [tracing] section at all, and the level was only discoverable
by running `cuprated --generate-config` and diffing.

Add both tables to the manifest's files[].content with the same
values cuprated already defaults to, so every new install ships a
Cuprated.toml an operator can actually read and tune. overwrite:false
means already-deployed nodes (amishparadise included) keep their
existing file untouched — this only changes what fresh installs get.

Regenerated releases/app-catalog.json (unsigned) to embed the updated
manifest; needs scripts/sign-catalog.sh before it's authoritative for
the fleet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ga6N8Jk1YdCTMMX1LjDpAr
2026-09-01 12:19:20 +00:00
archipelago 6c31eb9d4a chore(catalog): sign the LND 0.21.2 sweep 2026-09-01 07:45:39 -04:00
archipelago 63e6c64c63 fix(ci): drop the remaining stray Claude-worktree gitlinks 2026-09-01 05:27:15 -04:00
archipelago 4d8bb1fd44 fix(ci): drop a stray gitlink that broke every demo build
A Claude worktree under aiui/ was committed as a submodule gitlink
(160000) with no .gitmodules entry, so actions/checkout's recursive
submodule pass exited 128 and every 'Build & push demo images' run since
v1.8.6 failed. Removing the index entry — the worktree is local state
and was never meant to be tracked.
2026-09-01 05:27:00 -04:00
archipelago 2b4b60013c feat(lnd): build LND 0.21.2-beta in-house and sweep the pin
Demo images / Build & push demo images (push) Failing after 40s
Upstream publishes no docker images; our v0.18.4 image was built in-house.
This pass: official v0.21.2-beta release binaries (sha256-verified against
the signed release manifest), static, on alpine with the same entrypoint
shape as our existing image, pushed to our registry and smoke-run.
LND 0.21 auto-migrates the channel DB on first start (keeping a backup) —
the Update button is user-initiated, never auto-applied.
2026-09-01 05:14:50 -04:00
archipelago f0ef410948 chore(catalog): sign the swept pins 2026-09-01 04:52:28 -04:00
archipelago 19467e9b7c chore(apps): sweep pin bumps — gitea 1.27.3, vaultwarden 1.37.2, filebrowser 2.63.23, home-assistant 2026.8.3, adguardhome 0.107.79, portainer 2.45.0, pine-whisper 3.6.0
Demo images / Build & push demo images (push) Failing after 39s
First upstream sweep since v1.8.5: the safe patch/minor pins, mirrored
into our registry first (source.archipelago-foundation.org/lfg2025/*).
Held for their own careful passes: the majors (grafana 11, nextcloud,
uptime-kuma 2, bitcoin-core 29, the DBs) and consensus-sensitive apps
(fedimint, electrumx 2.0). LND 0.21.2 needs an in-house image build —
upstream publishes none.
2026-09-01 04:50:49 -04:00
archipelago 628ed252b4 chore: publish release v1.8.8-alpha 2026-09-01 04:18:16 -04:00
archipelago bc94445ca0 chore: sign the v1.8.8 app catalog + release manifest 2026-09-01 03:57:23 -04:00
archipelago 04cf0f663a chore: drop the superseded v1.8.8 prep for rebuild 2026-09-01 03:49:16 -04:00
archipelago 576c642da4 fix(apps): ollama resource type + adguardhome port; gate on collisions
Demo images / Build & push demo images (push) Failing after 38s
Ollama's embedded manifest failed the typed parse (memory_limit wants a
string) so the catalog overlay was skipped for it; AdGuard Home's
conventional :3000 collided with Grafana's. The release gate now runs
the host-port collision test (repo_app_manifests_have_no_host_port_collisions)
so this class can never ship untested again.
2026-09-01 03:31:02 -04:00
archipelago 12866db84a chore: sign the v1.8.8 app catalog + release manifest 2026-09-01 03:16:10 -04:00
archipelago a184254706 style: rustfmt the ssh-mesh module 2026-09-01 02:42:39 -04:00
archipelago 192e045426 feat(ui): SSH-over-mesh card, store-listing filter, icon treatment
Demo images / Build & push demo images (push) Failing after 41s
Settings gains the SSH-over-mesh card (danger-zone confirmation for the
any-peer scope, sshd preflights, fipssh copy hint). The signed-catalog
merge filters components via the shared serviceNames module; Discover
grids get the standard icon container; install no longer yanks the user
to My Apps; v1.8.8 release notes.
2026-09-01 02:41:55 -04:00
archipelago 9ac46a69f8 feat(fips): SSH-over-mesh toggle + manifest-driven package metadata
fips/ssh_mesh.rs owns the 90-ssh.nft drop-in lifecycle: off by default,
any-peer scope behind the UI's danger confirmation or an explicit mesh
address list, reconciled on every daemon config install. The scanner now
takes installed apps' icons from their real manifest metadata (Cuprate's
Services tile) and classifies manifest-declared UI apps as launchable
even when the address probe misses (Alby Hub).
2026-09-01 02:41:55 -04:00
archipelago bf6ef9644c chore(apps): retire morphos-server, did-wallet, lightning-stack, cryptpad
Store-listing components are filtered via the shared serviceNames canon;
these four never earn a tile: MorphOS server is old, the Web5 DID wallet
and CryptPad are untested, Lightning Stack is an untracked upstream
bundle (LND covers it).
2026-09-01 02:41:55 -04:00
archipelago c32910809e chore: publish release v1.8.7-alpha 2026-09-01 01:42:12 -04:00
115 changed files with 7110 additions and 8466 deletions
+42
View File
@@ -1,5 +1,47 @@
# Changelog # Changelog
## v1.8.11-alpha (2026-09-07)
- **Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.
- **OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.
- **Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links.
## v1.8.10-alpha (2026-09-02)
- **Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered "No route to the recipient" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.
- **A channel that drops its peer link now heals itself — on every node.** Restarting LND (an app update, a reboot, container churn) can leave a channel's peer connection down for hours while both endpoints keep the channel flagged disabled in the routing graph: the node looks perfectly healthy, the wallet shows balance, and every payment in either direction fails "no route to the recipient". Observed live: a node's only channel sat unroutable for ~17 hours after the LND 0.21.2 update, with no sign of it in any dashboard. The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't, using the peer's advertised addresses. Nodes without LND are untouched; an unreachable peer is retried gently, not hammered.
- **The Lightning wallet states the node's real funding state instead of "you have no channel."** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had NO channel at all (the outbound sum is legitimately zero in both states), pointed the user at opening a second channel, and — for payment routing failures — even showed the *receiving* copy. The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of claiming channel problems, and only a genuinely channel-less node keeps the open-one guidance.
## v1.8.9-alpha (2026-09-01)
- **Lightning sends work again after the LND 0.21.2 update.** LND 0.21 removed the old synchronous payment route the node's backend paid through (`/v1/channels/transactions`) — every Lightning send answered the literal "Not Found" and the wallet showed "Payment failed: Not Found". The backend now pays through the supported Router.SendPaymentV2 route, keeps the same settle-then-report behaviour (a slow multi-hop payment is still tracked to completion, never falsely declared failed), and translates LND's failure reasons into plain advice. A new gate test speaks the payment route directly against the running LND, so an image/backend skew like this can never ship silently again.
- **The node no longer pins HSTS — HTTP access is a supported mode, and it stays working.** The HTTPS listener used to send `Strict-Transport-Security: max-age=31536000; includeSubDomains`; browsers that visited HTTPS once cached that and then silently upgraded the still-open HTTP dashboard's calls to HTTPS, which is a scheme change — cross-origin — so every request died as "CORS blocked / Failed to fetch" while the node was perfectly healthy. The HTTPS listener now actively clears the cached policy (`max-age=0`) and port 80 sends no HSTS at all, which is deliberate: the node's certificate is optional and self-signed, and devices that haven't installed the CA must keep plain-HTTP access (that's what Settings → Node certificate is for). If your browser already cached the old policy, visiting the dashboard over HTTPS once after this update clears it; a gate test now refuses any config that reintroduces the pin.
- **App frames open over HTTPS again — including the ones that "did not connect."** The launcher asked the signed catalog for each app's port policy under the name you click ("Mempool Web", "Bitcoin Knots"), but the catalog declares those ports under the manifest that owns them (the Mempool web container, Bitcoin UI). The lookup missed, the launcher handed the iframe an `http://` address, and the browser blocked it as mixed content — the app tile went blank or spun forever. Port resolution now follows launch aliases (mempool-web, bitcoin-knots/bitcoin-core, lnd, electrs and friends), falls back to a port-wide catalog scan when the id is unknown, and the catalog is warmed as soon as the dashboard loads rather than only in the App Store, so the very first app you open already knows which ports serve TLS.
- **Signing in to IndeeHub with Nostr works over HTTPS.** The NIP-07 bridge compared the app frame's origin for exact equality with the recorded `http://` app URL — a frame the browser upgraded to HTTPS (or any scheme change) was silently ignored, and replies addressed to the stale origin were refused outright, so Nostr sign-in quietly did nothing. The bridge now matches host and port (scheme intentionally ignored) and always replies to the frame's real origin.
- **Nginx Proxy Manager starts again.** Converting it to a platform manifest dropped two things its image needs: the `/etc/letsencrypt` mount its boot script hard-requires, and the `NET_BIND_SERVICE` capability its internal nginx needs to bind ports 80/443/81 under the orchestrator's `--cap-drop=ALL`. The result was an endless start/die loop (a node watched it restart 3,176 times). Both are declared in its manifest now, its certs live on unchanged under the same persistent app directory, and the signed catalog carries the fix so installed nodes heal on the next update.
- **Portainer's first-run token is in the app page, not buried in "server logs."** New Portainer versions mint a one-time setup token on a fresh install and print it only to the container logs — on an appliance that meant telling the user to go read a server log to get into their own app. The token now appears in the same launch interstitial as app login credentials (with a copy button), only while first-run setup is actually pending; once the admin account exists the card disappears on its own.
- **The Lightning wallet states the node's real funding state instead of "you have no channel."** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had no channel at all (the outbound sum is legitimately zero in both states). The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of pointing at channel setup, and only a genuinely channel-less node is sent to open one.
## v1.8.8-alpha (2026-09-01)
- **SSH over the mesh is now a first-class setting.** Settings gains an "SSH over mesh" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit "I understand" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the `90-ssh.nft` drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, `fipssh <user>@<node-npub>` connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.
- **The App Store now lists apps — not parts of apps.** The signed catalog carries every manifest because the node's update layer needs their pins, and the store briefly listed them all: Mempool API, LND UI, Bitcoin UI, the Pine voice engines, the IndeeHub and Immich backends, the mesh router and friends. Components are hidden from the store listing (they still appear where they belong — the Services tab of My Apps, once installed), and four entries that never earned a tile are gone outright: MorphOS server (old), the Web5 DID wallet, Lightning Stack (an untracked upstream bundle — LND covers the need), and CryptPad (never tested).
- **App icons now persist everywhere, in the proper container style.** Two fixes: installed apps render the icon from their own manifest — Cuprate no longer falls back to the generic A-mark on its Services tile — and the store grids (the Discover page) apply the same icon container treatment (backdrop, border, shadow) as My Apps, the detail pages, and Home. Manifest-declared UI apps also classify correctly again: Alby Hub installs into My Apps with a working tile, not into Services, because a probe miss no longer buries an app the manifest itself says has a frontend.
- **Installing from the store keeps you on the store page.** The install progress lives on the tile itself and the app appears in My Apps when it lands — no more being yanked to My Apps mid-browse.
## v1.8.7-alpha (2026-08-31) ## v1.8.7-alpha (2026-08-31)
- **What's New really does stop at v1.8.0 now.** The first correction removed old generated release blocks but missed six much older hand-written v1.2 sections at the bottom of the modal. Those sections are gone, and the release check now recognizes and rejects that legacy format too, so the history floor cannot falsely pass again. - **What's New really does stop at v1.8.0 now.** The first correction removed old generated release blocks but missed six much older hand-written v1.2 sections at the bottom of the modal. Those sections are gone, and the release check now recognizes and rejects that legacy format too, so the history floor cannot falsely pass again.
Submodule aiui/.claude/worktrees/agitated-hofstadter deleted from 10e12a329f
Submodule aiui/.claude/worktrees/funny-hofstadter deleted from 1c5185a15c
Submodule aiui/.claude/worktrees/happy-colden deleted from 666e1232f4
Submodule aiui/.claude/worktrees/hardcore-beaver deleted from a817fa199f
Submodule aiui/.claude/worktrees/heuristic-raman deleted from e8e002debc
Submodule aiui/.claude/worktrees/priceless-colden deleted from aaaef7d710
+20 -32
View File
@@ -13,20 +13,20 @@
{ {
"id": "adguardhome", "id": "adguardhome",
"title": "AdGuard Home", "title": "AdGuard Home",
"version": "v0.107.55", "version": "v0.107.79",
"description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.", "description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.",
"icon": "", "icon": "",
"author": "AdGuard", "author": "AdGuard",
"category": "networking", "category": "networking",
"tier": "optional", "tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55", "dockerImage": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.79",
"repoUrl": "https://github.com/AdguardTeam/AdGuardHome" "repoUrl": "https://github.com/AdguardTeam/AdGuardHome"
}, },
{ {
"id": "alby-hub", "id": "alby-hub",
"title": "Alby Hub", "title": "Alby Hub",
"version": "1.23.0", "version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect \u2014 one hub, every app pays through it.", "description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg", "icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby", "author": "Alby",
"category": "money", "category": "money",
@@ -117,18 +117,6 @@
"bitcoin-knots" "bitcoin-knots"
] ]
}, },
{
"id": "cryptpad",
"title": "CryptPad",
"version": "2024.12.0",
"description": "End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.",
"icon": "/assets/icon/favico-black-v2.svg",
"author": "XWiki SAS",
"category": "data",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/cryptpad:2024.12.0",
"repoUrl": "https://github.com/cryptpad/cryptpad"
},
{ {
"id": "cuprate", "id": "cuprate",
"title": "Cuprate", "title": "Cuprate",
@@ -203,13 +191,13 @@
{ {
"id": "filebrowser", "id": "filebrowser",
"title": "File Browser", "title": "File Browser",
"version": "2.27.0", "version": "2.63.23",
"description": "Baseline Archipelago file manager service.", "description": "Baseline Archipelago file manager service.",
"icon": "/assets/img/app-icons/file-browser.webp", "icon": "/assets/img/app-icons/file-browser.webp",
"author": "File Browser", "author": "File Browser",
"category": "data", "category": "data",
"tier": "core", "tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0", "dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.63.23",
"repoUrl": "https://github.com/filebrowser/filebrowser", "repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -230,12 +218,12 @@
{ {
"id": "gitea", "id": "gitea",
"title": "Gitea", "title": "Gitea",
"version": "1.23", "version": "1.27.3",
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.", "description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
"icon": "/assets/img/app-icons/gitea.svg", "icon": "/assets/img/app-icons/gitea.svg",
"author": "Gitea", "author": "Gitea",
"category": "development", "category": "development",
"dockerImage": "docker.io/gitea/gitea:1.23", "dockerImage": "source.archipelago-foundation.org/lfg2025/gitea:1.27.3",
"repoUrl": "https://gitea.com", "repoUrl": "https://gitea.com",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -286,12 +274,12 @@
{ {
"id": "homeassistant", "id": "homeassistant",
"title": "Home Assistant", "title": "Home Assistant",
"version": "2026.7.3", "version": "2026.8.3",
"description": "Open source home automation platform. Control and monitor your smart home devices.", "description": "Open source home automation platform. Control and monitor your smart home devices.",
"icon": "/assets/img/app-icons/homeassistant.png", "icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant", "author": "Home Assistant",
"category": "home", "category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2", "dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.3",
"repoUrl": "https://github.com/home-assistant/core", "repoUrl": "https://github.com/home-assistant/core",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -350,13 +338,13 @@
{ {
"id": "lnd", "id": "lnd",
"title": "LND", "title": "LND",
"version": "0.18.4", "version": "0.21.2",
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.", "description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
"icon": "/assets/img/app-icons/lnd.png", "icon": "/assets/img/app-icons/lnd.png",
"author": "Lightning Labs", "author": "Lightning Labs",
"category": "money", "category": "money",
"tier": "core", "tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta", "dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.21.2-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd", "repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [ "requires": [
"bitcoin-knots" "bitcoin-knots"
@@ -382,7 +370,7 @@
"id": "netbird", "id": "netbird",
"title": "NetBird", "title": "NetBird",
"version": "2.38.0", "version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point \u2014 a TLS proxy in front of the dashboard + server.", "description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg", "icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird", "author": "NetBird",
"category": "networking", "category": "networking",
@@ -424,7 +412,7 @@
"id": "nginx-proxy-manager", "id": "nginx-proxy-manager",
"title": "Nginx Proxy Manager", "title": "Nginx Proxy Manager",
"version": "2.12.1", "version": "2.12.1",
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration \u2014 the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).", "description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
"icon": "/assets/img/app-icons/nginx.svg", "icon": "/assets/img/app-icons/nginx.svg",
"author": "Nginx Proxy Manager", "author": "Nginx Proxy Manager",
"category": "networking", "category": "networking",
@@ -460,7 +448,7 @@
"id": "ollama", "id": "ollama",
"title": "Ollama", "title": "Ollama",
"version": "0.5.4", "version": "0.5.4",
"description": "Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware \u2014 served on the node's loopback for the AI assistant (Settings \u2192 Claude Auth \u2192 model backend), never exposed to the network.", "description": "Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware — served on the node's loopback for the AI assistant (Settings → Claude Auth → model backend), never exposed to the network.",
"icon": "/assets/img/app-icons/ollama.png", "icon": "/assets/img/app-icons/ollama.png",
"author": "Ollama", "author": "Ollama",
"category": "community", "category": "community",
@@ -472,7 +460,7 @@
"id": "phoenixd", "id": "phoenixd",
"title": "phoenixd", "title": "phoenixd",
"version": "0.9.0", "version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own \u2014 it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.", "description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg", "icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ", "author": "ACINQ",
"category": "money", "category": "money",
@@ -507,7 +495,7 @@
"id": "pine", "id": "pine",
"title": "Pine", "title": "Pine",
"version": "1.3.0", "version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.", "description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg", "icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago", "author": "Archipelago",
"category": "home", "category": "home",
@@ -517,13 +505,13 @@
{ {
"id": "portainer", "id": "portainer",
"title": "Portainer", "title": "Portainer",
"version": "2.19.4", "version": "2.45.0",
"description": "Container management web UI for the local Podman socket.", "description": "Container management web UI for the local Podman socket.",
"icon": "/assets/img/app-icons/portainer.webp", "icon": "/assets/img/app-icons/portainer.webp",
"author": "Portainer", "author": "Portainer",
"category": "development", "category": "development",
"tier": "optional", "tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6", "dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.45.0",
"repoUrl": "https://github.com/portainer/portainer", "repoUrl": "https://github.com/portainer/portainer",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -615,13 +603,13 @@
{ {
"id": "vaultwarden", "id": "vaultwarden",
"title": "Vaultwarden", "title": "Vaultwarden",
"version": "1.30.0", "version": "1.37.2",
"description": "Self-hosted password vault with zero-knowledge encryption.", "description": "Self-hosted password vault with zero-knowledge encryption.",
"icon": "/assets/img/app-icons/vaultwarden.webp", "icon": "/assets/img/app-icons/vaultwarden.webp",
"author": "Vaultwarden", "author": "Vaultwarden",
"category": "data", "category": "data",
"tier": "recommended", "tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine", "dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.2-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden", "repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
+8 -5
View File
@@ -1,7 +1,7 @@
app: app:
id: adguardhome id: adguardhome
name: AdGuard Home name: AdGuard Home
version: v0.107.55 version: v0.107.79
upstream: upstream:
kind: github kind: github
repo: AdguardTeam/AdGuardHome repo: AdguardTeam/AdGuardHome
@@ -10,7 +10,7 @@ app:
device on your LAN, with a web console for rules and client management. device on your LAN, with a web console for rules and client management.
container: container:
image: source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55 image: source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.79
pull_policy: if-not-present pull_policy: if-not-present
network: pasta network: pasta
@@ -28,10 +28,13 @@ app:
network_policy: isolated network_policy: isolated
ports: ports:
- host: 3000 - host: 3030
container: 3000 container: 3000
protocol: tcp protocol: tcp
bind: 127.0.0.1 bind: 127.0.0.1
# 3030, not AdGuard Home's conventional 3000: Grafana owns :3000 on a
# node, and both being installable means the host ports must not
# collide (the orchestrator refuses/loads warn on overlap).
# open: the setup wizard and admin console carry AdGuard Home's own # open: the setup wizard and admin console carry AdGuard Home's own
# login; the gate fronts the port (TLS, header fixes) without a # login; the gate fronts the port (TLS, header fixes) without a
# second cookie challenge. # second cookie challenge.
@@ -67,7 +70,7 @@ app:
health_check: health_check:
type: tcp type: tcp
endpoint: localhost:3000 endpoint: localhost:3030
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
@@ -77,7 +80,7 @@ app:
name: Admin console name: Admin console
description: AdGuard Home web console description: AdGuard Home web console
type: ui type: ui
port: 3000 port: 3030
protocol: http protocol: http
path: / path: /
-67
View File
@@ -1,67 +0,0 @@
app:
id: cryptpad
name: CryptPad
version: 2024.12.0
upstream:
kind: github
repo: cryptpad/cryptpad
description: End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.
container:
image: source.archipelago-foundation.org/lfg2025/cryptpad:2024.12.0
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 5Gi
resources:
memory_limit: 1Gi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 3000
container: 3000
protocol: tcp
bind: 127.0.0.1
# gated: CryptPad is browser-only (its own per-user accounts sit on top
# of the node login, exactly like Vaultwarden), so the gate's session
# challenge costs nothing and keeps the pads behind the node login.
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/cryptpad
target: /cryptpad/data
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:3000
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: CryptPad
description: Encrypted collaboration suite
type: ui
port: 3000
protocol: http
path: /
metadata:
author: XWiki SAS
category: data
icon: /assets/icon/favico-black-v2.svg
repo: https://github.com/cryptpad/cryptpad
tier: optional
+67
View File
@@ -0,0 +1,67 @@
app:
id: cuprate-ui
name: Cuprate UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP frontend for the Cuprate Monero node. Runs nginx
inside a container, serves a static status dashboard, and proxies
/cuprate-rpc/ to the cuprate restricted RPC on 127.0.0.1:18090 (the
published host port for the container's 18089). No credentials are
injected — the restricted RPC is Monero's own safe-for-public subset — so
the nginx.conf is baked into the image and there is no rendered-config
bind-mount like bitcoin-ui's.
container:
build:
context: /opt/archipelago/docker/cuprate-ui
dockerfile: Dockerfile
tag: localhost/cuprate-ui:local
dependencies:
- app_id: cuprate
resources:
memory_limit: 64Mi
security:
readonly_root: false
network_policy: host
# Host networking: nginx listens on 18091 directly on the host IP.
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate would have no
# idea the port existed: neither protected nor listed as unprotected.
ports:
- host: 18091
container: 18091
protocol: tcp
bind: 127.0.0.1
auth: gated
# First-party companion UI: its nginx forwards the node session cookie
# to the daemon's authenticated endpoints; without passthrough the gate
# strips it and every data call 401s while the page shell renders.
session_passthrough: true
volumes: []
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:18091
path: /
interval: 30s
timeout: 5s
retries: 3
metadata:
icon: /assets/img/app-icons/cuprate.svg
category: money
tier: optional
author: Archipelago
repo: https://github.com/Cuprate/cuprate
+74 -17
View File
@@ -36,17 +36,38 @@ app:
data_uid: "1000:1000" data_uid: "1000:1000"
dependencies: dependencies:
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a # Monero mainnet is ~250GiB unpruned as of 2026 and growing ~60GiB/year.
# month; cuprated's pruning support is not confirmed stable yet (the # Verified against upstream main (binaries/cuprated/src/config.rs, 2026-09):
# `pruning` crate exists in the workspace but nothing in this config # cuprated has NO on-disk pruning setting of any kind — the `pruning`
# surface toggles it), so this sizes for a full unpruned chain plus # crate in its workspace is Monero's p2p *protocol* pruning, not a
# headroom rather than assuming pruning is available. # smaller chain — so unlike bitcoin-knots this app CANNOT self-prune
- storage: 300Gi # when disk is scarce (see the DISK_GB branch in
# apps/bitcoin-knots/manifest.yml). Left running on a too-small disk it
# syncs until the filesystem fills and takes Archipelago down. The
# disk-scarce equivalent is enforced in Rust instead: install, start,
# restart and update refuse, and boot reconcile skips, on any node under
# CUPRATE_MIN_DISK_GB (450GB — chain + headroom; refuses the 250GB VPS
# class, allows 500GB-class disks). If upstream ever ships a prune flag,
# replace that gate with the bitcoin-style entrypoint branch.
#
# 450Gi, not the chain size (~250GiB): every manifest-driven surface
# (store size display, install pre-checks, docs) must show the number the
# Rust gate actually enforces, or a user provisioned to the displayed
# value gets refused at a different, unexplained one. Single source of
# truth is crate::constants::CUPRATE_MIN_DISK_GB — keep in lockstep.
- storage: 450Gi
resources: resources:
cpu_limit: 0 cpu_limit: 0
memory_limit: 4Gi # Raised from 4Gi alongside target_max_memory below (see files[] comment)
disk_limit: 300Gi # — 2026-09-03 incident: a 4Gi/3GB-cache config starved
# cuprated's DB cache into constant eviction/flush, driving 45% sustained
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
# headroom above the 8GiB cache for the process itself.
memory_limit: 10Gi
# Matches the storage dependency above (= the enforced disk floor),
# not the raw chain size — see the CUPRATE_MIN_DISK_GB note.
disk_limit: 450Gi
security: security:
# FROM scratch, no package manager/shell, ownership fixed at build time # FROM scratch, no package manager/shell, ownership fixed at build time
@@ -82,17 +103,21 @@ app:
# bind without an explicit i_know_what_im_doing override. # bind without an explicit i_know_what_im_doing override.
# Restricted RPC: Monero's own purpose-built safe-for-public subset — # Restricted RPC: Monero's own purpose-built safe-for-public subset —
# what wallets use when connecting to a "remote node". Disabled by # what wallets use when connecting to a "remote node". Disabled by
# cuprated's own default; enabled via files[] below. A dashboard login # cuprated's own default; enabled via files[] below. `open`, not `gated`:
# would break wallet clients connecting programmatically, same # the gate still takes the port over (loopback pin, external binds,
# reasoning as electrumx's port. The daemon still uses its canonical # fronts the Tor onion) but skips the dashboard login challenge, same
# container port 18089, but Penpot already owns host port 18089, so this # reasoning as electrumx's port — wallet clients (Feather,
# maps the public host port to the free 18090 instead. # monero-wallet-rpc, GUI) speak plain HTTP JSON-RPC programmatically and
# cannot complete a browser login or hold a session cookie. The daemon
# still uses its canonical container port 18089, but Penpot already owns
# host port 18089, so this maps the public host port to the free 18090
# instead.
- host: 18090 - host: 18090
container: 18089 container: 18089
protocol: tcp protocol: tcp
auth: none auth: open
auth_rationale: >- auth_rationale: >-
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie. Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot complete a browser login or hold a dashboard session cookie.
volumes: volumes:
- type: bind - type: bind
@@ -103,11 +128,23 @@ app:
# Settings that need to differ from cuprated's own documented defaults # Settings that need to differ from cuprated's own documented defaults
# (verified against `cuprated --generate-config` and `--dry-run` locally, # (verified against `cuprated --generate-config` and `--dry-run` locally,
# 2026-08-21): # 2026-08-21):
# - fast_sync: cuprated's own default is false, which performs full
# cryptographic verification (ring signatures + RandomX PoW) on every
# incoming block instead of trusting checkpointed history. Root-caused
# 2026-09-03 as the dominant cause of a sustained 45% CPU node,
# vs. 2.8% on a reference node with fast_sync = true — same chain height, same
# block rate. Set explicitly rather than relying on the binary
# default so fresh deploys don't silently regress into full-verify.
# - target_max_memory: cuprated's own default auto-detects total *host* # - target_max_memory: cuprated's own default auto-detects total *host*
# RAM via sysinfo, which inside a memory-limited container would let # RAM via sysinfo, which inside a memory-limited container would let
# it size caches far past what resources.memory_limit above actually # it size caches far past what resources.memory_limit above actually
# grants — same class of problem bitcoin-knots' -dbcache sizing # grants — same class of problem bitcoin-knots' -dbcache sizing
# comment addresses. Set explicitly, comfortably under the 4Gi limit. # comment addresses. Set explicitly, comfortably under the 10Gi limit.
# Previously 3000000000 (~2.8GiB); that starved the DB cache and
# forced constant eviction/flush (595GB/24h block I/O on a node just
# appending ~2MB blocks every 2 minutes) — raised to 8GiB, matching
# the healthy reference node, and
# resources.memory_limit above raised in step to keep headroom above it.
# - rpc.restricted.enable: cuprated ships this off by default; flip on # - rpc.restricted.enable: cuprated ships this off by default; flip on
# so the auth:none host port above actually serves something instead # so the auth:none host port above actually serves something instead
# of refusing every connection. port stays at its documented default # of refusing every connection. port stays at its documented default
@@ -125,14 +162,34 @@ app:
# uses for its own RPC port (-rpcbind=0.0.0.0:8332 internally, gate # uses for its own RPC port (-rpcbind=0.0.0.0:8332 internally, gate
# restricts it externally) — not a new risk, the same one already # restricts it externally) — not a new risk, the same one already
# reviewed and accepted for Bitcoin's RPC. # reviewed and accepted for Bitcoin's RPC.
# - tracing.stdout.level / tracing.file.{level,max_log_files}: an
# operator reading Cuprated.toml on disk should be able to see and
# tune the log level directly instead of the file silently omitting
# the whole [tracing] table (verified live on the affected node
# 2026-09-01: the deployed file had no [tracing] section at all, and
# the level was only discoverable by running `cuprated
# --generate-config` and diffing). file.level is set to "info", NOT
# cuprated's own raw default of "debug" — matches the reference dev
# config this app was built and tested against (verified 2026-09-01),
# which deliberately runs file logging quieter
# than the binary default. max_log_files similarly follows that
# reference (14, not the binary default of 7).
files: files:
- path: /var/lib/archipelago/cuprate/Cuprated.toml - path: /var/lib/archipelago/cuprate/Cuprated.toml
content: | content: |
network = "Mainnet" network = "Mainnet"
target_max_memory = 3000000000 fast_sync = true
target_max_memory = 8589934592
[rpc.restricted] [rpc.restricted]
enable = true enable = true
[tracing.stdout]
level = "info"
[tracing.file]
level = "info"
max_log_files = 14
overwrite: false overwrite: false
health_check: health_check:
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md
-39
View File
@@ -1,39 +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 ./
COPY --from=builder /app/public ./public
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/wallet && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
ENV WALLET_STORAGE=/app/wallet
ENV DWN_ENDPOINT=http://web5-dwn:3000
CMD ["node", "dist/index.js"]
-35
View File
@@ -1,35 +0,0 @@
# DID Wallet
Web5 wallet with Decentralized Identifier (DID) support.
## Building
```bash
# From the apps directory
./build.sh did-wallet
# Or manually
cd did-wallet
docker build -t archipelago/did-wallet:latest .
```
## Development
```bash
cd did-wallet
npm install
npm run dev
```
## Ports
- **8083**: Web UI (dev: 18083)
## Running Locally
```bash
docker run -p 8083:8080 \
-v /tmp/archipelago-dev/did-wallet:/app/wallet \
-e DWN_ENDPOINT=http://localhost:13000 \
archipelago/did-wallet:latest
```
-59
View File
@@ -1,59 +0,0 @@
app:
id: did-wallet
name: Web5 DID Wallet
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
container:
image: archipelago/did-wallet:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 2Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: did-wallet
ports:
- host: 8088
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/did-wallet
target: /app/wallet
options: [rw]
environment:
- WALLET_STORAGE=/app/wallet
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
web5_integration:
did_support: true
wallet_functionality: true
bitcoin_integration: true
-2747
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
{
"name": "did-wallet",
"version": "1.0.0",
"description": "Web5 DID Wallet for Archipelago",
"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"
}
}
-23
View File
@@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DID Wallet</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
</style>
</head>
<body>
<h1>Web5 DID Wallet</h1>
<p>Decentralized Identity Wallet for Archipelago</p>
<div id="app">
<p>Wallet interface coming soon...</p>
</div>
</body>
</html>
-37
View File
@@ -1,37 +0,0 @@
import express from 'express';
const app = express();
const port = 8080;
// Middleware
app.use(express.json());
app.use(express.static('public'));
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'did-wallet' });
});
// Wallet API endpoints
app.get('/api/wallet/info', (req, res) => {
res.json({
status: 'ok',
wallet: {
dids: [],
balance: 0
}
});
});
app.post('/api/wallet/did/create', async (req, res) => {
// Placeholder for DID creation
res.json({
status: 'ok',
did: 'did:key:placeholder'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`DID Wallet listening on port ${port}`);
});
-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"]
}
+2 -2
View File
@@ -1,7 +1,7 @@
app: app:
id: filebrowser id: filebrowser
name: File Browser name: File Browser
version: 2.27.0 version: 2.63.23
# Where this app comes from, so scripts/check-upstream-releases.py can # Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can: # tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from. # container.image names our mirror, not the project it was mirrored from.
@@ -11,7 +11,7 @@ app:
description: Baseline Archipelago file manager service. description: Baseline Archipelago file manager service.
container: container:
image: source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0 image: source.archipelago-foundation.org/lfg2025/filebrowser:v2.63.23
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
custom_args: ["--config", "/data/.filebrowser.json"] custom_args: ["--config", "/data/.filebrowser.json"]
+2 -2
View File
@@ -1,7 +1,7 @@
app: app:
id: gitea id: gitea
name: Gitea name: Gitea
version: "1.23" version: "1.27.3"
# Where this app comes from, so scripts/check-upstream-releases.py can # Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can: # tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from. # container.image names our mirror, not the project it was mirrored from.
@@ -12,7 +12,7 @@ app:
category: development category: development
container: container:
image: docker.io/gitea/gitea:1.23 image: source.archipelago-foundation.org/lfg2025/gitea:1.27.3
pull_policy: if-not-present pull_policy: if-not-present
dependencies: dependencies:
+2 -2
View File
@@ -1,7 +1,7 @@
app: app:
id: homeassistant id: homeassistant
name: Home Assistant name: Home Assistant
version: 2026.7.3 version: 2026.8.3
# Where this app comes from, so scripts/check-upstream-releases.py can # Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can: # tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from. # container.image names our mirror, not the project it was mirrored from.
@@ -11,7 +11,7 @@ app:
description: Open source home automation platform. Control and monitor your smart home devices. description: Open source home automation platform. Control and monitor your smart home devices.
container: container:
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2 image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.3
pull_policy: if-not-present pull_policy: if-not-present
network: pasta network: pasta
-5
View File
@@ -1,5 +0,0 @@
# Lightning Stack - uses official image
FROM lightninglabs/lightning-stack:v0.12.0
# Default configuration is in the image
# No additional setup needed
-85
View File
@@ -1,85 +0,0 @@
app:
id: lightning-stack
name: Lightning Stack
version: 0.12.0
# No public listing exists for lightninglabs/lightning-stack (checked
# docker.io, ghcr.io and github.com) — nothing can be queried automatically,
# so this one is tracked by hand.
upstream:
kind: manual
url: no public listing for lightninglabs/lightning-stack — verify by hand
description: Complete Lightning Network implementation. Includes LND, CLN, and management tools.
container:
image: lightninglabs/lightning-stack:v0.12.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- app_id: bitcoin-core
version: ">=24.0"
- storage: 50Gi
resources:
cpu_limit: 4
memory_limit: 4Gi
disk_limit: 50Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: lightning-stack
ports:
- host: 9738
container: 9735
protocol: tcp # P2P
auth: none
auth_rationale: >-
Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.
- host: 10010
container: 10009
protocol: tcp # gRPC
auth: none
auth_rationale: >-
LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.
# Mirrors lnd's 18080 exemption — same LND REST API, same macaroon auth.
- host: 8091
container: 8080
protocol: tcp # REST/Web UI
auth: none
auth_rationale: >-
LND REST, authenticated by macaroon over TLS. A browser login page would break
Zeus and every non-browser wallet client, exactly as for lnd's 18080.
volumes:
- type: bind
source: /var/lib/archipelago/lightning-stack
target: /root/.lightning
options: [rw]
environment:
- BITCOIND_HOST=bitcoin-core
- BITCOIND_RPCUSER=${BITCOIN_RPC_USER}
- BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}
- NETWORK=mainnet
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /v1/getinfo
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
lightning_integration:
channel_management: true
payment_routing: true
+2 -2
View File
@@ -1,7 +1,7 @@
app: app:
id: lnd id: lnd
name: LND name: LND
version: 0.18.4 version: 0.21.2
# Where this app comes from, so scripts/check-upstream-releases.py can # Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can: # tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from. # container.image names our mirror, not the project it was mirrored from.
@@ -11,7 +11,7 @@ app:
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments. description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
container: container:
image: source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta image: source.archipelago-foundation.org/lfg2025/lnd:v0.21.2-beta
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
# BITCOIND_HOST must follow the node's actual Bitcoin container — Knots or # BITCOIND_HOST must follow the node's actual Bitcoin container — Knots or
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md
-37
View File
@@ -1,37 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci --only=production
# 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 8080
ENV MORPHOS_DATA_DIR=/app/data
CMD ["node", "dist/index.js"]
-55
View File
@@ -1,55 +0,0 @@
app:
id: morphos-server
name: MorphOS Server
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: MorphOS server platform. Decentralized application server.
container:
image: archipelago/morphos-server:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 5Gi
resources:
cpu_limit: 2
memory_limit: 2Gi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: morphos-server
ports:
- host: 8089
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/morphos-server
target: /app/data
options: [rw]
environment:
- MORPHOS_ENV=production
- MORPHOS_DATA_DIR=/app/data
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
{
"name": "morphos-server",
"version": "1.0.0",
"description": "MorphOS server platform",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}
-27
View File
@@ -1,27 +0,0 @@
import express from 'express';
const app = express();
const port = 8080;
// Middleware
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'morphos-server', version: '1.0.0' });
});
// API endpoints
app.get('/api/info', (req, res) => {
res.json({
name: 'MorphOS Server',
version: '1.0.0',
status: 'running'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`MorphOS Server listening on port ${port}`);
console.log(`Data directory: ${process.env.MORPHOS_DATA_DIR || '/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"]
}
+15 -1
View File
@@ -24,7 +24,14 @@ app:
disk_limit: 1Gi disk_limit: 1Gi
security: security:
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE] # NET_BIND_SERVICE is load-bearing, not decoration: NPM's internal nginx
# listens on 80, 443 AND 81, and the orchestrator runs --cap-drop=ALL —
# without this cap every start dies with "bind() to 0.0.0.0:80 failed
# (13: Permission denied)" and s6 restart-loops forever (shorty-s,
# 2026-09-01, restart counter 3176 within hours of the manifest
# conversion). The legacy podman-run path defaulted to the full cap set,
# which is why it never showed there.
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
readonly_root: false readonly_root: false
no_new_privileges: true no_new_privileges: true
network_policy: isolated network_policy: isolated
@@ -47,6 +54,13 @@ app:
source: /var/lib/archipelago/nginx-proxy-manager source: /var/lib/archipelago/nginx-proxy-manager
target: /data target: /data
options: [rw] options: [rw]
# Current NPM images refuse to start unless /etc/letsencrypt is a mount in
# its own right. Keeping the files below the same persistent app directory
# preserves existing certificates while satisfying that startup contract.
- type: bind
source: /var/lib/archipelago/nginx-proxy-manager/letsencrypt
target: /etc/letsencrypt
options: [rw]
environment: [] environment: []
+2 -1
View File
@@ -20,7 +20,8 @@ app:
- storage: 50Gi - storage: 50Gi
resources: resources:
memory_limit: 0 # No memory limit: models are sized by the disk allowance below, and a
# RAM ceiling would just OOM-kill long inferences.
disk_limit: 50Gi disk_limit: 50Gi
security: security:
+2 -2
View File
@@ -5,7 +5,7 @@ app:
# (--beam-size 1). Bumped past the image version so catalog-driven nodes # (--beam-size 1). Bumped past the image version so catalog-driven nodes
# pick up the args change; the pre-release form "3.4.1-1" would compare # pick up the args change; the pre-release form "3.4.1-1" would compare
# LOWER than 3.4.1 under semver and never roll out. # LOWER than 3.4.1 under semver and never roll out.
version: "3.4.2" version: "3.6.0"
# Tracks the rhasspy/wyoming-whisper image we pin (Docker Hub — the # Tracks the rhasspy/wyoming-whisper image we pin (Docker Hub — the
# project's GitHub tags are not the image tags). NOTE: this manifest # project's GitHub tags are not the image tags). NOTE: this manifest
# deliberately ships an args-tuned revision AHEAD of the image tag (see # deliberately ships an args-tuned revision AHEAD of the image tag (see
@@ -24,7 +24,7 @@ app:
container_name: pine-whisper container_name: pine-whisper
container: container:
image: docker.io/rhasspy/wyoming-whisper:3.4.1 image: docker.io/rhasspy/wyoming-whisper:3.6.0
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
network_aliases: [pine-whisper] network_aliases: [pine-whisper]
+2 -2
View File
@@ -1,7 +1,7 @@
app: app:
id: portainer id: portainer
name: Portainer name: Portainer
version: 2.19.4 version: 2.45.0
# Where this app comes from, so scripts/check-upstream-releases.py can # Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can: # tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from. # container.image names our mirror, not the project it was mirrored from.
@@ -12,7 +12,7 @@ app:
category: development category: development
container: container:
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.6 image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
pull_policy: if-not-present pull_policy: if-not-present
data_uid: "1000:1000" data_uid: "1000:1000"
+2 -2
View File
@@ -1,7 +1,7 @@
app: app:
id: vaultwarden id: vaultwarden
name: Vaultwarden name: Vaultwarden
version: 1.30.0 version: 1.37.2
# Where this app comes from, so scripts/check-upstream-releases.py can # Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can: # tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from. # container.image names our mirror, not the project it was mirrored from.
@@ -11,7 +11,7 @@ app:
description: Self-hosted password vault with zero-knowledge encryption. description: Self-hosted password vault with zero-knowledge encryption.
container: container:
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.2-alpine
pull_policy: if-not-present pull_policy: if-not-present
network: pasta network: pasta
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.8.7-alpha" version = "1.8.11-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.8.7-alpha" version = "1.8.11-alpha"
edition = "2021" edition = "2021"
license.workspace = true license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
@@ -558,6 +558,11 @@ impl RpcHandler {
self.handle_fips_remove_seed_anchor(&p).await self.handle_fips_remove_seed_anchor(&p).await
} }
"fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await, "fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await,
"fips.ssh-over-mesh.get" => self.handle_fips_ssh_over_mesh_get().await,
"fips.ssh-over-mesh.set" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_fips_ssh_over_mesh_set(&p).await
}
// System updates // System updates
"update.check" => self.handle_update_check().await, "update.check" => self.handle_update_check().await,
+47
View File
@@ -261,4 +261,51 @@ impl RpcHandler {
}).collect::<Vec<_>>(), }).collect::<Vec<_>>(),
})) }))
} }
/// The SSH-over-mesh toggle state plus sshd preflights (the card explains
/// the rule instead of gating on it — see ssh_mesh.rs).
pub(super) async fn handle_fips_ssh_over_mesh_get(&self) -> Result<serde_json::Value> {
let state = fips::ssh_mesh::load(&self.config.data_dir).await;
let preflights = fips::ssh_mesh::preflights().await;
Ok(serde_json::json!({
"enabled": state.enabled,
"sources": state.sources,
"scope": if state.sources.is_empty() { "any" } else { "list" },
"preflights": preflights,
}))
}
/// Set the toggle. Params: `{ enabled: bool, sources?: string[] }` —
/// an empty/absent source list opens port 22 to every mesh peer (the UI
/// confirms that explicitly before calling with it).
pub(super) async fn handle_fips_ssh_over_mesh_set(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let enabled = params
.get("enabled")
.and_then(|v| v.as_bool())
.ok_or_else(|| anyhow::anyhow!("missing boolean 'enabled'"))?;
let sources: Vec<String> = params
.get("sources")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let (state, outcome) =
fips::ssh_mesh::set(&self.config.data_dir, enabled, &sources).await?;
let preflights = fips::ssh_mesh::preflights().await;
Ok(serde_json::json!({
"enabled": state.enabled,
"sources": state.sources,
"scope": if state.sources.is_empty() { "any" } else { "list" },
"applied": outcome.applied,
"removed": outcome.removed,
"reloaded": outcome.reloaded,
"preflights": preflights,
}))
}
} }
+152 -49
View File
@@ -4,6 +4,59 @@ use tracing::info;
use super::LND_REST_BASE_URL; use super::LND_REST_BASE_URL;
fn router_error_message(body: &serde_json::Value) -> Option<&str> {
body.get("error")
.and_then(|e| e.get("message"))
.and_then(|v| v.as_str())
.or_else(|| body.get("message").and_then(|v| v.as_str()))
}
fn payment_error(message: &str) -> anyhow::Error {
if message.to_ascii_lowercase().contains("invoice expired") {
anyhow::anyhow!(
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
message.trim_start_matches("invoice expired. ")
)
} else {
anyhow::anyhow!("Payment failed: {message}")
}
}
fn payment_failure_reason(reason: &str) -> &'static str {
match reason {
"FAILURE_REASON_NO_ROUTE" => "No route to the recipient",
"FAILURE_REASON_INSUFFICIENT_BALANCE" => "Insufficient channel balance",
"FAILURE_REASON_TIMEOUT" => "Payment timed out in the network",
"FAILURE_REASON_INCORRECT_PAYMENT_DETAILS" => {
"Recipient rejected the payment (wrong details or expired invoice)"
}
_ => "Payment failed",
}
}
fn json_i64(value: &serde_json::Value, key: &str) -> Option<i64> {
value.get(key).and_then(|v| {
v.as_str()
.and_then(|s| s.parse().ok())
.or_else(|| v.as_i64())
})
}
/// Fee budget for a send, matching lncli's own default: the payment amount
/// (100%). Zero-amount invoices take the payer-supplied amount; fixed invoices
/// take the invoice's own amount. Falls back to a nominal 1,000 sats only when
/// both are somehow absent — the limit must never be left at LND's zero
/// default, which rejects every fee-carrying route as "no route".
fn fee_limit_sats(amount_sats: Option<u64>, decoded_amt: i64) -> i64 {
if let Some(amt) = amount_sats {
return amt as i64;
}
if decoded_amt > 0 {
return decoded_amt;
}
1_000
}
impl RpcHandler { impl RpcHandler {
/// Pay a Lightning invoice. /// Pay a Lightning invoice.
pub(in crate::api::rpc) async fn handle_lnd_payinvoice( pub(in crate::api::rpc) async fn handle_lnd_payinvoice(
@@ -65,23 +118,30 @@ impl RpcHandler {
let mut pay_body = serde_json::json!({ let mut pay_body = serde_json::json!({
"payment_request": payment_request, "payment_request": payment_request,
// Suppress intermediate stream records: one terminal Payment is
// enough, and it makes grpc-gateway's response a single JSON value.
"no_inflight_updates": true,
"timeout_seconds": 120,
// Router.SendPaymentV2 treats an ABSENT fee limit as ZERO — every
// real route carries a routing fee, so the pathfinder rejects
// them all and the wallet gets "No route to the recipient" on
// every send (fleet-wide, 2026-09-01: the v1.8.9 switch to the v2
// route shipped without this, and a manual lncli test that set
// --fee_limit masked it). lncli's own default is the payment
// amount (100%), which is what we send here.
"fee_limit_sat": fee_limit_sats(amount_sats, decoded_amt),
}); });
if let Some(amt) = amount_sats { if let Some(amt) = amount_sats {
pay_body["amt"] = serde_json::json!(amt.to_string()); pay_body["amt"] = serde_json::json!(amt.to_string());
} }
// `/v1/channels/transactions` is SYNCHRONOUS: it blocks until the // LND 0.21 removed the deprecated Lightning.SendPaymentSync REST route
// payment settles or definitively fails, and multi-hop routing with // (`/v1/channels/transactions`). Router.SendPaymentV2 is its supported
// retries routinely takes longer than the shared client's 15s budget. // replacement. The old route now returns literal 404 "Not Found" on
// That 15s abort used to surface as "Payment failed" while LND kept // every payment — the fleet failure seen immediately after the 0.21.2
// paying in the background — only LND may declare a payment failed, // update. Keep the short browser-facing wait: after LND accepts a slow
// so a post-connect timeout is IN FLIGHT (status: pending), never // payment we return pending and the UI follows it through
// failure. The window is deliberately SHORT: most payments settle in // lnd.paymentstatus instead of declaring a transport timeout a failure.
// a couple of seconds and still get their answer in one round trip,
// while a slow multi-hop route flips the UI into its "settling…"
// polling state (lnd.paymentstatus every 3s) after ~8s instead of
// freezing the modal for two minutes with no feedback (a test node
// user report, 2026-07-29).
let pay_client = reqwest::Client::builder() let pay_client = reqwest::Client::builder()
.no_proxy() .no_proxy()
.connect_timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(10))
@@ -91,7 +151,7 @@ impl RpcHandler {
.context("Failed to create HTTP client")?; .context("Failed to create HTTP client")?;
let resp = match pay_client let resp = match pay_client
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions")) .post(format!("{LND_REST_BASE_URL}/v2/router/send"))
.header("Grpc-Metadata-macaroon", &macaroon_hex) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&pay_body) .json(&pay_body)
.send() .send()
@@ -119,49 +179,42 @@ impl RpcHandler {
let body: serde_json::Value = resp let body: serde_json::Value = resp
.json() .json()
.await .await
.context("Failed to parse payment response")?; .context("Failed to parse Router.SendPaymentV2 response")?;
// grpc-gateway wraps server-streaming records as {"result": ...} and
// transport/RPC failures as {"error": {"message": ...}}. Do not look
// only for the old endpoint's top-level `message`: that turns useful
// LND errors into "Unknown error".
if !status.is_success() { if !status.is_success() {
let msg = body let msg = router_error_message(&body).unwrap_or("Unknown error");
.get("message") return Err(payment_error(msg));
.and_then(|v| v.as_str()) }
.unwrap_or("Unknown error"); let payment = body.get("result").unwrap_or(&body);
// Invoices are short-lived; retrying the same one can never match payment.get("status").and_then(|v| v.as_str()).unwrap_or("") {
// succeed, so tell the user the way out instead of just the fact. "SUCCEEDED" => {}
if msg.contains("invoice expired") { "FAILED" => {
return Err(anyhow::anyhow!( let reason = payment
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.", .get("failure_reason")
msg.trim_start_matches("invoice expired. ") .and_then(|v| v.as_str())
)); .map(payment_failure_reason)
.unwrap_or("Payment failed");
return Err(anyhow::anyhow!("Payment failed: {reason}"));
}
_ => {
return Ok(serde_json::json!({
"status": "pending",
"payment_hash": decoded_hash,
"amount_sats": decoded_amt,
}));
} }
return Err(anyhow::anyhow!("Payment failed: {}", msg));
} }
let payment_error = body let amount_sat = json_i64(payment, "value_sat").unwrap_or(decoded_amt);
.get("payment_error")
.and_then(|v| v.as_str())
.unwrap_or("");
if !payment_error.is_empty() {
return Err(anyhow::anyhow!("Payment failed: {}", payment_error));
}
let amount_sat = body
.get("payment_route")
.and_then(|r| r.get("total_amt"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(decoded_amt);
let payment_hash = body
.get("payment_hash")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or(decoded_hash);
Ok(serde_json::json!({ Ok(serde_json::json!({
"status": "succeeded", "status": "succeeded",
"payment_hash": payment_hash, // The decode endpoint returns the canonical hex hash used by our
// polling/list APIs. Router's bytes field is base64 in REST JSON.
"payment_hash": decoded_hash,
"amount_sats": amount_sat, "amount_sats": amount_sat,
})) }))
} }
@@ -482,3 +535,53 @@ impl RpcHandler {
Ok(serde_json::json!({ "transactions": transactions })) Ok(serde_json::json!({ "transactions": transactions }))
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unwraps_grpc_gateway_router_success() {
let body = serde_json::json!({
"result": { "status": "SUCCEEDED", "value_sat": "1000" }
});
let payment = body.get("result").unwrap_or(&body);
assert_eq!(
payment.get("status").and_then(|v| v.as_str()),
Some("SUCCEEDED")
);
assert_eq!(json_i64(payment, "value_sat"), Some(1000));
}
#[test]
fn reads_nested_router_error() {
let body = serde_json::json!({
"error": { "code": 2, "message": "invoice expired. valid until yesterday" }
});
let msg = router_error_message(&body).unwrap();
assert!(payment_error(msg).to_string().contains("fresh invoice"));
}
#[test]
fn router_failure_reasons_are_actionable() {
assert_eq!(
payment_failure_reason("FAILURE_REASON_NO_ROUTE"),
"No route to the recipient"
);
assert_eq!(
payment_failure_reason("FAILURE_REASON_INSUFFICIENT_BALANCE"),
"Insufficient channel balance"
);
}
#[test]
fn fee_limit_never_falls_back_to_zero() {
// SendPaymentV2 defaults an ABSENT fee limit to zero — which rejects
// every fee-carrying route as "no route". The budget must always be
// positive: the payer-supplied amount for zero-amount invoices, the
// invoice's own amount otherwise.
assert_eq!(fee_limit_sats(Some(20_000), 0), 20_000);
assert_eq!(fee_limit_sats(None, 20_000), 20_000);
assert_eq!(fee_limit_sats(None, 0), 1_000);
}
}
+1 -1
View File
@@ -135,7 +135,7 @@ impl RpcHandler {
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk // not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
// *package* name, never an on-disk filename. // *package* name, never an on-disk filename.
let tollgate_installed = router let tollgate_installed = router
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \ .run("opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
test -f /usr/bin/tollgate-wrt 2>/dev/null") test -f /usr/bin/tollgate-wrt 2>/dev/null")
.map(|(_, code)| code == 0) .map(|(_, code)| code == 0)
.unwrap_or(false); .unwrap_or(false);
@@ -55,6 +55,7 @@ impl RpcHandler {
.to_string(); .to_string();
super::validation::validate_app_id(&package_id)?; super::validation::validate_app_id(&package_id)?;
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?; super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
// Reject if already in a transitional lifecycle (prevents double-click // Reject if already in a transitional lifecycle (prevents double-click
// queuing two installs on the same package). // queuing two installs on the same package).
@@ -294,6 +295,12 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))? .ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string(); .to_string();
super::validation::validate_app_id(&package_id)?; super::validation::validate_app_id(&package_id)?;
// Update is stop → pull → remove → recreate, i.e. a fresh start by
// another name: on a disk that shrank since install it would resume
// cuprate's unprunable sync unchecked. Same gate as install and
// start, run BEFORE the Updating flip so a refusal leaves the app
// cleanly in its previous state.
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
// Reject if already in a transitional lifecycle. // Reject if already in a transitional lifecycle.
{ {
@@ -670,6 +670,50 @@ async fn detect_disk_gb() -> u64 {
.unwrap_or(u64::MAX) .unwrap_or(u64::MAX)
} }
/// Smallest disk (GB, total) a cuprate node can live on. The value and its
/// rationale live in ONE place — `crate::constants::CUPRATE_MIN_DISK_GB` —
/// shared with the boot reconciler so install/start and boot can never
/// disagree about where cuprate may run.
use crate::constants::CUPRATE_MIN_DISK_GB;
/// The bitcoin apps pick `-prune` automatically when disk is scarce, because
/// bitcoind supports pruning. Cuprate CANNOT: upstream has no pruning config
/// at all (the `pruning` crate in its workspace is Monero's p2p *protocol*
/// pruning, not on-disk pruning), so the disk-scarce equivalent is to refuse
/// to run cuprate at all rather than let it sync until the filesystem fills —
/// which took Archipelago itself down on nodes with too little disk.
fn cuprate_insufficient_disk_message(disk_gb: u64) -> String {
format!(
"Cuprate needs a disk of at least {} GB and this node has {} GB. \
A Monero node cannot run pruned — upstream cuprate has no pruning \
support — so the chain (~250 GB and growing) would fill the disk and \
take Archipelago down with it. Attach a larger disk (or move \
/var/lib/archipelago to one) and try again. Bitcoin apps CAN run \
pruned on smaller disks; Monero currently cannot.",
CUPRATE_MIN_DISK_GB, disk_gb
)
}
/// Pure decision half of the cuprate disk gate — testable without df.
pub(super) fn cuprate_disk_gate(disk_gb: u64) -> Option<String> {
(disk_gb < CUPRATE_MIN_DISK_GB).then(|| cuprate_insufficient_disk_message(disk_gb))
}
/// Install/start-time pre-check: refuse cuprate on disks too small to hold
/// the Monero chain. Mirrors `check_bitcoin_pruning_compatibility`'s
/// fail-open-on-unknown-disk behaviour (`detect_disk_gb` returns u64::MAX
/// when df fails, so an unreadable disk never blocks an install).
pub(super) async fn check_cuprate_disk_compatibility(package_id: &str) -> Result<()> {
if package_id != "cuprate" {
return Ok(());
}
let disk_gb = detect_disk_gb().await;
if let Some(message) = cuprate_disk_gate(disk_gb) {
anyhow::bail!(message);
}
Ok(())
}
/// Log informational messages about optional dependencies. /// Log informational messages about optional dependencies.
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) { pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd { if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
@@ -873,9 +917,9 @@ pub(super) fn configure_fedimint_lnd(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
bitcoin_is_warming_up, dependency_list_declares_archival_bitcoin, bitcoin_is_warming_up, cuprate_disk_gate, dependency_list_declares_archival_bitcoin,
manifest_declares_archival_bitcoin, order_present_containers, requires_unpruned_bitcoin, manifest_declares_archival_bitcoin, order_present_containers, requires_unpruned_bitcoin,
startup_order, BITCOIN_WARMUP_BUDGET, startup_order, BITCOIN_WARMUP_BUDGET, CUPRATE_MIN_DISK_GB,
}; };
use archipelago_container::Dependency; use archipelago_container::Dependency;
@@ -1017,6 +1061,37 @@ mod tests {
assert!(!manifest_declares_archival_bitcoin("does-not-exist")); assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
} }
#[test]
fn cuprate_disk_gate_refuses_disks_too_small_for_the_monero_chain() {
// 250 GB VPS class: the ~250 GiB chain does not fit, full stop.
assert!(cuprate_disk_gate(0).is_some());
assert!(cuprate_disk_gate(250).is_some());
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB - 1).is_some());
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB).is_none());
assert!(cuprate_disk_gate(1000).is_none());
// df failure reads as u64::MAX — an unreadable disk must not block.
assert!(cuprate_disk_gate(u64::MAX).is_none());
}
#[test]
fn cuprate_disk_gate_message_names_the_fix_not_just_the_problem() {
let msg = cuprate_disk_gate(250).expect("250 GB must be refused");
assert!(msg.contains("cannot run pruned"), "{msg}");
assert!(msg.contains("larger disk"), "{msg}");
assert!(msg.contains("250 GB"), "{msg}");
}
#[tokio::test]
async fn cuprate_disk_gate_only_applies_to_cuprate() {
// Every other package passes regardless of disk — including the
// bitcoin apps, which self-prune via their manifest entrypoint.
for package_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
super::check_cuprate_disk_compatibility(package_id)
.await
.expect("non-cuprate installs must not be gated here");
}
}
mod dep_wait { mod dep_wait {
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps}; use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
@@ -3,10 +3,10 @@ use super::config::{
is_readonly_compatible, is_valid_docker_image, is_readonly_compatible, is_valid_docker_image,
}; };
use super::dependencies::{ use super::dependencies::{
check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers, check_bitcoin_pruning_compatibility, check_cuprate_disk_compatibility, configure_fedimint_lnd,
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info, detect_existing_containers, detect_running_deps, detect_running_deps_from_package_data,
needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL, log_optional_dep_info, needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps,
DEP_WAIT_MAX_ATTEMPTS, DEP_WAIT_INTERVAL, DEP_WAIT_MAX_ATTEMPTS,
}; };
use super::progress::parse_pull_progress; use super::progress::parse_pull_progress;
use super::validation::validate_app_id; use super::validation::validate_app_id;
@@ -306,6 +306,7 @@ impl RpcHandler {
// failing instantly. // failing instantly.
let deps = self.gate_install_deps(package_id).await?; let deps = self.gate_install_deps(package_id).await?;
check_bitcoin_pruning_compatibility(package_id).await?; check_bitcoin_pruning_compatibility(package_id).await?;
check_cuprate_disk_compatibility(package_id).await?;
log_optional_dep_info(package_id, &deps); log_optional_dep_info(package_id, &deps);
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") { if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
// Materialise the RPC password file before any install path // Materialise the RPC password file before any install path
@@ -2040,10 +2041,59 @@ autopilot.active=false\n",
})); }));
} }
// Portainer ≥2.21 no longer lets whoever loads the page first claim the
// admin account: on a fresh install it mints a one-time setup token and
// prints it to the SERVER LOGS, expecting the operator to go digging.
// On an appliance that is hostile UX — "check the Portainer server
// logs" is exactly the dead end users cannot follow. The token is the
// only thing standing between the user and their own app, so surface
// it in the same launch interstitial as the login credentials: extract
// it from the container logs and hand it over with a copy button.
// Once setup completes Portainer invalidates the token, and a container
// recreate (any update) drops the log line entirely — so absence of the
// line naturally makes the card disappear and no stale token lingers.
if app_id == "portainer" {
if let Some(token) = portainer_setup_token(self).await {
return Ok(serde_json::json!({
"title": "Portainer first-run token",
"description": "New Portainer versions protect the first launch with a one-time setup token instead of letting anyone on the network claim the admin account. Paste this token into Portainer's setup screen to create your administrator login. It is only valid until setup finishes — if you already created your admin account, ignore this.",
"credentials": [
{ "label": "Setup token", "value": token, "sensitive": true }
]
}));
}
}
Ok(serde_json::json!({ "credentials": [] })) Ok(serde_json::json!({ "credentials": [] }))
} }
} }
/// Extract Portainer's first-run `setup_token=…` from the live container's
/// recent logs. `None` when the line is absent (setup already done, or an
/// older Portainer without the token flow).
async fn portainer_setup_token(rpc: &RpcHandler) -> Option<String> {
let logs = rpc.get_container_logs_value("portainer", 300).await.ok()?;
let lines = logs.as_array()?;
let lines: Vec<&str> = lines.iter().filter_map(|l| l.as_str()).collect();
parse_setup_token(&lines)
}
/// Pure log-line scan: the token is 64 hex chars after `setup_token=`.
/// Sear newest-first so the most recent mint wins.
fn parse_setup_token(lines: &[&str]) -> Option<String> {
for line in lines.iter().rev() {
let Some(idx) = line.find("setup_token=") else {
continue;
};
let tail = &line[idx + "setup_token=".len()..];
let token: String = tail.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
if token.len() == 64 {
return Some(token);
}
}
None
}
async fn cleanup_stale_package_ports(package_id: &str) { async fn cleanup_stale_package_ports(package_id: &str) {
match package_id { match package_id {
"grafana" => cleanup_stale_pasta_port("3000").await, "grafana" => cleanup_stale_pasta_port("3000").await,
@@ -2751,7 +2801,7 @@ fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
orchestrator_install_app_id, should_try_orchestrator_install, orchestrator_install_app_id, parse_setup_token, should_try_orchestrator_install,
uses_orchestrator_install_flow, uses_orchestrator_install_flow,
}; };
use crate::api::rpc::package::runtime::orchestrator_uninstall_app_ids; use crate::api::rpc::package::runtime::orchestrator_uninstall_app_ids;
@@ -2861,4 +2911,41 @@ mod tests {
"Error: no container with name or ID \"bitcoin-knots\" found" "Error: no container with name or ID \"bitcoin-knots\" found"
)); ));
} }
#[test]
fn portainer_setup_token_is_extracted_from_log_lines() {
// Shape captured live from portainer:2.45.0 on 2026-09-01 — the
// token line is plain text inside the bordered s6 log block.
let logs = [
"2026/09/01 12:38PM INF github.com/portainer/portainer/api/database/boltdb/db.go:163 > loading PortainerDB | filename=portainer.db",
"==========================",
"setup_token=27637c02b6323972dff76bcad4caa456f957b521d3cfe3bc7fb95d2488dfd23a",
"Paste it into the setup screen, or send it in the X-Setup-Token header.",
"==========================",
];
assert_eq!(
parse_setup_token(&logs).as_deref(),
Some("27637c02b6323972dff76bcad4caa456f957b521d3cfe3bc7fb95d2488dfd23a")
);
}
#[test]
fn portainer_setup_token_absent_when_setup_already_done() {
// An instance with an existing admin account never prints the line —
// the credentials card must not render a stale or empty token.
let logs = [
"2026/09/01 11:37AM INF api/datastore/migrator/migrate_ce.go:76 > db migrated to 2.45.0 |",
"2026/09/01 11:37:38 server: Listening on http://0.0.0.0:8000",
];
assert_eq!(parse_setup_token(&logs), None);
}
#[test]
fn portainer_setup_token_rejects_short_or_non_hex_values() {
assert_eq!(parse_setup_token(&["setup_token=abc123"]), None);
assert_eq!(
parse_setup_token(&["setup_token=".to_string().as_str()]),
None
);
}
} }
@@ -60,6 +60,12 @@ impl RpcHandler {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?; .ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?; validate_app_id(package_id)?;
// A cuprate node that starts on a too-small disk fills it and takes
// Archipelago down with it (no upstream pruning — see
// dependencies::check_cuprate_disk_compatibility). Fail the start
// before clearing user-stopped or flipping state, so the app stays
// cleanly stopped and the error carries the actionable message.
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) { let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
vec![orchestrator_app_id(package_id).to_string()] vec![orchestrator_app_id(package_id).to_string()]
@@ -251,6 +257,11 @@ impl RpcHandler {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?; .ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?; validate_app_id(package_id)?;
// Restart is stop + recreate, so on a disk that shrank below the cuprate
// minimum after install it resumes the doomed unprunable sync just like
// start would — same gate, same "fail before clearing user-stopped /
// flipping state" contract (see handle_package_start).
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
let single_orchestrator_app = let single_orchestrator_app =
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id); self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
+16
View File
@@ -9,3 +9,19 @@ pub const DWN_HEALTH_URL: &str = "http://127.0.0.1:3100/health";
/// Tor SOCKS5 proxy for outbound onion connections. /// Tor SOCKS5 proxy for outbound onion connections.
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050"; pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
/// Smallest disk (GB, total) a cuprate node may be installed, started,
/// restarted, updated, or boot-reconciled onto. Cuprate has no on-disk
/// pruning (verified against upstream `cuprated/src/config.rs` — the
/// `pruning` crate is Monero's p2p protocol pruning), so unlike the bitcoin
/// apps it cannot self-shrink on a scarce disk; below this line the ~250 GiB
/// Monero chain simply does not fit and running it would fill the filesystem
/// and take Archipelago down. 450 = chain + growth/headroom: allows
/// 500 GB-class disks, refuses the 250 GB VPS class.
///
/// SINGLE SOURCE OF TRUTH — the RPC gates
/// (`api::rpc::package::dependencies`) and the boot reconciler
/// (`container::prod_orchestrator`) both read this; a drift between them
/// would silently reopen the disk-fill failure the gate exists to close.
/// Keep `apps/cuprate/manifest.yml` (storage dependency + comments) aligned.
pub const CUPRATE_MIN_DISK_GB: u64 = 450;
+43 -5
View File
@@ -10,6 +10,7 @@
//! | lnd | archy-lnd-ui | wallet/channel UI | //! | lnd | archy-lnd-ui | wallet/channel UI |
//! | electrumx | archy-electrs-ui | indexer status UI | //! | electrumx | archy-electrs-ui | indexer status UI |
//! | fedimint | archy-fedimint-ui | wait/proxy Guardian UI | //! | fedimint | archy-fedimint-ui | wait/proxy Guardian UI |
//! | cuprate | archy-cuprate-ui | Monero node status UI |
//! //!
//! Lifecycle: `install` writes a Quadlet `.container` unit to //! Lifecycle: `install` writes a Quadlet `.container` unit to
//! `~/.config/containers/systemd/`, daemon-reloads, then starts the //! `~/.config/containers/systemd/`, daemon-reloads, then starts the
@@ -97,6 +98,7 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
"lnd" => LND_UI, "lnd" => LND_UI,
"electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI, "electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI,
"fedimint" | "fedimintd" => FEDIMINT_UI, "fedimint" | "fedimintd" => FEDIMINT_UI,
"cuprate" => CUPRATE_UI,
_ => &[], _ => &[],
} }
} }
@@ -104,7 +106,8 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
/// Every companion this build knows how to provision. Kept beside /// Every companion this build knows how to provision. Kept beside
/// `companions_for` — a new companion must be added to both, or the reaper /// `companions_for` — a new companion must be added to both, or the reaper
/// will not recognise it as one of ours and will leave it running forever. /// will not recognise it as one of ours and will leave it running forever.
const ALL_COMPANIONS: &[&[CompanionSpec]] = &[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI]; const ALL_COMPANIONS: &[&[CompanionSpec]] =
&[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI, CUPRATE_UI];
const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec { const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec {
name: "archy-bitcoin-ui", name: "archy-bitcoin-ui",
@@ -172,6 +175,24 @@ const FEDIMINT_UI: &[CompanionSpec] = &[CompanionSpec {
host_network: true, host_network: true,
}]; }];
const CUPRATE_UI: &[CompanionSpec] = &[CompanionSpec {
name: "archy-cuprate-ui",
image_base: "cuprate-ui",
build_dir_candidates: &[
"/opt/archipelago/docker/cuprate-ui",
"/home/archipelago/archy/docker/cuprate-ui",
"/home/archipelago/Projects/archy/docker/cuprate-ui",
],
// No pre-start hook and no bind mounts: unlike bitcoin-ui there is no
// secret to inject. Cuprate's restricted RPC (the only thing this UI
// proxies) is unauthenticated by design — Monero's safe-for-public
// subset — so the nginx.conf is baked into the image.
pre_start: None,
bind_mounts: &[],
ports: &[],
host_network: true,
}];
fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> { fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> {
Box::pin(async { Box::pin(async {
let paths = crate::container::bitcoin_ui::RenderPaths::default(); let paths = crate::container::bitcoin_ui::RenderPaths::default();
@@ -869,6 +890,7 @@ mod tests {
"mempool-electrs", "mempool-electrs",
"fedimint", "fedimint",
"fedimintd", "fedimintd",
"cuprate",
]; ];
let known: std::collections::HashSet<&str> = ALL_COMPANIONS let known: std::collections::HashSet<&str> = ALL_COMPANIONS
.iter() .iter()
@@ -893,6 +915,7 @@ mod tests {
names(&orphan_companions(&[])), names(&orphan_companions(&[])),
vec![ vec![
"archy-bitcoin-ui", "archy-bitcoin-ui",
"archy-cuprate-ui",
"archy-electrs-ui", "archy-electrs-ui",
"archy-fedimint-ui", "archy-fedimint-ui",
"archy-lnd-ui" "archy-lnd-ui"
@@ -906,7 +929,10 @@ mod tests {
// electrumx installed, fedimint and lnd not — yet all four companions // electrumx installed, fedimint and lnd not — yet all four companions
// were running because the reconciler was fed the manifest list. // were running because the reconciler was fed the manifest list.
let orphans = orphan_companions(&ids(&["bitcoin-knots", "electrumx"])); let orphans = orphan_companions(&ids(&["bitcoin-knots", "electrumx"]));
assert_eq!(names(&orphans), vec!["archy-fedimint-ui", "archy-lnd-ui"]); assert_eq!(
names(&orphans),
vec!["archy-cuprate-ui", "archy-fedimint-ui", "archy-lnd-ui"]
);
} }
#[test] #[test]
@@ -926,12 +952,18 @@ mod tests {
#[test] #[test]
fn apps_without_companions_orphan_everything_and_panic_nothing() { fn apps_without_companions_orphan_everything_and_panic_nothing() {
let orphans = orphan_companions(&ids(&["nextcloud", "not-a-real-app"])); let orphans = orphan_companions(&ids(&["nextcloud", "not-a-real-app"]));
assert_eq!(orphans.len(), 4); assert_eq!(orphans.len(), 5);
} }
#[test] #[test]
fn every_backend_installed_leaves_no_orphans() { fn every_backend_installed_leaves_no_orphans() {
let orphans = orphan_companions(&ids(&["bitcoin-knots", "lnd", "electrumx", "fedimint"])); let orphans = orphan_companions(&ids(&[
"bitcoin-knots",
"lnd",
"electrumx",
"fedimint",
"cuprate",
]));
assert!( assert!(
names(&orphans).is_empty(), names(&orphans).is_empty(),
"unexpected orphans: {:?}", "unexpected orphans: {:?}",
@@ -970,7 +1002,12 @@ mod tests {
let due = due_after_grace(orphans, &names_seen, &mut since, start + ORPHAN_GRACE); let due = due_after_grace(orphans, &names_seen, &mut since, start + ORPHAN_GRACE);
assert_eq!( assert_eq!(
names(&due), names(&due),
vec!["archy-electrs-ui", "archy-fedimint-ui", "archy-lnd-ui"] vec![
"archy-cuprate-ui",
"archy-electrs-ui",
"archy-fedimint-ui",
"archy-lnd-ui"
]
); );
} }
@@ -1024,6 +1061,7 @@ mod tests {
assert_eq!(companions_for("mempool-electrs").len(), 1); assert_eq!(companions_for("mempool-electrs").len(), 1);
assert_eq!(companions_for("fedimint").len(), 1); assert_eq!(companions_for("fedimint").len(), 1);
assert_eq!(companions_for("fedimintd").len(), 1); assert_eq!(companions_for("fedimintd").len(), 1);
assert_eq!(companions_for("cuprate").len(), 1);
assert_eq!(companions_for("nextcloud").len(), 0); assert_eq!(companions_for("nextcloud").len(), 0);
assert_eq!(companions_for("not-a-real-app").len(), 0); assert_eq!(companions_for("not-a-real-app").len(), 0);
} }
@@ -141,6 +141,12 @@ impl DockerPackageScanner {
// Get metadata for this app // Get metadata for this app
let metadata = get_app_metadata(&app_id); let metadata = get_app_metadata(&app_id);
// Manifest-owned metadata (icon) wins over the static table: the
// manifest is what the catalog signed and what the App Store shows,
// so it is also what an installed tile must render.
let manifest_icon = real_manifest_metadata(&app_id)
.and_then(|m| m.get("icon").and_then(|v| v.as_str()).map(str::to_string))
.filter(|s| !s.trim().is_empty());
// Resolve UI address: separate UI containers > static map > dynamic ports // Resolve UI address: separate UI containers > static map > dynamic ports
let lan_address = if app_id == "netbird" { let lan_address = if app_id == "netbird" {
@@ -191,7 +197,7 @@ impl DockerPackageScanner {
static_files: StaticFiles { static_files: StaticFiles {
license: "MIT".to_string(), license: "MIT".to_string(),
instructions: metadata.description.clone(), instructions: metadata.description.clone(),
icon: metadata.icon.clone(), icon: manifest_icon.unwrap_or_else(|| metadata.icon.clone()),
}, },
manifest: Manifest { manifest: Manifest {
id: app_id.clone(), id: app_id.clone(),
@@ -211,28 +217,34 @@ impl DockerPackageScanner {
author: Some("Archipelago".to_string()), author: Some("Archipelago".to_string()),
website: lan_address.clone(), website: lan_address.clone(),
tier: Some(metadata.tier.to_string()), tier: Some(metadata.tier.to_string()),
interfaces: if lan_address.is_some() || tor_address.is_some() { interfaces: {
// `ui` is no longer implied by a published port: a // `ui` is no longer implied by a published port: a
// headless backend with an exposed port is a service, // headless backend with an exposed port is a service,
// not a launchable app. ui_detection consults the // not a launchable app. ui_detection consults the
// manifest declaration first, then HTTP-probes the // manifest declaration first, then HTTP-probes the
// port. Addresses stay present either way so the // port. A DECLARED UI classifies the app as launchable
// Services tab can still show where a backend lives. // even when no reachable address was confirmed this
// scan — the launch button falls back to the static
// port map, and burying a manifest-declared UI app
// (Alby Hub) in Services because a probe missed was
// exactly the classification bug this fixes.
let has_ui = super::ui_detection::has_web_ui( let has_ui = super::ui_detection::has_web_ui(
&app_id, &app_id,
lan_address.as_deref(), lan_address.as_deref(),
package_state == PackageState::Running, package_state == PackageState::Running,
) )
.await; .await;
Some(Interfaces { if lan_address.is_some() || tor_address.is_some() || has_ui {
main: Some(MainInterface { Some(Interfaces {
ui: has_ui.then(|| "true".to_string()), main: Some(MainInterface {
tor_config: tor_address.clone(), ui: has_ui.then(|| "true".to_string()),
lan_config: None, tor_config: tor_address.clone(),
}), lan_config: None,
}) }),
} else { })
None } else {
None
}
}, },
}, },
available_update, available_update,
@@ -322,6 +334,47 @@ fn is_transient_podman_helper(app_id: &str, ports: &[String]) -> bool {
&& right.chars().all(|c| c.is_ascii_lowercase()) && right.chars().all(|c| c.is_ascii_lowercase())
} }
/// Raw `metadata` block of an installed app's real manifest — catalog overlay
/// first (origin-wins), disk manifest as fallback. Kept as raw JSON because
/// the typed `AppManifest` deliberately does not model `metadata`, yet its
/// `icon` is what makes an installed app's tile render the right icon on
/// every surface (My Apps, Services, launcher, companion) instead of the
/// generic A-mark — the exact regression Cuprate exposed on install.
fn real_manifest_metadata(app_id: &str) -> Option<serde_json::Value> {
for (id, value) in crate::container::app_catalog::catalog_manifest_values() {
if id == app_id {
return value.get("app").and_then(|a| a.get("metadata")).cloned();
}
}
let mut candidates = Vec::new();
if let Ok(dir) = std::env::var("ARCHIPELAGO_DATA_DIR") {
candidates.push(
std::path::PathBuf::from(dir)
.join("../apps")
.join(app_id)
.join("manifest.yml"),
);
}
candidates.push(
std::path::PathBuf::from("/opt/archipelago/apps")
.join(app_id)
.join("manifest.yml"),
);
for path in candidates {
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(value) = serde_yaml::from_str::<serde_json::Value>(&content) else {
continue;
};
let meta = value.get("app").and_then(|a| a.get("metadata")).cloned();
if meta.is_some() {
return meta;
}
}
None
}
fn get_app_metadata(app_id: &str) -> AppMetadata { fn get_app_metadata(app_id: &str) -> AppMetadata {
let mut meta = match app_id { let mut meta = match app_id {
"bitcoin-core" => AppMetadata { "bitcoin-core" => AppMetadata {
@@ -146,6 +146,7 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
"bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"), "bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"),
"lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"), "lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"),
"electrs-ui" | "archy-electrs-ui" => Some("ELECTRS_UI_IMAGE"), "electrs-ui" | "archy-electrs-ui" => Some("ELECTRS_UI_IMAGE"),
"cuprate-ui" | "archy-cuprate-ui" => Some("CUPRATE_UI_IMAGE"),
// Mempool stack (primary = web) // Mempool stack (primary = web)
"mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"), "mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
@@ -163,7 +164,6 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
"vaultwarden" => Some("VAULTWARDEN_IMAGE"), "vaultwarden" => Some("VAULTWARDEN_IMAGE"),
"nextcloud" => Some("NEXTCLOUD_IMAGE"), "nextcloud" => Some("NEXTCLOUD_IMAGE"),
"searxng" => Some("SEARXNG_IMAGE"), "searxng" => Some("SEARXNG_IMAGE"),
"cryptpad" => Some("CRYPTPAD_IMAGE"),
"filebrowser" => Some("FILEBROWSER_IMAGE"), "filebrowser" => Some("FILEBROWSER_IMAGE"),
"nginx-proxy-manager" => Some("NPM_IMAGE"), "nginx-proxy-manager" => Some("NPM_IMAGE"),
"portainer" => Some("PORTAINER_IMAGE"), "portainer" => Some("PORTAINER_IMAGE"),
+217
View File
@@ -131,6 +131,10 @@ const LND_STATE_DIRS: &[&str] = &[
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl. /// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
const LND_CONTAINER: &str = "lnd"; const LND_CONTAINER: &str = "lnd";
/// Canonical on-host admin macaroon — same path the RPC layer reads.
const LND_ADMIN_MACAROON: &str =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
/// Archipelago data dir (default; not overridden in prod). Holds the /// Archipelago data dir (default; not overridden in prod). Holds the
/// `user-stopped.json` that gates health-monitor auto-restart. /// `user-stopped.json` that gates health-monitor auto-restart.
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago"; const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
@@ -872,6 +876,188 @@ fn cert_sha256_thumbprint(pem: &str) -> Result<String> {
Ok(hex::encode_upper(Sha256::digest(&der))) Ok(hex::encode_upper(Sha256::digest(&der)))
} }
// ── Channel-peer watchdog ──────────────────────────────────────────────────
/// Every open channel's remote peer that is NOT currently connected.
/// Pure over LND's REST JSON so the selection can be unit-tested.
///
/// `/v1/peers` uses `pub_key`; `/v1/channels` uses `remote_pubkey` — the
/// asymmetry is LND's, not ours.
fn select_reconnect_targets(
channels: &serde_json::Value,
peers: &serde_json::Value,
) -> Vec<String> {
let connected: std::collections::HashSet<&str> = peers
.get("peers")
.and_then(|p| p.as_array())
.map(|arr| {
arr.iter()
.filter_map(|p| p.get("pub_key").and_then(|v| v.as_str()))
.collect()
})
.unwrap_or_default();
let mut targets: Vec<String> = channels
.get("channels")
.and_then(|c| c.as_array())
.map(|arr| {
arr.iter()
.filter_map(|c| c.get("remote_pubkey").and_then(|v| v.as_str()))
.filter(|pk| !connected.contains(pk))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
targets.sort();
targets.dedup();
targets
}
/// Reconnect peers of open channels that LND has not re-established on its
/// own. Returns the number of peers reconnected this pass.
///
/// LND normally reconnects channel peers after a restart — but not reliably:
/// when the restart outages are long or repeated (an app update, a node
/// reboot, reconciler churn), the peer link can stay down for hours while
/// BOTH endpoints keep flagging the channel `disabled` in the routing
/// graph. The node itself looks perfectly healthy and every payment in
/// either direction fails "no route to the recipient" — observed live on
/// framework-pt (2026-09-01): its only channel sat disabled on both policy
/// sides for ~17h after the LND 0.21.2 update, while the wallet showed
/// plenty of outbound. The channel graph is desired state; this keeps it.
///
/// Quietly returns Ok(0) when LND is not installed or its wallet is locked —
/// that is every node without LND, on every pass.
///
/// `last_attempt` throttles retries per peer (`min_retry`) so an unreachable
/// peer is not hammered every pass; the caller owns the map so the pass
/// itself stays stateless and testable.
pub(crate) async fn reconnect_disconnected_channel_peers(
last_attempt: &mut std::collections::HashMap<String, std::time::Instant>,
min_retry: std::time::Duration,
) -> Result<usize> {
let Ok(macaroon) = read_file_as_root(LND_ADMIN_MACAROON).await else {
return Ok(0); // LND not installed (or not initialized yet)
};
let macaroon_hex = hex::encode(macaroon);
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(8))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client for the channel-peer watchdog")?;
let channels: serde_json::Value = client
.get(format!("{LND_REST_BASE_URL}/v1/channels"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST: listing channels for the peer watchdog")?
.json()
.await
.context("parsing LND channel list")?;
// A locked wallet answers 503 with an error body — it parses as JSON
// with no "channels" key, which selects nothing. That is a quiet pass.
let peers: serde_json::Value = client
.get(format!("{LND_REST_BASE_URL}/v1/peers"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST: listing peers for the peer watchdog")?
.json()
.await
.context("parsing LND peer list")?;
let mut reconnected = 0usize;
for pubkey in select_reconnect_targets(&channels, &peers) {
if last_attempt
.get(&pubkey)
.is_some_and(|t| t.elapsed() < min_retry)
{
continue;
}
last_attempt.insert(pubkey.clone(), std::time::Instant::now());
// Where does the peer live? Its advertised addresses in the public
// graph. A peer with none (fully private) cannot be dialed from here
// — LND itself may still find it; we only log the gap once per pass.
// Unknown to the public graph (or the graph query failed) — nothing
// to dial on.
let Ok(node) = client
.get(format!("{LND_REST_BASE_URL}/v1/graph/node/{pubkey}"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.and_then(|r| r.error_for_status())
else {
continue;
};
let Ok(node) = node.json::<serde_json::Value>().await else {
continue;
};
let addresses: Vec<String> = node
.get("node")
.and_then(|n| n.get("addresses"))
.and_then(|a| a.as_array())
.map(|arr| {
arr.iter()
.filter_map(|a| a.get("addr").and_then(|v| v.as_str()))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
if addresses.is_empty() {
tracing::warn!(
peer = %pubkey,
"LND channel peer is disconnected and advertises no address — cannot dial it; payments through this channel stay unroutable"
);
continue;
}
for addr in addresses {
let Some((host, port)) = addr.rsplit_once(':') else {
continue;
};
let Ok(port) = port.parse::<u32>() else {
continue;
};
let body = serde_json::json!({
"perm": false,
"timeout": "15s",
"addr": { "pubkey": pubkey, "host": host, "port": port },
});
match client
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&body)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
reconnected += 1;
tracing::info!(
peer = %pubkey,
addr = %addr,
"reconnected a disconnected channel peer (channel was unroutable)"
);
break;
}
Ok(resp) => {
let msg = resp.text().await.unwrap_or_default();
// Already connected between our list call and now — success.
if msg.contains("already connected") {
break;
}
tracing::debug!(peer = %pubkey, addr = %addr, %msg, "channel-peer connect attempt failed");
}
Err(e) => {
tracing::debug!(peer = %pubkey, addr = %addr, error = %e, "channel-peer connect attempt failed");
}
}
}
}
Ok(reconnected)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -985,4 +1171,35 @@ mod tests {
let cands = unlock_password_candidates().await; let cands = unlock_password_candidates().await;
assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD)); assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD));
} }
#[test]
fn reconnect_targets_pick_disconnected_channel_peers_only() {
// Shape captured from a live node: /v1/channels uses remote_pubkey,
// /v1/peers uses pub_key, and an offline channel's peer is simply
// absent from the peer list — that absence is the whole signal.
let channels = serde_json::json!({
"channels": [
{ "remote_pubkey": "AAA", "active": true },
{ "remote_pubkey": "BBB", "active": false },
{ "remote_pubkey": "AAA" }
]
});
let peers = serde_json::json!({ "peers": [ { "pub_key": "AAA" } ] });
let targets = select_reconnect_targets(&channels, &peers);
assert_eq!(targets, vec!["BBB".to_string()]);
}
#[test]
fn reconnect_targets_empty_without_channels_or_peers() {
// No LND wallet (503 error body), locked wallet, or an empty node:
// selects nothing, quietly.
let error_body = serde_json::json!({ "message": "locked" });
assert!(select_reconnect_targets(&error_body, &serde_json::json!({})).is_empty());
assert!(select_reconnect_targets(
&serde_json::json!({ "channels": [] }),
&serde_json::json!({ "peers": [] })
)
.is_empty());
}
} }
@@ -47,8 +47,17 @@ use crate::update::host_sudo;
/// ///
/// Keep in sync with the running fixture on .116. Centralized as a constant /// Keep in sync with the running fixture on .116. Centralized as a constant
/// so the rule is visible in one place and unit-testable. /// so the rule is visible in one place and unit-testable.
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"]; const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui", "cuprate-ui"];
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000; const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
// The cuprate disk floor is `crate::constants::CUPRATE_MIN_DISK_GB` — one
// value shared with the install/start/restart/update RPC gates so boot
// reconcile can never resume below the line they refuse at.
use crate::constants::CUPRATE_MIN_DISK_GB;
fn requires_cuprate_disk(app_id: &str, disk_gb: u64) -> bool {
app_id == "cuprate" && disk_gb < CUPRATE_MIN_DISK_GB
}
/// Apps expected to exist from first boot on every node — the ONLY apps the /// Apps expected to exist from first boot on every node — the ONLY apps the
/// boot reconciler may install from nothing. Every other app needs /// boot reconciler may install from nothing. Every other app needs
@@ -1944,6 +1953,23 @@ impl ProdContainerOrchestrator {
crate::crash_recovery::pending_boot_start_done(&container_name); crate::crash_recovery::pending_boot_start_done(&container_name);
continue; continue;
} }
// Same shape as the archival-bitcoin skip above: recorded BEFORE
// ensure_running_with_mode, so the "absent" desired-state recovery
// below can never fire on this reason and undo it.
if mode == ReconcileMode::ExistingOnly && requires_cuprate_disk(&app_id, disk_gb) {
tracing::warn!(
app_id = %app_id,
disk_gb,
"cuprate needs a larger disk (no pruning support) — skipping start"
);
report.record(
&app_id,
ReconcileAction::Left("cuprate-insufficient-disk".into()),
);
crate::crash_recovery::pending_boot_start_done(&app_id);
crate::crash_recovery::pending_boot_start_done(&container_name);
continue;
}
match self.ensure_running_with_mode(&lm, mode).await { match self.ensure_running_with_mode(&lm, mode).await {
// Desired-state recovery: the app has no container and was left // Desired-state recovery: the app has no container and was left
// "absent" by boot reconcile, BUT it was running at the last // "absent" by boot reconcile, BUT it was running at the last
@@ -5312,6 +5338,27 @@ app:
assert_eq!(compute_container_name(&m), "archy-electrs-ui"); assert_eq!(compute_container_name(&m), "archy-electrs-ui");
let m = pull_manifest("lnd-ui", "foo:1"); let m = pull_manifest("lnd-ui", "foo:1");
assert_eq!(compute_container_name(&m), "archy-lnd-ui"); assert_eq!(compute_container_name(&m), "archy-lnd-ui");
let m = pull_manifest("cuprate-ui", "foo:1");
assert_eq!(compute_container_name(&m), "archy-cuprate-ui");
}
#[test]
fn cuprate_disk_gate_blocks_only_cuprate_on_small_disks() {
// 250 GB VPS class: the ~250 GiB Monero chain cannot fit and cuprate
// has no pruning — boot reconcile must leave it down.
assert!(requires_cuprate_disk("cuprate", 250));
assert!(requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB - 1));
assert!(!requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB));
assert!(!requires_cuprate_disk("cuprate", 1000));
// df failure in detect_disk_gb reads as 0 → fail closed at boot: a
// doomed sync is worse than a node that stays down until it can
// measure (same direction as the archival-bitcoin skip).
assert!(requires_cuprate_disk("cuprate", 0));
// Nothing else is gated here: bitcoin apps self-prune, everything
// else is irrelevant to the Monero chain.
for app_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
assert!(!requires_cuprate_disk(app_id, 0), "{app_id}");
}
} }
#[test] #[test]
+3 -3
View File
@@ -6,7 +6,7 @@
//! no listener, so allowing them is inert. //! no listener, so allowing them is inert.
pub const APP_LAUNCH_PORTS: &[u16] = &[ pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088, 2283, 2342, 3000, 3001, 3002, 3030, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087,
8089, 8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
11434, 18081, 18083, 23000, 32838, 50002, 18081, 18083, 18091, 23000, 32838, 50002,
]; ];
+8
View File
@@ -305,6 +305,14 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
} }
} }
// SSH-over-mesh rides every config install so the on-state survives
// upgrades, reconnects, and the startup self-heal (see ssh_mesh.rs —
// this module owns the 90-ssh.nft slot exclusively).
let ssh_data_dir = identity_dir.parent().unwrap_or(identity_dir);
if let Err(e) = super::ssh_mesh::reconcile(ssh_data_dir).await {
tracing::warn!("ssh-over-mesh reconcile after config install failed (non-fatal): {e:#}");
}
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?; sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
// Heal a legacy fips_key.pub that was written as bech32 npub text // Heal a legacy fips_key.pub that was written as bech32 npub text
// (pre-fix identity::write_fips_key_from_seed did this). Upstream // (pre-fix identity::write_fips_key_from_seed did this). Upstream
+1
View File
@@ -32,6 +32,7 @@ pub mod dial;
pub mod endpoints; pub mod endpoints;
pub mod iface; pub mod iface;
pub mod service; pub mod service;
pub mod ssh_mesh;
pub mod telemetry; pub mod telemetry;
pub mod update; pub mod update;
+492
View File
@@ -0,0 +1,492 @@
//! SSH over the FIPS mesh — a first-class settings toggle.
//!
//! `fips0` is default-deny inbound: the hardening baseline (`/etc/fips/
//! fips.nft`) rejects un-allowlisted ports, and the daemon's own drop-ins
//! (`80-web-ui.nft`, `85-app-ports.nft`) do not include 22. That is correct
//! by default — but the user asked to be able to SSH their node from Termux
//! over the phone's FIPS mesh instead of keeping a second VPN around for it,
//! and the mesh path already works end-to-end (verified live: the connect
//! reaches fips0 and gets a RST from the node).
//!
//! This module owns the whole lifecycle of the `90-ssh.nft` drop-in, exactly
//! the way `config.rs` owns `80-web-ui.nft` — a hand-added rule and this
//! feature can never fight over the same slot:
//!
//! * toggle OFF → drop-in removed, port 22 refused again
//! * toggle ON → drop-in written on every toggle change AND on every
//! daemon config install (upgrade, reconnect, self-heal),
//! so the on-state survives reinstalls idempotently
//! * scope → "any" (every mesh peer — a real exposure, gated in the
//! UI behind an explicit confirmation) or an explicit list
//! of mesh addresses
//!
//! Nothing else is touched: `80-web-ui.nft` / `85-app-ports.nft` belong to
//! `config.rs`, and the sshd process itself is entirely the operator's.
use std::net::Ipv6Addr;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
/// On-disk state under the archipelago data dir. Absent file = disabled,
/// which is the safe default for every node that never touched the toggle.
const STATE_FILE: &str = "fips-ssh-over-mesh.json";
/// The drop-in slot this module owns. 90 sorts after the daemon's own
/// drop-ins (80/85) so a human reading the directory sees the deliberate
/// order; the include order does not change semantics for plain accepts.
pub const DROPIN_PATH: &str = "/etc/fips/fips.d/90-ssh.nft";
/// The hardening baseline this drop-in hangs off. Same file `config.rs`
/// reloads after its own drop-ins.
const FIPS_NFT: &str = "/etc/fips/fips.nft";
/// Persisted toggle state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SshMeshState {
/// Whether port 22 is allowed through the fips0 baseline at all.
#[serde(default)]
pub enabled: bool,
/// Mesh addresses (ULAs) the rule is restricted to. Empty = any mesh
/// peer. Kept as strings as-entered but validated as IPv6 on save.
#[serde(default)]
pub sources: Vec<String>,
}
fn state_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(STATE_FILE)
}
/// Load the persisted state. Missing file = disabled, no sources — never an
/// error, so a fresh node and a deleted file both mean "off".
pub async fn load(data_dir: &Path) -> SshMeshState {
match tokio::fs::read_to_string(state_path(data_dir)).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => SshMeshState::default(),
}
}
/// Validate and normalise an operator-supplied source list. Every entry must
/// be a parseable IPv6 address (mesh addresses are full ULAs, not CIDRs) —
/// anything else is refused with the offending entry named, so a typo can
/// never silently narrow or widen the rule.
pub fn validate_sources(raw: &[String]) -> Result<Vec<String>> {
let mut out = Vec::with_capacity(raw.len());
for entry in raw {
let trimmed = entry.trim();
if trimmed.is_empty() {
continue;
}
let addr: Ipv6Addr = trimmed
.parse()
.with_context(|| format!("not a valid mesh (IPv6) address: {trimmed:?}"))?;
out.push(addr.to_string());
}
out.dedup();
Ok(out)
}
/// Render the nft drop-in for a state. The rule shape mirrors the interim
/// manual unblock from the field notes (`ip6 saddr <ula> tcp dport 22
/// accept`) — an unrestricted rule is the same statement without the saddr.
pub fn render_dropin(state: &SshMeshState) -> String {
let mut out = String::from(
"# Written by archipelago — SSH over mesh (Settings → SSH over mesh).\n\
# Allows sshd (port 22) through the fips0 default-deny inbound\n\
# baseline. Remove = refused again; never edit 80/85-* by hand.\n",
);
if state.sources.is_empty() {
out.push_str("tcp dport 22 accept\n");
} else {
out.push_str(&format!(
"ip6 saddr {{ {} }} tcp dport 22 accept\n",
state.sources.join(", ")
));
}
out
}
/// Write or remove the drop-in to match the persisted state, then reload the
/// baseline so the change is live immediately. Returns whether a reload was
/// attempted and succeeded — a node without the hardening baseline has
/// nothing to reload (port 22 is governed by sshd and the host firewall
/// there), which is reported rather than treated as failure.
pub async fn reconcile(data_dir: &Path) -> Result<ReconcileOutcome> {
let state = load(data_dir).await;
if !state.enabled {
let removed = remove_dropin().await?;
let reloaded = reload_nft().await;
return Ok(ReconcileOutcome {
applied: false,
removed,
reloaded,
});
}
// Ensure /etc/fips/fips.d exists, exactly like config::install.
let out = Command::new("sudo")
.args(["install", "-d", "-m", "0755", "/etc/fips/fips.d"])
.output()
.await
.context("sudo install -d /etc/fips/fips.d")?;
if !out.status.success() {
anyhow::bail!(
"sudo install -d /etc/fips/fips.d failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let dropin = render_dropin(&state);
let stage = std::env::temp_dir().join(format!("fips-ssh-{}.nft", std::process::id()));
tokio::fs::write(&stage, &dropin)
.await
.context("stage ssh nft drop-in")?;
let install = Command::new("sudo")
.args(["install", "-m", "0644"])
.arg(&stage)
.arg(DROPIN_PATH)
.output()
.await;
let _ = tokio::fs::remove_file(&stage).await;
let install = install?;
if !install.status.success() {
anyhow::bail!(
"install {} failed: {}",
DROPIN_PATH,
String::from_utf8_lossy(&install.stderr).trim()
);
}
let reloaded = reload_nft().await;
Ok(ReconcileOutcome {
applied: true,
removed: false,
reloaded,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReconcileOutcome {
/// The allow rule is in place.
pub applied: bool,
/// A previously-written drop-in was removed this call.
pub removed: bool,
/// The hardening baseline existed and `nft -f` succeeded.
pub reloaded: bool,
}
async fn remove_dropin() -> Result<bool> {
match tokio::fs::try_exists(DROPIN_PATH).await {
Ok(true) => {}
_ => return Ok(false),
}
let out = Command::new("sudo")
.args(["rm", "-f", DROPIN_PATH])
.output()
.await
.context("sudo rm 90-ssh.nft")?;
if !out.status.success() {
anyhow::bail!(
"removing {} failed: {}",
DROPIN_PATH,
String::from_utf8_lossy(&out.stderr).trim()
);
}
tracing::info!("ssh-over-mesh: drop-in removed — port 22 refused over fips0 again");
Ok(true)
}
/// Reload the hardening baseline. Best-effort in the same spirit as
/// `config.rs`: absent baseline (nothing to reload) → Ok(false); a failed
/// reload is Ok(false) with a warn, never an error — the drop-in is on disk
/// either way and the next daemon install reloads it.
async fn reload_nft() -> bool {
match tokio::fs::try_exists(FIPS_NFT).await {
Ok(true) => {}
_ => return false,
}
match Command::new("sudo")
.args(["nft", "-f", FIPS_NFT])
.output()
.await
{
Ok(out) if out.status.success() => true,
Ok(out) => {
tracing::warn!(
"ssh-over-mesh: nft reload failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
false
}
Err(e) => {
tracing::warn!("ssh-over-mesh: nft reload failed: {e}");
false
}
}
}
/// Persist new state and reconcile immediately. Validation happens here so
/// an invalid source list can never reach disk, and reconcile reads back
/// exactly what was saved.
pub async fn set(
data_dir: &Path,
enabled: bool,
sources: &[String],
) -> Result<(SshMeshState, ReconcileOutcome)> {
let state = SshMeshState {
enabled,
sources: validate_sources(sources)?,
};
tokio::fs::create_dir_all(data_dir)
.await
.with_context(|| format!("mkdir -p {}", data_dir.display()))?;
tokio::fs::write(state_path(data_dir), serde_json::to_string_pretty(&state)?)
.await
.with_context(|| format!("write {}", state_path(data_dir).display()))?;
let outcome = reconcile(data_dir).await?;
Ok((state, outcome))
}
/// Preflights surfaced in the settings card. None of these gate the toggle —
/// they explain it: writing the rule on a node whose sshd doesn't listen on
/// IPv6 simply has no effect until sshd does, and the card says so instead of
/// the user discovering it as a silent connection failure.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SshPreflights {
/// ssh.service (or sshd.service) is active.
pub sshd_active: bool,
/// Something listens on :22 for IPv6 (`[::]:22` or a dual-stack `*:22`).
/// fips0 is IPv6-only, so a 0.0.0.0-bound sshd is unreachable over it.
pub sshd_ipv6_listen: bool,
/// sshd_config's PasswordAuthentication (last directive wins, includes
/// after the main file). None = not found / unreadable.
pub password_auth: Option<bool>,
}
pub async fn preflights() -> SshPreflights {
SshPreflights {
sshd_active: sshd_active().await,
sshd_ipv6_listen: sshd_ipv6_listen().await,
password_auth: password_auth_enabled().await,
}
}
async fn sshd_active() -> bool {
for unit in ["ssh", "sshd"] {
if let Ok(out) = Command::new("systemctl")
.args(["is-active", "--quiet", unit])
.output()
.await
{
if out.status.success() {
return true;
}
}
}
false
}
async fn sshd_ipv6_listen() -> bool {
let Ok(out) = Command::new("ss").args(["-H", "-tln"]).output().await else {
return false;
};
let text = String::from_utf8_lossy(&out.stdout);
text.lines().any(|line| {
let mut cols = line.split_whitespace();
// -t -l: State Recv-Q Send-Q Local:Port Peer:Port → local is col 4.
let _state = cols.next();
let _recv = cols.next();
let _send = cols.next();
match cols.next() {
Some(local) => {
let port_ok = local.rsplit(':').next() == Some("22");
let v6 = local.starts_with("[::]") || local.starts_with('*');
port_ok && v6
}
None => false,
}
})
}
async fn password_auth_enabled() -> Option<bool> {
let mut directives: Vec<bool> = Vec::new();
if let Ok(main) = tokio::fs::read_to_string("/etc/ssh/sshd_config").await {
collect_password_auth(&main, &mut directives);
}
if let Ok(includes) = glob_sorted("/etc/ssh/sshd_config.d/*.conf").await {
for path in includes {
if let Ok(content) = tokio::fs::read_to_string(&path).await {
collect_password_auth(&content, &mut directives);
}
}
}
directives.pop()
}
fn collect_password_auth(content: &str, out: &mut Vec<bool>) {
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("PasswordAuthentication") {
let rest = rest.trim_start();
let value = rest.split_whitespace().next().unwrap_or("");
if value.eq_ignore_ascii_case("yes") {
out.push(true);
} else if value.eq_ignore_ascii_case("no") {
out.push(false);
}
}
}
}
async fn glob_sorted(pattern: &str) -> Result<Vec<std::path::PathBuf>> {
let dir = std::path::Path::new(pattern)
.parent()
.unwrap_or_else(|| Path::new("/"));
let prefix = std::path::Path::new(pattern)
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.split('.').next())
.unwrap_or("")
.to_string();
let mut files: Vec<std::path::PathBuf> = Vec::new();
let mut entries = tokio::fs::read_dir(dir)
.await
.context("read sshd_config.d")?;
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(&prefix) && name.ends_with(".conf") {
files.push(entry.path());
}
}
files.sort();
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_is_the_default_and_missing_file_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let state = tokio::runtime::Runtime::new()
.unwrap()
.block_on(load(dir.path()));
assert!(!state.enabled);
assert!(state.sources.is_empty());
}
#[test]
fn any_peer_dropin_is_an_unrestricted_accept() {
let state = SshMeshState {
enabled: true,
sources: vec![],
};
let out = render_dropin(&state);
assert!(out.contains("tcp dport 22 accept"));
assert!(!out.contains("ip6 saddr"), "no saddr restriction expected");
}
#[test]
fn source_list_dropin_restricts_to_those_addresses() {
let state = SshMeshState {
enabled: true,
sources: vec![
"fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string(),
"fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824".to_string(),
],
};
let out = render_dropin(&state);
assert!(out.contains("ip6 saddr { fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586, fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824 } tcp dport 22 accept"));
}
#[test]
fn sources_must_be_ipv6_and_are_normalised() {
let bad = validate_sources(&["192.168.1.5".to_string()]).unwrap_err();
assert!(bad.to_string().contains("192.168.1.5"));
let bad = validate_sources(&["not-an-address".to_string()]).unwrap_err();
assert!(bad.to_string().contains("not-an-address"));
// Uppercase/whitespace entries normalise to canonical lowercase.
let ok = validate_sources(&[
" FD68:496D:FE34:A06D:0CF1:06E4:B6A4:3586 ".to_string(),
"fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string(),
String::new(),
])
.unwrap();
assert_eq!(
ok,
vec!["fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string()]
);
}
#[test]
fn state_round_trips_through_disk() {
let dir = tempfile::tempdir().unwrap();
let state = SshMeshState {
enabled: true,
sources: vec!["fd00::1".to_string()],
};
std::fs::write(
dir.path().join(STATE_FILE),
serde_json::to_string(&state).unwrap(),
)
.unwrap();
let loaded = tokio::runtime::Runtime::new()
.unwrap()
.block_on(load(dir.path()));
assert_eq!(loaded, state);
}
#[test]
fn set_validates_before_persisting() {
let dir = tempfile::tempdir().unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let err = rt
.block_on(set(dir.path(), true, &["bogus".to_string()]))
.unwrap_err();
assert!(err.to_string().contains("bogus"));
// Nothing was persisted.
let state = rt.block_on(load(dir.path()));
assert!(!state.enabled);
}
#[test]
fn preflight_parse_helpers_cover_the_directives() {
let mut directives = Vec::new();
collect_password_auth(
"# comment\nPasswordAuthentication yes\nMatch all\n PasswordAuthentication no\n",
&mut directives,
);
assert_eq!(directives, vec![true, false]);
}
#[test]
fn sshd_ipv6_listen_recognises_dual_stack_and_v6_only() {
assert!(line_listens("[::]:22"));
assert!(line_listens("*:22"));
assert!(!line_listens("0.0.0.0:22"));
assert!(!line_listens("[::]:80"));
}
fn line_listens(local: &str) -> bool {
let line = format!("LISTEN 0 128 {local} 0.0.0.0:*");
let mut cols = line.split_whitespace();
cols.next();
cols.next();
cols.next();
match cols.next() {
Some(l) => {
let port_ok = l.rsplit(':').next() == Some("22");
let v6 = l.starts_with("[::]") || l.starts_with('*');
port_ok && v6
}
None => false,
}
}
}
+2 -2
View File
@@ -53,8 +53,8 @@ fn container_tier(name: &str) -> StartupTier {
| "indeedhub-api" => StartupTier::DependentService, | "indeedhub-api" => StartupTier::DependentService,
// Tier 4: Frontend/UI // Tier 4: Frontend/UI
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "penpot-frontend" "mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "cuprate-ui"
| "penpot-exporter" | "indeedhub" => StartupTier::Frontend, | "penpot-frontend" | "penpot-exporter" | "indeedhub" => StartupTier::Frontend,
// Tier 3: Application layer (everything else) // Tier 3: Application layer (everything else)
_ => StartupTier::Application, _ => StartupTier::Application,
+31
View File
@@ -841,6 +841,37 @@ impl Server {
}); });
} }
// LND channel-peer watchdog — every 2 minutes, reconnect the peers
// of open channels that LND has not re-established on its own. LND's
// reconnect logic gives up with a long backoff after repeated or
// extended downtime (an app update, a reboot, reconciler churn), and
// while the peer link is down BOTH endpoints keep the channel flagged
// `disabled` in the routing graph — payments fail "no route" in both
// directions while the node itself looks perfectly healthy. The
// channel graph is desired state; this keeps it (framework-pt,
// 2026-09-01: only channel unroutable ~17h after the 0.21.2 update).
// No-ops quietly on nodes without LND. Per-peer retries are throttled
// to 10 minutes so an unreachable peer is not hammered every pass.
{
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(120));
let mut last_attempt: HashMap<String, Instant> = HashMap::new();
loop {
interval.tick().await;
match crate::container::lnd::reconnect_disconnected_channel_peers(
&mut last_attempt,
Duration::from_secs(600),
)
.await
{
Ok(0) => {}
Ok(n) => info!(n, "LND channel-peer watchdog reconnected channel peers"),
Err(e) => debug!("LND channel-peer watchdog (non-fatal): {}", e),
}
}
});
}
// FIPS seed-anchor apply loop — every 5 minutes we re-push the // FIPS seed-anchor apply loop — every 5 minutes we re-push the
// configured seed anchors into the running fips daemon via // configured seed anchors into the running fips daemon via
// `fipsctl connect`. This keeps the mesh bootstrap resilient: // `fipsctl connect`. This keeps the mesh bootstrap resilient:
+19 -15
View File
@@ -15,25 +15,32 @@ pub enum PkgManager {
impl Router { impl Router {
/// Detect which package manager is available. /// Detect which package manager is available.
/// ///
/// - If `/usr/bin/opkg` exists → `PkgManager::Opkg` (nothing to do). /// Looks up `opkg`/`apk` via the router's `$PATH` (`command -v`) rather
/// - If `/usr/bin/apk` exists → run `apk update` (switching repos to HTTP /// than a hardcoded `/usr/bin/<tool>` — official OpenWrt images don't all
/// symlink `/bin` into `/usr/bin` (e.g. the `glinet_gl-mt3000` 24.10.2
/// build keeps them as separate real directories with `opkg` living in
/// `/bin`), so a fixed absolute path silently misses a perfectly normal
/// install and reports "no package management" (archy-x250-pa3, 2026-09-05).
///
/// - If `opkg` is on PATH → `PkgManager::Opkg` (nothing to do).
/// - If `apk` is on PATH → run `apk update` (switching repos to HTTP
/// first to work around missing CA bundle on fresh images), then try /// first to work around missing CA bundle on fresh images), then try
/// `apk add opkg`. If opkg is in the repos → `Opkg`. If not (OpenWrt /// `apk add opkg`. If opkg is in the repos → `Opkg`. If not (OpenWrt
/// 25.x) → `ApkNative`. /// 25.x) → `ApkNative`.
/// - Neither found → error. /// - Neither found → error.
pub fn opkg_check(&self) -> Result<PkgManager> { pub fn opkg_check(&self) -> Result<PkgManager> {
let (_, code) = self.run("test -x /usr/bin/opkg")?; let (_, code) = self.run("command -v opkg >/dev/null 2>&1")?;
if code == 0 { if code == 0 {
return Ok(PkgManager::Opkg); return Ok(PkgManager::Opkg);
} }
let (_, apk_code) = self.run("test -x /usr/bin/apk")?; let (_, apk_code) = self.run("command -v apk >/dev/null 2>&1")?;
if apk_code == 0 { if apk_code == 0 {
info!("[{}] opkg not found — using apk (OpenWrt 25.x+)", self.host); info!("[{}] opkg not found — using apk (OpenWrt 25.x+)", self.host);
// Fresh images ship without a CA bundle; switch repos to HTTP so // Fresh images ship without a CA bundle; switch repos to HTTP so
// apk's wget can reach the package index without TLS verification. // apk's wget can reach the package index without TLS verification.
self.run_ok("sed -i 's|https://|http://|g' /etc/apk/repositories 2>/dev/null || true")?; self.run_ok("sed -i 's|https://|http://|g' /etc/apk/repositories 2>/dev/null || true")?;
let (update_out, update_code) = self.run("/usr/bin/apk update 2>&1")?; let (update_out, update_code) = self.run("apk update 2>&1")?;
if update_code != 0 { if update_code != 0 {
anyhow::bail!( anyhow::bail!(
"apk update failed (exit {}) — router may have no internet access. \ "apk update failed (exit {}) — router may have no internet access. \
@@ -43,7 +50,7 @@ impl Router {
); );
} }
// Try to install opkg (only available on some 25.x builds). // Try to install opkg (only available on some 25.x builds).
let (add_out, add_code) = self.run("/usr/bin/apk add opkg 2>&1")?; let (add_out, add_code) = self.run("apk add opkg 2>&1")?;
if add_code == 0 { if add_code == 0 {
return Ok(PkgManager::Opkg); return Ok(PkgManager::Opkg);
} }
@@ -62,7 +69,7 @@ impl Router {
} }
anyhow::bail!( anyhow::bail!(
"opkg not found at /usr/bin/opkg — this router's firmware may not \ "Neither opkg nor apk found on this router's $PATH — its firmware may not \
support package management (TollGate requires a standard OpenWrt build)" support package management (TollGate requires a standard OpenWrt build)"
); );
} }
@@ -70,31 +77,28 @@ impl Router {
/// `opkg update` — refresh package lists. /// `opkg update` — refresh package lists.
pub fn opkg_update(&self) -> Result<()> { pub fn opkg_update(&self) -> Result<()> {
info!("[{}] opkg update", self.host); info!("[{}] opkg update", self.host);
self.run_ok("/usr/bin/opkg update")?; self.run_ok("opkg update")?;
Ok(()) Ok(())
} }
/// Install a package, skipping if already installed. /// Install a package, skipping if already installed.
pub fn opkg_install(&self, package: &str) -> Result<()> { pub fn opkg_install(&self, package: &str) -> Result<()> {
// Check if already installed to avoid unnecessary network traffic. // Check if already installed to avoid unnecessary network traffic.
let (_, code) = self.run(&format!( let (_, code) = self.run(&format!("opkg list-installed | grep -q '^{} '", package))?;
"/usr/bin/opkg list-installed | grep -q '^{} '",
package
))?;
if code == 0 { if code == 0 {
info!("[{}] {} already installed", self.host, package); info!("[{}] {} already installed", self.host, package);
return Ok(()); return Ok(());
} }
info!("[{}] opkg install {}", self.host, package); info!("[{}] opkg install {}", self.host, package);
self.run_ok(&format!("/usr/bin/opkg install {}", package))?; self.run_ok(&format!("opkg install {}", package))?;
Ok(()) Ok(())
} }
/// Remove a package. /// Remove a package.
pub fn opkg_remove(&self, package: &str) -> Result<()> { pub fn opkg_remove(&self, package: &str) -> Result<()> {
info!("[{}] opkg remove {}", self.host, package); info!("[{}] opkg remove {}", self.host, package);
self.run_ok(&format!("/usr/bin/opkg remove {}", package))?; self.run_ok(&format!("opkg remove {}", package))?;
Ok(()) Ok(())
} }
@@ -121,7 +125,7 @@ impl Router {
} }
info!("[{}] apk add {}", self.host, package); info!("[{}] apk add {}", self.host, package);
self.run_ok(&format!("/usr/bin/apk add {}", package))?; self.run_ok(&format!("apk add {}", package))?;
Ok(()) Ok(())
} }
} }
+82 -14
View File
@@ -6,18 +6,53 @@ use crate::Router;
/// The OpenWrt package name for the TollGate reference implementation. /// The OpenWrt package name for the TollGate reference implementation.
const TOLLGATE_PACKAGE: &str = "tollgate-module-basic-go"; const TOLLGATE_PACKAGE: &str = "tollgate-module-basic-go";
/// Direct-download fallback URLs by opkg architecture string. /// Pinned upstream release. Was stuck on v0.2.0 (Oct 2025) until 2026-09-05 —
/// nine releases behind. v0.5.0's changelog covers exactly the failure modes
/// hit live against archy-x250-pa3: a mint with an empty/broken keyset used
/// to crash-loop the daemon forever ("graceful degradation when Cashu mints
/// fail" in v0.5.0), and the bundled captive-portal build had no CBOR support
/// at all, so it could only decode legacy `cashuA` tokens — rejecting the
/// `cashuB` (NUT-00 V4) tokens modern wallets like Minibits generate by
/// default ("portal improvements" in v0.5.0 include a JS bundle update that
/// should carry a current cashu-ts with V4 support). Bump this string to move
/// both this crate's URLs and the version baked into the source comments.
const TOLLGATE_VERSION: &str = "v0.5.0";
/// Direct-download fallback URLs by opkg architecture string, for the
/// `.ipk` (ar-archive) package format.
/// Used when the package is not in any configured feed. /// Used when the package is not in any configured feed.
/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.2.0 /// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.5.0
fn ipk_url(arch: &str) -> Option<&'static str> { fn ipk_url(arch: &str) -> Option<String> {
match arch { let name = match arch {
"mips_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mips_24kc.ipk"), "mips_24kc" => "mips_24kc",
"mipsel_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mipsel_24kc.ipk"), "mipsel_24kc" => "mipsel_24kc",
"aarch64_cortex-a53" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a53.ipk"), "aarch64_cortex-a53" => "aarch64_cortex-a53",
"aarch64_cortex-a72" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a72.ipk"), "aarch64_cortex-a72" => "aarch64_cortex-a72",
"arm_cortex-a7" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/arm_cortex-a7.ipk"), "arm_cortex-a7" => "arm_cortex-a7",
_ => None, "x86_64" => "x86_64",
} _ => return None,
};
Some(format!(
"https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/{TOLLGATE_VERSION}/tollgate-wrt_{TOLLGATE_VERSION}_{name}.ipk"
))
}
/// Direct-download URLs for the native Alpine-style `.apk` package format —
/// only published for a subset of architectures as of v0.5.0. Where
/// available this is strictly better than [`ipk_url`] on an apk-native
/// (OpenWrt 25.x+) router: `apk add` installs it directly (dependency
/// resolution, postinst, uci-defaults all handled by apk itself), instead of
/// the manual `ar`/`tar` extraction dance `install_ipk` has to do to unpack
/// an `.ipk` on a router with no `opkg`.
fn apk_url(arch: &str) -> Option<String> {
let name = match arch {
"aarch64_cortex-a53" => "aarch64_cortex-a53",
"x86_64" => "x86_64",
_ => return None,
};
Some(format!(
"https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/{TOLLGATE_VERSION}/tollgate-wrt_{TOLLGATE_VERSION}_{name}.apk"
))
} }
/// Install tollgate-module-basic-go via opkg (OpenWrt ≤24.x). /// Install tollgate-module-basic-go via opkg (OpenWrt ≤24.x).
@@ -34,8 +69,9 @@ pub fn install_tollgate(router: &Router) -> Result<()> {
} }
// Package not in any feed — download the .ipk directly. // Package not in any feed — download the .ipk directly.
let arch = router let arch = router.run_ok(
.run_ok("/usr/bin/opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'")?; "opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'",
)?;
let arch = arch.trim(); let arch = arch.trim();
let url = ipk_url(arch).ok_or_else(|| { let url = ipk_url(arch).ok_or_else(|| {
@@ -88,7 +124,7 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
". /etc/openwrt_release 2>/dev/null \ ". /etc/openwrt_release 2>/dev/null \
&& a=\"${DISTRIB_ARCH:-${OPENWRT_ARCH:-}}\" \ && a=\"${DISTRIB_ARCH:-${OPENWRT_ARCH:-}}\" \
&& [ -n \"$a\" ] && echo \"$a\" \ && [ -n \"$a\" ] && echo \"$a\" \
|| /usr/bin/apk --print-arch 2>/dev/null \ || apk --print-arch 2>/dev/null \
|| uname -m", || uname -m",
)?; )?;
// Normalise: uname -m returns bare "mipsel"/"mips"; map to 24kc variant // Normalise: uname -m returns bare "mipsel"/"mips"; map to 24kc variant
@@ -103,6 +139,38 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
anyhow::bail!("Could not determine router architecture"); anyhow::bail!("Could not determine router architecture");
} }
// Prefer a native .apk when the release publishes one for this arch —
// `apk add` handles the install itself (deps, postinst, uci-defaults),
// skipping the manual ar/tar extraction the .ipk fallback below needs.
if let Some(url) = apk_url(arch) {
info!(
"[{}] Downloading native TollGate .apk for {} from GitHub releases",
router.host, arch
);
let (dl_out, dl_code) = router.run(&format!(
"wget --no-check-certificate -O /tmp/tollgate.apk '{}' 2>&1",
url
))?;
if dl_code != 0 {
anyhow::bail!("TollGate .apk download failed: {}", dl_out.trim());
}
let (size_out, _) = router.run("wc -c < /tmp/tollgate.apk 2>/dev/null")?;
let size: u64 = size_out.trim().parse().unwrap_or(0);
if size < 50_000 {
anyhow::bail!(
"Downloaded TollGate .apk is only {}B — wget likely captured an error page. \
Check router internet access and that the release URL is reachable.",
size
);
}
let (add_out, add_code) = router.run("apk add --allow-untrusted /tmp/tollgate.apk 2>&1")?;
router.run_ok("rm -f /tmp/tollgate.apk")?;
if add_code != 0 {
anyhow::bail!("TollGate .apk install failed: {}", add_out.trim());
}
return Ok(());
}
let url = ipk_url(arch).ok_or_else(|| { let url = ipk_url(arch).ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"No pre-built TollGate package for architecture '{}'. \ "No pre-built TollGate package for architecture '{}'. \
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>An error occurred.</h1>
<p>Sorry, the page you are looking for is currently unavailable.<br/>
Please try again later.</p>
<p>If you are the system administrator of this resource then you should check
the error log for details.</p>
<p><em>Faithfully yours, nginx.</em></p>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
# Static site content.
COPY index.html /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/
COPY assets/ /usr/share/nginx/html/assets/
# Unlike bitcoin-ui, the nginx.conf is baked into the image, not
# bind-mounted: there is no secret to render in. Cuprate's restricted RPC
# (the only upstream this UI proxies) is unauthenticated by design —
# Monero's safe-for-public subset — so there is nothing to substitute at
# start time and no rotation to follow.
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 18091
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

+407
View File
@@ -0,0 +1,407 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Cuprate Node - Archipelago</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
min-height: 100vh;
background: #000;
color: white;
overflow-x: hidden;
}
.bg-layer {
position: fixed;
inset: 0;
z-index: -10;
background-image: url('assets/img/bg-network.jpg');
background-size: cover;
background-position: center;
opacity: .42;
}
.glass-card {
position: relative;
background: rgba(0, 0, 0, 0.60);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
border-radius: 1rem;
padding: 1.5rem;
}
.glass-button {
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.9);
border-radius: 0.5rem;
padding: 0.4rem 0.9rem;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.3s ease;
}
.glass-button:hover { color: white; background-color: rgba(0, 0, 0, 0.7); }
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem 3rem; }
/* Match the Bitcoin dashboard's app-header rhythm: identity on the
left, compact live status cards on the right. */
.app-header { display:flex; align-items:center; justify-content:space-between; gap:1.5rem; flex-wrap:wrap; }
.app-header-id { display:flex; align-items:center; gap:1rem; min-width:0; flex:1 1 auto; }
.app-header-actions { display:flex; align-items:center; justify-content:flex-end; gap:.75rem; flex:0 0 auto; flex-wrap:nowrap; margin-left:auto; }
.info-card { display:flex; align-items:center; gap:.75rem; background:rgba(0,0,0,.6); backdrop-filter:blur(24px); border-radius:.75rem; padding:.65rem .85rem; box-shadow:inset 0 1px 0 rgba(255,255,255,.16); min-height:3.25rem; }
.info-card .card-icon { width:1.25rem; height:1.25rem; color:rgba(255,255,255,.6); flex:0 0 auto; }
.info-card-copy { display:flex; flex-direction:column; gap:.15rem; }
.info-card-copy .sub { margin:0; font-size:.6875rem; }
.info-card-copy strong { font-size:.8125rem; font-weight:600; color:rgba(255,255,255,.95); white-space:nowrap; }
.node-mark { width:3.25rem; height:3.25rem; border-radius:.85rem; display:grid; place-items:center; background:#050505; border:1px solid rgba(255,255,255,.18); box-shadow:0 8px 24px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.2); }
.tabbar { display:flex; gap:.35rem; padding:.35rem; margin:1.25rem 0; background:rgba(0,0,0,.62); border:1px solid rgba(255,255,255,.12); border-radius:.85rem; overflow-x:auto; }
.tab-btn { flex:1 0 auto; border:0; border-radius:.6rem; padding:.65rem 1rem; background:transparent; color:rgba(255,255,255,.55); cursor:pointer; font-size:.8rem; font-weight:600; }
.tab-btn:hover { color:#fff; background:rgba(255,255,255,.06); }
.tab-btn.active { color:#fff; background:linear-gradient(135deg,rgba(247,147,26,.3),rgba(255,255,255,.08)); box-shadow:inset 0 1px 0 rgba(255,255,255,.15); }
[data-panel].tab-hidden { display:none; }
@media (max-width:700px) {
.wrap { padding:1rem 1rem calc(6.75rem + env(safe-area-inset-bottom, 0px)); }
.app-header { flex-direction:column; align-items:stretch; gap:1rem; }
.app-header-id { flex-direction:column; justify-content:center; text-align:center; }
.app-header-text { text-align:center; }
.app-header-actions { flex-direction:column; align-items:stretch; justify-content:center; gap:.6rem; margin-left:0; }
.app-header-actions > .info-card { width:100%; }
.tabbar { position:fixed; left:.75rem; right:.75rem; bottom:calc(.5rem + env(safe-area-inset-bottom, 0px)); z-index:40; margin:0; padding:.35rem; border-radius:1rem; box-shadow:0 10px 30px rgba(0,0,0,.65); }
.tab-btn { min-width:4.5rem; padding:.7rem .5rem; font-size:.7rem; }
}
@media (min-width:701px) {
.app-header { flex-wrap:nowrap; }
.app-header-text { min-width:0; }
}
header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; }
header h1 .accent { color: inherit; }
.sub { color: rgba(255, 255, 255, 0.55); font-size: 0.85rem; margin-top: 0.2rem; }
.pill {
display: inline-flex; align-items: center; gap: 0.45rem;
padding: 0.35rem 0.8rem; border-radius: 999px;
font-size: 0.78rem; font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.06);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
.dot.online { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.dot.syncing { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
.dot.offline { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
.pill.online .dot { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.pill.syncing .dot { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
.pill.offline .dot { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; }
.card-title {
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.12em;
text-transform: uppercase; color: rgba(255, 255, 255, 0.5);
margin-bottom: 1rem;
}
.stat { display: flex; justify-content: space-between; align-items: baseline; padding: 0.45rem 0; border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
.stat:last-child { border-bottom: none; }
.stat .label { color: rgba(255, 255, 255, 0.55); font-size: 0.82rem; }
.stat .value { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.95rem; text-align: right; }
.stat .value.warn { color: #f7931a; }
.stat .value.err { color: #ef4444; }
.stat .value.ok { color: #22c55e; }
.height-hero { display: flex; align-items: baseline; gap: 0.6rem; margin-bottom: 0.75rem; }
.height-hero .big { font-size: 2.4rem; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.height-hero .of { color: rgba(255, 255, 255, 0.45); font-size: 1rem; font-variant-numeric: tabular-nums; }
.progress { height: 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); overflow: hidden; margin: 0.5rem 0 0.35rem; }
.progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, #f7931a, #ffc46b); transition: width 0.6s ease; }
.progress-label { font-size: 0.75rem; color: rgba(255, 255, 255, 0.55); font-variant-numeric: tabular-nums; }
.notice {
margin-top: 1rem; padding: 0.9rem 1.1rem; border-radius: 0.75rem;
background: rgba(247, 147, 26, 0.08);
border: 1px solid rgba(247, 147, 26, 0.35);
font-size: 0.82rem; line-height: 1.5; color: rgba(255, 255, 255, 0.8);
}
.notice.error { background: rgba(239, 68, 68, 0.08); border-color: rgba(239, 68, 68, 0.4); }
.notice b { color: white; }
.endpoint {
display: flex; align-items: center; gap: 0.6rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.5rem; padding: 0.55rem 0.75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.82rem; overflow-x: auto; white-space: nowrap;
}
.hint { font-size: 0.75rem; color: rgba(255, 255, 255, 0.45); margin-top: 0.6rem; line-height: 1.5; }
footer { margin-top: 2rem; text-align: center; color: rgba(255, 255, 255, 0.35); font-size: 0.72rem; }
</style>
</head>
<body>
<div class="bg-layer"></div>
<div class="wrap">
<header class="glass-card" style="padding:1.5rem;">
<div class="app-header">
<div class="app-header-id">
<div class="node-mark"><img src="assets/img/app-icons/cuprate.svg" alt="Cuprate" style="width:2.35rem;height:2.35rem;object-fit:contain"></div>
<div class="app-header-text">
<h1><span class="accent">Cuprate</span> Monero Node</h1>
<div class="sub">Rust implementation of the Monero protocol, on Archipelago</div>
</div>
</div>
<div class="app-header-actions">
<div class="info-card">
<div class="relative"><span class="dot" id="headerStatusDot"></span><span class="absolute inset-0 dot animate-ping opacity-50"></span></div>
<div class="info-card-copy"><div class="sub">Status</div><strong id="statusText">Connecting…</strong></div>
</div>
<div class="info-card">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
<div class="info-card-copy"><div class="sub">Network</div><strong id="headerNetwork">—</strong></div>
</div>
<div class="info-card">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h16M4 17h16" /></svg>
<div class="info-card-copy"><div class="sub">Height</div><strong id="headerHeight">—</strong></div>
</div>
</div>
</div>
</header>
<div id="offlineNotice" class="notice error" style="display:none;">
<b>Cuprate is not reachable.</b> The node may be stopped, still installing, or syncing.
Check the service status and try again shortly.
</div>
<nav class="tabbar" role="tablist" aria-label="Cuprate dashboard sections">
<button class="tab-btn active" data-tab="node" role="tab">Node</button>
<button class="tab-btn" data-tab="insights" role="tab">Insights</button>
<button class="tab-btn" data-tab="peers" role="tab">Peers</button>
<button class="tab-btn" data-tab="connect" role="tab">Connect</button>
</nav>
<div class="grid" style="margin-top:1rem;">
<div class="glass-card" data-panel="node">
<div class="card-title">Blockchain Sync</div>
<div class="height-hero">
<span class="big" id="height">—</span>
<span class="of" id="targetOf">of —</span>
</div>
<div class="progress"><div class="progress-fill" id="syncBar"></div></div>
<div class="progress-label" id="syncLabel">Waiting for node…</div>
<div class="stat"><span class="label">Network</span><span class="value" id="nettype">—</span></div>
<div class="stat"><span class="label">Uptime</span><span class="value" id="uptime">—</span></div>
<div class="stat"><span class="label">Node time</span><span class="value" id="nodeTime">—</span></div>
</div>
<div class="glass-card tab-hidden" data-panel="peers">
<div class="card-title">Peers &amp; Traffic</div>
<div class="stat"><span class="label">Outgoing connections</span><span class="value" id="outConns">—</span></div>
<div class="stat"><span class="label">Incoming connections</span><span class="value" id="inConns">—</span></div>
<div class="stat"><span class="label">RPC connections</span><span class="value" id="rpcConns">—</span></div>
<div class="stat"><span class="label">Known peers (white)</span><span class="value" id="whitePeers">—</span></div>
<div class="stat"><span class="label">Known peers (gray)</span><span class="value" id="grayPeers">—</span></div>
<div class="stat"><span class="label">Mempool transactions</span><span class="value" id="txPool">—</span></div>
<div class="stat"><span class="label">Alt blocks</span><span class="value" id="altBlocks">—</span></div>
</div>
<div class="glass-card tab-hidden" data-panel="insights">
<div class="card-title">Chain &amp; Disk</div>
<div class="stat"><span class="label">Difficulty</span><span class="value" id="difficulty">—</span></div>
<div class="stat"><span class="label">Chain size</span><span class="value" id="chainSize">—</span></div>
<div class="stat"><span class="label">Free disk</span><span class="value" id="freeSpace">—</span></div>
</div>
<div class="glass-card tab-hidden" data-panel="connect">
<div class="card-title">Connect a Wallet</div>
<div class="endpoint">
<span id="walletEndpoint">—</span>
<button class="glass-button" onclick="copyEndpoint(this)">Copy</button>
</div>
<div class="hint">
Restricted RPC — Monero's own safe-for-public subset, what Feather,
monero-wallet-rpc and the GUI use for a “remote node”. Full (unrestricted) RPC
stays container-loopback only and is never published.
</div>
<div class="stat" style="margin-top:0.9rem;"><span class="label">P2P port</span><span class="value" id="p2pPort">18183</span></div>
<div class="stat"><span class="label">Restricted RPC port</span><span class="value">18090</span></div>
</div>
</div>
<footer>
Cuprate is work-in-progress software; it independently validates Monero consensus rules.
Data served from this node's restricted RPC, refreshed every 15 seconds.
</footer>
</div>
<script>
const RPC = 'cuprate-rpc/';
const POLL_MS = 15000;
function tabular(n) { return Number(n || 0).toLocaleString('en-US'); }
function formatBytes(bytes) {
const n = Number(bytes);
if (!Number.isFinite(n) || n <= 0) return '—';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let v = n, i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
return `${v >= 100 || i === 0 ? tabular(Math.round(v)) : v.toFixed(1)} ${units[i]}`;
}
function formatUptime(secs) {
const s = Number(secs);
if (!Number.isFinite(s) || s < 0) return '—';
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function setStat(id, value, cls) {
const el = document.getElementById(id);
el.textContent = (value === null || value === undefined || value === '') ? '—' : value;
el.className = 'value' + (cls ? ' ' + cls : '');
}
function setStatus(kind, text) {
document.getElementById('statusText').textContent = text;
const dot = document.getElementById('headerStatusDot');
dot.className = 'dot ' + kind;
document.getElementById('offlineNotice').style.display = (kind === 'offline') ? '' : 'none';
}
async function callRpc(endpoint) {
const response = await fetch(RPC + endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
cache: 'no-store',
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data && data.error) throw new Error(data.error);
return data;
}
function render(info, heightFallback) {
const height = info.height ?? heightFallback ?? 0;
// Monero's get_info returns target_height == 0 when the node is
// FULLY SYNCED — the field is the height being caught up to, not
// the chain tip, so `??` cannot substitute for the 0 case (a
// synced node would sit forever at "Syncing — 0.00%"). Treat
// 0/absent as "target is our own height" — the same sentinel
// core/archipelago/src/electrs_status.rs branches on.
const rawTarget = info.target_height ?? info.target ?? 0;
const target = rawTarget > 0 ? rawTarget : height;
document.getElementById('height').textContent = tabular(height);
document.getElementById('targetOf').textContent = `of ${tabular(target)}`;
const pct = target > 0 ? Math.min(100, (height / target) * 100) : 0;
document.getElementById('syncBar').style.width = pct.toFixed(2) + '%';
const synced = target > 0 && height >= target;
if (synced) {
document.getElementById('syncLabel').textContent = 'Fully synced';
setStatus('online', 'Synced');
} else {
const behind = Math.max(0, target - height);
document.getElementById('syncLabel').textContent =
`${pct.toFixed(2)}% — ${tabular(behind)} blocks behind`;
setStatus('syncing', 'Syncing');
}
const nettype = info.mainnet ? 'Mainnet'
: info.testnet ? 'Testnet'
: info.stagenet ? 'Stagenet'
: (info.net || info.nettype || '—');
setStat('nettype', nettype);
document.getElementById('headerNetwork').textContent = nettype;
document.getElementById('headerHeight').textContent = tabular(height);
const nowSecs = info.time ?? info.adjusted_time ?? Math.floor(Date.now() / 1000);
const started = info.start_time ?? info.startup_time;
setStat('uptime', started ? formatUptime(nowSecs - started) : '—');
setStat('nodeTime', new Date(nowSecs * 1000).toLocaleString());
setStat('outConns', tabular(info.outgoing_connections_count));
setStat('inConns', tabular(info.incoming_connections_count));
setStat('rpcConns', tabular(info.rpc_connections_count));
setStat('whitePeers', tabular(info.white_peerlist_size));
setStat('grayPeers', tabular(info.grey_peerlist_size));
setStat('txPool', tabular(info.tx_pool_size));
const alt = Number(info.alt_blocks_count || 0);
setStat('altBlocks', tabular(alt), alt > 0 ? 'warn' : undefined);
setStat('difficulty', tabular(info.difficulty));
setStat('chainSize', formatBytes(info.blocks_size ?? info.block_sizes?.[0]));
const free = info.free_space;
const freeWarn = Number.isFinite(free) && free > 0 && free < 50 * 1024 * 1024 * 1024;
setStat('freeSpace', formatBytes(free), freeWarn ? 'warn' : undefined);
if (!document.getElementById('walletEndpoint').textContent.includes(':')) {
document.getElementById('walletEndpoint').textContent =
`${window.location.hostname}:18090`;
}
}
async function refresh() {
let height = null;
try {
const h = await callRpc('get_height');
height = h.height;
} catch { /* get_info below carries the real error */ }
try {
const info = await callRpc('get_info');
render(info, height);
} catch {
setStatus('offline', 'Node offline');
if (height !== null) document.getElementById('height').textContent = tabular(height);
}
}
function copyEndpoint(btn) {
const text = document.getElementById('walletEndpoint').textContent;
const done = () => { btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = 'Copy'; }, 1500); };
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done).catch(() => { done(); });
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch { /* best effort */ }
document.body.removeChild(ta);
done();
}
}
document.querySelectorAll('.tab-btn').forEach((button) => {
button.addEventListener('click', () => {
const tab = button.dataset.tab;
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.toggle('active', b === button));
document.querySelectorAll('[data-panel]').forEach((panel) => panel.classList.toggle('tab-hidden', panel.dataset.panel !== tab));
});
});
// Height alone answers even while get_info is warming up; if both
// fail the offline card explains the disk gate as a likely cause.
refresh();
setInterval(refresh, POLL_MS);
</script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
server {
# Loopback ONLY — same rule as docker/bitcoin-ui and docker/electrs-ui.
# This container is host-networked, so nginx binds the HOST's address
# directly; a bare `listen` would expose the page on LAN, Tailscale and
# the mesh with the app gate nowhere in front of it. Binding loopback lets
# the daemon claim the external addresses and authenticate them;
# see appgate::listener and apps/cuprate-ui/manifest.yml (auth: gated).
listen 127.0.0.1:18091;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Session gate for the RPC proxy below. Internal: reachable only by
# nginx's own auth_request subrequest, never by a client.
location = /_session_check {
internal;
proxy_pass http://127.0.0.1:5678/auth/session-check;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-CSRF-Token $http_x_csrf_token;
}
# Cuprate's restricted RPC (host-published on 127.0.0.1:18090, auth: open
# — Monero's own safe-for-public subset, what remote-node wallets use).
# It injects no credentials the caller lacks, but it is still session-
# gated here so the whole companion behaves as one authenticated surface
# (same defence-in-depth bitcoin-ui applies to its credential-injecting
# proxy: loopback reaches it without the gate's challenge).
location /cuprate-rpc/ {
# Preflight carries no cookies by design — answer it before the gate,
# otherwise the browser reports an opaque CORS failure instead of a 401.
if ($request_method = OPTIONS) { return 204; }
auth_request /_session_check;
proxy_pass http://127.0.0.1:18090/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header Access-Control-Allow-Origin $scheme://$http_host always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary "Origin" always;
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
}
# no-cache (revalidate), not no-store — same reasoning as docker/bitcoin-ui:
# a rebuilt companion image must actually be seen by the browser, while the
# ETag still saves the transfer when nothing changed.
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
}
+1
View File
@@ -10,6 +10,7 @@ disagree, the code wins and the doc is a bug.
- [Talking to your node](COMMANDS.md) — the conversational command surface - [Talking to your node](COMMANDS.md) — the conversational command surface
- [Seed Verification](SEED-VERIFICATION.md) — independently verify your 24-word backup - [Seed Verification](SEED-VERIFICATION.md) — independently verify your 24-word backup
- [Troubleshooting](troubleshooting.md) — common problems and how to resolve them - [Troubleshooting](troubleshooting.md) — common problems and how to resolve them
- [OpenWrt Gateway Setup](openwrt-gateway-setup.md) — pairing an OpenWrt router and provisioning TollGate pay-as-you-go WiFi
- [Gamepad / Controller Navigation](GAMEPAD-NAV.md) — driving the UI from a controller - [Gamepad / Controller Navigation](GAMEPAD-NAV.md) — driving the UI from a controller
- [Pine voice commands](pine-voice-commands.md) — the voice-satellite phrase surface - [Pine voice commands](pine-voice-commands.md) — the voice-satellite phrase surface
+108
View File
@@ -0,0 +1,108 @@
# Incident + follow-up tracker — 2026-09-01 (post-HTTPS-work, post-LND-0.21.2 breakage)
Live incident spanning framework-pt and shorty-s after the HTTPS/launcher
work and the LND 0.18.4→0.21.2 pin bump. Root causes found on real nodes;
status updated as work lands. Each fix ships with a regression test so the
same class cannot silently return.
## A. Root causes (all verified live)
| # | Symptom | Root cause |
|---|---------|-----------|
| A1 | LND sends fail "Payment failed: Not Found" | LND 0.21 **removed** the deprecated `/v1/channels/transactions` REST route; backend still called it. Receive was fine; the "Failed to fetch" on framework-pt was A3 masking it. |
| A2 | Shorty NPM restart-loops (counter 3176) | Manifest conversion (fc68c5b6) dropped (a) the `/etc/letsencrypt` mount NPM's s6 boot demands, and (b) `NET_BIND_SERVICE` — its internal nginx binds 80/443/81 and the orchestrator runs `--cap-drop=ALL`. |
| A3 | framework-pt: every `/rpc/v1` fetch CORS-blocked, "Failed to fetch", dashboard "not responding", mempool/indeehub frames broken | nginx sent `Strict-Transport-Security: max-age=31536000; includeSubDomains` on **HTTPS**; browsers cached it, then silently upgraded the still-open **http** dashboard's fetches/frames to https → scheme change = cross-origin → CORS block. HTTP is a supported mode on purpose (self-signed cert, /ca.crt flow). |
| A4 | Mempool/IndeeHub/bitcoin-UI frames stay `http://` on HTTPS pages (mixed content, "does not connect") | `portAuth()` looked the launch port up under the launch alias (`mempool-web`, `lnd`, `bitcoin-knots`…); the signed catalog declares those ports under the manifest id that owns them (`archy-mempool-web`, `lnd-ui`, `bitcoin-ui`) → miss → launcher fell back to http. Cache also only warmed in Store/Discover views. |
| A5 | IndeeHub nostr sign-in dead over HTTPS | NIP-07 bridge compared `event.origin` for strict equality with the stored (http) app URL and replied to the **stored** URL as postMessage targetOrigin — both break when the frame was scheme-upgraded. |
| A6 | Portainer "disappeared" after restart/update, then demands a setup token "see server logs" | Update to 2.45.0 recreated the container; on a fresh DB Portainer ≥2.21 mints a one-time setup token printed ONLY in container logs — hostile appliance UX. The "disappearance" was the recreate + this unknown-token first screen. |
## B. Fixes (code)
| Fix | Files | Status |
|-----|-------|--------|
| B1 LND pay via `Router.SendPaymentV2` (`/v2/router/send`), pending-status + actionable failure reasons preserved | `core/archipelago/src/api/rpc/lnd/payments.rs` (+ unit tests) | ✅ code |
| B2 Portainer setup token surfaced in the existing credentials interstitial (`package.credentials` → AppSidebar card with copy) | `core/archipelago/src/api/rpc/package/install.rs` (+ unit tests) | ✅ code |
| B3 HSTS: none on :80, `max-age=0` on :443 (actively clears cached policy) | `image-recipe/configs/nginx-archipelago.conf` | ✅ code |
| B4 NPM manifest: `/etc/letsencrypt` mount + `NET_BIND_SERVICE` | `apps/nginx-proxy-manager/manifest.yml` | ✅ code |
| B5 `portAuth` alias resolution + unanimous port-wide fallback | `neode-ui/src/views/discover/curatedApps.ts` | ✅ code |
| B6 Catalog cache warmed at dashboard bootstrap | `neode-ui/src/App.vue` | ✅ |
| B7 NIP-07 bridge: host/port equality + reply to `event.origin` | `neode-ui/src/stores/appLauncher.ts` ✅ · `neode-ui/src/views/appSession/useNostrBridge.ts` ✅ | ✅ |
| B8 Stale LND 0.18.4 refs in test expectations | `tests/lifecycle/remote-lifecycle.sh` | ✅ |
## C. Regression tests ("never again")
| Test | Guards | Status |
|------|-------|--------|
| C1 Rust: router v2 response shape, nested errors, failure reasons | B1 | ✅ |
| C2 Rust: setup-token log extraction (live-captured 2.45.0 line shape) | B2 | ✅ |
| C3 bats: `lnd-api-compat` — POST `/v2/router/send` on the running LND must answer (never 404) | B1 vs image skew at gate time | ✅ (route probe verified live on shorty: HTTP 500 ≠ 404) |
| C4 bats: nginx must NOT send HSTS on :80; :443 must send `max-age=0` | B3 | ✅ |
| C5 neode-ui unit: portAuth alias + unanimous-scan (incl. bitcoin-knots→8334 https) | B5/B4-mixed-content | ✅ (6 tests) |
| C6 neode-ui unit: bridge origin equality ignores scheme | B7 | ✅ (2 tests) |
Backend suites: 34 targeted Rust tests green (payments v2 shape, setup-token
extraction, lnd wallet/info regressions); middleware/dispatcher suite green;
full neode-ui suite green (62 tests in the touched areas); production bundle
built and verified to embed the alias fix. `cargo fmt` applied.
## D. Deploy & live verification
| Step | Status |
|------|--------|
| D1 shorty NPM crash-loop stopped cleanly (user-stopped marker; public hosts keep serving via host nginx mirror) | ✅ 12:52Z |
| D2 shorty live nginx HSTS patch + reload | ✅ verified: :80 and :443 both answer `max-age=0` |
| D3 Regenerate catalog (releases/app-catalog.json + store copies) | ✅ semantic diff = exactly the two NPM fixes |
| D4 **User runs `scripts/sign-catalog.sh`** (signer built at /tmp/archy-sign-bin) | ✅ catalog signed + committed + pushed |
| D5 Commit + push (origin + gitea-vps2 OTA mirror) | ✅ 9 commits pushed |
| D6 Release v1.8.9-alpha: `scripts/create-release.sh 1.8.9-alpha` (mnemonic) → `scripts/publish-release-assets.sh 1.8.9-alpha gitea-vps2` | ✅ PUBLISHED (tag v1.8.9-alpha, releases/manifest.json live, backend+frontend assets verified by the script) |
| D7 OTA on shorty-s + framework-pt (Update button; shorty is on 1.8.8-alpha, daily check — hit Update now) | ⬜ user action |
| D8 shorty: clear the NPM user-stopped marker + Start (or it starts via the fixed catalog) | ✅ NPM LIVE-HEALED via the signed catalog: unit regenerated with both fixes, container up, admin UI HTTP 200 on :8081 (verified 15:42Z) |
| D9 framework-pt: Start Mempool — its containers are confirmed stopped (port 4080 refuses; gate answers on 7778/8334/50002/18083 so those apps will embed over https immediately) | ⬜ |
| D10 Post-deploy live checks: LND send+receive; mempool/IndeeHub/bitcoin-UI frames over https; NPM healthy + admin :8081 ✅; portainer token card on fresh DB; zero CORS errors | ⬜ after nodes update |
## E. Follow-ups discovered during the incident (ride the NEXT release, v1.8.10+)
- **LND channel-peer watchdog** (this release's headline platform fix): every
2 minutes the daemon reconnects peers of open channels that LND has not
re-established on its own (per-peer retry throttled to 10 minutes), using
the peer's advertised addresses from the public graph. Kills the whole
class this incident exposed — a channel unroutable ~17h after an LND update
while both nodes looked healthy. Unit tests pin the selection logic over the
live REST shapes.
- **Funding-modal honesty fix** (1464b1b2): the
Lightning "no channel" modal now states the node's real state — pending
channel confirming / balance on the far side / payment couldn't route /
genuinely no channels. Note the stale-direction defect it fixes: the
payment-failure mapper never set the direction, so a SEND failure showed
the RECEIVE-branch copy ("Receiving needs inbound liquidity…") — the exact
modal users saw while their node had a healthy 583k-outbound channel.
Both fixes have their v1.8.10 CHANGELOG + What's New entries staged so the
next `create-release.sh 1.8.10-alpha` runs clean first time.
- Nodes poll for OTA updates on `daily_check` — after publishing, tell the
user to hit Update rather than wait for the next check.
- `origin` remote had a stale pushurl with a dead token (pushes failed);
fixed to the canonical repo URL, stale `~/.git-credentials` entry with an
encoded port removed.
## F. Post-v1.8.9 verification on shorty-s (2026-09-01 evening)
- v1.8.9 applied; payment pipeline confirmed live: a 400,000 sat payment
SUCCEEDED through the v2 router route; the 404s are gone.
- App gate serves TLS on 4080/8334/18083/50002 (401 gate pages over https) —
https app frames now answer. Mempool over https requires a hard refresh
(PWA precaches the old bundle).
- **"No route to the recipient" on sends is real**: the invoices being tested
are from framework-pt, whose only channel (peer "Sandwich Farm",
0224c955…) is flagged `disabled` on BOTH policy sides in the routing graph
after today's node churn — the peer connection never re-established
(LND's reconnect backoff can stretch to hours). A disabled edge is
unroutable in both directions, so payments to/from framework-pt fail
regardless of shorty's 583k outbound. Fix: `lncli connect` the peer, wait
for the channel_update to re-enable the edge (~minutes), then re-test.
- The 577k attempt earlier failed for a different, correct reason: it exceeded
the channel's spendable balance (583,542 − 9,850 reserve ≈ 573k max).
framework-pt immediate workaround until its OTA lands: open the dashboard by
IP (`http://192.168.x.x`) instead of `framework-pt.local`, and/or clear the
cached policy once via `chrome://net-internals/#hsts` → Delete domain security
policies → `framework-pt.local`.
+299
View File
@@ -0,0 +1,299 @@
# OpenWrt Gateway Setup
How to connect an OpenWrt router to an Archipelago node and, optionally, turn
it into a pay-as-you-go WiFi gateway with **TollGate**. Written for a node
operator following the UI; a developer-facing RPC/architecture reference is
at the bottom.
This feature manages a **separate physical (or virtual) router** running
OpenWrt over SSH/UCI — it is not a containerized app. Archipelago itself does
not flash or install OpenWrt; you bring a router that already runs it.
## What you get
- **Status dashboard**: hostname, uptime, firmware release, WiFi interfaces,
WAN state — polled live from the router.
- **WAN/WISP wizard**: point the router's radio at an upstream WiFi network
(turns it into a wireless bridge/repeater) with DHCP + NAT configured for
you.
- **TollGate provisioning** (optional): installs the
[TollGate](https://tollgate.me) captive-portal package
(`tollgate-module-basic-go`) and stands up an `archipelago` SSID that
sells timed internet access for sats, settled against this node's local
Cashu mint.
## Prerequisites
1. **A router already flashed with OpenWrt.** Check the
[OpenWrt Table of Hardware](https://openwrt.org/toh/start) for your model
and follow OpenWrt's own install/flashing instructions — that part is
outside Archipelago's scope. See below for a worked example (GL.iNet
AX3000).
2. **SSH reachable.** Fresh OpenWrt images enable `dropbear` (SSH) on LAN by
default, listening as `root` with no password (or the password you set
during OpenWrt's first-boot wizard at `192.168.1.1`). Archipelago
connects with `ssh2` over a password (key-based auth is supported at the
library level but the UI only offers password so far).
3. **Same LAN as the Archipelago node**, at least for setup — plug the
router's LAN port into the same switch/network segment the node is on.
4. **For TollGate**: a running Cashu mint app (`nutshell`/`cashu-mint`) on
this node — provisioning defaults `mint_url` to
`http://<node-ip>:3338` and TollGate customers must be able to reach that
URL from outside the node's loopback.
## Worked example: flashing a GL.iNet AX3000 to stock OpenWrt
GL.iNet's "AX3000" travel router is the **Beryl AX (GL-MT3000)** —
MediaTek MT7981B (Cortex-A53), OpenWrt target `mediatek/filogic`. It ships
running a GL.iNet fork of OpenWrt with its own web UI and LuCI already
enabled, but the steps below replace that with stock/vanilla OpenWrt so it
matches the prebuilt TollGate `.ipk` architectures exactly
(`aarch64_cortex-a53`).
1. **Download the sysupgrade image** for the current stable release from
`https://downloads.openwrt.org/releases/<version>/targets/mediatek/filogic/`
— the file you want is
`openwrt-<version>-mediatek-filogic-glinet_gl-mt3000-squashfs-sysupgrade.bin`.
2. **Verify the checksum** against the `sha256sums` file in that same
directory before flashing anything.
3. **Flash from the GL.iNet UI**: on the router's default address
(`192.168.8.1`), go to **More Settings → Upgrade → Local Upgrade**, or
open **Advanced → LuCI** and use **System → Backup / Flash Firmware →
Flash new firmware image**.
4. Upload the `.bin` file. **Uncheck "Keep Settings"** — going from the
GL.iNet fork to stock OpenWrt needs a clean reset, not a config carry-over.
5. Confirm and wait ~3–5 minutes without power-cycling the router.
6. **After it reboots** you're on stock OpenWrt: LAN at `192.168.1.1`, DHCP
on, SSH (dropbear) open as `root` with **no password set yet** — set one
via LuCI at `192.168.1.1` or `passwd` over SSH before doing anything else.
From here, continue with the Prerequisites/Step 2 flow above to connect
it to the Archipelago node.
> The Archipelago UI's Connect form (Step 2) authenticates *with* a
> password — it has no flow for setting the initial one on a fresh,
> passwordless router. You have to set it out-of-band first. If you're
> working from the node's own local kiosk display rather than a normal
> desktop browser, there's no visible tab bar/address bar to open a new
> tab from — press **Ctrl+T** to open one anyway, navigate to
> `192.168.1.1`, and use LuCI's first-boot prompt to set the root
> password. Then switch back to the Archipelago tab and Connect with it.
**If the flash fails / the router doesn't come back**: filogic devices
don't use a reset-button recovery. Instead, connect to the router's LAN
port and, during boot, press a key within the first ~2 seconds to enter
U-Boot; per the OpenWrt wiki, typing `gl` then `httpd` at the U-Boot prompt
brings up a recovery web UI at `192.168.1.2` that accepts a firmware image.
## Step 1: Open the OpenWrt Gateway panel
1. In the Archipelago UI, go to **Server**.
2. Under the network status list, click **OpenWrt Gateway**
(`/dashboard/server/openwrt`).
If no router has been connected before, you'll land on the connect form.
## Step 2: Connect the router
You have two options:
- **Detect**: click **Detect** — this reads the node's own active wired
Ethernet interface, derives its subnet, and probes every host on it for
`TCP/22` + a valid `/etc/openwrt_release`. If it finds exactly one router
it fills in the host automatically; if it finds several you pick from the
list. A `/24` scan can take up to ~2 minutes (255 sequential probes at
500 ms each on hosts that don't respond).
- **Manual**: type the router's LAN IP (commonly `192.168.1.1` on a router
freshly bridged in, or whatever address it has on your network) plus the
SSH username (default `root`) and password.
Click **Connect**. On success the panel switches to the status dashboard and
the connection (host + credentials) is persisted server-side — you won't
need to re-enter them on future visits or from other views (e.g. the Home
dashboard's network tile also polls this without prompting again).
> Credentials are stored in `router_config.json` under the node's data
> directory alongside other node config. There's no separate secrets
> vault entry for this yet — treat the router's SSH password like any other
> node-local config.
## Step 3: (Optional) Configure WAN/WISP
Use this to make the OpenWrt router pull its internet connection from an
upstream WiFi network instead of a wired uplink — useful for a
battery/off-grid TollGate node or extending coverage from an existing
network.
1. From the status dashboard, start the **WAN setup** wizard.
2. **Scan** — the router's radio scans for visible networks (a few seconds
of SSH round-trips).
3. **Select network** — pick the upstream SSID from the list.
4. **Password** — enter the upstream network's WiFi password (encryption
defaults to `psk2`; leave blank only for open networks).
5. **DHCP / NAT** — review the LAN DHCP pool (default `.100`–`.249`) and
whether to enable NAT/masquerade on the WAN zone (leave this on unless
you have a specific reason not to).
6. **Connect** — this writes a `wwan` STA `wifi-iface` + `network` interface
over UCI, enables the radio if it was disabled (OpenWrt ships with
`radio0.disabled=1` on a fresh flash), and adds `wwan` to the WAN
firewall zone.
The dashboard's WAN panel shows the resulting association state, assigned
IP, and whether the router currently has internet reachability.
## Step 4: (Optional) Install TollGate
Once connected (and with a local Cashu mint app running), the dashboard
shows a **TollGate: not installed** panel with a single **Install TollGate**
button — there's no config form at this stage, it installs with defaults.
The panel itself warns: *"Router needs internet access to install TollGate
— configure WAN above first"* (Step 3), since the router has to reach the
internet to download the package.
1. Click **Install TollGate**. The button relabels to *"Installing… this
may take a few minutes"* while it works.
2. Under the hood this installs `tollgate-module-basic-go` on the router
(via `opkg` on OpenWrt ≤24.x, or a manual `.ipk` extract on 25.x images
where `opkg` isn't available), writes `/etc/tollgate/config.json`, and
creates the `archipelago` SSID — all with default pricing (10 sats per
1-minute step, minimum 1 step, `mint_url` auto-filled to
`http://<node-ip>:3338`, enabled).
3. On success you'll see *"TollGate provisioned successfully"* and the
panel switches to the installed view (Enabled/Disabled badge, current
price/step/mint).
### Configuring price, step size, or mint (after install)
The installed-state panel has an **Edit** button — this is the only place
you set price/step/mint, and it only appears once TollGate is already
installed:
1. Click **Edit**.
2. Set **Price** (sats), **Step size** (minutes — billed as `step_size_ms`
under the hood), **Minimum steps** a customer must buy at once, **Mint
URL** (leave as the auto-filled node URL unless pointing at an external
mint), and the **Enable TollGate** toggle.
3. Click **Save**. Changes are pushed to `/etc/tollgate/config.json` and the
daemon is restarted to pick them up — it does not hot-reload.
Anyone who joins the `archipelago` SSID sees TollGate's captive portal and
pays sats (via the configured Cashu mint) for timed access.
## Verifying a successful install
A clean install (flash → Connect → WAN/WISP → Install TollGate, all through
the UI as above) ends in this state — worth checking if you want to confirm
everything actually landed correctly rather than trusting the UI's success
toast alone:
- `tollgate-wrt` is running (`/etc/init.d/tollgate-wrt status` → `running`).
- nodogsplash's **rendered** config — not just the UCI source — has
`GatewayInterface br-tollgate`. Check the actual file the daemon was
started with (typically `/tmp/etc/nodogsplash_main.conf`), since that's
what's actually enforced, not `uci show nodogsplash`. This matters because
provisioning must stop nodogsplash and reconfigure it to gate the
`br-tollgate` bridge *before* starting it — installing the package by hand
(bypassing the UI/RPC flow) leaves nodogsplash on its default
`br-lan`-gating behavior instead, which locks out the router's own
admin/SSH access. If you ever see a router become unreachable right after
a TollGate install, this is the first thing to check.
- The router's own LAN (the interface you manage it over — SSH, ping) is
still reachable and untouched by the portal.
- TollGate's own log (`logread | grep tollgate-wrt`) shows successful mint
probes for each configured mint.
A `dev build detected (branch=unknown), injecting test mint:
https://nofee.testnut.cashu.space` line in that log means the installed
build considers itself a dev build and silently adds a test mint alongside
your configured one(s) — check the Edit panel's Mint URL afterward if you
don't want that test mint accepted.
### A note on network topology during setup
If the Archipelago node reaches the router over the same wired interface the
router uses as its LAN, expect the router to become the node's default
route on that interface once it has its own working WAN/WISP uplink — this
is normal and, once WAN is actually configured with internet access, works
fine end-to-end (the node's traffic routes out through the router's
uplink). It's only a problem *before* WAN is configured: a freshly flashed
or freshly factory-reset router has no upstream internet yet, so if it wins
the node's default-route race (lowest metric on its own interface) while
still offline, it creates a dead-end route and the node loses its own
connectivity (including anything tunneled, e.g. a VPN/mesh network the node
relies on) until that route is removed or the router gets its uplink
working. If you hit this, either wait until WAN/WISP is actually up before
letting the router's interface win the route race, or temporarily lower the
priority of that route until it is.
## Reconfiguring or moving to a different router
Use **Disconnect** on the status dashboard to return to the connect form —
this only clears the panel's client-side state, it doesn't delete the
persisted `router_config.json`, so reconnecting to the same router needs no
re-entry. To point at a *different* router, disconnect and connect with a
new host/credentials; the newly connected router becomes the persisted one.
## Troubleshooting
- **"No router configured"**: nothing has been connected yet, or the saved
config didn't include a host — go through Step 2 again.
- **Connect hangs or times out**: the router isn't reachable on `TCP/22`
from the node's network, or SSH auth failed. Confirm you can `ssh
root@<router-ip>` manually from the node (or a machine on the same LAN)
with the same credentials.
- **Router "moved networks" / stale saved host**: SSH/status calls are
bounded (5s TCP connect, 30s read/write) precisely so an unreachable
saved router can't stall other RPCs — but the dashboard will show a
connection error until you reconnect with the router's current address.
- **TollGate provision fails with "No pre-built TollGate package for
architecture..."**: your router's SoC isn't one of the prebuilt
`.ipk` targets (`mips_24kc`, `mipsel_24kc`, `aarch64_cortex-a53`,
`aarch64_cortex-a72`, `arm_cortex-a7`). You'll need a custom opkg feed or
to build `tollgate-module-basic-go` from source for your architecture.
- **TollGate download looks like it succeeded but provisioning still
fails**: the node sanity-checks the downloaded `.ipk` is at least 50 KB —
a smaller file usually means `wget` captured an HTML error page instead
(no internet access from the router, or a bad release URL).
- **Install fails right after a reboot or a fresh WAN setup** with `apk
update failed ... router may have no internet access` even though WAN
looks configured: this is usually just timing, not a real problem — the
router's WiFi-uplink association (`wwan`/`hakodosh`-style STA interface)
can take a few seconds longer to reconnect than the dashboard takes to
let you click Install. Wait ~10–15 seconds after WAN shows `sta_state:
up` and retry; it should succeed on the next attempt.
- **Install fails with `opkg not found at /usr/bin/opkg` (or similar) even
though the router clearly has `opkg`/`apk` installed**: fixed as of
2026-09-05 — the backend used to hardcode `/usr/bin/opkg`/`/usr/bin/apk`,
which some official OpenWrt builds don't symlink into `/bin`. If you're
running an Archipelago build from before that fix, update first.
---
## Developer reference
Backend crate: `core/openwrt` (`archipelago-openwrt`) — SSH/UCI plumbing,
WAN/WISP config, WiFi scanning, and TollGate install/config. See
[`architecture.md`](architecture.md) for where it sits in the workspace.
RPC methods (`core/archipelago/src/api/rpc/openwrt.rs`, dispatched in
`core/archipelago/src/api/rpc/dispatcher.rs`):
| Method | Purpose |
|---|---|
| `openwrt.scan` | Probe a subnet for OpenWrt routers (`subnet`, `prefix`, `ssh_user`, `ssh_password`) |
| `openwrt.get-status` | Full status: release, WiFi interfaces, WAN, TollGate state. No params → uses saved `router_config.json`; params with `host` also persist the connection |
| `openwrt.configure-wan` | Write WISP/WAN config (`ssid`, `password`, `encryption`, `dhcp_start`, `dhcp_limit`, `masq`) |
| `openwrt.scan-wifi` | Radio scan for visible upstream networks |
| `openwrt.provision-tollgate` | Install/reconfigure TollGate (`price_sats`, `step_size_ms`, `min_steps`, `mint_url`, `enabled`) |
Note: these are distinct from the unrelated `router.*` methods
(`router.discover`, `router.configure`, `router.list-forwards`, ...), which
handle UPnP/NAT-PMP port forwarding on the node's own upstream home router —
not the OpenWrt gateway feature described here.
Frontend: `neode-ui/src/views/server/OpenWrtGateway.vue`, routed at
`server/openwrt` (`neode-ui/src/router/index.ts`), linked from
`neode-ui/src/views/Server.vue`.
Persisted connection state: `router_config.json` in the node's data
directory (`core/archipelago/src/network/router.rs`:
`load_router_config`/`save_router_config`).
+16 -2
View File
@@ -34,7 +34,14 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # NO HSTS on this node, by design (see the HTTPS block below for the
# active clear). The dashboard is deliberately reachable over plain
# HTTP on LANs/mDNS names where users have not installed the node CA —
# setup-node-ca.sh keeps port 80 serving for exactly that reason. A
# long-cache HSTS policy upgrades an already-open HTTP page's fetches to
# HTTPS; that scheme change is cross-origin, so every /rpc/v1 call died
# with "No Access-Control-Allow-Origin header" while the node was
# perfectly healthy (framework-pt, 2026-09-01: "Failed to fetch" storm).
add_header X-DNS-Prefetch-Control "off" always; add_header X-DNS-Prefetch-Control "off" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
@@ -1009,7 +1016,14 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # HSTS actively CLEARED (max-age=0), not set: this origin's certificate is
# optional/self-signed and plain-HTTP access is a supported mode. Earlier
# builds sent max-age=31536000 includeSubDomains, and browsers that had
# visited HTTPS once kept silently upgrading the HTTP dashboard's
# subresources afterwards — every fetch became cross-origin by scheme and
# was CORS-blocked. max-age=0 over HTTPS deletes that cached policy;
# never raise it on this origin unless HTTP access is retired first.
add_header Strict-Transport-Security "max-age=0" always;
add_header X-DNS-Prefetch-Control "off" always; add_header X-DNS-Prefetch-Control "off" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.7-alpha", "version": "1.8.11-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.7-alpha", "version": "1.8.11-alpha",
"dependencies": { "dependencies": {
"@scure/bip39": "^2.2.0", "@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.8.7-alpha", "version": "1.8.11-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",
+20 -32
View File
@@ -13,20 +13,20 @@
{ {
"id": "adguardhome", "id": "adguardhome",
"title": "AdGuard Home", "title": "AdGuard Home",
"version": "v0.107.55", "version": "v0.107.79",
"description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.", "description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.",
"icon": "", "icon": "",
"author": "AdGuard", "author": "AdGuard",
"category": "networking", "category": "networking",
"tier": "optional", "tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55", "dockerImage": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.79",
"repoUrl": "https://github.com/AdguardTeam/AdGuardHome" "repoUrl": "https://github.com/AdguardTeam/AdGuardHome"
}, },
{ {
"id": "alby-hub", "id": "alby-hub",
"title": "Alby Hub", "title": "Alby Hub",
"version": "1.23.0", "version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect \u2014 one hub, every app pays through it.", "description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg", "icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby", "author": "Alby",
"category": "money", "category": "money",
@@ -117,18 +117,6 @@
"bitcoin-knots" "bitcoin-knots"
] ]
}, },
{
"id": "cryptpad",
"title": "CryptPad",
"version": "2024.12.0",
"description": "End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.",
"icon": "/assets/icon/favico-black-v2.svg",
"author": "XWiki SAS",
"category": "data",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/cryptpad:2024.12.0",
"repoUrl": "https://github.com/cryptpad/cryptpad"
},
{ {
"id": "cuprate", "id": "cuprate",
"title": "Cuprate", "title": "Cuprate",
@@ -203,13 +191,13 @@
{ {
"id": "filebrowser", "id": "filebrowser",
"title": "File Browser", "title": "File Browser",
"version": "2.27.0", "version": "2.63.23",
"description": "Baseline Archipelago file manager service.", "description": "Baseline Archipelago file manager service.",
"icon": "/assets/img/app-icons/file-browser.webp", "icon": "/assets/img/app-icons/file-browser.webp",
"author": "File Browser", "author": "File Browser",
"category": "data", "category": "data",
"tier": "core", "tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0", "dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.63.23",
"repoUrl": "https://github.com/filebrowser/filebrowser", "repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -230,12 +218,12 @@
{ {
"id": "gitea", "id": "gitea",
"title": "Gitea", "title": "Gitea",
"version": "1.23", "version": "1.27.3",
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.", "description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
"icon": "/assets/img/app-icons/gitea.svg", "icon": "/assets/img/app-icons/gitea.svg",
"author": "Gitea", "author": "Gitea",
"category": "development", "category": "development",
"dockerImage": "docker.io/gitea/gitea:1.23", "dockerImage": "source.archipelago-foundation.org/lfg2025/gitea:1.27.3",
"repoUrl": "https://gitea.com", "repoUrl": "https://gitea.com",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -286,12 +274,12 @@
{ {
"id": "homeassistant", "id": "homeassistant",
"title": "Home Assistant", "title": "Home Assistant",
"version": "2026.7.3", "version": "2026.8.3",
"description": "Open source home automation platform. Control and monitor your smart home devices.", "description": "Open source home automation platform. Control and monitor your smart home devices.",
"icon": "/assets/img/app-icons/homeassistant.png", "icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant", "author": "Home Assistant",
"category": "home", "category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2", "dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.3",
"repoUrl": "https://github.com/home-assistant/core", "repoUrl": "https://github.com/home-assistant/core",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -350,13 +338,13 @@
{ {
"id": "lnd", "id": "lnd",
"title": "LND", "title": "LND",
"version": "0.18.4", "version": "0.21.2",
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.", "description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
"icon": "/assets/img/app-icons/lnd.png", "icon": "/assets/img/app-icons/lnd.png",
"author": "Lightning Labs", "author": "Lightning Labs",
"category": "money", "category": "money",
"tier": "core", "tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta", "dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.21.2-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd", "repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [ "requires": [
"bitcoin-knots" "bitcoin-knots"
@@ -382,7 +370,7 @@
"id": "netbird", "id": "netbird",
"title": "NetBird", "title": "NetBird",
"version": "2.38.0", "version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point \u2014 a TLS proxy in front of the dashboard + server.", "description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg", "icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird", "author": "NetBird",
"category": "networking", "category": "networking",
@@ -424,7 +412,7 @@
"id": "nginx-proxy-manager", "id": "nginx-proxy-manager",
"title": "Nginx Proxy Manager", "title": "Nginx Proxy Manager",
"version": "2.12.1", "version": "2.12.1",
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration \u2014 the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).", "description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
"icon": "/assets/img/app-icons/nginx.svg", "icon": "/assets/img/app-icons/nginx.svg",
"author": "Nginx Proxy Manager", "author": "Nginx Proxy Manager",
"category": "networking", "category": "networking",
@@ -460,7 +448,7 @@
"id": "ollama", "id": "ollama",
"title": "Ollama", "title": "Ollama",
"version": "0.5.4", "version": "0.5.4",
"description": "Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware \u2014 served on the node's loopback for the AI assistant (Settings \u2192 Claude Auth \u2192 model backend), never exposed to the network.", "description": "Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware — served on the node's loopback for the AI assistant (Settings → Claude Auth → model backend), never exposed to the network.",
"icon": "/assets/img/app-icons/ollama.png", "icon": "/assets/img/app-icons/ollama.png",
"author": "Ollama", "author": "Ollama",
"category": "community", "category": "community",
@@ -472,7 +460,7 @@
"id": "phoenixd", "id": "phoenixd",
"title": "phoenixd", "title": "phoenixd",
"version": "0.9.0", "version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own \u2014 it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.", "description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg", "icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ", "author": "ACINQ",
"category": "money", "category": "money",
@@ -507,7 +495,7 @@
"id": "pine", "id": "pine",
"title": "Pine", "title": "Pine",
"version": "1.3.0", "version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.", "description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg", "icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago", "author": "Archipelago",
"category": "home", "category": "home",
@@ -517,13 +505,13 @@
{ {
"id": "portainer", "id": "portainer",
"title": "Portainer", "title": "Portainer",
"version": "2.19.4", "version": "2.45.0",
"description": "Container management web UI for the local Podman socket.", "description": "Container management web UI for the local Podman socket.",
"icon": "/assets/img/app-icons/portainer.webp", "icon": "/assets/img/app-icons/portainer.webp",
"author": "Portainer", "author": "Portainer",
"category": "development", "category": "development",
"tier": "optional", "tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6", "dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.45.0",
"repoUrl": "https://github.com/portainer/portainer", "repoUrl": "https://github.com/portainer/portainer",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -615,13 +603,13 @@
{ {
"id": "vaultwarden", "id": "vaultwarden",
"title": "Vaultwarden", "title": "Vaultwarden",
"version": "1.30.0", "version": "1.37.2",
"description": "Self-hosted password vault with zero-knowledge encryption.", "description": "Self-hosted password vault with zero-knowledge encryption.",
"icon": "/assets/img/app-icons/vaultwarden.webp", "icon": "/assets/img/app-icons/vaultwarden.webp",
"author": "Vaultwarden", "author": "Vaultwarden",
"category": "data", "category": "data",
"tier": "recommended", "tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine", "dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.2-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden", "repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
+8
View File
@@ -117,6 +117,7 @@ import { useSpotlightStore } from '@/stores/spotlight'
import { useCLIStore } from '@/stores/cli' import { useCLIStore } from '@/stores/cli'
import { useMessageToast } from '@/composables/useMessageToast' import { useMessageToast } from '@/composables/useMessageToast'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { fetchAppCatalog } from './views/discover/curatedApps'
import { useScreensaverStore } from '@/stores/screensaver' import { useScreensaverStore } from '@/stores/screensaver'
import { useUIModeStore } from '@/stores/uiMode' import { useUIModeStore } from '@/stores/uiMode'
import { startRemoteRelay, stopRemoteRelay } from '@/api/remote-relay' import { startRemoteRelay, stopRemoteRelay } from '@/api/remote-relay'
@@ -396,6 +397,13 @@ function onVisibilityChange() {
onMounted(async () => { onMounted(async () => {
syncKioskSafeArea() syncKioskSafeArea()
// Warm the signed-catalog cache before any app launch needs it: port auth
// (gate-fronted ⇒ TLS on the app port) decides whether an app frame opens
// over https on an HTTPS dashboard. The cache used to be filled only by
// the Store/Discover views, so a user who went straight to My Apps got an
// http:// frame URL — blocked as mixed content (mempool/indeehub "did not
// connect", 2026-09-01). fetchAppCatalog() memoizes with a 1h TTL.
void fetchAppCatalog()
// Light app-wide mesh poll so a freshly plugged-in radio surfaces the // Light app-wide mesh poll so a freshly plugged-in radio surfaces the
// setup modal on any page (the Mesh view's own poll takes over there). // setup modal on any page (the Mesh view's own poll takes over there).
useMeshStore().startGlobalDetection() useMeshStore().startGlobalDetection()
@@ -10,7 +10,32 @@
z-index="z-[3600]" z-index="z-[3600]"
@close="onClose" @close="onClose"
> >
<p v-if="lightning.status.value === 'no-funds'" class="text-sm text-white/70 leading-relaxed"> <p v-if="lightning.status.value === 'no-funds' && lightning.fundingReason.value === 'pending'" class="text-sm text-white/70 leading-relaxed">
Your new channel is <span class="text-white/90">waiting for its on-chain confirmations</span> —
that's why the network doesn't see it yet. It unlocks automatically once
confirmed (usually within about half an hour); nothing is needed from
you. This screen will work as soon as it lands.
</p>
<p v-else-if="lightning.status.value === 'no-funds' && lightning.fundingReason.value === 'far-side'" class="text-sm text-white/70 leading-relaxed">
<template v-if="lightning.fundingDirection.value === 'receive'">
You have channels, but <span class="text-white/90">all the balance is on your side</span> —
you can send, but there's nothing to be paid into right now. Receive a
payment by spending first, or open another channel to bring inbound
liquidity in.
</template>
<template v-else>
You have channels, but <span class="text-white/90">all the balance is on the far side</span> —
you can receive, but there's nothing to send right now. Someone has to
pay you first (or rebalance the channel), and sending unlocks on its own.
</template>
</p>
<p v-else-if="lightning.status.value === 'no-funds' && lightning.fundingReason.value === 'failed-payment'" class="text-sm text-white/70 leading-relaxed">
LND couldn't route this payment — most often there's
<span class="text-white/90">not enough outbound for this amount</span>, or no
route to the recipient at the fees offered. Smaller amounts sometimes
get through; check the channels screen to see what's actually spendable.
</p>
<p v-else-if="lightning.status.value === 'no-funds'" class="text-sm text-white/70 leading-relaxed">
Your Lightning node is running, but it has no payment channel yet. Your Lightning node is running, but it has no payment channel yet.
<template v-if="lightning.fundingDirection.value === 'receive'"> <template v-if="lightning.fundingDirection.value === 'receive'">
Receiving needs <span class="text-white/90">inbound liquidity</span> — a Receiving needs <span class="text-white/90">inbound liquidity</span> — a
@@ -101,14 +126,31 @@
@click="openApps" @click="openApps"
>Open My Apps</button> >Open My Apps</button>
<template v-else-if="lightning.status.value === 'no-funds'"> <template v-else-if="lightning.status.value === 'no-funds'">
<button <!-- A confirming channel needs no action at all — offering "open a
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" channel" here would send the user to fix a problem they don't
@click="openSetupGuide" have (and possibly open a second one). -->
>Setup Guide</button> <template v-if="lightning.fundingReason.value === 'pending'">
<button <button
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm"
@click="openLightningSetup" @click="onClose"
>Open a channel</button> >Got it — I'll wait</button>
</template>
<template v-else>
<button
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm"
@click="openSetupGuide"
>Setup Guide</button>
<button
v-if="lightning.fundingReason.value !== 'failed-payment'"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
@click="openLightningSetup"
>Open a channel</button>
<button
v-else
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm"
@click="onClose"
>Close</button>
</template>
</template> </template>
</div> </div>
</BaseModal> </BaseModal>
@@ -155,7 +197,12 @@ const nodes: NodeChoice[] = [
const router = useRouter() const router = useRouter()
const modalTitle = computed(() => { const modalTitle = computed(() => {
if (lightningStatusIs('no-funds')) return 'You need a Lightning channel' if (lightningStatusIs('no-funds')) {
if (lightning.fundingReason.value === 'pending') return 'Channel confirming…'
if (lightning.fundingReason.value === 'far-side') return 'Balance is on the far side'
if (lightning.fundingReason.value === 'failed-payment') return 'Payment couldn\u2019t route'
return 'You need a Lightning channel'
}
if (lightningStatusIs('stopped')) return 'Lightning node not running' if (lightningStatusIs('stopped')) return 'Lightning node not running'
return 'Lightning node required' return 'Lightning node required'
}) })
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { useLightningRequired } from '../useLightningRequired' import { useLightningRequired } from '../useLightningRequired'
import { rpcClient } from '@/api/rpc-client'
// The gate reads install state off the app store's package list. Stub the // The gate reads install state off the app store's package list. Stub the
// store rather than the RPC layer so the test pins the decision, not the // store rather than the RPC layer so the test pins the decision, not the
@@ -14,6 +15,12 @@ vi.mock('@/stores/app', () => ({
}), }),
})) }))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
describe('useLightningRequired', () => { describe('useLightningRequired', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
@@ -73,4 +80,82 @@ describe('useLightningRequired', () => {
packages.value = {} packages.value = {}
expect(useLightningRequired().lightningStatus()).toBe('absent') expect(useLightningRequired().lightningStatus()).toBe('absent')
}) })
describe('requireLightningReady states the node\u2019s real funding state', () => {
beforeEach(() => {
packages.value = { lnd: { state: 'running' } }
vi.mocked(rpcClient.call).mockReset()
})
it('says the channel is confirming, not \u201cno channel\u201d, while pending', async () => {
// The regression (framework-pt, 2026-09-01): a just-opened channel
// sits in LND's pending list; the outbound sum is legitimately 0, but
// the modal claimed the node had no channel at all.
vi.mocked(rpcClient.call).mockResolvedValue({
total_inbound: 0,
total_outbound: 0,
channels: [{ status: 'pending_open', local_balance: 900000, remote_balance: 0 }],
})
const lightning = useLightningRequired()
expect(await lightning.requireLightningReady('send')).toBe(false)
expect(lightning.show.value).toBe(true)
expect(lightning.status.value).toBe('no-funds')
expect(lightning.fundingReason.value).toBe('pending')
})
it('says the balance is on the far side when channels exist but outbound is 0', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({
total_inbound: 985000,
total_outbound: 0,
channels: [{ status: 'active', local_balance: 0, remote_balance: 985000 }],
})
const lightning = useLightningRequired()
expect(await lightning.requireLightningReady('send')).toBe(false)
expect(lightning.fundingReason.value).toBe('far-side')
// The same node CAN receive — the gate must pass for the other way.
vi.mocked(rpcClient.call).mockResolvedValue({
total_inbound: 985000,
total_outbound: 0,
channels: [{ status: 'active', local_balance: 0, remote_balance: 985000 }],
})
expect(await lightning.requireLightningReady('receive')).toBe(true)
})
it('keeps the open-a-channel guidance only when there truly is no channel', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({
total_inbound: 0,
total_outbound: 0,
channels: [],
})
const lightning = useLightningRequired()
expect(await lightning.requireLightningReady('send')).toBe(false)
expect(lightning.fundingReason.value).toBe('none')
})
it('fails OPEN on an RPC error \u2014 a transient blip must not block a working wallet', async () => {
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Failed to fetch'))
const lightning = useLightningRequired()
expect(await lightning.requireLightningReady('send')).toBe(true)
expect(lightning.show.value).toBe(false)
})
it('maps a routing/liquidity payment failure onto the modal without claiming \u201cno channel\u201d', () => {
const lightning = useLightningRequired()
expect(lightning.handleLightningFailure(new Error('Payment failed: unable to find a path to destination'))).toBe(true)
expect(lightning.status.value).toBe('no-funds')
expect(lightning.fundingReason.value).toBe('failed-payment')
})
it('leaves non-funding payment errors to the caller', () => {
const lightning = useLightningRequired()
expect(lightning.handleLightningFailure(new Error('Payment failed: Not Found'))).toBe(false)
expect(lightning.show.value).toBe(false)
})
})
}) })
@@ -34,12 +34,27 @@ export const LIGHTNING_NODE_APP_IDS = ['lnd'] as const
* `running` — good to go. */ * `running` — good to go. */
export type LightningStatus = 'absent' | 'stopped' | 'running' | 'no-funds' export type LightningStatus = 'absent' | 'stopped' | 'running' | 'no-funds'
/** WHY the funding modal opened — the old copy always said "you have no
* channel yet", which was a lie three ways: a just-opened channel sits in
* LND's pending list (invisible to the outbound sum) until it has ~3
* confirmations, channels can exist with all their balance on the far
* side, and a payment failure can look like a funding problem. The user
* sees "no channel" while looking at a wallet full of pending liquidity
* (framework-pt, 2026-09-01: "LND thinks I do not have a channel").
* `none` — genuinely no channels, the open-one flow is right.
* `pending` — channel(s) exist but are still confirming on-chain.
* `far-side` — open channel(s), but the needed direction has zero balance.
* `failed-payment` — LND refused a payment; looks like routing/liquidity. */
export type FundingReason = 'none' | 'pending' | 'far-side' | 'failed-payment'
// Module-scope: one source of truth shared by every caller and the single // Module-scope: one source of truth shared by every caller and the single
// global modal mounted in App.vue. // global modal mounted in App.vue.
const show = ref(false) const show = ref(false)
const status = ref<LightningStatus>('absent') const status = ref<LightningStatus>('absent')
/** Which direction raised the funding modal, so the copy can be specific. */ /** Which direction raised the funding modal, so the copy can be specific. */
const fundingDirection = ref<'send' | 'receive'>('receive') const fundingDirection = ref<'send' | 'receive'>('receive')
/** Why the funding modal opened, so the copy states the node's real state. */
const fundingReason = ref<FundingReason>('none')
export function useLightningRequired() { export function useLightningRequired() {
// The store is resolved lazily, inside the functions that need it, rather // The store is resolved lazily, inside the functions that need it, rather
@@ -86,8 +101,9 @@ export function useLightningRequired() {
* rather than inventing a second one, and routes to the Lightning setup * rather than inventing a second one, and routes to the Lightning setup
* goal where funding and channel-opening already live. * goal where funding and channel-opening already live.
*/ */
function openLightningFunding() { function openLightningFunding(reason: FundingReason = 'none') {
status.value = 'no-funds' status.value = 'no-funds'
fundingReason.value = reason
show.value = true show.value = true
} }
@@ -114,7 +130,9 @@ export function useLightningRequired() {
'no path', 'no path',
].some((needle) => msg.includes(needle)) ].some((needle) => msg.includes(needle))
if (!fundingRelated) return false if (!fundingRelated) return false
openLightningFunding() // LND refused the payment itself — not necessarily "no channels", so
// the modal must not claim it is. Most often this is routing/liquidity.
openLightningFunding('failed-payment')
return true return true
} }
@@ -133,14 +151,28 @@ export function useLightningRequired() {
async function requireLightningReady(direction: 'send' | 'receive'): Promise<boolean> { async function requireLightningReady(direction: 'send' | 'receive'): Promise<boolean> {
if (!requireLightningNode()) return false if (!requireLightningNode()) return false
try { try {
const res = await rpcClient.call<{ total_inbound?: number; total_outbound?: number }>({ const res = await rpcClient.call<{
total_inbound?: number
total_outbound?: number
channels?: { status?: string; local_balance?: number; remote_balance?: number }[]
}>({
method: 'lnd.listchannels', method: 'lnd.listchannels',
timeout: 15000, timeout: 15000,
}) })
const liquidity = direction === 'receive' ? res?.total_inbound ?? 0 : res?.total_outbound ?? 0 const liquidity = direction === 'receive' ? res?.total_inbound ?? 0 : res?.total_outbound ?? 0
if (liquidity > 0) return true if (liquidity > 0) return true
fundingDirection.value = direction fundingDirection.value = direction
openLightningFunding() // Zero in the needed direction — say WHY, from the same response.
// The channel list carries pending entries (status 'pending_open');
// the totals deliberately exclude them (nothing is spendable through
// an unconfirmed channel), so "0 outbound + pending channels" is the
// just-opened-a-channel state, not "no channel".
const channels = res?.channels ?? []
const hasPending = channels.some(c => c.status === 'pending_open')
const hasOpen = channels.some(
c => c.status === 'active' || c.status === 'inactive' || (!c.status && (c.local_balance || c.remote_balance)),
)
openLightningFunding(hasPending ? 'pending' : hasOpen ? 'far-side' : 'none')
return false return false
} catch { } catch {
return true return true
@@ -150,6 +182,7 @@ export function useLightningRequired() {
return { return {
show, show,
fundingDirection, fundingDirection,
fundingReason,
status, status,
lightningStatus, lightningStatus,
hasLightningNode, hasLightningNode,
@@ -29,7 +29,7 @@ vi.mock('@/router', () => ({
vi.stubGlobal('open', mockWindowOpen) vi.stubGlobal('open', mockWindowOpen)
import { useAppLauncherStore } from '../appLauncher' import { useAppLauncherStore, senderMatchesApp } from '../appLauncher'
describe('useAppLauncherStore', () => { describe('useAppLauncherStore', () => {
beforeEach(() => { beforeEach(() => {
@@ -448,4 +448,21 @@ describe('useAppLauncherStore', () => {
vi.runAllTimers() vi.runAllTimers()
vi.useRealTimers() vi.useRealTimers()
}) })
describe('NIP-07 sender origin matching', () => {
it('accepts a scheme-upgraded frame (HSTS) as the opened app', () => {
// Regression (2026-09-01): the stored app URL was http:// but the
// browser loaded the frame as https:// — strict origin equality
// dropped every nostr sign-in from the upgraded frame.
expect(senderMatchesApp('http://framework-pt.local:7778', 'https://framework-pt.local:7778')).toBe(true)
expect(senderMatchesApp('https://framework-pt.local:7778', 'http://framework-pt.local:7778')).toBe(true)
})
it('still rejects a different host or port', () => {
expect(senderMatchesApp('http://framework-pt.local:7778', 'https://evil.example:7778')).toBe(false)
expect(senderMatchesApp('http://framework-pt.local:7778', 'https://framework-pt.local:7777')).toBe(false)
expect(senderMatchesApp('http://framework-pt.local:7778', 'null')).toBe(false)
expect(senderMatchesApp('', 'https://framework-pt.local:7778')).toBe(false)
})
})
}) })
+22 -12
View File
@@ -29,6 +29,24 @@ function openExternal(launchUrl: string) {
window.open(launchUrl, '_blank', 'noopener,noreferrer') window.open(launchUrl, '_blank', 'noopener,noreferrer')
} }
/** Whether a postMessage sender's origin belongs to the app the launcher
* actually opened. Same hostname and port are REQUIRED; the SCHEME is
* deliberately not compared: a browser with cached HSTS (or any scheme
* upgrade) loads a stored http:// app URL as https://, and strict equality
* silently dropped every nostr request from the upgraded frame — nostr
* sign-in on IndeeHub died exactly there over HTTPS (2026-09-01). */
export function senderMatchesApp(appUrl: string, senderOrigin: string): boolean {
let expected: URL
let sender: URL
try {
expected = new URL(appUrl, 'http://localhost/')
sender = new URL(senderOrigin)
} catch {
return false
}
return sender.hostname === expected.hostname && sender.port === expected.port
}
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */ /** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
const NEW_TAB_PORTS = new Set([ const NEW_TAB_PORTS = new Set([
'23000', // BTCPay — X-Frame-Options: DENY '23000', // BTCPay — X-Frame-Options: DENY
@@ -393,19 +411,11 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const source = event.source as Window | null const source = event.source as Window | null
if (!source) return if (!source) return
// Only the app we actually opened may drive this bridge. The sender's // Only the app we actually opened may drive this bridge — see
// real origin must match the open app's URL origin — without this, any // senderMatchesApp for why the scheme is deliberately not compared.
// co-resident iframe could deanonymize the nostr identity or use the if (!senderMatchesApp(url.value, event.origin)) return
// node as a decryption oracle while an app happened to be open.
let expectedOrigin: string
try {
expectedOrigin = new URL(url.value, window.location.href).origin
} catch {
return
}
if (event.origin !== expectedOrigin) return
const origin = url.value || 'unknown' const origin = event.origin
// Check if app has a per-app identity stored (from identity picker) // Check if app has a per-app identity stored (from identity picker)
const IDENTITY_KEY = 'archipelago_app_identity_' const IDENTITY_KEY = 'archipelago_app_identity_'
+1 -1
View File
@@ -265,7 +265,7 @@ function closeRouteSession() {
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null) const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker) const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
const nostrBridge = useNostrBridge(identity.getStoredIdentity, () => appUrl.value) const nostrBridge = useNostrBridge(identity.getStoredIdentity)
// --- Display mode --- // --- Display mode ---
+6 -4
View File
@@ -515,8 +515,10 @@ async function installApp(app: MarketplaceApp) {
} }
queueInstall(app) queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps") // Stay on the store page: the tile itself shows install progress via the
router.push('/dashboard/apps').catch(() => {}) // global watcher, and a forced jump to My Apps yanked the user out of the
// page they were deliberately browsing.
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps")
try { try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl const installUrl = app.url || app.manifestUrl || app.s9pkUrl
@@ -540,8 +542,8 @@ async function installCommunityApp(app: MarketplaceApp) {
} }
queueInstall(app) queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps") // Stay on the store page (see installApp).
router.push('/dashboard/apps').catch(() => {}) toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps")
try { try {
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version } const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
@@ -1,4 +1,4 @@
import { describe, expect, it, beforeEach } from 'vitest' import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
import { NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig' import { NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig' import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
import { __setSignedCatalogForTests } from '../../discover/curatedApps' import { __setSignedCatalogForTests } from '../../discover/curatedApps'
@@ -19,6 +19,8 @@ const SIGNED = {
tailscale: { version: 'stable' }, tailscale: { version: 'stable' },
// auth:none ports are container-published too — https would fail. // auth:none ports are container-published too — https would fail.
cuprate: { version: '0.1.0-preview', manifest: { app: { ports: [{ host: 18090, auth: 'none' }] } } }, cuprate: { version: '0.1.0-preview', manifest: { app: { ports: [{ host: 18090, auth: 'none' }] } } },
// Cuprate's companion dashboard owns the gated launch port.
'cuprate-ui': { version: '1.0.0', manifest: { app: { ports: [{ host: 18091, auth: 'gated' }] } } },
}, },
} }
@@ -34,6 +36,9 @@ describe('appSessionConfig', () => {
beforeEach(() => { beforeEach(() => {
__setSignedCatalogForTests(SIGNED as never) __setSignedCatalogForTests(SIGNED as never)
}) })
afterEach(() => {
vi.unstubAllEnvs()
})
it('keeps manifest-owned new-tab apps marked on every viewport', () => { it('keeps manifest-owned new-tab apps marked on every viewport', () => {
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true) expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
expect(NEW_TAB_APPS.has('photoprism')).toBe(true) expect(NEW_TAB_APPS.has('photoprism')).toBe(true)
@@ -64,10 +69,10 @@ describe('appSessionConfig', () => {
configurable: true, configurable: true,
}) })
// did-wallet's manifest publishes host port 8088 (apps/did-wallet/ // searxng's manifest publishes host port 8888 (apps/searxng/
// manifest.yml) — assert against the manifest-generated value, which is // manifest.yml) — assert against the manifest-generated value, which is
// exactly what this test exists to protect. // exactly what this test exists to protect.
expect(resolveAppUrl('did-wallet')).toBe('http://192.0.2.10:8088') expect(resolveAppUrl('searxng')).toBe('http://192.0.2.10:8888')
}) })
it('does not treat service-only tcp ports as web launch surfaces', () => { it('does not treat service-only tcp ports as web launch surfaces', () => {
@@ -144,7 +149,18 @@ describe('appSessionConfig', () => {
// A runtime port the gate does NOT front keeps plain http (https would // A runtime port the gate does NOT front keeps plain http (https would
// fail to connect outright). // fail to connect outright).
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083') expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083')
// Cuprate's UI port is auth:none — plain HTTP stays plain. })
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090')
it('launches cuprate on the companion UI, never the raw restricted RPC', () => {
// resolveAppUrl has a DEV branch (vite path proxy) — assert prod launch.
vi.stubEnv('DEV', false)
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
// cuprate publishes only raw JSON RPC (18090 restricted, 18183 p2p).
// Launches must land on the companion dashboard (:18091, gate-fronted →
// page scheme), even when the backend hands back a running-node runtime
// URL pointing at the bare RPC.
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('https://192.0.2.10:18091')
expect(resolveAppUrl('cuprate-ui')).toBe('https://192.0.2.10:18091')
expect(resolveAppUrl('archy-cuprate-ui')).toBe('https://192.0.2.10:18091')
}) })
}) })
@@ -29,6 +29,9 @@ export const APP_PORTS: Record<string, number> = {
'bitcoin-knots': 8334, 'bitcoin-knots': 8334,
'bitcoin-core': 8334, 'bitcoin-core': 8334,
'bitcoin-ui': 8334, 'bitcoin-ui': 8334,
'cuprate': 18091,
'cuprate-ui': 18091,
'archy-cuprate-ui': 18091,
'electrumx': 50002, 'electrumx': 50002,
'electrs': 50002, 'electrs': 50002,
'archy-electrs-ui': 50002, 'archy-electrs-ui': 50002,
@@ -111,6 +114,15 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
return appOrigin(8334, id) return appOrigin(8334, id)
} }
// Cuprate UI is the same companion shape on :18091. The cuprate app itself
// publishes only the restricted RPC (18090) — a raw JSON endpoint, not a
// page — so cuprate launches must land on the companion, never on the
// runtimeUrl a running cuprate reports.
if (id === 'cuprate' || id === 'cuprate-ui' || id === 'archy-cuprate-ui') {
if (import.meta.env.DEV) return '/app/cuprate-ui/'
return appOrigin(18091, id)
}
if (runtimeUrl && id !== 'netbird') { if (runtimeUrl && id !== 'netbird') {
let base = runtimeUrl.replace(/localhost/i, window.location.hostname) let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
// The backend reports runtime URLs as http:// because that is how the app // The backend reports runtime URLs as http:// because that is how the app
@@ -1,6 +1,7 @@
/** Generated by scripts/generate-app-catalog.py. Do not edit manually. */ /** Generated by scripts/generate-app-catalog.py. Do not edit manually. */
export const GENERATED_APP_PORTS: Record<string, number> = { export const GENERATED_APP_PORTS: Record<string, number> = {
"adguardhome": 3030,
"aiui": 5180, "aiui": 5180,
"alby-hub": 8187, "alby-hub": 8187,
"archy-mempool-web": 4080, "archy-mempool-web": 4080,
@@ -8,7 +9,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"bitcoin-ui": 8334, "bitcoin-ui": 8334,
"botfights": 9100, "botfights": 9100,
"btcpay-server": 23000, "btcpay-server": 23000,
"did-wallet": 8088, "cuprate-ui": 18091,
"electrs-ui": 50002, "electrs-ui": 50002,
"electrumx": 50002, "electrumx": 50002,
"fedimint": 8175, "fedimint": 8175,
@@ -23,9 +24,9 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"lnd-ui": 18083, "lnd-ui": 18083,
"mempool": 4080, "mempool": 4080,
"mempool-api": 8999, "mempool-api": 8999,
"morphos-server": 8089,
"netbird": 8087, "netbird": 8087,
"nextcloud": 8085, "nextcloud": 8085,
"nginx-proxy-manager": 8081,
"nostr-rs-relay": 18081, "nostr-rs-relay": 18081,
"photoprism": 2342, "photoprism": 2342,
"pine": 10380, "pine": 10380,
@@ -33,11 +34,13 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"router": 8084, "router": 8084,
"searxng": 8888, "searxng": 8888,
"strfry": 8090, "strfry": 8090,
"tailscale": 8240,
"uptime-kuma": 3002, "uptime-kuma": 3002,
"vaultwarden": 8082, "vaultwarden": 8082,
} }
export const GENERATED_APP_TITLES: Record<string, string> = { export const GENERATED_APP_TITLES: Record<string, string> = {
"adguardhome": "AdGuard Home",
"aiui": "AI Assistant", "aiui": "AI Assistant",
"alby-hub": "Alby Hub", "alby-hub": "Alby Hub",
"archy-btcpay-db": "BTCPay Postgres", "archy-btcpay-db": "BTCPay Postgres",
@@ -52,7 +55,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"btcpay-server": "BTCPay Server", "btcpay-server": "BTCPay Server",
"core-lightning": "Core Lightning (CLN)", "core-lightning": "Core Lightning (CLN)",
"cuprate": "Cuprate", "cuprate": "Cuprate",
"did-wallet": "Web5 DID Wallet", "cuprate-ui": "Cuprate UI",
"electrs-ui": "Electrs UI", "electrs-ui": "Electrs UI",
"electrumx": "ElectrumX", "electrumx": "ElectrumX",
"fedimint": "Fedimint Guardian", "fedimint": "Fedimint Guardian",
@@ -74,17 +77,17 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"indeedhub-redis": "IndeedHub Redis", "indeedhub-redis": "IndeedHub Redis",
"indeedhub-relay": "IndeedHub Nostr Relay", "indeedhub-relay": "IndeedHub Nostr Relay",
"jellyfin": "Jellyfin", "jellyfin": "Jellyfin",
"lightning-stack": "Lightning Stack",
"lnd": "LND", "lnd": "LND",
"lnd-ui": "LND UI", "lnd-ui": "LND UI",
"mempool": "Mempool Explorer", "mempool": "Mempool Explorer",
"mempool-api": "Mempool API", "mempool-api": "Mempool API",
"morphos-server": "MorphOS Server",
"netbird": "NetBird", "netbird": "NetBird",
"netbird-dashboard": "NetBird Dashboard", "netbird-dashboard": "NetBird Dashboard",
"netbird-server": "NetBird Server", "netbird-server": "NetBird Server",
"nextcloud": "Nextcloud", "nextcloud": "Nextcloud",
"nginx-proxy-manager": "Nginx Proxy Manager",
"nostr-rs-relay": "Nostr Relay (Rust)", "nostr-rs-relay": "Nostr Relay (Rust)",
"ollama": "Ollama",
"phoenixd": "phoenixd", "phoenixd": "phoenixd",
"photoprism": "PhotoPrism", "photoprism": "PhotoPrism",
"pine": "Pine", "pine": "Pine",
@@ -95,6 +98,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"router": "Mesh Router", "router": "Mesh Router",
"searxng": "SearXNG", "searxng": "SearXNG",
"strfry": "Strfry Nostr Relay", "strfry": "Strfry Nostr Relay",
"tailscale": "Tailscale",
"uptime-kuma": "Uptime Kuma", "uptime-kuma": "Uptime Kuma",
"vaultwarden": "Vaultwarden", "vaultwarden": "Vaultwarden",
} }
@@ -1,11 +1,15 @@
/** Composable for NIP-07 Nostr signing bridge between parent and iframe */ /** Composable for NIP-07 Nostr signing between parent and iframe apps.
*
* Replies always target event.origin — the frame's REAL origin. The app's
* recorded URL can carry a stale scheme (HSTS-upgraded http app on an HTTPS
* dashboard); targeting it makes postMessage throw and the app never sees
* its response. */
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import type { SelectedIdentity } from './useAppIdentity' import type { SelectedIdentity } from './useAppIdentity'
export function useNostrBridge( export function useNostrBridge(
getStoredIdentity: () => SelectedIdentity | null, getStoredIdentity: () => SelectedIdentity | null,
getAppUrl: () => string,
) { ) {
async function handleNostrRequest(event: MessageEvent) { async function handleNostrRequest(event: MessageEvent) {
const { id, method, params } = event.data const { id, method, params } = event.data
@@ -43,14 +47,15 @@ export function useNostrBridge(
else if (method === 'nip44.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext } else if (method === 'nip44.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip44.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext } else if (method === 'nip44.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
else { throw new Error(`Unsupported NIP-07 method: ${method}`) } else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
const url = getAppUrl() // Reply to the sender's REAL origin, never to the stored app URL:
const targetOrigin = url ? new URL(url).origin : '*' // a scheme-upgraded frame (HSTS, or any future upgrade) makes the
source.postMessage({ type: 'nostr-response', id, result }, targetOrigin) // stored http:// URL a stale targetOrigin — postMessage then throws
// and the app never receives its response. nostr sign-in on IndeeHub
// over HTTPS died exactly there (2026-09-01).
source.postMessage({ type: 'nostr-response', id, result }, event.origin || '*')
} catch (err) { } catch (err) {
if (import.meta.env.DEV) console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err) if (import.meta.env.DEV) console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
const url = getAppUrl() source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, event.origin || '*')
const targetOrigin = url ? new URL(url).origin : '*'
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, targetOrigin)
} }
} }
+2 -4
View File
@@ -44,10 +44,8 @@
class="tier-badge" class="tier-badge"
:class="tier === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'" :class="tier === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
>{{ tier }}</span> >{{ tier }}</span>
<span <!-- Update state is the bottom-left button only: a second pill up
v-if="pkg['available-update']" here duplicated the signal and crowded the title. -->
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-orange-500/20 text-orange-300 border border-orange-500/30"
>Update</span>
</div> </div>
<p class="text-sm text-white/50">{{ version ? $ver(version) : '' }}</p> <p class="text-sm text-white/50">{{ version ? $ver(version) : '' }}</p>
<p v-if="author" class="text-xs text-white/40 mt-0.5">{{ author }}</p> <p v-if="author" class="text-xs text-white/40 mt-0.5">{{ author }}</p>
+13 -34
View File
@@ -6,33 +6,17 @@ import { PackageState, type PackageDataEntry } from '@/types/api'
import { matchPageScheme, resolveAppUrl } from '../appSession/appSessionConfig' import { matchPageScheme, resolveAppUrl } from '../appSession/appSessionConfig'
import { portIsGateFronted } from '../discover/curatedApps' import { portIsGateFronted } from '../discover/curatedApps'
import { isAutoTabApp } from '@/utils/autoTabApps' import { isAutoTabApp } from '@/utils/autoTabApps'
import {
SERVICE_NAMES as SHARED_SERVICE_NAMES,
isServiceContainer as sharedIsServiceContainer,
} from './serviceNames'
export type AppsTab = 'apps' | 'websites' | 'services' export type AppsTab = 'apps' | 'websites' | 'services'
// Service container name patterns (backend/infra, not user-facing) // Re-exported for every existing caller — the canon moved to serviceNames.ts
export const SERVICE_NAMES = new Set([ // so the App Store's catalog merge can share it without a circular import.
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor', export const SERVICE_NAMES = SHARED_SERVICE_NAMES
// Headless backends with no user-facing UI: the Fedimint ecash client daemon, export const isServiceContainer = sharedIsServiceContainer
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
// built-in Mesh tab) belong in Services, not My Apps.
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
'immich_postgres', 'immich_redis',
// immich is now a manifest-driven stack (app_id-named, hyphen). The server is
// the launcher app; postgres/redis are backends → Services.
'immich-postgres', 'immich-redis',
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
'indeedhub-api', 'indeedhub-ffmpeg',
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
// Pine voice-assistant stack: the two Wyoming engines are backends (STT/TTS)
// reached by Home Assistant over host.containers.internal — the user-facing
// card is "pine" (the setup/status launcher), so the engines go to Services.
'pine-whisper', 'pine-piper', 'pine-openwakeword',
])
const INTERNAL_TOOLING_NAMES = new Set([ const INTERNAL_TOOLING_NAMES = new Set([
'buildx_buildkit_default', 'buildx_buildkit_default',
@@ -43,16 +27,7 @@ export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): bo
return INTERNAL_TOOLING_NAMES.has(id) || INTERNAL_TOOLING_NAMES.has(manifestId) || id.startsWith('buildx_buildkit') || manifestId.startsWith('buildx_buildkit') return INTERNAL_TOOLING_NAMES.has(id) || INTERNAL_TOOLING_NAMES.has(manifestId) || id.startsWith('buildx_buildkit') || manifestId.startsWith('buildx_buildkit')
} }
export function isServiceContainer(id: string): boolean { // isServiceContainer now re-exports the shared canon (serviceNames.ts).
if (SERVICE_NAMES.has(id)) return true
if (id.startsWith('indeedhub-build_')) return true
if (id.startsWith('archy-')) return true
// Backend naming patterns that never carry a user-facing UI: databases and
// caches. Safe to classify by suffix (a database is never a launcher).
if (/-(db|postgres|postgresql|redis|valkey|mariadb|mysql|cache)$/.test(id)) return true
if (id.endsWith('_db')) return true
return false
}
export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean { export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
if (isServiceContainer(id)) return true if (isServiceContainer(id)) return true
@@ -63,6 +38,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
// Known app -> category mappings (matches App Store categorisation) // Known app -> category mappings (matches App Store categorisation)
export const APP_CATEGORY_MAP: Record<string, string> = { export const APP_CATEGORY_MAP: Record<string, string> = {
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money', 'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'cuprate': 'money', 'cuprate-ui': 'money',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce', 'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
'fedimint': 'money', 'fedimint-gateway': 'money', 'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media', 'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
@@ -212,6 +188,9 @@ const APP_ICON_FALLBACKS: Record<string, string> = {
'archy-bitcoin-ui': '/assets/img/app-icons/bitcoin-knots.webp', 'archy-bitcoin-ui': '/assets/img/app-icons/bitcoin-knots.webp',
'archy-lnd-ui': '/assets/img/app-icons/lnd.png', 'archy-lnd-ui': '/assets/img/app-icons/lnd.png',
'archy-electrs-ui': '/assets/img/app-icons/electrumx.png', 'archy-electrs-ui': '/assets/img/app-icons/electrumx.png',
// Cuprate's companion dashboard shares the node's icon.
'cuprate-ui': '/assets/img/app-icons/cuprate.svg',
'archy-cuprate-ui': '/assets/img/app-icons/cuprate.svg',
// ElectrumX ships under a few historical ids (the backend was renamed // ElectrumX ships under a few historical ids (the backend was renamed
// electrs → electrumx). Pin the whole family to the ElectrumX icon so My // electrs → electrumx). Pin the whole family to the ElectrumX icon so My
// Apps shows the right logo no matter which id the node has it installed // Apps shows the right logo no matter which id the node has it installed
+62
View File
@@ -0,0 +1,62 @@
/**
* Canonical internal/service app-id classification — the shared source for
* every surface that must decide "is this a user-facing app or a component
* of one": the My Apps/Services tabs (appsConfig) and the App Store listing
* (the signed-catalog merge in discover/curatedApps must not publish
* components as standalone store apps).
*
* Keep this module dependency-free: it is imported from both the apps view
* and the catalog module (which must not import view code that reads the
* store — circulars).
*/
// Service container ids (backend/infra, not user-facing). Mirrors the
// SERVICE_NAMES set that used to live in appsConfig.ts verbatim.
export const SERVICE_NAMES = new Set([
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
// built-in Mesh tab) belong in Services, not My Apps.
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
'immich_postgres', 'immich_redis',
// immich is now a manifest-driven stack (app_id-named, hyphen). The server is
// the launcher app; postgres/redis are backends → Services.
'immich-postgres', 'immich-redis',
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
'indeedhub-api', 'indeedhub-ffmpeg',
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
// Pine voice-assistant stack: the two Wyoming engines are backends (STT/TTS)
// reached by Home Assistant over host.containers.internal — the user-facing
// card is "pine" (the setup/status launcher), so the engines go to Services.
'pine-whisper', 'pine-piper', 'pine-openwakeword',
])
// Node-bundled internals that are real manifests (so they surface in the
// signed catalog's manifest list) but are not store apps: the assistant and
// the bundled relay/UIs ship with the node itself, the DWN is internal
// plumbing, and the mesh router + CLN are components of other surfaces.
export const NODE_INTERNAL_IDS = new Set([
'aiui', 'fips-ui', 'strfry', 'web5-dwn', 'router', 'core-lightning',
])
/** Is this id a component/backend rather than a standalone user app? */
export function isServiceContainer(id: string): boolean {
if (SERVICE_NAMES.has(id)) return true
if (id.startsWith('indeedhub-build_')) return true
if (id.startsWith('archy-')) return true
// Backend naming patterns that never carry a user-facing UI: databases and
// caches. Safe to classify by suffix (a database is never a launcher).
if (/-(db|postgres|postgresql|redis|valkey|mariadb|mysql|cache)$/.test(id)) return true
if (id.endsWith('_db')) return true
return false
}
/** Should this app id appear in the App Store listing at all? */
export function isStoreListedApp(id: string): boolean {
return !isServiceContainer(id) && !NODE_INTERNAL_IDS.has(id)
}
+1 -1
View File
@@ -40,7 +40,7 @@
v-if="app.icon" v-if="app.icon"
:src="app.icon" :src="app.icon"
:alt="app.title" :alt="app.title"
class="w-14 h-14 rounded-lg object-cover" class="w-14 h-14 rounded-lg archy-app-icon"
@error="handleImageError" @error="handleImageError"
/> />
<div v-else class="w-14 h-14 rounded-lg bg-white/10 flex items-center justify-center"> <div v-else class="w-14 h-14 rounded-lg bg-white/10 flex items-center justify-center">
+1 -1
View File
@@ -24,7 +24,7 @@
v-if="app.icon" v-if="app.icon"
:src="app.icon" :src="app.icon"
:alt="app.title" :alt="app.title"
class="w-20 h-20 rounded-xl object-cover flex-shrink-0" class="w-20 h-20 rounded-xl archy-app-icon flex-shrink-0"
@error="handleImageError" @error="handleImageError"
/> />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it } from 'vitest'
import { __setSignedCatalogForTests, portAuth, portIsGateFronted, type SignedAppCatalog } from '../curatedApps'
/** Catalog fragments mirroring the live signed catalog's port declarations
* (releases/app-catalog.json, 2026-09-01). */
const catalog = (apps: SignedAppCatalog['apps']): SignedAppCatalog => ({ apps })
const FULL = catalog({
'archy-mempool-web': {
version: '3.0.1',
manifest: { app: { id: 'archy-mempool-web', ports: [{ host: 4080, container: 8080, auth: 'gated' }] } },
},
'mempool': {
version: '3.0.0',
manifest: { app: { id: 'mempool', ports: [{ host: 4080, container: 8080, auth: 'gated' }] } },
},
'lnd-ui': {
version: '1.0.0',
manifest: { app: { id: 'lnd-ui', ports: [{ host: 18083, container: 18083, auth: 'gated' }] } },
},
'bitcoin-ui': {
version: '1.0.0',
manifest: { app: { id: 'bitcoin-ui', ports: [{ host: 8334, container: 8334, auth: 'gated' }] } },
},
'bitcoin-knots': {
version: '29.3',
manifest: { app: { id: 'bitcoin-knots', ports: [{ host: 8332, container: 8332, auth: 'none' }] } },
},
'electrs-ui': {
version: '1.0.0',
manifest: { app: { id: 'electrs-ui', ports: [{ host: 50002, container: 50002, auth: 'gated' }] } },
},
})
afterEach(() => __setSignedCatalogForTests(null))
describe('portAuth', () => {
it('resolves the UI port through the launch alias, not just the app id', () => {
__setSignedCatalogForTests(FULL)
// 'mempool-web' has no catalog entry of its own; archy-mempool-web owns 4080.
expect(portIsGateFronted('mempool-web', 4080)).toBe(true)
// 'bitcoin-knots' declares 8332 (auth none) but its UI port 8334 is owned
// by bitcoin-ui — the alias must find it, or the new-tab button hands
// out an http:// URL on an HTTPS dashboard (2026-09-01 report).
expect(portIsGateFronted('bitcoin-knots', 8334)).toBe(true)
expect(portIsGateFronted('lnd', 18083)).toBe(true)
expect(portIsGateFronted('electrs', 50002)).toBe(true)
})
it('keeps a port the app itself publishes as plain HTTP off the gate', () => {
__setSignedCatalogForTests(FULL)
expect(portAuth('bitcoin-knots', 8332)).toBe('none')
expect(portIsGateFronted('bitcoin-knots', 8332)).toBe(false)
})
it('answers null for unknown apps and ports (never assume TLS)', () => {
__setSignedCatalogForTests(FULL)
expect(portAuth('never-installed-app', 1234)).toBeNull()
expect(portIsGateFronted('bitcoin-ui', 9999)).toBe(false)
})
it('falls back to a unanimous port-wide scan for unknown ids', () => {
__setSignedCatalogForTests(FULL)
// No alias for this id, but every declarer of 4080 says gated.
expect(portIsGateFronted('some-future-alias', 4080)).toBe(true)
})
it('refuses the port-wide scan when declarers disagree (no TLS guess)', () => {
__setSignedCatalogForTests(catalog({
'app-a': { version: '1', manifest: { app: { ports: [{ host: 7000, auth: 'gated' }] } } },
'app-b': { version: '1', manifest: { app: { ports: [{ host: 7000, auth: 'none' }] } } },
}))
expect(portAuth('unknown-app', 7000)).toBeNull()
})
it('returns null without a warmed catalog (cache miss, not a guess)', () => {
expect(portAuth('mempool-web', 4080)).toBeNull()
})
})
+59 -7
View File
@@ -1,4 +1,5 @@
import type { MarketplaceApp } from './types' import type { MarketplaceApp } from './types'
import { isStoreListedApp } from '../apps/serviceNames'
const R = 'source.archipelago-foundation.org/lfg2025' const R = 'source.archipelago-foundation.org/lfg2025'
@@ -48,10 +49,15 @@ export interface SignedAppEntry {
} }
/** Convert the signed catalog's keyed entries into store-listing apps. /** Convert the signed catalog's keyed entries into store-listing apps.
* Pure — unit-tested against the live catalog's shape (Cuprate). */ * Pure — unit-tested against the live catalog's shape (Cuprate).
* Components and node internals never enter the listing: the signed catalog
* carries every manifest (including backends like mempool-api and the
* bundled UIs) because the node's update layer needs their pins, but the
* App Store publishes apps, not parts of apps. */
export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[] { export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[] {
const out: MarketplaceApp[] = [] const out: MarketplaceApp[] = []
for (const [id, entry] of Object.entries(catalog.apps || {})) { for (const [id, entry] of Object.entries(catalog.apps || {})) {
if (!isStoreListedApp(id)) continue
const app = entry.manifest?.app const app = entry.manifest?.app
out.push({ out.push({
id, id,
@@ -73,15 +79,62 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
* after fetchAppCatalog() has run. Test-hookable. */ * after fetchAppCatalog() has run. Test-hookable. */
let signedCatalogCache: SignedAppCatalog | null = null let signedCatalogCache: SignedAppCatalog | null = null
/** Launch aliases → the catalog app id that OWNS the UI port.
*
* The launcher knows apps by several historical names (`mempool-web`, `lnd`,
* `electrs`…); the signed catalog knows them by manifest id. Without this
* map the port-auth lookup below misses, `portIsGateFronted` answers false,
* and an HTTPS dashboard hands the app session an http:// frame URL — which
* the browser then blocks outright as mixed content. That is exactly how
* Mempool and IndeeHub “did not connect” over HTTPS while working fine over
* HTTP (2026-09-01). */
const CATALOG_APP_ID_ALIASES: Record<string, string> = {
'mempool-web': 'archy-mempool-web',
'mempool-electrs': 'electrs-ui',
'electrs': 'electrs-ui',
'archy-electrs-ui': 'electrs-ui',
'lnd': 'lnd-ui',
'archy-lnd-ui': 'lnd-ui',
'bitcoin-knots': 'bitcoin-ui',
'bitcoin-core': 'bitcoin-ui',
'cuprate': 'cuprate-ui',
'archy-cuprate-ui': 'cuprate-ui',
'fedimintd': 'fedimint',
'immich_server': 'immich',
}
/** Port auth for an app's host port, from the signed catalog's embedded /** Port auth for an app's host port, from the signed catalog's embedded
* manifest. `gated`/`open` = the node's app gate owns the port and serves * manifest. `gated`/`open` = the node's app gate owns the port and serves
* TLS on it; `none`/`local` = container-published plain HTTP; null = app * TLS on it; `none`/`local` = container-published plain HTTP; null = app
* unknown to the signed catalog (legacy curated installs). */ * unknown to the signed catalog (legacy curated installs).
*
* Resolution order: the app's own manifest, then its alias (the manifest
* that actually owns the UI port), then — only for ports no known id
* declares — a port-wide scan of the catalog. The scan must be UNANIMOUS:
* a host port that any app publishes as plain HTTP (`none`) must never be
* answered `gated`, or an https frame URL would point at a port that never
* serves TLS. */
export function portAuth(appId: string, hostPort: number | string): string | null { export function portAuth(appId: string, hostPort: number | string): string | null {
const ports = signedCatalogCache?.apps?.[appId]?.manifest?.app?.ports const apps = signedCatalogCache?.apps
if (!Array.isArray(ports)) return null if (!apps) return null
const hit = ports.find(p => String(p.host) === String(hostPort)) const alias: string | undefined = CATALOG_APP_ID_ALIASES[appId]
return hit?.auth ?? null const ids: string[] = alias === undefined || alias === appId ? [appId] : [appId, alias]
for (const id of ids) {
const ports = apps[id]?.manifest?.app?.ports
if (!Array.isArray(ports)) continue
const hit = ports.find(p => String(p.host) === String(hostPort))
if (hit?.auth) return hit.auth
}
let found: string | null = null
for (const entry of Object.values(apps)) {
const ports = entry?.manifest?.app?.ports
if (!Array.isArray(ports)) continue
const hit = ports.find(p => String(p.host) === String(hostPort))
if (!hit?.auth) continue
if (found === null) found = hit.auth
else if (found !== hit.auth) return null
}
return found
} }
/** Whether an app's host port is fronted by the node's app gate (and so /** Whether an app's host port is fronted by the node's app gate (and so
@@ -200,7 +253,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'grafana', title: 'Grafana', version: '10.2.0', description: 'Analytics and monitoring platform. Dashboards for your node metrics and system health.', icon: '/assets/img/app-icons/grafana.png', author: 'Grafana Labs', dockerImage: `${R}/grafana:10.2.0`, repoUrl: 'https://github.com/grafana/grafana' }, { id: 'grafana', title: 'Grafana', version: '10.2.0', description: 'Analytics and monitoring platform. Dashboards for your node metrics and system health.', icon: '/assets/img/app-icons/grafana.png', author: 'Grafana Labs', dockerImage: `${R}/grafana:10.2.0`, repoUrl: 'https://github.com/grafana/grafana' },
{ id: 'searxng', title: 'SearXNG', version: '2024.1.0', description: 'Privacy-respecting metasearch engine. Search the internet without being tracked or profiled.', icon: '/assets/img/app-icons/searxng.png', author: 'SearXNG', dockerImage: `${R}/searxng:latest`, repoUrl: 'https://github.com/searxng/searxng' }, { id: 'searxng', title: 'SearXNG', version: '2024.1.0', description: 'Privacy-respecting metasearch engine. Search the internet without being tracked or profiled.', icon: '/assets/img/app-icons/searxng.png', author: 'SearXNG', dockerImage: `${R}/searxng:latest`, repoUrl: 'https://github.com/searxng/searxng' },
{ id: 'ollama', title: 'Ollama', version: '0.5.4', description: 'Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.', icon: '/assets/img/app-icons/ollama.png', author: 'Ollama', dockerImage: `${R}/ollama:latest`, repoUrl: 'https://github.com/ollama/ollama' }, { id: 'ollama', title: 'Ollama', version: '0.5.4', description: 'Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.', icon: '/assets/img/app-icons/ollama.png', author: 'Ollama', dockerImage: `${R}/ollama:latest`, repoUrl: 'https://github.com/ollama/ollama' },
{ id: 'cryptpad', title: 'CryptPad', version: '2024.12.0', description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.', icon: '/assets/icon/favico-black-v2.svg', author: 'XWiki SAS', dockerImage: `${R}/cryptpad:2024.12.0`, repoUrl: 'https://github.com/cryptpad/cryptpad' },
{ id: 'nextcloud', title: 'Nextcloud', version: '29', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:29`, repoUrl: 'https://github.com/nextcloud/server' }, { id: 'nextcloud', title: 'Nextcloud', version: '29', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:29`, repoUrl: 'https://github.com/nextcloud/server' },
{ id: 'vaultwarden', title: 'Vaultwarden', version: '1.30.0', description: 'Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.', icon: '/assets/img/app-icons/vaultwarden.webp', author: 'Vaultwarden', dockerImage: `${R}/vaultwarden:1.30.0-alpine`, repoUrl: 'https://github.com/dani-garcia/vaultwarden' }, { id: 'vaultwarden', title: 'Vaultwarden', version: '1.30.0', description: 'Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.', icon: '/assets/img/app-icons/vaultwarden.webp', author: 'Vaultwarden', dockerImage: `${R}/vaultwarden:1.30.0-alpine`, repoUrl: 'https://github.com/dani-garcia/vaultwarden' },
{ id: 'jellyfin', title: 'Jellyfin', version: '10.8.13', description: 'Free media server. Stream your movies, music, and photos to any device.', icon: '/assets/img/app-icons/jellyfin.webp', author: 'Jellyfin', dockerImage: `${R}/jellyfin:10.8.13`, repoUrl: 'https://github.com/jellyfin/jellyfin' }, { id: 'jellyfin', title: 'Jellyfin', version: '10.8.13', description: 'Free media server. Stream your movies, music, and photos to any device.', icon: '/assets/img/app-icons/jellyfin.webp', author: 'Jellyfin', dockerImage: `${R}/jellyfin:10.8.13`, repoUrl: 'https://github.com/jellyfin/jellyfin' },
@@ -247,17 +247,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined, manifestUrl: undefined,
repoUrl: 'https://github.com/ollama/ollama' repoUrl: 'https://github.com/ollama/ollama'
}, },
{
id: 'cryptpad',
title: 'CryptPad',
version: '2024.12.0',
description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.',
icon: '/assets/icon/favico-black-v2.svg',
author: 'XWiki SAS',
dockerImage: `${REGISTRY}/cryptpad:2024.12.0`,
manifestUrl: undefined,
repoUrl: 'https://github.com/cryptpad/cryptpad'
},
{ {
id: 'nextcloud', id: 'nextcloud',
title: 'Nextcloud', title: 'Nextcloud',
+22 -16
View File
@@ -115,6 +115,24 @@ const showConnectForm = ref(false)
const connecting = ref(false) const connecting = ref(false)
const connectedParams = ref<Record<string, string> | null>(null) const connectedParams = ref<Record<string, string> | null>(null)
// Every action below (install/edit TollGate, WiFi scan, WAN configure) needs
// host/ssh_user/ssh_password to reach the router. `connectedParams` only gets
// set when the Connect form was actually submitted this session (WR-03 above)
// — on a normal page load the router reconnects via the server-persisted
// config instead, so `sshPassword`/`sshUser`/`host` (the Connect form's own
// local refs) sit at their untouched defaults ('', 'root', ''). Falling back
// to those refs here used to send an explicit-but-empty ssh_password, which
// the backend treats as "the caller provided this" and never falls back to
// the real saved password — a real router password then fails auth on every
// action even though the status poll (which sends no params at all) keeps
// working fine (archy-x250-pa3, 2026-09-05: dropbear logged one bad-password
// attempt at the exact moment "Install TollGate" was clicked). Omitting the
// fields entirely when there's no explicit connectedParams lets the backend's
// own saved-config fallback do the right thing, same as the status poll.
function authParams(): Record<string, string> {
return connectedParams.value ?? {}
}
const detecting = ref(false) const detecting = ref(false)
const detectError = ref('') const detectError = ref('')
const detectedCandidates = ref<string[]>([]) const detectedCandidates = ref<string[]>([])
@@ -271,11 +289,7 @@ async function provisionTollgate() {
provisionError.value = '' provisionError.value = ''
provisionSuccess.value = false provisionSuccess.value = false
try { try {
const params: Record<string, unknown> = { const params: Record<string, unknown> = { ...authParams() }
host: connectedParams.value?.host ?? status.value?.host,
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
}
await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 }) await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 })
provisionSuccess.value = true provisionSuccess.value = true
await load(connectedParams.value ?? undefined) await load(connectedParams.value ?? undefined)
@@ -302,9 +316,7 @@ async function saveTollgateConfig() {
updateTollgateError.value = '' updateTollgateError.value = ''
try { try {
const params: Record<string, unknown> = { const params: Record<string, unknown> = {
host: connectedParams.value?.host ?? status.value?.host, ...authParams(),
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
price_sats: editPriceSats.value, price_sats: editPriceSats.value,
step_size_ms: editStepSizeMin.value * 60_000, step_size_ms: editStepSizeMin.value * 60_000,
min_steps: editMinSteps.value, min_steps: editMinSteps.value,
@@ -336,11 +348,7 @@ async function scanWifi() {
wanStep.value = 'scanning' wanStep.value = 'scanning'
wanError.value = '' wanError.value = ''
try { try {
const params: Record<string, unknown> = { const params: Record<string, unknown> = { ...authParams() }
host: connectedParams.value?.host ?? status.value?.host,
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
}
const result = await rpcClient.call<{ networks: ScannedNetwork[] }>({ const result = await rpcClient.call<{ networks: ScannedNetwork[] }>({
method: 'openwrt.scan-wifi', method: 'openwrt.scan-wifi',
params, params,
@@ -367,9 +375,7 @@ async function configureWan() {
wanError.value = '' wanError.value = ''
try { try {
const params: Record<string, unknown> = { const params: Record<string, unknown> = {
host: connectedParams.value?.host ?? status.value?.host, ...authParams(),
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
ssid: selectedNetwork.value.ssid, ssid: selectedNetwork.value.ssid,
password: wanPassword.value, password: wanPassword.value,
encryption: selectedNetwork.value.encryption, encryption: selectedNetwork.value.encryption,
@@ -362,6 +362,59 @@ init()
</button> </button>
</div> </div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1"> <div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.8.11-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.11-alpha</span>
<span class="text-xs text-white/40">September 7, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>Cuprate now syncs without burning a core for days.</strong> The app's shipped config now enables Cuprate's checkpoint-backed fast_sync path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.</p>
<p><strong>OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.</strong> The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds opkg/apk through the router's actual PATH instead of assuming /usr/bin, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to v0.5.0 with a native .apk install path where upstream provides one.</p>
<p><strong>Release publishing now checks the public Gitea download links before a manifest goes live.</strong> The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea ROOT_URL or proxy setting cannot publish working files behind broken public HTTPS download links.</p>
</div>
</div>
<!-- v1.8.10-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.10-alpha</span>
<span class="text-xs text-white/40">September 2, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>Lightning sends work again — v1.8.9's payment switch lost the fee budget.</strong> Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as <strong>zero allowed fees</strong>: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered "No route to the recipient" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (fee_limit=0 mSAT on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.</p>
<p><strong>A channel that drops its peer link now heals itself — on every node.</strong> Restarting LND (an app update, a reboot, container churn) can leave a channel's peer connection down for hours while both endpoints keep the channel flagged disabled in the routing graph: the node looks perfectly healthy, the wallet shows balance, and every payment in either direction fails "no route to the recipient". Observed live: a node's only channel sat unroutable for ~17 hours after the LND 0.21.2 update, with no sign of it in any dashboard. The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't, using the peer's advertised addresses. Nodes without LND are untouched; an unreachable peer is retried gently, not hammered.</p>
<p><strong>The Lightning wallet states the node's real funding state instead of "you have no channel."</strong> Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had NO channel at all (the outbound sum is legitimately zero in both states), pointed the user at opening a second channel, and — for payment routing failures — even showed the <em>receiving</em> copy. The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of claiming channel problems, and only a genuinely channel-less node keeps the open-one guidance.</p>
</div>
</div>
<!-- v1.8.9-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.9-alpha</span>
<span class="text-xs text-white/40">September 1, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>Lightning sends work again after the LND 0.21.2 update.</strong> LND 0.21 removed the old synchronous payment route the node's backend paid through (/v1/channels/transactions) — every Lightning send answered the literal "Not Found" and the wallet showed "Payment failed: Not Found". The backend now pays through the supported Router.SendPaymentV2 route, keeps the same settle-then-report behaviour (a slow multi-hop payment is still tracked to completion, never falsely declared failed), and translates LND's failure reasons into plain advice. A new gate test speaks the payment route directly against the running LND, so an image/backend skew like this can never ship silently again.</p>
<p><strong>The node no longer pins HSTS — HTTP access is a supported mode, and it stays working.</strong> The HTTPS listener used to send Strict-Transport-Security: max-age=31536000; includeSubDomains; browsers that visited HTTPS once cached that and then silently upgraded the still-open HTTP dashboard's calls to HTTPS, which is a scheme change — cross-origin — so every request died as "CORS blocked / Failed to fetch" while the node was perfectly healthy. The HTTPS listener now actively clears the cached policy (max-age=0) and port 80 sends no HSTS at all, which is deliberate: the node's certificate is optional and self-signed, and devices that haven't installed the CA must keep plain-HTTP access (that's what Settings → Node certificate is for). If your browser already cached the old policy, visiting the dashboard over HTTPS once after this update clears it; a gate test now refuses any config that reintroduces the pin.</p>
<p><strong>App frames open over HTTPS again — including the ones that "did not connect."</strong> The launcher asked the signed catalog for each app's port policy under the name you click ("Mempool Web", "Bitcoin Knots"), but the catalog declares those ports under the manifest that owns them (the Mempool web container, Bitcoin UI). The lookup missed, the launcher handed the iframe an http:// address, and the browser blocked it as mixed content — the app tile went blank or spun forever. Port resolution now follows launch aliases (mempool-web, bitcoin-knots/bitcoin-core, lnd, electrs and friends), falls back to a port-wide catalog scan when the id is unknown, and the catalog is warmed as soon as the dashboard loads rather than only in the App Store, so the very first app you open already knows which ports serve TLS.</p>
<p><strong>Signing in to IndeeHub with Nostr works over HTTPS.</strong> The NIP-07 bridge compared the app frame's origin for exact equality with the recorded http:// app URL — a frame the browser upgraded to HTTPS (or any scheme change) was silently ignored, and replies addressed to the stale origin were refused outright, so Nostr sign-in quietly did nothing. The bridge now matches host and port (scheme intentionally ignored) and always replies to the frame's real origin.</p>
<p><strong>Nginx Proxy Manager starts again.</strong> Converting it to a platform manifest dropped two things its image needs: the /etc/letsencrypt mount its boot script hard-requires, and the NET_BIND_SERVICE capability its internal nginx needs to bind ports 80/443/81 under the orchestrator's --cap-drop=ALL. The result was an endless start/die loop (a node watched it restart 3,176 times). Both are declared in its manifest now, its certs live on unchanged under the same persistent app directory, and the signed catalog carries the fix so installed nodes heal on the next update.</p>
<p><strong>Portainer's first-run token is in the app page, not buried in "server logs."</strong> New Portainer versions mint a one-time setup token on a fresh install and print it only to the container logs — on an appliance that meant telling the user to go read a server log to get into their own app. The token now appears in the same launch interstitial as app login credentials (with a copy button), only while first-run setup is actually pending; once the admin account exists the card disappears on its own.</p>
<p><strong>The Lightning wallet states the node's real funding state instead of "you have no channel."</strong> Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had no channel at all (the outbound sum is legitimately zero in both states). The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of pointing at channel setup, and only a genuinely channel-less node is sent to open one.</p>
</div>
</div>
<!-- v1.8.8-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.8-alpha</span>
<span class="text-xs text-white/40">September 1, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>SSH over the mesh is now a first-class setting.</strong> Settings gains an "SSH over mesh" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit "I understand" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the 90-ssh.nft drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, fipssh &lt;user&gt;@&lt;node-npub&gt; connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.</p>
<p><strong>The App Store now lists apps — not parts of apps.</strong> The signed catalog carries every manifest because the node's update layer needs their pins, and the store briefly listed them all: Mempool API, LND UI, Bitcoin UI, the Pine voice engines, the IndeeHub and Immich backends, the mesh router and friends. Components are hidden from the store listing (they still appear where they belong — the Services tab of My Apps, once installed), and four entries that never earned a tile are gone outright: MorphOS server (old), the Web5 DID wallet, Lightning Stack (an untracked upstream bundle — LND covers the need), and CryptPad (never tested).</p>
<p><strong>App icons now persist everywhere, in the proper container style.</strong> Two fixes: installed apps render the icon from their own manifest — Cuprate no longer falls back to the generic A-mark on its Services tile — and the store grids (the Discover page) apply the same icon container treatment (backdrop, border, shadow) as My Apps, the detail pages, and Home. Manifest-declared UI apps also classify correctly again: Alby Hub installs into My Apps with a working tile, not into Services, because a probe miss no longer buries an app the manifest itself says has a frontend.</p>
<p><strong>Installing from the store keeps you on the store page.</strong> The install progress lives on the tile itself and the app appears in My Apps when it lands — no more being yanked to My Apps mid-browse.</p>
</div>
</div>
<!-- v1.8.7-alpha --> <!-- v1.8.7-alpha -->
<div> <div>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useToast } from '@/composables/useToast'
interface SshOverMeshStatus {
enabled: boolean
sources: string[]
scope: 'any' | 'list'
preflights?: {
sshd_active: boolean
sshd_ipv6_listen: boolean
password_auth: boolean | null
}
applied?: boolean
reloaded?: boolean
}
const toast = useToast()
const status = ref<SshOverMeshStatus | null>(null)
const loading = ref(true)
const error = ref('')
const saving = ref(false)
const confirmAnyPeer = ref(false)
const pendingEnableAny = ref(false)
const sourcesText = ref('')
const fipsNpub = ref<string | null>(null)
async function refresh() {
loading.value = true
error.value = ''
try {
status.value = (await rpcClient.call({ method: 'fips.ssh-over-mesh.get' })) as SshOverMeshStatus
sourcesText.value = (status.value.sources || []).join('\n')
if (!fipsNpub.value) {
try {
const s = (await rpcClient.call<{ npub?: string }>({ method: 'fips.status', dedup: true, maxRetries: 1 }))
fipsNpub.value = s.npub ?? null
} catch { /* npub hint is optional */ }
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load SSH-over-mesh state'
} finally {
loading.value = false
}
}
/** The unrestricted scope opens port 22 to every mesh peer — demand an
* explicit confirmation before applying it, like the other danger zones. */
async function toggle(enabled: boolean) {
const sources = enabled && status.value?.scope === 'list'
? sourcesText.value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean)
: (status.value?.sources ?? [])
if (enabled && sources.length === 0) {
pendingEnableAny.value = true
confirmAnyPeer.value = true
return
}
await apply(enabled, sources)
}
async function apply(enabled: boolean, sources: string[]) {
saving.value = true
try {
status.value = (await rpcClient.call({
method: 'fips.ssh-over-mesh.set',
params: { enabled, sources },
timeout: 30_000,
})) as SshOverMeshStatus
sourcesText.value = (status.value.sources || []).join('\n')
toast.success(enabled ? 'SSH over mesh allowed' : 'SSH over mesh blocked')
if (enabled && status.value.reloaded === false) {
toast.error('Rule saved, but the firewall reload failed — reconnect or check logs')
}
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to apply')
} finally {
saving.value = false
pendingEnableAny.value = false
confirmAnyPeer.value = false
}
}
function copyFipssh() {
if (!fipsNpub.value) return
const cmd = `fipssh <user>@${fipsNpub.value}`
navigator.clipboard?.writeText(cmd).then(
() => toast.success('Copied: ' + cmd),
() => toast.error('Copy failed'),
)
}
onMounted(refresh)
</script>
<template>
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-start justify-between gap-4 mb-2">
<div>
<h2 class="text-xl font-semibold text-white/96">SSH over mesh</h2>
<p class="text-sm text-white/60 mt-1">
Let the phone's FIPS mesh reach this node's SSH (port 22). Off by default — the mesh
firewall refuses SSH until you allow it here.
</p>
</div>
<button
class="path-action-button path-action-button--continue px-4 py-2 text-sm shrink-0"
:disabled="saving || loading"
:class="{ '!bg-orange-500/30 !border-orange-400/50': status?.enabled }"
@click="toggle(!status?.enabled)"
>
{{ status?.enabled ? 'Allowed — block again' : 'Allow SSH over mesh' }}
</button>
</div>
<div v-if="loading" class="text-sm text-white/50 py-3">Loading…</div>
<div v-else-if="error" class="text-sm text-red-300 py-3">{{ error }}
<button class="ml-2 underline" @click="refresh">Retry</button>
</div>
<template v-else-if="status">
<!-- Preflights: explain the toggle rather than gating it -->
<div class="mt-3 space-y-1.5 text-sm">
<div class="flex items-center gap-2">
<span :class="status.preflights?.sshd_active ? 'text-green-400' : 'text-orange-300'">●</span>
<span class="text-white/80">sshd {{ status.preflights?.sshd_active ? 'is running' : 'is NOT running' }}</span>
</div>
<div class="flex items-center gap-2">
<span :class="status.preflights?.sshd_ipv6_listen ? 'text-green-400' : 'text-orange-300'">●</span>
<span class="text-white/80">
{{ status.preflights?.sshd_ipv6_listen ? 'listens on IPv6 (mesh-reachable)' : 'does not listen on IPv6 — the mesh cannot reach it' }}
</span>
</div>
<div class="flex items-center gap-2">
<span :class="status.preflights?.password_auth === false ? 'text-green-400' : 'text-orange-300'">●</span>
<span class="text-white/80">
{{ status.preflights?.password_auth == null
? 'PasswordAuthentication unknown'
: status.preflights?.password_auth
? 'password login allowed — keys-only is the safer pairing for the firewall rule'
: 'keys-only login (recommended)' }}
</span>
</div>
</div>
<!-- Source restriction -->
<div class="mt-4">
<p class="text-xs text-white/50 mb-1">Restrict to specific mesh addresses (one per line), or leave empty to allow any mesh peer.</p>
<textarea
v-model="sourcesText"
rows="2"
class="w-full bg-white/10 border border-white/20 rounded-lg p-2 text-sm text-white/90 font-mono placeholder-white/30"
placeholder="fd79:… phone mesh address"
:disabled="!status.enabled || saving"
@change="apply(true, sourcesText.split(/[\n,]+/).map(s => s.trim()).filter(Boolean))"
/>
</div>
<!-- Phone-side hint: the npub is the durable address -->
<div v-if="status.enabled && fipsNpub" class="mt-3 flex items-center gap-2 text-sm">
<span class="text-white/60">From Termux:</span>
<code class="text-orange-200 truncate max-w-[55%]">fipssh &lt;user&gt;@{{ fipsNpub.slice(0, 12) }}…</code>
<button class="glass-button px-2 py-1 text-xs" @click="copyFipssh">Copy</button>
</div>
</template>
<!-- Danger-zone confirmation for the unrestricted scope -->
<Teleport to="body">
<div v-if="confirmAnyPeer" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="confirmAnyPeer = false">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="glass-card p-6 max-w-md w-full relative z-10">
<h3 class="text-lg font-semibold text-white mb-3">Allow SSH from ANY mesh peer?</h3>
<p class="text-sm text-white/70 mb-4">
Anyone who can route to this node over the FIPS mesh will reach port 22. Restricting
to your phone's mesh address above is the safer pairing. Only continue if you
understand port 22 will face every mesh peer.
</p>
<div class="flex gap-3">
<button class="flex-1 glass-button px-4 py-2 text-sm" @click="confirmAnyPeer = false">Restrict instead</button>
<button
class="flex-1 px-4 py-2 rounded-lg text-sm border border-orange-400/50 bg-orange-500/20 text-orange-200 hover:bg-orange-500/30"
:disabled="saving || pendingEnableAny === false"
@click="apply(true, [])"
>I understand — allow any</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
@@ -6,6 +6,7 @@ import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
import RoutstrBudgetSection from '@/views/settings/RoutstrBudgetSection.vue' import RoutstrBudgetSection from '@/views/settings/RoutstrBudgetSection.vue'
import WebhookSection from '@/views/settings/WebhookSection.vue' import WebhookSection from '@/views/settings/WebhookSection.vue'
import TelemetrySection from '@/views/settings/TelemetrySection.vue' import TelemetrySection from '@/views/settings/TelemetrySection.vue'
import SshOverMeshCard from '@/views/settings/SshOverMeshCard.vue'
import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue' import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue'
import LightningCredentialsSection from '@/views/settings/LightningCredentialsSection.vue' import LightningCredentialsSection from '@/views/settings/LightningCredentialsSection.vue'
import BackupSection from '@/views/settings/BackupSection.vue' import BackupSection from '@/views/settings/BackupSection.vue'
@@ -20,6 +21,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
<AIDataAccessSection /> <AIDataAccessSection />
<WebhookSection /> <WebhookSection />
<TelemetrySection /> <TelemetrySection />
<SshOverMeshCard />
<NodeCertificateSection /> <NodeCertificateSection />
<LightningCredentialsSection /> <LightningCredentialsSection />
<BackupSection /> <BackupSection />

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