Compare commits

..
Author SHA1 Message Date
DorianandClaude Opus 4.7 85417de952 release(v1.7.40-alpha): fix tarball root perms at source so OTA can't 500 again
v1.7.38 and v1.7.39 both shipped with `./` inside the frontend tarball marked
drwx------ (700). Tar extraction preserves archive perms, so every node that
pulled the OTA landed with /opt/archipelago/web-ui at 700, nginx (www-data)
returned 500 "permission denied" on every page, and the browser showed
"Internal Server Error nginx". .116 hit this on both v1.7.38 and v1.7.39
rollouts. The v1.7.39 runtime self-heal in main.rs was the wrong layer —
systemd's ReadOnlyPaths namespace made /opt/archipelago read-only from inside
the archipelago service, so chmod from there returned EROFS.

Root cause: create-release-manifest.sh used mktemp -d (700 default umask) for
staging, then tar preserved that 700 in the archive's root entry.

Fix the archive itself:
- chmod 755 staging dir + `find -type d -exec chmod 755` + `-type f chmod 644`
  before tar, so the on-disk entries are correct.
- tar --owner=0 --group=0 --mode='u=rwX,go=rX' to normalize archive perms
  belt-and-braces in case file-mode drift ever reappears.
- Post-tar verify: `tar tvzf | head -1` must show drwxr-xr-x at root, or
  the release script aborts before the manifest is even generated.

Binary unchanged semantically — the main.rs self-heal stays in as a last-
resort belt (can't hurt on nodes whose FS isn't namespace-isolated), and the
update.rs in-extractor chmod stays in so v1.7.40-onwards extractors are
double-safe. The authoritative fix is the archive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:54:44 -04:00
DorianandClaude Opus 4.7 b8d084368e release(v1.7.39-alpha): hotfix web-ui perms after OTA (nginx 500) + startup self-heal
v1.7.38 shipped with an OTA bug: the tar-extracted staging dir inherited 700
perms and nginx (www-data) returned 500/403 on every request after the swap.
.116 hit this on rollout; had to chmod by hand to recover.

- update.rs: after extraction, explicitly chmod 755 dirs + 644 files on the
  new staging dir before the mv into place, so nginx can stat/serve them.
- main.rs: self-heal on startup — if /opt/archipelago/web-ui is not
  world-readable, run `sudo chmod -R u=rwX,go=rX` to repair. This is what
  rescues nodes upgrading from v1.7.37/v1.7.38, since their extractor
  (running on the old binary) doesn't have the chmod fix yet — the new
  binary's first boot fixes the mess before nginx serves a single request.

Everything v1.7.38 shipped is still in this release:
- auth.rs auto-heals is_onboarding_complete() from setup_complete +
  password_hash so nodes don't bounce back to /onboarding/intro after
  browser clear / reboot / update
- useOnboarding tri-state: backend-unreachable no longer defaults to intro
- login sounds gated by isFirstInstallPhase() — silent after onboarding,
  typing sounds unaffected
- FIPS app / Nostr Relay / Nostr VPN / Routstr / Penpot removed from
  catalog + frontend + Rust + docker + icons; 15 image versions deleted
  from tx1138, .168, gitea-local
- AIUI baked into release tarball via demo/aiui/
- prebuild hook syncs app-catalog/catalog.json → public/catalog.json

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:26:54 -04:00
DorianandClaude Opus 4.7 36a6101026 release(v1.7.38-alpha): onboarding auto-heal + silent returning logins + app-store trim
- auth.rs now infers onboarding-complete from setup_complete + password_hash so
  nodes stop bouncing users through the intro wizard after browser clear / update
  / reboot; the flag self-heals to disk on next check
- frontend: "backend uncertain" no longer defaults to /onboarding/intro —
  useOnboarding returns null + callers poll / retry instead of flashing the wizard
- login sounds (synthwave, welcome voice, pop, whoosh, oomph) gated by
  isFirstInstallPhase(); typing sounds unaffected
- removed FIPS app, Nostr Relay, Nostr VPN, Routstr, Penpot from catalog,
  frontend config, Rust AppMetadata + install dispatch + install_penpot_stack;
  docker/fips-ui + docker/nostr-vpn-ui + apps/penpot dirs and 5 icons deleted;
  15 image versions deleted from tx1138, .168, gitea-local registries (.160
  Gitea was 502 at release time — follow-up)
- AIUI baked into frontend release tarball via demo/aiui/; deploy-to-target
  falls back to demo/aiui/ when the AIUI sibling checkout is missing
- prebuild hook syncs app-catalog/catalog.json → public/catalog.json so the
  two copies can no longer drift (was the source of the "apps still visible"
  bug — public/ had stale data)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:02:24 -04:00
DorianandClaude Opus 4.7 cfc98c600e release(v1.7.37-alpha): bitcoin-core install fixes + dynamic node UI + full-archive default
Install flow
- api/rpc/package/install.rs: always append the literal image URL as a
  last-resort pull candidate in do_pull_image, so images not carried by
  any configured mirror (docker.io/bitcoin/bitcoin:28.4) still install
  instead of masquerading as a generic pull failure across every mirror.
- api/rpc/package/install.rs: write_bitcoin_conf now skips on any stat
  error, not just "file exists". Once bitcoin-knots' first-boot chowns
  /var/lib/archipelago/bitcoin into the container's user namespace (700
  perms, UID 100100/100101), the archipelago daemon can't even traverse
  in — try_exists returns Err which unwrap_or(false) treated as "not
  present" and drove a doomed write. Now errors out of the directory
  traversal are treated as "conf already owned by container user" and
  the write is skipped. Mirrors the lnd.conf pattern.
- api/rpc/package/install.rs: drop the hardcoded `prune=550` from the
  conf default. Operators with multi-TB drives shouldn't be silently
  pruned; users who want a pruned node can set it in bitcoin.conf
  themselves. Full archive is the only honest default.
- api/rpc/package/config.rs: bitcoin-core now passes explicit
  -server/-rpcbind/-rpcallowip/-rpcport/-printtoconsole/-datadir CLI
  args. Vanilla bitcoin/bitcoin:28.4 has no entrypoint wrapper and
  reads conf + argv only; without these the RPC listens on 127.0.0.1
  inside the container and rootlessport can't reach it, so the
  bitcoin-ui companion gets 502 on every /bitcoin-rpc/ call.
  Bitcoin Knots keeps its own entrypoint-driven defaults.
- container/docker_packages.rs: split bitcoin-core out of the shared
  AppMetadata arm. bitcoin-core now surfaces as "Bitcoin Core" with
  bitcoin-core.svg and a Reference-implementation description; the
  bitcoin + bitcoin-knots ids keep the Knots branding. Fixes the home
  card showing "Bitcoin Knots" for a Core install.

Bitcoin node UI (docker/bitcoin-ui)
- index.html: impl name/tagline/logo now dynamic. applyImplBranding()
  reads subversion from getnetworkinfo — /Satoshi:X/Knots:Y/ resolves
  to Bitcoin Knots, plain /Satoshi:X/ resolves to Bitcoin Core. Both
  get their own icon and subtitle. Settings modal replaced its
  hardcoded Regtest/txindex=1/port-18443 placeholders with live values
  from getblockchaininfo + getindexinfo + getzmqnotifications.
- index.html: new Storage info card (Full Archive · X GB /
  Pruned · X GB from blockchainInfo.pruned + size_on_disk) visible on
  the main dashboard, same level as Network. Settings modal mirrors it
  with the prune height when applicable.
- Dockerfile + assets/: bitcoin-core.svg, bitcoin-knots.webp, and the
  bg-network.jpg used by the dashboard are now COPY'd into the image
  under /usr/share/nginx/html/assets. Previously the <img src> pointed
  at paths that 404'd into the SPA fallback and the onerror handler
  hid the broken logo silently.

Frontend
- appSession/appSessionConfig.ts: add bitcoin-core to APP_PORTS (8334),
  HTTPS_PROXY_PATHS (/app/bitcoin-ui/), and APP_TITLES (Bitcoin Core).
  Without these the AppSessionFrame showed "No URL found for
  bitcoin-core" and the home/app-list title fell through to the raw id.
- settings/AccountInfoSection.vue: backfill What's New entries for
  v1.7.31 through v1.7.37 that had been missed in earlier cuts.

Release plumbing
- releases/v1.7.37-alpha/: binary + frontend tarball.
- releases/manifest.json: v1.7.37-alpha, sha256/size refreshed.
- Cargo.toml / package.json: version bumps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:03:47 -04:00
DorianandClaude Opus 4.7 e206e1fc94 fix(catalog): prefix bitcoin-core image with docker.io/ so the install validator accepts it
The trusted-registry allowlist in api/rpc/package/config.rs splits the
image on '/' and matches the first segment against a fixed set (docker.io,
ghcr.io, git.tx1138.com, 23.182.128.160:3000, ghcr.io, localhost). A bare
'bitcoin/bitcoin:28.4' splits to registry="bitcoin" which isn't on the
list, so the install RPC was returning 'Invalid Docker image format'.

Live catalogs on .160 and gitea-local already hotfixed directly; these
static copies keep ISO builds and the final hardcoded fallback in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:18:49 -04:00
DorianandClaude Opus 4.7 9cf1177b73 release(v1.7.36-alpha): bitcoin-core in App Store + Sovereignty Stack + dynamic catalog URL
- neode-ui/public/assets/img/app-icons/bitcoin-core.svg (NEW): 256×256
  Umbrel community Bitcoin icon sourced from getumbrel.github.io/
  umbrel-apps-gallery/bitcoin/icon.svg. Referenced by the static
  catalog, the curated fallback, and the upstream lfg2025/app-catalog
  entry so every surface shows the same image.
- app-catalog/catalog.json + neode-ui/public/catalog.json: add
  bitcoin-core (v28.4) entry pointing at bitcoin/bitcoin:28.4. Same
  entry pushed to the lfg2025/app-catalog repo on .160 and the local
  gitea mirror so nodes see it without needing a full archipelago
  update. Sovereignty Stack entry added to FEATURED_DEFINITIONS with
  a description that frames it as a Knots alternative, not a rival.
- core/archipelago/src/api/handler/mod.rs: handle_app_catalog_proxy
  is now instance-scoped (&self) and derives its upstream list from
  load_registries — each active container registry contributes one
  `<scheme>://<reg.url>/app-catalog/raw/branch/main/catalog.json` URL
  in priority order (scheme follows tls_verify). When the operator
  switches mirrors in Settings, the App Store now follows. Falls back
  to the legacy hardcoded .160/tx1138 pair only when registry config
  can't be loaded, so the App Store still renders on nodes that
  haven't persisted one yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:06:10 -04:00
DorianandClaude Opus 4.7 a7048f6d8e release(v1.7.35-alpha): rootless-netns self-heal + app update button + bitcoin-core 28.4 + Node DID unification
- core/archipelago/src/bootstrap.rs (NEW): embed scripts/container-doctor.sh
  and image-recipe/configs/archipelago-doctor.{service,timer} via
  include_str! and sync to disk + enable the timer on every archipelago
  startup. Idempotent (content-hash compare), dev-box symlink guard keeps
  the git checkout untouched, best-effort (warn-only on failure) so
  bootstrap never blocks server readiness. Wired in main.rs as a
  background tokio task.
- scripts/container-doctor.sh: add fix_rootless_netns_egress(). Detects
  when the rootless-netns has lost its pasta tap (container-to-container
  still works but outbound DNS/TCP fails) via an nsenter probe into
  aardvark-dns; with a two-probe 10s debounce to rule out transients and
  a host-precheck that bails out if the host itself is offline. When the
  rootless-netns is truly broken, does a graceful podman stop --all /
  start --all so pasta + aardvark-dns rebuild the netns from scratch.
  Bitcoin-knots and every other outbound container recover in one cycle.
- core/archipelago/src/update.rs: host_sudo → pub(crate) so bootstrap.rs
  can reuse the existing systemd-run escape hatch.
- apps/bitcoin-core/manifest.yml: bump app version 24.0.0 → 28.4.0 and
  image bitcoin/bitcoin:24.0 → bitcoin/bitcoin:28.4. Resources aligned
  with the real container-specs.sh large-disk tune (4 GiB memory cap,
  cpu_limit: 0 so bitcoind can run -par=auto across every core).
- neode-ui/src/views/apps/AppCard.vue + Apps.vue: add an Update button
  + Updating spinner to every app card that has available-update set.
  Wires through serverStore.updatePackage(id) — the same RPC the detail
  view already calls. common.update / common.updating i18n keys added in
  en.json and es.json.
- core/archipelago/src/identity_manager.rs: add create_from_signing_key()
  that mirrors an existing Ed25519 key as a manager-level identity with
  a deterministic id (`node-<pubkey16>`). Idempotent across restarts,
  gets the hex-SVG master avatar.
- core/archipelago/src/server.rs: the auto-create path on first boot now
  mirrors the node's own signing_key (seed-derived on onboarded installs)
  as a "Node" identity instead of generating a random "Default" keypair.
  Once this ships, the DID on the Web5 DID Status card (via node.did
  RPC), the Node entry on the Identities page (via identity.list), and
  the DID used for peer-to-peer connects (via server_info.pubkey) all
  resolve to the same seed-derived pubkey.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:29:56 -04:00
DorianandClaude Opus 4.7 06feb85aa5 release(v1.7.34-alpha): re-seed onboarding cache + rotating login bg + drop re-login zoom
- useOnboarding.ts: when the backend gives a definitive answer
  (true/false, not a null retry failure), re-seed the
  neode_onboarding_complete localStorage flag accordingly. Fixes the
  case where a user clears site data on an already-onboarded node —
  OnboardingWrapper's useVideoBackground computed reads localStorage
  synchronously, so without this re-seed the intro video would fire
  again on /login even though RootRedirect correctly sent them
  straight to /login.
- OnboardingWrapper.vue: login background now rotates through
  bg-intro-1..6 on each /login mount, with the current index
  persisted to localStorage (neode_login_bg_idx) so subsequent
  logouts advance rather than repeat the same image.
- Dashboard.vue: subsequent-login branch drops the 1.2s showZoomIn
  entirely. Only the first dashboard entry after onboarding plays
  the full zoom + glitch reveal; every re-login now just fades in
  with the welcome typing (~300ms).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 05:42:52 -04:00
DorianandClaude Opus 4.7 aa0677be57 release(v1.7.33-alpha): onboarding/login UX fixes + PWA cache bust
- useOnboarding.ts: prefer the backend over localStorage when checking
  onboarding completion. The old order (localStorage first) meant any
  browser that had ever onboarded a node would treat every new fresh
  node as already-onboarded and skip the wizard, dumping the user
  straight at the inline set-password form. Backend is now authoritative;
  localStorage stays as the offline fallback.
- OnboardingWrapper.vue: skip the intro video on `/login` once
  `neode_onboarding_complete` is set. Returning logged-out users now
  get the static lock-screen background + glitch overlay instead of
  replaying the full intro on every logout.
- RootRedirect.vue: when the health check fails, only show the full
  BootScreen if the node was never onboarded. For already-onboarded
  nodes (i.e. an OTA-update blip), keep the spinner and poll the
  health endpoint every 2s for up to 60s before falling back to the
  boot screen. Fixes the "fake boot loader" / "server starting up"
  screens flashing on every successful update.
- loginTransition store: new `justCompletedOnboarding` flag distinct
  from `justLoggedIn`. Set true only by the inline setup-password
  flow (handleSetup). Dashboard.vue branches on it: full glitch+zoom
  reveal for the post-onboarding entry, quick zoom + welcome typing
  on every other login (no triple glitch flashes, ~1.2s vs 8s).
- vite.config.ts: bump assets cache from `assets-cache-v2` to
  `assets-cache-v3` so service workers running the previous bundle
  invalidate their cache and pick up the new UI cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 04:45:33 -04:00
DorianandClaude Opus 4.7 974fce5870 release(v1.7.32-alpha): fix frontend tarball layout + mDNS shutdown hang
- HOTFIX: v1.7.31-alpha's frontend tarball was packaged with a
  `neode-ui/` top-level directory instead of the flat layout v1.7.30
  and earlier used. Nodes that applied v1.7.31 ended up with
  `/opt/archipelago/web-ui/neode-ui/index.html` instead of
  `/opt/archipelago/web-ui/index.html`, and nginx returned 403/500.
  v1.7.32's tarball is built with `tar -C web/dist/neode-ui .` so
  files land directly at web-ui root. Broken nodes auto-heal on this
  update (web-ui dir is replaced).
- transport/lan.rs: add Drop impl that calls ServiceDaemon::shutdown()
  on the mdns_sd daemon. Without this the OS thread it spawns, plus
  the blocking `receiver.recv()` task, keep the tokio runtime alive
  past SIGTERM — long enough for systemd's TimeoutStopSec to SIGKILL
  the service and mark it Failed. Was visible on every update:
  "shut down cleanly" logged, then 15s later systemd forcibly kills.
- main.rs: after logging "Archipelago shut down cleanly", call
  `std::process::exit(0)` explicitly. Belt-and-suspenders against
  any future non-daemon thread creeping in (reqwest resolver pool,
  etc.) and causing the same SIGKILL regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 03:52:22 -04:00
DorianandClaude Opus 4.7 682b93f2d6 release(v1.7.31-alpha): idempotent IndeedHub install + auto-merge default mirrors/registries + 3rd OVH update mirror
- Backend: install.rs registry reachability probe now strips the
  `host[:port]/namespace` suffix before appending `/v2/` (the Docker
  V2 API lives at the host root, not under the namespace) and accepts
  HTTP 405 in addition to 200/401 as "registry daemon alive". This
  fixes false "unreachable" reports on the Test button for Gitea and
  other registries that protect their /v2/ endpoint.
- Backend: stacks.rs install_indeedhub_stack now force-removes any
  leftover indeedhub-* containers and indeedhub-net before creating
  the stack. A partial install (or the old first-boot stub racing the
  installer) used to leave containers around that blocked re-install
  with "name already in use". Re-running the App Store install now
  self-heals.
- Backend: registry.rs load_registries auto-merges any default
  registry URLs missing from the saved config (appended with priority
  max+10+i, persisted). Lets new default mirrors (e.g. Server 3 OVH)
  roll out to existing nodes without manual config edits. Explicit
  removals still stick — URLs absent from disk AND absent from
  defaults stay gone.
- Backend: update.rs adds DEFAULT_TERTIARY_MIRROR_URL at
  http://146.59.87.168:3000/ (Server 3 OVH) to default_mirrors, with
  the same auto-merge-on-load behavior as registries. Test updated
  for 3-mirror default (.160, tx1138, .168).
- Scripts: dropped the first-boot IndeedHub stub (~38 lines in
  first-boot-containers.sh §8b). It predated the proper stack
  installer, raced it, and was the main source of the name-conflict
  mess the stacks.rs cleanup above now also guards against.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 03:26:09 -04:00
DorianandClaude Opus 4.7 18f0929614 release(v1.7.30-alpha): live install/uninstall progress + cleaner pull waterfall
- Backend: unified pull-progress streaming across primary AND fallback
  registries. Earlier code only streamed for the primary attempt; if it
  failed fast (VPS 404, etc.) the UI froze at 0% until the fallback
  finished. The waterfall now uses a single shared helper that streams
  podman stderr through update_install_progress for every URL tried.
- Backend: PackageDataEntry gains uninstall_stage, set at each phase of
  handle_package_uninstall ("Stopping containers (i/total)",
  "Cleaning up volumes", "Removing app data"). State flips to Removing
  during the pipeline.
- Frontend: MarketplaceAppCard renders the live progress bar with byte
  counts during installs, matching the System Update download bar style.
- Frontend: AppCard renders the live uninstall stage label per app.
  Modal closes immediately on confirm so concurrent uninstalls each
  show their own progress on their own card.
- Cleanup: removed dead helpers (image_candidates, rewrite_for_primary,
  primary_image_url, pull_from_registries_with_skip) made unused by
  the install.rs refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:11:36 -04:00
DorianandClaude Opus 4.7 1709149ebd release(v1.7.29-alpha): VPS as default app registry + settings UI
- New Settings → App registries page (/dashboard/settings/registries)
  that mirrors the update-mirrors experience: list of configured
  registries, test reachability, set primary, add/remove. New
  registry.set-primary RPC; existing registry.{list,add,remove,test}
  reused.
- Default RegistryConfig flipped: VPS (23.182.128.160:3000/lfg2025) is
  now Server 1 (primary), tx1138 is Server 2 (fallback).
- Install pipeline now rewrites the first pull to the primary registry
  URL before attempting it. Before this, installs always hit whichever
  registry the image was hardcoded to, so changing the primary didn't
  actually affect where images came from. On failure, the existing
  fallback walk skips the primary (already tried) and walks the rest.
- App catalog proxy UPSTREAMS order flipped so the catalog follows the
  same VPS-first rule.
- Reboot overlay: animated "a" logo now sits in the center of the ring
  (matches the screensaver composition). Extracted the logo-wrapper
  pattern inline.

7/7 registry tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:54:07 -04:00
DorianandClaude Opus 4.7 2664074210 release(v1.7.28-alpha): reboot progress overlay + VPS default primary
- New reboot progress overlay: full-screen black with the screensaver's
  pulsing ring, rebooting → reconnecting → back-online → stalled stages,
  elapsed counter, auto-reload on health-check success, manual reload
  button at 3 min stall. Mirrors the existing update overlay.
- Ring extracted from Screensaver.vue into a reusable ScreensaverRing
  component so the reboot overlay reuses the same animation.
- default_mirrors() now puts the VPS as Server 1 (primary) and tx1138 as
  Server 2 — new nodes fetch manifests from VPS first; existing nodes
  keep whatever mirror order they've customized.
- What's New entry prepended for v1.7.28-alpha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:06:37 -04:00
DorianandClaude Opus 4.7 9868991900 release(v1.7.27-alpha): mirror transparency — served-by line + one-click test button
- New "Served by {mirror}" line on the System Update page so operators can see
  which mirror actually served the available manifest (vs. which is configured
  primary). Backend threads the served URL through UpdateState.manifest_mirror.
- New update.test-mirror RPC + per-row lightning-bolt button that pings a
  mirror and renders reachable/latency or error inline under the URL.
- UI polish on the mirrors section: Set Primary, Remove, and the new Test
  action are compact icon buttons; add-mirror form moved into a dialog.
- "What's New" block prepended for v1.7.27-alpha.

21/21 update module tests pass. vue-tsc + vite build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:05:42 -04:00
DorianandClaude Opus 4.7 0d15ca588a release(v1.7.26-alpha): mirror list + origin-relative download URLs
Adds a multi-mirror manifest fetch. `check_for_updates` walks a
configurable list (data_dir/update-mirrors.json) in priority order
and falls through to the next mirror on any HTTP / parse / timeout
failure. Two defaults bake in: Server 1 (git.tx1138.com) and Server 2
(23.182.128.160:3000).

Critical fix: after parsing a manifest, rewrite every component's
`download_url` so its origin matches the manifest URL we fetched.
Before this, the manifest hard-coded absolute URLs pointing at one
specific server — so even when a node fetched the manifest from a
faster mirror, the actual 200MB download went back to the slow
original. Now the faster mirror wins end-to-end.

New RPCs: update.list-mirrors, update.add-mirror, update.remove-mirror,
update.set-primary-mirror. New UI section on the System Update page
for operator management. 5 new unit tests for origin parsing and
manifest rewriting (21/21 green).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:09:28 -04:00
DorianandClaude Opus 4.7 1c1416cc1a release(v1.7.25-alpha): TCP transport for public FIPS mesh + modal cleanup
Re-adds the TCP transport (`0.0.0.0:8443`) to the rendered fips.yaml
alongside UDP. Upstream factory default enables both; we had
inadvertently narrowed to UDP-only when the yaml rewriter was last
touched, which left nodes unable to reach fips.v0l.io (the public
anchor only answers on TCP right now) or talk across networks that
block UDP.

Backend startup now compares the installed yaml against the current
rendered schema and restarts whichever fips unit is active when they
differ — so OTA-upgrading nodes pick up the new transport without
anyone having to click Reconnect.

Dropped the earlier plan to auto-add federated peers as seed anchors:
invites don't carry a FIPS-reachable IP:port, and once TCP reconnects
the public mesh, federated peers become npub-routable without needing
a seed entry.

Seed Anchors modal cleanup: replaced malformed header icon with a
three-arc broadcast glyph, and the close button now matches the
What's New modal (embedded in the card header, same icon + hover
style) instead of the earlier floating off-design placeholder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:25:53 -04:00
DorianandClaude Opus 4.7 1735098d81 release(v1.7.24-alpha): unbreak frontend pipeline — fresh UI for the first time since v1.7.17
The npm run build step in the release ritual had been silently failing
for roughly seven releases. vue-tsc died with EACCES on a root-owned
node_modules/.tmp, exited non-zero, and my `tail -5` of the build
output happened to only show vite's precache summary — which makes
vite look successful even when the typecheck that precedes it failed.
The resulting archipelago-frontend-*.tar.gz files were rebuilds from
whatever content happened to live in web/dist/neode-ui/ at the moment
(files left over from v1.7.9, owned root:root from an earlier sudo'd
operation, unchanged since).

Fixed by chowning both paths back to the archipelago user and
rebuilding. Every published frontend tarball from v1.7.17 through
v1.7.23 therefore shipped the same frozen UI; v1.7.24 is the first
release in that stretch whose frontend actually matches its backend.

Recorded the build-verification rule as a persistent feedback memory
(feedback_frontend_build_verify.md) — future ships must grep the
packaged tarball for the new version string before push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 08:53:00 -04:00
DorianandClaude Opus 4.7 b5da6875d7 release(v1.7.23-alpha): FIPS Seed Anchors reachable via gear icon
Adds a gear button next to the FIPS Mesh card's status pill that
opens a Teleport-ed modal containing FipsSeedAnchorsCard. The card
was landed on disk in v1.7.21 but never wired into a UI entry point
per the entry-point convention, so users couldn't access the
Add/Remove/Apply controls at all. One gear click now opens them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 08:17:26 -04:00
DorianandClaude Opus 4.7 4b6a088e38 release(v1.7.22-alpha): honest anchor status + Reconnect works on all nodes
- fips::service::active_unit() picks whichever fips unit is running
  (archipelago-fips.service vs upstream fips.service) so
  handle_fips_restart and handle_fips_reconnect don't silently no-op
  on hosts where the archipelago-managed unit was never created.
- peer_connectivity_summary(anchor_candidates) replaces the old
  identity-cache check. anchor_connected is now true when at least
  one authenticated peer's npub matches the public anchor OR any
  entry in seed-anchors.json, which matches what the user actually
  cares about ("am I in the mesh?") rather than what the card used
  to claim ("is this one specific public anchor reachable?").
- FipsStatus::query takes data_dir now (so it can read seed-anchors)
  rather than identity_dir. All call-sites updated.
- handle_fips_reconnect re-pushes seed anchors after restart so the
  new daemon gets dialed without waiting for the 5-min apply loop.
- FipsNetworkCard label drops "(fips.v0l.io)" — misleading now that
  multiple anchors may be configured.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 07:08:26 -04:00
DorianandClaude Opus 4.7 f8304aed90 release(v1.7.21-alpha): operator-editable FIPS seed anchors
Adds a local seed-anchor list at <data_dir>/seed-anchors.json. Each
entry is {npub, address, transport, label}. On archipelago startup
and every 5 minutes the list is pushed into the running fips daemon
via `fipsctl connect <npub> <addr> <transport>`, so a cluster can
anchor itself independently of the global fips.v0l.io. A flaky or
unreachable public anchor no longer strands a fresh install.

New RPCs:
- fips.list-seed-anchors
- fips.add-seed-anchor (validates npub1… + host:port)
- fips.remove-seed-anchor
- fips.apply-seed-anchors (on-demand re-dial)

New standalone UI card at views/server/FipsSeedAnchorsCard.vue. Not
wired into Home.vue / Server.vue — operator places it per the
entry-point convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 06:21:37 -04:00
DorianandClaude Opus 4.7 40f76013dc release(v1.7.20-alpha): stop auto-apply scheduler killing the service
The 3AM auto-update path called std::process::exit(0) immediately
after apply_update returned. apply_update had already spawned a 2s-
delayed systemctl restart, but exit(0) killed the runtime before that
spawned task could run — and the unit's Restart=on-failure does not
trigger on a clean exit 0, so the service stayed dead until someone
SSH'd in and started it manually (.253 hit this today).

Scheduler now returns from the task without killing the process;
apply_update's existing restart path (same one the UI's Install
Update button uses) brings the new version up cleanly.

Also hardens the ISO CI: the AIUI inclusion step now falls back to
extracting from the newest release tarball if the runner's cached
/opt/archipelago/web-ui/aiui path is missing, so a reprovisioned
runner can't silently ship a frontend tarball without AIUI. The ISO
build step also sanity-checks the binary exists before invoking the
builder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 04:33:11 -04:00
DorianandClaude Opus 4.7 4e2c6d210b release(v1.7.19-alpha): kill stale available_update + numeric version compare
load_state now drops any stored available_update whenever the running
binary version differs from what's on disk — the old migration only
cleared it when the stale entry happened to match the new version, so
skipping releases (e.g. sideloading 1.7.16 → 1.7.18 without 1.7.17)
left a pointer to an intermediate version as the "update available",
which the UI then offered as a downgrade prompt.

check_for_updates also uses a numeric version comparator so a stale or
cached manifest with an older version can't offer itself as an
update, and 1.7.10 correctly outranks 1.7.9 past the single-digit
patch boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 04:04:20 -04:00
DorianandClaude Opus 4.7 7d8ddcccef release(v1.7.18-alpha): transitive peers default Trusted + update-flow logs
Flip transitively-discovered federation peers to Trusted instead of
Observer. Hints are already only ingested from peers we trust and only
peers we trust are re-exported via build_local_state, so the chain of
trust is already vetted end-to-end — making the user promote each
newcomer by hand was friction with no security win.

Backend:
- federation/sync.rs: merge_transitive_peers now inserts TrustLevel::Trusted
  (doc comment updated to explain the transitive-trust rationale)
- update.rs: info! log at download start (version, components, total_bytes,
  staging path), cancel (staging wiped?, marker cleared?), and apply (backup
  path) so journalctl reveals where a stuck update actually is

Frontend:
- SystemUpdate What's New block gets a v1.7.18-alpha entry

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:20:36 -04:00
DorianandClaude Opus 4.7 f853d14421 release(v1.7.17-alpha): cancel download + stall detection
Add Cancel Download button + stall detection so a wedged download can
be recovered instead of leaving the UI stuck on a frozen progress bar.

Backend:
- update.rs: DOWNLOAD_CANCEL AtomicBool + DOWNLOAD_PROGRESS_AT AtomicU64
- download loop checks cancel between chunks and during retry backoff
  (500ms slices instead of one exponential sleep, so Cancel wakes fast)
- cancel_download() wipes staging + clears update_in_progress
- update.status exposes download_progress.stalled (30s no-progress)
- RPC: update.cancel-download + dispatcher entry

Frontend:
- SystemUpdate.vue: Cancel Download button, amber stall styling,
  stalled copy, cancel-download confirm branch in modal
- i18n keys (en + es) for cancel/stall flow
- v1.7.17-alpha What's New block in AccountInfoSection

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:10:34 -04:00
DorianandClaude Opus 4.7 f2360d570f release(v1.7.16-alpha): bidirectional + transitive federation, no self-peering
Federation join flow now notifies the inviter with the joiner's name and
immediately bumps state so the Federation UI reloads without a manual
Sync click. Accepting an invite that points back at the local node is
rejected up front (DID/pubkey/onion match). After a peer joins, we spawn
a transitive sync that pulls the new peer's federated peer hints so all
nodes in the federation learn about each other as Observer entries.
Federation.vue polls every 5s while mounted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:12:02 -04:00
DorianandClaude Opus 4.7 749234b8b0 release(v1.7.15-alpha): bulletproof downloads — resume, retry, real progress
download_update
  Each component download is now resumable via HTTP Range requests
  (Range: bytes=N-) and retried up to 6 times with exponential
  backoff (5/15/30/60/120/180s). On a dropped connection the next
  attempt picks up at the last written byte offset instead of
  restarting at zero. Streams via reqwest::Response::chunk() to the
  staging file so a 160 MB frontend tarball doesn't sit in RAM. SHA
  is verified over the complete file at the end of each component;
  mismatch nukes the staged file and restarts from scratch.

Real download progress counters
  New AtomicU64 globals DOWNLOAD_BYTES/DOWNLOAD_TOTAL are updated
  from the chunk loop. update.status exposes them as
  download_progress.{bytes_downloaded, total_bytes, active}. The
  SystemUpdate.vue progress bar now polls update.status every
  second instead of incrementing a fake random counter — and
  crucially, if the user navigates away and back, the component
  picks up the in-progress download from the backend atomics
  immediately.

Update-check retries
  handle_update_check now retries the manifest fetch up to 3 times
  with a 5s gap if the first try hits a transport error, so a
  momentary gitea hiccup doesn't make a node report "up to date"
  when there actually is a new release. Tight 10s connect timeout
  per attempt keeps the total bounded.

Artefacts:
  archipelago                                      1070c87f…c081c162b  40584792
  archipelago-frontend-1.7.15-alpha.tar.gz         8e630eba…63fd43f   162078068

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:17:58 -04:00
DorianandClaude Opus 4.7 be8e5ee46b release(v1.7.14-alpha): install overlay + FIPS real fix + AIUI restore
Install UX
  SystemUpdate.vue now shows a full-screen overlay after apply: the
  BitcoinFaceAscii logo, a target-version label, an indeterminate
  progress stripe (solid orange; solid green on ready), and an
  elapsed-time readout. Polls /health every 1.5s and auto-reloads
  once the backend reports the new version. 3-min stall → "Reload
  now" button. Download UI also shows a spinner + "Finishing
  download — verifying checksum…" while the fake bar sits at 95%.

FIPS reconnect — for real this time
  New fips.reconnect RPC does stop → start → wait 20s → re-poll →
  classify. Classification buckets: connected / daemon_down /
  no_seed_key / no_outbound_udp_or_anchor_down / peers_but_no_anchor,
  each with a plain-language hint surfaced verbatim by the Reconnect
  button. The real reason nodes like .198/.253 couldn't reach the
  anchor: identity::write_fips_key_from_seed was writing fips_key.pub
  as a bech32 npub TEXT file, but upstream fips expects 32 raw
  bytes. The daemon silently authenticated with garbage. Fix:
  PublicKey::to_bytes() → raw 32 bytes, and new
  fips::config::normalize_pub_file migrates legacy files by decoding
  the npub and rewriting in place. fips.reconnect also re-installs
  the config + healed keys to /etc/fips before restarting.

AIUI preservation + restore
  apply_update was wiping /opt/archipelago/web-ui/aiui because the
  Vue build doesn't include it — every OTA lost the Claude sidebar.
  The preserve block now copies aiui/ + archipelago-companion.apk
  from the old web-ui into the staging dir before the swap, and
  prefers new-tar versions if present. To restore it on the three
  nodes that already lost it (.116/.198/.253), this release bundles
  the 85 MB aiui build into the frontend tarball. Frontend component
  size is now ~155 MB.

Download / install timeouts
  Backend download client timeout 1800s → 3600s (1 h). Larger
  tarball + slow gitea raw throughput put us above the old cap.
  Frontend update.download rpc timeout 30 min → 65 min to match.
  package.install rpc timeout 15 min → 45 min — IndeedHub pulls
  6 images and was timing out mid-install.

UI nit
  "Rollback to Previous" → "Rollback Available".

App-catalog proxy already landed in v1.7.13.

Artefacts:
  archipelago                                      725e18e6…3c525e6   40462288
  archipelago-frontend-1.7.14-alpha.tar.gz         c35284be…ff2c16   162077052 (+aiui)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:40:25 -04:00
DorianandClaude Opus 4.7 687c216e65 release(v1.7.13-alpha): proxy app catalog server-side (CORS + CSP fix)
The Discover / Marketplace page fetched the app catalog directly from
git.tx1138.com/lfg2025/app-catalog/raw/.../catalog.json in the
browser. Two blockers hit the fleet simultaneously: (1) tx1138's
Gitea doesn't emit Access-Control-Allow-Origin so the HTTPS fetch
got CORS-blocked; (2) the HTTP IP-port fallback
(http://23.182.128.160:3000/...) falls outside the node's
`connect-src` CSP. Users saw the hardcoded fallback instead of the
live catalog.

Backend: new authenticated GET /api/app-catalog handler uses reqwest
to pull catalog.json server-side (15s timeout) and returns it with
application/json + 1h Cache-Control. Tries the HTTPS URL first,
HTTP IP-port second.

Frontend: curatedApps.ts now calls /api/app-catalog (same-origin,
no CORS/CSP) with credentials included so the session cookie
authenticates the proxy. Baked /catalog.json stays as the last
resort.

Artefacts:
  archipelago                                      0aaf7262…b979f22c  40371192
  archipelago-frontend-1.7.13-alpha.tar.gz         27505811…efc6f4142 76982505

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:43:45 -04:00
DorianandClaude Opus 4.7 26630e5ffd release(v1.7.12-alpha): bump on top of working-OTA 1.7.11
Version-only bump. Sits above v1.7.11-alpha which user has verified
runs the full Install Update pipeline end-to-end (check → download
→ install → auto-restart). Freshly-installed nodes from the 1.7.11
ISO will see 1.7.12 as their first OTA target.

Frontend tarball byte-identical to v1.7.11 (same sha).

Artefacts:
  archipelago                                      247f65c2…54f40df9  40385472
  archipelago-frontend-1.7.12-alpha.tar.gz         0644a436…54f58    76983846 (reused)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:39:07 -04:00
DorianandClaude Opus 4.7 fee690744d release(v1.7.11-alpha): OTA proof bump on top of namespace-escape apply
Version-only bump. Frontend tarball byte-identical to v1.7.10. First
OTA-testable release where the running backend (v1.7.10) has the
host_sudo/systemd-run apply fix — clicking Install Update should
walk through check → download → install → auto-restart with no
manual intervention.

Artefacts:
  archipelago                                      cf003f62…65465f  40378752
  archipelago-frontend-1.7.11-alpha.tar.gz         0644a436…54f58   76983846 (reused)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:03:36 -04:00
DorianandClaude Opus 4.7 79f4ec4bde release(v1.7.10-alpha): apply namespace fix + FIPS cascade + profile polish
THE apply fix
  archipelago.service uses ProtectSystem=strict, so /opt and /usr are
  read-only inside the service's mount namespace. sudo inherits that
  namespace — every sudo mkdir/mv/chown from apply_update was hitting
  EROFS even as root. Every prior "Failed to apply update" was a
  symptom of this. New `host_sudo()` helper wraps every filesystem
  call in `sudo systemd-run --wait --collect --pipe -- <cmd>`, which
  spawns a transient unit with systemd's default (no ProtectSystem)
  protections — the command runs in the host namespace and can touch
  /opt/archipelago + /usr/local/bin normally.

FIPS cascade (#2)
  Home.vue and Server.vue both carry a FIPS row that previously only
  looked at {installed, service_active, key_present}. Now they also
  read anchor_connected + authenticated_peer_count and mirror the
  full FIPS card: green "Active · N peers" when healthy, orange "No
  anchor" when the DHT bootstrap has failed.

Profile paste URL fallback (#4)
  Web5Identities.vue list + editor previously had `@error="display:none"`
  on the <img>, which hid the tag without re-rendering the fallback —
  a broken pasted URL showed up blank. Replaced with reactive
  pictureLoadFailed / listPictureFailed flags plus a watcher that
  resets on URL change. Broken URL now falls back to the initial (or
  identicon for seed-derived identities).

Small-upload data URL (#3)
  Uploaded profile pictures ≤ 64 KB are now inlined as
  `data:image/png;base64,...` into profile.picture on the client
  before calling update-profile. That kind-0 event is fetchable by
  any Nostr client — no Tor needed. Larger uploads fall back to the
  onion-rooted public_url with a hint telling the user to paste a
  public https:// URL for broader visibility.

Deferred: #1 FIPS Reconnect "actually fixes" — the current Reconnect
calls fips.restart which clears the daemon state, but when the
anchor is truly unreachable (UDP 8668 blocked by network/ISP), no
amount of restart can help. A richer diagnostic is out of scope for
this bundle.

Artefacts:
  archipelago                                      4a77c704…82aa6f8  40379696
  archipelago-frontend-1.7.10-alpha.tar.gz         0644a436…54f58    76983846

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:46:03 -04:00
DorianandClaude Opus 4.7 61bfdac7cc release(v1.7.9-alpha): OTA proof bump on top of mv-based apply
Version-only bump. First release where .116/.198/.253 (running v1.7.8
with the mv-based apply) should walk through Check → Download →
Install → auto-restart cleanly via UI, no sideload intervention.

Artefacts:
  archipelago                                      1ec7383d…301629  40378536
  archipelago-frontend-1.7.9-alpha.tar.gz          4fb79664…0172e9  76984615 (reused)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:23:37 -04:00
DorianandClaude Opus 4.7 1e648800ad release(v1.7.8-alpha): fix apply ETXTBSY — use mv instead of install
apply_update's binary swap called `sudo install -m 0755 src
/usr/local/bin/archipelago`. install opens the destination for write
with O_TRUNC; the kernel returns ETXTBSY (exit 1) when the path is a
currently-running executable, which it always is during apply because
apply_update is called by the archipelago RPC handler — running as
archipelago itself. Every previous "Failed to apply update" was this
one root cause; the manual sideload path only worked because we
stopped the service first.

rename() doesn't modify the file it replaces — it repoints the path
at a new inode while the old inode stays alive for any process that
has it mapped. `mv` uses rename(). Switched to `sudo mv` (with prior
chmod+chown on the staging file) so the swap is atomic and tolerant
of the running binary.

Frontend tarball byte-identical to v1.7.7-alpha; only the binary
version string changes.

Artefacts:
  archipelago                                      2753daec…48094d  40377648
  archipelago-frontend-1.7.8-alpha.tar.gz          4fb79664…0172e9  76984615 (reused)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:04:09 -04:00
104 changed files with 5532 additions and 2084 deletions
+40 -6
View File
@@ -74,12 +74,38 @@ jobs:
- name: Include AIUI if available
run: |
if [ -d "/opt/archipelago/web-ui/aiui" ] && [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then
mkdir -p web/dist/neode-ui/aiui
cp -r /opt/archipelago/web-ui/aiui/* web/dist/neode-ui/aiui/
echo "AIUI included from /opt/archipelago/web-ui/aiui/"
# AIUI (the Claude chat sidebar) lives outside the Vue build
# and must be copied into the frontend dist BEFORE packaging,
# otherwise OTA-tarball upgrades silently strip it from nodes
# in the field. Try in order: cached on runner, then the
# newest release tarball in this repo's releases/ dir as a
# fallback so a freshly-provisioned runner still gets AIUI.
AIUI_SRC=""
if [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then
AIUI_SRC="/opt/archipelago/web-ui/aiui"
elif [ -f "$HOME/archy/web/dist/neode-ui/aiui/index.html" ]; then
AIUI_SRC="$HOME/archy/web/dist/neode-ui/aiui"
else
echo "WARNING: AIUI not found on build server — ISO will not include AIUI"
LATEST_FRONTEND=$(ls -t releases/v*/archipelago-frontend-*.tar.gz 2>/dev/null | head -1)
if [ -n "$LATEST_FRONTEND" ]; then
echo "Extracting AIUI from $LATEST_FRONTEND (runner cache miss)"
TMP=$(mktemp -d)
tar xzf "$LATEST_FRONTEND" -C "$TMP" ./aiui 2>/dev/null || true
if [ -f "$TMP/aiui/index.html" ]; then
AIUI_SRC="$TMP/aiui"
fi
fi
fi
if [ -n "$AIUI_SRC" ]; then
mkdir -p web/dist/neode-ui/aiui
cp -r "$AIUI_SRC/"* web/dist/neode-ui/aiui/
echo "AIUI included from $AIUI_SRC ($(du -sh web/dist/neode-ui/aiui | cut -f1))"
else
echo "FAIL: AIUI not found anywhere (runner cache + release tarballs)"
echo " checked: /opt/archipelago/web-ui/aiui"
echo " \$HOME/archy/web/dist/neode-ui/aiui"
echo " releases/v*/archipelago-frontend-*.tar.gz"
exit 1
fi
- name: Configure root podman for insecure registry
@@ -93,7 +119,15 @@ jobs:
run: |
cd image-recipe
export ARCHIPELAGO_BIN="$(pwd)/../core/target/release/archipelago"
ls -la "$ARCHIPELAGO_BIN" || echo "WARNING: binary not found"
if [ ! -x "$ARCHIPELAGO_BIN" ]; then
echo "FAIL: backend binary missing or not executable at $ARCHIPELAGO_BIN"
exit 1
fi
BIN_VERSION=$(strings "$ARCHIPELAGO_BIN" | grep -oE 'archipelago [0-9]+\.[0-9]+\.[0-9]+(-[a-z]+)?' | head -1 || true)
EXPECTED=$(grep '^version' ../core/archipelago/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
echo "Binary: $ARCHIPELAGO_BIN ($(du -h "$ARCHIPELAGO_BIN" | cut -f1))"
echo "Embedded version string: ${BIN_VERSION:-unknown}"
echo "Expected version (Cargo.toml): $EXPECTED"
sudo -E UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 \
ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \
./build-auto-installer-iso.sh
+9 -41
View File
@@ -1,6 +1,6 @@
{
"version": 2,
"updated": "2026-04-12T00:00:00Z",
"updated": "2026-04-22T00:00:00Z",
"registry": "git.tx1138.com/lfg2025",
"featured": {
"id": "indeedhub",
@@ -18,6 +18,14 @@
"dockerImage": "git.tx1138.com/lfg2025/bitcoin-knots:latest",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
"id": "bitcoin-core", "title": "Bitcoin Core", "version": "28.4",
"description": "Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks.",
"icon": "/assets/img/app-icons/bitcoin-core.svg",
"author": "Bitcoin Core contributors", "category": "money", "tier": "optional",
"dockerImage": "docker.io/bitcoin/bitcoin:28.4",
"repoUrl": "https://github.com/bitcoin/bitcoin"
},
{
"id": "lnd", "title": "LND", "version": "0.18.4",
"description": "Lightning Network Daemon. Fast Bitcoin payments through Lightning.",
@@ -102,14 +110,6 @@
"dockerImage": "git.tx1138.com/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng"
},
{
"id": "nostr-rs-relay", "title": "Nostr Relay", "version": "0.9.0",
"description": "Your own Nostr relay. Store events locally, relay for friends.",
"icon": "/assets/img/app-icons/nostr-rs-relay.svg",
"author": "scsiblade", "category": "nostr",
"dockerImage": "git.tx1138.com/lfg2025/nostr-rs-relay:0.9.0",
"repoUrl": "https://sr.ht/~gheartsfield/nostr-rs-relay/"
},
{
"id": "fedimint", "title": "Fedimint", "version": "0.10.0",
"description": "Federated Bitcoin mint with privacy through federated guardians.",
@@ -182,30 +182,6 @@
"dockerImage": "git.tx1138.com/lfg2025/uptime-kuma:1",
"repoUrl": "https://github.com/louislam/uptime-kuma"
},
{
"id": "nostr-vpn", "title": "Nostr VPN", "version": "0.3.7",
"description": "Tailscale-style mesh VPN with Nostr control plane.",
"icon": "/assets/img/app-icons/nostr-vpn.svg",
"author": "Martti Malmi", "category": "networking",
"dockerImage": "git.tx1138.com/lfg2025/nostr-vpn:v0.3.7",
"repoUrl": "https://github.com/mmalmi/nostr-vpn"
},
{
"id": "fips", "title": "FIPS", "version": "0.1.0",
"description": "Free Internetworking Peering System. Encrypted mesh network.",
"icon": "/assets/img/app-icons/fips.svg",
"author": "Jim Corgan", "category": "networking",
"dockerImage": "git.tx1138.com/lfg2025/fips:v0.1.0",
"repoUrl": "https://github.com/jmcorgan/fips"
},
{
"id": "routstr", "title": "Routstr", "version": "0.4.3",
"description": "Decentralized AI inference proxy with Cashu ecash.",
"icon": "/assets/img/app-icons/routstr.svg",
"author": "Routstr", "category": "community",
"dockerImage": "git.tx1138.com/lfg2025/routstr:v0.4.3",
"repoUrl": "https://github.com/routstr/routstr-core"
},
{
"id": "dwn", "title": "Decentralized Web Node", "version": "0.4.0",
"description": "Own your data with DID-based access control.",
@@ -222,14 +198,6 @@
"dockerImage": "git.tx1138.com/lfg2025/endurain:0.8.0",
"repoUrl": "https://github.com/joaovitoriasilva/endurain"
},
{
"id": "penpot", "title": "Penpot", "version": "2.4",
"description": "Open-source design platform. Self-hosted Figma alternative.",
"icon": "/assets/img/app-icons/penpot.webp",
"author": "Penpot", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/penpot-frontend:2.4",
"repoUrl": "https://github.com/penpot/penpot"
},
{
"id": "photoprism", "title": "PhotoPrism", "version": "240915",
"description": "AI-powered photo management with facial recognition.",
+5 -5
View File
@@ -1,11 +1,11 @@
app:
id: bitcoin-core
name: Bitcoin Core
version: 24.0.0
version: 28.4.0
description: Full Bitcoin node implementation. The reference implementation of the Bitcoin protocol.
container:
image: bitcoin/bitcoin:24.0
image: bitcoin/bitcoin:28.4
image_signature: cosign://...
pull_policy: verify-signature
@@ -13,8 +13,8 @@ app:
- storage: 500Gi # Minimum disk space for mainnet
resources:
cpu_limit: 2
memory_limit: 2Gi
cpu_limit: 0 # 0 = unlimited; bitcoind uses -par=auto across all cores
memory_limit: 4Gi # matches container-specs.sh bitcoin-knots large-disk dbcache=4096
disk_limit: 500Gi
security:
-5
View File
@@ -1,5 +0,0 @@
# Penpot - uses official image
FROM penpot/penpot:latest
# Default configuration is in the image
# No additional setup needed
-51
View File
@@ -1,51 +0,0 @@
app:
id: penpot
name: Penpot
version: 2.0.0
description: Open-source design and prototyping platform. Design tools for teams.
container:
image: penpotapp/frontend:2.13.3
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 10Gi
resources:
cpu_limit: 4
memory_limit: 4Gi
disk_limit: 10Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: penpot
ports:
- host: 8089
container: 80
protocol: tcp # Web UI
volumes:
- type: bind
source: /var/lib/archipelago/penpot
target: /app/data
options: [rw]
environment:
- PENPOT_PUBLIC_URI=http://localhost:8089
- PENPOT_DATABASE_URI=postgresql://penpot:penpot@penpot-db:5432/penpot
- PENPOT_REDIS_URI=redis://penpot-redis:6379
health_check:
type: http
endpoint: http://localhost:8089
path: /api/health
interval: 30s
timeout: 5s
retries: 3
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.7-alpha"
version = "1.7.40-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.7-alpha"
version = "1.7.40-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+86
View File
@@ -113,6 +113,80 @@ impl ApiHandler {
}
}
/// Server-side fetch of the upstream app catalog so the browser can
/// load it without fighting CORS (git.tx1138.com emits no ACAO) or
/// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream
/// list is derived from the operator's configured container registries
/// so switching mirrors in Settings changes the App Store source too —
/// each active registry contributes one Gitea `raw/branch/main/catalog.json`
/// URL (http or https per `tls_verify`), tried in priority order.
/// If registry config can't be loaded, falls back to the legacy
/// hardcoded pair so the App Store still renders on nodes that haven't
/// persisted a registry config yet. 15s total timeout.
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
let mut upstreams: Vec<String> = Vec::new();
if let Ok(config) =
crate::container::registry::load_registries(&self.config.data_dir).await
{
for reg in config.active_registries() {
let scheme = if reg.tls_verify { "https" } else { "http" };
// Gitea raw URL: <scheme>://<host>/<namespace>/app-catalog/raw/branch/main/catalog.json.
// reg.url already includes the namespace (e.g. "host/lfg2025"),
// so we just tack on the repo + raw path.
upstreams.push(format!(
"{}://{}/app-catalog/raw/branch/main/catalog.json",
scheme, reg.url
));
}
}
if upstreams.is_empty() {
upstreams.push(
"http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
.to_string(),
);
upstreams.push(
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json"
.to_string(),
);
}
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
{
Ok(c) => c,
Err(e) => {
return Ok(build_response(
hyper::StatusCode::INTERNAL_SERVER_ERROR,
"text/plain",
hyper::Body::from(format!("client build failed: {}", e)),
));
}
};
for url in &upstreams {
match client.get(url).send().await {
Ok(resp) if resp.status().is_success() => {
if let Ok(bytes) = resp.bytes().await {
return Ok(Response::builder()
.status(hyper::StatusCode::OK)
.header("Content-Type", "application/json")
.header("Cache-Control", "public, max-age=3600")
.body(hyper::Body::from(bytes))
.unwrap_or_else(|_| {
Response::new(hyper::Body::from("proxy response build failed"))
}));
}
}
_ => continue,
}
}
Ok(build_response(
hyper::StatusCode::BAD_GATEWAY,
"text/plain",
hyper::Body::from("all upstream catalog URLs failed"),
))
}
/// Build a 401 Unauthorized JSON response.
fn unauthorized() -> Response<hyper::Body> {
let body = serde_json::json!({ "error": "Unauthorized" });
@@ -352,6 +426,18 @@ impl ApiHandler {
// Electrs status — unauthenticated (read-only sync status)
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
// App-catalog proxy — fetches catalog.json from the configured
// upstream URLs server-side so the browser doesn't hit CORS
// (git.tx1138.com has no ACAO header) or CSP (IP-port upstream
// falls outside `connect-src`). Session-authenticated so only
// the logged-in node owner can spin up fetches.
(Method::GET, "/api/app-catalog") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
self.handle_app_catalog_proxy().await
}
// LND connect info — nginx validates session cookie (presence check),
// backend is bound to 127.0.0.1 so only nginx can reach it.
// No backend auth check here because the LND UI iframe fetches this
@@ -220,6 +220,7 @@ impl RpcHandler {
"registry.list" => self.handle_registry_list().await,
"registry.add" => self.handle_registry_add(params).await,
"registry.remove" => self.handle_registry_remove(params).await,
"registry.set-primary" => self.handle_registry_set_primary(params).await,
"registry.test" => self.handle_registry_test(params).await,
// Streaming ecash payments
@@ -413,12 +414,41 @@ impl RpcHandler {
"fips.apply-update" => self.handle_fips_apply_update().await,
"fips.install" => self.handle_fips_install().await,
"fips.restart" => self.handle_fips_restart().await,
"fips.reconnect" => self.handle_fips_reconnect().await,
"fips.list-seed-anchors" => self.handle_fips_list_seed_anchors().await,
"fips.add-seed-anchor" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_fips_add_seed_anchor(&p).await
}
"fips.remove-seed-anchor" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_fips_remove_seed_anchor(&p).await
}
"fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await,
// System updates
"update.check" => self.handle_update_check().await,
"update.status" => self.handle_update_status().await,
"update.dismiss" => self.handle_update_dismiss().await,
"update.download" => self.handle_update_download().await,
"update.cancel-download" => self.handle_update_cancel_download().await,
"update.list-mirrors" => self.handle_update_list_mirrors().await,
"update.add-mirror" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_add_mirror(&p).await
}
"update.remove-mirror" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_remove_mirror(&p).await
}
"update.set-primary-mirror" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_set_primary_mirror(&p).await
}
"update.test-mirror" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_test_mirror(&p).await
}
"update.apply" => self.handle_update_apply().await,
"update.git-apply" => self.handle_update_git_apply().await,
"update.rollback" => self.handle_update_rollback().await,
@@ -79,6 +79,7 @@ impl RpcHandler {
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
let local_pubkey = data.server_info.pubkey.clone();
let local_name = data.server_info.name.clone();
let identity_dir = self.config.data_dir.join("identity");
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
@@ -90,6 +91,7 @@ impl RpcHandler {
&local_onion,
&local_pubkey,
local_fips_npub.as_deref(),
local_name.as_deref(),
|data| node_identity.sign(data),
)
.await?;
@@ -447,6 +449,38 @@ impl RpcHandler {
.get("fips_npub")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Optional, unsigned: peer's display name. Display-only — identity
// claims are anchored on the signed did/pubkey below.
let incoming_name = params
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Reject self-peering. If somehow our own did / onion / pubkey
// comes back at us (misconfigured invite, gossip loop), adding
// the entry causes sync loops where the node syncs with itself
// forever. Drop it quietly — no useful recovery path.
let (own_data, _) = self.state_manager.get_snapshot().await;
let own_did_result =
identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
let own_onion_trim = own_data
.server_info
.tor_address
.as_deref()
.unwrap_or("")
.trim_end_matches(".onion")
.to_string();
let incoming_onion_trim = onion.trim_end_matches(".onion");
if own_did_result.as_deref() == Some(did)
|| pubkey == own_data.server_info.pubkey
|| (!own_onion_trim.is_empty() && own_onion_trim == incoming_onion_trim)
{
tracing::warn!(
peer_did = %did,
"Rejected peer-joined: inbound identity matches this node"
);
anyhow::bail!("Refusing to peer with self");
}
// Verify ed25519 signature to prevent federation spoofing (H2 security fix)
let signature = params.get("signature").and_then(|v| v.as_str());
@@ -471,11 +505,12 @@ impl RpcHandler {
let nodes = federation::load_nodes(&self.config.data_dir).await?;
if let Some(existing) = nodes.iter().find(|n| n.did == did) {
// If already known but missing onion/pubkey/fips_npub, update them
// If already known but missing onion/pubkey/fips_npub/name, update them
let needs_onion = existing.onion.is_empty();
let needs_pubkey = existing.pubkey.is_empty();
let needs_fips = existing.fips_npub.is_none() && fips_npub.is_some();
if needs_onion || needs_pubkey || needs_fips {
let needs_name = existing.name.is_none() && incoming_name.is_some();
if needs_onion || needs_pubkey || needs_fips || needs_name {
let mut updated = existing.clone();
if needs_onion && !onion.is_empty() {
updated.onion = onion.to_string();
@@ -486,6 +521,9 @@ impl RpcHandler {
if needs_fips {
updated.fips_npub = fips_npub.clone();
}
if needs_name {
updated.name = incoming_name.clone();
}
updated.last_seen = Some(chrono::Utc::now().to_rfc3339());
federation::update_node(&self.config.data_dir, &updated).await?;
info!(peer_did = %did, peer_onion = %onion, "Updated existing peer with fresh identity fields");
@@ -497,7 +535,7 @@ impl RpcHandler {
did: did.to_string(),
pubkey: pubkey.to_string(),
onion: onion.to_string(),
name: None,
name: incoming_name.clone(),
trust_level: TrustLevel::Trusted,
added_at: chrono::Utc::now().to_rfc3339(),
last_seen: None,
@@ -512,9 +550,38 @@ impl RpcHandler {
// Mirror into mesh state so the inbound peer is addressable from
// the chat UI without waiting for the next mesh restart.
self.register_federation_peer_in_mesh(pubkey, did, None)
self.register_federation_peer_in_mesh(pubkey, did, incoming_name.as_deref())
.await;
// Bump the data-model revision so any Federation view with an
// open WebSocket reloads its node list without waiting for the
// user to click Sync.
let (data, _) = self.state_manager.get_snapshot().await;
self.state_manager.update_data(data).await;
// Transitive discovery: spawn a task that pulls the new peer's
// state (its own federated peers end up as Observer entries on
// our side) so after a join every existing peer in our list is
// aware of the newcomer via the next pair of syncs, without the
// user clicking anything. Best-effort; errors are logged only.
let data_dir = self.config.data_dir.clone();
let new_peer_did = did.to_string();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
if let Err(e) = crate::federation::sync_with_peer_by_did(
&data_dir,
&new_peer_did,
)
.await
{
tracing::debug!(
peer_did = %new_peer_did,
error = %e,
"Transitive sync on peer-joined failed (non-fatal)"
);
}
});
Ok(serde_json::json!({ "accepted": true }))
}
+171 -5
View File
@@ -12,8 +12,7 @@ use anyhow::Result;
impl RpcHandler {
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
let status = fips::FipsStatus::query(&identity_dir).await;
let status = fips::FipsStatus::query(&self.config.data_dir).await;
Ok(serde_json::to_value(status)?)
}
@@ -36,12 +35,179 @@ impl RpcHandler {
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
fips::config::install(&identity_dir).await?;
fips::service::activate(fips::SERVICE_UNIT).await?;
let status = fips::FipsStatus::query(&identity_dir).await;
let status = fips::FipsStatus::query(&self.config.data_dir).await;
Ok(serde_json::to_value(status)?)
}
/// Restart whichever fips unit is supervising the daemon on this host.
/// Nodes installed from the archipelago ISO use `archipelago-fips.service`;
/// nodes that had the upstream debian package set up first may only have
/// `fips.service`. We resolve the active one via `service::active_unit()`
/// so the UI button is never a no-op.
pub(super) async fn handle_fips_restart(&self) -> Result<serde_json::Value> {
fips::service::restart(fips::SERVICE_UNIT).await?;
Ok(serde_json::json!({ "restarted": true }))
let unit = fips::service::active_unit().await;
fips::service::restart(unit).await?;
Ok(serde_json::json!({ "restarted": true, "unit": unit }))
}
/// Full reconnect: stop the daemon, bring it back, wait for the DHT
/// bootstrap window, poll the identity-cache + peer list, and
/// classify what recovered (or didn't) so the UI can explain it to
/// the user instead of showing a generic failure.
///
/// Runtime: ~20s. Needs an RPC timeout ≥ 45s on the client.
pub(super) async fn handle_fips_reconnect(&self) -> Result<serde_json::Value> {
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
let before = fips::FipsStatus::query(&self.config.data_dir).await;
// Heal the pre-fix bech32-text fips_key.pub → 32-raw-bytes
// mismatch. The daemon silently authenticates with a garbage
// pubkey when the .pub file is 63-char text, which looks like
// "anchor unreachable" to the user even though the real fault
// was an identity malformed on the node itself. Re-install the
// config + keys so /etc/fips gets the healed .pub.
let key_src = identity_dir.join("fips_key");
let pub_src = identity_dir.join("fips_key.pub");
if key_src.exists() {
let _ = fips::config::normalize_pub_file(&key_src, &pub_src).await;
// Re-install refreshes /etc/fips/fips.pub from the healed
// source. No-op if nothing changed.
let _ = fips::config::install(&identity_dir).await;
}
// Operate on whichever fips unit is actually up — nodes that
// have the upstream `fips.service` rather than the
// archipelago-managed `archipelago-fips.service` used to see
// Reconnect silently fail because we stopped a unit that
// didn't exist. Clean stop+start rather than `restart` so a
// daemon that fails to come back up surfaces as
// service_active=false instead of quietly sticking with the
// old process.
let unit = fips::service::active_unit().await;
let _ = fips::service::stop(unit).await;
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
fips::service::activate(unit).await?;
// Re-push seed anchors after restart so freshly-bound daemons
// don't have to wait 5 min for the periodic apply loop.
if let Ok(list) = fips::anchors::load(&self.config.data_dir).await {
if !list.is_empty() {
let _ = fips::anchors::apply(&list).await;
}
}
// Anchor bootstrap window: poll the status every ~3s for up to
// 20s. Bail as soon as the anchor is connected.
let mut last_status: Option<fips::FipsStatus> = None;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
loop {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let s = fips::FipsStatus::query(&self.config.data_dir).await;
if s.anchor_connected {
last_status = Some(s);
break;
}
last_status = Some(s);
if std::time::Instant::now() >= deadline {
break;
}
}
let after = last_status.unwrap_or_else(|| before.clone());
let recovered = after.anchor_connected && !before.anchor_connected;
let likely_cause = if after.anchor_connected {
"connected"
} else if !after.service_active {
"daemon_down"
} else if !after.key_present {
"no_seed_key"
} else if after.authenticated_peer_count == 0 {
// Daemon is up with a key but hasn't authenticated any
// peers — almost always outbound UDP/8668 dropped by the
// local firewall/router, or the anchor itself being down.
"no_outbound_udp_or_anchor_down"
} else {
"peers_but_no_anchor"
};
let hint = match likely_cause {
"connected" => "An anchor is reachable.",
"daemon_down" => "The FIPS daemon didn't come back up — check the FIPS service on this host.",
"no_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step.",
"no_outbound_udp_or_anchor_down" =>
"Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or every configured anchor could be down. Add a reachable peer in Seed Anchors.",
"peers_but_no_anchor" =>
"Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors.",
_ => "",
};
Ok(serde_json::json!({
"recovered": recovered,
"likely_cause": likely_cause,
"hint": hint,
"before": before,
"after": after,
}))
}
/// List the seed-anchor entries configured on this node.
pub(super) async fn handle_fips_list_seed_anchors(&self) -> Result<serde_json::Value> {
let list = fips::anchors::load(&self.config.data_dir).await?;
Ok(serde_json::json!({ "seed_anchors": list }))
}
/// Add (or update) a seed anchor and immediately push it into the
/// running daemon. Params: `{ npub, address, transport?, label? }`.
pub(super) async fn handle_fips_add_seed_anchor(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let anchor: fips::anchors::SeedAnchor = serde_json::from_value(params.clone())
.map_err(|e| anyhow::anyhow!("bad seed anchor payload: {}", e))?;
if !anchor.npub.starts_with("npub1") {
anyhow::bail!("npub must be bech32 (npub1...)");
}
if !anchor.address.contains(':') {
anyhow::bail!("address must be host:port (e.g. 192.168.1.116:8668)");
}
let list =
fips::anchors::add(&self.config.data_dir, anchor.clone()).await?;
// Push just the newly-added anchor into the running daemon so
// the user sees effect without waiting for the periodic apply.
let results = fips::anchors::apply(&[anchor]).await;
Ok(serde_json::json!({
"seed_anchors": list,
"apply": results.iter().map(|r| {
serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message })
}).collect::<Vec<_>>(),
}))
}
/// Remove a seed anchor by npub. Params: `{ npub }`. Does NOT tear
/// down an already-authenticated peer connection — it only stops
/// us from re-dialing the anchor on the next apply cycle.
pub(super) async fn handle_fips_remove_seed_anchor(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let npub = params
.get("npub")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing npub"))?;
let list = fips::anchors::remove(&self.config.data_dir, npub).await?;
Ok(serde_json::json!({ "seed_anchors": list }))
}
/// Re-apply all seed anchors to the running daemon. Useful after a
/// FIPS restart or when the user wants to force a reconnection
/// attempt without waiting for the periodic apply loop.
pub(super) async fn handle_fips_apply_seed_anchors(&self) -> Result<serde_json::Value> {
let list = fips::anchors::load(&self.config.data_dir).await?;
let results = fips::anchors::apply(&list).await;
Ok(serde_json::json!({
"applied": results.len(),
"results": results.iter().map(|r| {
serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message })
}).collect::<Vec<_>>(),
}))
}
}
@@ -282,6 +282,7 @@ impl RpcHandler {
let local_fips_npub = crate::identity::fips_npub(&identity_dir2)
.await
.unwrap_or(None);
let local_name = data.server_info.name.clone();
match crate::federation::accept_invite(
&self.config.data_dir,
invite_code,
@@ -289,6 +290,7 @@ impl RpcHandler {
&local_onion,
&local_pubkey,
local_fips_npub.as_deref(),
local_name.as_deref(),
|bytes| node_identity.sign(bytes),
)
.await
+24 -1
View File
@@ -483,7 +483,30 @@ pub(super) async fn get_app_config(
None,
None,
),
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (
"bitcoin-core" => (
vec![
"8332:8332".to_string(),
"8333:8333".to_string(),
"28332:28332".to_string(),
"28333:28333".to_string(),
],
vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
vec![],
None,
// Vanilla bitcoin/bitcoin image has no entrypoint wrapper and reads
// only what's in bitcoin.conf + argv. The shared bitcoin.conf
// carries rpcauth; we inject the networking flags as CLI args so
// RPC is reachable from the bitcoin-ui companion container.
Some(vec![
"-server=1".to_string(),
"-rpcbind=0.0.0.0".to_string(),
"-rpcallowip=0.0.0.0/0".to_string(),
"-rpcport=8332".to_string(),
"-printtoconsole=1".to_string(),
"-datadir=/home/bitcoin/.bitcoin".to_string(),
]),
),
"bitcoin" | "bitcoin-knots" => (
vec![
"8332:8332".to_string(),
"8333:8333".to_string(),
+184 -96
View File
@@ -86,9 +86,6 @@ impl RpcHandler {
if package_id == "immich" {
return self.install_immich_stack().await;
}
if package_id == "penpot" || package_id == "penpot-frontend" {
return self.install_penpot_stack().await;
}
if matches!(package_id, "btcpay-server" | "btcpayserver" | "btcpay") {
return self.install_btcpay_stack().await;
}
@@ -312,11 +309,6 @@ impl RpcHandler {
}
}
// TUN device for mesh networking apps
if matches!(package_id, "nostr-vpn" | "fips") {
run_args.push("--device=/dev/net/tun");
}
// Create data directories (mkdir only — chown happens AFTER config files are written)
for volume in &volumes {
if let Some(host_path) = volume.split(':').next() {
@@ -358,36 +350,6 @@ impl RpcHandler {
}
}
// Pre-install: write Nostr identity key files for headless Nostr-aware apps
if matches!(package_id, "nostr-vpn" | "fips") {
let nostr_secret =
std::fs::read_to_string("/var/lib/archipelago/identity/nostr_secret")
.map(|s| s.trim().to_string())
.unwrap_or_default();
if !nostr_secret.is_empty() {
let key_dir = match package_id {
"nostr-vpn" => "/var/lib/archipelago/nostr-vpn",
"fips" => "/var/lib/archipelago/fips/config",
_ => unreachable!(),
};
let key_path = match package_id {
"nostr-vpn" => format!("{}/nostr_secret", key_dir),
"fips" => format!("{}/fips.key", key_dir),
_ => unreachable!(),
};
tokio::fs::create_dir_all(key_dir).await.ok();
tokio::fs::write(&key_path, &nostr_secret).await.ok();
// Restrict permissions on key file
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&key_path, perms).ok();
}
info!("Wrote Nostr identity key for {}", package_id);
}
}
// NOW chown data directories to container UID (after all config files are written)
self.create_data_dirs(package_id, &volumes).await;
@@ -635,31 +597,32 @@ impl RpcHandler {
unreachable!()
}
/// Single image pull attempt with progress streaming.
async fn do_pull_image(&self, package_id: &str, docker_image: &str) -> Result<()> {
debug!("Pulling image: {}", docker_image);
self.set_install_progress(package_id, 0, 0).await;
// Set TMPDIR to user-writable location — rootless podman's user namespace
// makes /var/tmp read-only, which causes `podman pull` to fail with
// "mkdir /var/tmp/container_images_storage...: read-only file system"
let user_tmp = format!(
"{}/.local/share/containers/tmp",
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string())
);
let _ = std::fs::create_dir_all(&user_tmp);
/// Pull one image URL with live progress streamed through
/// `update_install_progress`. Returns Ok(true) on a successful pull,
/// Ok(false) on transient failure (so the caller can try the next
/// mirror), Err only for unrecoverable setup errors.
async fn pull_one_url_with_progress(
&self,
url: &str,
tls_verify: bool,
package_id: &str,
user_tmp: &str,
) -> Result<bool> {
let mut pull_args = vec!["pull".to_string(), url.to_string()];
if !tls_verify {
pull_args.push("--tls-verify=false".to_string());
}
let mut child = tokio::process::Command::new("podman")
.args(["pull", docker_image])
.env("TMPDIR", &user_tmp)
.args(&pull_args)
.env("TMPDIR", user_tmp)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("Failed to start image pull")?;
// Wrap the entire pull (stderr progress + wait) in a 10-minute timeout.
// Large image layers (Minio, Postgres, ffmpeg) can take several minutes
// to pull. 60s was too short and caused premature retries on slow registries.
// 10-minute per-URL budget — large layers (Minio, Postgres,
// ffmpeg) regularly take several minutes and we'd rather wait
// than bounce to the next mirror mid-download.
let pull_result = tokio::time::timeout(std::time::Duration::from_secs(600), async {
if let Some(stderr) = child.stderr.take() {
let reader = BufReader::new(stderr);
@@ -677,38 +640,118 @@ impl RpcHandler {
})
.await;
let primary_failed = match pull_result {
Ok(Ok(status)) => !status.success(),
match pull_result {
Ok(Ok(status)) => Ok(status.success()),
Ok(Err(e)) => {
tracing::warn!("Image pull process error: {}", e);
true
tracing::warn!("Image pull process error on {}: {}", url, e);
Ok(false)
}
Err(_) => {
tracing::warn!("Image pull timed out after 60s: {}", docker_image);
tracing::warn!("Image pull timed out after 600s: {}", url);
let _ = child.kill().await;
let _ = child.wait().await; // reap zombie
true
Ok(false)
}
};
if primary_failed {
// Try all configured fallback registries dynamically
match crate::container::registry::pull_from_registries(
&self.config.data_dir,
docker_image,
&user_tmp,
)
}
}
/// Pull a container image, trying each configured registry in
/// priority order and streaming progress during every attempt. The
/// primary is tried first; if it doesn't have the image (or 404's),
/// the next mirror is tried — with its own progress streaming, so
/// the UI doesn't freeze at 0% after a primary miss. On success the
/// image is tagged under `docker_image` so downstream commands
/// (images -q, run -d, etc.) can find it by its canonical name.
async fn do_pull_image(&self, package_id: &str, docker_image: &str) -> Result<()> {
debug!("Pulling image: {}", docker_image);
self.set_install_progress(package_id, 0, 0).await;
// Set TMPDIR to user-writable location — rootless podman's user namespace
// makes /var/tmp read-only, which causes `podman pull` to fail with
// "mkdir /var/tmp/container_images_storage...: read-only file system"
let user_tmp = format!(
"{}/.local/share/containers/tmp",
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string())
);
let _ = std::fs::create_dir_all(&user_tmp);
// Build the ordered candidate list: every enabled registry
// (highest priority first), each rewriting the image URL to its
// own origin. Deduplicate — two registries that happen to share
// a URL should only be tried once.
let config = crate::container::registry::load_registries(&self.config.data_dir)
.await
.unwrap_or_default();
let mut tried: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut candidates: Vec<(String, bool)> = Vec::new();
for reg in config.active_registries() {
let url = config.rewrite_image(docker_image, reg);
if tried.insert(url.clone()) {
candidates.push((url, reg.tls_verify));
}
}
// Always include the literal URL as a last-resort candidate —
// internal mirrors may not host every third-party upstream image
// (e.g. docker.io/bitcoin/bitcoin:28.4), and we don't want
// "app not mirrored" to masquerade as a generic pull failure.
if tried.insert(docker_image.to_string()) {
candidates.push((docker_image.to_string(), true));
}
// Walk candidates, streaming progress for each attempt.
let mut pulled_url: Option<String> = None;
let attempts = candidates.len();
for (i, (url, tls_verify)) in candidates.iter().enumerate() {
if url != docker_image {
debug!("Attempt {}/{}: {}", i + 1, attempts, url);
} else {
debug!("Attempt {}/{}: {} (literal)", i + 1, attempts, url);
}
// Reset progress at the top of each attempt so the UI reflects
// the fresh pull instead of showing stale bytes from a prior
// partial attempt.
self.set_install_progress(package_id, 0, 0).await;
match self
.pull_one_url_with_progress(url, *tls_verify, package_id, &user_tmp)
.await?
{
Ok(_) => {
tracing::info!("Pulled {} via dynamic registry fallback", docker_image);
true => {
tracing::info!("Pulled {} from {}", docker_image, url);
pulled_url = Some(url.clone());
break;
}
Err(e) => {
return Err(anyhow::anyhow!("Image pull failed: {}", e));
false => {
tracing::debug!(
"Pull attempt {}/{} failed for {}, trying next mirror",
i + 1,
attempts,
url
);
continue;
}
}
}
// Verify image exists locally after pull
let Some(pulled_url) = pulled_url else {
return Err(anyhow::anyhow!(
"Image pull failed from all {} configured registries for {}",
attempts,
docker_image
));
};
// Tag under the original docker_image reference if the successful
// pull came from a rewritten URL — downstream code (images -q,
// run -d docker_image, etc.) needs to find it by its canonical
// name regardless of which mirror actually served the bytes.
if pulled_url != docker_image {
let _ = tokio::process::Command::new("podman")
.args(["tag", &pulled_url, docker_image])
.output()
.await;
}
// Verify image exists locally after pull.
let verify = tokio::process::Command::new("podman")
.args(["images", "-q", docker_image])
.output()
@@ -735,7 +778,7 @@ impl RpcHandler {
"grafana" => 472,
"lnd" => 1000,
"mariadb" | "mysql" | "mysql-mempool" | "archy-mempool-db" => 999,
"postgres" | "btcpay-postgres" | "immich-postgres" | "penpot-postgres"
"postgres" | "btcpay-postgres" | "immich-postgres"
| "archy-btcpay-db" | "nextcloud-db" => 70,
"electrumx" | "electrs" => 1000,
_ => 0, // Most containers run as root (UID 0)
@@ -795,6 +838,24 @@ impl RpcHandler {
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
// Idempotent: once bitcoin-knots (or a prior install) has started,
// the data dir is chowned into the container's user namespace
// (e.g. UID 100100 on the host) with 700 perms — the archipelago
// daemon can no longer stat or write there. Treat any non-NotFound
// error on the conf as "conf already provisioned by the container
// user" and skip. Matches the lnd.conf behavior below.
match tokio::fs::metadata(&conf_path).await {
Ok(_) => {
info!("bitcoin.conf already exists, skipping write");
return Ok(());
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => {
info!("bitcoin.conf path inaccessible (container-owned data dir), skipping write");
return Ok(());
}
}
use hmac::{Hmac, Mac};
use sha2::Sha256;
let salt_bytes: [u8; 16] = rand::random();
@@ -805,12 +866,14 @@ impl RpcHandler {
let hash_hex = hex::encode(mac.finalize().into_bytes());
let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex);
// Default to full archive — operators with 2TB+ drives shouldn't be
// silently pruned down to 550 MB. Users who want a pruned node can
// set `prune=N` in bitcoin.conf themselves after install.
let bitcoin_conf = format!(
"\
# rpcauth: salted hash only — no plaintext password in config or CLI\n\
{}\n\
server=1\n\
prune=550\n\
rpcbind=0.0.0.0\n\
rpcallowip=0.0.0.0/0\n\
rpcport=8332\n\
@@ -1278,20 +1341,6 @@ server {
"electrs-ui",
)]
}
"nostr-vpn" => {
vec![(
"archy-nostr-vpn-ui",
"/opt/archipelago/docker/nostr-vpn-ui",
"nostr-vpn-ui",
)]
}
"fips" => {
vec![(
"archy-fips-ui",
"/opt/archipelago/docker/fips-ui",
"fips-ui",
)]
}
_ => vec![],
};
@@ -1467,6 +1516,36 @@ server {
Ok(serde_json::json!({ "registries": config.registries, "removed": url }))
}
/// Promote a registry to primary by resetting priorities — the named
/// URL becomes priority 0, every other enabled registry is bumped up
/// by 10. Order is stable (ties broken by original priority).
pub(in crate::api::rpc) async fn handle_registry_set_primary(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let url = params
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing url"))?;
let mut config = crate::container::registry::load_registries(&self.config.data_dir).await?;
if !config.registries.iter().any(|r| r.url == url) {
return Err(anyhow::anyhow!("Registry '{}' not found", url));
}
// Reassign priorities: target = 0, everyone else = 10, 20, 30…
// in their existing priority order.
let target_url = url.to_string();
config.registries.sort_by_key(|r| (r.url != target_url, r.priority));
for (i, r) in config.registries.iter_mut().enumerate() {
r.priority = if r.url == target_url { 0 } else { (i as u32) * 10 };
}
crate::container::registry::save_registries(&self.config.data_dir, &config).await?;
Ok(serde_json::json!({ "registries": config.registries, "primary": url }))
}
pub(in crate::api::rpc) async fn handle_registry_test(
&self,
params: Option<serde_json::Value>,
@@ -1481,10 +1560,16 @@ server {
.and_then(|v| v.as_bool())
.unwrap_or(true);
// Registries are configured as `host[:port]/namespace` (for
// example `23.182.128.160:3000/lfg2025`), but the Docker V2
// registry API lives at `/v2/` on the ROOT of the host — NOT
// under the namespace. Strip the namespace before appending
// `/v2/` so the reachability probe hits the correct URL.
let host = url.split('/').next().unwrap_or(url);
let test_url = if tls_verify {
format!("https://{}/v2/", url)
format!("https://{}/v2/", host)
} else {
format!("http://{}/v2/", url)
format!("http://{}/v2/", host)
};
let client = reqwest::Client::builder()
@@ -1496,8 +1581,11 @@ server {
match client.get(&test_url).send().await {
Ok(resp) => {
let status = resp.status().as_u16();
// 200 = open registry, 401 = auth required (both mean it exists)
let reachable = status == 200 || status == 401;
// Accept any "the server responded to HTTP" code as
// reachable — 200 (open registry), 401 (auth required),
// or 405 (server rejects GET but the endpoint exists)
// all mean the registry daemon is alive on the host.
let reachable = status == 200 || status == 401 || status == 405;
Ok(serde_json::json!({
"url": url,
"reachable": reachable,
@@ -28,6 +28,18 @@ impl RpcHandler {
self.state_manager.update_data(data).await;
}
/// Set the uninstall stage label so the UI can show what's happening
/// instead of a generic spinner. Each call broadcasts a state change
/// — call sparingly (one per pipeline phase, not per container).
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.uninstall_stage = Some(stage.to_string());
entry.state = crate::data_model::PackageState::Removing;
}
self.state_manager.update_data(data).await;
}
/// Update install progress (static method for use in async closures).
pub(super) async fn update_install_progress(
state_manager: &crate::state::StateManager,
@@ -81,6 +93,7 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
},
installed: None,
install_progress: None,
uninstall_stage: None,
available_update: None,
}
}
@@ -261,12 +261,28 @@ impl RpcHandler {
if containers_to_remove.is_empty() {
tracing::warn!("Uninstall {}: no containers found", package_id);
}
let total = containers_to_remove.len();
let mut stopped = 0u32;
let mut removed = 0u32;
let mut errors = Vec::new();
for name in &containers_to_remove {
self.set_uninstall_stage(
package_id,
&if total > 0 {
format!("Stopping containers (0/{})", total)
} else {
"Cleaning up".to_string()
},
)
.await;
for (i, name) in containers_to_remove.iter().enumerate() {
self.set_uninstall_stage(
package_id,
&format!("Stopping containers ({}/{})", i + 1, total),
)
.await;
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
let stop_out = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(name), name])
@@ -326,6 +342,7 @@ impl RpcHandler {
}
}
self.set_uninstall_stage(package_id, "Cleaning up volumes").await;
// Clean up dangling volumes associated with removed containers
let _ = tokio::process::Command::new("podman")
.args(["volume", "prune", "-f"])
@@ -354,6 +371,7 @@ impl RpcHandler {
// Clean data directories unless preserve_data
if !preserve_data {
self.set_uninstall_stage(package_id, "Removing app data").await;
let data_dirs = get_data_dirs_for_app(package_id);
for dir in &data_dirs {
tracing::info!("Uninstall {}: removing data {}", package_id, dir);
+25 -228
View File
@@ -273,234 +273,6 @@ impl RpcHandler {
}))
}
/// Install Penpot stack (postgres + valkey + backend + exporter + frontend).
pub(super) async fn install_penpot_stack(&self) -> Result<serde_json::Value> {
if let Some(adopted) = adopt_stack_if_exists(
"penpot-frontend",
"penpot",
&[
"penpot-postgres",
"penpot-valkey",
"penpot-backend",
"penpot-exporter",
"penpot-frontend",
],
)
.await?
{
return Ok(adopted);
}
let images = [
"git.tx1138.com/lfg2025/postgres:15",
"git.tx1138.com/lfg2025/valkey:8.1",
"git.tx1138.com/lfg2025/penpot-backend:2.4",
"git.tx1138.com/lfg2025/penpot-exporter:2.4",
"git.tx1138.com/lfg2025/penpot-frontend:2.4",
];
for img in &images {
pull_image_with_retry(img).await?;
}
let _ = tokio::process::Command::new("sudo")
.args(["mkdir", "-p", "/var/lib/archipelago/penpot-assets"])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args(["network", "create", "penpot-net"])
.output()
.await;
// Generate a stable secret key derived from the data directory
let secret = {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(b"penpot-secret-");
hasher.update(self.config.data_dir.to_string_lossy().as_bytes());
hex::encode(hasher.finalize())
};
let host_ip = &self.config.host_ip;
let _ = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"penpot-postgres",
"--restart",
"unless-stopped",
"--network",
"penpot-net",
"--network-alias",
"penpot-postgres",
"--cap-drop=ALL",
"--cap-add=CHOWN",
"--cap-add=DAC_OVERRIDE",
"--cap-add=FOWNER",
"--cap-add=SETGID",
"--cap-add=SETUID",
"--security-opt=no-new-privileges:true",
"--memory=512m",
"--pids-limit=4096",
"--health-cmd=pg_isready -U penpot || exit 1",
"--health-interval=30s",
"--health-retries=3",
"-v",
"/var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data",
"-e",
"POSTGRES_DB=penpot",
"-e",
"POSTGRES_USER=penpot",
"-e",
"POSTGRES_PASSWORD=penpot",
"git.tx1138.com/lfg2025/postgres:15",
])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let _ = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"penpot-valkey",
"--restart",
"unless-stopped",
"--network",
"penpot-net",
"--network-alias",
"penpot-valkey",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=192m",
"--pids-limit=2048",
"--health-cmd=valkey-cli ping || exit 1",
"--health-interval=30s",
"--health-retries=3",
"-e",
"VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu",
"git.tx1138.com/lfg2025/valkey:8.1",
])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let _ = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"penpot-backend",
"--restart",
"unless-stopped",
"--network",
"penpot-net",
"--network-alias",
"penpot-backend",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=1g",
"--pids-limit=4096",
"-v",
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
"-e",
&format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
"-e",
&format!("PENPOT_SECRET_KEY={}", secret),
"-e",
"PENPOT_DATABASE_URI=postgresql://penpot-postgres/penpot",
"-e",
"PENPOT_DATABASE_USERNAME=penpot",
"-e",
"PENPOT_DATABASE_PASSWORD=penpot",
"-e",
"PENPOT_REDIS_URI=redis://penpot-valkey/0",
"-e",
"PENPOT_OBJECTS_STORAGE_BACKEND=fs",
"-e",
"PENPOT_OBJECTS_STORAGE_FS_DIRECTORY=/opt/data/assets",
"-e",
"PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies",
"git.tx1138.com/lfg2025/penpot-backend:2.4",
])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let _ = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"penpot-exporter",
"--restart",
"unless-stopped",
"--network",
"penpot-net",
"--network-alias",
"penpot-exporter",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=512m",
"--pids-limit=2048",
"-e",
&format!("PENPOT_SECRET_KEY={}", secret),
"-e",
"PENPOT_PUBLIC_URI=http://penpot-frontend:8080",
"-e",
"PENPOT_REDIS_URI=redis://penpot-valkey/0",
"git.tx1138.com/lfg2025/penpot-exporter:2.4",
])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let run = tokio::process::Command::new("podman")
.args([
"run",
"-d",
"--name",
"penpot-frontend",
"--restart",
"unless-stopped",
"--network",
"penpot-net",
"--network-alias",
"penpot-frontend",
"--cap-drop=ALL",
"--security-opt=no-new-privileges:true",
"--memory=512m",
"--pids-limit=2048",
"-p",
"9001:8080",
"-v",
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
"-e",
&format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
"-e",
"PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies",
"git.tx1138.com/lfg2025/penpot-frontend:2.4",
])
.output()
.await
.context("Failed to start penpot-frontend")?;
if !run.status.success() {
let stderr = String::from_utf8_lossy(&run.stderr);
return Err(anyhow::anyhow!(
"Failed to start Penpot frontend: {}",
stderr
));
}
info!("Penpot stack installed and started");
Ok(serde_json::json!({
"success": true,
"package_id": "penpot",
"message": "Penpot stack installed and started"
}))
}
/// Install BTCPay stack (postgres + nbxplorer + btcpay-server).
pub(super) async fn install_btcpay_stack(&self) -> Result<serde_json::Value> {
@@ -937,6 +709,31 @@ impl RpcHandler {
.with_context(|| format!("Failed to pull IndeedHub image: {}", img))?;
}
// Remove any leftover containers from a previous partial install (or
// from the first-boot frontend stub that used to race the installer).
// Without this, `podman run --name indeedhub` fails on name conflict
// and the whole stack install errors out — leaving a half-broken node.
for name in [
"indeedhub",
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub-build_api_1",
"indeedhub-build_ffmpeg-worker_1",
] {
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", name])
.status()
.await;
}
let _ = tokio::process::Command::new("podman")
.args(["network", "rm", "-f", "indeedhub-net"])
.status()
.await;
// Create indeedhub-net
let _ = tokio::process::Command::new("podman")
.args(["network", "create", "indeedhub-net"])
+131
View File
@@ -40,6 +40,7 @@ impl RpcHandler {
"last_check": state.last_check,
"update_available": update_info.is_some(),
"update": update_info,
"manifest_mirror": state.manifest_mirror,
}))
}
@@ -157,6 +158,37 @@ impl RpcHandler {
/// Get update status without checking remote.
pub(super) async fn handle_update_status(&self) -> Result<serde_json::Value> {
let state = update::get_status(&self.config.data_dir).await?;
// Expose live download progress so the UI can resume the
// progress bar after navigation instead of showing the fake
// creep again. An RPC poll every ~1s during download drives a
// real progress indicator that survives route changes.
let downloaded = update::DOWNLOAD_BYTES
.load(std::sync::atomic::Ordering::Relaxed);
let total = update::DOWNLOAD_TOTAL
.load(std::sync::atomic::Ordering::Relaxed);
let active = total > 0 && downloaded < total;
let completed = total > 0 && downloaded >= total;
// Stall detection: if the progress-at timestamp hasn't advanced
// for 30+ seconds while active, the download is wedged (usually
// HTTP stream silently dropped and reqwest is waiting out its
// read timeout). The UI uses this to surface a Cancel button
// with explanatory copy.
let stalled = if active {
let last_at = update::DOWNLOAD_PROGRESS_AT
.load(std::sync::atomic::Ordering::Relaxed);
if last_at > 0 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
now.saturating_sub(last_at) > 30_000
} else {
false
}
} else {
false
};
Ok(serde_json::json!({
"current_version": state.current_version,
@@ -164,6 +196,15 @@ impl RpcHandler {
"update_available": state.available_update.is_some(),
"update_in_progress": state.update_in_progress,
"rollback_available": state.rollback_available,
"manifest_mirror": state.manifest_mirror,
"download_progress": if active || completed {
Some(serde_json::json!({
"bytes_downloaded": downloaded,
"total_bytes": total,
"active": active,
"stalled": stalled,
}))
} else { None },
}))
}
@@ -183,6 +224,13 @@ impl RpcHandler {
}))
}
/// Cancel an in-flight or stuck download. Clears the live counters
/// and staging dir so the UI returns to the "Download Update" state.
pub(super) async fn handle_update_cancel_download(&self) -> Result<serde_json::Value> {
update::cancel_download(&self.config.data_dir).await?;
Ok(serde_json::json!({ "canceled": true }))
}
/// Apply the staged update.
pub(super) async fn handle_update_apply(&self) -> Result<serde_json::Value> {
update::apply_update(&self.config.data_dir).await?;
@@ -195,6 +243,89 @@ impl RpcHandler {
Ok(serde_json::json!({ "rolled_back": true, "restart_required": true }))
}
/// List configured update mirrors in priority order.
pub(super) async fn handle_update_list_mirrors(&self) -> Result<serde_json::Value> {
let list = update::load_mirrors(&self.config.data_dir).await?;
Ok(serde_json::json!({ "mirrors": list }))
}
/// Add a mirror to the end of the list. Params: `{ url, label? }`.
/// Duplicates (same URL) are replaced rather than added twice.
pub(super) async fn handle_update_add_mirror(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let url = params
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing url"))?
.trim()
.to_string();
if !url.starts_with("http://") && !url.starts_with("https://") {
anyhow::bail!("url must start with http:// or https://");
}
let label = params
.get("label")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let mut list = update::load_mirrors(&self.config.data_dir).await?;
list.retain(|m| m.url != url);
list.push(update::UpdateMirror { url, label });
update::save_mirrors(&self.config.data_dir, &list).await?;
Ok(serde_json::json!({ "mirrors": list }))
}
/// Remove a mirror by URL. Params: `{ url }`.
pub(super) async fn handle_update_remove_mirror(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let url = params
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
let mut list = update::load_mirrors(&self.config.data_dir).await?;
list.retain(|m| m.url != url);
update::save_mirrors(&self.config.data_dir, &list).await?;
Ok(serde_json::json!({ "mirrors": list }))
}
/// Ping a mirror's manifest URL. Returns reachability, wall-clock
/// latency, and HTTP status. Params: `{ url }`.
pub(super) async fn handle_update_test_mirror(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let url = params
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
let result = update::test_mirror(url).await;
Ok(serde_json::to_value(result)?)
}
/// Move a mirror to the top of the list so it's tried first.
/// Params: `{ url }`.
pub(super) async fn handle_update_set_primary_mirror(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let url = params
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
let mut list = update::load_mirrors(&self.config.data_dir).await?;
let Some(idx) = list.iter().position(|m| m.url == url) else {
anyhow::bail!("mirror not in list");
};
let entry = list.remove(idx);
list.insert(0, entry);
update::save_mirrors(&self.config.data_dir, &list).await?;
Ok(serde_json::json!({ "mirrors": list }))
}
/// Get the current update schedule.
pub(super) async fn handle_update_get_schedule(&self) -> Result<serde_json::Value> {
let schedule = update::get_schedule(&self.config.data_dir).await?;
+26 -6
View File
@@ -185,12 +185,32 @@ impl AuthManager {
}
}
}
// Fallback: user.json
Ok(self
.get_user()
.await?
.map(|u| u.onboarding_complete)
.unwrap_or(false))
// Fallback: user.json. A node that has a password set AND
// setup_complete=true has been through onboarding by
// definition — you can't reach the password-set step any
// other way. The separate `onboarding_complete` flag can drift
// out of sync (e.g. the completion RPC never reached disk, or
// the node was seeded from a backup pre-dating the flag), so
// auto-heal by inferring from setup_complete + password_hash.
// Without this, a fully-onboarded node whose `onboarding_complete`
// is stuck false will force its user back through the intro
// wizard on every cleared browser cache.
if let Some(u) = self.get_user().await? {
if u.onboarding_complete {
return Ok(true);
}
if u.setup_complete && !u.password_hash.is_empty() {
// Persist the healed state so subsequent calls skip this
// inference. Ignore write errors — returning true is
// still correct even if we can't persist.
let healed = OnboardingState { complete: true };
if let Ok(json) = serde_json::to_string_pretty(&healed) {
let _ = fs::write(&onboarding_file, json).await;
}
return Ok(true);
}
}
Ok(false)
}
/// Check if 2FA is enabled for the user.
+264
View File
@@ -0,0 +1,264 @@
//! Bootstrap host-side artifacts on every archipelago startup.
//!
//! The update pipeline swaps the archipelago binary but does not touch
//! scripts, systemd units, or nginx configuration — those are installed
//! once by the ISO builder. Without this module, changes to
//! `container-doctor.sh`, the doctor service/timer, or the nginx config
//! never reach boxes installed before the change.
//!
//! Two things are synced on startup:
//! 1. Doctor artifacts (container-doctor.sh + service + timer).
//! 2. An nginx `location /api/app-catalog` proxy block — required for
//! the App Store catalog proxy to actually reach the backend.
//!
//! Idempotent: no-ops on boxes that are already in sync. All work is
//! best-effort — failures are logged but never abort the backend.
use anyhow::{Context, Result};
use std::path::Path;
use tokio::fs;
use tracing::{debug, info, warn};
use crate::update::host_sudo;
const DOCTOR_SH: &str = include_str!("../../../scripts/container-doctor.sh");
const DOCTOR_SERVICE: &str =
include_str!("../../../image-recipe/configs/archipelago-doctor.service");
const DOCTOR_TIMER: &str =
include_str!("../../../image-recipe/configs/archipelago-doctor.timer");
const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.sh";
const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service";
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
/// Inserted into every server block of the nginx config that lacks the
/// `/api/app-catalog` proxy. Kept in sync with the canonical block in
/// image-recipe/configs/nginx-archipelago.conf.
const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n";
/// Entry point called from main startup. Never returns an error to the caller —
/// failing to bootstrap host artifacts must not prevent the backend from serving.
pub async fn ensure_doctor_installed() {
match run().await {
Ok(changed) if changed => info!("Doctor artifacts synchronized with binary"),
Ok(_) => debug!("Doctor artifacts already in sync"),
Err(e) => warn!("Doctor bootstrap failed (non-fatal): {:#}", e),
}
match run_nginx().await {
Ok(true) => info!("Patched nginx config to proxy /api/app-catalog"),
Ok(false) => debug!("Nginx already has /api/app-catalog block"),
Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e),
}
}
async fn run() -> Result<bool> {
// Dev-box guard: on contributors' laptops `/home/archipelago/archy` is
// typically a symlink into the git checkout, and writing through it
// would clobber the working tree with whatever the binary happens to
// have been compiled from. Production ISO installs materialize a real
// directory.
let home_archy = Path::new("/home/archipelago/archy");
if fs::symlink_metadata(home_archy)
.await
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
debug!("/home/archipelago/archy is a symlink — skipping doctor bootstrap (dev box)");
return Ok(false);
}
// Skip entirely on machines without the canonical scripts directory —
// writing orphan files there just causes confusion.
let scripts_dir = Path::new(DOCTOR_SH_PATH)
.parent()
.context("doctor script path has no parent")?;
if !scripts_dir.exists() {
debug!(
"Scripts dir {} missing — skipping doctor bootstrap",
scripts_dir.display()
);
return Ok(false);
}
let mut changed = false;
// 1. Script — lives in archipelago's home dir, user-writable.
if needs_write(DOCTOR_SH_PATH, DOCTOR_SH).await {
fs::write(DOCTOR_SH_PATH, DOCTOR_SH)
.await
.with_context(|| format!("write {}", DOCTOR_SH_PATH))?;
let _ = tokio::process::Command::new("chmod")
.args(["+x", DOCTOR_SH_PATH])
.status()
.await;
info!("Updated {}", DOCTOR_SH_PATH);
changed = true;
}
// 2. Systemd unit files — /etc is restricted; route through host_sudo.
let service_changed = write_root_if_needed(DOCTOR_SERVICE_PATH, DOCTOR_SERVICE).await?;
let timer_changed = write_root_if_needed(DOCTOR_TIMER_PATH, DOCTOR_TIMER).await?;
changed = changed || service_changed || timer_changed;
// 3. Reload + enable. Only when we actually touched units, or when the
// timer isn't enabled yet (catches fresh upgrades of boxes that predate
// the doctor entirely).
let timer_enabled = is_timer_enabled().await;
if service_changed || timer_changed || !timer_enabled {
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
warn!("daemon-reload failed: {:#}", e);
}
if let Err(e) = host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"])
.await
{
warn!("enable archipelago-doctor.timer failed: {:#}", e);
} else if !timer_enabled {
info!("Enabled archipelago-doctor.timer");
}
}
Ok(changed)
}
async fn needs_write(path: &str, expected: &str) -> bool {
match fs::read_to_string(path).await {
Ok(current) => current != expected,
Err(_) => true,
}
}
/// Write content to a root-owned path via `sudo mv` of a user-owned tmp file.
/// Returns true if a write happened.
async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
if !needs_write(path, content).await {
return Ok(false);
}
let tmp = format!(
"/tmp/archipelago-bootstrap-{}-{}.tmp",
std::process::id(),
Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unit")
);
fs::write(&tmp, content)
.await
.with_context(|| format!("write tmp {}", tmp))?;
let status = host_sudo(&["mv", &tmp, path])
.await
.with_context(|| format!("sudo mv {} -> {}", tmp, path))?;
if !status.success() {
let _ = fs::remove_file(&tmp).await;
anyhow::bail!("sudo mv to {} exited with {}", path, status);
}
info!("Updated {}", path);
Ok(true)
}
async fn is_timer_enabled() -> bool {
tokio::process::Command::new("systemctl")
.args(["is-enabled", "--quiet", "archipelago-doctor.timer"])
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}
/// Patch the nginx site config to add a `/api/app-catalog` proxy block if
/// it's missing. The original ISO shipped individual per-endpoint `location`
/// blocks and no catch-all `/api/`, so `/api/app-catalog` silently fell
/// through to the SPA `index.html` and the frontend got HTML instead of
/// JSON. We anchor the insert to the DWN comment that already sits right
/// after the `/api/blob` block, so the new block lands in both the HTTP
/// and HTTPS server blocks.
///
/// Validates via `nginx -t` before reloading. On failure the patch is
/// rolled back from a backup written just before the write.
async fn run_nginx() -> Result<bool> {
// Skip on dev symlinks — we don't want to touch `/etc/nginx` on laptops.
let home_archy = Path::new("/home/archipelago/archy");
if fs::symlink_metadata(home_archy)
.await
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Ok(false);
}
if !Path::new(NGINX_CONF_PATH).exists() {
debug!(
"{} missing — skipping nginx bootstrap",
NGINX_CONF_PATH
);
return Ok(false);
}
let content = fs::read_to_string(NGINX_CONF_PATH)
.await
.with_context(|| format!("read {}", NGINX_CONF_PATH))?;
if content.contains("location /api/app-catalog") {
return Ok(false);
}
// The DWN comment sits at the same indent right after the `/api/blob`
// block in both server blocks — a stable anchor that existed on every
// ISO shipped to date. If it's absent (config got heavily customized),
// we bail rather than guess where to splice.
let anchor = " # DWN endpoints — peer access over Tor (no auth)";
if !content.contains(anchor) {
warn!("nginx conf missing DWN anchor — skipping /api/app-catalog patch");
return Ok(false);
}
let replacement = format!("{}{}", NGINX_APP_CATALOG_BLOCK, anchor);
let patched = content.replace(anchor, &replacement);
// Write patched config via a user-owned tmp + sudo mv, after stashing
// a backup so we can revert if `nginx -t` hates what we produced.
let pid = std::process::id();
let tmp = format!("/tmp/archipelago-nginx-{}.conf", pid);
fs::write(&tmp, &patched)
.await
.with_context(|| format!("write {}", tmp))?;
let backup = format!("/tmp/archipelago-nginx-backup-{}.conf", pid);
if let Err(e) = host_sudo(&["cp", NGINX_CONF_PATH, &backup]).await {
let _ = fs::remove_file(&tmp).await;
return Err(e.context("backup nginx conf"));
}
let mv = host_sudo(&["mv", &tmp, NGINX_CONF_PATH]).await;
match mv {
Ok(s) if s.success() => {}
Ok(s) => {
let _ = fs::remove_file(&tmp).await;
anyhow::bail!("sudo mv nginx conf exited with {}", s);
}
Err(e) => {
let _ = fs::remove_file(&tmp).await;
return Err(e.context("mv tmp -> nginx conf"));
}
}
// Validate.
let test = host_sudo(&["nginx", "-t"]).await;
let valid = matches!(&test, Ok(s) if s.success());
if !valid {
warn!("nginx -t failed after patch — reverting");
let _ = host_sudo(&["mv", &backup, NGINX_CONF_PATH]).await;
if let Err(e) = test {
return Err(e.context("nginx -t"));
}
anyhow::bail!("nginx config invalid after patch — reverted");
}
// Reload nginx so the new block takes effect immediately. Reload (not
// restart) keeps in-flight connections alive.
if let Err(e) = host_sudo(&["systemctl", "reload", "nginx"]).await {
warn!("nginx reload failed (non-fatal): {:#}", e);
}
let _ = host_sudo(&["rm", "-f", &backup]).await;
Ok(true)
}
@@ -44,11 +44,6 @@ impl DockerPackageScanner {
"nbxplorer",
"mempool-db",
"mempool-api",
"penpot-postgres",
"penpot-backend",
"penpot-exporter",
"penpot-valkey",
"penpot-mailcatch",
"immich_postgres",
"immich_redis",
"endurain-db",
@@ -247,6 +242,7 @@ impl DockerPackageScanner {
status: service_status,
}),
install_progress: None,
uninstall_stage: None,
};
packages.insert(app_id.clone(), package);
@@ -296,9 +292,16 @@ fn get_app_tier(app_id: &str) -> &'static str {
fn get_app_metadata(app_id: &str) -> AppMetadata {
let mut meta = match app_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => AppMetadata {
"bitcoin-core" => AppMetadata {
title: "Bitcoin Core".to_string(),
description: "Reference Bitcoin node implementation".to_string(),
icon: "/assets/img/app-icons/bitcoin-core.svg".to_string(),
repo: "https://github.com/bitcoin/bitcoin".to_string(),
tier: "",
},
"bitcoin" | "bitcoin-knots" => AppMetadata {
title: "Bitcoin Knots".to_string(),
description: "Full Bitcoin node implementation".to_string(),
description: "Enhanced Bitcoin node implementation".to_string(),
icon: "/assets/img/app-icons/bitcoin-knots.webp".to_string(),
repo: "https://github.com/bitcoinknots/bitcoin".to_string(),
tier: "",
@@ -408,13 +411,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/cryptpad/cryptpad".to_string(),
tier: "",
},
"penpot" | "penpot-frontend" => AppMetadata {
title: "Penpot".to_string(),
description: "Open-source design and prototyping".to_string(),
icon: "/assets/img/app-icons/penpot.webp".to_string(),
repo: "https://github.com/penpot/penpot".to_string(),
tier: "",
},
"nextcloud" => AppMetadata {
title: "Nextcloud".to_string(),
description: "Self-hosted cloud storage and file management".to_string(),
@@ -492,13 +488,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/indeedhub/indeedhub".to_string(),
tier: "",
},
"nostr-rs-relay" => AppMetadata {
title: "Nostr Relay".to_string(),
description: "Run your own Nostr relay for sovereign event storage".to_string(),
icon: "/assets/img/app-icons/nostr-rs-relay.svg".to_string(),
repo: "https://sr.ht/~gheartsfield/nostr-rs-relay/".to_string(),
tier: "",
},
"dwn" => AppMetadata {
title: "Decentralized Web Node".to_string(),
description: "Store and sync personal data with DID-based access control".to_string(),
+54 -98
View File
@@ -44,19 +44,26 @@ impl Default for RegistryConfig {
Self {
registries: vec![
Registry {
url: "git.tx1138.com/lfg2025".to_string(),
name: "Archipelago Primary".to_string(),
tls_verify: true,
url: "23.182.128.160:3000/lfg2025".to_string(),
name: "Server 1 (VPS)".to_string(),
tls_verify: false,
enabled: true,
priority: 0,
},
Registry {
url: "23.182.128.160:3000/lfg2025".to_string(),
name: "Archipelago Fallback".to_string(),
tls_verify: false,
url: "git.tx1138.com/lfg2025".to_string(),
name: "Server 2 (tx1138)".to_string(),
tls_verify: true,
enabled: true,
priority: 10,
},
Registry {
url: "146.59.87.168:3000/lfg2025".to_string(),
name: "Server 3 (OVH)".to_string(),
tls_verify: false,
enabled: true,
priority: 20,
},
],
}
}
@@ -80,20 +87,6 @@ impl RegistryConfig {
format!("{}/{}", registry.url, image_name)
}
/// Generate fallback image URLs to try (excludes the original since it already failed).
pub fn image_candidates(&self, image: &str) -> Vec<(String, bool)> {
let mut candidates = Vec::new();
// Rewrite for each active registry (skip if identical to original)
for reg in self.active_registries() {
let rewritten = self.rewrite_image(image, reg);
if rewritten != image {
candidates.push((rewritten, reg.tls_verify));
}
}
candidates
}
}
/// Extract the image name from a full image reference.
@@ -104,7 +97,12 @@ fn extract_image_name(image: &str) -> &str {
image.rsplit('/').next().unwrap_or(image)
}
/// Load registry config from disk.
/// Load registry config from disk, merging in any default registries
/// that the operator hasn't explicitly removed. This lets us roll out
/// new default mirrors (e.g. a new Server 3) to existing nodes without
/// them having to edit their saved config. Explicit removals stick —
/// if the URL is absent from disk AND absent from current defaults, it
/// stays gone.
pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
let path = data_dir.join(REGISTRY_FILE);
if !path.exists() {
@@ -113,8 +111,34 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
let content = fs::read_to_string(&path)
.await
.context("Failed to read registry config")?;
let config: RegistryConfig =
let mut config: RegistryConfig =
serde_json::from_str(&content).unwrap_or_else(|_| RegistryConfig::default());
// Migrate: any default registry URL that isn't already in the
// saved list gets appended at the end (so existing priority order
// is preserved for anything the operator already configured).
let defaults = RegistryConfig::default();
let known: std::collections::HashSet<String> =
config.registries.iter().map(|r| r.url.clone()).collect();
let max_priority = config
.registries
.iter()
.map(|r| r.priority)
.max()
.unwrap_or(0);
let mut added = false;
for (i, def) in defaults.registries.iter().enumerate() {
if !known.contains(&def.url) {
let mut cloned = def.clone();
cloned.priority = max_priority.saturating_add(10 + i as u32);
config.registries.push(cloned);
added = true;
}
}
if added {
// Persist so the next load doesn't have to re-merge.
let _ = save_registries(data_dir, &config).await;
}
Ok(config)
}
@@ -133,69 +157,6 @@ pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result
Ok(())
}
/// Try pulling an image from configured registries in priority order.
/// Returns the image reference that succeeded.
pub async fn pull_from_registries(data_dir: &Path, image: &str, tmpdir: &str) -> Result<String> {
let config = load_registries(data_dir).await?;
let candidates = config.image_candidates(image);
for (candidate, tls_verify) in &candidates {
debug!("Trying registry: {}", candidate);
let mut args = vec!["pull".to_string(), candidate.clone()];
if !tls_verify {
args.push("--tls-verify=false".to_string());
}
let mut child = tokio::process::Command::new("podman")
.args(&args)
.env("TMPDIR", tmpdir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok();
let status = if let Some(ref mut c) = child {
match tokio::time::timeout(std::time::Duration::from_secs(120), c.wait()).await {
Ok(Ok(s)) => Some(s.success()),
_ => {
let _ = c.kill().await;
let _ = c.wait().await;
debug!("Fallback pull timed out: {}", candidate);
None
}
}
} else {
None
};
if status == Some(true) {
// If we pulled from a non-original registry, tag it with the original name
if candidate != image {
let _ = tokio::process::Command::new("podman")
.args(["tag", candidate, image])
.status()
.await;
info!(
"Pulled {} from fallback registry, tagged as {}",
candidate, image
);
} else {
info!("Pulled {} from primary registry", image);
}
return Ok(candidate.clone());
}
debug!("Failed to pull from {}", candidate);
}
Err(anyhow::anyhow!(
"Failed to pull {} from all {} configured registries",
image,
candidates.len()
))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -217,34 +178,29 @@ mod tests {
#[test]
fn test_rewrite_image() {
let config = RegistryConfig::default();
let fallback = &config.registries[1];
// Default primary is now VPS (index 0). A tx1138-hardcoded image
// rewrites to VPS when asked for the primary mirror.
let primary = &config.registries[0];
assert_eq!(
config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", fallback),
config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", primary),
"23.182.128.160:3000/lfg2025/bitcoin-knots:latest"
);
}
#[test]
fn test_image_candidates() {
let config = RegistryConfig::default();
let candidates = config.image_candidates("git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
assert!(candidates.len() >= 2);
assert_eq!(candidates[0].0, "git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
}
#[test]
fn test_active_registries_sorted() {
let config = RegistryConfig::default();
let active = config.active_registries();
assert_eq!(active.len(), 2);
assert_eq!(active.len(), 3);
assert!(active[0].priority <= active[1].priority);
assert!(active[1].priority <= active[2].priority);
}
#[tokio::test]
async fn test_load_default() {
let tmp = TempDir::new().unwrap();
let config = load_registries(tmp.path()).await.unwrap();
assert_eq!(config.registries.len(), 2);
assert_eq!(config.registries.len(), 3);
}
#[tokio::test]
@@ -260,6 +216,6 @@ mod tests {
});
save_registries(tmp.path(), &config).await.unwrap();
let loaded = load_registries(tmp.path()).await.unwrap();
assert_eq!(loaded.registries.len(), 3);
assert_eq!(loaded.registries.len(), 4);
}
}
+7
View File
@@ -138,6 +138,13 @@ pub struct PackageDataEntry {
pub installed: Option<InstalledPackageDataEntry>,
#[serde(rename = "install-progress")]
pub install_progress: Option<InstallProgress>,
/// Live label describing the current uninstall step ("Stopping
/// containers (2/5)", "Removing data", …). Set by the uninstall
/// pipeline so the UI can show real progress instead of a generic
/// "Uninstalling…" spinner. Cleared after the package entry is
/// removed.
#[serde(rename = "uninstall-stage", skip_serializing_if = "Option::is_none", default)]
pub uninstall_stage: Option<String>,
/// Pinned image version from image-versions.sh when it differs from running version
#[serde(rename = "available-update", skip_serializing_if = "Option::is_none")]
pub available_update: Option<String>,
+30 -5
View File
@@ -121,6 +121,7 @@ pub async fn accept_invite(
local_onion: &str,
local_pubkey: &str,
local_fips_npub: Option<&str>,
local_name: Option<&str>,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<FederatedNode> {
let ParsedInvite {
@@ -131,6 +132,20 @@ pub async fn accept_invite(
fips_npub,
} = parse_invite(code)?;
// Refuse self-peering. If the invite's did / onion / pubkey matches
// our own, adding it pollutes the federation list with a node that
// sees itself as its own peer and causes sync loops. The user
// almost certainly pasted the wrong invite.
if did == local_did || pubkey == local_pubkey || {
let a = onion.trim_end_matches(".onion");
let b = local_onion.trim_end_matches(".onion");
!a.is_empty() && a == b
} {
anyhow::bail!(
"Refusing to federate with self — invite points at this node's own did / onion / pubkey"
);
}
// Make accept idempotent: drop any existing entry that conflicts with
// this invite — same DID (same node, refreshing the link), same onion
// (node rotated identity but kept its hidden service), or same pubkey
@@ -190,6 +205,7 @@ pub async fn accept_invite(
local_onion,
local_pubkey,
local_fips_npub,
local_name,
sign_fn,
)
.await;
@@ -201,20 +217,22 @@ pub async fn accept_invite(
/// Prefers FIPS (if the remote advertised an npub in their invite) and
/// falls back to Tor. Signs the message with our ed25519 key so the
/// remote peer can verify authenticity regardless of transport.
async fn notify_join(
pub(crate) async fn notify_join(
remote_onion: &str,
remote_fips_npub: Option<&str>,
local_did: &str,
local_onion: &str,
local_pubkey: &str,
local_fips_npub: Option<&str>,
local_name: Option<&str>,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<()> {
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
// Signature domain intentionally unchanged — fips_npub is carried
// as an unsigned informational field. The FIPS daemon's own Noise
// handshake authenticates the actual transport session, so a
// stripped/substituted npub here merely downgrades the path to Tor.
// Signature domain intentionally unchanged — fips_npub + name are
// carried as unsigned informational fields. Name is display-only
// (any identity claim is anchored on the signed did/pubkey); the
// FIPS daemon's own Noise handshake authenticates the transport
// session regardless of the advertised npub.
let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
let signature = sign_fn(sign_data.as_bytes());
@@ -227,6 +245,9 @@ async fn notify_join(
if let Some(npub) = local_fips_npub {
params["fips_npub"] = serde_json::Value::String(npub.to_string());
}
if let Some(name) = local_name {
params["name"] = serde_json::Value::String(name.to_string());
}
let body = serde_json::json!({
"method": "federation.peer-joined",
@@ -321,6 +342,7 @@ mod tests {
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
@@ -355,6 +377,7 @@ mod tests {
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
@@ -388,6 +411,7 @@ mod tests {
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
@@ -400,6 +424,7 @@ mod tests {
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
+1 -1
View File
@@ -17,5 +17,5 @@ pub use storage::{
add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes,
set_trust_level, update_node,
};
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer};
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
+29 -3
View File
@@ -82,9 +82,35 @@ pub async fn sync_with_peer(
Ok(state)
}
/// Convenience wrapper: look up a federated peer by DID, derive our
/// own local_did / signing context from the node identity on disk, and
/// call sync_with_peer. Used by transitive-discovery code paths where
/// the caller only knows the peer's DID (e.g. the peer-joined RPC's
/// follow-up task).
pub async fn sync_with_peer_by_did(
data_dir: &Path,
peer_did: &str,
) -> Result<NodeStateSnapshot> {
let nodes = super::storage::load_nodes(data_dir).await?;
let peer = nodes
.into_iter()
.find(|n| n.did == peer_did)
.ok_or_else(|| anyhow::anyhow!("Unknown federation peer: {}", peer_did))?;
let identity_dir = data_dir.join("identity");
let node_identity =
crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
let local_pubkey_hex = node_identity.pubkey_hex();
let local_did = crate::identity::did_key_from_pubkey_hex(&local_pubkey_hex)?;
sync_with_peer(data_dir, &peer, &local_did, |data| node_identity.sign(data)).await
}
/// Merge peers advertised by a Trusted federated node into our own
/// federation list. New peers are added at `Observer` trust (not
/// Trusted — that requires a direct invite). Existing peers get their
/// federation list. New peers are added at `Trusted` — hints only
/// arrive from peers we already trust, and `build_local_state` only
/// re-exports our Trusted list, so transitive membership carries the
/// same trust the direct-invite path gives. Existing peers get their
/// `fips_npub` refreshed if we hadn't learned it yet.
///
/// Peers we are (us) or that we already track by DID are skipped.
@@ -118,7 +144,7 @@ async fn merge_transitive_peers(
pubkey: hint.pubkey.clone(),
onion: hint.onion.clone(),
name: hint.name.clone(),
trust_level: TrustLevel::Observer,
trust_level: TrustLevel::Trusted,
added_at: chrono::Utc::now().to_rfc3339(),
last_seen: None,
last_state: None,
+241
View File
@@ -0,0 +1,241 @@
//! Seed-anchor management for FIPS bootstrap.
//!
//! A freshly-installed node can't reach the global mesh via npub
//! routing until it's connected to at least one peer that's already in
//! the DHT. Upstream `fips` solves this by dialing a public anchor
//! (e.g. `fips.v0l.io`) on first start. That's a single point of
//! failure and doesn't help nodes behind restrictive firewalls or
//! intermittent networks — archipelago operators reported fresh
//! installs failing to reach any public anchor.
//!
//! This module adds a local, operator-editable seed-anchor list. Each
//! entry is a `{npub, address, transport}` triple that archipelago
//! pushes into the running daemon via `fipsctl connect` on startup and
//! periodically thereafter. If one anchor falls over, the next one
//! seeds the DHT instead. A well-configured cluster (e.g. a VPS
//! running fips in anchor mode + a couple of home nodes) stops
//! depending on the global anchor entirely.
//!
//! The list is persisted at `<data_dir>/seed-anchors.json`. The
//! archipelago service user owns that directory, so no sudo is needed
//! to read or write it.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::process::Command;
/// On-disk filename under `data_dir/`.
const SEED_ANCHORS_FILE: &str = "seed-anchors.json";
/// Public anchor (`fips.v0l.io`) carried as a default seed for fresh
/// installs — the one the upstream daemon dials anyway. Operators can
/// remove it from the UI once their own cluster has independent anchors.
pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "fips.v0l.io:8668";
/// One seed-anchor entry. `address` must be directly dialable (IP or
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
/// "tor", "ethernet" (the values upstream `fipsctl connect` accepts).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SeedAnchor {
/// Bech32 `npub1...` of the anchor's FIPS identity.
pub npub: String,
/// Directly-dialable transport address, e.g. `192.168.1.116:8668`.
pub address: String,
/// Transport to use — almost always `"udp"`.
#[serde(default = "default_transport")]
pub transport: String,
/// Human-readable note shown in the UI (e.g. "Home anchor", "VPS").
#[serde(default)]
pub label: String,
}
fn default_transport() -> String {
"udp".to_string()
}
fn anchors_path(data_dir: &Path) -> PathBuf {
data_dir.join(SEED_ANCHORS_FILE)
}
/// Load the seed-anchor list. Returns an empty list if the file
/// doesn't exist yet — a first-boot node with no operator config.
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
let path = anchors_path(data_dir);
if !path.exists() {
return Ok(Vec::new());
}
let bytes = tokio::fs::read(&path)
.await
.with_context(|| format!("read {}", path.display()))?;
let anchors: Vec<SeedAnchor> = serde_json::from_slice(&bytes)
.with_context(|| format!("parse {}", path.display()))?;
Ok(anchors)
}
/// Persist the list. Overwrites atomically via write-then-rename so a
/// crashed archipelago never leaves a half-written config.
pub async fn save(data_dir: &Path, anchors: &[SeedAnchor]) -> Result<()> {
tokio::fs::create_dir_all(data_dir)
.await
.with_context(|| format!("mkdir -p {}", data_dir.display()))?;
let path = anchors_path(data_dir);
let tmp = path.with_extension("json.tmp");
let json = serde_json::to_vec_pretty(anchors).context("serialize seed anchors")?;
tokio::fs::write(&tmp, json)
.await
.with_context(|| format!("write {}", tmp.display()))?;
tokio::fs::rename(&tmp, &path)
.await
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
/// Add (or update) one anchor, keyed by npub. Returns the resulting list.
pub async fn add(data_dir: &Path, anchor: SeedAnchor) -> Result<Vec<SeedAnchor>> {
let mut list = load(data_dir).await?;
if let Some(existing) = list.iter_mut().find(|a| a.npub == anchor.npub) {
*existing = anchor;
} else {
list.push(anchor);
}
save(data_dir, &list).await?;
Ok(list)
}
/// Remove an anchor by npub. Returns the resulting list.
pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
let mut list = load(data_dir).await?;
list.retain(|a| a.npub != npub);
save(data_dir, &list).await?;
Ok(list)
}
/// Apply the seed anchors to the running FIPS daemon. For each entry,
/// asks `fipsctl connect` to dial the peer. Errors are logged but don't
/// fail the whole operation — a single unreachable anchor shouldn't
/// block the others.
///
/// `fipsctl connect` is idempotent-ish: calling it for an already-
/// connected peer is a no-op at the protocol layer, so re-applying on
/// a timer is safe. Returns a list of per-anchor results for logging.
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
let mut results = Vec::with_capacity(anchors.len());
for anchor in anchors {
let out = Command::new("fipsctl")
.args([
"connect",
&anchor.npub,
&anchor.address,
&anchor.transport,
])
.output()
.await;
let result = match out {
Ok(o) if o.status.success() => ApplyResult {
npub: anchor.npub.clone(),
ok: true,
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
},
Ok(o) => ApplyResult {
npub: anchor.npub.clone(),
ok: false,
message: format!(
"fipsctl exited {}: {}",
o.status,
String::from_utf8_lossy(&o.stderr).trim()
),
},
Err(e) => ApplyResult {
npub: anchor.npub.clone(),
ok: false,
message: format!("fipsctl launch failed: {}", e),
},
};
if result.ok {
tracing::debug!(npub = %result.npub, "Seed anchor applied");
} else {
tracing::warn!(
npub = %result.npub,
message = %result.message,
"Seed anchor apply failed (non-fatal)"
);
}
results.push(result);
}
results
}
/// Outcome of a single `fipsctl connect` call.
#[derive(Debug, Clone)]
pub struct ApplyResult {
pub npub: String,
pub ok: bool,
pub message: String,
}
#[cfg(test)]
mod tests {
use super::*;
fn mk(npub: &str) -> SeedAnchor {
SeedAnchor {
npub: npub.to_string(),
address: "example.test:8668".to_string(),
transport: "udp".to_string(),
label: "test".to_string(),
}
}
#[tokio::test]
async fn load_missing_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let got = load(dir.path()).await.unwrap();
assert!(got.is_empty());
}
#[tokio::test]
async fn save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let a = mk("npub1aaa");
let b = mk("npub1bbb");
save(dir.path(), &[a.clone(), b.clone()]).await.unwrap();
let got = load(dir.path()).await.unwrap();
assert_eq!(got, vec![a, b]);
}
#[tokio::test]
async fn add_replaces_existing_by_npub() {
let dir = tempfile::tempdir().unwrap();
let mut a = mk("npub1aaa");
save(dir.path(), &[a.clone()]).await.unwrap();
a.address = "newhost:8668".to_string();
let list = add(dir.path(), a.clone()).await.unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].address, "newhost:8668");
}
#[tokio::test]
async fn remove_by_npub() {
let dir = tempfile::tempdir().unwrap();
save(
dir.path(),
&[mk("npub1aaa"), mk("npub1bbb"), mk("npub1ccc")],
)
.await
.unwrap();
let list = remove(dir.path(), "npub1bbb").await.unwrap();
assert_eq!(list.len(), 2);
assert!(list.iter().all(|a| a.npub != "npub1bbb"));
}
#[test]
fn seed_anchor_uses_udp_by_default() {
let json = r#"{"npub":"npub1x","address":"h:8668"}"#;
let a: SeedAnchor = serde_json::from_str(json).unwrap();
assert_eq!(a.transport, "udp");
assert_eq!(a.label, "");
}
}
+72 -8
View File
@@ -13,22 +13,27 @@ use anyhow::{Context, Result};
use std::path::Path;
use tokio::process::Command;
use super::{DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_UDP_PORT};
use super::{DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT};
/// Write the FIPS daemon config based on the local npub and default
/// transports. Overwrites any existing file — callers are expected to
/// re-run this whenever the key or daemon version changes.
///
/// Schema is intentionally minimal: node identity comes from the key
/// file on disk (the daemon handles it), transports enable UDP + Tor,
/// IPv6 TUN + DNS on defaults. Static peer list is empty — archipelago
/// feeds peers dynamically via federation updates.
/// file on disk (the daemon handles it), transports enable UDP + TCP
/// (matching upstream factory default), IPv6 TUN + DNS on defaults.
/// Static peer list is empty — archipelago feeds peers dynamically via
/// the seed-anchors apply loop and federation-invite hooks.
pub fn render_config_yaml() -> String {
// Schema matches upstream jmcorgan/fips as of 2026-04. With
// `node.identity.persistent: true` the daemon reuses the key file at
// config-dir/fips.key (= DAEMON_KEY_PATH). Transports take `bind_addr`
// rather than `enabled: true / port: N`, and the upstream no longer
// has a `tor:` transport — archipelago's own Tor fallback handles that.
// rather than `enabled: true / port: N`. Both UDP and TCP are
// enabled by default because the public anchor (fips.v0l.io)
// currently answers on TCP/8443 only, and networks that block UDP
// outbound can still bootstrap via TCP. Upstream fips no longer
// has a `tor:` transport variant — archipelago's own Tor fallback
// handles that layer.
format!(
"# Generated by archipelago — do not edit by hand.\n\
# Regenerated on every key change and daemon upgrade.\n\
@@ -44,9 +49,12 @@ pub fn render_config_yaml() -> String {
bind_addr: \"127.0.0.1\"\n\
transports:\n \
udp:\n \
bind_addr: \"0.0.0.0:{port}\"\n\
bind_addr: \"0.0.0.0:{udp}\"\n \
tcp:\n \
bind_addr: \"0.0.0.0:{tcp}\"\n\
peers: []\n",
port = DEFAULT_UDP_PORT,
udp = DEFAULT_UDP_PORT,
tcp = DEFAULT_TCP_PORT,
)
}
@@ -78,11 +86,65 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
install_result?;
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
// Heal a legacy fips_key.pub that was written as bech32 npub text
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
// fips expects 32 raw bytes; a text file silently passes through
// and then the daemon can't identify itself to peers. This
// rewrites the source file in place with the correct binary form
// derived from fips_key before staging it to /etc/fips/fips.pub.
normalize_pub_file(&src_key, &src_pub).await?;
sudo_install_file(&src_pub, DAEMON_PUB_PATH, "0644").await?;
Ok(())
}
/// Ensure `fips_key.pub` is 32 raw bytes. If it's a bech32 npub text
/// file (from the pre-fix writer), decode it and rewrite in place. If
/// the file is missing or its content doesn't match either format,
/// re-derive the public key from `fips_key` and write that.
pub async fn normalize_pub_file(key_path: &Path, pub_path: &Path) -> Result<()> {
// Happy path: already 32 raw bytes.
if let Ok(bytes) = tokio::fs::read(pub_path).await {
if bytes.len() == 32 {
return Ok(());
}
// bech32 npub text from the pre-fix writer: decode in place.
if let Ok(s) = std::str::from_utf8(&bytes) {
let trimmed = s.trim();
if trimmed.starts_with("npub1") {
if let Ok(pk) = nostr_sdk::PublicKey::parse(trimmed) {
let raw: [u8; 32] = pk.to_bytes();
tokio::fs::write(pub_path, raw)
.await
.context("rewriting fips_key.pub as 32 raw bytes")?;
tracing::info!(
"Migrated legacy bech32 fips_key.pub to raw-byte form at {}",
pub_path.display()
);
return Ok(());
}
}
}
}
// Fallback: no pub file, or unreadable format. Re-derive from the
// private key file (already validated by load_fips_keys).
let secret_bytes = tokio::fs::read(key_path)
.await
.with_context(|| format!("read {} to derive public", key_path.display()))?;
let text = std::str::from_utf8(&secret_bytes)
.context("fips_key is not UTF-8 — can't derive public")?;
let secret = nostr_sdk::SecretKey::parse(text.trim())
.context("fips_key not parseable as bech32 nsec")?;
let keys = nostr_sdk::Keys::new(secret);
let raw: [u8; 32] = keys.public_key().to_bytes();
tokio::fs::write(pub_path, raw)
.await
.context("writing re-derived fips_key.pub")?;
tracing::info!("Re-derived fips_key.pub from fips_key");
Ok(())
}
async fn sudo_install_dir(path: &str) -> Result<()> {
let out = Command::new("sudo")
.args(["install", "-d", "-m", "0755", path])
@@ -131,7 +193,9 @@ mod tests {
let yaml = render_config_yaml();
assert!(yaml.contains("persistent: true"));
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_UDP_PORT)));
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_TCP_PORT)));
assert!(yaml.contains("udp:"));
assert!(yaml.contains("tcp:"));
assert!(yaml.contains("tun:"));
assert!(yaml.contains("name: fips0"));
// Upstream fips dropped the `tor:` transport variant; archipelago
+31 -8
View File
@@ -25,6 +25,7 @@
// the module is deliberately API-ready ahead of those call-sites.
#![allow(dead_code)]
pub mod anchors;
pub mod config;
pub mod dial;
pub mod iface;
@@ -52,6 +53,14 @@ pub const UPSTREAM_REPO: &str = "jmcorgan/fips";
/// Default UDP port the daemon listens on.
pub const DEFAULT_UDP_PORT: u16 = 8668;
/// Default TCP port the daemon listens on. Used as a fallback when a
/// peer can't be reached over UDP — common on networks that block UDP
/// (corporate/guest wifi) and the path the public fips.v0l.io anchor
/// currently accepts. Upstream factory default enables both transports
/// and archipelago intentionally matches that baseline so fresh nodes
/// can reach the broader FIPS mesh without operator config.
pub const DEFAULT_TCP_PORT: u16 = 8443;
/// Upstream systemd unit shipped by the `fips` debian package. Archipelago
/// prefers its own supervision (`archipelago-fips.service`) but respects an
/// already-running upstream unit so legacy/dev nodes — where no seed-derived
@@ -98,7 +107,13 @@ pub struct FipsStatus {
impl FipsStatus {
/// Snapshot the current state across package, key, and service.
pub async fn query(identity_dir: &Path) -> Self {
///
/// `data_dir` is the archipelago data-dir (used to load the
/// operator-configured seed-anchor list so "anchor_connected" means
/// "at least one authenticated peer matches a public or configured
/// seed anchor", not just "fips.v0l.io specifically").
pub async fn query(data_dir: &Path) -> Self {
let identity_dir = identity_dir_from(data_dir);
let installed = service::package_installed().await;
let version = if installed {
service::daemon_version().await.ok()
@@ -109,17 +124,24 @@ impl FipsStatus {
let upstream_service_state = service::unit_state(UPSTREAM_SERVICE_UNIT).await;
let service_active =
service_state == "active" || upstream_service_state == "active";
let key_present = crate::identity::fips_key_exists(identity_dir);
let key_present = crate::identity::fips_key_exists(&identity_dir);
// Prefer the seed-derived npub; otherwise read the daemon's own
// key file at /etc/fips/fips.pub (world-readable per debian pkg).
let npub = match crate::identity::fips_npub(identity_dir).await {
let npub = match crate::identity::fips_npub(&identity_dir).await {
Ok(Some(n)) => Some(n),
_ => service::read_upstream_npub().await.ok().flatten(),
};
let (authenticated_peer_count, anchor_connected) = if service_active {
service::peer_connectivity_summary().await
// Build the anchor-candidate list: hardcoded public anchor
// plus every entry in the operator's seed-anchors.json.
// The card lights up if any of them is authenticated.
let mut anchor_npubs = vec![service::PUBLIC_ANCHOR_NPUB.to_string()];
if let Ok(seed) = anchors::load(data_dir).await {
anchor_npubs.extend(seed.into_iter().map(|a| a.npub));
}
service::peer_connectivity_summary(&anchor_npubs).await
} else {
(0, false)
};
@@ -152,10 +174,11 @@ mod tests {
#[tokio::test]
async fn test_status_reports_no_key_pre_onboarding() {
let dir = tempfile::tempdir().unwrap();
let id_dir = dir.path().join("identity");
tokio::fs::create_dir_all(&id_dir).await.unwrap();
let status = FipsStatus::query(&id_dir).await;
// query() now takes a data_dir (parent) rather than identity_dir,
// since it also reads seed-anchors.json for the anchor check.
// No identity/ subdir → no key; no seed-anchors.json → public
// anchor is the only candidate.
let status = FipsStatus::query(dir.path()).await;
assert!(!status.key_present, "no key before onboarding");
assert!(status.npub.is_none());
// `installed`, `service_state`, `version` depend on the host and are
+53 -38
View File
@@ -97,6 +97,27 @@ pub async fn restart(unit: &str) -> Result<()> {
sudo_systemctl("restart", unit).await
}
/// Resolve which systemd unit is actually supervising the fips daemon
/// on this host. Nodes installed from the archipelago ISO run
/// `archipelago-fips.service`; nodes that were apt-installed (or had
/// fips running before archipelago took over) may only have the
/// upstream `fips.service`. Restart/Reconnect must operate on whichever
/// one is running, otherwise the UI button is a silent no-op.
///
/// Returns the archipelago-managed unit name if it's active,
/// else the upstream unit name if that's active,
/// else the archipelago-managed name as a default (so activate() can
/// bring it up).
pub async fn active_unit() -> &'static str {
if unit_state(super::SERVICE_UNIT).await == "active" {
return super::SERVICE_UNIT;
}
if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" {
return super::UPSTREAM_SERVICE_UNIT;
}
super::SERVICE_UNIT
}
pub async fn mask(unit: &str) -> Result<()> {
let _ = sudo_systemctl("stop", unit).await;
let _ = sudo_systemctl("disable", unit).await;
@@ -108,12 +129,19 @@ pub async fn mask(unit: &str) -> Result<()> {
pub const PUBLIC_ANCHOR_NPUB: &str =
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
/// Summarise peer connectivity from `fipsctl show peers` + `identity-cache`.
/// Returns `(authenticated_peer_count, anchor_connected)`. Shells out rather
/// than embedding a fips client because fipsctl is the daemon's own ground
/// truth — the daemon can always rewrite its internal routing and we'd
/// rather be consistent with `fipsctl` than snapshot it ourselves.
pub async fn peer_connectivity_summary() -> (u32, bool) {
/// Summarise peer connectivity from `fipsctl show peers`. Returns
/// `(authenticated_peer_count, anchor_connected)`.
///
/// `anchor_candidates` is the operator-controlled list of npubs this
/// node considers a valid mesh anchor — always includes the hard-coded
/// public anchor, plus any entries from `seed-anchors.json`. A node is
/// "anchor connected" when at least one currently-authenticated peer
/// matches one of these npubs. We used to check the identity cache
/// (which includes transient hearsay from other peers), but a cache
/// hit on `fips.v0l.io` didn't mean we could actually route through
/// it, and the card lied to users whose mesh was federated through
/// their own seed anchors instead.
pub async fn peer_connectivity_summary(anchor_candidates: &[String]) -> (u32, bool) {
let peers_json = match Command::new("sudo")
.args(["-n", "fipsctl", "show", "peers"])
.output()
@@ -122,39 +150,26 @@ pub async fn peer_connectivity_summary() -> (u32, bool) {
Ok(o) if o.status.success() => o.stdout,
_ => return (0, false),
};
let authenticated_peer_count =
match serde_json::from_slice::<serde_json::Value>(&peers_json) {
Ok(v) => v
.get("peers")
.and_then(|p| p.as_array())
.map(|a| a.len() as u32)
.unwrap_or(0),
Err(_) => 0,
let parsed: serde_json::Value =
match serde_json::from_slice(&peers_json) {
Ok(v) => v,
Err(_) => return (0, false),
};
// Anchor check: look in identity-cache (known node pubkeys the daemon
// has heard about) rather than authenticated peers — the anchor may be
// in the cache but not currently at session depth.
let cache_json = match Command::new("sudo")
.args(["-n", "fipsctl", "show", "identity-cache"])
.output()
.await
{
Ok(o) if o.status.success() => o.stdout,
_ => return (authenticated_peer_count, false),
};
let anchor_connected = match serde_json::from_slice::<serde_json::Value>(&cache_json) {
Ok(v) => v
.get("entries")
.and_then(|e| e.as_array())
.map(|entries| {
entries
.iter()
.any(|e| e.get("npub").and_then(|n| n.as_str()) == Some(PUBLIC_ANCHOR_NPUB))
})
.unwrap_or(false),
Err(_) => false,
};
let peers = parsed
.get("peers")
.and_then(|p| p.as_array())
.cloned()
.unwrap_or_default();
let authenticated_peer_count = peers.len() as u32;
let anchor_connected = peers.iter().any(|p| {
let npub = p.get("npub").and_then(|n| n.as_str()).unwrap_or_default();
let connected = p
.get("connectivity")
.and_then(|c| c.as_str())
.map(|s| s == "connected")
.unwrap_or(true);
connected && anchor_candidates.iter().any(|a| a == npub)
});
(authenticated_peer_count, anchor_connected)
}
+11 -3
View File
@@ -219,14 +219,22 @@ async fn write_fips_key_from_seed(
.await
.context("Failed to set FIPS key permissions")?;
}
let npub = keys.public_key().to_bech32().unwrap_or_default();
fs::write(&pub_path, format!("{npub}\n"))
// Upstream fips daemon expects 32 raw bytes in /etc/fips/fips.pub —
// not a bech32 npub string. Writing the bech32 form here meant the
// installed .pub file was a 63-char text file the daemon parsed as
// 63 raw bytes of garbage, so it couldn't identify itself to peers
// and the anchor never handshook. Write the raw public-key bytes
// (PublicKey::to_bytes returns a [u8; 32]) so the daemon reads
// them directly.
let raw_pub: [u8; 32] = keys.public_key().to_bytes();
fs::write(&pub_path, raw_pub)
.await
.context("Failed to write FIPS public key")?;
let npub_for_log = keys.public_key().to_bech32().unwrap_or_default();
tracing::info!(
"Derived FIPS mesh key from seed (npub: {}...)",
npub.chars().take(20).collect::<String>()
npub_for_log.chars().take(20).collect::<String>()
);
Ok(())
}
+74
View File
@@ -216,6 +216,80 @@ impl IdentityManager {
Ok(record)
}
/// Mirror an existing Ed25519 signing key as a manager-level identity.
///
/// Used at boot to expose the node's own seed-derived key (the one that
/// backs `server_info.pubkey` and peer-to-peer connections) as an
/// entry in the Identities page, so all three surfaces — DID Status,
/// "Node" entry on Identities, and peer-connect DID — resolve to the
/// same DID. The id is deterministic (`node-<pubkey16>`), so repeated
/// calls on the same key are idempotent: if the file already exists
/// we return the existing record untouched.
pub async fn create_from_signing_key(
&self,
name: String,
purpose: IdentityPurpose,
signing_key: SigningKey,
) -> Result<IdentityRecord> {
let pubkey_hex = hex::encode(signing_key.verifying_key().as_bytes());
let did = did_key_from_pubkey_hex(&pubkey_hex)?;
let id = format!("node-{}", &pubkey_hex[..16]);
// Idempotent: if we already mirrored this key, just return it.
let file_path = self.identities_dir.join(format!("{}.json", id));
if file_path.exists() {
return self.get(&id).await;
}
let created_at = chrono::Utc::now().to_rfc3339();
// Mark as the node (master) identity so it gets the hex SVG.
let default_profile = IdentityProfile {
picture: Some(crate::avatar::default_picture(&pubkey_hex, true)),
..Default::default()
};
let identity_file = IdentityFile {
id: id.clone(),
name: name.clone(),
purpose: purpose.clone(),
secret_key: signing_key.to_bytes().to_vec(),
pubkey_hex: pubkey_hex.clone(),
did: did.clone(),
created_at,
nostr_secret_hex: None,
nostr_pubkey_hex: None,
profile: Some(default_profile),
derivation_index: Some(0),
};
let json = serde_json::to_string_pretty(&identity_file)
.context("Failed to serialize identity")?;
fs::write(&file_path, json.as_bytes())
.await
.context("Failed to write identity file")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o600))
.await
.context("Failed to set identity file permissions")?;
}
// First identity becomes the default.
let (existing, _) = self.list().await?;
if existing.len() <= 1 {
self.set_default(&id).await?;
}
tracing::info!(
"Mirrored node signing key as Node identity '{}' ({})",
name,
purpose
);
self.get(&id).await
}
/// Create a new identity with keys derived from a BIP-39 master seed.
/// The derivation index is auto-incremented and persisted.
pub async fn create_from_seed(
+46 -1
View File
@@ -28,6 +28,7 @@ mod avatar;
mod backup;
mod bitcoin_rpc;
mod blobs;
mod bootstrap;
mod config;
mod constants;
mod container;
@@ -85,6 +86,36 @@ async fn main() -> Result<()> {
info!("Starting Archipelago Bitcoin Node OS");
// Self-heal web-ui permissions. The OTA updater in <=v1.7.38 left
// /opt/archipelago/web-ui as drwx------ (700) after the atomic
// swap — nginx (www-data) then returned 500/403 on every request
// until someone shelled in and chmod'd it. Check on every boot
// and repair if needed so a node auto-recovers after the next
// service restart that follows a broken OTA.
tokio::spawn(async move {
use std::os::unix::fs::PermissionsExt;
let web_ui = std::path::Path::new("/opt/archipelago/web-ui");
if let Ok(meta) = tokio::fs::metadata(web_ui).await {
let mode = meta.permissions().mode() & 0o777;
if mode & 0o005 != 0o005 {
tracing::warn!(
"web-ui perms {:o} not world-readable — self-healing",
mode
);
let _ = tokio::process::Command::new("sudo")
.args([
"-n",
"chmod",
"-R",
"u=rwX,go=rX",
"/opt/archipelago/web-ui",
])
.status()
.await;
}
}
});
// Load configuration
let config = Config::load().await?;
info!("📁 Data directory: {}", config.data_dir.display());
@@ -171,6 +202,11 @@ async fn main() -> Result<()> {
update::run_update_scheduler(update_data_dir).await;
});
// Synchronize host-side doctor artifacts (script + systemd units) with
// what's embedded in this binary. Runs in the background so it never
// delays server readiness; best-effort, warnings only.
tokio::spawn(bootstrap::ensure_doctor_installed());
// Spawn periodic container snapshot (for crash recovery)
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
@@ -229,5 +265,14 @@ async fn main() -> Result<()> {
crash_recovery::remove_pid_marker(&config.data_dir).await;
info!("Archipelago shut down cleanly");
Ok(())
// Hard-exit after logging. All business state is persisted by now
// (connections drained, PID marker removed, disk flushes done via
// tokio::fs awaits). Letting tokio try to drop the runtime instead
// can stall for 15s+ on non-daemon OS threads we don't directly
// own (mdns_sd daemon, reqwest resolver pool, etc.) — long enough
// for systemd's TimeoutStopSec to SIGKILL us and mark the service
// Failed, which makes an otherwise-successful update look like a
// crash in `systemctl status`.
std::process::exit(0);
}
+69 -4
View File
@@ -89,22 +89,32 @@ impl Server {
// Load persisted messages (Archipelago channel)
node_message::init(&config.data_dir).await;
// Auto-create default identity if none exist (fresh boot or factory reset)
// Auto-create the Node identity on fresh boot, mirroring the node's
// own signing key (seed-derived when onboarded, random otherwise).
// This keeps the DID shown on the Identities page, the DID Status
// card, and the DID used for peer-to-peer connects all aligned on
// one value — the seed-derived node DID. Idempotent: if the entry
// already exists from a prior boot, create_from_signing_key returns
// the existing record unchanged.
{
let im = crate::identity_manager::IdentityManager::new(&config.data_dir).await;
if let Ok(mgr) = im {
if let Ok((list, _)) = mgr.list().await {
if list.is_empty() {
let signing_key = ed25519_dalek::SigningKey::from_bytes(
&identity.signing_key().to_bytes(),
);
match mgr
.create(
"Default".to_string(),
.create_from_signing_key(
"Node".to_string(),
crate::identity_manager::IdentityPurpose::Personal,
signing_key,
)
.await
{
Ok(record) => {
let _ = mgr.create_nostr_key(&record.id).await;
tracing::info!(did = %record.did, "Auto-created default identity with Nostr key");
tracing::info!(did = %record.did, "Auto-created Node identity mirroring node key");
}
Err(e) => tracing::debug!("Auto-identity creation (non-fatal): {}", e),
}
@@ -353,6 +363,34 @@ impl Server {
});
}
// FIPS seed-anchor apply loop — every 5 minutes we re-push the
// configured seed anchors into the running fips daemon via
// `fipsctl connect`. This keeps the mesh bootstrap resilient:
// operators add cluster-local anchors in the UI, and a daemon
// restart or a flaky public anchor can't strand the node.
// First run is delayed 30s so fips has time to come up after
// onboarding before we start dialing.
{
let data_dir = config.data_dir.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(30)).await;
let mut interval = tokio::time::interval(Duration::from_secs(300));
loop {
interval.tick().await;
match crate::fips::anchors::load(&data_dir).await {
Ok(list) if !list.is_empty() => {
let _ = crate::fips::anchors::apply(&list).await;
}
Ok(_) => { /* no seed anchors configured yet */ }
Err(e) => tracing::debug!(
"Seed-anchor apply: load failed (non-fatal): {}",
e
),
}
}
});
}
// did:dht auto-refresh — re-publish DHT records every 2 hours
if config.nostr_discovery_enabled {
let data_dir = config.data_dir.clone();
@@ -482,10 +520,37 @@ impl Server {
tracing::warn!("FIPS key load/migrate failed: {}", e);
return;
}
// Check if the installed fips.yaml matches what we'd
// render now. If not, we need to restart the daemon after
// reinstalling so it picks up schema changes (e.g. the
// v1.7.25 re-addition of the TCP transport). Without this,
// OTA'd nodes would be stuck on the old UDP-only config
// until someone manually clicked Reconnect.
let expected = crate::fips::config::render_config_yaml();
let installed = tokio::fs::read_to_string("/etc/fips/fips.yaml")
.await
.ok();
let config_changed = installed.as_deref() != Some(expected.as_str());
if let Err(e) = crate::fips::config::install(&identity_dir).await {
tracing::warn!("FIPS config install failed on startup: {}", e);
return;
}
if config_changed {
tracing::info!(
"FIPS config schema changed on disk — restarting daemon to pick up new transports"
);
// Restart whichever unit is actually supervising
// the daemon (archipelago-fips vs upstream fips).
let unit = crate::fips::service::active_unit().await;
if let Err(e) = crate::fips::service::restart(unit).await {
tracing::warn!(
"FIPS restart after config migration failed on {}: {} — user can retry via fips.reconnect",
unit,
e
);
}
}
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
tracing::warn!(
"archipelago-fips activate failed on startup: {} — user can retry via fips.install RPC",
+12
View File
@@ -160,6 +160,18 @@ impl LanTransport {
}
}
impl Drop for LanTransport {
// The mdns_sd daemon runs on its own OS thread and the browse
// listener task blocks on a sync channel. Without this call both
// keep the process alive past SIGTERM, long enough for systemd to
// SIGKILL us — which makes a normal update look like a crash.
fn drop(&mut self) {
if let Some(daemon) = self.daemon.take() {
let _ = daemon.shutdown();
}
}
}
impl NodeTransport for LanTransport {
fn kind(&self) -> TransportKind {
TransportKind::Lan
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,6 +1,7 @@
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
COPY index.html /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/
COPY assets/ /usr/share/nginx/html/assets/
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Run nginx as root to avoid chown failures in rootless Podman user namespaces
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 976 KiB

+121 -13
View File
@@ -6,7 +6,7 @@
<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>Bitcoin Knots - Archipelago</title>
<title id="pageTitle">Bitcoin Node - Archipelago</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
* {
@@ -336,9 +336,10 @@
<!-- Logo - Top Left -->
<div class="flex-shrink-0">
<div class="logo-gradient-border">
<img
<img
id="implLogo"
src="/assets/img/app-icons/bitcoin-knots.webp"
alt="Bitcoin Knots"
alt="Bitcoin Node"
class="w-16 h-16"
style="object-fit: contain;"
onerror="this.style.display='none'"
@@ -348,8 +349,8 @@
<!-- Title and Description -->
<div class="flex-1 min-w-0">
<h1 class="text-3xl font-bold text-white mb-2">Bitcoin Knots</h1>
<p class="text-white/70">Enhanced Bitcoin node implementation</p>
<h1 id="implName" class="text-3xl font-bold text-white mb-2">Bitcoin Node</h1>
<p id="implTagline" class="text-white/70">Detecting implementation</p>
</div>
<!-- Node Status Info - Compact on Desktop -->
@@ -385,8 +386,18 @@
</div>
</div>
<button
onclick="openSettings()"
<div class="info-card flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2 1.6 3 4 3h8c2.4 0 4-1 4-3V7M4 7c0-2 1.6-3 4-3h8c2.4 0 4 1 4 3M4 7h16M9 11h6M9 15h6" />
</svg>
<div>
<p class="text-xs text-white/60">Storage</p>
<p class="text-sm font-medium text-white" id="storageMode">Loading...</p>
</div>
</div>
<button
onclick="openSettings()"
class="px-4 py-3 glass-button rounded-lg text-sm font-medium"
>
Settings
@@ -556,19 +567,23 @@
<div class="space-y-3">
<div class="p-3 bg-white/5 rounded-lg">
<div class="font-semibold text-white mb-1">Network Mode</div>
<div class="text-white/70 text-sm">Regtest (Development)</div>
<div class="text-white/70 text-sm" id="settingsNetworkMode">Loading…</div>
</div>
<div class="p-3 bg-white/5 rounded-lg">
<div class="font-semibold text-white mb-1">Storage Mode</div>
<div class="text-white/70 text-sm" id="settingsStorageMode">Loading…</div>
</div>
<div class="p-3 bg-white/5 rounded-lg">
<div class="font-semibold text-white mb-1">Transaction Index</div>
<div class="text-white/70 text-sm">Enabled (txindex=1)</div>
<div class="text-white/70 text-sm" id="settingsTxIndex">Loading…</div>
</div>
<div class="p-3 bg-white/5 rounded-lg">
<div class="font-semibold text-white mb-1">ZMQ Publishing</div>
<div class="text-white/70 text-sm">Block & TX notifications enabled</div>
<div class="text-white/70 text-sm" id="settingsZmq">Loading…</div>
</div>
<div class="p-3 bg-white/5 rounded-lg">
<div class="font-semibold text-white mb-1">RPC Access</div>
<div class="text-white/70 text-sm">Enabled on 0.0.0.0:18443</div>
<div class="text-white/70 text-sm" id="settingsRpc">Loading…</div>
</div>
</div>
</div>
@@ -630,6 +645,31 @@
}
}
// Implementation branding — detected from getnetworkinfo.subversion.
// Bitcoin Knots identifies as "/Satoshi:<ver>/Knots:<date>/", Bitcoin Core as "/Satoshi:<ver>/".
let brandingApplied = false;
function applyImplBranding(subversion) {
if (brandingApplied) return;
if (!subversion) return;
const isKnots = /Knots/i.test(subversion);
const name = isKnots ? 'Bitcoin Knots' : 'Bitcoin Core';
const tagline = isKnots
? 'Enhanced Bitcoin node implementation'
: 'Reference Bitcoin node implementation';
const icon = isKnots
? '/assets/img/app-icons/bitcoin-knots.webp'
: '/assets/img/app-icons/bitcoin-core.svg';
const pageTitle = document.getElementById('pageTitle');
const implName = document.getElementById('implName');
const implTagline = document.getElementById('implTagline');
const implLogo = document.getElementById('implLogo');
if (pageTitle) pageTitle.textContent = `${name} - Archipelago`;
if (implName) implName.textContent = name;
if (implTagline) implTagline.textContent = tagline;
if (implLogo) { implLogo.src = icon; implLogo.alt = name; }
brandingApplied = true;
}
// Track last block count for animations
let lastBlockCount = 0;
@@ -648,7 +688,9 @@
}
const networkInfo = await callRPC('getnetworkinfo');
applyImplBranding(networkInfo && networkInfo.subversion);
// Update network mode
const chain = blockchainInfo.chain || 'unknown';
const networkType = document.getElementById('networkType');
@@ -666,6 +708,70 @@
if (networkType) networkType.textContent = networkShort;
// Mirror to Settings modal — Network Mode
const settingsNetworkMode = document.getElementById('settingsNetworkMode');
if (settingsNetworkMode) {
const labels = { main: 'Mainnet', test: 'Testnet', signet: 'Signet', regtest: 'Regtest (Development)' };
settingsNetworkMode.textContent = labels[chain] || networkShort;
}
// Update storage mode (pruned vs full archive)
const storageMode = document.getElementById('storageMode');
if (storageMode) {
const sizeGb = blockchainInfo.size_on_disk
? (blockchainInfo.size_on_disk / 1e9).toFixed(1) + ' GB'
: null;
if (blockchainInfo.pruned) {
storageMode.textContent = sizeGb ? `Pruned · ${sizeGb}` : 'Pruned';
storageMode.className = 'text-sm font-medium text-amber-300';
} else {
storageMode.textContent = sizeGb ? `Full Archive · ${sizeGb}` : 'Full Archive';
storageMode.className = 'text-sm font-medium text-emerald-300';
}
}
// Mirror to Settings modal — Storage Mode
const settingsStorageMode = document.getElementById('settingsStorageMode');
if (settingsStorageMode) {
if (blockchainInfo.pruned) {
const heightNote = blockchainInfo.prune_height != null
? ` (keeping from block ${blockchainInfo.prune_height.toLocaleString()})` : '';
settingsStorageMode.textContent = `Pruned${heightNote}`;
} else {
settingsStorageMode.textContent = 'Full archive (no pruning)';
}
}
// Populate Settings — Transaction Index, ZMQ, RPC (fire-and-forget)
(async () => {
const txIndexEl = document.getElementById('settingsTxIndex');
if (txIndexEl) {
const idx = await callRPC('getindexinfo');
if (idx && typeof idx === 'object') {
const names = Object.keys(idx);
txIndexEl.textContent = names.length
? `Enabled: ${names.join(', ')}`
: 'Disabled';
} else {
txIndexEl.textContent = 'Disabled';
}
}
const zmqEl = document.getElementById('settingsZmq');
if (zmqEl) {
const zmq = await callRPC('getzmqnotifications');
if (Array.isArray(zmq) && zmq.length) {
zmqEl.textContent = zmq.map(z => `${z.type}@${z.address}`).join('; ');
} else {
zmqEl.textContent = 'Not enabled';
}
}
const rpcEl = document.getElementById('settingsRpc');
if (rpcEl && networkInfo) {
const port = chain === 'main' ? 8332 : (chain === 'test' ? 18332 : (chain === 'signet' ? 38332 : 18443));
rpcEl.textContent = `Reachable on port ${port}`;
}
})();
// Update sync status
const blocks = blockchainInfo.blocks || 0;
const headers = blockchainInfo.headers || 0;
@@ -779,7 +885,9 @@
const peerInfo = await callRPC('getpeerinfo');
if (networkInfo && blockchainInfo) {
logsContent.textContent = `Bitcoin Knots version ${networkInfo.subversion || 'unknown'}
applyImplBranding(networkInfo.subversion);
const implLabel = /Knots/i.test(networkInfo.subversion || '') ? 'Bitcoin Knots' : 'Bitcoin Core';
logsContent.textContent = `${implLabel} version ${networkInfo.subversion || 'unknown'}
Network: ${blockchainInfo.chain}
Blocks: ${blockchainInfo.blocks}
Headers: ${blockchainInfo.headers}
-10
View File
@@ -1,10 +0,0 @@
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
COPY index.html /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf
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 8202
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]
-236
View File
@@ -1,236 +0,0 @@
<!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">
<title>FIPS - Archipelago</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; color: white; overflow-x: hidden; }
.bg-layer { position: fixed; inset: 0; z-index: -10; background: linear-gradient(135deg, rgba(5,20,15,0.95) 0%, rgba(10,30,25,0.98) 50%, rgba(5,15,20,0.95) 100%); }
.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); z-index: -5; }
.glass-card { background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-radius: 1rem; border: 1px solid rgba(255, 255, 255, 0.12); transform: translateZ(0); isolation: isolate; }
.info-card { background: rgba(255, 255, 255, 0.05); border-radius: 12px; padding: 12px; border: 1px solid rgba(255, 255, 255, 0.08); }
.container { max-width: 56rem; margin: 0 auto; padding: 1.5rem; }
.flex { display: flex; } .flex-col { flex-direction: column; } .items-center { align-items: center; }
.gap-3 { gap: 0.75rem; } .gap-4 { gap: 1rem; } .flex-1 { flex: 1; } .flex-shrink-0 { flex-shrink: 0; }
.mb-2 { margin-bottom: 0.5rem; } .mb-4 { margin-bottom: 1rem; } .mb-6 { margin-bottom: 1.5rem; }
.p-5 { padding: 1.25rem; } .p-6 { padding: 1.5rem; }
.grid { display: grid; } .grid-cols-2 { grid-template-columns: repeat(2, 1fr); } .grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.text-xs { font-size: 0.75rem; } .text-sm { font-size: 0.875rem; } .text-lg { font-size: 1.125rem; }
.text-xl { font-size: 1.25rem; } .text-2xl { font-size: 1.5rem; }
.font-bold { font-weight: 700; } .font-semibold { font-weight: 600; } .font-medium { font-weight: 500; } .font-mono { font-family: monospace; }
.text-white-70 { color: rgba(255,255,255,0.7); } .text-white-60 { color: rgba(255,255,255,0.6); } .text-white-50 { color: rgba(255,255,255,0.5); }
.text-emerald { color: #34d399; } .text-green { color: #4ade80; } .text-yellow { color: #fbbf24; } .text-red { color: #f87171; }
.justify-between { justify-content: space-between; }
.status-dot { width: 0.75rem; height: 0.75rem; border-radius: 9999px; }
.bg-green { background: #4ade80; } .bg-yellow { background: #fbbf24; } .bg-red { background: #f87171; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
.icon-box { width: 3.5rem; height: 3.5rem; border-radius: 0.75rem; background: rgba(52, 211, 153, 0.15); display: flex; align-items: center; justify-content: center; }
.step { display: flex; gap: 1rem; align-items: flex-start; }
.step-num { width: 2rem; height: 2rem; border-radius: 50%; background: rgba(52, 211, 153, 0.2); border: 1px solid rgba(52, 211, 153, 0.4); display: flex; align-items: center; justify-content: center; font-size: 0.875rem; font-weight: 700; color: #34d399; flex-shrink: 0; }
.copy-btn { padding: 0.5rem 0.625rem; background: none; border: none; border-left: 1px solid rgba(255,255,255,0.1); cursor: pointer; color: rgba(255,255,255,0.4); transition: all 0.2s ease; display: flex; align-items: center; }
.copy-btn:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
.copy-btn.copied { color: #4ade80; }
.field-row { display: flex; align-items: center; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 0.5rem; overflow: hidden; }
.field-value { flex: 1; padding: 0.625rem 0.875rem; font-family: monospace; font-size: 0.8125rem; color: rgba(255,255,255,0.9); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.field-label { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255,255,255,0.4); margin-bottom: 0.375rem; }
.feature-icon { width: 2.5rem; height: 2.5rem; border-radius: 0.5rem; background: rgba(52, 211, 153, 0.1); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
@media (max-width: 640px) { .grid-cols-2 { grid-template-columns: 1fr; } .grid-cols-3 { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="bg-layer"></div>
<div class="overlay"></div>
<div class="container">
<!-- Header -->
<div class="glass-card p-6 mb-6">
<div class="flex items-center gap-4">
<div class="icon-box flex-shrink-0">
<svg style="width:1.75rem;height:1.75rem;color:#34d399" 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>
<div class="flex-1">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold">FIPS</h1>
<span class="text-xs text-white-50">v0.1.0</span>
</div>
<p class="text-white-60 text-sm">Free Internetworking Peering System</p>
</div>
<div class="info-card flex items-center gap-3">
<div id="statusDot" class="status-dot bg-yellow animate-pulse"></div>
<div>
<p class="text-xs text-white-50">Status</p>
<p class="text-sm font-medium" id="statusText">Checking...</p>
</div>
</div>
</div>
</div>
<!-- What It Does -->
<div class="glass-card p-5 mb-6">
<h2 class="text-lg font-semibold mb-4" style="color:#34d399">What is FIPS?</h2>
<p class="text-white-70 text-sm mb-4" style="line-height:1.6">
FIPS is a <strong style="color:white">self-organizing encrypted mesh network</strong>. Each node gets a
<strong style="color:white">secp256k1 keypair</strong> (same as Nostr/Bitcoin) that serves as its identity.
Nodes discover each other, negotiate encryption using the <strong style="color:white">Noise protocol</strong>,
and route traffic without any central authority. A virtual network interface (<code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">fips0</code>)
lets unmodified applications — SSH, web browsers, anything — communicate transparently over the mesh.
Think of it as <strong style="color:white">a new internet layer, built on cryptographic identity</strong>.
</p>
<div class="grid grid-cols-3 gap-3">
<div class="info-card flex items-center gap-3">
<div class="feature-icon">
<svg style="width:1.25rem;height:1.25rem;color:#34d399" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>
</div>
<div>
<p class="text-sm font-medium">Zero Config</p>
<p class="text-xs text-white-50">Self-organizing mesh</p>
</div>
</div>
<div class="info-card flex items-center gap-3">
<div class="feature-icon">
<svg style="width:1.25rem;height:1.25rem;color:#34d399" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
</div>
<div>
<p class="text-sm font-medium">End-to-End Encrypted</p>
<p class="text-xs text-white-50">Noise IK + XK protocols</p>
</div>
</div>
<div class="info-card flex items-center gap-3">
<div class="feature-icon">
<svg style="width:1.25rem;height:1.25rem;color:#34d399" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064"/></svg>
</div>
<div>
<p class="text-sm font-medium">Multi-Transport</p>
<p class="text-xs text-white-50">UDP, TCP, Tor, BLE</p>
</div>
</div>
</div>
</div>
<!-- Node Identity -->
<div class="glass-card p-5 mb-6">
<h2 class="text-lg font-semibold mb-4" style="color:#34d399">Node Identity</h2>
<p class="text-white-60 text-sm mb-4">Your node's Nostr public key doubles as its FIPS mesh address. Share with peers to connect.</p>
<div class="grid grid-cols-2 gap-3 mb-4">
<div>
<div class="field-label">Nostr Public Key (npub)</div>
<div class="field-row">
<span class="field-value" id="npub">Loading...</span>
<button class="copy-btn" onclick="copyField('npub', this)" title="Copy">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
<div>
<div class="field-label">Mesh Ports</div>
<div class="field-row">
<span class="field-value">UDP 2121 / TCP 8443</span>
<button class="copy-btn" onclick="copyText('2121', this)" title="Copy">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
</div>
</div>
<!-- How to Use -->
<div class="glass-card p-5 mb-6">
<h2 class="text-lg font-semibold mb-4" style="color:#34d399">How to Use</h2>
<div class="flex flex-col gap-4">
<div class="step">
<div class="step-num">1</div>
<div>
<p class="text-sm font-semibold mb-2">Install FIPS on your other devices</p>
<p class="text-xs text-white-60" style="line-height:1.5">Download <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">fips</code> from <a href="https://github.com/jmcorgan/fips" style="color:#34d399;text-decoration:underline" target="_blank">GitHub</a>. Build with <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">cargo build --release</code> (requires Rust 1.85+).</p>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div>
<p class="text-sm font-semibold mb-2">Configure peers in fips.yaml</p>
<p class="text-xs text-white-60" style="line-height:1.5">Edit <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">/etc/fips/fips.yaml</code> on each device. Add your Archipelago node's IP and port as a peer. The node's npub above is its identity on the mesh.</p>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div>
<p class="text-sm font-semibold mb-2">Start the daemon and connect</p>
<p class="text-xs text-white-60" style="line-height:1.5">Run <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">fips --config /etc/fips/fips.yaml</code>. A <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">fips0</code> virtual interface appears. Use <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">fipsctl show peers</code> to see connected nodes. You can now SSH, browse, or run any IP app over the encrypted mesh using <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">.fips</code> DNS names.</p>
</div>
</div>
</div>
</div>
<!-- Container Logs -->
<div class="glass-card p-5">
<h2 class="text-lg font-semibold mb-4" style="color:#34d399">Container Logs</h2>
<div id="logs" style="background:rgba(0,0,0,0.4);border-radius:0.5rem;padding:0.75rem;font-family:monospace;font-size:0.75rem;color:rgba(255,255,255,0.6);max-height:200px;overflow-y:auto;line-height:1.6">
Fetching logs...
</div>
</div>
</div>
<script>
var COPY_SVG = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>';
var CHECK_SVG = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>';
function flashCopied(btn) { btn.classList.add('copied'); var o = btn.innerHTML; btn.innerHTML = CHECK_SVG; setTimeout(function() { btn.classList.remove('copied'); btn.innerHTML = o; }, 1500); }
function copyField(id, btn) { var t = document.getElementById(id).textContent.trim(); if (!t || t === 'Loading...') return; navigator.clipboard.writeText(t).then(function() { flashCopied(btn); }); }
function copyText(text, btn) { navigator.clipboard.writeText(text).then(function() { flashCopied(btn); }); }
async function fetchNodeIdentity() {
try {
var resp = await fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'node.nostr-pubkey', params: {} }) });
var data = await resp.json();
if (data.result && data.result.npub) {
document.getElementById('npub').textContent = data.result.npub;
} else if (data.result && data.result.pubkey) {
document.getElementById('npub').textContent = data.result.pubkey;
}
} catch(e) { document.getElementById('npub').textContent = 'Unavailable'; }
}
async function fetchStatus() {
try {
var resp = await fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'package.info', params: { id: 'fips' } }) });
var data = await resp.json();
var dot = document.getElementById('statusDot');
var txt = document.getElementById('statusText');
if (data.result && data.result.state === 'running') {
dot.className = 'status-dot bg-green'; txt.textContent = 'Running';
} else if (data.result && data.result.state === 'stopped') {
dot.className = 'status-dot bg-red'; txt.textContent = 'Stopped';
} else {
dot.className = 'status-dot bg-yellow animate-pulse'; txt.textContent = data.result ? data.result.state : 'Unknown';
}
} catch(e) { /* keep checking */ }
}
async function fetchLogs() {
try {
var resp = await fetch('/api/container/logs?app_id=fips&lines=30');
if (resp.ok) {
var data = await resp.json();
var logs = data.logs || data.stdout || '';
if (typeof logs === 'object') logs = JSON.stringify(logs);
document.getElementById('logs').textContent = logs || 'No logs available yet.';
var el = document.getElementById('logs');
el.scrollTop = el.scrollHeight;
}
} catch(e) { document.getElementById('logs').textContent = 'Waiting for container...'; }
}
fetchNodeIdentity();
fetchStatus();
fetchLogs();
setInterval(fetchStatus, 10000);
setInterval(fetchLogs, 15000);
</script>
</body>
</html>
-11
View File
@@ -1,11 +0,0 @@
server {
listen 8202;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
-10
View File
@@ -1,10 +0,0 @@
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
COPY index.html /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf
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 8201
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]
-232
View File
@@ -1,232 +0,0 @@
<!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">
<title>Nostr VPN - Archipelago</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; color: white; overflow-x: hidden; }
.bg-layer { position: fixed; inset: 0; z-index: -10; background: linear-gradient(135deg, rgba(10,5,30,0.95) 0%, rgba(20,10,50,0.98) 50%, rgba(5,15,35,0.95) 100%); }
.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); z-index: -5; }
.glass-card { background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-radius: 1rem; border: 1px solid rgba(255, 255, 255, 0.12); transform: translateZ(0); isolation: isolate; }
.info-card { background: rgba(255, 255, 255, 0.05); border-radius: 12px; padding: 12px; border: 1px solid rgba(255, 255, 255, 0.08); }
.container { max-width: 56rem; margin: 0 auto; padding: 1.5rem; }
.flex { display: flex; } .flex-col { flex-direction: column; } .items-center { align-items: center; }
.gap-3 { gap: 0.75rem; } .gap-4 { gap: 1rem; } .flex-1 { flex: 1; } .flex-shrink-0 { flex-shrink: 0; }
.mb-2 { margin-bottom: 0.5rem; } .mb-4 { margin-bottom: 1rem; } .mb-6 { margin-bottom: 1.5rem; }
.p-5 { padding: 1.25rem; } .p-6 { padding: 1.5rem; }
.grid { display: grid; } .grid-cols-2 { grid-template-columns: repeat(2, 1fr); } .grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.text-xs { font-size: 0.75rem; } .text-sm { font-size: 0.875rem; } .text-lg { font-size: 1.125rem; }
.text-xl { font-size: 1.25rem; } .text-2xl { font-size: 1.5rem; }
.font-bold { font-weight: 700; } .font-semibold { font-weight: 600; } .font-medium { font-weight: 500; } .font-mono { font-family: monospace; }
.text-white-70 { color: rgba(255,255,255,0.7); } .text-white-60 { color: rgba(255,255,255,0.6); } .text-white-50 { color: rgba(255,255,255,0.5); }
.text-purple { color: #a78bfa; } .text-green { color: #4ade80; } .text-yellow { color: #fbbf24; } .text-red { color: #f87171; }
.justify-between { justify-content: space-between; }
.status-dot { width: 0.75rem; height: 0.75rem; border-radius: 9999px; }
.bg-green { background: #4ade80; } .bg-yellow { background: #fbbf24; } .bg-red { background: #f87171; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
.icon-box { width: 3.5rem; height: 3.5rem; border-radius: 0.75rem; background: rgba(167, 139, 250, 0.15); display: flex; align-items: center; justify-content: center; }
.step { display: flex; gap: 1rem; align-items: flex-start; }
.step-num { width: 2rem; height: 2rem; border-radius: 50%; background: rgba(167, 139, 250, 0.2); border: 1px solid rgba(167, 139, 250, 0.4); display: flex; align-items: center; justify-content: center; font-size: 0.875rem; font-weight: 700; color: #a78bfa; flex-shrink: 0; }
.copy-btn { padding: 0.5rem 0.625rem; background: none; border: none; border-left: 1px solid rgba(255,255,255,0.1); cursor: pointer; color: rgba(255,255,255,0.4); transition: all 0.2s ease; display: flex; align-items: center; }
.copy-btn:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
.copy-btn.copied { color: #4ade80; }
.field-row { display: flex; align-items: center; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 0.5rem; overflow: hidden; }
.field-value { flex: 1; padding: 0.625rem 0.875rem; font-family: monospace; font-size: 0.8125rem; color: rgba(255,255,255,0.9); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.field-label { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255,255,255,0.4); margin-bottom: 0.375rem; }
.feature-icon { width: 2.5rem; height: 2.5rem; border-radius: 0.5rem; background: rgba(167, 139, 250, 0.1); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
@media (max-width: 640px) { .grid-cols-2 { grid-template-columns: 1fr; } .grid-cols-3 { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="bg-layer"></div>
<div class="overlay"></div>
<div class="container">
<!-- Header -->
<div class="glass-card p-6 mb-6">
<div class="flex items-center gap-4">
<div class="icon-box flex-shrink-0">
<svg style="width:1.75rem;height:1.75rem;color:#a78bfa" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div class="flex-1">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold">Nostr VPN</h1>
<span class="text-xs text-white-50">v0.3.4</span>
</div>
<p class="text-white-60 text-sm">Decentralized mesh VPN with Nostr signaling</p>
</div>
<div class="info-card flex items-center gap-3">
<div id="statusDot" class="status-dot bg-yellow animate-pulse"></div>
<div>
<p class="text-xs text-white-50">Status</p>
<p class="text-sm font-medium" id="statusText">Checking...</p>
</div>
</div>
</div>
</div>
<!-- What It Does -->
<div class="glass-card p-5 mb-6">
<h2 class="text-lg font-semibold mb-4" style="color:#a78bfa">What is Nostr VPN?</h2>
<p class="text-white-70 text-sm mb-4" style="line-height:1.6">
Nostr VPN creates a <strong style="color:white">private mesh network</strong> between your devices using WireGuard tunnels.
Unlike traditional VPNs, there is no central server. Peers discover each other and exchange encryption keys over
<strong style="color:white">Nostr relays</strong>, making the network censorship-resistant and self-sovereign.
Think of it as <strong style="color:white">Tailscale, but decentralized</strong> — your node's Nostr identity is your network identity.
</p>
<div class="grid grid-cols-3 gap-3">
<div class="info-card flex items-center gap-3">
<div class="feature-icon">
<svg style="width:1.25rem;height:1.25rem;color:#a78bfa" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"/></svg>
</div>
<div>
<p class="text-sm font-medium">No Central Server</p>
<p class="text-xs text-white-50">Fully peer-to-peer mesh</p>
</div>
</div>
<div class="info-card flex items-center gap-3">
<div class="feature-icon">
<svg style="width:1.25rem;height:1.25rem;color:#a78bfa" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<div>
<p class="text-sm font-medium">WireGuard Tunnels</p>
<p class="text-xs text-white-50">Fast, modern encryption</p>
</div>
</div>
<div class="info-card flex items-center gap-3">
<div class="feature-icon">
<svg style="width:1.25rem;height:1.25rem;color:#a78bfa" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064"/></svg>
</div>
<div>
<p class="text-sm font-medium">NAT Traversal</p>
<p class="text-xs text-white-50">Works behind firewalls</p>
</div>
</div>
</div>
</div>
<!-- Node Identity -->
<div class="glass-card p-5 mb-6">
<h2 class="text-lg font-semibold mb-4" style="color:#a78bfa">Node Identity</h2>
<p class="text-white-60 text-sm mb-4">Your node's Nostr public key is used as its network identity. Share it with peers to connect.</p>
<div class="mb-4">
<div class="field-label">Nostr Public Key (npub)</div>
<div class="field-row">
<span class="field-value" id="npub">Loading...</span>
<button class="copy-btn" onclick="copyField('npub', this)" title="Copy">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
<div>
<div class="field-label">VPN Listen Port</div>
<div class="field-row">
<span class="field-value">51820/udp</span>
<button class="copy-btn" onclick="copyText('51820', this)" title="Copy">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
</div>
<!-- How to Use -->
<div class="glass-card p-5 mb-6">
<h2 class="text-lg font-semibold mb-4" style="color:#a78bfa">How to Use</h2>
<div class="flex flex-col gap-4">
<div class="step">
<div class="step-num">1</div>
<div>
<p class="text-sm font-semibold mb-2">Install the Nostr VPN client on your device</p>
<p class="text-xs text-white-60" style="line-height:1.5">Download <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">nvpn</code> from <a href="https://github.com/mmalmi/nostr-vpn/releases" style="color:#a78bfa;text-decoration:underline" target="_blank">GitHub Releases</a> on your laptop, phone, or other devices you want to connect.</p>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div>
<p class="text-sm font-semibold mb-2">Create or join a network</p>
<p class="text-xs text-white-60" style="line-height:1.5">Run <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">nvpn network create</code> on this node to create a new network, or join an existing one with an invite code. Each network gets a unique ID shared between members.</p>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div>
<p class="text-sm font-semibold mb-2">Connect your devices</p>
<p class="text-xs text-white-60" style="line-height:1.5">Run <code style="background:rgba(255,255,255,0.1);padding:0.125rem 0.375rem;border-radius:0.25rem">nvpn start --daemon --connect</code> on each device. Peers discover each other automatically over Nostr relays and establish direct WireGuard tunnels. Your devices are now privately connected.</p>
</div>
</div>
</div>
</div>
<!-- Container Status -->
<div class="glass-card p-5">
<h2 class="text-lg font-semibold mb-4" style="color:#a78bfa">Container Logs</h2>
<div id="logs" style="background:rgba(0,0,0,0.4);border-radius:0.5rem;padding:0.75rem;font-family:monospace;font-size:0.75rem;color:rgba(255,255,255,0.6);max-height:200px;overflow-y:auto;line-height:1.6">
Fetching logs...
</div>
</div>
</div>
<script>
var COPY_SVG = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>';
var CHECK_SVG = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>';
function flashCopied(btn) { btn.classList.add('copied'); var o = btn.innerHTML; btn.innerHTML = CHECK_SVG; setTimeout(function() { btn.classList.remove('copied'); btn.innerHTML = o; }, 1500); }
function copyField(id, btn) { var t = document.getElementById(id).textContent.trim(); if (!t || t === 'Loading...') return; navigator.clipboard.writeText(t).then(function() { flashCopied(btn); }); }
function copyText(text, btn) { navigator.clipboard.writeText(text).then(function() { flashCopied(btn); }); }
async function fetchNodeIdentity() {
try {
var resp = await fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'node.nostr-pubkey', params: {} }) });
var data = await resp.json();
if (data.result && data.result.npub) {
document.getElementById('npub').textContent = data.result.npub;
} else if (data.result && data.result.pubkey) {
document.getElementById('npub').textContent = data.result.pubkey;
}
} catch(e) { document.getElementById('npub').textContent = 'Unavailable'; }
}
async function fetchStatus() {
try {
var resp = await fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'package.info', params: { id: 'nostr-vpn' } }) });
var data = await resp.json();
var dot = document.getElementById('statusDot');
var txt = document.getElementById('statusText');
if (data.result && data.result.state === 'running') {
dot.className = 'status-dot bg-green'; txt.textContent = 'Running';
} else if (data.result && data.result.state === 'stopped') {
dot.className = 'status-dot bg-red'; txt.textContent = 'Stopped';
} else {
dot.className = 'status-dot bg-yellow animate-pulse'; txt.textContent = data.result ? data.result.state : 'Unknown';
}
} catch(e) { /* keep checking */ }
}
async function fetchLogs() {
try {
var resp = await fetch('/api/container/logs?app_id=nostr-vpn&lines=30');
if (resp.ok) {
var data = await resp.json();
var logs = data.logs || data.stdout || '';
if (typeof logs === 'object') logs = JSON.stringify(logs);
document.getElementById('logs').textContent = logs || 'No logs available yet.';
var el = document.getElementById('logs');
el.scrollTop = el.scrollHeight;
}
} catch(e) { document.getElementById('logs').textContent = 'Waiting for container...'; }
}
fetchNodeIdentity();
fetchStatus();
fetchLogs();
setInterval(fetchStatus, 10000);
setInterval(fetchLogs, 15000);
</script>
</body>
</html>
-11
View File
@@ -1,11 +0,0 @@
server {
listen 8201;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
@@ -241,6 +241,23 @@ server {
error_page 504 = @backend_timeout;
}
# App Store catalog proxy — backend fetches from configured registries
# so the browser doesn't hit CORS/CSP. Without this block nginx falls
# through to the SPA index.html and the frontend gets HTML back instead
# of JSON.
location /api/app-catalog {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_connect_timeout 15s;
proxy_read_timeout 30s;
proxy_send_timeout 15s;
error_page 502 503 = @backend_unavailable;
error_page 504 = @backend_timeout;
}
# DWN endpoints — peer access over Tor (no auth)
location /dwn {
limit_req zone=peer burst=20 nodelay;
@@ -1029,6 +1046,23 @@ server {
error_page 504 = @backend_timeout;
}
# App Store catalog proxy — backend fetches from configured registries
# so the browser doesn't hit CORS/CSP. Without this block nginx falls
# through to the SPA index.html and the frontend gets HTML back instead
# of JSON.
location /api/app-catalog {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_connect_timeout 15s;
proxy_read_timeout 30s;
proxy_send_timeout 15s;
error_page 502 503 = @backend_unavailable;
error_page 504 = @backend_timeout;
}
# DWN endpoints — peer access over Tor (no auth)
location /dwn {
limit_req zone=peer burst=20 nodelay;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.3.5",
"version": "1.7.38-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.3.5",
"version": "1.7.38-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.6.0-alpha",
"version": "1.7.40-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -14,6 +14,7 @@
"dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite",
"backend:mock": "node mock-backend.js",
"backend:real": "cd ../core && cargo run --release",
"prebuild": "cp ../app-catalog/catalog.json public/catalog.json",
"build": "vue-tsc -b && vite build",
"build:docker": "vite build",
"build:production": "NODE_ENV=production vue-tsc -b && vite build --mode production",
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

@@ -1,4 +0,0 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="#10b981"/>
<text x="32" y="38" text-anchor="middle" font-family="system-ui" font-size="16" font-weight="700" fill="white">FIPS</text>
</svg>

Before

Width:  |  Height:  |  Size: 284 B

@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<rect width="100" height="100" rx="20" fill="#7B2DBC"/>
<circle cx="50" cy="40" r="12" stroke="white" stroke-width="3" fill="none"/>
<circle cx="28" cy="68" r="8" stroke="white" stroke-width="2.5" fill="none"/>
<circle cx="72" cy="68" r="8" stroke="white" stroke-width="2.5" fill="none"/>
<line x1="42" y1="49" x2="33" y2="62" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<line x1="58" y1="49" x2="67" y2="62" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="50" cy="40" r="4" fill="white"/>
<circle cx="28" cy="68" r="3" fill="white"/>
<circle cx="72" cy="68" r="3" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 718 B

@@ -1,4 +0,0 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="#6366f1"/>
<text x="32" y="38" text-anchor="middle" font-family="system-ui" font-size="18" font-weight="700" fill="white">NV</text>
</svg>

Before

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

@@ -1,4 +0,0 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="#f59e0b"/>
<text x="32" y="38" text-anchor="middle" font-family="system-ui" font-size="18" font-weight="700" fill="white">R</text>
</svg>

Before

Width:  |  Height:  |  Size: 281 B

+129 -334
View File
@@ -1,415 +1,210 @@
{
"version": 1,
"updated": "2026-04-11T00:00:00Z",
"registry": "23.182.128.160:3000/lfg2025",
"version": 2,
"updated": "2026-04-22T00:00:00Z",
"registry": "git.tx1138.com/lfg2025",
"featured": {
"id": "indeedhub",
"banner": "/assets/img/featured/indeedhub-banner.jpg",
"headline": "Stream Sovereignty",
"description": "Bitcoin documentaries with Nostr identity. God Bless Bitcoin, The Bitcoin Psyop, and more \u2014 streaming from your own node.",
"description": "Bitcoin documentaries with Nostr identity.",
"tag": "NOSTR IDENTITY // YOUR NODE"
},
"apps": [
{
"id": "bitcoin-knots",
"title": "Bitcoin Knots",
"version": "28.1.0",
"description": "Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.",
"id": "bitcoin-knots", "title": "Bitcoin Knots", "version": "28.1.0",
"description": "Run a full Bitcoin node. Validate and relay blocks and transactions.",
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
"author": "Bitcoin Knots",
"dockerImage": "bitcoin-knots:latest",
"repoUrl": "https://github.com/bitcoinknots/bitcoin",
"category": "money",
"tier": "core"
"author": "Bitcoin Knots", "category": "money", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/bitcoin-knots:latest",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
"id": "lnd",
"title": "LND",
"version": "0.18.4",
"description": "Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.",
"id": "bitcoin-core", "title": "Bitcoin Core", "version": "28.4",
"description": "Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks.",
"icon": "/assets/img/app-icons/bitcoin-core.svg",
"author": "Bitcoin Core contributors", "category": "money", "tier": "optional",
"dockerImage": "docker.io/bitcoin/bitcoin:28.4",
"repoUrl": "https://github.com/bitcoin/bitcoin"
},
{
"id": "lnd", "title": "LND", "version": "0.18.4",
"description": "Lightning Network Daemon. Fast Bitcoin payments through Lightning.",
"icon": "/assets/img/app-icons/lnd.svg",
"author": "Lightning Labs",
"dockerImage": "lnd:v0.18.4-beta",
"author": "Lightning Labs", "category": "money", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"category": "money",
"tier": "core"
"requires": ["bitcoin-knots"]
},
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "1.13.7",
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.",
"id": "btcpay-server", "title": "BTCPay Server", "version": "1.13.7",
"description": "Self-hosted Bitcoin payment processor.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"dockerImage": "btcpayserver:1.13.7",
"author": "BTCPay Server Foundation", "category": "commerce", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/btcpayserver:1.13.7",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"category": "commerce",
"tier": "core"
"requires": ["bitcoin-knots"]
},
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"description": "Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses.",
"id": "mempool", "title": "Mempool Explorer", "version": "3.0.0",
"description": "Self-hosted Bitcoin blockchain and mempool visualizer.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"dockerImage": "mempool-frontend:v3.0.0",
"author": "Mempool", "category": "money", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/mempool-frontend:v3.0.0",
"repoUrl": "https://github.com/mempool/mempool",
"category": "money",
"tier": "core"
"requires": ["bitcoin-knots", "electrumx"]
},
{
"id": "electrumx",
"title": "ElectrumX",
"version": "1.18.0",
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups, privately.",
"id": "electrumx", "title": "ElectrumX", "version": "1.18.0",
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups.",
"icon": "/assets/img/app-icons/electrumx.webp",
"author": "Luke Childs",
"dockerImage": "electrumx:v1.18.0",
"author": "Luke Childs", "category": "money", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"category": "money",
"tier": "core"
"requires": ["bitcoin-knots"]
},
{
"id": "indeedhub",
"title": "IndeeHub",
"version": "1.0.0",
"description": "Bitcoin documentary streaming with Nostr identity. Stream sovereignty content from your node.",
"id": "indeedhub", "title": "IndeeHub", "version": "1.0.0",
"description": "Bitcoin documentary streaming with Nostr identity.",
"icon": "/assets/img/app-icons/indeedhub.png",
"author": "IndeeHub Team",
"dockerImage": "indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub",
"category": "community"
"author": "IndeeHub", "category": "community",
"dockerImage": "git.tx1138.com/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
},
{
"id": "botfights",
"title": "BotFights",
"version": "1.0.0",
"description": "Bot arena + 2-player arcade fighter with controller support.",
"id": "botfights", "title": "BotFights", "version": "1.1.0",
"description": "Bot arena + 2-player arcade fighter with controller support and Adventure Mode.",
"icon": "/assets/img/app-icons/botfights.svg",
"author": "BotFights",
"dockerImage": "botfights:1.1.0",
"repoUrl": "https://botfights.net",
"category": "community"
"author": "BotFights", "category": "community",
"dockerImage": "git.tx1138.com/lfg2025/botfights:1.1.0",
"repoUrl": "https://botfights.net"
},
{
"id": "gitea",
"title": "Gitea",
"version": "1.23",
"description": "Self-hosted Git service with container registry, CI/CD, issue tracking, and package hosting.",
"id": "gitea", "title": "Gitea", "version": "1.23",
"description": "Self-hosted Git service with container registry, CI/CD, issue tracking.",
"icon": "/assets/img/app-icons/gitea.svg",
"author": "Gitea",
"author": "Gitea", "category": "development",
"dockerImage": "docker.io/gitea/gitea:1.23",
"repoUrl": "https://gitea.com",
"category": "development"
"repoUrl": "https://gitea.com"
},
{
"id": "filebrowser",
"title": "File Browser",
"version": "2.27.0",
"description": "Web-based file manager. Browse, upload, and manage files on your server.",
"id": "filebrowser", "title": "File Browser", "version": "2.27.0",
"description": "Web-based file manager.",
"icon": "/assets/img/app-icons/file-browser.webp",
"author": "File Browser",
"dockerImage": "filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"category": "data",
"tier": "core"
"author": "File Browser", "category": "data", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser"
},
{
"id": "vaultwarden",
"title": "Vaultwarden",
"version": "1.30.0",
"description": "Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.",
"id": "vaultwarden", "title": "Vaultwarden", "version": "1.30.0",
"description": "Self-hosted password vault with zero-knowledge encryption.",
"icon": "/assets/img/app-icons/vaultwarden.webp",
"author": "Vaultwarden",
"dockerImage": "vaultwarden:1.30.0-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"category": "data",
"tier": "recommended"
"author": "Vaultwarden", "category": "data", "tier": "recommended",
"dockerImage": "git.tx1138.com/lfg2025/vaultwarden:1.30.0-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden"
},
{
"id": "searxng",
"title": "SearXNG",
"version": "2024.1.0",
"description": "Privacy-respecting metasearch engine. Search the internet without being tracked.",
"id": "searxng", "title": "SearXNG", "version": "2024.1.0",
"description": "Privacy-respecting metasearch engine.",
"icon": "/assets/img/app-icons/searxng.png",
"author": "SearXNG",
"dockerImage": "searxng:latest",
"repoUrl": "https://github.com/searxng/searxng",
"category": "data",
"tier": "recommended"
"author": "SearXNG", "category": "data", "tier": "recommended",
"dockerImage": "git.tx1138.com/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng"
},
{
"id": "nostr-rs-relay",
"title": "Nostr Relay",
"version": "0.9.0",
"description": "Your own Nostr relay. Store events locally, relay for friends, publish over Tor.",
"icon": "/assets/img/app-icons/nostr-rs-relay.svg",
"author": "scsiblade",
"dockerImage": "nostr-rs-relay:0.9.0",
"repoUrl": "https://sr.ht/~gheartsfield/nostr-rs-relay/",
"category": "nostr"
},
{
"id": "fedimint",
"title": "Fedimint",
"version": "0.10.0",
"description": "Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.",
"id": "fedimint", "title": "Fedimint", "version": "0.10.0",
"description": "Federated Bitcoin mint with privacy through federated guardians.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"dockerImage": "fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint",
"category": "money"
"author": "Fedimint", "category": "money",
"dockerImage": "git.tx1138.com/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "ollama",
"title": "Ollama",
"version": "0.5.4",
"description": "Run AI models locally. Llama, Mistral, and more \u2014 on your hardware, completely private.",
"id": "ollama", "title": "Ollama", "version": "0.5.4",
"description": "Run AI models locally. Private and on your hardware.",
"icon": "/assets/img/app-icons/ollama.png",
"author": "Ollama",
"dockerImage": "ollama:latest",
"repoUrl": "https://github.com/ollama/ollama",
"category": "data"
"author": "Ollama", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/ollama:latest",
"repoUrl": "https://github.com/ollama/ollama"
},
{
"id": "nextcloud",
"title": "Nextcloud",
"version": "28",
"description": "Your own private cloud. File sync, calendars, contacts \u2014 all on your hardware.",
"id": "nextcloud", "title": "Nextcloud", "version": "28",
"description": "Your own private cloud. File sync, calendars, contacts.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud",
"dockerImage": "nextcloud:28",
"repoUrl": "https://github.com/nextcloud/server",
"category": "data"
"author": "Nextcloud", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/nextcloud:28",
"repoUrl": "https://github.com/nextcloud/server"
},
{
"id": "jellyfin",
"title": "Jellyfin",
"version": "10.8.13",
"description": "Free media server. Stream your movies, music, and photos to any device.",
"id": "jellyfin", "title": "Jellyfin", "version": "10.8.13",
"description": "Free media server. Stream movies, music, and photos.",
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"dockerImage": "jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"category": "data"
"author": "Jellyfin", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin"
},
{
"id": "immich",
"title": "Immich",
"version": "1.90.0",
"description": "High-performance photo and video backup. Mobile-first with ML features.",
"id": "immich", "title": "Immich", "version": "1.90.0",
"description": "High-performance photo and video backup with ML.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich",
"dockerImage": "immich-server:release",
"repoUrl": "https://github.com/immich-app/immich",
"category": "data"
"author": "Immich", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
},
{
"id": "homeassistant",
"title": "Home Assistant",
"version": "2024.1",
"description": "Open-source home automation. Control smart home devices privately.",
"id": "homeassistant", "title": "Home Assistant", "version": "2024.1",
"description": "Open-source home automation.",
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"dockerImage": "home-assistant:2024.1",
"repoUrl": "https://github.com/home-assistant/core",
"category": "home"
"author": "Home Assistant", "category": "home",
"dockerImage": "git.tx1138.com/lfg2025/home-assistant:2024.1",
"repoUrl": "https://github.com/home-assistant/core"
},
{
"id": "grafana",
"title": "Grafana",
"version": "10.2.0",
"description": "Analytics and monitoring platform. Dashboards for your node metrics.",
"id": "grafana", "title": "Grafana", "version": "10.2.0",
"description": "Analytics and monitoring dashboards.",
"icon": "/assets/img/app-icons/grafana.png",
"author": "Grafana Labs",
"dockerImage": "grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana",
"category": "data",
"tier": "recommended"
"author": "Grafana Labs", "category": "data", "tier": "recommended",
"dockerImage": "git.tx1138.com/lfg2025/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana"
},
{
"id": "tailscale",
"title": "Tailscale",
"version": "1.78.0",
"description": "Zero-config VPN. Secure remote access with WireGuard mesh networking.",
"id": "tailscale", "title": "Tailscale", "version": "1.78.0",
"description": "Zero-config VPN with WireGuard mesh networking.",
"icon": "/assets/img/app-icons/tailscale.webp",
"author": "Tailscale",
"dockerImage": "tailscale:stable",
"repoUrl": "https://github.com/tailscale/tailscale",
"category": "networking",
"tier": "recommended"
"author": "Tailscale", "category": "networking", "tier": "recommended",
"dockerImage": "git.tx1138.com/lfg2025/tailscale:stable",
"repoUrl": "https://github.com/tailscale/tailscale"
},
{
"id": "penpot",
"title": "Penpot",
"version": "2.4",
"description": "Open-source design platform. Self-hosted alternative to Figma.",
"icon": "/assets/img/app-icons/penpot.webp",
"author": "Penpot",
"dockerImage": "penpot-frontend:2.4",
"repoUrl": "https://github.com/penpot/penpot",
"category": "data"
},
{
"id": "photoprism",
"title": "PhotoPrism",
"version": "240915",
"description": "AI-powered photo management with facial recognition, privately.",
"icon": "/assets/img/app-icons/photoprism.svg",
"author": "PhotoPrism",
"dockerImage": "photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism",
"category": "data"
},
{
"id": "uptime-kuma",
"title": "Uptime Kuma",
"version": "1.23.0",
"description": "Self-hosted uptime monitoring. Track HTTP, TCP, DNS, and more.",
"id": "uptime-kuma", "title": "Uptime Kuma", "version": "1.23.0",
"description": "Self-hosted uptime monitoring.",
"icon": "/assets/img/app-icons/uptime-kuma.webp",
"author": "Uptime Kuma",
"dockerImage": "uptime-kuma:1",
"repoUrl": "https://github.com/louislam/uptime-kuma",
"category": "data",
"tier": "recommended"
"author": "Uptime Kuma", "category": "data", "tier": "recommended",
"dockerImage": "git.tx1138.com/lfg2025/uptime-kuma:1",
"repoUrl": "https://github.com/louislam/uptime-kuma"
},
{
"id": "nostr-vpn",
"title": "Nostr VPN",
"version": "0.3.7",
"description": "Tailscale-style mesh VPN with Nostr control plane.",
"icon": "/assets/img/app-icons/nostr-vpn.svg",
"author": "Martti Malmi",
"dockerImage": "nostr-vpn:v0.3.7",
"repoUrl": "https://github.com/mmalmi/nostr-vpn",
"category": "networking"
},
{
"id": "fips",
"title": "FIPS",
"version": "0.1.0",
"description": "Free Internetworking Peering System. Self-organizing encrypted mesh.",
"icon": "/assets/img/app-icons/fips.svg",
"author": "Jim Corgan",
"dockerImage": "fips:v0.1.0",
"repoUrl": "https://github.com/jmcorgan/fips",
"category": "networking"
},
{
"id": "routstr",
"title": "Routstr",
"version": "0.4.3",
"description": "Decentralized AI inference proxy. Pay-per-request with Cashu ecash.",
"icon": "/assets/img/app-icons/routstr.svg",
"author": "Routstr",
"dockerImage": "routstr:v0.4.3",
"repoUrl": "https://github.com/routstr/routstr-core",
"category": "community"
},
{
"id": "dwn",
"title": "Decentralized Web Node",
"version": "0.4.0",
"description": "Own your data with DID-based access control. Sync across devices.",
"id": "dwn", "title": "Decentralized Web Node", "version": "0.4.0",
"description": "Own your data with DID-based access control.",
"icon": "/assets/img/app-icons/dwn.svg",
"author": "TBD",
"dockerImage": "dwn-server:main",
"repoUrl": "https://github.com/TBD54566975/dwn-server",
"category": "data"
"author": "TBD", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/dwn-server:main",
"repoUrl": "https://github.com/TBD54566975/dwn-server"
},
{
"id": "cryptpad",
"title": "CryptPad",
"version": "2024.12.0",
"description": "End-to-end encrypted documents and collaboration. Zero-knowledge.",
"icon": "/assets/img/app-icons/cryptpad.webp",
"author": "XWiki SAS",
"dockerImage": "cryptpad:2024.12.0",
"repoUrl": "https://github.com/cryptpad/cryptpad",
"category": "data"
"id": "endurain", "title": "Endurain", "version": "0.8.0",
"description": "Self-hosted fitness tracking. Strava alternative.",
"icon": "/assets/img/app-icons/endurain.png",
"author": "Endurain", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/endurain:0.8.0",
"repoUrl": "https://github.com/joaovitoriasilva/endurain"
},
{
"id": "nostrudel",
"title": "noStrudel",
"version": "0.40.0",
"description": "Feature-rich Nostr web client.",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "hzrd149",
"dockerImage": "",
"repoUrl": "https://github.com/hzrd149/nostrudel",
"webUrl": "https://nostrudel.ninja",
"category": "nostr"
},
{
"id": "nwnn",
"title": "Next Web News Network",
"version": "1.0.0",
"description": "Decentralized news aggregator.",
"icon": "/assets/img/app-icons/nwnn.png",
"author": "L484",
"dockerImage": "",
"webUrl": "https://nwnn.l484.com",
"category": "l484"
},
{
"id": "484-kitchen",
"title": "484 Kitchen",
"version": "1.0.0",
"description": "K484 application platform.",
"icon": "/assets/img/app-icons/484-kitchen.png",
"author": "L484",
"dockerImage": "",
"webUrl": "https://484.kitchen",
"category": "l484"
},
{
"id": "call-the-operator",
"title": "Call the Operator",
"version": "1.0.0",
"description": "Escape the Matrix.",
"icon": "/assets/img/app-icons/call-the-operator.png",
"author": "TX1138",
"dockerImage": "",
"webUrl": "https://cta.tx1138.com",
"category": "l484"
},
{
"id": "arch-presentation",
"title": "Arch Presentation",
"version": "1.0.0",
"description": "The Future of Decentralized Infrastructure.",
"icon": "/assets/img/app-icons/arch-presentation.png",
"author": "L484",
"dockerImage": "",
"webUrl": "https://present.l484.com",
"category": "l484"
},
{
"id": "syntropy-institute",
"title": "Syntropy Institute",
"version": "1.0.0",
"description": "Medicine Reimagined.",
"icon": "/assets/img/app-icons/syntropy-institute.png",
"author": "Syntropy Institute",
"dockerImage": "",
"webUrl": "https://syntropy.institute",
"category": "l484"
},
{
"id": "t-zero",
"title": "T-0",
"version": "1.0.0",
"description": "Documentary series exploring decentralization.",
"icon": "/assets/img/app-icons/t-zero.png",
"author": "T-0",
"dockerImage": "",
"webUrl": "https://teeminuszero.net",
"category": "l484"
"id": "photoprism", "title": "PhotoPrism", "version": "240915",
"description": "AI-powered photo management with facial recognition.",
"icon": "/assets/img/app-icons/photoprism.svg",
"author": "PhotoPrism", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism"
}
],
"registries": [
"23.182.128.160:3000/lfg2025",
"git.tx1138.com/lfg2025"
]
}
}
+22 -5
View File
@@ -284,12 +284,29 @@ async function handleSplashComplete() {
}
try {
const { isOnboardingComplete } = await import('@/composables/useOnboarding')
const seenOnboarding = await isOnboardingComplete()
const destination = seenOnboarding ? '/login' : '/onboarding/intro'
router.push(destination).catch(() => {})
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
const seenOnboarding = await checkOnboardingStatus()
if (seenOnboarding === true) {
router.push('/login').catch(() => {})
return
}
if (seenOnboarding === false) {
router.push('/onboarding/intro').catch(() => {})
return
}
// Backend unreachable after retries. Prefer the localStorage
// cache on THIS browser (if a prior successful check set it)
// otherwise defer to RootRedirect which polls + retries rather
// than forcing an already-onboarded user through the wizard.
if (localStorage.getItem('neode_onboarding_complete') === '1') {
router.push('/login').catch(() => {})
} else {
router.push('/').catch(() => {})
}
} catch {
router.push('/onboarding/intro').catch(() => {})
// Do NOT default to /onboarding/intro here. RootRedirect has retry
// + polling + boot-screen handling; let it decide.
router.push('/').catch(() => {})
}
}
</script>
+5 -1
View File
@@ -525,7 +525,11 @@ class RPCClient {
return this.call({
method: 'package.install',
params: { id, 'marketplace-url': marketplaceUrl, version },
timeout: 900000, // 15 min — multi-GB stacks (IndeedHub, Bitcoin, Penpot) take time
// 45 min — IndeedHub is 6 images and gitea raw-file throughput is
// ~70 KB/s per image; 15 min was short enough to kill the install
// mid-pull and land the user on a "didn't work" screen while the
// backend kept working in the background.
timeout: 2700000,
})
}
-1
View File
@@ -73,7 +73,6 @@ const APP_ICON_MAP: Record<string, string> = {
fedimint: '/assets/img/app-icons/fedimint.png',
mempool: '/assets/img/app-icons/mempool.webp',
electrs: '/assets/img/app-icons/electrs.svg',
'nostr-rs-relay': '/assets/img/app-icons/nostr-rs-relay.svg',
}
function goalAppIcons(goal: GoalDefinition): { appId: string; url: string }[] {
+7 -111
View File
@@ -13,15 +13,7 @@
</div>
<!-- Normal logo with audio viz ring -->
<div v-else class="screensaver-content">
<!-- Radial audio visualization - bars around the logo -->
<div class="screensaver-viz-ring">
<div
v-for="(_, i) in segmentCount"
:key="i"
class="screensaver-viz-segment"
:style="getSegmentStyle(i)"
/>
</div>
<ScreensaverRing />
<!-- Logo in center -->
<div class="screensaver-logo-wrapper">
<ScreensaverLogo />
@@ -35,21 +27,12 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue'
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
import ScreensaverRing from '@/components/ScreensaverRing.vue'
import BitcoinFaceAscii from '@/views/discover/BitcoinFaceAscii.vue'
import { useScreensaverStore } from '@/stores/screensaver'
const store = useScreensaverStore()
const segmentCount = 48
function getSegmentStyle(i: number) {
const deg = (i / segmentCount) * 360
return {
'--segment-index': i,
'--segment-deg': `${deg}deg`,
}
}
// Dismiss on any key (except when typing)
function onKeyDown(e: KeyboardEvent) {
if (store.isActive) {
@@ -86,102 +69,15 @@ onBeforeUnmount(() => {
.screensaver-content {
position: relative;
width: 280px;
height: 280px;
flex-shrink: 0;
}
@media (min-width: 640px) {
.screensaver-content {
width: 360px;
height: 360px;
}
}
@media (min-width: 768px) {
.screensaver-content {
width: 400px;
height: 400px;
}
}
/* Ring of segments around the logo - audio viz style (behind logo) */
.screensaver-viz-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
--viz-radius: 140px;
}
@media (min-width: 640px) {
.screensaver-viz-ring {
--viz-radius: 180px;
}
}
@media (min-width: 768px) {
.screensaver-viz-ring {
--viz-radius: 200px;
}
}
.screensaver-viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 24px;
margin-left: -2px;
margin-top: -12px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
border-radius: 2px;
/* Origin at segment center = ring center (segment is centered via left/top 50%) */
transform-origin: center center;
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
animation: segment-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--segment-index) * 0.02s);
}
@media (min-width: 768px) {
.screensaver-viz-segment {
height: 28px;
margin-top: -14px;
}
}
/* 5 normal loops (10s) then stronger longer expression (4s) - total 14s */
@keyframes segment-pulse {
/* Loop 1 */
0% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
7.1% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
14.3% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
/* Loop 2 */
21.4% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
28.6% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
/* Loop 3 */
35.7% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
42.9% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
/* Loop 4 */
50% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
57.1% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
/* Loop 5 */
64.3% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
71.4% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
/* Strong expression: ramp up (1.5s), hold (2s), ease back (0.5s) */
78.6% { opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
85.7% { opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
92.9% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
100% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
display: grid;
place-items: center;
}
.screensaver-logo-wrapper {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
inset: 0;
display: grid;
place-items: center;
z-index: 10;
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
}
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="viz-ring" :class="sizeClass">
<div
v-for="(_, i) in segmentCount"
:key="i"
class="viz-segment"
:style="getSegmentStyle(i)"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
/** Visual size: 'default' matches the screensaver; 'compact' drops the
* min-width breakpoints (useful inside overlays on narrower canvases). */
size?: 'default' | 'compact'
/** Override segment count. Defaults to 48 (screensaver standard). */
segmentCount?: number
}>(), { size: 'default', segmentCount: 48 })
const sizeClass = computed(() => props.size === 'compact' ? 'viz-ring-compact' : 'viz-ring-default')
function getSegmentStyle(i: number) {
const deg = (i / props.segmentCount) * 360
return {
'--segment-index': i,
'--segment-deg': `${deg}deg`,
}
}
</script>
<style scoped>
.viz-ring {
position: relative;
pointer-events: none;
}
.viz-ring-default {
width: 280px;
height: 280px;
--viz-radius: 140px;
}
@media (min-width: 640px) {
.viz-ring-default {
width: 360px;
height: 360px;
--viz-radius: 180px;
}
}
@media (min-width: 768px) {
.viz-ring-default {
width: 400px;
height: 400px;
--viz-radius: 200px;
}
}
.viz-ring-compact {
width: 240px;
height: 240px;
--viz-radius: 120px;
}
@media (min-width: 768px) {
.viz-ring-compact {
width: 320px;
height: 320px;
--viz-radius: 160px;
}
}
.viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 24px;
margin-left: -2px;
margin-top: -12px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
border-radius: 2px;
transform-origin: center center;
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
animation: segment-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--segment-index) * 0.02s);
}
@media (min-width: 768px) {
.viz-segment {
height: 28px;
margin-top: -14px;
}
}
/* 5 normal loops (10s) then stronger longer expression (4s) — total 14s */
@keyframes segment-pulse {
0% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
7.1% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
14.3%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
21.4%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
28.6%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
35.7%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
42.9%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
50% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
57.1%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
64.3%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
71.4%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
78.6%{ opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
85.7%{ opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
92.9%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
100% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
}
</style>
@@ -1,7 +1,28 @@
/**
* Login screen audio: intro loop (MP3) + transition sounds.
*
* First-install vs returning-user gate: the synthwave loop, welcome
* voice, pop/whoosh/oomph transitions exist for the first-boot cinematic
* moment. After the user has completed onboarding we silence all of
* them every subsequent login should be quiet. Typing sounds are
* exempt and continue to play regardless.
*/
/** True when the node has not yet completed onboarding i.e. we're
* still in the first-install cinematic. Reads the localStorage cache
* set by useOnboarding (which is re-seeded from the backend on each
* successful check), so this stays correct after a browser clear
* once the onboarding-complete probe runs. Sound calls that fire
* before that probe completes will fall through silent on an already-
* onboarded node which is exactly what we want. */
function isFirstInstallPhase(): boolean {
try {
return localStorage.getItem('neode_onboarding_complete') !== '1'
} catch {
return true
}
}
let audioContext: AudioContext | null = null
let introAudio: HTMLAudioElement | null = null
let introGain: GainNode | null = null
@@ -30,6 +51,7 @@ const LOOP_START_URL = '/assets/audio/loop-start.mp3'
/** Play loop-start when transitioning from typing intro to Welcome Noderunner, as the intro music comes in.
* Uses Web Audio API so it plays after context is resumed (user gesture). */
export function playLoopStart() {
if (!isFirstInstallPhase()) return
const ctx = getContext()
if (!ctx) return
try {
@@ -62,6 +84,7 @@ export function resumeAudioContext() {
/** Start intro loop - Cosmic Updrift. Only works after resumeAudioContext() (user gesture). */
export function startSynthwave() {
if (!isFirstInstallPhase()) return
const ctxOrNull = getContext()
if (!ctxOrNull) return
@@ -123,6 +146,7 @@ export function stopAllAudio() {
/** Pop sound - plays when intro initiator (tap to start) is pressed */
export function playPop() {
if (!isFirstInstallPhase()) return
const audio = new Audio('/assets/audio/pop.mp3')
audio.volume = 0.6
audio.play().catch(() => {})
@@ -130,6 +154,7 @@ export function playPop() {
/** Whoosh transition on successful login */
export function playLoginSuccessWhoosh() {
if (!isFirstInstallPhase()) return
const woosh = new Audio('/assets/audio/woosh.mp3')
woosh.volume = 0.5
woosh.play().catch(() => {})
@@ -169,6 +194,7 @@ const WELCOME_SPEECH_URL = '/assets/audio/welcome-noderunner.mp3'
* ELEVENLABS_API_KEY=your_key node neode-ui/scripts/generate-welcome-speech.js
* Browse sci-fi voices at elevenlabs.io/voice-library and set ELEVENLABS_VOICE_ID for custom voice. */
export function playWelcomeNoderunnerSpeech() {
if (!isFirstInstallPhase()) return
const audio = new Audio(WELCOME_SPEECH_URL)
audio.volume = 0.9
audio.play().catch(() => {})
@@ -226,6 +252,7 @@ export function playKeyboardTypingSound() {
/** Gaming-style boot thud - soft impact when dashboard loads */
export function playDashboardLoadOomph() {
if (!isFirstInstallPhase()) return
const ctx = getContext()
if (!ctx) return
+44 -9
View File
@@ -1,29 +1,64 @@
/**
* Onboarding state - prefers backend, falls back to localStorage for mock/offline.
* Hardened: retries on 502/503, never blocks completion.
* Onboarding state - backend is authoritative.
* "Unknown" (backend unreachable) must NEVER default to false
* that would falsely send an already-onboarded user back through
* the intro after a browser clear / update / reboot.
*/
import { rpcClient } from '@/api/rpc-client'
async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T | null> {
async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 5): Promise<T | null> {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn()
} catch (e) {
const msg = e instanceof Error ? e.message : ''
const isRetryable = /502|503|timeout|fetch|network/i.test(msg)
const isRetryable = /502|503|504|timeout|fetch|network|abort/i.test(msg)
if (!isRetryable || i === maxRetries - 1) return null
await new Promise((r) => setTimeout(r, 800 * (i + 1)))
// Exponential-ish backoff: 500, 1000, 2000, 4000, 8000 (capped)
const delay = Math.min(500 * Math.pow(2, i), 8000)
await new Promise((r) => setTimeout(r, delay))
}
}
return null
}
/**
* Returns true/false if the backend gave a definitive answer, null if
* the backend is unreachable. Callers MUST handle null explicitly
* do not coerce to boolean without thinking about the consequences.
*/
export async function checkOnboardingStatus(): Promise<boolean | null> {
const result = await callWithRetry(() => rpcClient.isOnboardingComplete(), 5)
if (result !== null) {
if (result) {
try { localStorage.setItem('neode_onboarding_complete', '1') } catch {}
} else {
try { localStorage.removeItem('neode_onboarding_complete') } catch {}
}
}
return result
}
/**
* Boolean-only variant for places that genuinely cannot wait.
* Backend answer wins; on backend-unreachable, trusts a prior
* localStorage cache (set by a past successful check on THIS node).
* Returns false only when both the backend and the cache agree
* or when the cache is empty on a genuinely fresh install.
*
* Prefer checkOnboardingStatus() where possible so the caller can
* distinguish "confirmed fresh install" from "can't reach backend".
*/
export async function isOnboardingComplete(): Promise<boolean> {
// localStorage is set on completion and survives backend restarts/resets
if (localStorage.getItem('neode_onboarding_complete') === '1') return true
const result = await callWithRetry(() => rpcClient.isOnboardingComplete(), 2)
const result = await checkOnboardingStatus()
if (result !== null) return result
return false
// Backend unreachable — trust the local cache. If the cache says
// we're onboarded, we almost certainly are (this browser saw a
// prior backend 'true' and re-seeded the flag). If the cache is
// empty, we genuinely don't know; returning false here is the
// last-resort fallback, and the calling views should additionally
// keep polling the backend instead of treating this as gospel.
return localStorage.getItem('neode_onboarding_complete') === '1'
}
export async function completeOnboarding(): Promise<void> {
+20 -2
View File
@@ -19,6 +19,8 @@
"launch": "Launch",
"starting": "Starting...",
"stopping": "Stopping...",
"update": "Update",
"updating": "Updating...",
"send": "Send",
"sending": "Sending...",
"back": "Back",
@@ -666,7 +668,7 @@
"applyUpdate": "Install Update",
"checkForUpdates": "Check for Updates",
"checking": "Checking...",
"rollback": "Rollback to Previous",
"rollback": "Rollback Available",
"backToSettings": "Back to Settings",
"percentComplete": "{percent}% complete",
"applyWarning": "Installing components and restarting services. Do not power off.",
@@ -685,10 +687,26 @@
"rollbackSuccess": "Rolled back to previous version. Service will restart.",
"rollbackFailed": "Rollback failed.",
"pullAndRebuild": "Pull & Rebuild",
"finishingDownload": "Finishing download — verifying checksum…",
"overlayApplying": "Installing update…",
"overlayRestarting": "Restarting server…",
"overlayReconnecting": "Reconnecting to the new version…",
"overlayReady": "Update installed — reloading…",
"overlayStalled": "Taking longer than expected",
"overlayTarget": "Installing v{version}",
"overlayReloadNow": "Reload now",
"gitMethodHint": "This node builds from source. Update will git-pull, rebuild the backend and UI, then restart — takes a few minutes.",
"gitApplyTitle": "Pull & Rebuild?",
"gitApplyMessage": "Archipelago will pull the latest code, rebuild, and restart. This can take several minutes and the UI will be briefly unavailable.",
"gitApplyStarted": "Update started. The backend will rebuild and restart — this can take a few minutes."
"gitApplyStarted": "Update started. The backend will rebuild and restart — this can take a few minutes.",
"cancelDownload": "Cancel Download",
"cancelingDownload": "Canceling…",
"cancelDownloadTitle": "Cancel Download?",
"cancelDownloadConfirm": "This will stop the current download and discard the partial file. You can start again from scratch afterwards.",
"cancelDownloadButton": "Cancel Download",
"cancelDownloadSuccess": "Download canceled. You can try again.",
"cancelDownloadFailed": "Failed to cancel download.",
"downloadStalled": "Download appears stuck — try Cancel and start again."
},
"kiosk": {
"pressEsc": "Press ESC to exit",
+21 -3
View File
@@ -19,6 +19,8 @@
"launch": "Abrir",
"starting": "Iniciando...",
"stopping": "Deteniendo...",
"update": "Actualizar",
"updating": "Actualizando...",
"send": "Enviar",
"sending": "Enviando...",
"back": "Volver",
@@ -665,7 +667,7 @@
"applyUpdate": "Instalar actualizaci\u00f3n",
"checkForUpdates": "Buscar actualizaciones",
"checking": "Verificando...",
"rollback": "Revertir a la versi\u00f3n anterior",
"rollback": "Rollback disponible",
"backToSettings": "Volver a configuraci\u00f3n",
"percentComplete": "{percent}% completado",
"applyWarning": "Instalando componentes y reiniciando servicios. No apague el equipo.",
@@ -684,10 +686,26 @@
"rollbackSuccess": "Se revirti\u00f3 a la versi\u00f3n anterior. El servicio se reiniciar\u00e1.",
"rollbackFailed": "Error al revertir.",
"pullAndRebuild": "Pull y Recompilar",
"gitMethodHint": "Este nodo compila desde el c\u00f3digo fuente. La actualizaci\u00f3n har\u00e1 git-pull, recompilar\u00e1 y reiniciar\u00e1 — tarda unos minutos.",
"finishingDownload": "Terminando descarga \u2014 verificando checksum\u2026",
"overlayApplying": "Instalando actualizaci\u00f3n\u2026",
"overlayRestarting": "Reiniciando servidor\u2026",
"overlayReconnecting": "Reconectando a la nueva versi\u00f3n\u2026",
"overlayReady": "Actualizaci\u00f3n instalada \u2014 recargando\u2026",
"overlayStalled": "Tardando m\u00e1s de lo esperado",
"overlayTarget": "Instalando v{version}",
"overlayReloadNow": "Recargar ahora",
"gitMethodHint": "Este nodo compila desde el c\u00f3digo fuente. La actualizaci\u00f3n har\u00e1 git-pull, recompilar\u00e1 y reiniciar\u00e1 \u2014 tarda unos minutos.",
"gitApplyTitle": "\u00bfPull y Recompilar?",
"gitApplyMessage": "Archipelago descargar\u00e1 el c\u00f3digo m\u00e1s reciente, lo compilar\u00e1 y reiniciar\u00e1. Puede tardar varios minutos y la UI estar\u00e1 brevemente no disponible.",
"gitApplyStarted": "Actualizaci\u00f3n iniciada. El backend se recompilar\u00e1 y reiniciar\u00e1 puede tardar unos minutos."
"gitApplyStarted": "Actualizaci\u00f3n iniciada. El backend se recompilar\u00e1 y reiniciar\u00e1 \u2014 puede tardar unos minutos.",
"cancelDownload": "Cancelar descarga",
"cancelingDownload": "Cancelando\u2026",
"cancelDownloadTitle": "\u00bfCancelar descarga?",
"cancelDownloadConfirm": "Esto detendr\u00e1 la descarga actual y descartar\u00e1 el archivo parcial. Podr\u00e1s volver a empezar desde cero.",
"cancelDownloadButton": "Cancelar descarga",
"cancelDownloadSuccess": "Descarga cancelada. Puedes intentarlo de nuevo.",
"cancelDownloadFailed": "No se pudo cancelar la descarga.",
"downloadStalled": "La descarga parece atascada \u2014 prueba a cancelar y volver a empezar."
},
"kiosk": {
"pressEsc": "Presione ESC para salir",
+28 -6
View File
@@ -193,6 +193,11 @@ const router = createRouter({
name: 'system-update',
component: () => import('../views/SystemUpdate.vue'),
},
{
path: 'settings/registries',
name: 'app-registries',
component: () => import('../views/AppRegistries.vue'),
},
{
path: 'goals/:goalId',
name: 'goal-detail',
@@ -306,18 +311,35 @@ router.beforeEach(async (to, _from, next) => {
next()
return
}
// Check if this is a fresh install that needs onboarding
// Check if this is a fresh install that needs onboarding.
// Prefer checkOnboardingStatus() (tri-state) so we can distinguish
// "confirmed fresh install" from "backend unreachable". On the
// latter, send the user to RootRedirect (/) rather than the intro
// wizard — RootRedirect polls the backend and will route to
// /login once it answers, instead of forcing a re-onboarding.
try {
const { isOnboardingComplete, getSavedOnboardingStep } = await import('@/composables/useOnboarding')
const setupDone = await isOnboardingComplete()
if (!setupDone) {
const { checkOnboardingStatus, getSavedOnboardingStep } = await import('@/composables/useOnboarding')
const setupDone = await checkOnboardingStatus()
if (setupDone === false) {
const step = getSavedOnboardingStep()
next(`/onboarding/${step}`)
return
}
if (setupDone === null) {
// Backend unreachable after retries — bounce through RootRedirect
// so it can keep polling and land the user on /login once the
// backend answers, instead of flashing the onboarding wizard.
const cached = localStorage.getItem('neode_onboarding_complete') === '1'
if (!cached) {
next('/')
return
}
// Cached as onboarded — continue to the /login path below.
}
} catch {
// If we can't check, assume fresh install and show onboarding
next('/onboarding/intro')
// Unexpected error — do NOT default to onboarding. Hand off to
// RootRedirect which has retry + polling + boot-screen handling.
next('/')
return
}
next({ path: '/login', query: { redirect: to.fullPath } })
@@ -10,10 +10,19 @@ describe('useLoginTransitionStore', () => {
it('starts with all flags false', () => {
const store = useLoginTransitionStore()
expect(store.justLoggedIn).toBe(false)
expect(store.justCompletedOnboarding).toBe(false)
expect(store.pendingWelcomeTyping).toBe(false)
expect(store.startWelcomeTyping).toBe(false)
})
it('setJustCompletedOnboarding updates justCompletedOnboarding', () => {
const store = useLoginTransitionStore()
store.setJustCompletedOnboarding(true)
expect(store.justCompletedOnboarding).toBe(true)
store.setJustCompletedOnboarding(false)
expect(store.justCompletedOnboarding).toBe(false)
})
it('setJustLoggedIn updates justLoggedIn', () => {
const store = useLoginTransitionStore()
store.setJustLoggedIn(true)
-2
View File
@@ -41,7 +41,6 @@ const PORT_TO_APP_ID: Record<string, string> = {
'8334': 'bitcoin-knots',
'8888': 'searxng',
'9000': 'portainer',
'9001': 'penpot',
'9980': 'onlyoffice',
'11434': 'ollama',
'2283': 'immich',
@@ -51,7 +50,6 @@ const PORT_TO_APP_ID: Record<string, string> = {
'8175': 'fedimint',
'8176': 'fedimint-gateway',
'3100': 'dwn',
'18081': 'nostr-rs-relay',
'7777': 'indeedhub',
'50002': 'electrumx',
'3010': 'thunderhub',
+13
View File
@@ -4,6 +4,13 @@ import { ref } from 'vue'
/** Signals that we just logged in - Dashboard uses this for zoom + oomph */
export const useLoginTransitionStore = defineStore('loginTransition', () => {
const justLoggedIn = ref(false)
/**
* True only when the user just finished the onboarding wizard
* (first password setup), as distinct from a regular re-login.
* Dashboard uses this to decide whether to play the full glitchy
* reveal vs just a quick interface-draw.
*/
const justCompletedOnboarding = ref(false)
/** Show empty welcome block until typing starts (hide static text) */
const pendingWelcomeTyping = ref(false)
/** Trigger welcome typing on Home - set true after dashboard animation finishes */
@@ -13,6 +20,10 @@ export const useLoginTransitionStore = defineStore('loginTransition', () => {
justLoggedIn.value = value
}
function setJustCompletedOnboarding(value: boolean) {
justCompletedOnboarding.value = value
}
function setPendingWelcomeTyping(value: boolean) {
pendingWelcomeTyping.value = value
}
@@ -24,6 +35,8 @@ export const useLoginTransitionStore = defineStore('loginTransition', () => {
return {
justLoggedIn,
setJustLoggedIn,
justCompletedOnboarding,
setJustCompletedOnboarding,
pendingWelcomeTyping,
setPendingWelcomeTyping,
startWelcomeTyping,
+2
View File
@@ -91,6 +91,8 @@ export interface PackageDataEntry {
manifest: Manifest
installed?: InstalledPackageDataEntry
'install-progress'?: InstallProgress
/** Live label for the current uninstall step ("Stopping containers (2/5)", …). */
'uninstall-stage'?: string | null
'available-update'?: string | null
}
-36
View File
@@ -474,42 +474,6 @@ export const dummyApps: Record<string, PackageDataEntry> = {
status: ServiceStatus.Running
}
},
'penpot': {
state: PackageState.Running,
'static-files': {
license: 'MPL-2.0',
instructions: 'Design and prototyping platform',
icon: '/assets/img/penpot.webp'
},
manifest: {
id: 'penpot',
title: 'Penpot',
version: '2.0.0',
description: {
short: 'Open-source design and prototyping platform',
long: 'Penpot is an open-source design and prototyping platform for teams. Create designs, prototypes, and collaborate in real-time. Self-hosted alternative to Figma.'
},
'release-notes': 'Initial release',
license: 'MPL-2.0',
'wrapper-repo': 'https://github.com/penpot/penpot',
'upstream-repo': 'https://github.com/penpot/penpot',
'support-site': 'https://github.com/penpot/penpot/issues',
'marketing-site': 'https://penpot.app',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'penpot.onion',
'lan-address': 'http://localhost:9001'
}
},
status: ServiceStatus.Running
}
},
'indeedhub': {
state: PackageState.Running,
'static-files': {
+330
View File
@@ -0,0 +1,330 @@
<template>
<div class="pb-6">
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">App registries</h1>
<p class="text-white/70">
Container registries this node pulls app images from. The primary is tried first; if it's
slow or unreachable, the next one in the list is tried automatically.
</p>
</div>
<!-- Status message -->
<div
v-if="statusMessage"
class="mb-4 p-3 rounded-lg text-sm"
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
>
{{ statusMessage }}
</div>
<!-- Registry list -->
<div class="glass-card p-6 mb-6">
<div class="flex items-start justify-between gap-4 mb-2">
<h2 class="text-lg font-semibold text-white">Registries</h2>
<button
type="button"
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
@click="openAddRegistry"
>+ Add registry</button>
</div>
<p class="text-sm text-white/60 mb-4">
Registries are tried in priority order on every app install. Changing the primary takes
effect on the next install existing containers keep running on whatever image they
already pulled.
</p>
<ul v-if="registries.length" class="space-y-2">
<li
v-for="r in sortedRegistries"
:key="r.url"
class="p-3 bg-white/5 rounded-lg"
>
<div class="flex items-start gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5 flex-wrap">
<p class="text-sm font-medium text-white truncate">{{ r.name || r.url }}</p>
<span
v-if="r.priority === 0"
class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300"
>PRIMARY</span>
<span
v-if="!r.tls_verify"
class="text-[10px] font-mono px-2 py-0.5 rounded bg-amber-500/20 text-amber-300"
title="TLS verification disabled — HTTP or self-signed registry"
>HTTP</span>
</div>
<p class="text-xs text-white/50 font-mono break-all">{{ r.url }}</p>
</div>
<div class="shrink-0 flex items-center gap-1">
<button
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="registryTests[r.url]?.testing"
title="Test reachability"
@click="testRegistry(r)"
>
<svg v-if="registryTests[r.url]?.testing" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25"></circle>
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</button>
<button
v-if="r.priority !== 0"
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-yellow-300 hover:bg-white/10 transition-colors"
title="Make primary"
@click="setPrimary(r.url)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button
v-if="registries.length > 1"
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-red-300 hover:bg-red-400/10 transition-colors"
title="Remove registry"
@click="removeRegistry(r.url)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div
v-if="registryTests[r.url] && !registryTests[r.url]?.testing"
class="mt-2 pt-2 border-t border-white/5 text-xs"
>
<span v-if="registryTests[r.url]?.reachable" class="inline-flex items-center gap-1.5 text-green-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
Reachable (HTTP {{ registryTests[r.url]?.status }})
</span>
<span v-else class="inline-flex items-center gap-1.5 text-red-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="truncate">{{ registryTests[r.url]?.error || 'Unreachable' }}</span>
</span>
</div>
</li>
</ul>
</div>
<!-- Back link -->
<RouterLink
to="/dashboard/settings"
class="glass-button rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to Settings
</RouterLink>
<!-- Add-registry modal -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="addingRegistry"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md"
@click.self="cancelAddRegistry"
>
<div class="glass-card p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-1">Add app registry</h3>
<p class="text-sm text-white/60 mb-5">
The URL should be of the form <span class="font-mono text-white/80">host[:port]/namespace</span>
for example <span class="font-mono text-white/80">ghcr.io/myorg</span> or
<span class="font-mono text-white/80">192.168.1.50:3000/apps</span>. Registries are
added to the end of the list; use "Make primary" to reorder.
</p>
<form class="space-y-3" @submit.prevent="submitRegistry">
<div>
<label class="block text-xs text-white/60 mb-1">Name</label>
<input
v-model="registryDraft.name"
type="text"
placeholder="My private registry"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
/>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Registry URL</label>
<input
v-model="registryDraft.url"
type="text"
autofocus
placeholder="host:port/namespace"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
/>
</div>
<label class="flex items-center gap-2 cursor-pointer text-sm text-white/80">
<input
v-model="registryDraft.tls_verify"
type="checkbox"
class="accent-orange-400"
/>
Verify TLS certificate (uncheck for HTTP or self-signed)
</label>
<div class="flex gap-3 justify-end pt-2">
<button
type="button"
@click="cancelAddRegistry"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
>Cancel</button>
<button
type="submit"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="registrySaving || !registryDraft.url.trim()"
>{{ registrySaving ? 'Adding…' : 'Add registry' }}</button>
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue'
import { RouterLink } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
interface Registry {
url: string
name: string
tls_verify: boolean
enabled: boolean
priority: number
}
interface RegistryTestState {
testing?: boolean
reachable?: boolean
status?: number | null
error?: string | null
}
const registries = ref<Registry[]>([])
const sortedRegistries = computed(() =>
[...registries.value].sort((a, b) => a.priority - b.priority)
)
const registryTests = ref<Record<string, RegistryTestState>>({})
const statusMessage = ref('')
const statusIsError = ref(false)
const addingRegistry = ref(false)
const registrySaving = ref(false)
const registryDraft = reactive({ url: '', name: '', tls_verify: true })
function showStatus(msg: string, isError = false) {
statusMessage.value = msg
statusIsError.value = isError
setTimeout(() => { statusMessage.value = '' }, 8000)
}
async function loadRegistries() {
try {
const res = await rpcClient.call<{ registries: Registry[] }>({ method: 'registry.list' })
registries.value = res.registries
} catch (e) {
if (import.meta.env.DEV) console.warn('registry.list failed', e)
}
}
function openAddRegistry() {
registryDraft.url = ''
registryDraft.name = ''
registryDraft.tls_verify = true
addingRegistry.value = true
}
function cancelAddRegistry() {
addingRegistry.value = false
}
async function submitRegistry() {
const url = registryDraft.url.trim()
if (!url) return
registrySaving.value = true
try {
const res = await rpcClient.call<{ registries: Registry[] }>({
method: 'registry.add',
params: {
url,
name: registryDraft.name.trim() || url,
tls_verify: registryDraft.tls_verify,
},
})
registries.value = res.registries
addingRegistry.value = false
showStatus('Registry added.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Add registry failed: ${msg}`, true)
} finally {
registrySaving.value = false
}
}
async function removeRegistry(url: string) {
try {
const res = await rpcClient.call<{ registries: Registry[] }>({
method: 'registry.remove',
params: { url },
})
registries.value = res.registries
showStatus('Registry removed.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Remove failed: ${msg}`, true)
}
}
async function setPrimary(url: string) {
try {
const res = await rpcClient.call<{ registries: Registry[] }>({
method: 'registry.set-primary',
params: { url },
})
registries.value = res.registries
showStatus('Primary registry updated. Next install will try it first.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Set primary failed: ${msg}`, true)
}
}
async function testRegistry(r: Registry) {
registryTests.value = { ...registryTests.value, [r.url]: { testing: true } }
try {
const res = await rpcClient.call<{
url: string
reachable: boolean
status: number | null
error?: string | null
}>({ method: 'registry.test', params: { url: r.url, tls_verify: r.tls_verify } })
registryTests.value = {
...registryTests.value,
[r.url]: {
testing: false,
reachable: res.reachable,
status: res.status,
error: res.error ?? null,
},
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
registryTests.value = {
...registryTests.value,
[r.url]: { testing: false, reachable: false, error: msg },
}
}
}
onMounted(() => { void loadRegistries() })
</script>
+12
View File
@@ -117,6 +117,7 @@
@start="actions.startApp"
@stop="actions.stopApp"
@restart="actions.restartApp"
@update="updateApp"
@show-uninstall="showUninstallModal"
/>
</div>
@@ -282,6 +283,9 @@ function closeUninstallModal() {
async function onConfirmUninstall() {
const { appId } = uninstallModal.value
// Close the modal immediately so the user can fire off concurrent
// uninstalls. Each AppCard surfaces its own live stage label while
// its uninstall is in flight.
uninstallModal.value.show = false
await actions.confirmUninstall(appId)
}
@@ -293,4 +297,12 @@ function goToApp(id: string) {
function launchApp(id: string) {
useAppLauncherStore().openSession(id)
}
async function updateApp(id: string) {
try {
await serverStore.updatePackage(id)
} catch (err) {
actions.actionError.value = `Failed to update ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`
}
}
</script>
+14 -1
View File
@@ -264,10 +264,13 @@ watch(() => route.path, (newPath) => {
onMounted(() => {
previousRoutePath = route.path
document.body.classList.add('dashboard-active')
if (loginTransition.justLoggedIn) {
if (loginTransition.justCompletedOnboarding) {
// Full glitchy reveal only on the very first dashboard entry
// right after onboarding (one-time event, persists in feel).
playDashboardLoadOomph()
showZoomIn.value = true
loginTransition.setPendingWelcomeTyping(true)
loginTransition.setJustCompletedOnboarding(false)
loginTransition.setJustLoggedIn(false)
const triggerRevealGlitch = () => {
isGlitching.value = true
@@ -281,6 +284,16 @@ onMounted(() => {
loginTransition.setStartWelcomeTyping(true)
loginTransition.setPendingWelcomeTyping(false)
}, 4000)
} else if (loginTransition.justLoggedIn) {
// Regular re-login no zoom, no glitch. Just land on the
// dashboard and kick off the welcome typing quickly.
playDashboardLoadOomph()
loginTransition.setPendingWelcomeTyping(true)
loginTransition.setJustLoggedIn(false)
scheduledTimeout(() => {
loginTransition.setStartWelcomeTyping(true)
loginTransition.setPendingWelcomeTyping(false)
}, 300)
}
window.addEventListener('keydown', handleKioskShortcuts)
+14 -1
View File
@@ -142,7 +142,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useTransportStore } from '@/stores/transport'
import { useAppStore } from '@/stores/app'
@@ -537,6 +537,8 @@ async function rotateDid(password: string) {
}
}
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
loadNodes()
loadDwnStatus()
@@ -549,5 +551,16 @@ onMounted(async () => {
} catch {
// Self DID not available
}
autoRefreshTimer = setInterval(() => {
loadNodes()
loadPendingRequests()
}, 5000)
})
onUnmounted(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
})
</script>
-1
View File
@@ -178,7 +178,6 @@ const APP_ICON_MAP: Record<string, string> = {
fedimint: '/assets/img/app-icons/fedimint.png',
mempool: '/assets/img/app-icons/mempool.webp',
electrs: '/assets/img/app-icons/electrs.svg',
'nostr-rs-relay': '/assets/img/app-icons/nostr-rs-relay.svg',
}
function stepIconUrl(step: GoalStep): string | undefined {
+17 -8
View File
@@ -317,26 +317,35 @@ const torConnected = computed(() => {
})
const vpnStatus = ref({ connected: false, provider: '' })
const vpnConnected = computed(() => vpnStatus.value.connected || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null)
const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
const fipsDotClass = computed(() => {
const s = fipsStatus.value
if (!s || !s.installed) return 'bg-white/40'
if (s.service_active) return 'bg-green-400'
return 'bg-white/40'
if (!s.service_active) return 'bg-white/40'
// Active but no anchor = degraded, not fully green
if (s.anchor_connected === false) return 'bg-orange-400'
return 'bg-green-400'
})
const fipsTextClass = computed(() => {
const s = fipsStatus.value
if (!s || !s.installed) return 'text-white/40'
if (s.service_active) return 'text-green-400'
return 'text-white/40'
if (!s.service_active) return 'text-white/40'
if (s.anchor_connected === false) return 'text-orange-400'
return 'text-green-400'
})
const fipsStatusLabel = computed(() => {
const s = fipsStatus.value
if (!s) return '…'
if (!s.installed) return 'Not installed'
if (s.service_active) return 'Active'
if (!s.key_present) return 'Awaiting seed'
return 'Inactive'
if (!s.service_active) {
if (!s.key_present) return 'Awaiting seed'
return 'Inactive'
}
// Service is active reflect anchor reachability in the label so the
// Home and Server rows flip in sync with the FIPS card.
if (s.anchor_connected === false) return 'No anchor'
const peers = s.authenticated_peer_count ?? 0
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
})
const bitcoinSyncDisplay = computed(() => {
if (!systemStats.bitcoinAvailable) return 'Not running'
-2
View File
@@ -99,7 +99,6 @@ const launchableApps = computed<KioskApp[]>(() => {
'filebrowser': '/app/filebrowser/',
'searxng': '/app/searxng/',
'ollama': '/app/ollama/',
'penpot': '/app/penpot/',
'onlyoffice': '/app/onlyoffice/',
'portainer': '/app/portainer/',
'uptime-kuma': '/app/uptime-kuma/',
@@ -108,7 +107,6 @@ const launchableApps = computed<KioskApp[]>(() => {
'fedimint': '/app/fedimint/',
'fedimint-gateway': '/app/fedimint-gateway/',
'dwn': '/app/dwn/',
'nostr-rs-relay': '/app/nostr-rs-relay/',
'indeedhub': 'http://localhost:8190',
'botfights': 'http://localhost:9100',
'nwnn': 'https://nwnn.l484.com',
+1
View File
@@ -408,6 +408,7 @@ async function handleSetup() {
stopSynthwave()
whooshAway.value = true
playLoginSuccessWhoosh()
loginTransition.setJustCompletedOnboarding(true)
loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520))
await router.replace(loginRedirectTo.value).catch(() => {
+41 -2
View File
@@ -86,9 +86,23 @@ const videoBackgroundRoutes = ['/onboarding/intro', '/login']
// Login uses video when coming from splash, or static + glitch when direct
const isLoginRoute = computed(() => route.path === '/login')
// True once onboarding is complete. Used to skip the intro video on
// the /login route so that returning (logged-out) users go straight
// to the screensaver-style static + glitch background instead of
// replaying the full intro every time.
const onboardingDone = computed(() => {
try {
return localStorage.getItem('neode_onboarding_complete') === '1'
} catch {
return false
}
})
// Check if current route should use video background
const useVideoBackground = computed(() => {
return videoBackgroundRoutes.includes(route.path)
if (!videoBackgroundRoutes.includes(route.path)) return false
if (route.path === '/login' && onboardingDone.value) return false
return true
})
// Map each route to a specific background image
@@ -108,7 +122,32 @@ const routeBackgrounds: Record<string, string> = {
'/login': 'bg-intro.jpg' // Video loops from splash (same as intro)
}
const loginBackground = 'bg-intro-1.jpg'
// Rotate the login background so the lock screen doesn't look
// identical on every logout. Cycles through bg-intro-1..6 using a
// counter persisted to localStorage so subsequent visits advance.
const LOGIN_BACKGROUNDS = [
'bg-intro-1.jpg',
'bg-intro-2.jpg',
'bg-intro-3.jpg',
'bg-intro-4.jpg',
'bg-intro-5.jpg',
'bg-intro-6.jpg',
]
function pickNextLoginBackground(): string {
try {
const raw = localStorage.getItem('neode_login_bg_idx')
const prev = raw !== null ? parseInt(raw, 10) : -1
const next = (Number.isFinite(prev) ? prev + 1 : 0) % LOGIN_BACKGROUNDS.length
localStorage.setItem('neode_login_bg_idx', String(next))
return LOGIN_BACKGROUNDS[next]!
} catch {
return LOGIN_BACKGROUNDS[Math.floor(Math.random() * LOGIN_BACKGROUNDS.length)]!
}
}
const loginBackground = ref(pickNextLoginBackground())
watch(() => route.path, (p) => {
if (p === '/login') loginBackground.value = pickNextLoginBackground()
})
// Restore video time from splash screen for seamless transition
function restoreVideoTime() {
+32 -5
View File
@@ -50,11 +50,14 @@ async function quickHealthCheck(): Promise<boolean> {
}
async function checkOnboarded(): Promise<boolean> {
// No hard timeout here. isOnboardingComplete() already retries with
// backoff (see useOnboarding.ts). A 3s Promise.race that resolves to
// `false` on timeout was previously the main cause of the intro
// flashing on already-onboarded nodes after browser-clear / reboot /
// update: if the backend was slow to warm up, we'd force a 'false'
// and route the user back through the setup wizard.
try {
const result = await Promise.race([
isOnboardingComplete(),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 3000)),
])
const result = await isOnboardingComplete()
log('checkOnboarded', { result })
return result
} catch (e) {
@@ -129,7 +132,31 @@ onMounted(async () => {
return
}
// Server not ready show boot screen (waiting for backend)
// Server not ready. The full BootScreen is meant for a genuine
// cold-start (fresh install), not for the brief blip during an
// OTA update where the backend restarts. If onboarding has already
// completed we just keep the spinner and retry until the server
// responds again.
const wasOnboardedBefore = localStorage.getItem('neode_onboarding_complete') === '1'
if (wasOnboardedBefore) {
log('server down + onboarded → polling without boot screen')
let retries = 0
const maxRetries = 30 // 30 * 2s = 60s before giving up and showing boot screen
const poll = setInterval(async () => {
retries++
if (await quickHealthCheck()) {
clearInterval(poll)
proceedToApp()
return
}
if (retries >= maxRetries) {
clearInterval(poll)
log('server still down after retries → falling back to boot screen')
showBootScreen.value = true
}
}, 2000)
return
}
showBootScreen.value = true
})
</script>
+14 -8
View File
@@ -420,25 +420,31 @@ const networkData = ref({
})
// FIPS status row for the Local Network card. Full FIPS card lives below.
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null)
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
const fipsRowLabel = computed(() => {
const s = fipsSummary.value
if (!s) return '…'
if (!s.installed) return 'Not installed'
// Service-active wins even on legacy nodes with no seed-derived key.
if (s.service_active) return 'Active'
if (!s.key_present) return 'Awaiting seed'
return 'Inactive'
if (!s.service_active) {
if (!s.key_present) return 'Awaiting seed'
return 'Inactive'
}
// Service is active reflect anchor reachability so the row flips in
// sync with the full FIPS card below.
if (s.anchor_connected === false) return 'No anchor'
const peers = s.authenticated_peer_count ?? 0
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
})
const fipsRowTextClass = computed(() => {
const s = fipsSummary.value
if (!s || !s.installed) return 'text-white/40'
if (s.service_active) return 'text-green-400'
return 'text-white/60'
if (!s.service_active) return 'text-white/60'
if (s.anchor_connected === false) return 'text-orange-400'
return 'text-green-400'
})
async function loadFipsSummary() {
try {
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean }>({ method: 'fips.status' })
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ method: 'fips.status' })
} catch { /* backend too old */ }
}
+554 -34
View File
@@ -42,6 +42,12 @@
<div>
<h2 class="text-lg font-semibold text-white">{{ t('systemUpdate.updateAvailable') }}</h2>
<p class="text-sm text-white/60">Version {{ updateInfo.version }} &mdash; {{ updateInfo.release_date }}</p>
<p v-if="manifestMirrorLabel" class="text-xs text-white/40 mt-1 flex items-center gap-1.5">
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7" />
</svg>
<span>Served by <span class="text-white/70">{{ manifestMirrorLabel }}</span></span>
</p>
</div>
<span class="px-3 py-1 bg-orange-500/20 text-orange-400 text-xs font-medium rounded-full">{{ t('systemUpdate.new') }}</span>
</div>
@@ -109,11 +115,31 @@
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.downloading') }}</h2>
<div class="w-full h-3 bg-white/10 rounded-full overflow-hidden mb-2">
<div
class="h-full bg-orange-400 rounded-full transition-all duration-500"
class="h-full rounded-full transition-all duration-500"
:class="downloadStalled ? 'bg-amber-400' : 'bg-orange-400'"
:style="{ width: downloadPercentFormatted + '%' }"
></div>
</div>
<p class="text-xs text-white/60">{{ t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}</p>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-2 min-w-0">
<div v-if="downloadFinishing && !downloadStalled" class="w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin shrink-0"></div>
<p class="text-xs" :class="downloadStalled ? 'text-amber-300' : 'text-white/60'">
{{ downloadStalled
? t('systemUpdate.downloadStalled')
: downloadFinishing
? t('systemUpdate.finishingDownload')
: t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}
</p>
</div>
<button
@click="requestCancelDownload"
:disabled="cancelingDownload"
class="glass-button rounded-lg px-4 py-1.5 text-xs font-medium disabled:opacity-40 shrink-0"
:class="downloadStalled ? 'bg-amber-500/20 border-amber-400/40 text-amber-200' : ''"
>
{{ cancelingDownload ? t('systemUpdate.cancelingDownload') : t('systemUpdate.cancelDownload') }}
</button>
</div>
</div>
<!-- Applying -->
@@ -152,6 +178,87 @@
</div>
</div>
<!-- Mirrors -->
<div class="glass-card p-6 mb-6">
<div class="flex items-start justify-between gap-4 mb-2">
<h2 class="text-lg font-semibold text-white">Update mirrors</h2>
<button
type="button"
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
@click="openAddMirror"
>+ Add mirror</button>
</div>
<p class="text-sm text-white/60 mb-4">
Servers this node checks for updates. The primary is tried first; if it's slow or unreachable, the next one in the list is tried automatically. Downloads always come from the mirror that served the manifest switching primary switches where files come from.
</p>
<ul v-if="mirrors.length" class="space-y-2">
<li v-for="(m, i) in mirrors" :key="m.url" class="p-3 bg-white/5 rounded-lg">
<div class="flex items-start gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5 flex-wrap">
<p class="text-sm font-medium text-white truncate">{{ m.label || `Mirror ${i + 1}` }}</p>
<span v-if="i === 0" class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300">PRIMARY</span>
</div>
<p class="text-xs text-white/50 font-mono break-all">{{ m.url }}</p>
</div>
<div class="shrink-0 flex items-center gap-1">
<button
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="mirrorTests[m.url]?.testing"
title="Test reachability"
@click="testMirror(m.url)"
>
<svg v-if="mirrorTests[m.url]?.testing" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25"></circle>
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</button>
<button
v-if="i !== 0"
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-yellow-300 hover:bg-white/10 transition-colors"
title="Make primary"
@click="setPrimaryMirror(m.url)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button
v-if="mirrors.length > 1"
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-red-300 hover:bg-red-400/10 transition-colors"
title="Remove mirror"
@click="removeMirror(m.url)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div v-if="mirrorTests[m.url] && !mirrorTests[m.url]?.testing" class="mt-2 pt-2 border-t border-white/5 text-xs">
<span v-if="mirrorTests[m.url]?.reachable" class="inline-flex items-center gap-1.5 text-green-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
Reachable &middot; {{ mirrorTests[m.url]?.latency_ms }}ms
</span>
<span v-else class="inline-flex items-center gap-1.5 text-red-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="truncate">{{ mirrorTests[m.url]?.error || 'Unreachable' }}</span>
</span>
</div>
</li>
</ul>
</div>
<!-- Actions row -->
<div class="glass-card p-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2>
@@ -176,6 +283,112 @@
</div>
</div>
<!-- Install progress overlay covers the UI while the backend
swaps files, restarts, and comes back up on the new version.
Auto-reloads the page as soon as /health reports the target
version. Styled to match the screensaver (ASCII logo, full-
screen black). -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="installing"
class="fixed inset-0 z-[3000] bg-black flex flex-col items-center justify-center overflow-hidden"
>
<!-- Centered ASCII logo same asset used by the screensaver -->
<div class="install-overlay-ascii">
<BitcoinFaceAscii />
</div>
<!-- Status text + progress bar underneath -->
<div class="mt-8 w-[min(520px,80vw)] text-center">
<h2 class="text-xl font-semibold text-white mb-1">
{{ installStage === 'applying' ? t('systemUpdate.overlayApplying')
: installStage === 'restarting' ? t('systemUpdate.overlayRestarting')
: installStage === 'reconnecting' ? t('systemUpdate.overlayReconnecting')
: installStage === 'ready' ? t('systemUpdate.overlayReady')
: t('systemUpdate.overlayStalled') }}
</h2>
<p v-if="installTargetVersion" class="text-sm text-white/60 mb-4">
{{ t('systemUpdate.overlayTarget', { version: installTargetVersion }) }}
</p>
<!-- Animated bar: indeterminate stripe while working; full
orange when ready; steady at 50% (paused look) when
stalled so it reads as "something needs the user". -->
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden mb-3 relative">
<div
v-if="installStage === 'ready'"
class="absolute inset-0 bg-green-400"
></div>
<div
v-else-if="installStage === 'stalled'"
class="absolute inset-y-0 left-0 w-1/2 bg-orange-400/60"
></div>
<div
v-else
class="absolute inset-y-0 w-1/3 bg-orange-400 rounded-full install-overlay-bar-anim"
></div>
</div>
<p class="text-xs text-white/40">{{ installElapsedLabel }}</p>
<button
v-if="installStage === 'stalled'"
@click="reloadNow"
class="mt-5 glass-button rounded-lg px-5 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
>
{{ t('systemUpdate.overlayReloadNow') }}
</button>
</div>
</div>
</Transition>
</Teleport>
<!-- Add-mirror modal -->
<Transition name="fade">
<div v-if="addingMirror" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelAddMirror">
<div class="glass-card p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-1">Add update mirror</h3>
<p class="text-sm text-white/60 mb-5">
The URL should point directly at a <span class="font-mono text-white/80">manifest.json</span> served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order.
</p>
<form class="space-y-3" @submit.prevent="submitMirror">
<div>
<label class="block text-xs text-white/60 mb-1">Manifest URL</label>
<input
v-model="mirrorDraft.url"
type="text"
autofocus
placeholder="https://host/.../manifest.json"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
/>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Label (optional)</label>
<input
v-model="mirrorDraft.label"
type="text"
placeholder="Home VPS"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
/>
</div>
<div class="flex gap-3 justify-end pt-2">
<button
type="button"
@click="cancelAddMirror"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
>{{ t('common.cancel') }}</button>
<button
type="submit"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
</div>
</form>
</div>
</div>
</Transition>
<!-- Confirmation modal -->
<Transition name="fade">
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelConfirm">
@@ -185,14 +398,18 @@
? t('systemUpdate.rollbackTitle')
: confirmAction === 'git-apply'
? t('systemUpdate.gitApplyTitle')
: t('systemUpdate.applyTitle') }}
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadTitle')
: t('systemUpdate.applyTitle') }}
</h3>
<p class="text-sm text-white/70 mb-6">
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackMessage')
: confirmAction === 'git-apply'
? t('systemUpdate.gitApplyMessage')
: t('systemUpdate.applyMessage') }}
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadConfirm')
: t('systemUpdate.applyMessage') }}
</p>
<div class="flex gap-3 justify-end">
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
@@ -201,13 +418,15 @@
<button
@click="executeConfirm"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
:class="confirmAction === 'rollback' ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
:class="(confirmAction === 'rollback' || confirmAction === 'cancel-download') ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
>
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackButton')
: confirmAction === 'git-apply'
? t('systemUpdate.pullAndRebuild')
: t('systemUpdate.applyNow') }}
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadButton')
: t('systemUpdate.applyNow') }}
</button>
</div>
</div>
@@ -217,10 +436,11 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import BitcoinFaceAscii from '@/views/discover/BitcoinFaceAscii.vue'
interface UpdateDetail {
version: string
@@ -244,7 +464,9 @@ const loading = ref(false)
const downloading = ref(false)
const downloaded = ref(false)
const applying = ref(false)
const confirmAction = ref<'apply' | 'git-apply' | 'rollback' | null>(null)
const cancelingDownload = ref(false)
const downloadStalled = ref(false)
const confirmAction = ref<'apply' | 'git-apply' | 'rollback' | 'cancel-download' | null>(null)
const currentVersion = ref('0.0.0')
const lastCheck = ref<string | null>(null)
const updateInfo = ref<UpdateDetail | null>(null)
@@ -256,6 +478,231 @@ const statusIsError = ref(false)
const downloadPercent = ref(0)
const downloadPercentFormatted = computed(() => downloadPercent.value.toFixed(2))
// Mirrors servers this node tries for the manifest, in priority
// order. First entry is the primary. Add/remove/set-primary are wired
// to update.*-mirror RPCs; downloads automatically go to the mirror
// that served the manifest.
interface UpdateMirror { url: string; label: string }
const mirrors = ref<UpdateMirror[]>([])
const addingMirror = ref(false)
const mirrorSaving = ref(false)
const mirrorDraft = reactive({ url: '', label: '' })
// URL of the mirror that served the currently-available-update manifest.
// Backend reports it in update.status and update.check responses; the UI
// resolves it to a friendly label by matching against the mirrors list.
const manifestMirror = ref<string | null>(null)
const manifestMirrorLabel = computed(() => {
const url = manifestMirror.value
if (!url) return null
const match = mirrors.value.find(m => m.url === url)
if (match && match.label) return match.label
try {
const u = new URL(url)
return u.host
} catch {
return url
}
})
// Per-mirror test state. Populated by testMirror(); each entry is either
// { testing: true } while in flight or the backend response shape on
// completion. Rendered inline under each mirror row.
interface MirrorTestState {
testing?: boolean
reachable?: boolean
latency_ms?: number
http_status?: number | null
error?: string | null
}
const mirrorTests = ref<Record<string, MirrorTestState>>({})
async function testMirror(url: string) {
mirrorTests.value = { ...mirrorTests.value, [url]: { testing: true } }
try {
const res = await rpcClient.call<{
reachable: boolean
latency_ms: number
http_status: number | null
error: string | null
}>({ method: 'update.test-mirror', params: { url } })
mirrorTests.value = { ...mirrorTests.value, [url]: { ...res, testing: false } }
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
mirrorTests.value = { ...mirrorTests.value, [url]: { testing: false, reachable: false, error: msg } }
}
}
function openAddMirror() {
mirrorDraft.url = ''
mirrorDraft.label = ''
addingMirror.value = true
}
function cancelAddMirror() {
addingMirror.value = false
}
async function loadMirrors() {
try {
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({ method: 'update.list-mirrors' })
mirrors.value = res.mirrors
} catch (e) {
if (import.meta.env.DEV) console.warn('update.list-mirrors failed', e)
}
}
async function submitMirror() {
const url = mirrorDraft.url.trim()
if (!url) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
showStatus('Mirror URL must start with http:// or https://', true)
return
}
mirrorSaving.value = true
try {
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
method: 'update.add-mirror',
params: { url, label: mirrorDraft.label.trim() },
})
mirrors.value = res.mirrors
mirrorDraft.url = ''
mirrorDraft.label = ''
addingMirror.value = false
showStatus('Mirror added.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Add mirror failed: ${msg}`, true)
} finally {
mirrorSaving.value = false
}
}
async function removeMirror(url: string) {
try {
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
method: 'update.remove-mirror',
params: { url },
})
mirrors.value = res.mirrors
showStatus('Mirror removed.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Remove failed: ${msg}`, true)
}
}
async function setPrimaryMirror(url: string) {
try {
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
method: 'update.set-primary-mirror',
params: { url },
})
mirrors.value = res.mirrors
showStatus('Primary mirror updated. Next update check will try it first.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Set primary failed: ${msg}`, true)
}
}
// Poll the backend for the real bytes_downloaded / total_bytes so the
// progress bar tracks actual download state (and survives route
// changes). Returns true if a download is currently in progress.
async function pollDownloadProgress(): Promise<boolean> {
try {
const res = await rpcClient.call<{
download_progress?: {
bytes_downloaded: number
total_bytes: number
active: boolean
stalled?: boolean
} | null
}>({ method: 'update.status' })
const p = res.download_progress
if (p && p.total_bytes > 0) {
downloadPercent.value = Math.min(100, (p.bytes_downloaded / p.total_bytes) * 100)
downloadStalled.value = !!p.stalled
return p.active
}
downloadStalled.value = false
return false
} catch {
return false
}
}
// Shown next to the progress bar when the fake increment has maxed out
// at 95% but the real RPC hasn't returned yet lets the user know the
// UI hasn't frozen while SHA verification and disk writes finish.
const downloadFinishing = computed(() => downloading.value && downloadPercent.value >= 95)
// Install overlay state drives the full-screen progress modal shown
// while the backend swaps files, restarts, and comes back up on the
// new version. The overlay polls /health and auto-reloads the browser
// as soon as the backend reports the target version, so the user
// doesn't need to manually refresh.
type InstallStage = 'applying' | 'restarting' | 'reconnecting' | 'ready' | 'stalled'
const installing = ref(false)
const installStage = ref<InstallStage>('applying')
const installTargetVersion = ref<string | null>(null)
const installStartedAt = ref<number>(0)
const installElapsedSec = ref(0)
let installPollTimer: ReturnType<typeof setInterval> | null = null
let installElapsedTimer: ReturnType<typeof setInterval> | null = null
const installElapsedLabel = computed(() => {
const s = installElapsedSec.value
if (s < 60) return `Elapsed: ${s}s`
return `Elapsed: ${Math.floor(s / 60)}m${s % 60 < 10 ? '0' : ''}${s % 60}s`
})
function startInstallOverlay(targetVersion: string) {
installing.value = true
installStage.value = 'applying'
installTargetVersion.value = targetVersion
installStartedAt.value = Date.now()
installElapsedSec.value = 0
// Tick an elapsed counter once per second for the UI.
installElapsedTimer = setInterval(() => {
installElapsedSec.value = Math.floor((Date.now() - installStartedAt.value) / 1000)
// Stop polling after 3 min surface the manual reload button.
if (installElapsedSec.value >= 180 && installStage.value !== 'ready') {
installStage.value = 'stalled'
}
}, 1000)
// Start polling /health after a short delay the backend restarts 2s
// after replying to update.apply, so an immediate poll would see the
// old backend and conclude nothing happened.
setTimeout(() => {
installStage.value = 'restarting'
installPollTimer = setInterval(pollHealth, 1500)
}, 2500)
}
async function pollHealth() {
if (installStage.value === 'ready' || installStage.value === 'stalled') return
try {
const res = await fetch('/health', { signal: AbortSignal.timeout(2000) })
if (!res.ok) throw new Error(`health ${res.status}`)
const data = await res.json() as { version?: string }
if (data.version && data.version === installTargetVersion.value) {
installStage.value = 'ready'
if (installPollTimer) { clearInterval(installPollTimer); installPollTimer = null }
// Brief pause so the user sees the "Ready" state before the reload.
setTimeout(() => { window.location.reload() }, 1200)
} else {
// Backend is up but still reporting the old version frontend
// and backend are mid-swap. Signal to the user.
installStage.value = 'reconnecting'
}
} catch {
// Fetch fails while the server is mid-restart. Stay in 'restarting'.
}
}
function reloadNow() { window.location.reload() }
// Cleanup if the component is torn down mid-install (unlikely but safe).
import { onBeforeUnmount } from 'vue'
onBeforeUnmount(() => {
if (installPollTimer) clearInterval(installPollTimer)
if (installElapsedTimer) clearInterval(installElapsedTimer)
})
const lastCheckDisplay = computed(() => {
if (!lastCheck.value) return t('common.never')
try {
@@ -300,11 +747,13 @@ async function loadStatus() {
update_available: boolean
update_in_progress: boolean
rollback_available: boolean
manifest_mirror: string | null
}>({ method: 'update.status' })
currentVersion.value = res.current_version
lastCheck.value = res.last_check
updateInProgress.value = res.update_in_progress
rollbackAvailable.value = res.rollback_available
manifestMirror.value = res.manifest_mirror ?? null
if (res.update_in_progress) {
downloaded.value = true
@@ -324,11 +773,13 @@ async function checkForUpdates() {
update_available: boolean
update: UpdateDetail | null
update_method?: string
manifest_mirror?: string | null
}>({ method: 'update.check' })
currentVersion.value = res.current_version
lastCheck.value = res.last_check
updateInfo.value = res.update
updateMethod.value = res.update_method === 'git' ? 'git' : 'manifest'
manifestMirror.value = res.manifest_mirror ?? null
if (!res.update_available) {
showStatus(t('systemUpdate.upToDateMessage'))
}
@@ -345,21 +796,19 @@ async function downloadUpdate() {
downloadPercent.value = 0
statusMessage.value = ''
// Simulate incremental progress while waiting for the RPC. Capped at
// 95% so the bar never shows >100% before the real completion jumps it
// to 100 previously the random increment could overshoot.
const progressInterval = setInterval(() => {
if (downloadPercent.value < 95) {
downloadPercent.value = Math.min(95, downloadPercent.value + Math.random() * 3)
}
}, 500)
// Poll the backend's real byte counter every second instead of
// faking progress. The backend exposes bytes_downloaded/total_bytes
// via update.status, updated per chunk. This also means the bar
// resumes correctly after navigating away and back no more
// "95% for some time" mystery.
const progressInterval = setInterval(() => { void pollDownloadProgress() }, 1000)
try {
const res = await rpcClient.call<{
total_bytes: number
downloaded_bytes: number
components_downloaded: number
}>({ method: 'update.download', timeout: 1_800_000 })
}>({ method: 'update.download', timeout: 3_900_000 })
downloadPercent.value = 100
downloaded.value = true
const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1)
@@ -385,6 +834,10 @@ function requestRollback() {
confirmAction.value = 'rollback'
}
function requestCancelDownload() {
confirmAction.value = 'cancel-download'
}
function cancelConfirm() {
confirmAction.value = null
}
@@ -395,40 +848,69 @@ async function executeConfirm() {
if (action === 'apply') {
await applyUpdate()
} else if (action === 'git-apply') {
await applyUpdateGit()
await applyUpdateGitWithOverlay()
} else if (action === 'rollback') {
await rollbackUpdate()
} else if (action === 'cancel-download') {
await cancelDownload()
}
}
async function applyUpdateGit() {
applying.value = true
statusMessage.value = ''
async function cancelDownload() {
cancelingDownload.value = true
try {
await rpcClient.call({ method: 'update.git-apply', timeout: 900_000 })
showStatus(t('systemUpdate.gitApplyStarted'))
updateInfo.value = null
await rpcClient.call({ method: 'update.cancel-download' })
downloading.value = false
downloaded.value = false
downloadPercent.value = 0
downloadStalled.value = false
showStatus(t('systemUpdate.cancelDownloadSuccess'))
} catch (e) {
showStatus(t('systemUpdate.applyFailed'), true)
if (import.meta.env.DEV) console.warn('Git apply failed', e)
showStatus(t('systemUpdate.cancelDownloadFailed'), true)
if (import.meta.env.DEV) console.warn('Cancel download failed', e)
} finally {
applying.value = false
cancelingDownload.value = false
}
}
async function applyUpdate() {
applying.value = true
statusMessage.value = ''
const target = updateInfo.value?.version || null
try {
await rpcClient.call({ method: 'update.apply', timeout: 300_000 })
showStatus(t('systemUpdate.applySuccess'))
updateInfo.value = null
downloaded.value = false
await loadStatus()
// Apply succeeded. Backend scheduled a restart 2s after returning;
// show the full-screen overlay while we wait for the new backend
// to report the target version, then auto-reload.
applying.value = false
if (target) {
startInstallOverlay(target)
} else {
// No target version known (legacy path) fall back to the old
// flash-and-reload behaviour.
showStatus(t('systemUpdate.applySuccess'))
setTimeout(() => window.location.reload(), 3000)
}
} catch (e) {
showStatus(t('systemUpdate.applyFailed'), true)
if (import.meta.env.DEV) console.warn('Apply failed', e)
} finally {
applying.value = false
}
}
async function applyUpdateGitWithOverlay() {
// Git-apply (dev path) also restarts the service reuse the overlay
// so the UX matches the manifest path. Target version isn't known up
// front for git-apply; we just wait for a version change on /health.
applying.value = true
statusMessage.value = ''
try {
await rpcClient.call({ method: 'update.git-apply', timeout: 900_000 })
applying.value = false
startInstallOverlay(updateInfo.value?.version || currentVersion.value)
} catch (e) {
showStatus(t('systemUpdate.applyFailed'), true)
if (import.meta.env.DEV) console.warn('Git apply failed', e)
applying.value = false
}
}
@@ -465,7 +947,45 @@ async function setSchedule(value: ScheduleValue) {
}
}
onMounted(() => {
Promise.all([loadStatus(), loadSchedule(), checkForUpdates()])
onMounted(async () => {
await Promise.all([loadStatus(), loadSchedule(), loadMirrors(), checkForUpdates()])
// If a download was already running when the user navigated here
// (or refreshed), pick up the progress bar where it is and keep
// polling until the backend reports done. No RPC call to start the
// download the backend's already running it.
const active = await pollDownloadProgress()
if (active) {
downloading.value = true
const resumeInterval = setInterval(async () => {
const stillActive = await pollDownloadProgress()
if (!stillActive) {
clearInterval(resumeInterval)
downloading.value = false
downloaded.value = true
}
}, 1000)
}
})
</script>
<style scoped>
/* Centered ASCII logo clamped so the overlay doesn't blow out on
narrow viewports. :deep so the rule reaches BitcoinFaceAscii's
inner <pre>. */
.install-overlay-ascii :deep(pre) {
font-size: clamp(6px, 1.2vw, 12px);
line-height: 1.1;
color: rgba(255, 255, 255, 0.85);
margin: 0;
}
/* Indeterminate progress stripe that slides left-to-right. */
.install-overlay-bar-anim {
animation: installBarSlide 1.8s ease-in-out infinite;
}
@keyframes installBarSlide {
0% { transform: translateX(-100%); }
50% { transform: translateX(120%); }
100% { transform: translateX(300%); }
}
</style>
@@ -34,7 +34,6 @@ export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
searxng: 'searxng',
ollama: 'ollama',
onlyoffice: 'onlyoffice',
penpot: 'penpot',
nextcloud: 'nextcloud',
vaultwarden: 'vaultwarden',
jellyfin: 'jellyfin',
@@ -79,7 +78,6 @@ export const APP_URLS: Record<string, { dev: string; prod: string }> = {
'ollama': { dev: 'http://localhost:11434', prod: 'http://localhost:11434' },
'searxng': { dev: 'http://localhost:8888', prod: 'http://localhost:8888' },
'onlyoffice': { dev: 'http://localhost:9980', prod: 'http://localhost:9980' },
'penpot': { dev: 'http://localhost:9001', prod: 'http://localhost:9001' },
'nextcloud': { dev: 'http://localhost:8085', prod: 'http://localhost:8085' },
'vaultwarden': { dev: 'http://localhost:8082', prod: 'http://localhost:8082' },
'jellyfin': { dev: 'http://localhost:8096', prod: 'http://localhost:8096' },
@@ -7,6 +7,7 @@ export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
/** Container apps: direct port access (avoids root-relative asset breakage under /app/xxx/ proxy) */
export const APP_PORTS: Record<string, number> = {
'bitcoin-knots': 8334,
'bitcoin-core': 8334,
'bitcoin-ui': 8334,
'electrumx': 50002,
'electrs': 50002,
@@ -23,7 +24,6 @@ export const APP_PORTS: Record<string, number> = {
'searxng': 8888,
'ollama': 11434,
'onlyoffice': 8044,
'penpot': 9001,
'nextcloud': 8085,
'vaultwarden': 8082,
'jellyfin': 8096,
@@ -37,10 +37,6 @@ export const APP_PORTS: Record<string, number> = {
'fedimint': 8175,
'fedimintd': 8175,
'fedimint-gateway': 8176,
'nostr-rs-relay': 18081,
'nostr-vpn': 8201,
'fips': 8202,
'routstr': 8200,
'indeedhub': 7778,
'botfights': 9100,
'gitea': 3000,
@@ -57,6 +53,7 @@ export const PROXY_APPS: Record<string, string> = {}
* On HTTP, direct port access is used instead (faster, no proxy). */
export const HTTPS_PROXY_PATHS: Record<string, string> = {
'bitcoin-knots': '/app/bitcoin-ui/',
'bitcoin-core': '/app/bitcoin-ui/',
'bitcoin-ui': '/app/bitcoin-ui/',
'lnd': '/app/lnd/',
'electrumx': '/app/electrs/',
@@ -85,14 +82,10 @@ export const HTTPS_PROXY_PATHS: Record<string, string> = {
'dwn': '/app/dwn/',
'btcpay-server': '/app/btcpay/',
'nextcloud': '/app/nextcloud/',
'penpot': '/app/penpot/',
'grafana': '/app/grafana/',
'indeedhub': '/app/indeedhub/',
'botfights': '/app/botfights/',
'gitea': '/app/gitea/',
'routstr': '/app/routstr/',
'nostr-vpn': '/app/nostr-vpn/',
'fips': '/app/fips/',
}
/** External HTTPS apps -- always loaded directly */
@@ -107,11 +100,11 @@ export const EXTERNAL_URLS: Record<string, string> = {
}
export const APP_TITLES: Record<string, string> = {
'bitcoin-knots': 'Bitcoin', 'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
'bitcoin-knots': 'Bitcoin Knots', 'bitcoin-core': 'Bitcoin Core',
'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
'botfights': 'BotFights', 'gitea': 'Gitea', '484-kitchen': '484 Kitchen', 'arch-presentation': 'Presentation',
'nostr-vpn': 'Nostr VPN', 'fips': 'FIPS', 'routstr': 'Routstr',
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
'nginx-proxy-manager': 'Nginx Proxy Manager', 'nostr-rs-relay': 'Nostr Relay',
'nginx-proxy-manager': 'Nginx Proxy Manager',
'call-the-operator': 'Call The Operator', 'syntropy-institute': 'Syntropy Institute',
't-zero': 'T-Zero', 'nostrudel': 'noStrudel',
}
@@ -125,12 +118,10 @@ export const NEW_TAB_APPS = new Set([
'vaultwarden',
'nextcloud',
'uptime-kuma',
'penpot',
'portainer',
'onlyoffice',
'nginx-proxy-manager',
'tailscale',
'routstr',
])
/** Sites known to block iframes -- skip the timeout and go straight to fallback */
@@ -15,7 +15,7 @@ export interface SelectedIdentity {
}
function isIdentityAwareApp(id: string): boolean {
return id === 'indeedhub' || id === 'nostrudel' || id === 'routstr'
return id === 'indeedhub' || id === 'nostrudel'
}
export function useAppIdentity(
+33 -2
View File
@@ -99,14 +99,14 @@
</div>
</div>
<!-- Uninstalling progress replaces action buttons -->
<!-- Uninstalling progress live stage label from backend -->
<div v-else-if="isUninstalling" class="mt-4">
<div class="flex items-center gap-1.5">
<svg class="animate-spin h-3 w-3 text-red-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-xs text-red-300">{{ t('common.uninstalling') }}...</span>
<span class="text-xs text-red-300 truncate">{{ uninstallStageLabel }}</span>
</div>
<div class="mt-1.5 w-full h-1.5 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-red-400/60 rounded-full animate-pulse w-full"></div>
@@ -114,6 +114,29 @@
</div>
<div v-else class="mt-4 flex gap-2">
<!-- Update available -->
<button
v-if="pkg['available-update'] && pkg.state !== 'updating'"
@click.stop="$emit('update', id)"
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-1.5 bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30 transition-colors"
:title="`Update to v${pkg['available-update']}`"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('common.update') }}
</button>
<!-- Updating in progress -->
<span
v-if="pkg.state === 'updating'"
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-1.5 bg-orange-500/20 border border-orange-500/40 text-orange-200"
>
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ t('common.updating') }}
</span>
<!-- Launch -->
<button
v-if="canLaunch(pkg)"
@@ -211,6 +234,7 @@ const emit = defineEmits<{
start: [id: string]
stop: [id: string]
restart: [id: string]
update: [id: string]
showUninstall: [id: string, pkg: PackageDataEntry]
}>()
@@ -251,6 +275,13 @@ const tier = computed(() => {
return 'optional'
})
// Live uninstall stage from backend, with a sensible fallback so the
// label is never blank between WS pushes.
const uninstallStageLabel = computed(() => {
const raw = props.pkg['uninstall-stage']
return raw ? raw : `${t('common.uninstalling')}`
})
const isTransitioning = computed(() => {
const s = props.pkg.state
const h = props.pkg.health
+2 -5
View File
@@ -8,11 +8,9 @@ import { PackageState, type PackageDataEntry } from '@/types/api'
export const SERVICE_NAMES = new Set([
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
'immich_postgres', 'immich_redis',
'penpot-postgres', 'penpot-valkey', 'penpot-backend', 'penpot-exporter',
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
'archy-nostr-vpn-ui', 'archy-fips-ui',
'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',
@@ -39,8 +37,7 @@ export const APP_CATEGORY_MAP: Record<string, string> = {
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
'searxng': 'community', 'ollama': 'community', 'grafana': 'data',
'nostr-rs-relay': 'nostr', 'nostrudel': 'nostr',
'nostr-vpn': 'networking', 'fips': 'networking', 'routstr': 'community',
'nostrudel': 'nostr',
'tailscale': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
'uptime-kuma': 'networking', 'dwn': 'data',
'botfights': 'community', 'nwnn': 'l484', '484-kitchen': 'l484',
@@ -99,7 +96,7 @@ export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
export const TAB_LAUNCH_APPS = new Set([
'btcpay-server', 'grafana', 'photoprism', 'homeassistant',
'vaultwarden', 'nextcloud', 'uptime-kuma', 'portainer',
'cryptpad', 'nginx-proxy-manager', 'tailscale', 'routstr',
'cryptpad', 'nginx-proxy-manager', 'tailscale',
])
export function opensInTab(id: string): boolean {
+13 -15
View File
@@ -22,13 +22,13 @@ let cachedCatalog: AppCatalog | null = null
let catalogFetchedAt = 0
const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
/** Remote catalog URLs — tried in order. First success wins. */
/** Catalog URLs tried in order. First success wins.
* Primary is the backend proxy (`/api/app-catalog`) server-side fetch
* bypasses CORS on git.tx1138.com and CSP restrictions on the IP-port
* fallback. If the backend is offline (mid-restart etc.) we fall back
* to the static copy baked into the frontend build. */
const CATALOG_URLS = [
// Primary: git.tx1138.com raw file (HTTPS, dynamic, updated without frontend rebuild)
'https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json',
// Fallback: direct IP (HTTP, only works if CSP allows http://$host:*)
'http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json',
// Last resort: local static file (baked into frontend build)
'/api/app-catalog',
'/catalog.json',
]
@@ -40,7 +40,7 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
for (const url of CATALOG_URLS) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) })
const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(20000) })
if (!res.ok) continue
const data = await res.json() as AppCatalog
if (!data.apps?.length) continue
@@ -77,6 +77,7 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
export function getCuratedAppList(): MarketplaceApp[] {
return [
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
{ id: 'btcpay-server', title: 'BTCPay Server', version: '1.13.7', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: `${R}/btcpayserver:1.13.7`, repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.svg', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
@@ -85,7 +86,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ 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: 'cryptpad', title: 'CryptPad', version: '2024.12.0', description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.', icon: '/assets/img/app-icons/cryptpad.webp', author: 'XWiki SAS', dockerImage: `${R}/cryptpad:2024.12.0`, repoUrl: 'https://github.com/cryptpad/cryptpad' },
{ id: 'penpot', title: 'Penpot', version: '2.4', description: 'Open-source design platform. Self-hosted alternative to Figma for design and prototyping.', icon: '/assets/img/app-icons/penpot.webp', author: 'Penpot', dockerImage: `${R}/penpot-frontend:2.4`, repoUrl: 'https://github.com/penpot/penpot' },
{ id: 'nextcloud', title: 'Nextcloud', version: '28', 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:28`, 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: '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' },
@@ -98,12 +98,8 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'tailscale', title: 'Tailscale', version: '1.78.0', description: 'Zero-config VPN. Secure remote access with WireGuard mesh networking.', icon: '/assets/img/app-icons/tailscale.webp', author: 'Tailscale', dockerImage: `${R}/tailscale:stable`, repoUrl: 'https://github.com/tailscale/tailscale' },
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.webp', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
{ id: 'fedimint', title: 'Fedimint', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
{ id: 'nostr-rs-relay', title: 'Nostr Relay', version: '0.9.0', category: 'nostr', description: 'Your own Nostr relay. Store events locally, relay for friends, publish over Tor.', icon: '/assets/img/app-icons/nostr-rs-relay.svg', author: 'scsiblade', dockerImage: `${R}/nostr-rs-relay:0.9.0`, repoUrl: 'https://sr.ht/~gheartsfield/nostr-rs-relay/' },
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
{ id: 'dwn', title: 'Decentralized Web Node', version: '0.4.0', description: 'Own your data with DID-based access control. Sync across devices, sovereign.', icon: '/assets/img/app-icons/dwn.svg', author: 'TBD', dockerImage: `${R}/dwn-server:main`, repoUrl: 'https://github.com/TBD54566975/dwn-server' },
{ id: 'nostr-vpn', title: 'Nostr VPN', version: '0.3.7', category: 'networking', description: 'Tailscale-style mesh VPN with Nostr control plane. Peer discovery and key exchange over relays, WireGuard tunnels.', icon: '/assets/img/app-icons/nostr-vpn.svg', author: 'Martti Malmi', dockerImage: `${R}/nostr-vpn:v0.3.7`, repoUrl: 'https://github.com/mmalmi/nostr-vpn' },
{ id: 'fips', title: 'FIPS', version: '0.1.0', category: 'networking', description: 'Free Internetworking Peering System. Self-organizing encrypted mesh network with Nostr identity.', icon: '/assets/img/app-icons/fips.svg', author: 'Jim Corgan', dockerImage: `${R}/fips:v0.1.0`, repoUrl: 'https://github.com/jmcorgan/fips' },
{ id: 'routstr', title: 'Routstr', version: '0.4.3', category: 'community', description: 'Decentralized AI inference proxy. Pay-per-request with Cashu ecash, provider discovery via Nostr.', icon: '/assets/img/app-icons/routstr.svg', author: 'Routstr', dockerImage: `${R}/routstr:v0.4.3`, repoUrl: 'https://github.com/routstr/routstr-core' },
{ id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' },
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: 'https://botfights.net' },
{ id: 'gitea', title: 'Gitea', version: '1.23', category: 'development', description: 'Self-hosted Git service with container registry, CI/CD, issue tracking, and package hosting.', icon: '/assets/img/app-icons/gitea.svg', author: 'Gitea', dockerImage: 'docker.io/gitea/gitea:1.23', repoUrl: 'https://gitea.com' },
@@ -138,9 +134,6 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
tailscale: ['tailscale'],
ollama: ['ollama'],
indeedhub: ['indeedhub'],
'nostr-vpn': ['nostr-vpn'],
fips: ['fips'],
routstr: ['routstr'],
botfights: ['botfights'],
}
@@ -164,6 +157,11 @@ export const FEATURED_DEFINITIONS: {
desc: 'The foundation of sovereignty. Run a full Bitcoin node to validate every transaction yourself. No trusted third parties. No asking permission. Your node enforces the consensus rules that protect your wealth. Don\'t trust — verify.',
tag: 'FULL VALIDATION // ZERO TRUST',
},
{
id: 'bitcoin-core',
desc: 'The reference Bitcoin implementation. Same full-node guarantees as Knots, tracking upstream releases from the Bitcoin Core maintainers. Pick this if you\'d rather run mainline Bitcoin Core than Knots — both validate every block themselves.',
tag: 'REFERENCE CLIENT // ZERO TRUST',
},
{
id: 'lnd',
desc: 'Lightning-fast payments over the Lightning Network. Open channels, route transactions, and earn routing fees — all from your sovereign node. Instant settlement. Near-zero fees. The future of money, running on your hardware.',
@@ -96,20 +96,32 @@
Checking...
</span>
</span>
<!-- Installing simple button-style indicator -->
<button
<!-- Installing live progress with bar + message matching the
update download bar's accuracy. Falls back to a simple
spinner if no install_progress data is available yet. -->
<div
v-else-if="!installed && installing"
disabled
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium opacity-80 cursor-wait"
class="flex-1 flex flex-col gap-1.5"
>
<span class="flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<div
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium opacity-90 cursor-wait flex items-center justify-center gap-2 text-center"
>
<svg class="animate-spin h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Installing
</span>
</button>
<span class="truncate">{{ installProgressMessage }}</span>
</div>
<div
v-if="(installProgress?.progress ?? 0) > 0"
class="w-full h-1 bg-white/10 rounded-full overflow-hidden"
>
<div
class="h-full bg-orange-400 transition-all duration-300"
:style="{ width: (installProgress?.progress ?? 0) + '%' }"
></div>
</div>
</div>
<button
v-else-if="!installed && (app.source === 'local' || app.dockerImage)"
data-controller-install-btn
@@ -130,6 +142,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MarketplaceApp, InstallProgress } from './marketplaceData'
@@ -154,6 +167,14 @@ defineEmits<{
launch: [app: MarketplaceApp]
}>()
const installProgressMessage = computed(() => {
const p = props.installProgress
if (!p) return 'Installing'
// The store already formats messages like "Downloading: 50.5 / 200.0 MB (25%)"
// so we just surface them directly.
return p.message || 'Installing'
})
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
img.src = '/assets/img/logo-archipelago.svg'
@@ -54,9 +54,6 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
filebrowser: ['filebrowser'],
tailscale: ['tailscale'],
ollama: ['ollama'],
'nostr-vpn': ['nostr-vpn'],
fips: ['fips'],
routstr: ['routstr'],
}
/** Get app tier classification (matches backend get_app_tier) */
@@ -240,17 +237,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://github.com/cryptpad/cryptpad'
},
{
id: 'penpot',
title: 'Penpot',
version: '2.4',
description: 'Open-source design and prototyping platform. Self-hosted alternative to Figma.',
icon: '/assets/img/app-icons/penpot.webp',
author: 'Penpot',
dockerImage: 'docker.io/penpotapp/frontend:2.4',
manifestUrl: undefined,
repoUrl: 'https://github.com/penpot/penpot'
},
{
id: 'nextcloud',
title: 'Nextcloud',
@@ -405,42 +391,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://github.com/TBD54566975/dwn-server'
},
{
id: 'nostr-vpn',
title: 'Nostr VPN',
version: '0.3.7',
category: 'networking',
description: 'Tailscale-style mesh VPN with Nostr control plane. Peer discovery and key exchange over relays, WireGuard tunnels.',
icon: '/assets/img/app-icons/nostr-vpn.svg',
author: 'Martti Malmi',
dockerImage: `${REGISTRY}/nostr-vpn:v0.3.7`,
manifestUrl: undefined,
repoUrl: 'https://github.com/mmalmi/nostr-vpn'
},
{
id: 'fips',
title: 'FIPS',
version: '0.1.0',
category: 'networking',
description: 'Free Internetworking Peering System. Self-organizing encrypted mesh network with Nostr identity.',
icon: '/assets/img/app-icons/fips.svg',
author: 'Jim Corgan',
dockerImage: `${REGISTRY}/fips:v0.1.0`,
manifestUrl: undefined,
repoUrl: 'https://github.com/jmcorgan/fips'
},
{
id: 'routstr',
title: 'Routstr',
version: '0.4.3',
category: 'community',
description: 'Decentralized AI inference proxy. Pay-per-request with Cashu ecash, provider discovery via Nostr.',
icon: '/assets/img/app-icons/routstr.svg',
author: 'Routstr',
dockerImage: `${REGISTRY}/routstr:v0.4.3`,
manifestUrl: undefined,
repoUrl: 'https://github.com/routstr/routstr-core'
},
{
id: 'nostrudel',
title: 'noStrudel',
@@ -454,18 +404,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
repoUrl: 'https://github.com/hzrd149/nostrudel',
webUrl: 'https://nostrudel.ninja'
},
{
id: 'nostr-rs-relay',
title: 'Nostr Relay',
version: '0.9.0',
category: 'nostr',
description: 'Run your own Nostr relay. Store your events locally, relay for friends, and publish over Tor. A sovereign relay for your sovereign node.',
icon: '/assets/img/app-icons/nostr-rs-relay.svg',
author: 'scsiblade',
dockerImage: 'docker.io/scsibug/nostr-rs-relay:0.9.0',
manifestUrl: undefined,
repoUrl: 'https://sr.ht/~gheartsfield/nostr-rs-relay/'
},
{
id: 'botfights',
title: 'BotFights',
+66 -16
View File
@@ -9,15 +9,53 @@
<div class="flex-1">
<div class="flex items-start justify-between gap-4 mb-2">
<h2 class="text-xl font-semibold text-white">FIPS Mesh</h2>
<div class="flex items-center gap-2" :title="statusLabel">
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
<span class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</span>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2" :title="statusLabel">
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
<span class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</span>
</div>
<button
type="button"
class="p-1.5 rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors"
title="Seed anchors"
aria-label="Open FIPS seed anchors settings"
@click="showAnchorsModal = true"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
</div>
<p class="text-white/70 text-sm mb-4">Fast Nostr-keyed mesh routing</p>
</div>
</div>
<!-- Seed anchors modal operator-editable list of peers this node
dials to bootstrap the mesh. Tucked behind the gear so it
doesn't crowd the card but is still one click away. Close
button and layout mirror the What's New modal (and the rest
of the app) so it feels like a first-class modal. -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="showAnchorsModal"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click="showAnchorsModal = false"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
class="relative z-10 max-w-xl w-full"
style="max-height: 90vh; overflow-y: auto"
@click.stop
>
<FipsSeedAnchorsCard closable @close="showAnchorsModal = false" />
</div>
</div>
</Transition>
</Teleport>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3 shrink-0">
<div class="p-3 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Daemon version</p>
@@ -50,7 +88,7 @@
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-2 text-xs">
<span class="w-2 h-2 rounded-full" :class="status.anchor_connected ? 'bg-cyan-400' : 'bg-orange-400'"></span>
<span class="text-white/70">Anchor (fips.v0l.io):</span>
<span class="text-white/70">Anchor:</span>
<span :class="status.anchor_connected ? 'text-cyan-300' : 'text-orange-300'">
{{ status.anchor_connected ? 'connected' : 'not reached' }}
</span>
@@ -68,7 +106,7 @@
</button>
</div>
<p v-if="!status.anchor_connected" class="mt-2 text-[11px] text-white/40 leading-snug">
Without the anchor, DHT routing to unknown npubs can't bootstrap; federation and messaging fall back to Tor until it reconnects. Reconnect restarts the FIPS daemon, which usually clears a stale identity cache.
No known anchor is currently an authenticated peer. DHT routing to unknown npubs can't bootstrap; federation and messaging fall back to Tor until one reconnects. Reconnect restarts the FIPS daemon, which usually clears a stale identity cache. Add a cluster-local anchor in Seed Anchors if the public one is unreachable.
</p>
</div>
@@ -84,6 +122,7 @@
import { computed, onMounted, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { safeClipboardWrite } from '@/views/web5/utils'
import FipsSeedAnchorsCard from './FipsSeedAnchorsCard.vue'
interface FipsStatus {
installed: boolean
@@ -113,6 +152,7 @@ const reconnecting = ref(false)
const statusMessage = ref('')
const statusIsError = ref(false)
const copied = ref(false)
const showAnchorsModal = ref(false)
async function copyNpub() {
if (!status.value.npub) return
@@ -180,21 +220,31 @@ async function installAndActivate() {
}
}
// Restart the FIPS daemon to kick it back onto the public anchor. Stale
// identity-cache entries are the usual cause of "not reached"; systemctl
// restart clears them and re-runs the bootstrap handshake.
// Restart the FIPS daemon and wait for the anchor bootstrap window.
// The backend runs a proper recovery sequence (stop start wait
// classify) and returns a structured diagnostic we can show the user
// instead of a generic "still unreachable".
async function reconnectAnchor() {
reconnecting.value = true
try {
await rpcClient.call({ method: 'fips.restart', timeout: 45_000 })
// Give the daemon a few seconds to come back and re-populate its
// identity cache before we re-query status.
await new Promise((resolve) => setTimeout(resolve, 5000))
await loadStatus()
if (status.value.anchor_connected) {
flash('Anchor reconnected')
const res = await rpcClient.call<{
recovered: boolean
likely_cause: string
hint: string
after: FipsStatus
}>({ method: 'fips.reconnect', timeout: 60_000 })
// Update the card with the post-reconnect status returned by the
// backend avoids an extra status fetch race.
status.value = { ...status.value, ...res.after }
if (res.recovered) {
flash('Anchor reconnected.')
} else if (res.likely_cause === 'connected') {
// Already connected, not a "recovery" per se.
flash('Anchor is reachable.')
} else {
flash('FIPS restarted — anchor still reporting unreachable. Check network / firewall.', true)
// Surface the backend's diagnostic hint verbatim it's been
// written for the fleet reader.
flash(res.hint || 'Reconnect finished but anchor is still unreachable.', true)
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
@@ -0,0 +1,186 @@
<template>
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1 relative">
<button
v-if="closable"
type="button"
class="absolute top-4 right-4 p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors z-10"
aria-label="Close"
@click="$emit('close')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div class="flex items-start gap-4 mb-4 shrink-0" :class="{ 'pr-10': closable }">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<!-- Radio/broadcast icon three concentric arcs radiating from a
dot. Reads as mesh, signal, anchor-reaching-peers. -->
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0" />
<circle cx="12" cy="18" r="1.25" fill="currentColor" stroke="none" />
</svg>
</div>
<div class="flex-1">
<div class="flex items-start justify-between gap-4 mb-2">
<h2 class="text-xl font-semibold text-white">FIPS Seed Anchors</h2>
<button
type="button"
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors disabled:opacity-60"
:disabled="applying"
:title="applying ? 'Applying…' : 'Re-dial every anchor in the list'"
@click="applyAll"
>
{{ applying ? 'Applying…' : 'Apply now' }}
</button>
</div>
<p class="text-white/70 text-sm mb-4">
Peers this node dials to bootstrap the FIPS mesh. A cluster with its own anchors doesn't depend on the global public anchor if one is down, the next seeds the DHT instead.
</p>
</div>
</div>
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
<div v-if="anchors.length === 0" class="p-4 rounded-lg bg-white/5 text-sm text-white/60 mb-3">
<p>No seed anchors configured. The daemon will fall back to whatever the upstream FIPS build dials on its own usually the single public anchor, which is fine until it isn't.</p>
<p class="mt-2 text-white/50">Add at least one known-reachable peer (e.g. your VPS or a home node with port-forwarded UDP 8668) to make this cluster self-anchoring.</p>
</div>
<ul v-else class="space-y-2 mb-3">
<li v-for="a in anchors" :key="a.npub" class="p-3 bg-white/5 rounded-lg flex items-start gap-3">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">{{ a.label || 'Unlabeled anchor' }}</p>
<p class="text-xs text-white/60 font-mono break-all">{{ a.npub.slice(0, 20) }}{{ a.npub.slice(-8) }}</p>
<p class="text-xs text-white/50 mt-0.5">{{ a.address }} · {{ a.transport }}</p>
</div>
<button
type="button"
class="shrink-0 text-xs px-2 py-1 rounded-md text-red-300 hover:bg-red-400/10 transition-colors"
:title="`Remove ${a.label || a.npub.slice(0, 12)}`"
@click="removeAnchor(a.npub)"
>Remove</button>
</li>
</ul>
<form class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-auto pt-3 border-t border-white/10 shrink-0" @submit.prevent="addAnchor">
<label class="flex flex-col gap-1 sm:col-span-2">
<span class="text-xs text-white/60">Anchor npub</span>
<input v-model="draft.npub" type="text" placeholder="npub1…" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-white/60">Address (host:port)</span>
<input v-model="draft.address" type="text" placeholder="192.168.1.116:8668" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-white/60">Label (optional)</span>
<input v-model="draft.label" type="text" placeholder="Home anchor" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
</label>
<button type="submit" class="sm:col-span-2 min-h-[44px] glass-button rounded-lg text-sm font-medium disabled:opacity-60" :disabled="adding || !draft.npub || !draft.address">{{ adding ? 'Adding…' : 'Add anchor' }}</button>
</form>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
defineProps<{ closable?: boolean }>()
defineEmits<{ (e: 'close'): void }>()
interface SeedAnchor {
npub: string
address: string
transport: string
label: string
}
interface ApplyResult {
npub: string
ok: boolean
message: string
}
const anchors = ref<SeedAnchor[]>([])
const adding = ref(false)
const applying = ref(false)
const statusMessage = ref('')
const statusIsError = ref(false)
const draft = reactive<Pick<SeedAnchor, 'npub' | 'address' | 'label'>>({
npub: '',
address: '',
label: '',
})
function flash(msg: string, isError = false) {
statusMessage.value = msg
statusIsError.value = isError
setTimeout(() => { statusMessage.value = '' }, 6000)
}
async function load() {
try {
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({ method: 'fips.list-seed-anchors' })
anchors.value = res.seed_anchors
} catch (e: unknown) {
if (import.meta.env.DEV) console.warn('fips.list-seed-anchors failed', e)
}
}
async function addAnchor() {
if (!draft.npub.trim() || !draft.address.trim()) return
adding.value = true
try {
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[]; apply: ApplyResult[] }>({
method: 'fips.add-seed-anchor',
params: {
npub: draft.npub.trim(),
address: draft.address.trim(),
transport: 'udp',
label: draft.label.trim(),
},
})
anchors.value = res.seed_anchors
draft.npub = ''
draft.address = ''
draft.label = ''
const applied = res.apply.find(r => r.ok)
flash(applied ? 'Anchor added and dialed.' : 'Anchor saved — dial failed, will retry on the next apply cycle.', !applied)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Add failed: ${msg}`, true)
} finally {
adding.value = false
}
}
async function removeAnchor(npub: string) {
try {
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({
method: 'fips.remove-seed-anchor',
params: { npub },
})
anchors.value = res.seed_anchors
flash('Anchor removed.')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Remove failed: ${msg}`, true)
}
}
async function applyAll() {
applying.value = true
try {
const res = await rpcClient.call<{ applied: number; results: ApplyResult[] }>({ method: 'fips.apply-seed-anchors' })
const ok = res.results.filter(r => r.ok).length
flash(`${ok} of ${res.applied} anchor${res.applied === 1 ? '' : 's'} dialed.`, ok === 0 && res.applied > 0)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Apply failed: ${msg}`, true)
} finally {
applying.value = false
}
}
onMounted(load)
</script>
@@ -180,6 +180,489 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.40-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.40-alpha</span>
<span class="text-xs text-white/40">Apr 22, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Proper fix for the 500 / Internal Server Error after update. The v1.7.38 and v1.7.39 frontend archives had the wrong permissions baked into the archive itself the tarball's root directory entry was private, so every node that extracted it ended up with a web UI directory nginx couldn't read. v1.7.40 packages the archive with correct world-readable permissions from the start, so no node ever sees the 500 again.</p>
</div>
</div>
<!-- v1.7.39-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.39-alpha</span>
<span class="text-xs text-white/40">Apr 22, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Hotfix for v1.7.38 on some nodes the update landed with the web UI directory set to private file permissions, so nginx returned a 500 / "Internal Server Error" on every page. This release fixes the updater to set world-readable permissions on the new frontend, and the node also now self-heals on boot if it ever finds the UI directory in that state again.</p>
</div>
</div>
<!-- v1.7.38-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.38-alpha</span>
<span class="text-xs text-white/40">Apr 22, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Signing in is quiet now. The intro music, welcome voice, and transition sounds belong to the first-boot cinematic and only play before you've finished onboarding every login after that is silent. Typing sounds in the search bar and on the dashboard are unaffected.</p>
<p>Fixed a bug where clearing your browser cache, updating the node, or rebooting could bounce you back through the onboarding wizard even though your node was already fully set up. The node now self-heals: if your password is set, it knows you've been through onboarding and takes you straight to the login screen. No more starting over.</p>
<p>Trimmed the App Store. FIPS, Nostr Relay, Nostr VPN, Routstr, and Penpot have been removed from the catalog and their container images deleted from our registries. Your node's native FIPS transport is untouched this is just the app-store entries going away.</p>
</div>
</div>
<!-- v1.7.37-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.37-alpha</span>
<span class="text-xs text-white/40">Apr 22, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Bitcoin Core (the reference implementation) now installs from the App Store and runs cleanly alongside Bitcoin Knots as a first-class option. The install flow pulls the official docker.io/bitcoin image directly if your internal mirrors don't carry it, and the node UI auto-detects which implementation is running so the logo, title, and version line all reflect Core vs. Knots without any manual config.</p>
<p>The node dashboard now shows a Storage indicator (Full Archive · X GB or Pruned · X GB) right next to Network, so you can tell at a glance whether your node is carrying the full chain history or the last ~550 MB. The Node Settings modal was stripped of its hardcoded Regtest/port-18443 placeholders and now shows real values network mode, storage mode, transaction index, ZMQ publishing, and RPC port all read from the running node.</p>
<p>Fresh installs no longer default to pruned mode. Previously, a new install would write <code>prune=550</code> into bitcoin.conf even on boxes with 2 TB of free space; now the default is full archive and you can opt into pruning by editing the conf yourself.</p>
</div>
</div>
<!-- v1.7.36-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.36-alpha</span>
<span class="text-xs text-white/40">Apr 22, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Bitcoin Core joins the App Store as its own entry, with the Umbrel community icon and a description that frames it as a reference alternative to Bitcoin Knots rather than a replacement. A Sovereignty Stack tile on the Discover page now groups your node options together so the choice is obvious.</p>
<p>The App Store catalog fetch now follows whichever container registries you've set as primary in Settings. Previously the catalog URL was hardcoded to two servers; now the operator's own mirror priority drives where the App Store pulls its listings from, so switching primary actually moves the catalog too.</p>
</div>
</div>
<!-- v1.7.35-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.35-alpha</span>
<span class="text-xs text-white/40">Apr 22, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Rootless-netns self-heal: if the container network loses its outbound tap (symptom: Bitcoin Knots and other outbound containers can't reach the internet even though container-to-container still works), the node now detects it and restarts the network from scratch on its own. No more having to SSH in and bounce podman.</p>
<p>Every app card on the Apps page now has an Update button whenever a newer version of the app is available same flow as the detail view, one click away. Updating apps used to require drilling into each card individually.</p>
<p>Your node's Web5 DID, Identities list, and peer-to-peer pubkey now all resolve to the same seed-derived identity instead of drifting apart after onboarding. The Node identity on fresh installs is mirrored from the onboarding seed rather than generated as a separate random keypair.</p>
<p>Bitcoin Knots (and the new Bitcoin Core slot) now run on bitcoin/bitcoin 28.4 with a realistic 4 GiB memory cap and uncapped CPUs so bitcoind can run -par=auto across every core on your box.</p>
</div>
</div>
<!-- v1.7.34-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.34-alpha</span>
<span class="text-xs text-white/40">Apr 22, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>The login background now rotates through six atmospheric images, advancing one each time you land on the login screen, so returning to your node doesn't keep showing the same wallpaper. The chosen index is remembered across logouts.</p>
<p>Re-logging in is noticeably snappier. The dashboard entry animation used to replay the full 1.2-second zoom reveal on every login; that's now reserved for the first entry after onboarding. Subsequent logins fade in with just the welcome typing in about 300 ms.</p>
<p>If you clear site data on a node you've already onboarded, the intro video no longer fires again on the login screen. The onboarding cache is re-seeded from the backend automatically, so /login stays quiet instead of replaying the whole intro sequence.</p>
</div>
</div>
<!-- v1.7.33-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.33-alpha</span>
<span class="text-xs text-white/40">Apr 22, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>The onboarding wizard no longer gets skipped on genuinely-fresh nodes when you connect from a browser that onboarded a different node earlier. The backend is now the source of truth for "has this node been onboarded yet?" the browser's local flag is the offline fallback, not the primary answer.</p>
<p>Already-onboarded nodes no longer show the "boot loader" or "server starting up" screens during an OTA update blip. The health check polls quietly for up to a minute before showing the boot screen, so a 10-second restart no longer looks like a catastrophic failure.</p>
<p>Logging out and returning to <code>/login</code> no longer replays the full intro video you get a quiet lock-screen background instead. The full welcome sequence is reserved for genuine first-time entries.</p>
<p>Upgrading nodes now pick up this release's UI cleanly without a stale cache hanging on. A cache-version bump tells your browser's service worker to ditch the old bundle on first load.</p>
</div>
</div>
<!-- v1.7.32-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.32-alpha</span>
<span class="text-xs text-white/40">Apr 22, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Hotfix: v1.7.31's frontend tarball was packaged with an extra wrapper directory, which left some nodes serving 403/500 after applying the update instead of the new UI. This release ships the tarball with the correct flat layout, and broken nodes heal automatically when this update applies.</p>
<p>Updates now finalize cleanly instead of being force-killed by systemd. Previously the node logged "shut down cleanly" during an update, then systemd waited 15 seconds and SIGKILL'd the service because one of the internal threads wasn't releasing. That's been tracked down and fixed, so the service exits promptly and the restart path is snappier.</p>
</div>
</div>
<!-- v1.7.31-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.31-alpha</span>
<span class="text-xs text-white/40">Apr 22, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>IndeedHub install is now idempotent re-running it after a failed first attempt no longer leaves orphaned containers blocking the retry with a "name already in use" error. The installer force-cleans leftover containers and the dedicated network before starting a fresh stack.</p>
<p>Server 3 (OVH) is now an automatic tertiary mirror for both system updates and app registries. Existing nodes pick it up on next restart without any manual config another independent network path, so a single-provider outage can't stall downloads.</p>
<p>The reachability test on the Registries page no longer reports false "unreachable" for Gitea-backed registries. The probe now hits the Docker V2 API at the correct host-root path and accepts HTTP 405 in addition to 200/401 as "registry alive".</p>
</div>
</div>
<!-- v1.7.30-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.30-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>App installs now show a real download progress bar same accuracy as the system update bar. You'll see "Downloading: 50.5 / 200.0 MB (25%)" with a live percentage instead of a generic spinner. The bar keeps streaming even when the install falls back from one registry to another, so you'll never see a "stuck at 0%" again.</p>
<p>Uninstalls now show what's actually happening: "Stopping containers (2/5)", "Cleaning up volumes", "Removing app data" — labelled per app so you can fire off multiple uninstalls in parallel and watch each one's stage on its own card.</p>
<p>OVH (146.59.87.168) is now baked in as Server 3 by default for both updates and the app registry extra mirror, completely independent network path so a single-provider outage can't take everything down.</p>
</div>
</div>
<!-- v1.7.29-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.29-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>New App registries page in Settings same experience as Update mirrors, but for the container registries your node pulls app images from. Add a mirror, test reachability with one click, pick the primary.</p>
<p>New nodes default to the VPS registry as the primary for both app installs and the app catalog, with tx1138 as the automatic fallback if the VPS is slow or unreachable. Existing nodes keep whatever registry order they've already set.</p>
<p>App installs now genuinely honor the primary registry: the first pull attempt rewrites the image URL to use your primary, and only falls through to the secondary if that fails. Before, installs always hit whichever registry the image was hardcoded to.</p>
<p>Reboot screen now shows the animated "a" logo in the center of the ring matching the screensaver's look so you get something nice to watch while the node comes back up.</p>
</div>
</div>
<!-- v1.7.28-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.28-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Reboot now shows a proper progress screen. Click Reboot and you'll see a full-screen overlay with the familiar pulsing ring animation, a rebooting / reconnecting / back-online status, and an elapsed counter no more black screen of mystery while you wait.</p>
<p>The overlay auto-reloads the page the moment your node is back up; if it takes longer than three minutes it surfaces a manual Reload button.</p>
<p>New nodes now default to the VPS mirror as Server 1 (primary) and tx1138 as Server 2 (fallback). Existing nodes keep whatever mirror order they've already set use Set Primary on the System Update page to change it.</p>
</div>
</div>
<!-- v1.7.27-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.27-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>The Update page now shows which mirror delivered your update a small "Served by" line under the new version tells you whether Server 1, Server 2, or a custom mirror was the one your node actually reached. Great for spot-checking that mirror fallback is doing its job.</p>
<p>Every mirror row has a new lightning-bolt button that pings the mirror and shows whether it's reachable, plus the round-trip latency in milliseconds. No more guessing if a mirror you just added is responding.</p>
<p>The Update mirrors section got a visual refresh: Set Primary, Remove, and the new Test action are compact icon buttons instead of crowded text, and adding a mirror now happens in a dedicated dialog that matches the rest of the UI.</p>
</div>
</div>
<!-- v1.7.26-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.26-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Update downloads now have a mirror list. If the primary update server is slow or unreachable, your node automatically tries the next mirror and downloads the files from there no more waiting on a stalled server with no recourse.</p>
<p>A new 'Update mirrors' section on the System Update page lets you see the list, add your own mirror URL, reorder which is tried first (Set primary), or remove one. The primary is tagged with a green PRIMARY pill.</p>
<p>Downloads automatically follow the mirror that served the manifest. Previously every mirror served the same manifest, and the manifest's download URLs were hardcoded to a single server — so even picking a faster mirror couldn't speed up the actual download. Now the backend rewrites download URLs to match whichever mirror succeeded.</p>
<p>Ships with two defaults: Server 1 (tx1138) and Server 2 (VPS). Add the URL format <code>https://host/.../releases/manifest.json</code> for custom mirrors.</p>
</div>
</div>
<!-- v1.7.25-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.25-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Your node can now reach the broader FIPS public mesh, not just your own federated cluster. The FIPS daemon now binds both UDP (fast mesh forwarding) and TCP (NAT-friendly bootstrap) transports matching the upstream factory default. The public anchor currently answers on TCP, so UDP-only nodes couldn't reach it; this fixes that without any action needed on your end.</p>
<p>Upgrading the config happens automatically. On next startup, if the installed FIPS yaml doesn't match the new two-transport schema, the node reinstalls and restarts the daemon so the TCP transport comes online. No manual Reconnect required.</p>
<p>Side benefit: TCP also helps on networks that block outbound UDP (corporate, some guest wifi) your node falls back to TCP/8443 automatically and still joins the mesh.</p>
</div>
</div>
<!-- v1.7.24-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.24-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Frontend updates now actually ship. Since roughly v1.7.17 the release pipeline had been rebuilding the backend every version but silently skipping the frontend bundle a permissions issue on the build server meant vue-tsc failed before vite ever ran, and nobody noticed because the published tarballs still extracted cleanly. The result was the backend moving forward while the UI stayed frozen at its v1.7.9-era state, which is why the FIPS gear icon and the What's New entries for every release since then had been missing on your node.</p>
<p>Once this update applies, your node gets the real v1.7.24 frontend: the FIPS Seed Anchors modal (gear icon on the FIPS Mesh card), the current What's New history, the cancel-download button, and every other UI touch from the releases in between.</p>
</div>
</div>
<!-- v1.7.23-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.23-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>FIPS Seed Anchors are now one click away. A small gear icon sits next to the status pill on the FIPS Mesh card click it to open a modal where you can add, remove, and re-apply anchors. No more needing to go digging for the card or editing JSON by hand.</p>
<p>The modal lists each anchor with its label, truncated npub, address, and transport, plus an Apply button to force-redial the full list and a Remove button per entry. The add form right below validates that the address is host:port and the npub is bech32 before saving.</p>
</div>
</div>
<!-- v1.7.22-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.22-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>The FIPS Reconnect and Restart buttons now work on every node, regardless of which systemd unit is actually supervising the daemon. Previously they targeted only the archipelago-managed unit nodes that were running the upstream unit instead saw the buttons silently do nothing. Both paths now auto-detect which unit is up and act on that one.</p>
<p>The FIPS anchor status no longer shows red just because one specific public anchor is unreachable. It now lights green whenever any authenticated peer is a recognised anchor that's either the public anchor or something you added under Seed Anchors. A federated cluster that routes through its own seed anchor finally reports the truth.</p>
<p>Reconnect also re-pushes your seed anchors after the restart, so you don't have to wait five minutes for the background apply loop to re-dial them.</p>
</div>
</div>
<!-- v1.7.21-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.21-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>FIPS bootstrap no longer depends on a single public anchor. You can now add your own anchors other archipelago nodes or a VPS you control and the node will dial every one of them to join the mesh on startup. If one anchor is down, the next one seeds the routing layer instead, so a flaky public anchor no longer strands a fresh install.</p>
<p>Anchors persist across restarts and are re-applied every five minutes, so a daemon that got temporarily isolated reconnects on its own without anyone having to SSH in. Each anchor carries an operator-editable label so you can remember which is which.</p>
<p>No behavior change if you don't configure any — the upstream daemon's own defaults keep working as before. This purely adds an operator-controlled list on top.</p>
</div>
</div>
<!-- v1.7.20-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.20-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Fixed a critical bug where nodes on the automatic daily-update schedule could end up offline after their nightly update. The scheduler was killing the service a moment too early, before the built-in restart handler had a chance to bring the new version back up leaving the node dead until someone SSH'd in and started it manually. The scheduler now hands off cleanly to the same restart path the 'Install Update' button uses, so auto-applied updates come back online on their own.</p>
<p>Applies to any node configured for 'Check &amp; Apply Daily' no change required on your end, the fix ships with this update.</p>
</div>
</div>
<!-- v1.7.19-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.19-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Your node no longer offers a version you've already passed as an "available update". If you sideload or skip a release, any stored pointer to an earlier version is dropped on next restart, and the System Update page offers only the genuinely newer release no more seeing an older version listed as something to install.</p>
<p>Version comparison is now numeric, not alphabetic. 1.7.10 correctly outranks 1.7.9 (earlier naive string-order would have got this backwards once the patch number hits double digits), so update prompts and "up to date" checks stay accurate past the nines.</p>
<p>A stale manifest from a slow cache or proxy can no longer downgrade your node. If the manifest reports a version equal to or behind what's running, your node treats that as "up to date" rather than offering the older version as an update.</p>
</div>
</div>
<!-- v1.7.18-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.18-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Nodes discovered through a trusted peer now land as Trusted instead of Observer. When your federated peer shares its own peer list with you, those nodes get the same trust level as a direct invite the link they came through is already one you vetted, so you no longer need to promote them by hand before they can be used normally.</p>
<p>The update flow now writes clearer logs at every step. Start of download, cancel, and apply each emit a one-line entry to the system journal with the staging path and the affected files, so if a download misbehaves on your node it's easy to see exactly where it got to.</p>
</div>
</div>
<!-- v1.7.17-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.17-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>When a download gets stuck, you can now cancel it. A new Cancel Download button sits next to the progress bar it stops the transfer, clears the partial file, and returns you to a clean state so you can retry. No more staring at a frozen bar with no way to recover.</p>
<p>Downloads that stall for 30 seconds or more now say so. The progress bar turns amber and shows 'Download appears stuck — try Cancel and start again' instead of just sitting silently at whatever percent it reached.</p>
<p>Canceling is fast. It no longer has to wait out the retry timer the download bails within half a second, so you're not stuck watching a stuck screen while you wait to unstick it.</p>
</div>
</div>
<!-- v1.7.16-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.16-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Federation is now bidirectional and instant. When someone joins using your invite code, their node appears on your Federation page automatically no need for the inviter to click Sync or wait for the next poll. Names and node details populate within seconds of the handshake finishing.</p>
<p>New nodes can no longer federate with themselves. Accepting an invite that points back at the local node (by DID, public key, or onion address) is rejected up front, so self-peering no longer clutters the node list with a duplicate card.</p>
<p>Transitive discovery: if nodes A and B are already federated and node C joins A, all three nodes now learn about each other. The new peer is pulled in as an Observer entry on existing federation members, so you can promote to Trusted with one click instead of trading a second invite code.</p>
<p>The Federation page auto-refreshes every five seconds while it's open. Status changes, new peers, and incoming join requests surface on their own clicking Sync remains available for an on-demand pull.</p>
</div>
</div>
<!-- v1.7.15-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.15-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Updates survive network hiccups. Downloads now resume from exactly where a dropped connection left off, and retry up to 6 times with increasing gaps between attempts, instead of restarting from byte zero or giving up.</p>
<p>The download progress bar now shows real progress. Instead of a fake number that creeps to 95% and freezes, you see the actual bytes arriving, and it continues to update correctly even if you navigate away and come back.</p>
<p>Update check itself retries on slow responses. If git.tx1138.com is momentarily overloaded, the node tries three times with a five-second wait between attempts before concluding you're up to date.</p>
</div>
</div>
<!-- v1.7.14-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.14-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Installing an update now shows a full-screen progress overlay with the Archipelago logo, a status message, and an animated bar. The page reloads itself automatically once the new version is up no manual refresh. If something stalls, a 'Reload now' button appears after a few minutes.</p>
<p>Download progress no longer looks frozen near the end. The bar pauses at 95% with a 'Finishing download — verifying checksum…' message and spinner while the last bytes arrive and are hashed.</p>
<p>FIPS Reconnect now genuinely tries to fix the anchor. It runs a proper recovery sequence (stop start wait for the bootstrap window check peers) and tells you the likely reason it's still unreachable — corrupt identity key, seed not unlocked, network blocking UDP, or the anchor server being down — instead of a generic 'try again'.</p>
<p>Healed a latent FIPS identity bug: the public-key file was being written in text form (an 'npub1…' string) on some nodes, which the daemon couldn't parse and silently authenticated with a garbage key. The Reconnect button now rewrites the file in the correct binary format and re-installs the config before restarting — nodes stuck with no peers for 'no reason' should come back online.</p>
<p>AIUI (Claude sidebar) is back. The installer now ships AIUI in the frontend bundle and preserves it across future updates it was being wiped on every OTA because it lived outside the Vue build.</p>
<p>Installing a big app (IndeedHub, Bitcoin, Penpot) no longer gives up early and shows 'didn't work' while the download is still running in the background. The client waits up to 45 minutes for the install pipeline to finish.</p>
<p>'Rollback to Previous' is now labelled 'Rollback Available' clearer that it's a choice you have, not a status you're stuck with.</p>
</div>
</div>
<!-- v1.7.13-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.13-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>App catalog now loads reliably. Before, the Marketplace / Discover page couldn't fetch the catalog of apps because the upstream host wasn't sending the right CORS headers and the node's security policy didn't allow the fallback URL either. The node now fetches the catalog server-side and serves it same-origin to the browser no more blank app lists.</p>
</div>
</div>
<!-- v1.7.12-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.12-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Nothing new version bump so freshly-installed nodes (from the 1.7.11 ISO) have something to OTA down, confirming the end-to-end update pipeline out of the box.</p>
</div>
</div>
<!-- v1.7.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.7.11-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>OTA proof release first version where Install Update should run clean from the UI with no manual steps. Click it and watch the sidebar flip to 1.7.11-alpha on its own.</p>
</div>
</div>
<!-- v1.7.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.7.10-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Install Update actually applies now. The installer had to write into system folders that the backend service was sandboxed out of every earlier 'Failed to apply update' was a layer of that onion. Fixed by running the file swaps in a separate system context.</p>
<p>FIPS status on the Home and Server pages now reflects whether the public anchor is reachable. You'll see 'Active · N peers' (green) when healthy or 'No anchor' (orange) when the network is blocking the bootstrap same signal as the full FIPS card.</p>
<p>Pasting an https:// URL into the profile picture or banner now previews correctly. Before, if the URL failed to load, the UI would silently blank out instead of showing your initial as a placeholder.</p>
<p>Uploaded profile pictures under 64 KB are now embedded directly in your Nostr profile (as a data URL), so any Nostr client can see them not just ones routing over Tor. Larger uploads keep the onion URL for now, with a hint to paste a public URL for wider visibility.</p>
</div>
</div>
<!-- v1.7.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.7.9-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>OTA verification release nothing new to see. Click Install Update, grab a coffee, and watch the sidebar flip to 1.7.9-alpha on its own. If this one works end to end, the pipeline is solid and future updates will flow the same way.</p>
</div>
</div>
<!-- v1.7.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.7.8-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Install Update finally works end-to-end over the air. The installer was trying to overwrite the running backend binary with a tool that fails on in-use files (ETXTBSY) swapped it for an atomic rename, which the kernel allows on a live executable. Every previous 'Failed to apply update' attempt was this one root cause.</p>
</div>
</div>
<!-- v1.7.7-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.7-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Over-the-air update test no feature changes, just a version bump so your node can walk through the whole update flow end-to-end using the new robust installer. Safe to apply; nothing to do afterwards.</p>
</div>
</div>
<!-- v1.7.6-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.6-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Install Update is now more robust. Each install gets its own uniquely-named staging folder and then moves files into place the previous version had a small cleanup step that could hit a transient filesystem hiccup and bail out halfway. You'll also still see a rollback folder after a successful install.</p>
<p>Dev-box OTA: nodes that build archipelago from source can now opt into the standard Download Install flow instead of Pull &amp; Rebuild, by setting ARCHIPELAGO_UPDATE_URL in the service environment. Useful when the dev machine has a checked-out repo but you want to test the regular update path.</p>
</div>
</div>
<!-- v1.7.5-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.5-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Over-the-air update test no feature changes, just a fresh version number so your node can walk through the whole update flow end-to-end: check, download, install, auto-restart. Safe to apply; nothing to do afterwards.</p>
</div>
</div>
<!-- v1.7.4-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.4-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Install Update actually installs now. Before, the final step extracted the new UI into the wrong folder and bailed with 'Failed to apply update' your node ended up backing up cleanly but never swapping in the new files. Fixed.</p>
<p>Download progress no longer overshoots 100%. You'll see the bar climb smoothly to 95% and then jump to 100% when the download actually finishes.</p>
</div>
</div>
<!-- v1.7.3-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.3-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>The version number in the sidebar now always matches the actual running version no more lying to you about being on an older release after an update.</p>
<p>FIPS Mesh card on the server page: cleaner layout on desktop (no more awkward gaps), and a one-click Reconnect button when the public anchor is unreachable it restarts the FIPS daemon so it can re-bootstrap from the anchor.</p>
<p>Profile pictures now show correctly in the identity list and editor. Before, uploaded images silently failed to render because the URL was only reachable over Tor; the UI now rewrites them to a local path while keeping the external URL for other Nostr clients.</p>
<p>Identity rows now show your Display Name first (from your Nostr profile) with the internal identity name beside it in parentheses, so you see the name other people will see not just the one you picked when creating it.</p>
</div>
</div>
<!-- v1.7.2-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.2-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Install Update now actually installs. Before, the button would back up your current version then fail with 'Failed to apply update' because the installer couldn't write into system folders.</p>
<p>The button's also been renamed to 'Install Update' (previously 'Apply Update') and the node restarts itself a moment after you click it no more manual restart step.</p>
<p>Your existing identities now show the generated avatar instead of just their initials same look as freshly created ones.</p>
<p>Everything from 1.7.0-alpha and 1.7.1-alpha carries over (default avatars on creation, one-click Save publishes to Nostr relays, public blob URLs for profile pictures, 30-minute download window, VPN peer restore on reboot, reconciler-only-repairs, filebrowser fix).</p>
</div>
</div>
<!-- v1.7.1-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.1-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Over-the-air update test same features as 1.7.0, just a fresh version number so your node can try the new download-and-apply flow end-to-end. Safe to apply; nothing to do afterwards.</p>
</div>
</div>
<!-- v1.7.0-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.0-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Every identity now gets a personal avatar the moment it's created. Your main node identity gets a distinctive hexagonal-network icon; other identities get a colourful generated pattern unique to each one.</p>
<p>Profile editor: upload a profile picture and a banner, then tap Save your Nostr profile now goes out to the relays in one step. No more 'Save' vs 'Save &amp; Publish' confusion.</p>
<p>Profile pictures and banners you upload are now reachable by other Nostr clients across the network not just your own browser. Anyone who sees your profile on a relay can load the image.</p>
<p>Update downloads on slow connections no longer cut out right at the end. The client waits up to 30 minutes for each component instead of giving up after 15 seconds.</p>
<p>When you move a node to a new version without going through Check for Updates (for example via a reinstall or manual copy), it now reports the new version correctly instead of endlessly saying 'update available'.</p>
<p>Your VPN peers come back automatically after a reboot. No more rescanning QR codes on your phone or laptop.</p>
<p>Fresh installs stay lean only File Browser is included out of the box. Other apps wait in the Marketplace until you pick them.</p>
<p>File Browser stops rebooting itself every few hours the housekeeper now leaves it alone once it's healthy.</p>
<p>One-click 'Pull &amp; Rebuild' button works for nodes that update from source (the development path), not just the standard download path.</p>
<p>The download progress number is now clean (like 45.23%) instead of 45.270894%.</p>
</div>
</div>
<!-- v1.3.5 -->
<div>
<div class="flex items-center gap-2 mb-3">
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<template>
<!-- App Registries Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-white/96">App registries</h2>
<p class="text-sm text-white/60 mt-1">
Choose the primary registry for app installs and add mirrors for fallback.
</p>
</div>
<RouterLink
to="/dashboard/settings/registries"
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1M12 12V3m0 0l-4 4m4-4l4 4" />
</svg>
Manage registries
</RouterLink>
</div>
</div>
</template>
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import ScreensaverRing from '@/components/ScreensaverRing.vue'
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
const router = useRouter()
const { t } = useI18n()
@@ -13,6 +15,59 @@ const rebooting = ref(false)
const rebootPassword = ref('')
const rebootError = ref('')
// Reboot overlay full-screen progress shown once the reboot is committed.
// Mirrors the update overlay pattern in SystemUpdate.vue: poll /health,
// auto-reload when the backend returns, stall fallback at 3 min.
type RebootStage = 'rebooting' | 'reconnecting' | 'ready' | 'stalled'
const rebootOverlay = ref(false)
const rebootStage = ref<RebootStage>('rebooting')
const rebootStartedAt = ref(0)
const rebootElapsedSec = ref(0)
let rebootPollTimer: ReturnType<typeof setInterval> | null = null
let rebootElapsedTimer: ReturnType<typeof setInterval> | null = null
const rebootElapsedLabel = computed(() => {
const s = rebootElapsedSec.value
if (s < 60) return `Elapsed: ${s}s`
return `Elapsed: ${Math.floor(s / 60)}m${s % 60 < 10 ? '0' : ''}${s % 60}s`
})
function startRebootOverlay() {
rebootOverlay.value = true
rebootStage.value = 'rebooting'
rebootStartedAt.value = Date.now()
rebootElapsedSec.value = 0
rebootElapsedTimer = setInterval(() => {
rebootElapsedSec.value = Math.floor((Date.now() - rebootStartedAt.value) / 1000)
if (rebootElapsedSec.value >= 180 && rebootStage.value !== 'ready') {
rebootStage.value = 'stalled'
}
}, 1000)
// Start health polling after 2.5s the kernel has to go down before
// /health can disappear, and we don't want to see the pre-reboot health
// reply and mis-report "ready".
setTimeout(() => {
rebootStage.value = 'reconnecting'
rebootPollTimer = setInterval(pollRebootHealth, 1500)
}, 2500)
}
async function pollRebootHealth() {
if (rebootStage.value === 'ready' || rebootStage.value === 'stalled') return
try {
const res = await fetch('/health', { signal: AbortSignal.timeout(2000) })
if (!res.ok) throw new Error(`health ${res.status}`)
rebootStage.value = 'ready'
if (rebootPollTimer) { clearInterval(rebootPollTimer); rebootPollTimer = null }
setTimeout(() => { window.location.reload() }, 1200)
} catch {
// Fetch failing is the normal state while the host is down.
}
}
function rebootReloadNow() { window.location.reload() }
onBeforeUnmount(() => {
if (rebootPollTimer) clearInterval(rebootPollTimer)
if (rebootElapsedTimer) clearInterval(rebootElapsedTimer)
})
async function performReboot() {
if (!rebootPassword.value) return
rebooting.value = true
@@ -21,6 +76,7 @@ async function performReboot() {
await rpcClient.call({ method: 'system.reboot', params: { password: rebootPassword.value } })
showRebootConfirm.value = false
rebootPassword.value = ''
startRebootOverlay()
} catch (e) {
rebootError.value = e instanceof Error ? e.message : 'Reboot failed'
rebooting.value = false
@@ -108,6 +164,55 @@ async function performFactoryReset() {
</div>
</Teleport>
<!-- Reboot Progress Overlay -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="rebootOverlay"
class="fixed inset-0 z-[3000] bg-black flex flex-col items-center justify-center overflow-hidden"
>
<!-- Centered animated ring + logo same composition as the screensaver -->
<div class="reboot-ring-content">
<ScreensaverRing />
<div class="reboot-logo-wrapper">
<ScreensaverLogo />
</div>
</div>
<!-- Stage text + progress bar underneath -->
<div class="mt-8 w-[min(520px,80vw)] text-center">
<h2 class="text-xl font-semibold text-white mb-1">
{{ rebootStage === 'rebooting' ? 'Rebooting…'
: rebootStage === 'reconnecting' ? 'Reconnecting to your node…'
: rebootStage === 'ready' ? 'Back online'
: 'Reboot is taking longer than expected' }}
</h2>
<p class="text-sm text-white/60 mb-4">
Your node is restarting. This page will refresh automatically once it's back.
</p>
<!-- Animated progress bar: indeterminate stripe while working,
solid green when ready, paused at half while stalled. -->
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden mb-3 relative">
<div v-if="rebootStage === 'ready'" class="absolute inset-0 bg-green-400"></div>
<div v-else-if="rebootStage === 'stalled'" class="absolute inset-y-0 left-0 w-1/2 bg-orange-400/60"></div>
<div v-else class="absolute inset-y-0 w-1/3 bg-orange-400 rounded-full reboot-overlay-bar-anim"></div>
</div>
<p class="text-xs text-white/40">{{ rebootElapsedLabel }}</p>
<button
v-if="rebootStage === 'stalled'"
@click="rebootReloadNow"
class="mt-5 glass-button rounded-lg px-5 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
>
Reload now
</button>
</div>
</div>
</Transition>
</Teleport>
<!-- Factory Reset Section -->
<div class="glass-card px-6 py-6 mb-6 border border-red-500/30">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
@@ -148,3 +253,37 @@ async function performFactoryReset() {
</div>
</Teleport>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.reboot-ring-content {
position: relative;
display: grid;
place-items: center;
}
.reboot-logo-wrapper {
position: absolute;
inset: 0;
display: grid;
place-items: center;
z-index: 10;
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
}
.reboot-overlay-bar-anim {
animation: rebootBarSlide 1.8s ease-in-out infinite;
}
@keyframes rebootBarSlide {
0% { transform: translateX(-100%); }
50% { transform: translateX(120%); }
100% { transform: translateX(300%); }
}
</style>
@@ -3,6 +3,7 @@ import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue'
import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue'
import AppRegistriesSection from '@/views/settings/AppRegistriesSection.vue'
import WebhookSection from '@/views/settings/WebhookSection.vue'
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
import BackupSection from '@/views/settings/BackupSection.vue'
@@ -14,6 +15,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
<ClaudeAuthSection />
<AIDataAccessSection />
<SystemUpdatesSection />
<AppRegistriesSection />
<WebhookSection />
<TelemetrySection />
<BackupSection />
+47 -9
View File
@@ -68,8 +68,13 @@
>
<!-- Avatar -->
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
<img v-if="identity.profile?.picture" :src="displayableUrl(identity.profile.picture)" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
<div v-if="!identity.profile?.picture" class="w-full h-full flex items-center justify-center" :class="{
<img
v-if="identity.profile?.picture && !listPictureFailed[identity.id]"
:src="displayableUrl(identity.profile.picture)"
class="w-full h-full object-cover"
@error="() => { listPictureFailed[identity.id] = true }"
/>
<div v-if="!identity.profile?.picture || listPictureFailed[identity.id]" class="w-full h-full flex items-center justify-center" :class="{
'bg-blue-500/20': identity.purpose === 'personal',
'bg-orange-500/20': identity.purpose === 'business',
'bg-purple-500/20': identity.purpose === 'anonymous',
@@ -302,8 +307,14 @@
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
<div class="flex items-center gap-3 mb-5">
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
<img v-if="profileForm.picture" :src="displayableUrl(profileForm.picture)" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
<div v-else class="w-full h-full flex items-center justify-center">
<img
v-if="profileForm.picture && !editorPictureFailed"
:src="displayableUrl(profileForm.picture)"
class="w-full h-full object-cover"
@error="editorPictureFailed = true"
@load="editorPictureFailed = false"
/>
<div v-if="!profileForm.picture || editorPictureFailed" class="w-full h-full flex items-center justify-center">
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
</div>
</div>
@@ -368,7 +379,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { safeClipboardWrite } from './utils'
@@ -409,6 +420,18 @@ const profilePublishing = ref(false)
const avatarUploading = ref(false)
const bannerUploading = ref(false)
// Track image load failures so the UI can fall back to the initial/
// identicon placeholder instead of showing a blank square. Pasted URLs
// that 404 (or point at an onion the local browser can't reach) were
// previously silently hidden by a display:none handler that left the
// fallback unrendered.
const editorPictureFailed = ref(false)
const listPictureFailed = reactive<Record<string, boolean>>({})
// Reset the failure flag when the URL changes so a freshly pasted URL
// gets re-tried (the watcher fires once the form reacts).
watch(() => profileForm.value.picture, () => { editorPictureFailed.value = false })
// The backend returns onion-based public URLs for uploaded profile
// pictures (so they're fetchable by external Nostr clients), but the
// local browser session isn't Tor-routed and can't resolve .onion hosts.
@@ -423,10 +446,12 @@ function displayableUrl(url: string | null | undefined): string {
return url
}
// Upload to the node's blob store and drop the returned public URL into
// the profile field. The /api/blob endpoint marks these blobs public, so
// the URL served back (`public_url`, onion-rooted when Tor is up) is
// reachable by external Nostr clients fetching kind:0 metadata.
// Upload to the node's blob store and drop a URL into the profile field.
// For small images (64KB) we inline the bytes as a data URL so external
// Nostr clients can render the picture without needing to reach a tor
// onion. Larger uploads fall back to the onion-rooted public_url.
const INLINE_MAX = 64 * 1024
async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
const input = ev.target as HTMLInputElement
const file = input?.files?.[0]
@@ -436,6 +461,14 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
profileError.value = ''
try {
const buf = await file.arrayBuffer()
// Inline small images as a data URL universally fetchable by any
// Nostr client and bypasses the "only reachable over Tor" limitation.
if (buf.byteLength <= INLINE_MAX) {
const mime = file.type || 'image/png'
const b64 = btoa(Array.from(new Uint8Array(buf), (b) => String.fromCharCode(b)).join(''))
profileForm.value[field] = `data:${mime};base64,${b64}`
return
}
const resp = await fetch('/api/blob', {
method: 'POST',
credentials: 'include',
@@ -451,6 +484,11 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
const url = public_url || self_test_url
if (!url) throw new Error('blob API returned no URL')
profileForm.value[field] = url
// Heads-up for large uploads: onion URLs only render on Tor-routed
// clients. Not an error, but worth telling the user.
if (url.includes('.onion/')) {
profileError.value = 'Large image stored on this node. Pasting a public https://… URL is recommended for Nostr visibility.'
}
} catch (e: unknown) {
profileError.value = e instanceof Error ? e.message : `${field} upload failed`
} finally {
+1 -1
View File
@@ -94,7 +94,7 @@ export default defineConfig({
urlPattern: /\/assets\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'assets-cache-v2',
cacheName: 'assets-cache-v3',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days

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