Compare commits

..
203 Commits
Author SHA1 Message Date
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
DorianandClaude Opus 4.7 8a2cabdba9 release(v1.7.7-alpha): clean OTA test bump on top of robust apply
Pure version bump. No code changes. First release shipped with the
reinforced apply_update (timestamped staging + all-mv) and frontend
with 95% progress cap — this OTA should walk through cleanly from
.116/.198/.253 without any sideload intervention.

Artefacts:
  archipelago                                      e3f1740d…006025  40373392
  archipelago-frontend-1.7.7-alpha.tar.gz          4fb79664…0172e9  76984615 (reused)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:44:19 -04:00
DorianandClaude Opus 4.7 4d1bd063e8 release(v1.7.6-alpha): robust apply_update + manifest-override env var
apply_update frontend swap
  Transient EROFS on .198 (filesystem hiccup — root FS mounts with
  errors=remount-ro so a fleeting glitch can bounce /opt to RO for a
  moment) caught the pre-cleanup `rm -rf web-ui.new web-ui.bak` mid-
  stride and aborted the apply. Rewrote the swap to use a timestamped
  staging dir (web-ui.new.<ms>) and a timestamped old-copy path so
  nothing needs to be rm'd before the extract. After the new tree is
  mv'd into place, the previous rollback copy is rotated aside with a
  .<ms> suffix (best-effort) and this apply's old copy becomes the new
  web-ui.bak. If the final mv fails, the staged old is restored so
  nginx keeps serving.

handle_update_check manifest override
  handle_update_check takes the git path whenever ~/archy/.git exists.
  On the dev box (.116) that meant the Pull & Rebuild button was
  always the only option even though the manifest-path OTA was
  already wired via ARCHIPELAGO_UPDATE_URL. Now: if that env var is
  set, we skip the git detection entirely and use the manifest path.
  The regular fleet (no env var, no repo) hits the manifest branch
  naturally; beta dev nodes (repo + no env var) still get Pull &
  Rebuild; dev nodes with the env var explicitly set can finally test
  the manifest OTA end-to-end.

Artefacts:
  archipelago                                      356e78cc…91a6dd  40372288
  archipelago-frontend-1.7.6-alpha.tar.gz          4fb79664…0172e9  76984615 (reused)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:33:10 -04:00
DorianandClaude Opus 4.7 b8397b5ecb release(v1.7.5-alpha): OTA end-to-end test bump
Trivial version-only bump. No code changes; binary differs only in its
embedded CARGO_PKG_VERSION string. Frontend tarball is byte-identical
to v1.7.4-alpha's (same sha), copied under the new filename to satisfy
the manifest component naming.

This exists so the fleet nodes (.116/.198/.253), all now running
v1.7.4-alpha with the fixed apply_update tar flow, can exercise the
full OTA pipeline from the UI: Check → Download (30-min timeout) →
Install (sudo install binary + sudo tar to web-ui.new + atomic swap) →
auto-restart (systemctl --no-block) → sidebar updates → state sync.

Artefacts:
  archipelago                                      7422a695…a1a2a6  40362432
  archipelago-frontend-1.7.5-alpha.tar.gz          4fb79664…0172e9  76984615 (reused)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:10:50 -04:00
DorianandClaude Opus 4.7 1e7df417a4 release(v1.7.4-alpha): fix Install Update tar extraction + progress overshoot
apply_update was extracting the frontend tarball with
`tar -xzf -C /opt/archipelago`, but the tar contents are the *inside*
of web-ui/ (root entries are ./test-aiui.html, ./assets/, etc.). So
the files landed directly in /opt/archipelago instead of under web-ui/,
and tar bailed on nginx-owned paths mid-extraction. First end-to-end
OTA test (.198) found it: "tar: ./assets/SystemUpdate-…js: Cannot
open: No such file or directory".

Now extracts into web-ui.new, chowns, then atomically swaps: move
existing web-ui → web-ui.bak, then web-ui.new → web-ui. Same pattern
as the manual sideload that's been working.

Frontend: SystemUpdate.vue fake download progress was capped at "<90"
with a Math.random()*15 increment — the last tick could push to
~104.99%. Capped at 95% with a smaller step so it stops at 95 and the
real RPC completion jumps it to 100.

Artefacts:
  archipelago                                      a14ad7e4…2a2be3  40361984
  archipelago-frontend-1.7.4-alpha.tar.gz          4fb79664…0172e9  76984615

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:02:14 -04:00
DorianandClaude Opus 4.7 3db71adb55 release(v1.7.3-alpha): sidebar version sync + FIPS reconnect + profile pic render
Sidebar version
  detect_build_version() no longer reads /opt/archipelago/build-info.txt
  first. That file was written by the ISO installer at flash time and
  never rewritten by OTA or sideload, so after any binary swap the
  sidebar kept advertising whatever the ISO shipped with. Now just
  returns env!("CARGO_PKG_VERSION") unconditionally — always matches the
  running binary.

FIPS card
  The two-column grid in FipsNetworkCard.vue placed version/npub boxes
  side-by-side on mobile but the anchor-status panel forced col-span-2,
  creating an unbalanced empty column at every desktop width. Anchor
  status moves to its own full-width row below the grid. When the
  anchor is not reached, a "Reconnect" button appears next to the
  status line; it calls fips.restart (45s timeout), waits 5s for the
  daemon to come back, then reloads fips.status. Surfaces whether the
  restart actually recovered the anchor in a status flash.

Profile picture render
  Uploaded profile pictures are stored with an onion-rooted URL so
  external Nostr clients can fetch them. The local browser isn't
  Tor-routed though, so the <img src> silently 404'd and the UI fell
  back to showing initials. Added a displayableUrl() helper on
  Web5Identities.vue that rewrites http://<onion>/blob/<cid>[?...] to
  same-origin /blob/<cid> for rendering, while the stored URL keeps
  its onion prefix so publishing to Nostr still works for external
  viewers. Pass-through for data: URLs and already-relative paths.

Identity row title
  The identity list header now renders profile.display_name (when set)
  and keeps identity.name as a muted parenthetical. Before, only the
  internal name was shown and a user who'd customised their Nostr
  display_name saw a mismatch between their own UI and what peers
  rendered.

Artefacts:
  archipelago                                      99184b95…22dc1b  40350664
  archipelago-frontend-1.7.3-alpha.tar.gz          7b933cf4…74a8bc  76987031

Changelog layman-style per the saved feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:44:59 -04:00
DorianandClaude Opus 4.7 66b4e2b313 release(v1.7.2-alpha): fix Install Update + identity avatar backfill + label
Three user-visible fixes shipped together.

1. update.apply permission-denied
   apply_update() was doing fs::copy into /usr/local/bin/archipelago and
   tar xzf into /opt/archipelago as the archipelago user — both root-owned.
   The backup step succeeded (it wrote to data_dir) but the swap failed
   with a silent permission denied, wrapped as "Failed to apply archipelago".
   Now uses `sudo install -m 0755` for the binary and `sudo tar -xzf` for
   the frontend, plus a post-apply `sudo systemctl --no-block restart
   archipelago` scheduled 2s after the RPC reply so the UI sees success.

2. Apply → Install label
   en/es locale strings: applyUpdate / applyTitle / applyNow changed from
   "Apply" to "Install". Matches the user's mental model and distinguishes
   the user-facing verb from the internal apply_update() function.

3. Identity avatar backfill
   Identities created before df83163f had profile=None on disk and so
   rendered as initials. load_record() now synthesizes an IdentityProfile
   with a default picture (identicon for regular identities, the hex node
   SVG for derivation_index=0) when profile is missing. The synthetic
   profile lives only in the returned record; the file stays untouched so
   a later explicit Save persists whatever the user actually chose.

Artefacts:
  archipelago                                        70e5444e…67c589  40381960
  archipelago-frontend-1.7.2-alpha.tar.gz            806b027b…358a824 76983699

Changelog rewritten layman-style per saved feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:25:10 -04:00
DorianandClaude Opus 4.7 0ea9ad9adb release(v1.7.1-alpha): version bump for end-to-end OTA test
Trivial bump on top of df83163f. No code changes — this exists purely so
the fleet nodes now sitting on 1.7.0-alpha have a real target to exercise
the OTA pipeline against: check → download → apply → restart → state
reconciliation. The binary content differs only in the embedded
CARGO_PKG_VERSION string.

Frontend tarball reused from v1.7.0-alpha (same bytes, copied to a new
filename to match the manifest component name convention).

Artefacts:
  archipelago                                     7f7981bd…56eef0  40391760
  archipelago-frontend-1.7.1-alpha.tar.gz (dup of 1.7.0)  dc3b63af…e9a8370  76984288

Manifest changelog is a single plain-language sentence explaining that
this is the test release — per the saved feedback about keeping
fleet-facing strings readable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:32:06 -04:00
DorianandClaude Opus 4.7 a9d8895395 feat(identity,update): default avatars, public blobs, long-running downloads
Follow-up to 1fb71b4b on the same v1.7.0-alpha line.

Identity avatars
  • New module `avatar.rs` generates two deterministic SVG styles keyed
    off the pubkey: a 5×5 mirrored identicon for sub-identities and a
    hexagonal-network motif for the master (seed index 0) identity.
    Both returned as base64 data URLs, so a fresh identity has a
    recognisable picture before the user uploads anything.
  • `IdentityManager::create()` and `create_from_seed()` populate
    `profile.picture` on creation. Index 0 gets the node SVG; all
    other seed-derived + ad-hoc identities get the identicon.

Blob store — public flag for profile assets
  • `BlobMeta.public` (default false) added; `BlobStore::put()` takes
    a `public: bool`. Missing in legacy meta files = false.
  • `POST /api/blob` now stores uploads with public=true and returns
    `public_url` alongside `self_test_url`. public_url is
    `http://<node-onion>/blob/<cid>` (no cap) if Tor has published the
    archipelago hidden service, else falls back to the local path.
  • `GET /blob/<cid>` bypasses the HMAC capability check when the
    requested blob is flagged public — external Nostr clients fetching
    a kind-0 `picture` URL can't hold a cap.
  • Mesh callers (content_ref attachments, dispatch rehydration) pin
    public=false explicitly so nothing leaks out of the mesh path.

Profile editor UX
  • Collapsed Save + Save & Publish into one button — the Save action
    now persists locally AND publishes the kind-0 metadata event in
    one step. Uploads store `public_url` into `profile.picture` /
    `profile.banner` so the published URL is reachable by external
    clients.

Update client — the 15-second cliff
  • Frontend `rpcClient.call` for `update.download` now has an
    explicit 30-minute timeout (was falling back to the default 15 s).
    `update.apply` gets 5 min, `update.git-apply` gets 15 min. Matches
    what the backend is actually willing to wait for.
  • Backend `load_state()` reconciles `state.current_version` with
    `CARGO_PKG_VERSION` on every start. Sideloaded or reflashed nodes
    were stuck advertising the old version even with a new binary in
    place, which kept re-offering the same release as an update.

Manifest changelog rewritten for fleet readers per the saved feedback
(no function names, no file paths). Artefacts refreshed:
  binary   12f838c5…5ba82d  40381864
  frontend dc3b63af…e9a8370 76984288

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:03:38 -04:00
DorianandClaude Opus 4.7 508f8e1786 fix(update): 30-min download timeout + tidier progress number
Follow-up to 56d4875b, same v1.7.0-alpha shipping band.

Backend download timeout bumped from 300s to 1800s (update.rs) with an
explicit 30s connect timeout. git.tx1138.com raw-file throughput can sit
around 70–80 KB/s, which meant OTA downloads were timing out at ~55%
through the 40 MB binary even though the SHA would have matched on a
full pull. 30 min gives ample headroom for the worst LAN-to-VPS link we
actually hit.

Frontend: SystemUpdate.vue now formats downloadPercent with toFixed(2)
via a new computed, so the progress card shows "45.23%" instead of
"45.270894%". Cosmetic only; the underlying ref still tracks raw floats.

Manifest changelog rewritten in user-facing language per the saved
feedback — no file paths, function names, or "root cause" phrasing.

Artifacts refreshed:
  binary   d85a71c5…982f4  40360936
  frontend 8adcdacf…e687f6 76986852

ISO at image-recipe/results/archipelago-installer-unbundled-x86_64.iso
(Apr 20 09:00) carries both fixes for fresh installs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 09:03:24 -04:00
DorianandClaude Opus 4.7 0399f45fb2 fix(vpn,reconcile): restore WG peers on boot + filebrowser spec drift
Follow-up to 8b7cb002 (no version bump — same v1.7.0-alpha manifest):

* WireGuard peer persistence. Kernel peer state is ephemeral; the add-peer
  RPC wrote each peer to data_dir/nostr-vpn/peers/*.json but nothing
  re-pushed them on reboot. Result on .198: wg0 came up listening with zero
  peers after last night's reboot. Added vpn::restore_wg_peers() — reads
  the peers dir, waits up to 30s for wg0 to exist, then replays each via
  `archipelago-wg add-peer`. Spawned from main.rs alongside the other
  startup tasks.
* Reconcile + filebrowser drift. scripts/container-specs.sh load_spec_
  filebrowser now declares SPEC_NETWORK="archy-net" (to match what
  first-boot-containers.sh creates) and pins the filebrowser-data volume
  + wget-style healthcheck so the reconciler stops reporting network
  drift. Without this, reconcile would kill the healthy first-boot
  filebrowser container and recreate it on bridge, breaking the archy-net
  DNS name the backend proxies to.

Manifest binary sha/size refreshed:
  6c178a76…3582cc, 40361912 bytes.
Rebuilt ISO at image-recipe/results/archipelago-installer-unbundled-x86_64.iso
(Apr 20 07:10) carries both fixes baked in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:10:49 -04:00
DorianandClaude Opus 4.7 6a4d48b49f release(v1.7.0-alpha): bump + fix git-method update + reconciler creates
Two fixes bundled into the OTA:

1. update.download hard-fail on git-path nodes. handle_update_check's git
   branch reported update_available=true + update_method="git" but never
   populated state.available_update, so update.download returned "No update
   available to download" even though the UI showed one. SystemUpdate.vue
   now routes update_method=="git" through update.git-apply (pull+rebuild+
   restart via self-update.sh); manifest-path nodes keep the download→apply
   flow. i18n strings + confirm modal added for the git path.

2. Reconciler creating containers behind the user's back. On fresh
   unbundled installs (.198, .253) archy-mempool-db and archy-btcpay-db
   materialised ~10 min after first boot because reconcile-containers.sh
   walked container-specs.sh's canonical tier list and created any
   "missing" container. reset_spec() now defaults SPEC_OPTIONAL="true",
   so reconcile is strictly a repair tool — baseline comes from
   first-boot-containers.sh (filebrowser on unbundled), everything else
   from the install RPC.

Also forces OTA trigger for nodes on 1.6.0-alpha that otherwise saw
"I'm at manifest.version, nothing to do" and skipped the refreshed 1.6
artifacts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 06:22:29 -04:00
DorianandClaude Opus 4.7 6b9b7a5a9c fix(fips,kiosk): auto-activate FIPS at onboarding end + 5-min kiosk wait
1. FIPS auto-activate at server startup only fires if fips_key already
   exists on disk, which on a fresh install is never true until AFTER
   onboarding. By the time the user completes seed-generate/restore,
   archipelago has been running for minutes and the startup task has
   long since exited. User still had to hit Activate.

   Fix: call spawn_post_onboarding_fips_activate() from the tail of
   handle_seed_generate and handle_seed_restore — the moment the
   fips_key materialises, a detached task runs `fips::config::install`
   + `archipelago-fips.service activate`. Logged only, never blocks
   the onboarding RPC.

2. Kiosk health-poll window was 30 × 2s (configs/ copy was 60 × 2s
   but unused — the heredoc in build-auto-installer-iso.sh is what
   actually lands on disk). On .198's slower hardware archipelago
   /health wasn't ready within 60s, so Chromium launched against a
   not-yet-running backend → blank window until manual reboot. Bumped
   to 150 × 2s (5 min) + TimeoutStartSec=360. .253 was already well
   within the window; this protects the slower box too. Standalone
   configs/archipelago-kiosk.service updated in lockstep so the two
   copies don't drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:09:46 -04:00
DorianandClaude Opus 4.7 66785c00dd release(v1.6.0-alpha): refresh with bulletproof FIPS + VPN label fix
Supersedes the earlier v1.6.0-alpha artifacts from today (which were
identical to v1.5.0-alpha and only existed for the update-flow smoke
test). This drop actually changes behaviour:

- archipelago-fips auto-activates on startup when fips_key exists; no
  Activate button needed.
- fips_key on-disk format migrated to bech32 nsec; legacy raw-byte
  files from v1.5.0-alpha self-heal when this version reads them.
- fips.yaml schema matches upstream jmcorgan/fips 0.3+.
- VPN status row shows "Not configured" instead of "Starting…" when
  wg0 isn't up — no VPN peer added yet is not a failure state.

New SHA256s + sizes in manifest.json. Fleet nodes .116/.228/.253 will
notice within 30 min (periodic update-check). Also lets .198 self-heal
its crashlooping archipelago-fips when it picks up the update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:13:58 -04:00
DorianandClaude Opus 4.7 d9411c3325 fix(fips,iso): bulletproof FIPS from install — no Activate button needed
Problems addressed (all observed on .198):
  * fips_key was written as raw 32 bytes; upstream fips daemon reads it
    with read_to_string() and bailed with "stream did not contain valid
    UTF-8", crashlooping indefinitely.
  * Activate button racy: user had to hit it, and it would keep failing
    silently because the daemon couldn't parse its own config.
  * FIPS schema drift (already fixed in 7d8a5864) put the config write
    path behind the same broken "Activate" flow, so the fix alone
    didn't help existing nodes.
  * Journal was on tmpfs — every reboot wiped install/onboarding history,
    making post-hoc debugging impossible.

Changes:
  * identity.rs: write fips_key as bech32 nsec + newline. load_fips_keys
    now auto-migrates legacy 32-byte files to bech32 the first time it
    reads them, so OTA updates from v1.5.0-alpha self-heal without user
    action.
  * server.rs: post-onboarding auto-activate task runs on every
    archipelago startup. If fips_key exists it ensures /etc/fips/fips.yaml
    is schema-current and starts archipelago-fips.service. Pre-onboarding
    nodes stay quiet (guarded on fips_key_exists).
  * ISO build: un-mask archipelago-fips + archipelago-wg + wg-address —
    all use ConditionPathExists on their key files, so systemd silently
    skips them pre-onboarding (no MOTD [FAILED]). Only nostr-vpn stays
    masked (legacy service, superseded by upstream fips).
  * Journald made persistent via /var/log/journal + 500M cap, so
    install and first-boot logs survive reboots for diagnosis.

After this, a fresh install + onboarding should bring FIPS up automatically
with no user interaction. The UI "Activate" button can stay as an escape
hatch (the RPC is still there) but is no longer on the critical path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:33:21 -04:00
DorianandClaude Opus 4.7 00a86e6ecf release(v1.6.0-alpha): smoke-test release for system-update flow
No functional changes from v1.5.0-alpha — this release exists only to
validate the in-app update pipeline end-to-end (manifest check → staged
download → apply → restart → version bump in UI sidebar).

Dropping just the manifest + artifacts; no manual deploy to the fleet.
.116/.228/.253 should notice within 30 min (periodic update-check
interval) and surface the update in the dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:12:28 -04:00
DorianandClaude Opus 4.7 361ebea85c fix(iso): verify_backend_version uses fixed-string substring match
Anchored regex was too strict — `strings` concatenates adjacent printable
bytes so the version never sits on its own line. The 1.5.0-alpha binary
DOES contain the version but as part of `1.5.0-alpharpcNot Found`. Fixed
by switching to `grep -qF $VERSION`: substring match is safe because the
version string is specific enough that accidental collisions are
vanishingly unlikely.

Caught mid-build today: check rejected the correct local binary, fell
through to container source-build — ISO still produced correctly but
wasted ~10 min on an unnecessary rebuild.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:41:48 -04:00
DorianandClaude Opus 4.7 fe963a1a8b fix(fips,iso): match upstream fips schema + guard ISO against stale binary
1. FIPS daemon config schema drifted: upstream jmcorgan/fips now takes
   `node.identity.persistent: true` (keys read from config-dir/fips.key)
   and `transports.udp.bind_addr: "0.0.0.0:PORT"` instead of
   `identity.key_file/pub_file` + `transports.udp.enabled/port`. The
   `tor:` transport was dropped entirely; archipelago handles Tor
   fallback itself. fips.yaml generated by archipelago::fips::config
   now matches the upstream schema, and archipelago-fips.service stops
   crashlooping on Activate. Observed on .198: 52 restarts with
   "data did not match any variant of untagged enum TransportInstances
   at line 7 column 3".

2. ISO backend-binary capture didn't verify that the captured binary
   matched the checked-out Cargo.toml version. Today's 14:40 ISO
   shipped a stale 1.4.0 binary because `core/target/release/archipelago`
   pre-dated the 1.5.0-alpha bump — the build grabbed it via the
   first-priority "local release build" path without looking at it.
   All four capture sources now go through verify_backend_version()
   which greps the binary for the expected version string; mismatches
   are skipped so the build falls through to the source-build path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:19:56 -04:00
DorianandClaude Opus 4.7 3441ea2459 fix(iso): pass installer-env script as bind-mounted file, not inline bash -c
On this host (and potentially others with a particular podman/overlay
state), passing the multi-hundred-line stage-2 script via
`debian:trixie bash -c '...'` caused debootstrap to fail at
"Extracting apt... tar failed" on the very first package — no matter
what patch, storage cleanup, or env-reset we tried.

Running the exact same script body via a bind-mounted file
(`bash /installer-env.sh`) succeeds. So: write the body to a temp
file in WORK_DIR, bind-mount it read-only, and have the container
bash execute it from the file. Same behavior, different invocation,
works.

Was blocking every ISO rebuild since ~10:57 local. First successful
build since: 14:40, sha256 41fad2ff…, 2.3GB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:40:52 -04:00
Dorian 3127d50091 Revert "fix(iso): patch debootstrap for Trixie apt 3.0.3 tar dup-entry bug"
This reverts commit 9d2af70767.
2026-04-19 13:49:48 -04:00
DorianandClaude Opus 4.7 8466cd14f0 fix(iso): patch debootstrap for Trixie apt 3.0.3 tar dup-entry bug
Debian Trixie apt 3.0.3's data.tar has duplicate entries for the same
path (regular file + symlink at e.g. libapt-private.so.0.0), and tar
bails on the second entry with "Cannot create symlink: File exists",
failing debootstrap on the very first package. Patch debootstrap's
tar invocation to use --skip-old-files so the duplicate is ignored.

Was blocking every unbundled ISO rebuild since the Trixie apt bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:23:20 -04:00
DorianandClaude Opus 4.7 036db76773 fix(iso): escape $svc in mask-loop heredoc (was expanding to empty)
Previous build's STEP 47/50 log showed:
  RUN for svc in nostr-vpn archipelago-wg archipelago-wg-address; do
    rm -f /etc/systemd/system/.service
    ln -sf /dev/null /etc/systemd/system/.service
  done

The Dockerfile is generated via <<DOCKERFILE heredoc in the build
script, so unescaped $svc resolved in the outer bash BEFORE Docker
ever saw it, leaving nostr-vpn/wg masks as a hidden `.service` file
with no effect. nostr-vpn still tries to start on boot → [FAILED].

Fixed with \$svc so the literal lands in the Dockerfile for Docker's
shell to expand per iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:37:00 -04:00
Dorian da3012b75a Revert "fix(iso): enable upstream fips.service so fresh installs show "active""
This reverts commit 810c111ba7.
2026-04-19 10:00:25 -04:00
DorianandClaude Opus 4.7 659d44a761 fix(iso): enable upstream fips.service so fresh installs show "active"
Fresh install of .198 reported "FIPS has an npub but says inactive".
The debian package writes /etc/fips/fips.pub during install (whence
the npub) but leaves the upstream fips.service disabled. Result:
FipsStatus.service_active = false, dashboard shows "inactive" until
the user hits Activate. Explicit `systemctl enable fips.service`
in the Dockerfile so first boot brings the daemon up immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:56:10 -04:00
DorianandClaude Opus 4.7 4cb5c07b1b fix(iso): 3 first-boot issues from .198 reinstall report
1. nostr-vpn still failing despite last mask attempt — confirmed in
   the 6th ISO's rootfs.tar: the .service file was present but
   not in multi-user.target.wants. Previous `systemctl mask` silently
   no-oped because the real file was already there. Fixed properly
   with explicit `rm -f` + `ln -sf /dev/null` for nostr-vpn,
   archipelago-wg, and archipelago-wg-address — same /dev/null
   symlink state that `mask` would produce on a clean install.

2. Kiosk didn't come up on first boot, only on reboot. Extended the
   ExecStartPre health-poll from 30s → 120s (unbundled ISO takes
   longer to settle on first boot: archipelago initializes state,
   pulls FileBrowser, frontend settles), raised TimeoutStartSec to
   180s, and added After=systemd-user-sessions.service +
   After=network-online.target so X / Chromium aren't racing.

3. /init: line 29: can't create /root/etc/network/interfaces error
   on installer boot — debootstrap --variant=minbase omits ifupdown
   so the target has no /etc/network/ directory, and live-boot's
   init tries to seed it. Non-fatal but noisy. Added ifupdown +
   isc-dhcp-client to the debootstrap --include list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:54:12 -04:00
DorianandClaude Opus 4.7 3018849cc8 fix(iso): add clang/libclang/nftables deps — rustables gateway feature uses bindgen
5th ISO attempt died in rustables's build.rs (which uses bindgen to
wrap libnftnl) with "couldn't find any valid shared libraries
matching: libclang". bindgen requires libclang.so at build time
to parse C headers. rustables also needs libnftnl-dev + libmnl-dev
for the actual wrappers.

Added to the fips-builder stage apt install line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:01:59 -04:00
DorianandClaude Opus 4.7 c1bb7b675d fix(iso): build fips with --features gateway so fips-gateway binary exists
Third time's the charm. The upstream fips Cargo.toml puts fips-gateway
behind features.gateway = ["dep:rustables"], so the previous two
attempts (--bins, --workspace --bins) never produced the binary —
only the default feature set was compiled. cargo deb --no-build then
panics looking for the missing binary.

Inspected /tmp/fips-investigate (fresh clone of upstream main on
2026-04-19) to confirm — the feature flag is the gate, not a
workspace layout issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:54:53 -04:00
DorianandClaude Opus 4.7 73b49bd5bf fix(iso): use --workspace --bins so cargo builds fips-gateway member crate
Plain `cargo build --release --bins` only built the root crate's
binary targets. fips-gateway is a workspace member, so we need
--workspace to pull every member's bins. Without it cargo deb
--no-build panics looking for target/release/fips-gateway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:44:39 -04:00
DorianandClaude Opus 4.7 423c2f8201 feat(web5): anchor connectivity badge on FipsNetworkCard
Consumes the new authenticated_peer_count + anchor_connected fields
from fips.status. Shows a cyan dot + "connected" when fips.v0l.io is
in the identity cache (DHT routing to unknown npubs will work), or
an orange "not reached" with a one-line explainer that federation
and messaging will fall back to Tor until the anchor reconnects.

Peer count appears on the same row so users see "3 peers" when the
fleet-pair script has been run, or "0 peers" on a fresh install
still waiting for the anchor handshake.

Block only renders when service_active — pre-onboarding the FIPS
package is masked so there's nothing meaningful to report.

Covers the UI half of task #20. Multi-anchor defaulting is still
open (need real anchor addresses beyond fips.v0l.io).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:42:50 -04:00
DorianandClaude Opus 4.7 3ce7bb6c18 feat(fips): surface anchor connectivity + peer count in FipsStatus
Two new fields on the /rpc fips.status payload:

- authenticated_peer_count: how many FIPS peers the daemon has an
  authenticated session to right now. 0 means isolated / not on
  the mesh; >0 means traffic to any known npub can DHT-route.
- anchor_connected: true when the public anchor (fips.v0l.io,
  npub1zv58cn7…) is present in the daemon's identity cache. The
  anchor bootstraps DHT routing for general-case deployments, so
  this is the best single-value indicator the UI can show for
  "will federation traffic over FIPS work between previously-
  unknown peers?"

Implementation: fips::service::peer_connectivity_summary shells
out to `sudo -n fipsctl show peers` + `... show identity-cache`
(archipelago user already has NOPASSWD:ALL per the ISO sudoers
and live fleet nodes, confirmed). Failure returns (0, false) so
the UI degrades to "unknown" state without crashing.

Only queried when service_active — pre-onboarding / daemon-down
nodes skip the fipsctl call entirely.

UI side (FipsNetworkCard) consumes the full status JSON, so the
two new fields are available via existing prop plumbing; visual
treatment can come later.

Also fixes ISO build (commit 3e04456c wasn't sufficient): the
Dockerfile needs `cargo build --release --bins` — upstream FIPS
added a `fips-gateway` binary target, and plain `cargo build
--release` only builds the default bin list, which caused
`cargo deb --no-build` to fail hunting for the missing binary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:40:31 -04:00
DorianandClaude Opus 4.7 8dd57bcbb1 feat(federation): periodic sync every 30 minutes
Until now federation.sync-state only fired on (a) user clicking Sync
in the UI or (b) server-name push. That meant own_fips_npub,
last_transport, peer state updates — all the things v1.5 added for
auto-upgrade from Tor to FIPS — didn't propagate until the user
poked the button.

Fix: spawn a background task in server.rs that runs
federation::sync_with_peer for every Trusted peer every 30 minutes.
First run is 60s after boot (let onboarding settle) and peers are
staggered 5s apart to not hammer Tor's SOCKS proxy with concurrent
connects.

The sync path already prefers FIPS (via PeerRequest), so once peers
have learned each other's fips_npub (now automatic thanks to the
own_fips_npub broadcast in state snapshots), subsequent periodic
syncs route over FIPS — transport badge cycles from 'tor' to 'fips'
on its own without user action.

Covers task #30.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:32:11 -04:00
DorianandClaude Opus 4.7 8fade7c435 fix(iso): rebuild-blocker — FIPS needs libdbus-1-dev + libssl-dev
rust:1-slim-bookworm doesn't include dbus/ssl dev headers, and
jmcorgan/fips upstream started linking against libdbus-sys + openssl
at some recent commit. Observed during the 2026-04-19 v1.5.0-alpha
rebuild: libdbus-sys's build.rs panics when pkg-config can't find
dbus-1.pc, which kills the whole cargo build → the whole ISO build
→ ships an ISO without FIPS installed.

Also mask nostr-vpn.service + archipelago-wg*.service in the rootfs
Dockerfile: these have WantedBy=multi-user.target so systemd pulls
them into the default boot target, but their EnvironmentFile + an
ExecStartPre guard cause them to [FAILED] in the boot MOTD on every
fresh install until onboarding writes their env files. Masking
keeps the startup clean; the onboarding / install RPC handlers
unmask + start them when prerequisites exist (same model as
archipelago-fips).

Bonus discovery from same diag: the default build was silently
reusing a stale rootfs cache from Apr 12 — before the FIPS
integration landed. So the v1.5.0-alpha ISO I shipped had no FIPS
package at all. Rebuild pass with --rebuild forces fresh rootfs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:27:22 -04:00
DorianandClaude Opus 4.7 fd8e93235f feat(web5): avatar + banner upload on the profile editor
Previously the profile editor only accepted external URLs for
picture/banner — typing in a URL works, but anyone without their
own image host couldn't use an avatar at all. Now there's an
"Upload" button next to each field that pushes the selected file
to /api/blob and pastes the returned capability-signed local URL
(`/blob/<cid>?cap=…&exp=…&peer=…`) straight into the form field.

- Two new refs: avatarUploading / bannerUploading so each button
  shows "Uploading…" independently.
- uploadAsset(ev, 'picture' | 'banner') wraps the POST, validates
  HTTP 200 + presence of self_test_url, surfaces failures in the
  existing profileError banner.
- File input is re-cleared on completion so the user can pick the
  same file again without refreshing.
- Live preview in the <img> at the top of the editor updates
  immediately because profileForm[field] is reactive.

Image persists through Save & Publish via the existing
identity.update-profile + identity.publish-profile (both now
multi-relay). The image URL is still local-only — external nostr
clients won't resolve it until we integrate a public image host
(noted in task #29).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 05:01:57 -04:00
DorianandClaude Opus 4.7 668ed1590c feat(web5): surface multi-relay publish result on profile save
Pairs with the backend change that broadcasts kind:0 to every
enabled relay. Web5Identities.vue's publishProfile() now reads the
richer response ({accepted, rejected, relays_attempted, published})
and shows one of three states:

- all relays accepted → "Published to all N relays (event_id…)"
- partial → "Published to X/N relays" plus a warning with the first
  relay's rejection reason
- zero → "Published to 0/N relays — check Manage Relays" (error)

User can now tell at a glance whether their profile actually made
it to the wider nostr network or only the local relay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 04:47:44 -04:00
DorianandClaude Opus 4.7 b77b031b8e fix(nostr): profile publish broadcasts to ALL enabled relays
Previously handle_identity_publish_profile defaulted to a single
hard-coded relay (ws://localhost:18081) so the user's kind:0 profile
event only ever landed on the local relay — hence "Manage Relays
shows N connected, but profile edits don't propagate" from testing.

Fix — two-layer change:

- identity_manager::publish_profile now takes `&[String]` relays
  instead of one URL. Adds each relay to the nostr-sdk client,
  gives 15s for handshakes, publishes, then surfaces per-relay
  accept/reject in a new ProfilePublishOutcome struct so the UI
  can show WHICH relays accepted vs. rejected and WHY.
- RPC handle_identity_publish_profile no longer defaults to the
  local relay: pulls the ENABLED list from nostr_relays::list_relays
  (the same table that powers Manage Relays) and publishes to every
  entry. Accepts an optional `relays: [...]` override for tests.
- At-least-one-accept guarantee: if every relay rejects, the call
  errors instead of silently reporting published=true. User gets a
  real error message listing the failures.
- Response shape: `{event_id, accepted: [urls], rejected: [[url,
  reason]], relays_attempted: N, published: bool}` so the UI can
  show a useful status block after clicking Publish.

relay_url_matches is tolerant of trailing-slash / case differences
since nostr-sdk canonicalises URLs internally.

Covers the publishing half of task #29; avatar/banner upload UI is
still open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 04:42:25 -04:00
DorianandClaude Opus 4.7 f756365935 feat(peers): bidirectional /network peer requests
Before: Alice sent /network.send-request to Bob, Bob accepted via
/network.accept-request and gained Alice in his peers list, but Alice
was never notified — her pending row sat there and she had to
manually add Bob separately. User complaint: "it's strange you have
to do it both ways."

Fix — the accept now fires a best-effort connection_accepted message
back to the requester:

- handle_network_accept_request: after writing the local peer record,
  assembles a `{type: "connection_accepted", request_id, from_did,
  from_onion, from_pubkey}` JSON, signs + encrypts + POSTs it to the
  requester via node_message::send_to_peer. Uses PeerRequest internally
  so it prefers FIPS and falls back to Tor.
- handle_node_message: parses incoming plaintext as JSON; on a match
  for type=connection_accepted, auto-adds the sender to peers.json
  (the existing self-pubkey guard in add_peer still applies) and
  short-circuits the normal store_received path so the acceptance
  doesn't also land as a chat message in Alice's inbox.

Offline handling: if Alice is offline when Bob accepts, the notify
warns and the local accept still succeeds. Alice will receive any
subsequent message from Bob normally; future iteration could
retry on reconnect.

Federation-invite flow (federation.accept-invite → notify_join) was
already bidirectional; this closes the gap for the peer flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 04:34:37 -04:00
DorianandClaude Opus 4.7 60758263f3 feat(server): lazy-bind FIPS peer listener so fips.install doesn't
need an archipelago restart

Previously the server checked `fips0` once at startup; if the
interface wasn't up (pre-onboarding, or post-onboarding before the
user clicked Activate FIPS), the peer listener never bound and stayed
unreachable until the next archipelago restart.

Replaced with a `peer_late_bind_loop` background task: polls every
30s for an fd00::/8 address on `fips0` and binds the listener the
moment one appears. First tick fires immediately so the hot path —
fips0 already up at startup — is still zero-cost. Cancellation
cascades through the same `tokio::sync::watch` channel the main
listener uses.

Side effects:
- main.rs no longer computes peer_addr eagerly; dropped the unused
  param from serve_with_shutdown.
- FipsTransport::is_available already caches the service probe so
  the 30s poll doesn't thrash systemctl.

Covers task #21. Unblocks the first-boot + onboarding flow for
fresh ISO installs on .253.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 04:21:20 -04:00
DorianandClaude Opus 4.7 6399b6881e feat(federation): advertise own_fips_npub in state snapshots
Pre-v1.4 federation pairs (who exchanged invites before fips_npub
was part of the invite code) had no path to learn each other's FIPS
npub — they'd stay Tor-only forever even after upgrading. Fix:
every state snapshot now carries the sender's own_fips_npub, and
update_node_state refreshes the stored fips_npub on the receiver
side whenever it differs.

- NodeStateSnapshot.own_fips_npub (serde default for back-compat).
- build_local_state takes own_fips_npub alongside the other
  single-value fields.
- handle_federation_get_state populates own_fips_npub from
  identity::fips_npub, with a fallback to the upstream daemon's
  /etc/fips/fips.pub for legacy nodes that never materialised a
  seed-derived key.
- storage::update_node_state now writes fips_npub into the
  FederatedNode when a new value arrives and trims whitespace
  before comparing, so key rotations also flow through.
- Test fixtures (storage + transport/delta + sync) updated for the
  new field; existing tests pass.

Net effect: on the next sync, .116 and .228 learn each other's
fips_npub (currently null from the old invite) and subsequent
federation calls route FIPS-first automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 04:16:05 -04:00
DorianandClaude Opus 4.7 ab85c2e361 fix(peers): reject self-add in add_peer()
Observed on .228: /var/lib/archipelago/peers.json contained an entry
matching the node's own node_key.pub pubkey. It had been added
2026-03-02 and stuck around forever since add_peer() only dedupes by
pubkey — nothing stops a pubkey that happens to be ours.

How it probably got there: somewhere in the auto-add paths
(node-message receive, mesh federation bridge, invite back-and-forth)
a message we'd sent was fed back and the receiver-side add used the
echoed from_pubkey without realising it was us. Doesn't matter which
path — the guard belongs in storage.

add_peer now short-circuits when the candidate pubkey matches
data_dir/identity/node_key.pub. Helper is_own_pubkey best-effort:
unreadable identity → returns false so normal peers aren't blocked.

Also manually purged the one stray entry on .228 (1 removed, 2 real
peers remain). Future deploys include this guard so the phantom can't
come back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 04:02:15 -04:00
DorianandClaude Opus 4.7 616f62ce4f fix(deploy): force nginx sites-enabled symlink so config updates actually apply
Real 413 root cause on .116 and .228 turned out not to be the body-size
limit — their /etc/nginx/sites-enabled/archipelago was a stale regular
FILE, not a symlink to sites-available, so every nginx update since
someone froze the active config had been invisible to running nginx.
The /api/blob location, added at some point after that freeze, didn't
exist in sites-enabled, so every attachment upload hit nginx's default
1m client_max_body_size and returned 413 regardless of attachment
size.

Deploy now re-creates the symlink on every run: if sites-enabled is a
regular file or missing, we replace it with a symlink to
sites-available. Idempotent if it's already correct.

Also applied the fix live on all 4 fleet nodes — /api/blob now
responds 401 (session-auth required, as designed) instead of 413 on
2MB+ test uploads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 03:45:16 -04:00
DorianandClaude Opus 4.7 5d2fac690e fix(nginx): raise body-size limit 10m → 256m for mesh/content/dwn peer paths
Was seeing "upload failed: 413" on mesh attachment sends between
federated nodes — a ~7MB image becomes ~10MB base64 in the
typed_envelope wire and hit the 10m client_max_body_size on
/archipelago/, /content/, and /dwn/. Bumped those six locations
(two per server block, regular + HTTPS) to 256m so modern
attachments/blobs don't trip the proxy. /rpc/ stays at 1m —
internal JSON-RPC calls are small and don't need the headroom.

Applied to all 4 fleet nodes live; ISO source config updated so
fresh installs get the same limits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 03:36:12 -04:00
DorianandClaude Opus 4.7 eac583c15e release: v1.5.0-alpha + version hygiene fixes
Versioning was drifting on three axes — fixed all of them:

1. Cargo.toml → 1.5.0-alpha (was 1.5.0). User wants `-alpha` suffix
   on every pre-stable release; this is the current state of main.
2. neode-ui/package.json was still 1.3.5 — brought in line.
3. /opt/archipelago/build-info.txt was stale on .198 (1.3.4) and
   .253 (1.3.5), absent on .116/.228. That file OVERRIDES the
   binary's CARGO_PKG_VERSION for the UI sidebar, which is why
   .198/.253 kept showing old versions even with fresh binaries.
   scripts/deploy-to-target.sh now writes build-info.txt on every
   deploy, reading the version straight from Cargo.toml — so the
   sidebar can never drift from the binary again.

Release artifacts + manifest:
- releases/v1.5.0-alpha/archipelago (40M, sha in manifest)
- releases/v1.5.0-alpha/archipelago-frontend-1.5.0-alpha.tar.gz (51M)
- releases/manifest.json bumped with full 7-line changelog covering
  FIPS-first routing, Settings toggle, transitive federation, cancel
  button, transport badges, peer listener, and the build-info fix.
- scripts/check-release-manifest.sh — new pre-publish guard. Refuses
  to pass if: Cargo.toml ≠ manifest version, changelog is empty
  (release notes are mandatory), or any component's sha256/size
  doesn't match the file on disk. Run locally or from CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 03:23:18 -04:00
DorianandClaude Opus 4.7 d63cd92bee feat(federation): v1.5.0 bump + transport badge on each node card
Every federated node card now shows a colored badge indicating how
archipelago actually reached the peer on the most recent successful
call — FIPS / TOR / LAN / MESH — not a prediction based on available
addresses. The badge is hidden when we've never reached the peer.

Backend:
- Cargo.toml: 1.4.0 → 1.5.0 (visible in the sidebar health endpoint).
- FederatedNode gains last_transport + last_transport_at (serde
  default for back-compat with v1.4 nodes.json files).
- federation::storage::record_peer_transport(did, onion, transport)
  — writes both fields plus last_seen after each successful peer
  call. Matches by DID first, falls back to onion.
- federation::sync::sync_with_peer now calls record_peer_transport
  immediately after a successful PeerRequest return, so the badge
  on the sync'ing peer's card reflects the transport the call
  actually rode (fips vs tor).

Frontend:
- types.ts FederatedNode gains last_transport / last_transport_at
  (union-typed to the four known kinds).
- NodeList.vue: new transportBadge(node) returns {label, cls, title}
  tuned per transport. Hidden when last_transport is absent so we
  never lie. Tooltip shows "Last reached via <x> · <time ago>" so
  stale data is self-evident. Removed the predictive icon from the
  transport store — badge is now 100% ground-truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 02:51:26 -04:00
DorianandClaude Opus 4.7 2e8417e39b feat(federation): cancel button for outbound pending peer requests
Previously the Pending Peer Requests panel only had Approve/Reject for
inbound rows; outbound rows in the 'sent' state had no action and
would sit there until the target explicitly approved or rejected. Now
you can Cancel an outbound request — the local row is dropped and a
PeerCancel nostr DM is sent so the target's inbound row also
disappears.

Backend:
- HandshakeMessage::PeerCancel {reason: Option<String>} variant.
- nostr_handshake::send_peer_cancel() mirrors send_peer_reject.
- handshake.poll handler dispatches inbound PeerCancel: finds the
  matching inbound pending row (same from_nostr_pubkey, state=Pending)
  and deletes it. Reply shape gains `cancelled_inbound: [id]`.
- federation::pending::delete() — hard-remove (set_state only
  transitions; we don't want 'Cancelled' ghosts in the audit trail).
- federation.cancel-request RPC: outbound+Sent only, default
  notify=true (cancelling silently is a footgun), best-effort DM
  (relay failure doesn't block local deletion). Wired in dispatcher.

Frontend:
- PendingRequestsPanel.vue: Cancel button appears only on
  outbound+sent rows. Emits 'cancel' event with request id.
- Federation.vue: cancelPending(id) handler calls
  rpcClient.federationCancelRequest and reloads the list.
- rpcClient.federationCancelRequest(id, reason?, notify=true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 02:28:16 -04:00
DorianandClaude Opus 4.7 2f327183eb fix(ui,ops): TransportPrefsCard import path + fleet unpair script
- TransportPrefsCard.vue: import from '@/api/rpc-client' (not
  '@/api/rpc') so vue-tsc resolves the module during build.
- scripts/fleet-fips-unpair.sh: companion to the fleet-pair script —
  rewrites each node's fips.yaml to anchor-only (fips.v0l.io) so we
  can prove the general-case deployment works without the LAN
  fast-path. Prints per-node peer counts + DHT AAAA resolution for
  every cross-node pair after the change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 02:08:32 -04:00
DorianandClaude Opus 4.7 6ddce90e45 feat(federation): transitive peer learning via state-sync
When Alice syncs state with a Trusted peer Bob, she now learns about
Bob's other Trusted peers and auto-adds them as Observers on her side
— so Carol's fips_npub is known locally and subsequent federation
traffic to Carol can route directly over FIPS without a separate
invite round-trip.

- NodeStateSnapshot gains a `federated_peers: Vec<FederationPeerHint>`
  field (serde default for backward compat with v1.4 snapshots).
- FederationPeerHint is a minimal projection: did, pubkey, onion,
  name, fips_npub — excludes per-receiver fields (trust_level,
  added_at, last_seen, last_state).
- build_local_state takes the local federation list and includes only
  Trusted peers. Observer/Untrusted peers are NOT re-exported — a
  node shouldn't launder other people's federation through its own
  authority.
- sync_with_peer merges the received hints via merge_transitive_peers
  when the source is Trusted: existing entries get fips_npub
  refreshed if missing; unknown DIDs are added at Observer trust
  (never auto-promoted to Trusted).
- Bounded to 1 hop: merged Observer entries do NOT get re-exported in
  the local node's own snapshots. So Bob → Alice learns Carol, but
  Alice's snapshots to Dave do not include Carol.
- Tests: round-trip + filter-non-trusted-from-snapshot coverage.
- Storage + delta test fixtures updated for the new field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:58:21 -04:00
DorianandClaude Opus 4.7 d80b8ce513 ops(fips): fleet LAN fast-path pairing script (dev nodes only)
scripts/fleet-fips-pair.sh writes a deterministic /etc/fips/fips.yaml
on each of our 4 dev fleet nodes (.116/.198/.228/.253), listing the
other three as static FIPS peers over their LAN IPs (UDP 2121 / TCP
8443). Also flips `node.identity.persistent: true` so the npub stays
stable across restarts — without this the daemon rolls a new keypair
on every restart and federation invites that carried the previous
npub go stale.

The script is NOT the general deployment mechanism:
- Every archipelago install already ships fips.v0l.io as an anchor
  peer, so any node can DHT-route to any npub that has ever announced
  on the public mesh.
- Federation invites (v1.4+) carry the peer's fips_npub, so accepting
  an invite is enough for crate::fips::dial::peer_base_url(npub) to
  reach the peer through the anchor network.
- This script is a LAN fast-path optimization so intra-fleet traffic
  stays on the wire instead of bouncing through fips.v0l.io.

Usage:
  scripts/fleet-fips-pair.sh           # apply to all nodes
  scripts/fleet-fips-pair.sh --verify  # print current peer state

Verified: all 4 fleet nodes now report 3 authenticated peers each
(their 3 fleet siblings), plus fips.v0l.io in the identity cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:50:20 -04:00
DorianandClaude Opus 4.7 8b88c45262 feat(settings): per-service FIPS/Tor transport preference
Adds a user-configurable toggle for how each peer-to-peer service
reaches federated peers. Three options per service:

- Auto (default) — FIPS preferred, Tor fallback (current behavior).
- FIPS only — fail rather than fall through to Tor.
- Tor only — explicit opt-in to onion anonymity for that service.

Services covered (matching the UI rows):
- Federation — state sync, invites, peer notifications
- Peers — address/DID rotation broadcasts
- Peer Files — content catalog download/browse/preview
- Messaging — archipelago channel + mesh bridge
- Mesh File Sharing — content_ref blob fetches

Implementation:
- settings::transport — persisted struct + process-wide OnceLock handle
  (so deep call sites don't need data_dir threaded through signatures).
  On-disk file: <data_dir>/settings/transport_preferences.json; missing
  or corrupt → defaults (Auto everywhere).
- settings::transport::init() called from main.rs after config load.
- fips::dial::PeerRequest gains a .service(kind) builder; send_* checks
  the preference before choosing a transport. FIPS-only fails loudly
  when FIPS is unavailable (so users who pick it know when something
  falls back).
- Every FIPS-first migration site tags its PeerRequest with the
  matching PeerService so the toggle actually applies.
- transport.preferences + transport.set-preference RPCs added; wired
  into the dispatcher.
- neode-ui/src/views/settings/TransportPrefsCard.vue — standalone card
  with a 5-row Auto/FIPS/Tor tri-state. Not wired into Settings.vue —
  the user places components themselves (see feedback_ui_entry_points).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:44:41 -04:00
DorianandClaude Opus 4.7 dbd19006f2 feat(messaging,dwn,mesh): route peer messaging + DWN sync + blob fetch via FIPS first
Migrates the remaining Tor-direct peer call sites to PeerRequest so
FIPS is the default when the peer is federated and running the daemon:

- node_message::send_to_peer / check_peer_reachable: gain a
  fips_npub parameter. Error messages updated to reference both
  transports.
- Callers (api/rpc/network.rs, api/rpc/peers.rs, server health
  loop): look up fips_npub from federation storage by onion and
  pass it.
- mesh::send_typed_wire_via_federation: the spawned background POST
  for the /archipelago/mesh-typed endpoint now uses PeerRequest with
  federation-resolved fips_npub. Signature domain unchanged.
- api/rpc/mesh/typed_messages.rs fetch_blob_from_peer: blob URL
  rebuilt as (base_url, path_with_query) so PeerRequest can append
  the query string after swapping the host. Cap/exp/peer
  parameters are still signed over the content ref itself, so
  transport choice is invisible to the signature.
- network/dwn_sync.rs sync_with_peers: per-peer fips_npub lookup
  before sync_single_peer; health/pull/push each dial through
  PeerRequest, so any DWN peer known to federation gets FIPS.

Left Tor-only on purpose:
- api/rpc/identity/handlers.rs handle_identity_resolve_peer_onion —
  resolving TO a DID, no anchor yet.
- content.browse / preview calls to non-federated peers fall
  through to Tor naturally inside PeerRequest (no fips_npub → skip
  FIPS branch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:36:04 -04:00
DorianandClaude Opus 4.7 ba825c13a5 feat(content): route peer content fetch via FIPS first
All four content-over-peer handlers prefer FIPS when the peer is in
our federation and has advertised a FIPS npub; fall back to Tor
otherwise (unknown peers, FIPS daemon down, transient failure).

- content.handle_content_download_peer / _paid: DID-authenticated
  fetch, payment token header threaded through both transports.
- content.handle_content_browse_peer / _preview: no DID header by
  design (anonymous browse) — still benefits from FIPS when the
  peer happens to be federated.
- federation::fips_npub_for_onion: storage helper that looks up a
  peer's FIPS npub from the federation nodes file given their onion
  address. Suffix-tolerant (`abc` matches `abc.onion`).

Preserves the Tor-only path for truly unknown peers: PeerRequest
returns Err from the Tor branch instead of silently succeeding,
matching the previous behavior when the peer was unreachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:29:13 -04:00
DorianandClaude Opus 4.7 1fdb5e5cf2 feat(federation): route state-sync / invites / notifications via FIPS first
Every federation peer-to-peer call now prefers FIPS (direct ULA dial
over `fips0`, ~LAN latency) and falls back to Tor only on network
failure. Per-method ed25519 signatures are preserved on both
transports so authenticity doesn't change.

- fips::dial::PeerRequest — fluent builder that owns transport
  selection. Returns the Response plus the TransportKind that carried
  it, so handlers can log or expose which path was used.
- fips::dial::is_service_active — free-standing async probe used by
  migration sites (the transport::fips::is_available cache is keyed
  to a `&self`, not usable from static contexts).
- federation/sync.rs: sync_with_peer + deploy_to_peer drop the
  hand-rolled reqwest::Proxy dance, call PeerRequest instead.
- federation/invites.rs: notify_join takes the remote's fips_npub
  (already parsed out of the invite code since v1.4) and dials over
  FIPS when available. The "peer-joined" signature domain is
  unchanged.
- api/rpc/federation/handlers.rs: DID rotation broadcast loops over
  federated peers through PeerRequest; the per-peer result payload
  gains a `transport` field so the UI can surface mesh vs. onion.
- api/rpc/tor/mod.rs: onion-address-change propagation is now the
  most useful FIPS-first call — fips_npub is stable across onion
  rotation, so peers get the new address even when the old onion
  is already dead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:20:44 -04:00
DorianandClaude Opus 4.7 274ed008fe feat(fips): peer dialing + dedicated fips0 listener with path whitelist
Wires the FIPS transport end-to-end so peer-to-peer calls can reach
other nodes over the mesh without going through Tor:

- fips::dial — raw RFC 1035 DNS client (zero new deps) that queries the
  FIPS daemon's local resolver at 127.0.0.1:5354 for `<npub>.fips` AAAA
  records. Exposes peer_base_url(npub) → "http://[fd9d:…]:5679" plus a
  reqwest client factory for call-site migrations.
- fips::iface — parses /proc/net/if_inet6 to find the ULA address on
  `fips0`. Runs under the archipelago service user without extra caps.
- FipsTransport::is_available() — live probe of archipelago-fips and
  upstream fips.service via `systemctl is-active`, cached 10s so the
  send hot path doesn't thrash DBus.
- FipsTransport::send() — resolve npub, POST TransportMessage JSON to
  the peer's /transport/inbox. Today /transport/inbox isn't wired on
  the receive side, so call-site migrations use dial::peer_base_url
  directly against the already-signed endpoints (/rpc/v1,
  /archipelago/node-message, /content/*). The inbox handler lands as
  part of the Settings/transport work.
- server::serve_with_shutdown — takes an optional peer_addr and spawns
  a second listener bound specifically to the fips0 ULA on port 5679.
  The peer listener applies is_peer_allowed_path() — a whitelist of
  endpoints that already do per-request signature auth — and returns
  404 for everything else. Shutdown cascades to both listeners via a
  watch channel; 5s drain window preserved.
- main.rs — if fips0 has a ULA at startup, pass the peer SocketAddr to
  serve_with_shutdown; otherwise run the main listener only.

Security: the peer listener is bound to the fips0 ULA directly, not
wildcard, so it's unreachable from WAN IPv6. The path whitelist limits
exposure to endpoints whose handlers verify ed25519 signatures or
federation DID headers server-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:12:39 -04:00
DorianandClaude Opus 4.7 6b42bfd503 fix(fips): fall back to upstream daemon npub on legacy/dev nodes
Nodes without a seed-derived FIPS key (legacy deploys, fresh pre-onboarding
installs) were reporting "Awaiting seed" in the dashboard even when the
upstream fips.service was running — status.npub was None unless
/data/identity/fips_key.pub existed.

- fips/service.rs: new read_upstream_npub() reads /etc/fips/fips.pub
  (bech32 text or raw 32 bytes) from the debian package.
- fips/mod.rs: FipsStatus::current() prefers the seed-derived npub,
  falls back to the upstream key. service_active is now TRUE if either
  archipelago-fips.service OR upstream fips.service is active; adds
  upstream_service_state to the status payload.
- fips/update.rs: resolve the upstream default branch from the GitHub
  repo API (jmcorgan/fips is on `master`, not `main`) instead of
  hardcoding — future repo rename just works.
- network/router.rs + api/rpc/router.rs: diagnostics gain wifi_ssid from
  `nmcli -t device` so the Network card can show the connected SSID.
- UI: Home.vue adds a FIPS row to the Local Network card; Server.vue
  mounts the new FipsNetworkCard and shows SSID + FIPS Mesh rows;
  HomeNetworkCard.vue removed (superseded by the inline rows).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:42:56 -04:00
DorianandClaude Opus 4.7 30a7f73ead feat(fips): integrate jmcorgan/fips as preferred non-Tor transport + v1.4.0
Bakes the FIPS (Free Internetworking Peering System) mesh daemon into
the node stack, supervised by archipelago alongside Tor. Runs as a
system service, identity derives from the same BIP-39 master seed, and
user-triggered updates track upstream main.

Identity
  seed.rs: new HKDF label archipelago/fips/secp256k1/v1 → dedicated
  secp256k1 key, distinct from the Nostr-node key for crypto isolation
  but still seed-recoverable
  identity.rs: writes fips_key[.pub] to /data/identity on onboarding,
  chmod 0600; fips_key_exists / load_fips_keys / fips_npub accessors

Transport
  TransportKind::Fips=3 inserted between LAN and Tor (Tor bumps to 4)
  → router prefers FIPS over Tor for all peer traffic
  PeerRecord gains fips_npub + last_fips fields (serde(default) for
  backward-compat with older nodes)
  transport/fips.rs: NodeTransport stub, reports unavailable until the
  daemon is live so router falls through to Tor cleanly

Federation invites
  FederatedNode and FederationInvite carry optional fips_npub
  create_invite / accept_invite / peer-joined callback thread it end
  to end; signature domain deliberately unchanged — FIPS Noise does
  its own session auth, so the unsigned hint only affects path
  selection

crate::fips
  config.rs: renders /etc/fips/fips.yaml and sudo-installs key material
  service.rs: systemctl status/activate/restart/mask wrappers
  update.rs: GitHub API check against upstream main; apply stubbed
  until per-commit .deb artefact source is decided

RPC + dashboard
  fips.status / fips.check-update / fips.apply-update / fips.install /
  fips.restart registered in dispatcher
  HomeNetworkCard.vue shipped standalone (unmounted — place in Home.vue
  when ready); shows state pill, version, FIPS npub, update button,
  activate button when key is present but service is down

ISO + systemd
  archipelago-fips.service: conditional on key presence, masked by
  default — backend unmasks after onboarding writes the key
  build-auto-installer-iso.sh: multi-stage Dockerfile builds the FIPS
  .deb from jmcorgan/fips main (fail-loud), COPYs it into rootfs, apt
  installs it so trixie resolves deps; unit copied + masked

Version bump: 1.3.5 → 1.4.0

Tests: 33 new/updated passing (seed, identity, transport, federation,
fips module, transport::fips).

Known gaps: fips.apply-update returns a clear stub error until
upstream publishes per-commit .deb artefacts; HomeNetworkCard is not
mounted in Home.vue by default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:57:51 -04:00
DorianandClaude Opus 4.7 46350f48b6 chore(fmt): rustfmt drift cleanup across misc crates
Pure formatter output — no semantic changes. Sweeping these into their
own commit so the FIPS integration diff that follows stays scoped to
the actual feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:57:14 -04:00
DorianandClaude Opus 4.7 7655a5971b fix(ci): QEMU boot test ignores trailing numeric arg + enforces timeout
The CI workflow calls `test-iso-qemu.sh "$ISO" 120`. The old arg parser
had a `case *) ISO=...` fallthrough that silently let the second
positional `120` overwrite ISO, so QEMU went looking for a file literally
named "120". That's the "failed step" the user was seeing on recent ISO
runs — the rest of the job succeeded because the QEMU step has
`continue-on-error: true`.

Changes:
- Treat `--timeout=N` or a bare numeric first-match as a CI timeout in
  seconds; the original ISO path still wins the positional.
- When a timeout is set, force `--nographic` (CI has no DISPLAY anyway)
  and wrap the QEMU invocation in coreutils' `timeout` so the script
  always returns instead of hanging.
- After termination (or timeout), grep the serial log for well-known
  systemd/live-boot markers. Pass if the kernel reached userspace, fail
  if no marker appeared within the window — useful signal rather than
  the previous "did the VM shut itself off" proxy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:36:20 -04:00
Dorian f5c581a725 ci: bump build marker to kick off ISO workflow 2026-04-18 18:32:02 -04:00
DorianandClaude Opus 4.7 b614c5c694 chore(ci): rustfmt + clippy clean-up to unblock the Rust CI job
The .github/workflows/ci.yml Rust job runs cargo fmt --check, clippy
with -D warnings, and tests. All three were failing. This commit:

- Applies rustfmt across the tree (the bulk of the diff — untouched
  since the last toolchain bump, so a wide sweep was unavoidable).
- Fixes the correctness-level clippy errors:
    container/bitcoin_simulator.rs wildcard-in-or-pattern
    container/manifest.rs from_str rename to parse (reserved name)
    container/podman_client.rs .get(0) -> .first()
    container/runtime.rs manual += collapse
    archipelago/src/constants.rs doc-comment → module-doc
    api/rpc/package/install.rs stray /// comment above a non-item
    container/docker_packages.rs redundant field init
    streaming/advertisement.rs missing Metric import in tests
    tests/orchestration_tests.rs `vec!` in non-Vec contexts
    mesh/listener/dispatch.rs unused store_plain_message import
    api/rpc/tor/mod.rs and mesh/steganography.rs: push-after-new → vec!
- Quiets wide legacy surfaces with crate-level allows in main.rs for
  stylistic lints (too_many_arguments, type_complexity, doc indent,
  enum variant prefix, wildcard-in-or, assertions-on-constants,
  drop_non_drop, unused_io_amount, ptr_arg) — these fired in dozens
  of places with no correctness payoff and have been churning every
  toolchain bump.
- Tags intentional-dead-code helpers: wallet/ and streaming/ modules
  are WIP, mesh::send_chunked_payload and DM_V1_MARKER are kept for
  rollback compatibility, vpn::get_nostr_vpn_status is surface-area
  for a not-yet-landed RPC.

cargo fmt --check, cargo clippy --all-targets --all-features
-- -D warnings, and cargo test --all-features now all pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:23:46 -04:00
DorianandClaude Opus 4.7 3a52c766ac fix(mesh): single-flight send + spinner + async federation POST
Root cause of the "every bubble shows twice" complaint after the prior
dedup fix: the frontend was firing mesh.send twice per user action. A
held/repeating Enter key on the input fires a keydown per repeat, and
handleSendMessage didn't guard on mesh.sending, so both calls queued
through the store's sendQueue and both executed against the same
contact_id (backend logs show two mesh.send RPCs 13ms apart, same text).
That's why sender and receiver both saw doubles — the envelope actually
was transmitted twice.

Mesh.vue: handleSendMessage now early-returns if mesh.sending or
sendingArch is already set. Send button replaces the `...` placeholder
with a proper spinning ring (`.mesh-send-spinner`) so the held-Enter case
stops looking like the app is ignoring the user.

mesh/mod.rs: send_typed_wire_via_federation no longer blocks on the Tor
POST. Sent MeshMessage is recorded synchronously (UI bubble appears
instantly); the HTTP goes in tokio::spawn. Tor circuit setup was the
1–5s lag the user was seeing on every send to a federation peer. Delivery
failure still shows as `delivered: false` via the read-receipt path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:57:11 -04:00
DorianandClaude Opus 4.7 7e4fed7967 fix(mesh): dedup across transports + persistent radio-contact blocklist
Two mesh fixes bundled so the deploy lands them together:

Doubled messages (radio + federation): dedup at store_message now runs
a third cross-transport check keyed on (sender_seq, plaintext, 120s).
The existing (sender_pubkey, sender_seq) match missed the common case
where the same envelope arrives via LoRa radio (sender_pubkey looked
up from the firmware key) and again via Tor federation (sender_pubkey
= archipelago ed25519), because the two lookups disagree. The new
cross-transport match closes that gap without loosening legacy paths.

Stale contacts after clear-all: meshcore's on-device contact table is
persistent and reads back into peers on the next refresh_contacts, so
the previous "nuclear" clear wiped app state for a few seconds before
the old rows reappeared. New persistent `radio_contact_blocklist`
(mesh-ignored-radio-contacts.json) captures the pubkeys present at
clear-time; `refresh_contacts` filters them on read and the filter
survives restart. Federation-synthetic peers are excluded from the
snapshot so the list rebuilds normally on the next gossip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:02:34 -04:00
DorianandClaude Opus 4.6 cd8763f468 fix(mesh): nuclear clear-all wipes state files + shared secrets
Clear All now deletes messages.json, mesh-contacts.json, sessions.json,
mesh-outbox.json and clears shared secrets for a truly clean slate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 12:23:51 -04:00
DorianandClaude Opus 4.6 dbafa12596 fix(mesh): correct rpcClient.call() usage in clear-all button
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 12:00:32 -04:00
DorianandClaude Opus 4.6 1736f6f99e feat(mesh): server name in adverts + clear-all button + CI fix
- Mesh adverts now use the node's configured server name (e.g. "ThinkPad",
  "Arch Dev") instead of DID key fragments ("Archy-z6MkmkSB")
- Added mesh.clear-all RPC to reset peers, messages, contacts, and history
- Added "Clear All" button in Mesh UI peers panel
- Both glibc and musl builds verified

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 11:53:06 -04:00
DorianandClaude Opus 4.6 0c02d06a66 feat: deploy-to-target supports .253 + mesh/federation/VPN updates
- Add deploy_secondary() function for deploying to multiple LAN nodes
- --both now deploys to .198 and .253 (previously .198 only)
- Fleet deploy updated for 3 LAN nodes
- Mesh DM fixes: protocol frame format, DM-via-channel routing
- Federation pending requests, discover modal
- VPN status UI improvements
- Image versions and container specs updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 11:07:08 -04:00
DorianandClaude Opus 4.6 6760d11a57 feat(mesh): Telegram primitives pass + attachment transport router
Bundles the Phase 2b/3/4/5 work that accumulated across prior sessions
and the new attachment chunking router from this session. Everything
ships in one shot so the full mesh surface stays coherent on-wire.

Telegram primitives (variants 13–18, 20–22):
- Reply / Reaction / ReadReceipt / Forward / Edit / Delete
- Presence heartbeat + last-seen tracking
- ChannelInvite + ContactCard payload types
- MessageKey (sender_pubkey, sender_seq) as cross-transport identity
- Action menu, reply banner, edit banner, tombstones, (edited) marker
- Debounced auto-read-receipts on scroll + message arrival

Activated prototypes (Phase 4):
- PsbtHash send RPC
- Contacts CRUD (in-memory alias/notes/pinned/blocked)
- Outbox 📤 badge, rotate-prekeys button
- Chunked send fallback (MCIIXXTT framing) as auto-failover inside
  send_typed_wire when a typed wire exceeds the LoRa per-frame budget

Unified inbox (Phase 1):
- conversations.list + conversations.messages RPCs (UI collapse deferred)

Attachment transport router (new this session):
- ContentInline variant 23 + ContentInlinePayload carrying file bytes
  directly in the envelope for small files with no Tor path
- mesh.send-content-inline RPC — mirrors to local BlobStore, rides
  send_typed_wire which auto-chunks over MCIIXXTT framing (~2.3 KB cap)
- mesh.transport-advice RPC as single source of truth for tier
  decisions: auto-mesh / choose / tor-only / impossible
- Receive arm writes inline bytes to local BlobStore so the existing
  content_ref card renderer handles both transports uniformly
- MeshState.blob_store field + order-independent propagation from
  RpcHandler::set_blob_store / set_mesh_service
- Frontend handleAttachFile calls advice first, branches into silent
  auto-send, transport-chooser modal, Tor-only path, or red error
- Transport modal with 📡 mesh / 🧅 Tor options + ETA + disabled
  state when peer has no Tor reachability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:40:19 -04:00
DorianandClaude Opus 4.6 5616bb74e6 fix(mesh): add txt_type + timestamp to CMD_SEND_CHANNEL_TXT_MSG frame
MeshCore firmware frame for cmd 0x03 is
`[cmd][txt_type][channel][timestamp_le32][text]`, not `[cmd][channel][text]`.
Missing txt_type + timestamp caused every channel broadcast to come
back with ERR_UNSUPPORTED, which broke the DM-via-channel path
entirely (nothing was reaching the radio). Bring the frame into
spec — verified against meshcore-dev/MeshCore docs/companion_protocol.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:22:20 -04:00
DorianandClaude Opus 4.6 164f938982 fix(mesh): route DM-via-channel on channel 0 (channel 1 unsupported)
Firmware rejected send_channel_text(1, ...) with "Unsupported command"
because channel 1 isn't configured on the device. Revert to channel 0
for the DM wrapper — the 0xD1 marker + dest_prefix header still
disambiguates DMs from plain public-channel text. Also revert
Mesh.vue publicChannel back to index 0 so user-typed broadcasts
target the same (only) working channel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:40:28 -04:00
DorianandClaude Opus 4.6 d514e0e5e4 fix(mesh): DM-via-channel tunnel + disable presence spam
Meshcore direct unicast silently drops between our two Archy nodes
(firmware reports flood sends with resp_code=6 but nothing arrives).
Wrap DMs as channel-1 broadcasts with a [0xD1][dest_prefix(6)][inner]
header; receivers filter by prefix and dispatch the inner payload
through the existing typed/base64/chunk ladder. Shrink chunk body to
125B so the wrapper still fits the 160B LoRa budget. Auto-heal
routing: CMD_RESET_PATH (0x0D) any type-1 contact with path_len=0 on
refresh so floods take over. send_text now returns the firmware's
flood/direct mode flag for diagnostics.

Disable the 120s presence heartbeat broadcaster — its CBOR payload
was being re-echoed as plaintext by the shared repeater, spamming
every visible node with garbled "Archy-…: av�…fstatusfonline…"
messages on channel 0. mesh.broadcast-presence RPC stays registered
but no longer transmits. Re-enable only once presence moves off the
shared broadcast path.

Also: MeshState.cmd_tx behind RwLock so stop()→start() cycles don't
fail with "command channel already consumed"; MeshService.send_cmd
helper; drop_message_by_id for control envelopes that shouldn't
appear as Sent bubbles; self_advert_name reflected into MeshStatus
after set; path_len/flags parsed out of RESP_CONTACT.

Frontend: unified inbox merges mesh peers with federation nodes by
DID/pubkey/name; hide presence/read_receipt/edit/channel_invite/
contact_card from chat stream; publicChannel index → 1 to match the
new DM-via-channel routing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:24:27 -04:00
DorianandClaude Opus 4.6 bdacc06a2b feat(mesh-ui): Telegram-style action menu + Forward/Edit/Delete/ReadReceipt/rotate/outbox
* Replaces click-anywhere-on-bubble with a tiny ⋯ trigger in the meta row
  that fades in on hover (always visible on touch devices). Outside-click
  closes the menu, bubble gets a `menu-open` class so the trigger stays lit.
* Action menu gains Forward (any message) + Edit + Delete (own messages
  only, delete is red). Reaction spinner + reply preview upgraded to handle
  typed targets (attachment/invoice/location/alert) via summarizeForPreview.
* Pending-edit banner with ✎ icon mirrors the reply banner; Send flushes as
  mesh.edit-message when pendingEdit is set.
* Forwarded bubbles render "↪ Forwarded from {orig_name}" header; tombstone
  + (edited) markers; pending-reply close button upsized (28px, red hover).
* Scroll + message-arrival watcher fires a debounced 400ms read receipt
  with per-peer seq dedup so we never double-ack.
* Chat header: ⟲ rotate-prekeys button next to the shield badge; 📤 outbox
  count when mesh.outbox reports queued messages. Blob-store test widget
  removed and chat list now sorts by most-recent message timestamp.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:50:08 -04:00
DorianandClaude Opus 4.6 8ef7af985d feat(mesh): Phase 1/2b/4/5 primitives — ReadReceipt/Forward/Edit/Delete/Presence/Contacts/ChannelInvite + chunked send + unified inbox RPCs
Adds every remaining wire variant and RPC needed to finish the Telegram-quality
mesh plan in a single pass:

* Variants 15 ReadReceipt, 16 Forward, 17 Edit, 18 Delete, 20 Presence,
  21 ChannelInvite; plus MeshMessageType::ContactCard(22) cleanup (was
  enum-only, now wired through from_u8/label/from_label).
* MessageType::from_label() as the inverse of label() — used by the Forward
  path to re-encode a stored typed body back through its original variant.
* RPCs: mesh.send-psbt (variant 3 was previously enum-only),
  mesh.send-read-receipt, mesh.forward-message, mesh.edit-message,
  mesh.delete-message, mesh.broadcast-presence, mesh.presence-list,
  mesh.contacts-list, mesh.contacts-save, mesh.contacts-block,
  mesh.send-channel-invite, conversations.list, conversations.messages.
* MeshState gains presence (pubkey → status+timestamps) and contacts
  (pubkey → ContactEntry{alias,notes,pinned,blocked}) in-memory stores.
* MeshService gains find_message_by_id (Forward lookup), apply_local_edit /
  apply_local_delete (optimistic local echo), and send_chunked_payload — an
  MC-framed base64 splitter that fires as a fallback inside send_typed_wire
  when wire > MAX_MESSAGE_LEN and no federation path is known. Reuses the
  existing receive-side reassembly in listener/decode.rs.
* Receive dispatch arms for PsbtHash, Presence, ChannelInvite, ReadReceipt
  (rolls forward `delivered` flag on own-Sent ≤ seq for that peer), Forward,
  Edit, Delete. Edit/Delete guard against cross-peer tampering by matching
  the target MessageKey pubkey against the sender's advertised pubkey_hex.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:24:05 -04:00
DorianandClaude Opus 4.6 002032b7da fix(mesh): resolve ContentRef peer via DID + name-match fallback
Mesh peer pubkeys (LoRa advert ed25519) differ from federation node
pubkeys (archipelago identity), so matching on pubkey always missed
and attachments >160B had no transport. Match on master DID instead;
also accept an explicit peer_onion override from the frontend, which
resolves the peer by display name against federation.list-nodes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:13:36 -04:00
DorianandClaude Opus 4.6 bc3729d99f fix(mesh): route ContentRef over federation when >160B
mesh.send-content was failing with "Message too large for LoRa: 624
bytes (max 160)" because a single ContentRef envelope (cid + onion +
cap_token + thumb) dwarfs a LoRa frame. Add a federation Tor fallback:

- New POST /archipelago/mesh-typed endpoint accepts
  {from_pubkey, typed_envelope_b64, signature}, verifies ed25519 over
  the raw wire bytes, and injects the decoded envelope into MeshState
  via a new MeshService::inject_typed_from_federation helper. This
  shares the same dispatch match as LoRa receives via a new pub(crate)
  handle_typed_envelope_direct extracted from handle_typed_message.
- MeshService::send_typed_wire_via_federation POSTs the signed wire to
  a peer's onion over TOR_SOCKS_PROXY and records a local Sent record.
- handle_mesh_send_content looks up the peer's onion in federation
  storage and routes via federation when available, falling back to
  LoRa only when no federation presence is known (still fails on
  oversized — chunking is Phase 4).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:37:48 -04:00
DorianandClaude Opus 4.6 649433b7fd feat(mesh-ui): reply banner + inline reaction chips (Phase 2a)
Tap a bubble to open an action menu with Reply + 6 quick reactions.
Reply stashes the target MessageKey and flips the Send button to
"Reply" mode, routing through mesh.send-reply. Reactions call
mesh.send-reaction immediately and render as chips under the target
bubble, collapsed per emoji with a count and self-highlight. Reaction
messages are filtered out of the main chat stream so they don't create
standalone bubbles. Reply bubbles show a "↳ quoted snippet" header
when the target is still in the local window.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:19:30 -04:00
DorianandClaude Opus 4.6 a360f90647 feat(mesh): MessageKey + Reply/Reaction variants and sender seq (Phase 2a)
Per-target outbound seq counter on MeshState allocates a monotonic seq
before each typed envelope is encoded; send_typed_wire +
send_channel_typed_wire record it (alongside our own pubkey_hex) on the
Sent MeshMessage so the local store carries the same MessageKey the
receiver will see. TypedEnvelope.with_seq lets the RPC layer stamp the
seq AFTER signing (signature covers t/v/ts only).

New MessageKey struct pairs sender_pubkey+sender_seq as the stable
cross-transport identity. Adds variants 13 Reply and 14 Reaction with
ReplyPayload {target, text} and ReactionPayload {target, emoji}, plus
mesh.send-reply / mesh.send-reaction RPCs and receive-side dispatch
arms that store the payload json for the UI to index.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:19:30 -04:00
DorianandClaude Opus 4.6 ab927afbaa feat(mesh-ui): receive share-to-mesh postMessage + pending attachment
App.vue listens for postMessage({type:'share-to-mesh',cid,...}) from
marketplace app iframes, stashes the payload in sessionStorage, and
routes to /mesh. Mesh.vue reads the stash on mount (and on a synthetic
'archipelago:share-to-mesh' event when already on the view), showing a
pending-attachment banner in the compose area. Send becomes Share and
flushes the CID via mesh.send-content with the input text as caption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:58:04 -04:00
DorianandClaude Opus 4.6 f94f5da6ee feat(mesh): /api/share-to-mesh iframe intent endpoint (Phase 3c)
Marketplace app iframes (Penpot, Gitea, IndeedHub, ...) can POST a file
to /api/share-to-mesh and postMessage the returned CID to the parent
window. The endpoint mirrors /api/blob's body format but adds CORS for
the requesting app origin (any port on host_ip) so proxied apps can
reach it with credentials:'include'. Session cookie is still the primary
auth; the origin check is a sanity guard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:58:03 -04:00
DorianandClaude Opus 4.6 019144903c feat(mesh-ui): attach button + ContentRef card in chat
Compose row gains a 📎 attach button that uploads the file via /api/blob
and calls mesh.send-content for the selected peer. Received content_ref
bubbles render as a caption+filename card with either an inline image
preview or a Download button that calls mesh.fetch-content and swaps in
the returned local_url.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:10:59 -04:00
DorianandClaude Opus 4.6 dce5084451 feat(mesh): ContentRef typed variant + send/fetch RPCs (Phase 3b)
Adds attachment sharing over the mesh: a ContentRef envelope (variant 19)
carries the blob CID, size, mime, optional thumb/caption, and a per-peer
HMAC capability URL so the recipient fetches the full blob out-of-band via
`GET {sender_onion}/blob/{cid}?cap=..&exp=..&peer=..`. BlobStore is shared
from ApiHandler into RpcHandler so mesh.send-content and mesh.fetch-content
(reqwest via TOR_SOCKS_PROXY) hit the same store and cap_key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:10:49 -04:00
DorianandClaude Opus 4.6 e8a729a4c7 feat(blobs): HTTP upload+download routes and UI round-trip widget
Plumbs the BlobStore from blobs.rs into ApiHandler. The HMAC capability
key is derived from the node's Ed25519 signing key via a domain-separated
SHA-256 — rotating the identity rotates every outstanding cap (intentional
so a replaced node cannot honour old tokens).

New routes (added to nginx config in both server blocks):
- POST /api/blob — session-authenticated raw upload, returns
  {cid, size, mime, filename, self_test_url}. The self_test_url is a
  pre-signed cap pointing at the local node so the UI can verify the
  round-trip without needing a peer pubkey.
- GET /blob/<cid>?cap=<hex>&exp=<epoch>&peer=<pubkey> — peer-facing,
  HMAC-verified in constant time, expiry-checked, then streams bytes.

Mesh.vue gets a minimal "Attachment test (blob store)" section: file
picker → upload → cid display → "Verify round-trip" and "Open in new
tab" buttons. This validates Phase 3a end-to-end before we layer the
ContentRef typed envelope variant on top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:48:48 -04:00
DorianandClaude Opus 4.6 77eb1b907b feat(blobs): content-addressed blob store scaffolding
Adds core/archipelago/src/blobs.rs: a SHA-256 content-addressed store
that writes bytes to ${data_dir}/blobs/<cid> with a sibling <cid>.meta
JSON file (mime, filename, size, created_at, optional tiny thumbnail).

BlobStore::put is idempotent, max 64 MiB per blob, and issues HMAC-SHA256
capability tokens scoped to (cid, peer_pubkey_hex, expiry_epoch). Tokens
are verified in constant time and rejected on expiry. This is the
foundation piece for the mesh ContentRef typed envelope — the /blob/<cid>
HTTP route and ContentRef variant will land in a follow-up increment
once the HMAC key is plumbed from node identity.

No consumer yet, so the module compiles with dead_code warnings; these
will clear when the HTTP handler and ApiHandler state wiring land next.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:29:44 -04:00
DorianandClaude Opus 4.6 de1b25cc78 feat(mesh): MessageKey foundation and debug-dump RPC
Adds sender_pubkey + sender_seq fields to MeshMessage so received
messages carry a stable cross-transport identity: (sender_pubkey,
sender_seq) pair. This is the foundation for the upcoming reply,
reaction, edit, and read-receipt variants — they need to target a
message by an ID that is meaningful on every node, not just locally.

Receive-side population lives in dispatch.rs::store_typed_message,
which now looks up the peer's pubkey_hex and copies envelope.seq from
the decoded TypedEnvelope. Sent-side population will land when we
plumb a per-node monotonic seq counter through the RPC layer.

Also adds mesh.debug-dump: a full in-memory state snapshot returning
peers, messages, status, shared-secret peer ids, encrypt_relay flag,
and stego mode — intended for smoke tests and bug investigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:18:01 -04:00
DorianandClaude Opus 4.6 d865136631 fix(rpc-client): 15min timeout on package.install for multi-GB stacks
IndeedHub, Bitcoin, and Penpot installs routinely exceed the default
RPC timeout on first pull. Bump package.install specifically to
900s so the frontend doesn't drop the request while the backend is
still downloading images.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:01:31 -04:00
DorianandClaude Opus 4.6 36cd3f4e7d feat(mesh-ui): render tx/lightning relay typed messages and skip self-send
Adds renderers for tx_relay, tx_relay_response, tx_confirmation,
lightning_relay, and lightning_relay_response message types so these
appear as rich cards in the chat stream. sendArchMessage now looks up
our own onion via getTorAddress and skips federation peers that match,
preventing the duplicate "echoed back to self" message we were seeing
on single-node test federations. Empty-federation error message is
also clearer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:01:21 -04:00
DorianandClaude Opus 4.6 3ed9243c50 feat(mesh): rich typed Sent records and echo dedup
Adds message_type + typed_payload (JSON) to MeshMessage so the UI can
render invoice/alert/coordinate/tx/lightning messages as structured
cards in both directions instead of showing raw wire bytes on the
Sent side. RPC handlers now route through send_typed_wire /
send_channel_typed_wire which transmit the binary envelope directly
(no utf8_lossy corruption) and record a rich Sent MeshMessage.

Also: store_message deduplicates echo-back doubles (20-msg lookback,
30s window), from_name is plumbed through the federation Incoming
path, and peer_dest_prefix / send_raw_payload are factored out of
send_message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:01:10 -04:00
Dorian 18284e1592 chore: remove CLAUDE.md and stale config files 2026-04-12 12:11:00 -04:00
DorianandClaude Opus 4.6 29ff413559 fix: 23.182.128.160:3000 is primary registry everywhere
Swapped all registry references: image-versions.sh, marketplaceData.ts,
curatedApps.ts, catalog.json. git.tx1138.com is now fallback only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:43:01 -04:00
DorianandClaude Opus 4.6 0f71013952 fix: registry fallback skips dead primary, WireGuard first-boot, Gitea port 3001
Registry fallback now only tries DIFFERENT registries (skips original
that already failed). 120s timeout per fallback attempt. WireGuard
keys generated on unbundled first-boot. Gitea ROOT_URL uses port 3001.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:40:52 -04:00
DorianandClaude Opus 4.6 98b570679d fix: gitea direct port access, push to registry, no PROXY_APPS
Gitea image pushed to Archipelago registry. PROXY_APPS stays empty
per user preference - direct port only. Gitea config uses
INSTALL_LOCK + dark theme.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:52:15 -04:00
DorianandClaude Opus 4.6 d378d94a05 fix: gitea always uses nginx proxy for iframe compatibility
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:46:07 -04:00
DorianandClaude Opus 4.6 a1f70f9c18 feat: IndeedHub multi-container stack installer
Installs all 7 containers (postgres, redis, minio, relay, api,
ffmpeg, frontend) on indeedhub-net with proper env vars and volumes.
Fixes pull timeout to cover stderr reader. Catalog registry set to
23.182.128.160:3000.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:37:18 -04:00
DorianandClaude Opus 4.6 2d11f262dd fix: pull timeout covers entire operation, swap registry priority
Timeout now wraps stderr reader + wait (was only wrapping wait, so
hung pulls were never killed). 23.182.128.160:3000 is now primary
registry since git.tx1138.com is unreachable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:18:24 -04:00
DorianandClaude Opus 4.6 877b3e4168 fix: image pull timeout actually triggers fallback
Previous timeout used ExitStatus::default() which is success on Linux,
so the fallback never triggered. Now properly kills process, awaits
exit, and forces fallback path on timeout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:08:22 -04:00
DorianandClaude Opus 4.6 b0656b068f fix: 60s timeout on image pull, gitea port 3001, wireguard first-boot
Image pulls now timeout after 60s and fall through to dynamic registry
fallback instead of hanging forever when primary is unreachable.
Gitea external port corrected to 3001. WireGuard key generation
added to first-boot for fresh installs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:00:06 -04:00
DorianandClaude Opus 4.6 f586cbc499 fix: ISO install - fallback registry, filebrowser noauth, registries
1. registries.conf includes docker.io search + fallback 23.182.128.160
2. First-boot pull_with_fallback() tries primary then fallback registry
3. FileBrowser created with noauth config on persistent volume
4. Backend dynamic registries.json pre-created in ISO
5. Filebrowser password secret created for token flow

Fixes: apps stuck at 0% download, filebrowser not working, dynamic
catalog not loading on fresh installs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:06:12 -04:00
DorianandClaude Opus 4.6 3078d4b69e feat: dynamic app catalog, Gitea app polish, registry sync
App catalog served from Gitea repos (app-catalog) with 35 apps.
Nodes fetch catalog dynamically — new apps appear without frontend
rebuild. Test app added and removed to verify pipeline.

Gitea manifest updated with internal_port/nginx_proxy for iframe.
Updated catalog.json, nginx configs, app session configs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 08:20:18 -04:00
DorianandClaude Opus 4.6 1147dbd882 feat: dynamic container registry with fallback
Configurable registry list persisted to config/registries.json.
Image pulls try all registries in priority order — if primary fails,
fallback registries are attempted automatically. RPC endpoints:
registry.list, registry.add, registry.remove, registry.test.

Replaces hardcoded fallback logic with extensible registry system.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 08:09:14 -04:00
DorianandClaude Opus 4.6 e08c0d0b9f fix: Gitea iframe uses proxy path, not direct port
Added gitea to PROXY_APPS so it always routes through /app/gitea/
nginx proxy (same origin as parent page). Fixes X-Frame-Options
SAMEORIGIN rejection when loading via direct port.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 07:05:32 -04:00
DorianandClaude Opus 4.6 a97128bfd2 feat: fallback container registry at 23.182.128.160:3000
When primary registry (git.tx1138.com) fails, image pull automatically
retries from Gitea registry at 23.182.128.160:3000. Tags pulled image
with original name so install continues seamlessly. Gitea added as
external app in app session config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 06:38:34 -04:00
DorianandClaude Opus 4.6 6cd67df575 feat: add Gitea as Archipelago app with container registry
Gitea app manifest, marketplace entry, nginx proxy, app session config,
image version, package install config. Container registry enabled on
Gitea for fallback image hosting. Trusted registries updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 06:10:56 -04:00
DorianandClaude Opus 4.6 8d8130109d fix: video/audio streaming instead of blob download
Videos and audio now stream directly via URL with auth token query
param instead of downloading entire file into a JS blob. Fixes
playback of large videos (170MB+ was timing out). Images still use
blob URLs. streamUrl() added to filebrowser client and cloud store.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:45:42 -04:00
DorianandClaude Opus 4.6 485c4d5d98 fix: cloud folder views use same background as cloud main tab
Cloud subpages (Music, Photos, etc.) now show bg-cloud.jpg instead
of falling through to bg-home.jpg.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:27:02 -04:00
DorianandClaude Opus 4.6 4cab118cb2 fix: paid video preview plays in lightbox, better error messages
Video thumbnail in card is pointer-events-none so clicks pass through
to the play handler. Better error messages when preview fetch fails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:59:55 -04:00
DorianandClaude Opus 4.6 cd08fd3c9e fix: filebrowser auth cookie path for video/audio playback
Cookie was scoped to /app/filebrowser but Cloud page reads it from
/dashboard/cloud — cookie was invisible. Changed to path=/ so the
auth token is accessible from any page for fetchBlobUrl calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:54:16 -04:00
DorianandClaude Opus 4.6 aae3391ce8 fix: fullscreen video in media lightbox
Video fills entire viewport with no padding/border-radius. Double-click
toggles native fullscreen. Reduced padding for all media types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:38:36 -04:00
DorianandClaude Opus 4.6 bb14490fb7 feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated
apps, mobile gamepad enhancements, content HTTP handler, package
install config updates, health monitor tweaks, shared content UI,
container specs and image version updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:11:41 -04:00
DorianandClaude Opus 4.6 24f122f35a fix: allow Fedimint install without local Bitcoin node
Fedimint can use a remote Bitcoin RPC (e.g., over Tailscale or Tor).
Dependency check now logs info instead of blocking installation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:00:06 -04:00
DorianandClaude Opus 4.6 8d82666c82 fix: beautiful media lightbox, filebrowser noauth, deploy script
MediaLightbox: full glassmorphic redesign with dark backdrop, smooth
transitions, proper video/audio/image support. FileBrowser: noauth
config on persistent volume. Deploy script: fixed sed quoting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:49:01 -04:00
DorianandClaude Opus 4.6 2c98bdd19d feat: streaming ecash payments + media playback overhaul
Cashu ecash protocol (BDHKE blind signatures, cashuA token format,
mint HTTP client) replacing the stub wallet. TollGate-inspired streaming
data payment system with step-based pricing (bytes/time/requests),
session management with incremental top-ups, usage metering, and
Nostr kind 10021 service advertisements.

13 new streaming.* RPC endpoints. Content server now verifies real
Cashu tokens. Profits tracking includes streaming revenue.

Frontend: GlobalAudioPlayer (persistent bottom bar across all pages),
video lightbox with full controls, audio in MediaLightbox, free file
previews (no blur), paid 10% audio/video previews, separated play
vs download buttons in PeerFiles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:31:28 -04:00
DorianandClaude Opus 4.6 0995aa1033 fix: move resolver directives into server blocks in external-app-proxies
Prevents duplicate resolver directive error when both
nginx-archipelago.conf and external-app-proxies.conf are loaded
at http context level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:57:58 -04:00
DorianandClaude Opus 4.6 abf6ca000d feat: botfights container app + mobile gamepad + indeedhub fixes
- Promote botfights from external proxy to container app (port 9100)
- Add /app/botfights/ nginx proxy rules (HTTP + HTTPS)
- Add ARCHY_EMBEDDED env var to botfights container config
- Add BOTFIGHTS_IMAGE to image-versions.sh
- Add mobile gamepad overlay (D-pad + A/B + START/SELECT) for botfights
  arcade mode, sends postMessage arcade-input to iframe
- Remove old /ext/botfights/ and port 8901 external proxy blocks
- IndeeHub: add post-install nginx patching for NIP-07 provider injection
- IndeeHub: fix docker image references to registry (was localhost)
- IndeeHub: update port 7777 -> 7778

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:47:54 -04:00
DorianandClaude Opus 4.6 c421fdb064 feat: companion app improvements and intro overlay
Android: NES controller/keyboard enhancements, WebSocket reconnect,
portrait mode. Backend: remote input handler updates. UI: companion
intro overlay on dashboard, relay improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 20:01:14 +01:00
DorianandClaude Opus 4.6 918fec0af7 feat: promote botfights from web-only to container app
Convert botfights from external link to real container app on port 9100.
Add manifest, update marketplace/discover/kiosk/session configs, switch
registry URLs to git.tx1138.com.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 20:01:14 +01:00
DorianandClaude Opus 4.6 548107eb8b feat: add botfights app config and update container registry
- Add git.tx1138.com to trusted registries (replaces old 80.71.235.15)
- Add botfights app config: port 9100, data volume, JWT_SECRET auto-gen, fight loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 20:01:14 +01:00
DorianandClaude Opus 4.6 a3217a9db4 refactor: remove app container creation from deploy script
Apps are now installed exclusively via the Marketplace UI.
The deploy script handles code sync, backend/frontend builds,
and service restarts only. The legacy container creation code
is wrapped in `if false` to preserve git history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:16:31 -04:00
DorianandClaude Opus 4.6 8ffb10d7e0 fix: ISO build freshness, WireGuard startup, VPN status, kiosk remote doubling
- ISO builder: run npm ci before npm run build to prevent stale UI artifacts
- Unbundled ISO: clean container-images dir to prevent bundled tars leaking
- WireGuard: use After=network.target instead of network-online.target for
  faster wg0 startup on install
- VPN status: check actual nvpn0 interface instead of config tunnel_ip to
  prevent NostrVPN from showing standalone WireGuard IP
- ContainerApps: filter out not-installed bundled apps (fixes Bitcoin Knots
  appearing on clean unbundled installs)
- Kiosk: persist kiosk mode to localStorage before /kiosk redirect so
  App.vue can skip remote relay (fixes input doubling with companion app)
- IndeedHub: fix port mapping and X-Forwarded-Prefix passthrough

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:01:10 -04:00
DorianandClaude Opus 4.6 401a44b40a fix: IndeedHub port 7778, podman registries v2 format
- IndeedHub container port changed from 7777 to 7778 (7777 used by nostr-relay)
- Nginx proxy updated to route to 7778
- Backend config.rs port mapping updated
- Podman registries.conf switched to v2 format (fixes mixed v1/v2 error)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:32:32 -04:00
DorianandClaude Opus 4.6 dd5c3783ed chore: update Cargo.lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:02:36 -04:00
DorianandClaude Opus 4.6 7ca973e7b1 chore: bump version to 1.3.5
Registry migration to git.tx1138.com/lfg2025, version bump for
release testing across nodes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 09:38:45 -04:00
DorianandClaude Opus 4.6 a147db9b70 refactor: migrate container registry from 80.71.235.15:3000 to git.tx1138.com/lfg2025
All hardcoded references to the old IP-based registry replaced across
Rust backend, Vue frontend, shell scripts, Dockerfiles, CI, and docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 09:33:10 -04:00
Dorian 68b02359dc ui updates 2026-04-11 13:38:01 +01:00
DorianandClaude Opus 4.6 b7ff0b1d38 fix: VPN IP dedup, status polling, pair-a-device text
- VPN status: don't show WG IP as NostrVPN IP when tunnel not up
- VPN section polls every 15s so IP updates after pairing
- NostrVPN shows "Pair a device" when service active but no tunnel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 04:48:08 -04:00
DorianandClaude Opus 4.6 b9044e58c7 fix: unbundled first-boot, fast VPN status, kiosk relay dedup
- Unbundled ISO: first-boot only creates FileBrowser (marker file .unbundled)
  Users install apps from Marketplace — no more bitcoin/mempool on clean install
- VPN status: read tunnel IP from config file (instant) instead of nvpn status (22s)
- Kiosk: App.vue skips remote relay on /kiosk path (prevents duplicate input)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 04:01:35 -04:00
DorianandClaude Opus 4.6 067a3ed106 fix: ISO boot, container installs, VPN, nginx, companion input
- LUKS auto-unlock: initramfs hook + systemd service + nofail fstab
- Rootfs packages: add passt, aardvark-dns, netavark, nftables for Podman 5.x
- nginx: resolver + variable proxy_pass for external domains (DNS at boot)
- Boot: loglevel=0 suppresses kernel warnings, serial console for QEMU
- Container installs: write configs before chown, sudo chown for LUKS volumes
- Container installs: build UI sidecars locally (not from registry) for auth injection
- Bitcoin UI: inject RPC auth from secrets file, --no-cache rebuild
- Secrets: chown to archipelago user in first-boot (backend needs read access)
- Podman: image_copy_tmp_dir for read-only /var/tmp in user namespace
- NostrVPN: enable service in auto-install, always include public relays
- NostrVPN: read tunnel IP from nvpn status (not just config file)
- VPN invite: v2 base64 no-pad format matching phone app
- Companion input: relay always active, kiosk skips relay listener (prevents double input)
- dev-start.sh: production build includes AIUI deployment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 03:10:49 -04:00
DorianandClaude Opus 4.6 88bdba19db fix: route ISO builds to iso-builder runner (ThinkPad only)
VPS runner was sniping jobs and failing instantly (no build env).
Changed runs-on from ubuntu-latest to iso-builder label, which only
the ThinkPad runner has registered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 00:50:44 +01:00
DorianandClaude Opus 4.6 f8cf0afbfc fix: source nvm in CI workflow for npm/npx availability
act_runner runs non-interactive shells where nvm isn't loaded.
Cargo steps already source .cargo/env but frontend steps were missing
the equivalent nvm.sh sourcing, causing "npm: command not found".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 00:36:03 +01:00
DorianandClaude Opus 4.6 e8251b5bad feat: add production build mode to dev-start.sh (option 10)
Linux-only option that mirrors ISO install exactly: builds backend
(release), frontend (with typecheck), syncs all configs, and restarts
all system services (Tor, WireGuard, NostrVPN, nginx, backend).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 00:08:42 +01:00
DorianandClaude Opus 4.6 ca2ddc889e fix: add e2fsprogs and cryptsetup-initramfs to rootfs
ISO boot failed in emergency mode because:
- fsck.ext4 binary missing (no e2fsprogs in rootfs)
- LUKS data volume never opened (no cryptsetup-initramfs in initramfs)

Both packages were in the installer debootstrap but not the target rootfs
Dockerfile. The initramfs regeneration at install time now includes LUKS
support since cryptsetup-initramfs is present.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 21:07:34 +01:00
DorianandClaude Opus 4.6 2517379ac3 chore: Debian 12 → 13 (Trixie) migration, service hardening
- Update all references from Debian 12 (Bookworm) to Debian 13 (Trixie)
- Enable SystemCallArchitectures, RestrictAddressFamilies, RestrictRealtime
  in archipelago.service (safe on systemd 256+ which respects NoNewPrivileges=no)
- Update GLIBC compatibility checks from 2.36 to 2.40
- ISO filename, build container, and docs updated throughout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 21:32:08 +02:00
DorianandClaude Opus 4.6 b8a09b448b fix: AIUI /aiui/ base path, nginx alias cycle, VPN auth, container boot
- AIUI: rebuild with /aiui/ base path (router, chunk loader, SW scope)
- nginx: remove alias from /aiui/ location (caused try_files redirect cycle)
- VPN: WireGuard standalone setup, auth improvements
- ISO: build script hardening, service file updates
- first-boot-containers: networking stack fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 20:42:09 +02:00
DorianandClaude Opus 4.6 a8c6a36cd1 fix: netavark GLIBC mismatch in ISO, container adopt, app updates
ISO build no longer copies netavark from build host (Debian 13/GLIBC 2.41)
which broke container networking on Debian 12 targets. Rootfs already
installs netavark from Debian 12 repos — just configure the backend.

Install RPC now adopts existing containers (from first-boot) instead of
erroring on duplicates. Container scanner extracts real versions from
image tags and detects available updates against pinned versions.

Frontend shows update button with version info when updates are available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 11:47:35 +02:00
DorianandClaude Opus 4.6 d0b9f168f4 fix: harden ElectrumX status — cached backend, stable frontend
Backend: cache status in RwLock, refresh every 15s via background task.
Eliminates per-request TCP race to ElectrumX that caused volatile errors.
Fix error classification so "Failed to read" is transient, not hard error.

Frontend: keep last-known-good data across failed polls, persist Tor
onion once discovered, adaptive polling (5s active / 30s synced).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 10:32:55 +02:00
DorianandClaude Opus 4.6 07dff3e4ca fix: container stack installers, DNS resolution, uninstall cleanup
- Replace aardvark-dns container names with host.containers.internal
  for all cross-app connections (LND→Bitcoin, ElectrumX→Bitcoin,
  Mempool→ElectrumX, Fedimint→Bitcoin, NBXplorer→Bitcoin P2P+RPC)
- Add BTCPay multi-container stack installer (postgres + nbxplorer +
  btcpay-server) with proper secrets, data dir ownership, NOAUTH
- Add Mempool multi-container stack installer (mariadb + mempool-api +
  mempool-frontend) with host.containers.internal for RPC
- Immediately remove apps from state on uninstall (no 3-min ghost delay)
- Include archy-bitcoin-ui in bitcoin uninstall container list
- Fix LND UI port 8081 (was 8080, conflicting with LND gRPC)
- Fix ElectrumX UI: proxy /electrs-status to backend, cache-busting
  headers, graceful fallback when backend returns HTML
- Add Tor hidden services for ElectrumX and LND in torrc template
- Remove unused detect_bitcoin_container_name() (replaced by
  host.containers.internal)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 23:29:50 +02:00
DorianandClaude Opus 4.6 b30f41f3d7 feat: standalone WireGuard from first install, fix networking stack
Standalone WireGuard (wg0:51820):
- New archipelago-wg.service creates wg0 independent of NostrVPN
- Keypair generated on first-boot, persisted on LUKS partition
- vpn.create-peer uses wg genkey/pubkey (no nvpn dependency)
- wg-address service depends on archipelago-wg, not nostr-vpn

Networking fixes:
- Remove nos.lol from default relays (requires PoW, events rejected)
- Add Tor hidden service for private relay (port 7777) — NAT'd peers
  can reach relay over Tor for NostrVPN signaling
- Fix Tor hostname sync race: wait loop before copying hostname files
- Add tor-hostnames + wireguard dirs to LUKS partition setup
- Include relay in hostname sync loops (setup-tor.sh + first-boot)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 20:27:38 +02:00
DorianandClaude Opus 4.6 a029a4c948 feat: NostrVPN add-device guided wizard
Replace disconnected "Generate Invite" + "Add participant" with a 2-step
wizard: enter phone npub → get invite QR + mesh details. Backend vpn.invite
now accepts optional npub param to add participant in the same call. Modal
shows network ID, node npub, and relay URLs for manual app configuration.

Also includes nostr-vpn service hardening (rate-limit restarts, reset-failed
before enable).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 19:04:53 +02:00
DorianandClaude Opus 4.6 ef69434364 fix: reboot/shutdown commands work without sudo prefix
polkit denies reboot/shutdown for non-root users without a local seat
(e.g. SSH sessions). Since archipelago has NOPASSWD sudo, add shell
aliases so reboot/shutdown/halt/poweroff transparently use sudo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:51:21 +02:00
DorianandClaude Opus 4.6 3ee5dd6715 fix: nostr-vpn crash-loop on fresh install, relay config lost on LUKS
Two issues on fresh ISO install:
1. nostr-vpn.service was enabled in rootfs but env file doesn't exist
   until first-boot generates Nostr identity — crash-loop on boot.
   Now only enabled by first-boot-containers.sh after identity exists.
2. LUKS encrypted partition mounts over /var/lib/archipelago/, hiding
   the relay config.toml the Dockerfile put there. Now copies relay
   config and creates nostr-relay/nostr-vpn dirs on the LUKS partition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:48:38 +02:00
DorianandClaude Opus 4.6 eebdade0d4 fix: vpn.add-participant writes to root-owned daemon config via sudo
The nvpn daemon config at /var/lib/archipelago/nostr-vpn/ is owned by
root, but the backend runs as archipelago. Direct write silently failed,
so adding a second phone's npub never reached the daemon — service
restarted with stale config. Now falls back to sudo cp for root-owned
paths, and first-boot sets ownership to archipelago.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 16:25:39 +02:00
DorianandClaude Opus 4.6 aa2a13d510 fix: build report — rootfs tar path prefix, git repo path
podman export creates paths without ./ prefix, but tar tf checks
used ./etc/... which never matched. List once, grep without prefix.
Also fix git commands to use $HOME/archy (workspace has no .git).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 16:00:53 +02:00
DorianandClaude Opus 4.6 d7286d5f63 fix: expand brace globs in Dockerfile RUN — dash has no brace expansion
Dockerfile RUN steps execute under /bin/sh (dash on Debian), which
doesn't support brace expansion {a,b,c}. The nostr-relay directory
was never created, causing the config copy to fail (build #444).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:43:22 +02:00
DorianandClaude Opus 4.6 5f40cd2af4 fix: restore musl static build, brand GRUB as Archipelago
Runner is Debian 13 (glibc 2.41), ISO rootfs is Debian 12/bookworm
(glibc 2.36). Dynamic binary crashes with GLIBC_2.41 not found.
Musl static build eliminates the dependency entirely.

Also set GRUB_DISTRIBUTOR="Archipelago" so installed system boot
menu says "Archipelago" not "Debian GNU/Linux".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:19:14 +02:00
DorianandClaude Opus 4.6 42c29b99e2 feat: ISO networking stack — relay + nvpn v0.3.7 + WireGuard
Add nostr-rs-relay as native system service (port 7777) for VPN
signaling. Every node runs its own private relay from first boot.
Update nvpn binary from v0.3.4 to v0.3.7 (fixes mesh event
processing). Add WireGuard helper and address service for peer VPN.
First-boot script configures relay, nvpn identity, relay URLs
(direct + Tor onion), and syncs daemon config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:06:27 +02:00
DorianandClaude Opus 4.6 b0907c48b2 feat: NostrVPN mesh + VPN card UI + nvpn v0.3.7
- VPN card: relay URLs, device management, invite QR, add participant
- Backend: vpn.invite, vpn.add-participant, vpn.peer-config RPCs
- nvpn v0.3.7 system service (fixes event processing bug in v0.3.4)
- First-boot: auto-configure nvpn with node identity and endpoint
- Service: AF_NETLINK for WireGuard, NoNewPrivileges=no for sudo wg
- TASK-50: networking stack reliability from first install

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:00:00 +02:00
DorianandClaude Opus 4.6 6c1f316956 fix: revert musl build, add ACPI power-off support
- Revert CI to normal cargo build --release (musl was false positive)
- Add acpid + acpi-support-base to rootfs packages
- Add acpi=force to GRUB and ISOLINUX boot params (installer + installed)
- Fixes "Maybe missing ACPI. Shutdown not powering off" on some hardware

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:15:09 +02:00
DorianandClaude Opus 4.6 8e094c7ce9 fix: install/uninstall UI state, progress bar, auto-Tor hidden services
- Install progress bar replaces action buttons (no overlay)
- Hide status badge during install/uninstall
- Uninstall keeps progress state until container disappears from WebSocket
- Uninstall RPC timeout increased to 660s (Bitcoin UTXO flush)
- Installing apps appear in My Apps immediately as placeholders
- Auto-configure Tor hidden service for every app on install
- Widen Tor module visibility for install hooks
- Only clear stale install entries on error status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:20:18 +02:00
DorianandClaude Opus 4.6 5c117f5718 fix: static musl build — eliminates GLIBC version mismatch on ISO
Build server (Debian 13) has GLIBC 2.41 but ISO targets Debian 12
(GLIBC 2.36). Switching to x86_64-unknown-linux-musl produces a
fully static binary that runs on any Linux.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 01:27:47 +02:00
DorianandClaude Opus 4.6 43ac2975dc fix: fast VPN status — read config file instead of slow nvpn CLI
nvpn status command hangs for seconds (connects to relays), causing
the Network page to never finish loading. Read tunnel_ip from the
local config file instead (instant).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:36:49 +02:00
DorianandClaude Opus 4.6 a34075287d fix: nostr-vpn service crash on reboot, detect activating state
- Remove ReadWritePaths sandbox (causes namespace error when /run/nostr-vpn
  doesn't exist after reboot — /run is tmpfs)
- Detect both 'active' and 'activating' states in VPN status check

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:05:08 +01:00
DorianandClaude Opus 4.6 0ecfdd1d01 perf: skip missed ticks on all intervals, reduce scan frequency
Prevents burst of health checks, scans, and snapshots after slow
podman responses by using MissedTickBehavior::Skip. Bumps container
scan interval from 30s to 60s to reduce DB lock contention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 20:25:09 +01:00
DorianandClaude Opus 4.6 4fc6c103ba feat: VPN peer QR code UI, consolidate CI workflows
- Add vpn.create-peer, vpn.list-peers, vpn.remove-peer RPC methods
- Generate WireGuard config + QR code (SVG) for mobile device connection
- Add "Add Device" modal on Network page with QR scanner support
- Remove old build-iso.yml (replaced by build-iso-dev.yml)
- Remove container-tests.yml (tests run in dev workflow)
- Remove container orchestration tests from dev workflow (redundant)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:44:00 +01:00
DorianandClaude Opus 4.6 b0e5e8c00e perf: incremental cargo builds, skip apt when cached
- Build in $HOME/archy to reuse target/ cache across CI runs
- Skip apt-get install when ISO build deps already present
- Cargo tests also use persistent target dir

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:08:29 +01:00
DorianandClaude Opus 4.6 9eb5d8cee0 fix: kiosk boot loop — redirect /kiosk to / for proper boot screen
Kiosk was redirecting /kiosk → /dashboard, bypassing RootRedirect
and BootScreen entirely. This caused the kiosk to land on Login.vue
showing "server is starting up" in a loop instead of the proper
terminal-style boot progression screen.

Now /kiosk → / → RootRedirect → BootScreen, matching what remote
browsers see.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:04:58 +01:00
DorianandClaude Opus 4.6 d8d472f72c fix: nostr-vpn service — set HOME, create dirs, remove strict sandbox
nvpn binary writes to $HOME/.config/nvpn. Set HOME to data dir,
create runtime dirs in ExecStartPre, remove overly restrictive
ProtectSystem/ProtectHome that blocked the binary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:57:38 +01:00
DorianandClaude Opus 4.6 7a78d750f4 fix: TS type error in VPN status, remove unused assignment warning
- Fix vpnStatus type mismatch (provider: string|undefined vs string|null)
- Remove redundant history_dirty assignment in health_monitor.rs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:23:17 +01:00
DorianandClaude Opus 4.6 4c41c38b3b fix: implement Claude API key save RPC, VPN status on home page
- Add system.settings.get/set RPC methods for Claude API key management
- Save key to secrets/claude-api-key, restart claude-api-proxy service
- Home Network card now fetches VPN status via vpn.status RPC
- Shows provider name (nostr-vpn, tailscale) instead of just "Connected"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:18:35 +01:00
DorianandClaude Opus 4.6 fddbf8ccf7 fix: add v1.3.4 What's New, fix VPN TS error
- Add v1.3.4 release notes: NostrVPN, FIPS/Routstr, ISO boot fix, bootstrap
- Remove unused i18n import from VpnStatusSection.vue (TS6133)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:05:56 +01:00
DorianandClaude Opus 4.6 9438bad7fc fix: handle NostrVpn provider in VPN disconnect match
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:53:26 +01:00
DorianandClaude Opus 4.6 209c2dcd6c fix: restore FIPS as installable container app
FIPS stays in the marketplace as an installable container app.
NostrVPN is the native system service; FIPS is a separate optional app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:51:13 +01:00
DorianandClaude Opus 4.6 42034c0ff9 feat: NostrVPN as native system service, remove FIPS
- Convert NostrVPN from container app to native systemd service
- Auto-configure VPN with node's Nostr identity after onboarding
- Add nostr-vpn.service with proper capabilities (NET_ADMIN, NET_RAW)
- Remove FIPS from marketplace, container config, nginx, image-versions
  (consolidated into NostrVPN — same mesh VPN concept)
- Add AIUI inclusion step to dev CI workflow
- AIUI installed on VPS build server for ISO inclusion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:49:34 +01:00
DorianandClaude Opus 4.6 54cb23f07b feat: NostrVPN as native system service, Claude API key input, fix duplicate password
- Add NostrVPN as a native systemd service (extracted from container)
- Add VPN status detection for nostr-vpn in backend vpn.rs
- ISO build extracts nvpn binary from container image
- First-boot auto-configures NostrVPN with node's Nostr identity
- Change Claude Auth from login iframe to API key input field
- Remove duplicate ChangePasswordSection from Settings.vue
- FIPS and Routstr remain as installable container apps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:40:33 +01:00
DorianandClaude Opus 4.6 0760819af4 fix: FIPS env var name, remove broken NostrVPN CMD
- FIPS container expects FIPS_NSEC/FIPS_NPUB, not FIPS_NOSTR_SECRET
- NostrVPN container doesn't have a 'start' binary — use image default

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:17:23 +01:00
DorianandClaude Opus 4.6 e5f695c1c4 fix: service file crash on fresh installs, CI workflow portability
- Remove MemoryDenyWriteExecute=yes from archipelago.service — ring
  (rustls) and secp256k1 (bitcoin/nostr) crypto libraries need
  executable memory mappings that this restriction blocks
- Add + prefix to ExecStartPre so mkdir/chown run as root
- Use $HOME/archy instead of /home/archipelago/archy in CI workflows
  so builds work on both .228 and VPS CI runners

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:08:21 +01:00
DorianandClaude Opus 4.6 637818c9f1 fix: dynamic UID in first-boot-containers.sh, remove temp fix-ssh workflow
Replace hardcoded /run/user/1000 with $(id -u archipelago) so first-boot
works regardless of the archipelago user's UID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:33:15 +01:00
DorianandClaude Opus 4.6 712e1c8b25 fix: run SSH fix from /tmp to bypass broken home dir
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:36:26 +01:00
DorianandClaude Opus 4.6 3e3dfafdfc feat: add Nostr VPN, FIPS, Routstr apps with status UIs
Add three new marketplace apps:
- Routstr (v0.4.3): Decentralized AI inference proxy with Cashu payments
- Nostr VPN (v0.3.4): Mesh VPN with Nostr signaling + WireGuard tunnels
- FIPS (v0.1.0): Self-organizing encrypted mesh network

Includes status UI dashboards for headless apps (nostr-vpn-ui, fips-ui)
with usage instructions, node identity display, and container logs.
Nostr identity injected via env vars for all three apps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 05:06:45 +01:00
DorianandClaude Opus 4.6 0fca903188 fix: use numeric UIDs for SSH fix
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 04:05:57 +01:00
DorianandClaude Opus 4.6 1508cc3e13 fix: emergency SSH permission fix via CI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 04:00:37 +01:00
DorianandClaude Opus 4.6 24e537c027 feat: auto-deploy to dev environment after CI build
- Deploy backend binary + frontend to VPS after successful build
- Fix ISO ownership to use runner's UID instead of hardcoded 1000
- FileBrowser on VPS serves ISOs at :8083

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 03:06:43 +01:00
DorianandClaude Opus 4.6 dd0a01f95c chore: bump version to 1.3.4
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 03:05:05 +01:00
DorianandClaude Opus 4.6 62c13152a3 fix: remove duplicate rpcbind from bitcoin-knots container creation
bitcoin.conf already has server=1, rpcbind=0.0.0.0, rpcallowip, listen.
Passing them again via command-line causes bitcoin to try binding port
8332 twice → "Address already in use" → container crashes on every start.

Now only passes pruning/txindex args and dbcache via CLI.
Health check uses cookie auth (-datadir) instead of plaintext password.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:56:26 +01:00
DorianandClaude Opus 4.6 b27b426728 fix: BUILD_VERSION from Cargo.toml, kiosk scaling, new apps, Rust warnings
Critical:
- BUILD_VERSION was hardcoded as "1.3.0-alpha" — now reads from Cargo.toml
  This caused ALL ISOs to show v1.3.0 regardless of actual binary version

Kiosk:
- Remove --disable-gpu flags (broke display scaling on some monitors)
- Add --start-fullscreen --window-size for reliable fullscreen

New apps:
- Nostr VPN, FIPS, Routstr, noStrudel, BotFights, NWNN, 484 Kitchen,
  Call the Operator, Arch Presentation, Syntropy Institute, T-0

Rust: suppress dead_code and unused_assignments warnings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:35:52 +01:00
DorianandClaude Opus 4.6 8f7798328b fix: replace actions/checkout in build-iso-dev.yml (THE ACTUAL WORKFLOW)
We were editing build-iso.yml but Gitea runs build-iso-dev.yml.
Replaced actions/checkout@v4 with direct git fetch+rsync.
This is the root cause of stale builds all day.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:33:40 +01:00
Dorian 54f1213a7f android 2026-04-02 20:49:43 +01:00
DorianandClaude Opus 4.6 2586a1dd86 fix: replace actions/checkout with direct git fetch+rsync (no more red cross)
actions/checkout@v4 uses a broken Gitea-generated token that always
fails. Replaced with direct git fetch+reset on the local repo, then
rsync to workspace. No more stale builds. Verified with version check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:38:52 +01:00
463 changed files with 38745 additions and 6242 deletions
+102 -51
View File
@@ -7,82 +7,127 @@ on:
jobs:
build-iso:
runs-on: ubuntu-latest
runs-on: iso-builder
timeout-minutes: 60
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
timeout-minutes: 5
continue-on-error: true
- name: Sync from local repo (fallback if checkout failed)
run: |
# Only sync from ~/archy if checkout failed or workspace is empty
if [ -f "CLAUDE.md" ] && [ -d "core" ] && [ -d "neode-ui" ]; then
echo "Checkout succeeded — using checked-out code"
elif [ -d "$HOME/archy/core" ] && [ -d "$HOME/archy/neode-ui" ]; then
echo "Checkout failed — syncing from ~/archy (LAN fallback)..."
rsync -a \
--exclude '.git' --exclude 'node_modules' --exclude 'target' \
--exclude 'image-recipe/build' --exclude 'image-recipe/results' \
--exclude 'web/dist' \
"$HOME/archy/" ./
else
echo "ERROR: No checkout and no local fallback"
exit 1
fi
echo "Workspace verification:"
# Direct fetch + sync (actions/checkout token is broken on this Gitea)
REPO_DIR="$HOME/archy"
cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main
echo "=== Source at commit: $(git log --oneline -1) ==="
rsync -a --delete \
--exclude '.git' --exclude 'node_modules' --exclude 'target' \
--exclude 'image-recipe/build' --exclude 'image-recipe/results' \
--exclude 'web/dist' \
"$REPO_DIR/" "$GITHUB_WORKSPACE/"
cd "$GITHUB_WORKSPACE"
echo "=== Workspace version: $(grep '^version' core/archipelago/Cargo.toml) ==="
[ -f "scripts/first-boot-containers.sh" ] && echo " first-boot-containers.sh: PRESENT" || echo " first-boot-containers.sh: MISSING"
grep -q 'network-alias' scripts/first-boot-containers.sh 2>/dev/null && echo " network-alias fix: PRESENT" || echo " network-alias fix: MISSING"
grep -q 'apache2-utils' image-recipe/build-auto-installer-iso.sh 2>/dev/null && echo " apache2-utils: PRESENT" || echo " apache2-utils: MISSING"
- name: Install ISO build dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
debootstrap squashfs-tools xorriso \
isolinux syslinux-common mtools \
grub-efi-amd64-bin grub-pc-bin grub-common
# Skip apt if packages already installed (persistent runner)
if dpkg -s debootstrap squashfs-tools xorriso isolinux syslinux-common mtools \
grub-efi-amd64-bin grub-pc-bin grub-common musl-tools >/dev/null 2>&1; then
echo "ISO build deps already installed, skipping apt"
else
sudo apt-get update -qq
sudo apt-get install -y -qq \
debootstrap squashfs-tools xorriso \
isolinux syslinux-common mtools \
grub-efi-amd64-bin grub-pc-bin grub-common \
musl-tools
fi
# Ensure musl Rust target is available
source $HOME/.cargo/env 2>/dev/null || true
rustup target add x86_64-unknown-linux-musl 2>/dev/null || true
- name: Build backend
- name: Build backend (incremental, musl static)
run: |
source $HOME/.cargo/env 2>/dev/null || true
# Build in persistent repo dir to reuse target/ cache
cd "$HOME/archy"
export GIT_HASH=$(git rev-parse --short HEAD)
cargo build --release --manifest-path core/Cargo.toml
# Static musl build for portability — ensures binary runs regardless
# of glibc version differences between build host and ISO rootfs.
cargo build --release --target x86_64-unknown-linux-musl --manifest-path core/Cargo.toml
# Copy binary to workspace for downstream steps
mkdir -p "$GITHUB_WORKSPACE/core/target/release"
cp core/target/x86_64-unknown-linux-musl/release/archipelago "$GITHUB_WORKSPACE/core/target/release/"
- name: Build frontend
run: cd neode-ui && npm ci && npm run build
run: |
source $HOME/.nvm/nvm.sh 2>/dev/null || true
cd neode-ui && npm ci && npm run build
- name: Type check frontend
run: cd neode-ui && npx vue-tsc -b --noEmit
run: |
source $HOME/.nvm/nvm.sh 2>/dev/null || true
cd neode-ui && npx vue-tsc -b --noEmit
- name: Run frontend tests
run: cd neode-ui && npx vitest run
- name: Run container orchestration unit tests
run: |
source $HOME/.cargo/env 2>/dev/null || true
echo "=== Container crate tests ==="
cargo test -p archipelago-container --no-fail-fast --manifest-path core/Cargo.toml
echo ""
echo "=== Orchestration integration tests ==="
cargo test --test orchestration_tests --no-fail-fast --manifest-path core/Cargo.toml 2>/dev/null || echo "orchestration_tests not found, skipping"
source $HOME/.nvm/nvm.sh 2>/dev/null || true
cd neode-ui && npx vitest run
- name: Include AIUI if available
run: |
# 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
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
run: |
sudo mkdir -p /etc/containers/registries.conf.d
echo '[[registry]]
location = "80.71.235.15:3000"
location = "git.tx1138.com"
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
- name: Build unbundled ISO
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
@@ -248,7 +293,7 @@ jobs:
echo "══════════════════════════════════════════"
echo "DEV ISO BUILD REPORT"
echo "══════════════════════════════════════════"
echo "Commit: $(git rev-parse --short HEAD) ($(git log -1 --format=%s))"
echo "Commit: $(git -C "$HOME/archy" rev-parse --short HEAD 2>/dev/null || echo 'unknown') ($(git -C "$HOME/archy" log -1 --format=%s 2>/dev/null || echo 'unknown'))"
echo "Branch: ${GITHUB_REF_NAME:-dev-iso}"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "Runner: $(hostname)"
@@ -261,11 +306,17 @@ jobs:
ROOTFS=$(ls image-recipe/build/auto-installer/archipelago-rootfs.tar 2>/dev/null) || true
if [ -n "$ROOTFS" ]; then
echo " rootfs.tar: $(sudo du -h "$ROOTFS" 2>/dev/null | cut -f1 || echo 'unknown')"
echo " nginx config: $(sudo tar tf "$ROOTFS" ./etc/nginx/sites-available/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " SSL cert: $(sudo tar tf "$ROOTFS" ./etc/archipelago/ssl/archipelago.crt 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " kiosk launcher: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago-kiosk-launcher 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " backend binary: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " web-ui index: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
# List key paths once (podman export omits ./ prefix, so match without it)
ROOTFS_LIST=$(sudo tar tf "$ROOTFS" 2>/dev/null | grep -E '(etc/nginx/sites-available/archipelago|etc/archipelago/ssl/archipelago.crt|usr/local/bin/archipelago-kiosk-launcher|usr/local/bin/archipelago|opt/archipelago/web-ui/index.html)' || true)
for item in \
"nginx config:etc/nginx/sites-available/archipelago" \
"SSL cert:etc/archipelago/ssl/archipelago.crt" \
"kiosk launcher:usr/local/bin/archipelago-kiosk-launcher" \
"backend binary:usr/local/bin/archipelago" \
"web-ui index:opt/archipelago/web-ui/index.html"; do
label="${item%%:*}"; path="${item#*:}"
echo "$ROOTFS_LIST" | grep -q "$path" && echo " $label: PRESENT" || echo " $label: MISSING"
done
else
echo " rootfs.tar not found in workspace"
fi
-157
View File
@@ -1,157 +0,0 @@
name: Build Archipelago ISO
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-iso:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
clean: true
continue-on-error: true
- name: Sync from local repo (always — checkout token is unreliable)
run: |
# Pull latest into local repo
cd /home/archipelago/archy && git pull origin main 2>/dev/null || true
# Always sync from local repo to ensure latest code
echo "Syncing workspace from local repo..."
rsync -a --delete --exclude='.git' --exclude='target/' --exclude='node_modules/' \
/home/archipelago/archy/ ./ 2>/dev/null || cp -a /home/archipelago/archy/* . 2>/dev/null || true
echo "Source version: $(grep '^version' core/archipelago/Cargo.toml 2>/dev/null)"
echo "Latest commit: $(cd /home/archipelago/archy && git log --oneline -1 2>/dev/null)"
- name: Build backend
run: |
source $HOME/.cargo/env 2>/dev/null || true
cargo build --release --manifest-path core/Cargo.toml
- name: Build frontend
run: |
rm -rf web/dist/neode-ui
cd neode-ui && npm ci && npm run build
- name: Type check frontend
run: cd neode-ui && npx vue-tsc -b --noEmit
- name: Run frontend tests
run: cd neode-ui && npx vitest run
- name: Cache Debian Live ISO
run: |
WORK_DIR="image-recipe/build/auto-installer"
mkdir -p "$WORK_DIR"
CACHED="/home/archipelago/archy/image-recipe/build/auto-installer/debian-live-installer.iso"
if [ -f "$CACHED" ] && [ ! -f "$WORK_DIR/debian-live-installer.iso" ]; then
cp "$CACHED" "$WORK_DIR/debian-live-installer.iso"
echo "Cached Debian Live ISO copied ($(du -h "$WORK_DIR/debian-live-installer.iso" | cut -f1))"
fi
- name: Configure root podman for insecure registry
run: |
sudo mkdir -p /etc/containers/registries.conf.d
echo '[[registry]]
location = "80.71.235.15:3000"
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
- name: Include AIUI if available
run: |
# Copy AIUI from the deployed system (build server has it at /opt/archipelago/web-ui/aiui/)
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/"
else
echo "WARNING: AIUI not found on build server"
fi
- name: Build unbundled ISO
run: |
cd image-recipe
export ARCHIPELAGO_BIN="$(pwd)/../core/target/release/archipelago"
ls -la "$ARCHIPELAGO_BIN" || echo "WARNING: binary not found"
sudo -E UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 \
ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \
./build-auto-installer-iso.sh
- name: Copy to Builds
run: |
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
if [ -n "$ISO" ]; then
DATE=$(date +%Y%m%d-%H%M)
DEST="/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso"
sudo cp "$ISO" "$DEST"
sudo chown 1000:1000 "$DEST"
echo "ISO: archipelago-unbundled-${DATE}.iso"
echo "Size: $(du -h "$DEST" | cut -f1)"
echo "SHA256: $(sha256sum "$DEST" | cut -d' ' -f1)"
fi
- name: Build report
if: always()
continue-on-error: true
run: |
set +eo pipefail
echo "══════════════════════════════════════════"
echo "BUILD REPORT"
echo "══════════════════════════════════════════"
echo "Commit: $(git rev-parse --short HEAD) ($(git log -1 --format=%s))"
echo "Branch: ${GITHUB_REF_NAME:-unknown}"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "Runner: $(hostname)"
echo ""
echo "── Artifacts ──"
ls -lh image-recipe/results/*.iso 2>/dev/null || echo " No ISO produced"
ls -lh /var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-*.iso 2>/dev/null | tail -3
echo ""
echo "── Rootfs contents check ──"
ROOTFS=$(ls image-recipe/build/auto-installer/archipelago-rootfs.tar 2>/dev/null) || true
if [ -n "$ROOTFS" ]; then
echo " rootfs.tar: $(sudo du -h "$ROOTFS" 2>/dev/null | cut -f1 || echo 'unknown')"
echo " nginx config: $(sudo tar tf "$ROOTFS" ./etc/nginx/sites-available/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " SSL cert: $(sudo tar tf "$ROOTFS" ./etc/archipelago/ssl/archipelago.crt 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " keyboard config: $(sudo tar tf "$ROOTFS" ./etc/default/keyboard 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " console-setup: $(sudo tar tf "$ROOTFS" ./etc/default/console-setup 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " kiosk launcher: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago-kiosk-launcher 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " logind lid: $(sudo tar tf "$ROOTFS" ./etc/systemd/logind.conf.d/lid-ignore.conf 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " backend binary: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " web-ui index: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " AIUI: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/aiui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
echo " claude-api-proxy: $(sudo tar tf "$ROOTFS" ./opt/archipelago/claude-api-proxy.py 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
else
echo " rootfs.tar not found in workspace"
fi
echo ""
echo "── ISO contents check ──"
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1) || true
if [ -n "$ISO" ]; then
echo " ISO size: $(sudo du -h "$ISO" 2>/dev/null | cut -f1 || echo 'unknown')"
ISO_MOUNT=$(mktemp -d)
if sudo mount -o loop,ro "$ISO" "$ISO_MOUNT" 2>/dev/null; then
echo " auto-install.sh: $([ -f "$ISO_MOUNT/archipelago/auto-install.sh" ] && echo 'PRESENT' || echo 'MISSING')"
echo " rootfs.tar: $([ -f "$ISO_MOUNT/archipelago/rootfs.tar" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/rootfs.tar" 2>/dev/null | cut -f1))" || echo 'MISSING')"
echo " backend bin: $([ -f "$ISO_MOUNT/archipelago/bin/archipelago" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/bin/archipelago" 2>/dev/null | cut -f1))" || echo 'MISSING')"
echo " frontend: $([ -f "$ISO_MOUNT/archipelago/web-ui/index.html" ] && echo 'PRESENT' || echo 'MISSING')"
echo " image-versions: $([ -f "$ISO_MOUNT/archipelago/scripts/image-versions.sh" ] && echo 'PRESENT' || echo 'MISSING')"
sudo umount "$ISO_MOUNT" 2>/dev/null || true
else
echo " Could not mount ISO for inspection"
fi
rmdir "$ISO_MOUNT" 2>/dev/null || true
fi
echo "══════════════════════════════════════════"
- name: Fix workspace permissions
if: always()
run: |
sudo chown -R $(id -u):$(id -g) . 2>/dev/null || true
sudo chmod -R u+rwX . 2>/dev/null || true
sudo chown -R $(id -u):$(id -g) "$HOME/.cache/act" 2>/dev/null || true
sudo chmod -R u+rwX "$HOME/.cache/act" 2>/dev/null || true
-63
View File
@@ -1,63 +0,0 @@
name: Container Orchestration Tests
on:
push:
branches: [dev-iso, main]
paths:
- 'core/archipelago/src/**'
- 'core/container/src/**'
- 'scripts/container-*.sh'
- 'scripts/reconcile-*.sh'
- 'scripts/image-versions.sh'
workflow_dispatch:
jobs:
unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Cache cargo registry
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
core/target
key: cargo-test-${{ hashFiles('core/Cargo.lock') }}
- name: Run orchestration unit tests
working-directory: core
run: |
source $HOME/.cargo/env 2>/dev/null || true
echo "=== Container crate tests ==="
cargo test -p archipelago-container --no-fail-fast 2>&1
echo ""
echo "=== Orchestration integration tests ==="
cargo test --test orchestration_tests --no-fail-fast 2>&1
- name: Verify cargo check (full crate)
working-directory: core
run: |
source $HOME/.cargo/env 2>/dev/null || true
cargo check --release 2>&1
smoke-tests:
runs-on: ubuntu-latest
needs: unit-tests
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Run container smoke tests on .228
env:
ARCHIPELAGO_SSH_KEY: ~/.ssh/archipelago-deploy
run: |
# Only run if SSH key exists (CI runner has deploy access)
if [ -f "$ARCHIPELAGO_SSH_KEY" ]; then
bash scripts/dev-container-test.sh --once
else
echo "⚠ SSH key not available — skipping live smoke tests"
echo " To enable: add archipelago-deploy key to CI runner"
fi
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
versionCode = 6
versionName = "0.4.2"
vectorDrawables {
useSupportLibrary = true
@@ -32,6 +32,9 @@ class InputWebSocket(
private var password: String = ""
private var sessionCookie: String? = null
/** Player ID for arcade mode (0 = broadcast, 1 = P1, 2 = P2) */
var playerId: Int = 0
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
val state: StateFlow<ConnectionState> = _state
@@ -109,10 +112,11 @@ class InputWebSocket(
}
private fun doConnect() {
val basePath = "/ws/remote-input" + if (playerId > 0) "?p=$playerId" else ""
val wsUrl = serverUrl
.replace("https://", "wss://")
.replace("http://", "ws://")
.trimEnd('/') + "/ws/remote-input"
.trimEnd('/') + basePath
val reqBuilder = Request.Builder().url(wsUrl)
sessionCookie?.let { reqBuilder.header("Cookie", "session=$it") }
@@ -160,7 +164,8 @@ class InputWebSocket(
// ─── Input senders ──────────────────────────────────────────
fun sendKey(key: String) {
ws?.send("""{"t":"k","k":"$key"}""")
val pField = if (playerId > 0) ""","p":$playerId""" else ""
ws?.send("""{"t":"k","k":"$key"$pField}""")
}
fun sendMouseMove(dx: Int, dy: Int) {
@@ -101,8 +101,10 @@ fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) C
@Composable
fun NESController(
style: ControllerStyle = ControllerStyle.CLASSIC,
playerId: Int = 0,
onKey: (String) -> Unit,
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val c = paletteFor(style)
@@ -184,29 +186,33 @@ fun NESController(
}
}
// A/B Buttons in inlay (same size as D-pad inlay, more right margin)
// A/B/C Buttons in inlay — triangle: C top, B+A bottom
Inlay(c, Modifier.align(Alignment.CenterEnd).padding(end = 48.dp).size(140.dp)) {
Row(
Column(
Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(10.dp))
RoundBtn(c, 52.dp) { onKey("Escape") }
Text("B", color = c.labelMuted, fontSize = 9.sp, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(16.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
RoundBtn(c, 52.dp) { onKey("Return") }
Text("A", color = c.labelMuted, fontSize = 9.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(10.dp))
// C on top (white)
ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 44.dp) { onKey("c") }
Spacer(Modifier.height(6.dp))
// B + A on bottom row
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 44.dp) { onKey("b") }
ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 44.dp) { onKey("a") }
}
}
}
// Settings button (bottom center)
SettingsBtn(c, Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp), onMenu)
// Player toggle + settings (bottom center)
Row(
Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PlayerPill(c, playerId, onPlayerToggle)
SettingsBtn(c, Modifier, onMenu)
}
}
}
}
@@ -347,6 +353,28 @@ fun RoundBtn(c: NESPalette, sz: Dp = 52.dp, onClick: () -> Unit) {
}
}
/** Colored round button — custom color instead of palette */
@Composable
fun ColorBtn(color: Color, pressColor: Color, sz: Dp = 48.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.size(sz)
.shadow(if (p) 1.dp else 4.dp, CircleShape)
.clip(CircleShape)
.background(Brush.verticalGradient(
if (p) listOf(pressColor, color.copy(alpha = 0.85f))
else listOf(color, color.copy(alpha = 0.8f))
))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent))
))
}
}
/** START/SELECT capsule */
@Composable
fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) {
@@ -370,19 +398,39 @@ fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onCli
}
}
/** Small settings gear button */
/** Settings gear button (48dp — large enough for easy tap on TV) */
@Composable
fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
modifier = modifier
.size(24.dp)
.size(48.dp)
.clip(CircleShape)
.background(if (p) c.capsulePress else c.capsule)
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Settings, "Settings", Modifier.size(14.dp), tint = c.labelMuted)
Icon(Icons.Default.Settings, "Settings", Modifier.size(28.dp), tint = c.labelMuted)
}
}
/** Player ID toggle pill (P1/P2/ALL) */
@Composable
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
val label = when (playerId) { 1 -> "P1"; 2 -> "P2"; else -> "ALL" }
val accent = when (playerId) { 1 -> Color(0xFF00F0FF); 2 -> Color(0xFFFF0080); else -> c.labelMuted }
var p by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.height(28.dp)
.width(44.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (p) c.capsulePress else c.capsule)
.border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(6.dp))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onToggle(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = accent, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
}
}
@@ -55,9 +55,15 @@ fun NESKeyboard(
var layer by remember { mutableStateOf(NKLayer.ALPHA) }
var shifted by remember { mutableStateOf(false) }
var capsLock by remember { mutableStateOf(false) }
var ctrlHeld by remember { mutableStateOf(false) }
val up = shifted || capsLock
fun emit(k: String) { onKey(k); if (shifted && !capsLock) shifted = false }
fun emit(k: String) {
val key = if (ctrlHeld) "ctrl+$k" else k
onKey(key)
if (shifted && !capsLock) shifted = false
if (ctrlHeld) ctrlHeld = false
}
fun ch(cc: String) { emit(if (up && layer == NKLayer.ALPHA) "shift+$cc" else cc) }
// NES body wrapping keyboard
@@ -113,9 +119,12 @@ fun NESKeyboard(
NKey(if (layer == NKLayer.ALPHA) "123" else "ABC", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) {
layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false
}
NKey(",", Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit("comma") }
NKey("space", Modifier.weight(5f), keyBg, keyBgP, keyTxt, 12) { emit("space") }
NKey(".", Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit("period") }
NKey("Ctrl", Modifier.weight(1.2f), keyBg, keyBgP, if (ctrlHeld) accent else keyTxt, 11) {
ctrlHeld = !ctrlHeld
}
NKey(",", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("comma") }
NKey("space", Modifier.weight(4f), keyBg, keyBgP, keyTxt, 12) { emit("space") }
NKey(".", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("period") }
NKey("\u23CE", Modifier.weight(1.4f), keyBg, keyBgP, accent, 15) { emit("Return") }
}
}
@@ -36,11 +36,13 @@ import com.archipelago.app.ui.theme.NES
@Composable
fun NESPortraitController(
style: ControllerStyle = ControllerStyle.CLASSIC,
playerId: Int = 0,
onKey: (String) -> Unit,
onMouseMove: (Int, Int) -> Unit = { _, _ -> },
onMouseClick: (Int) -> Unit = { _ -> },
onMouseScroll: (Int) -> Unit = { _ -> },
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
@@ -111,16 +113,18 @@ fun NESPortraitController(
Spacer(Modifier.height(12.dp))
// A/B Buttons
// A/B/C Buttons — triangle: C top, B+A bottom
Inlay(c, Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
Column(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
RoundBtn(c, 52.dp) { onKey("Escape") }
Spacer(Modifier.width(24.dp))
RoundBtn(c, 52.dp) { onKey("Return") }
ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 46.dp) { onKey("c") }
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 46.dp) { onKey("b") }
ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 46.dp) { onKey("a") }
}
}
}
@@ -139,8 +143,16 @@ fun NESPortraitController(
Spacer(Modifier.height(6.dp))
// Settings
SettingsBtn(c, Modifier, onMenu)
// Player toggle + Settings
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
PlayerPill(c, playerId, onPlayerToggle)
Spacer(Modifier.width(10.dp))
SettingsBtn(c, Modifier, onMenu)
}
}
}
}
@@ -59,8 +59,14 @@ fun RemoteInputScreen(onBack: () -> Unit) {
var isGamepadMode by remember { mutableStateOf(true) }
var showModal by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) }
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
val ws = remember { InputWebSocket(scope) }
fun togglePlayer() {
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
ws.playerId = playerId
}
val connectionState by ws.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
@@ -98,32 +104,44 @@ fun RemoteInputScreen(onBack: () -> Unit) {
when {
isGamepadMode && isLandscape -> NESController(
style = controllerStyle,
playerId = playerId,
onKey = { ws.sendKey(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
)
isGamepadMode && !isLandscape -> NESPortraitController(
style = controllerStyle,
playerId = playerId,
onKey = { ws.sendKey(it) },
onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
onMouseClick = { ws.sendClick(it) },
onMouseScroll = { ws.sendScroll(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
)
else -> {
// Keyboard mode: trackpad fills top, keyboard pinned bottom
Column(Modifier.fillMaxSize()) {
Trackpad(
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
onClick = { ws.sendClick(it) },
onScroll = { ws.sendScroll(it) },
onTwoFingerHold = { showModal = true },
modifier = Modifier.fillMaxWidth().weight(1f)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
NESKeyboard(
style = controllerStyle,
onKey = { ws.sendKey(it) },
modifier = Modifier.fillMaxWidth(),
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
Trackpad(
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
onClick = { ws.sendClick(it) },
onScroll = { ws.sendScroll(it) },
onTwoFingerHold = { showModal = true },
modifier = Modifier.fillMaxWidth().weight(1f)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
NESKeyboard(
style = controllerStyle,
onKey = { ws.sendKey(it) },
modifier = Modifier.fillMaxWidth(),
)
}
// Settings icon top-right in keyboard mode
com.archipelago.app.ui.components.SettingsBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
onClick = { showModal = true },
)
}
}
Binary file not shown.
-127
View File
@@ -1,127 +0,0 @@
# Quick Build Guide - Archipelago Beta Release
## Prerequisites
Make sure you have:
- Docker or Podman installed
- `xorriso` installed (for ISO creation)
- Access to dev server: archipelago@192.168.1.228
**Note**: When building on the target server with `sudo`, the script will automatically install missing dependencies (`xorriso`, `podman`).
## Build Auto-Installer ISO
### Option 1: Build on Target Server (Recommended)
```bash
# SSH to target server
ssh archipelago@192.168.1.228
# Navigate to project
cd ~/archy/image-recipe
# Run build (auto-installs missing deps)
sudo ./build-auto-installer-iso.sh
# Copy ISO back to your Mac
# On your Mac:
scp archipelago@192.168.1.228:~/archy/image-recipe/results/archipelago-auto-installer-*.iso .
```
### Option 2: Build from Mac (requires Docker)
**Important**: This requires Docker Desktop installed on macOS.
```bash
cd /Users/dorian/Projects/archy/image-recipe
# Capture current live server state
DEV_SERVER=archipelago@192.168.1.228 ./build-auto-installer-iso.sh
# ISO will be created in: results/archipelago-auto-installer-*.iso
```
## What the ISO Includes
✅ Complete Debian 12 root filesystem
✅ Pre-built Archipelago backend
✅ Pre-built frontend (web UI)
**Prepackaged container images** (Bitcoin Knots, LND, UIs, and other bundled apps), loaded on first boot
✅ Nginx configuration (HTTPS ready)
✅ Auto-installer that:
- Detects internal disk
- Creates partitions (EFI + root)
- Extracts pre-built system
- Installs bootloader
- Reboots to working system
## What Users Need to Do Post-Install
1. **Start apps from the Web UI** Container images are prepackaged and loaded on first boot. Bitcoin Knots + UI, LND + UI, and other bundled apps are ready to start from the Web UI without manual `podman run`. No need to pull or deploy core containers.
2. **Access Web UI** Navigate to `http://[server-ip]`
## Testing the ISO
```bash
# Use VirtualBox, QEMU, or real hardware
qemu-system-x86_64 \
-m 4G \
-cdrom results/archipelago-auto-installer-*.iso \
-hda archipelago-test.qcow2 \
-boot d
```
## Important Notes
⚠️ **The auto-installer will ERASE the target disk!**
⚠️ Make sure to test on a non-production machine first
⚠️ Minimum 20GB disk space required (500GB+ recommended for Bitcoin)
## Build from Source (Alternative)
If you want to build everything from scratch instead of capturing the live server:
```bash
BUILD_FROM_SOURCE=1 ./build-auto-installer-iso.sh
```
This will:
- Build backend from Rust source
- Build frontend with `npm run build`
- Create fresh SSL certificates
- Generate default configs
## Troubleshooting
**ISO won't boot:**
- Ensure UEFI mode is enabled
- Try disabling Secure Boot
**Installer hangs:**
- Check the auto-start script fix is applied (see DEPLOYMENT.md)
**Backend doesn't detect containers:**
- Verify `/etc/sudoers.d/archipelago-podman` exists
- Check backend can run `sudo podman ps`
## Version Naming
ISOs are automatically named with timestamp:
```
archipelago-auto-installer-YYYYMMDD-HHMMSS.iso
```
For releases, rename to:
```
archipelago-v0.1.0-beta.1.iso
```
## Next Steps After Building
1. Test the ISO on VM
2. Verify web UI loads
3. Test container deployment
4. Document any issues
5. Tag the release in git
6. Upload ISO to distribution point
-130
View File
@@ -1,130 +0,0 @@
# CLAUDE.md — Archipelago (Archy)
## Overview
Archipelago is a **Bitcoin Node OS** — bootable, self-sovereign personal server. Flash to USB, install on hardware, manage via web UI.
**Stack**: Rust backend + Vue 3 + TypeScript (strict) + Vite 7 + Tailwind + Pinia + Podman on Debian 12
**Version**: 1.3.0 | **Target**: x86_64 and ARM64
---
## Beta Freeze (2026-03-18)
**Phase 1: Feature Testing (internal) — WE ARE HERE**
Feature set is LOCKED. Only: bug fixes, security hardening, ISO build fixes, UI polish, testing.
No new features, no new apps, no new deps, no scope creep.
Track: `docs/BETA-PROGRESS.md` | Checklist: `docs/BETA-RELEASE-CHECKLIST.md`
---
## Quick Reference
```bash
cd neode-ui && npm start # Local dev (mock backend :5959, Vite :8100)
cd neode-ui && npm run build # Build (outputs to web/dist/neode-ui/)
./scripts/deploy-to-target.sh --live # Deploy to live server (.228)
```
## Infrastructure
| What | Where |
|------|-------|
| Dev server | `192.168.1.228` (SSH key: `~/.ssh/archipelago-deploy`) |
| Secondary | `192.168.1.198` |
| Git remote | `git.tx1138.com` (remote name: `tx1138`) |
| App registry | `80.71.235.15:3000/archipelago/` (HTTP, insecure) |
| CI runner | act_runner on .228, workflow: `.gitea/workflows/build-iso.yml` |
| ISO builds | FileBrowser at `http://192.168.1.228:8083` → Builds/ |
| SSH creds | Gitignored `scripts/deploy-config.sh` |
| Web password | `password123` |
## Architecture
```
Debian 12
├── Podman (rootless, user archipelago)
├── Nginx (80/443 → backend, app proxies)
├── Rust Backend (core/) on 127.0.0.1:5678
│ ├── core/archipelago/ — Binary, RPC, auth, sessions
│ └── core/container/ — PodmanClient, manifests, health
└── Vue.js UI (neode-ui/)
├── src/api/rpc-client.ts — All backend communication
├── src/stores/ — Pinia state
├── src/views/ — Pages
└── src/style.css — ALL styling (global classes only)
```
**Data paths**: `/var/lib/archipelago/{app-id}/` (data), `/opt/archipelago/web-ui/` (frontend), `/usr/local/bin/archipelago` (binary)
## Critical Rules
1. **Never build Rust on macOS** — deploy script handles cross-compilation via rsync + remote build
2. **Always deploy after changes**`./scripts/deploy-to-target.sh --live`
3. **Frontend builds to `web/dist/neode-ui/`** — not `neode-ui/dist/`
4. **Container images**: `scripts/image-versions.sh` is the single source of truth. All scripts use `$*_IMAGE` variables, never hardcoded registry paths.
5. **Type-check before committing**`cd neode-ui && npx vue-tsc -b --noEmit`
## Frontend
- `<script setup lang="ts">` always — no Options API
- Global CSS in `style.css`**never inline Tailwind**
- `.glass-button` for ALL buttons — `.gradient-button` is BANNED
- `.glass-card` for containers, `.path-option-card` for interactive cards
- `translateZ(0)` + `isolation: isolate` on glass elements (Chromium compositor fix)
- Pinia for state, typed RPC client, handle loading/error/empty states
## Backend (Rust)
- No `unwrap()`/`expect()` — use `?` with `.context()`
- `tracing` for logging, never `println!` or log secrets
- Backend binds `127.0.0.1` only — nginx handles external access
- Validate all input before path construction — reject `..`, `/`, null bytes
- `tokio` runtime, timeouts on all external ops
## Security (Post-Pentest)
- RBAC: explicit method allowlists, never prefix matching
- Session cookies: `SameSite=Lax; HttpOnly; Path=/`
- Rate-limit auth endpoints, rotate tokens after privilege escalation
- Validate redirect URLs with `isLocalRedirect()`, never `v-html` with user input
- Container security: drop ALL caps, add only required, `no-new-privileges`, memory limits, health checks
- See `.claude/rules/` for detailed crypto, API, container, and Bitcoin rules
## ISO Build & CI
CI builds on every push to `main` via git.tx1138.com Actions.
```bash
# Manual build on .228:
ssh archipelago@192.168.1.228
cd ~/archy/image-recipe
sudo UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 ./build-auto-installer-iso.sh
```
**Debugging fresh installs** — SSH in and check:
```bash
cat /var/log/archipelago-install.log # Full installer output
cat /var/log/archipelago-first-boot-diagnostics.log # Service status, nginx, LUKS, etc.
sudo archipelago-diagnostics # Re-run diagnostics anytime
```
**Kiosk**: X11 on VT7, console on VT1. `Ctrl+Alt+F1` for terminal, `Ctrl+Alt+F7` for kiosk.
Toggle: `sudo archipelago-kiosk enable|disable|toggle`
## App Integration Checklist
When adding/fixing apps, check ALL of these:
- `core/archipelago/src/api/rpc/package/` — config, capabilities, deps
- `neode-ui/src/views/marketplace/marketplaceData.ts` — marketplace entry
- `image-recipe/configs/nginx-archipelago.conf` — proxy rules (HTTP + HTTPS)
- `scripts/image-versions.sh` — pinned image version
- `scripts/first-boot-containers.sh` — first boot creation
- `scripts/deploy-to-target.sh` — deploy logic
## Git
Commits: `type: description` (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`)
Push to: `git push tx1138 main`
+1 -1
View File
@@ -28,7 +28,7 @@ npm test # Run tests
### Backend (Rust)
Build on a Linux server (Debian 12), **not** macOS:
Build on a Linux server (Debian 13), **not** macOS:
```bash
cargo clippy --all-targets --all-features
-46
View File
@@ -1,46 +0,0 @@
# Demo Deployment via Portainer
Deploy Archipelago with the **mock backend** for demos. No real node required.
## Quick Deploy (Portainer)
1. In Portainer: **Stacks****Add stack**
2. Name: `archy-demo`
3. **Web editor** → paste contents of `docker-compose.demo.yml`
4. Or **Build from repository**: use this repo URL and set Compose path to `docker-compose.demo.yml`
5. Deploy
**Access:** http://your-host:4848
## Mock Backend
- Uses the Node.js mock backend (not the Rust backend)
- Pre-loaded apps, fake data, simulated install/start/stop
- **Login password:** `password123`
## Port
Default: **4848**. To change, edit the ports mapping in `docker-compose.demo.yml`:
```yaml
ports:
- "YOUR_PORT:80"
```
## Chat (Claude AI)
Set `ANTHROPIC_API_KEY` in the Portainer stack environment to enable real AI chat:
1. In the stack editor, add under **Environment variables**:
- `ANTHROPIC_API_KEY` = your Anthropic API key (starts with `sk-ant-api...`)
2. Redeploy the stack
Without this key, chat shows a "not configured" error. The key is passed to the `neode-backend` container which proxies requests to `api.anthropic.com`.
## Dev Mode
`VITE_DEV_MODE=existing` skips setup/onboarding and goes straight to login. For other flows:
- `setup` Password setup screen first
- `onboarding` Experimental onboarding flow
- `existing` Login only (default for demo)
+3 -3
View File
@@ -4,7 +4,7 @@
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, and decentralized identity through a glassmorphism web UI.
[![Debian 12](https://img.shields.io/badge/Debian-12%20Bookworm-a80030)](https://www.debian.org/)
[![Debian 13](https://img.shields.io/badge/Debian-13%20Trixie-a80030)](https://www.debian.org/)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-stable-orange)](https://www.rust-lang.org/)
[![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/)
@@ -81,7 +81,7 @@ Bitcoin (ThunderHub), Storage (FileBrowser, Immich, Nextcloud), Productivity (Pe
### Prerequisites
- macOS or Linux for frontend development
- Linux dev server (Debian 12) for backend builds — **never build Rust on macOS for Linux**
- Linux dev server (Debian 13) for backend builds — **never build Rust on macOS for Linux**
- Node.js 20+, Rust stable toolchain
### Frontend Development
@@ -112,7 +112,7 @@ sudo ./build-auto-installer-iso.sh
## Architecture
```
Debian 12 (Bookworm)
Debian 13 (Trixie)
├── Rootless Podman (30 containers, archy-net DNS)
├── Nginx (reverse proxy, security headers, rate limiting)
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678)
+2 -1
View File
@@ -1,7 +1,7 @@
# Archipelago v1.0.0 Release Notes
**Release Date**: March 2026
**Target Platform**: Debian 12 (Bookworm) — x86_64 and ARM64
**Target Platform**: Debian 13 (Trixie) — x86_64 and ARM64
## What is Archipelago?
@@ -109,3 +109,4 @@ Archipelago is open source. To contribute:
## License
MIT License. See `LICENSE` for details.
# 2026-04-18 ISO build trigger
+39
View File
@@ -0,0 +1,39 @@
# Archipelago App Catalog
Dynamic app catalog for the Archipelago marketplace. Nodes fetch this catalog to discover available apps.
## How it works
1. The Archipelago frontend fetches `catalog.json` from this repo
2. Apps listed here appear in every node's app store automatically
3. When a user installs an app, the backend pulls the Docker image and creates the container
## Adding a new app
Add an entry to `catalog.json`:
```json
{
"id": "my-app",
"title": "My App",
"version": "1.0.0",
"description": "What it does",
"icon": "/assets/img/app-icons/my-app.svg",
"author": "Author",
"category": "data",
"dockerImage": "git.tx1138.com/lfg2025/my-app:1.0.0",
"repoUrl": "https://github.com/...",
"containerConfig": {
"ports": ["8080:8080"],
"volumes": ["/var/lib/archipelago/my-app:/data"],
"env": ["NODE_ENV=production"]
}
}
```
For apps with hardcoded backend configs (Bitcoin, LND, etc.), `containerConfig` is optional.
For new apps, include `containerConfig` so the backend knows how to create the container.
## Categories
money, commerce, data, home, nostr, networking, community, development, l484
+250
View File
@@ -0,0 +1,250 @@
{
"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.",
"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.",
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
"author": "Bitcoin Knots", "category": "money", "tier": "core",
"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.",
"icon": "/assets/img/app-icons/lnd.svg",
"author": "Lightning Labs", "category": "money", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": ["bitcoin-knots"]
},
{
"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", "category": "commerce", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/btcpayserver:1.13.7",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": ["bitcoin-knots"]
},
{
"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", "category": "money", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/mempool-frontend:v3.0.0",
"repoUrl": "https://github.com/mempool/mempool",
"requires": ["bitcoin-knots", "electrumx"]
},
{
"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", "category": "money", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": ["bitcoin-knots"]
},
{
"id": "indeedhub", "title": "IndeeHub", "version": "1.0.0",
"description": "Bitcoin documentary streaming with Nostr identity.",
"icon": "/assets/img/app-icons/indeedhub.png",
"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.1.0",
"description": "Bot arena + 2-player arcade fighter with controller support and Adventure Mode.",
"icon": "/assets/img/app-icons/botfights.svg",
"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.",
"icon": "/assets/img/app-icons/gitea.svg",
"author": "Gitea", "category": "development",
"dockerImage": "docker.io/gitea/gitea:1.23",
"repoUrl": "https://gitea.com"
},
{
"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", "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 with zero-knowledge encryption.",
"icon": "/assets/img/app-icons/vaultwarden.webp",
"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.",
"icon": "/assets/img/app-icons/searxng.png",
"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.",
"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.",
"icon": "/assets/img/app-icons/fedimint.png",
"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. Private and on your hardware.",
"icon": "/assets/img/app-icons/ollama.png",
"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.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"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 movies, music, and photos.",
"icon": "/assets/img/app-icons/jellyfin.webp",
"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 with ML.",
"icon": "/assets/img/app-icons/immich.png",
"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.",
"icon": "/assets/img/app-icons/homeassistant.png",
"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 dashboards.",
"icon": "/assets/img/app-icons/grafana.png",
"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 with WireGuard mesh networking.",
"icon": "/assets/img/app-icons/tailscale.webp",
"author": "Tailscale", "category": "networking", "tier": "recommended",
"dockerImage": "git.tx1138.com/lfg2025/tailscale:stable",
"repoUrl": "https://github.com/tailscale/tailscale"
},
{
"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", "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", "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.",
"icon": "/assets/img/app-icons/dwn.svg",
"author": "TBD", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/dwn-server:main",
"repoUrl": "https://github.com/TBD54566975/dwn-server"
},
{
"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": "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.",
"icon": "/assets/img/app-icons/photoprism.svg",
"author": "PhotoPrism", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism"
}
]
}
+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:
+73
View File
@@ -0,0 +1,73 @@
app:
id: botfights
name: BotFights
version: 1.0.0
description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.
category: community
container:
image: git.tx1138.com/lfg2025/botfights:1.1.0
pull_policy: always
dependencies:
- storage: 500Mi
resources:
cpu_limit: 2
memory_limit: 512Mi
disk_limit: 500Mi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1001
seccomp_profile: default
network_policy: bridge
apparmor_profile: default
ports:
- host: 9100
container: 9100
protocol: tcp # Web UI + API
volumes:
- type: bind
source: botfights-data
target: /app/server/data
- type: tmpfs
target: /tmp
options: [rw,noexec,nosuid,size=64m]
environment:
- NODE_ENV=production
health_check:
type: http
endpoint: http://localhost:9100
path: /api/health
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
interfaces:
main:
name: Web UI
description: Bot arena and arcade fighter with controller support
type: ui
port: 9100
protocol: http
path: /
metadata:
author: Dorian
license: MIT
tags:
- bitcoin
- gaming
- arcade
- fighter
- bots
- competition
- controller
+53
View File
@@ -0,0 +1,53 @@
id: gitea
name: Gitea
version: "1.23"
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
category: development
icon: git-branch
port: 3000
internal_port: 3001
ssh_port: 2222
image: docker.io/gitea/gitea:1.23
tier: optional
requires:
memory_mb: 256
disk_mb: 500
volumes:
- host: /var/lib/archipelago/gitea/data
container: /data
- host: /var/lib/archipelago/gitea/config
container: /etc/gitea
environment:
GITEA__database__DB_TYPE: sqlite3
GITEA__server__SSH_PORT: "2222"
GITEA__server__SSH_LISTEN_PORT: "22"
GITEA__server__LFS_START_SERVER: "true"
GITEA__packages__ENABLED: "true"
GITEA__repository__ENABLE_PUSH_CREATE_USER: "true"
GITEA__repository__ENABLE_PUSH_CREATE_ORG: "true"
# Gitea hardcodes X-Frame-Options: SAMEORIGIN which blocks iframe embedding.
# Container binds to internal_port (3001), nginx proxies public port (3000)
# stripping the X-Frame-Options header so the app works in Archipelago's iframe.
nginx_proxy:
listen: 3000
proxy_pass: "http://127.0.0.1:3001"
extra_headers:
- "proxy_hide_header X-Frame-Options"
- "proxy_hide_header Content-Security-Policy"
health_check:
endpoint: /
interval: 120
timeout: 5
retries: 3
features:
- Git repositories with web UI
- Built-in container/package registry
- Issue tracking and pull requests
- CI/CD via Gitea Actions
- Lightweight (SQLite, no external DB needed)
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.3.1"
version = "1.7.37-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.3.3"
version = "1.7.37-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+223
View File
@@ -0,0 +1,223 @@
//! HTTP handlers for the content-addressed blob store.
//!
//! - `POST /api/blob` — session-authenticated. Raw body is the blob;
//! headers set mime/filename. Returns `{cid, size, mime}`.
//! - `GET /blob/<cid>?cap=<hex>&exp=<epoch>&peer=<pubkey>` — peer-facing.
//! Capability verified against the stored HMAC key; bytes streamed back.
use super::{build_response, ApiHandler};
use crate::blobs::BlobStore;
use anyhow::Result;
use hyper::{Body, HeaderMap, Response, StatusCode};
use std::path::Path;
use std::sync::Arc;
/// Read the archipelago .onion address if Tor has published one, so uploads
/// that need to be publicly reachable (profile pictures, banners) can return
/// a URL a peer outside the LAN can actually fetch. Returns `None` before
/// onboarding or when Tor isn't running — callers fall back to the local
/// self-test URL.
async fn read_self_onion(data_dir: &Path) -> Option<String> {
let hostnames = data_dir.join("tor-hostnames").join("archipelago");
let legacy = Path::new("/var/lib/archipelago/tor-hostnames/archipelago");
for p in [hostnames.as_path(), legacy] {
if let Ok(s) = tokio::fs::read_to_string(p).await {
let trimmed = s.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
impl ApiHandler {
pub(super) async fn handle_blob_upload(
store: &Arc<BlobStore>,
self_pubkey_hex: &str,
data_dir: &Path,
headers: &HeaderMap,
body: hyper::body::Bytes,
) -> Result<Response<Body>> {
let mime = headers
.get("x-blob-mime")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let filename = headers
.get("x-blob-filename")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let bytes = body.to_vec();
// Uploads through /api/blob come from the node owner's session and
// are almost always intended for external consumption (profile
// pictures, banners). Store them public so `/blob/<cid>` serves
// without a capability check — external Nostr clients fetching a
// kind-0 `picture` URL have no cap and can't get one.
match store.put(&bytes, &mime, filename, None, true).await {
Ok(meta) => {
let exp =
(chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS;
let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp);
let self_test_url = format!(
"/blob/{}?cap={}&exp={}&peer={}",
meta.cid, cap, exp, self_pubkey_hex
);
let public_url = match read_self_onion(data_dir).await {
Some(onion) => format!("http://{}/blob/{}", onion, meta.cid),
// Pre-onboarding / Tor-not-up: surface the local path so
// the UI doesn't break; publishing to Nostr should wait
// until Tor is live anyway.
None => format!("/blob/{}", meta.cid),
};
let resp = serde_json::json!({
"cid": meta.cid,
"size": meta.size,
"mime": meta.mime,
"filename": meta.filename,
"public_url": public_url,
"self_test_url": self_test_url,
});
Ok(build_response(
StatusCode::OK,
"application/json",
Body::from(serde_json::to_vec(&resp).unwrap_or_default()),
))
}
Err(e) => Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
Body::from(format!("blob upload failed: {}", e)),
)),
}
}
/// Share-to-mesh iframe intent. Mirrors `handle_blob_upload` but adds
/// CORS headers for the requesting app origin and returns a small JSON
/// payload the app forwards to its parent via postMessage:
/// `{ type: "share-to-mesh", cid, size, mime, filename }`.
pub(super) async fn handle_share_to_mesh(
store: &Arc<BlobStore>,
self_pubkey_hex: &str,
headers: &HeaderMap,
body: hyper::body::Bytes,
origin: &str,
) -> Result<Response<Body>> {
let mime = headers
.get("x-blob-mime")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let filename = headers
.get("x-blob-filename")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let bytes = body.to_vec();
let meta = match store.put(&bytes, &mime, filename, None, false).await {
Ok(m) => m,
Err(e) => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
Body::from(format!("share-to-mesh failed: {}", e)),
));
}
};
// Self-signed capability so the app can preview/download its own
// upload before the user has picked a peer.
let exp = (chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS;
let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp);
let self_url = format!(
"/blob/{}?cap={}&exp={}&peer={}",
meta.cid, cap, exp, self_pubkey_hex
);
let resp = serde_json::json!({
"type": "share-to-mesh",
"cid": meta.cid,
"size": meta.size,
"mime": meta.mime,
"filename": meta.filename,
"self_url": self_url,
});
let body_vec = serde_json::to_vec(&resp).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Access-Control-Allow-Origin", origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(Body::from(body_vec))
.unwrap_or_else(|_| Response::new(Body::from("internal error"))))
}
pub(super) async fn handle_blob_download(
store: &Arc<BlobStore>,
path: &str,
query: &str,
) -> Result<Response<Body>> {
let cid = path.strip_prefix("/blob/").unwrap_or("");
if cid.is_empty() || !cid.chars().all(|c| c.is_ascii_hexdigit()) || cid.len() != 64 {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
Body::from("invalid cid"),
));
}
// Public blobs (profile pictures, banners) bypass the capability
// check — their CID is published on Nostr relays where any reader
// can see it, and external readers have no way to obtain a cap.
// Only blobs explicitly marked public at upload time qualify.
let is_public = store.meta(cid).await.map(|m| m.public).unwrap_or(false);
if !is_public {
let mut cap = None;
let mut exp: Option<u64> = None;
let mut peer = None;
for pair in query.split('&') {
let mut it = pair.splitn(2, '=');
match (it.next(), it.next()) {
(Some("cap"), Some(v)) => cap = Some(v.to_string()),
(Some("exp"), Some(v)) => exp = v.parse().ok(),
(Some("peer"), Some(v)) => peer = Some(v.to_string()),
_ => {}
}
}
let (Some(cap), Some(exp), Some(peer)) = (cap, exp, peer) else {
return Ok(build_response(
StatusCode::UNAUTHORIZED,
"text/plain",
Body::from("missing cap/exp/peer"),
));
};
if let Err(e) = store.verify_capability(cid, &peer, exp, &cap) {
tracing::warn!("blob cap rejected: cid={} peer={} reason={}", cid, peer, e);
return Ok(build_response(
StatusCode::FORBIDDEN,
"text/plain",
Body::from(format!("capability rejected: {}", e)),
));
}
}
let bytes = match store.get(cid).await {
Ok(b) => b,
Err(_) => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
Body::from("blob not found"),
))
}
};
let mime = store
.meta(cid)
.await
.map(|m| m.mime)
.unwrap_or_else(|_| "application/octet-stream".to_string());
Ok(build_response(StatusCode::OK, &mime, Body::from(bytes)))
}
}
+102 -24
View File
@@ -1,9 +1,10 @@
use super::build_response;
use crate::config::Config;
use super::build_response;use crate::content_server;
use crate::content_server;
use anyhow::Result;
use hyper::{Response, StatusCode};
use super::{ApiHandler, is_valid_app_id};
use super::{is_valid_app_id, ApiHandler};
impl ApiHandler {
pub(super) async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
@@ -25,14 +26,22 @@ impl ApiHandler {
})
})
.collect();
let body = serde_json::to_vec(&serde_json::json!({ "items": items }))
.unwrap_or_default();
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
let body =
serde_json::to_vec(&serde_json::json!({ "items": items })).unwrap_or_default();
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(body),
))
}
Err(e) => {
let body = serde_json::json!({ "error": e.to_string() });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Ok(build_response(StatusCode::INTERNAL_SERVER_ERROR, "application/json", hyper::Body::from(body_bytes)))
Ok(build_response(
StatusCode::INTERNAL_SERVER_ERROR,
"application/json",
hyper::Body::from(body_bytes),
))
}
}
}
@@ -44,7 +53,11 @@ impl ApiHandler {
) -> Result<Response<hyper::Body>> {
let content_id = path.strip_prefix("/content/").unwrap_or("");
if content_id.is_empty() || !is_valid_app_id(content_id) {
return Ok(build_response(StatusCode::BAD_REQUEST, "text/plain", hyper::Body::from("Invalid content ID")));
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid content ID"),
));
}
// Extract payment token from X-Payment-Token header
@@ -90,16 +103,17 @@ impl ApiHandler {
start,
end,
total,
}) => {
Ok(Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header("Content-Type", mime_type)
.header("Content-Length", bytes.len().to_string())
.header("Content-Range", format!("bytes {}-{}/{}", start, end, total))
.header("Accept-Ranges", "bytes")
.body(hyper::Body::from(bytes))
.unwrap())
}
}) => Ok(Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header("Content-Type", mime_type)
.header("Content-Length", bytes.len().to_string())
.header(
"Content-Range",
format!("bytes {}-{}/{}", start, end, total),
)
.header("Accept-Ranges", "bytes")
.body(hyper::Body::from(bytes))
.unwrap()),
Ok(content_server::ServeResult::PaymentRequired(price_sats)) => {
let body = serde_json::json!({
"error": "Payment required",
@@ -107,16 +121,80 @@ impl ApiHandler {
"payment_header": "X-Payment-Token",
});
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
Ok(build_response(StatusCode::PAYMENT_REQUIRED, "application/json", hyper::Body::from(body_bytes)))
Ok(build_response(
StatusCode::PAYMENT_REQUIRED,
"application/json",
hyper::Body::from(body_bytes),
))
}
Ok(content_server::ServeResult::Forbidden) => {
Ok(build_response(StatusCode::FORBIDDEN, "application/json", hyper::Body::from(
r#"{"error":"Access denied — federation peer required"}"#,
)))
Ok(content_server::ServeResult::Forbidden) => Ok(build_response(
StatusCode::FORBIDDEN,
"application/json",
hyper::Body::from(r#"{"error":"Access denied — federation peer required"}"#),
)),
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Content not found"),
)),
}
}
/// Serve a degraded preview of paid content (blurred image or first 2% of video).
pub(super) async fn handle_content_preview(
path: &str,
config: &Config,
) -> Result<Response<hyper::Body>> {
// Path format: /content/{id}/preview
let content_id = path
.strip_prefix("/content/")
.and_then(|s| s.strip_suffix("/preview"))
.unwrap_or("");
if content_id.is_empty() || !is_valid_app_id(content_id) {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid content ID"),
));
}
match content_server::serve_content_preview(&config.data_dir, content_id).await {
Ok(content_server::PreviewResult::FullContent(bytes, mime_type)) => {
let len = bytes.len();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", mime_type)
.header("Content-Length", len.to_string())
.body(hyper::Body::from(bytes))
.unwrap())
}
Ok(content_server::ServeResult::NotFound) | Err(_) => {
Ok(build_response(StatusCode::NOT_FOUND, "text/plain", hyper::Body::from("Content not found")))
Ok(content_server::PreviewResult::BlurPreview(bytes, mime_type)) => {
let len = bytes.len();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", mime_type)
.header("Content-Length", len.to_string())
.header("X-Content-Preview", "blur")
.body(hyper::Body::from(bytes))
.unwrap())
}
Ok(content_server::PreviewResult::TruncatedPreview(bytes, mime_type, total_size)) => {
let len = bytes.len();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", mime_type)
.header("Content-Length", len.to_string())
.header("X-Content-Preview", "truncated")
.header("X-Content-Total-Size", total_size.to_string())
.body(hyper::Body::from(bytes))
.unwrap())
}
Ok(content_server::PreviewResult::NotFound) | Err(_) => Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Preview not available"),
)),
}
}
}
+36 -24
View File
@@ -1,5 +1,6 @@
use super::build_response;
use crate::config::Config;
use super::build_response;use crate::network::dwn_store::DwnStore;
use crate::network::dwn_store::DwnStore;
use anyhow::Result;
use hyper::{Response, StatusCode};
@@ -10,11 +11,14 @@ impl ApiHandler {
pub(super) async fn handle_dwn_health(config: &Config) -> Result<Response<hyper::Body>> {
match DwnStore::new(&config.data_dir).await {
Ok(store) => {
let stats = store.stats().await.unwrap_or(crate::network::dwn_store::StoreStats {
message_count: 0,
protocol_count: 0,
total_bytes: 0,
});
let stats = store
.stats()
.await
.unwrap_or(crate::network::dwn_store::StoreStats {
message_count: 0,
protocol_count: 0,
total_bytes: 0,
});
let body = serde_json::json!({
"status": "ok",
"message_count": stats.message_count,
@@ -27,7 +31,11 @@ impl ApiHandler {
.body(hyper::Body::from(body.to_string()))
.unwrap())
}
Err(_) => Ok(build_response(StatusCode::SERVICE_UNAVAILABLE, "application/json", hyper::Body::from(r#"{"status":"unavailable"}"#))),
Err(_) => Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(r#"{"status":"unavailable"}"#),
)),
}
}
@@ -62,12 +70,8 @@ impl ApiHandler {
let mut results = Vec::new();
for message in &messages {
let interface = message["descriptor"]["interface"]
.as_str()
.unwrap_or("");
let method = message["descriptor"]["method"]
.as_str()
.unwrap_or("");
let interface = message["descriptor"]["interface"].as_str().unwrap_or("");
let method = message["descriptor"]["method"].as_str().unwrap_or("");
let result = match (interface, method) {
("Records", "Write") => {
@@ -88,7 +92,9 @@ impl ApiHandler {
Ok(msg) => {
serde_json::json!({"status": {"code": 202}, "entry": msg})
}
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
Err(e) => {
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
}
}
}
} else {
@@ -97,7 +103,9 @@ impl ApiHandler {
.await
{
Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}),
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
Err(e) => {
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
}
}
}
}
@@ -132,26 +140,26 @@ impl ApiHandler {
}
}
("Records", "Read") => {
let record_id = message["descriptor"]["recordId"]
.as_str()
.unwrap_or("");
let record_id = message["descriptor"]["recordId"].as_str().unwrap_or("");
match store.read_message(record_id).await {
Ok(Some(msg)) => {
serde_json::json!({"status": {"code": 200}, "entry": msg})
}
Ok(None) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
Ok(None) => {
serde_json::json!({"status": {"code": 404, "detail": "Record not found"}})
}
Err(e) => {
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
}
}
}
("Records", "Delete") => {
let record_id = message["descriptor"]["recordId"]
.as_str()
.unwrap_or("");
let record_id = message["descriptor"]["recordId"].as_str().unwrap_or("");
match store.delete_message(record_id).await {
Ok(true) => serde_json::json!({"status": {"code": 200}}),
Ok(false) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
Ok(false) => {
serde_json::json!({"status": {"code": 404, "detail": "Record not found"}})
}
Err(e) => {
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
}
@@ -184,6 +192,10 @@ impl ApiHandler {
)
};
Ok(build_response(http_status, "application/json", hyper::Body::from(response_body)))
Ok(build_response(
http_status,
"application/json",
hyper::Body::from(response_body),
))
}
}
+239 -21
View File
@@ -1,3 +1,4 @@
mod blob;
mod content;
mod dwn;
mod node_message;
@@ -7,12 +8,14 @@ mod remote_relay;
mod websocket;
use crate::api::rpc::RpcHandler;
use crate::blobs::BlobStore;
use crate::config::Config;
use crate::monitoring::MetricsStore;
use crate::session::{self, SessionStore};
use crate::state::StateManager;
use anyhow::Result;
use hyper::{Method, Request, Response, StatusCode};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::debug;
@@ -20,7 +23,11 @@ use tracing::debug;
/// Build an HTTP response without unwrap. Falls back to a plain 500 if builder fails.
// Used by handler submodules after unwrap elimination
#[allow(dead_code)]
pub(super) fn build_response(status: StatusCode, content_type: &str, body: hyper::Body) -> Response<hyper::Body> {
pub(super) fn build_response(
status: StatusCode,
content_type: &str,
body: hyper::Body,
) -> Response<hyper::Body> {
Response::builder()
.status(status)
.header("Content-Type", content_type)
@@ -36,6 +43,10 @@ pub struct ApiHandler {
session_store: SessionStore,
/// Broadcast channel for relaying companion app input to remote browsers.
input_relay_tx: broadcast::Sender<String>,
/// Content-addressed blob store for attachments shared over mesh/federation.
blob_store: Arc<BlobStore>,
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
self_pubkey_hex: String,
}
impl ApiHandler {
@@ -56,6 +67,27 @@ impl ApiHandler {
);
let (input_relay_tx, _) = broadcast::channel(64);
// Derive a blob-store capability key from the node's Ed25519 signing
// key. SHA-256 domain-separated so rotating the identity rotates
// every outstanding capability token (intentional — prevents a
// replaced node from honouring old caps).
let identity_dir = config.data_dir.join("identity");
let identity = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
let mut hasher = Sha256::new();
hasher.update(identity.signing_key().to_bytes());
hasher.update(b"|archipelago-blob-cap-v1");
let mut cap_key = [0u8; 32];
cap_key.copy_from_slice(&hasher.finalize());
let blob_store = Arc::new(BlobStore::open(&config.data_dir, cap_key).await?);
let self_pubkey_hex = hex::encode(identity.signing_key().verifying_key().as_bytes());
// Share blob store with the RPC layer so mesh.send-content /
// mesh.fetch-content can reach the same instance (single cap_key,
// single on-disk root) without re-opening it.
rpc_handler
.set_blob_store(blob_store.clone(), self_pubkey_hex.clone())
.await;
Ok(Self {
config,
rpc_handler,
@@ -63,6 +95,8 @@ impl ApiHandler {
metrics_store,
session_store,
input_relay_tx,
blob_store,
self_pubkey_hex,
})
}
@@ -79,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" });
@@ -105,9 +213,7 @@ impl ApiHandler {
/// Validate the Origin header against allowed origins.
/// Returns the matched origin if valid, None if cross-origin is not allowed.
fn validate_origin(&self, headers: &hyper::HeaderMap) -> Option<String> {
let origin = headers
.get("origin")
.and_then(|v| v.to_str().ok())?;
let origin = headers.get("origin").and_then(|v| v.to_str().ok())?;
let allowed = self.allowed_origins();
if allowed.iter().any(|a| a == origin) {
Some(origin.to_string())
@@ -116,10 +222,37 @@ impl ApiHandler {
}
}
pub async fn handle_request(
&self,
req: Request<hyper::Body>,
) -> Result<Response<hyper::Body>> {
/// Permissive origin check for the share-to-mesh iframe intent: any scheme
/// http(s):// followed by the configured host_ip, optionally `:port`. Apps
/// proxied under other ports (APP_PORTS) call this from within the same
/// node, so they share host_ip but not port. The session cookie still has
/// to be valid — this is a sanity check, not the primary auth.
fn validate_app_origin(&self, headers: &hyper::HeaderMap) -> Option<String> {
let origin = headers.get("origin").and_then(|v| v.to_str().ok())?;
// Allow localhost dev server too so the Vite frontend can exercise it.
if self.config.dev_mode && origin == "http://localhost:8100" {
return Some(origin.to_string());
}
let host_ip = &self.config.host_ip;
let matches = |scheme: &str| -> bool {
let prefix = format!("{}{}", scheme, host_ip);
if origin == prefix {
return true;
}
let with_port = format!("{}:", prefix);
origin.starts_with(&with_port)
&& origin[with_port.len()..]
.bytes()
.all(|b| b.is_ascii_digit())
};
if matches("http://") || matches("https://") {
Some(origin.to_string())
} else {
None
}
}
pub async fn handle_request(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
let path = req.uri().path().to_string();
let method = req.method().clone();
@@ -144,7 +277,12 @@ impl ApiHandler {
tracing::warn!("401 WebSocket /ws/db — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await;
return Self::handle_websocket(
req,
self.state_manager.clone(),
self.metrics_store.clone(),
)
.await;
}
// Remote input WebSocket — companion app sends keyboard/mouse events
@@ -167,8 +305,10 @@ impl ApiHandler {
// Convert body to bytes for non-WS routes
let headers = req.headers().clone();
let query_string = req.uri().query().map(|s| s.to_string()).unwrap_or_default();
let (parts, body) = req.into_parts();
let body_bytes = hyper::body::to_bytes(body).await
let body_bytes = hyper::body::to_bytes(body)
.await
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone()));
@@ -196,7 +336,9 @@ impl ApiHandler {
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(serde_json::to_vec(&status).unwrap_or_default()))
.body(hyper::Body::from(
serde_json::to_vec(&status).unwrap_or_default(),
))
.unwrap())
}
@@ -205,19 +347,97 @@ impl ApiHandler {
Self::handle_node_message(body_bytes).await
}
// Mesh typed envelope relay over federation — peers POST
// pre-encoded TypedEnvelope wire bytes here when the envelope is
// too large for a single LoRa frame (primarily ContentRef). No
// session auth: the body carries a pubkey + ed25519 signature
// over the wire bytes which we verify before dispatching.
(Method::POST, "/archipelago/mesh-typed") => {
Self::handle_mesh_typed_relay(self.rpc_handler.clone(), body_bytes).await
}
// Blob upload — local/session use only. Session-authenticated so
// only the node owner can push attachments into the blob store.
(Method::POST, "/api/blob") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
Self::handle_blob_upload(
&self.blob_store,
&self.self_pubkey_hex,
&self.config.data_dir,
&headers,
body_bytes,
)
.await
}
// Share-to-mesh intent — marketplace app iframes POST a file here
// to stage it as a mesh attachment. Same body format as /api/blob
// (raw bytes + X-Blob-Mime/X-Blob-Filename headers). The app is
// expected to postMessage `{type:'share-to-mesh', cid, ...}` to
// its parent window afterwards so the Mesh view can pick it up.
// Authenticated by session cookie + a relaxed Origin check (any
// port on the archipelago host is allowed, so proxied apps on
// their own ports can reach it with credentials:'include').
(Method::POST, "/api/share-to-mesh") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
let origin = match self.validate_app_origin(&headers) {
Some(o) => o,
None => {
return Ok(build_response(
StatusCode::FORBIDDEN,
"text/plain",
hyper::Body::from("origin not allowed"),
))
}
};
Self::handle_share_to_mesh(
&self.blob_store,
&self.self_pubkey_hex,
&headers,
body_bytes,
&origin,
)
.await
}
// Blob download — peer-facing. No session required; authenticated
// by HMAC capability token signed when the blob ref was shared.
(Method::GET, p) if p.starts_with("/blob/") => {
Self::handle_blob_download(&self.blob_store, p, &query_string).await
}
// Content preview — degraded previews for paid content (no auth, no payment)
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => {
Self::handle_content_preview(p, &self.config).await
}
// Content serving — peers access shared content over Tor (no session auth)
(Method::GET, p) if p.starts_with("/content/") => {
Self::handle_content_request(p, &headers, &self.config).await
}
// Content catalog — list available content (no session auth, for peers)
(Method::GET, "/content") => {
Self::handle_content_catalog(&self.config).await
}
(Method::GET, "/content") => Self::handle_content_catalog(&self.config).await,
// 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
@@ -245,14 +465,10 @@ impl ApiHandler {
}
// DWN health — unauthenticated
(Method::GET, "/dwn/health") => {
Self::handle_dwn_health(&self.config).await
}
(Method::GET, "/dwn/health") => Self::handle_dwn_health(&self.config).await,
// DWN message processing — peers access over Tor for sync (no session auth)
(Method::POST, "/dwn") => {
Self::handle_dwn_message(body_bytes, &self.config).await
}
(Method::POST, "/dwn") => Self::handle_dwn_message(body_bytes, &self.config).await,
_ => Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
@@ -266,7 +482,9 @@ impl ApiHandler {
fn is_valid_app_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& id.as_bytes()[0] != b'-'
}
+177 -11
View File
@@ -1,14 +1,20 @@
use super::build_response;
use crate::api::rpc::RpcHandler;
use crate::node_message as node_msg;
use super::build_response;use anyhow::Result;
use anyhow::Result;
use hyper::{Response, StatusCode};
use std::sync::Arc;
use super::{ApiHandler, is_valid_pubkey_hex, sanitize_html, sanitize_log_string};
use super::{is_valid_pubkey_hex, sanitize_html, sanitize_log_string, ApiHandler};
impl ApiHandler {
pub(super) async fn handle_node_message(body: hyper::body::Bytes) -> Result<Response<hyper::Body>> {
pub(super) async fn handle_node_message(
body: hyper::body::Bytes,
) -> Result<Response<hyper::Body>> {
#[derive(serde::Deserialize)]
struct Incoming {
from_pubkey: Option<String>,
from_name: Option<String>,
message: Option<String>,
signature: Option<String>,
#[serde(default)]
@@ -16,21 +22,31 @@ impl ApiHandler {
}
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
from_pubkey: None,
from_name: None,
message: None,
signature: None,
encrypted: false,
});
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref()) {
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref())
{
// Validate from_pubkey is a valid hex ed25519 pubkey
if !is_valid_pubkey_hex(from) {
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#)));
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#),
));
}
// Verify ed25519 signature if provided (required for trusted messages)
if let Some(sig_hex) = &incoming.signature {
match crate::identity::NodeIdentity::verify(from, msg.as_bytes(), sig_hex) {
Ok(true) => {}
_ => {
return Ok(build_response(StatusCode::FORBIDDEN, "application/json", hyper::Body::from(r#"{"error":"Invalid signature"}"#)));
return Ok(build_response(
StatusCode::FORBIDDEN,
"application/json",
hyper::Body::from(r#"{"error":"Invalid signature"}"#),
));
}
}
}
@@ -44,12 +60,23 @@ impl ApiHandler {
Ok(node_id) => {
match node_msg::decrypt_from_peer(node_id.signing_key(), from, msg) {
Ok(decrypted) => {
tracing::info!("Decrypted E2E message from {}...", &from[..16.min(from.len())]);
tracing::info!(
"Decrypted E2E message from {}...",
&from[..16.min(from.len())]
);
decrypted
}
Err(e) => {
tracing::warn!("E2E decryption failed from {}: {}", &from[..16.min(from.len())], e);
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(r#"{"error":"Decryption failed"}"#)));
tracing::warn!(
"E2E decryption failed from {}: {}",
&from[..16.min(from.len())],
e
);
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"Decryption failed"}"#),
));
}
}
}
@@ -62,13 +89,152 @@ impl ApiHandler {
msg.clone()
};
// Detect a `connection_accepted` reply: the remote peer just
// approved an outbound request we sent, so mirror their add on
// our side (bidirectional peering without a manual second
// click). JSON-shape only — any non-matching payload stays in
// the normal received-messages store below.
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&plaintext) {
if val.get("type").and_then(|v| v.as_str()) == Some("connection_accepted") {
if let (Some(their_onion), Some(their_pubkey)) = (
val.get("from_onion").and_then(|v| v.as_str()),
val.get("from_pubkey").and_then(|v| v.as_str()),
) {
let data_dir = std::path::Path::new("/var/lib/archipelago");
let peer = crate::peers::KnownPeer {
onion: their_onion.to_string(),
pubkey: their_pubkey.to_string(),
name: val
.get("from_name")
.and_then(|v| v.as_str())
.map(String::from),
added_at: Some(chrono::Utc::now().to_rfc3339()),
};
match crate::peers::add_peer(data_dir, peer).await {
Ok(_) => tracing::info!(
from = %sanitize_log_string(from),
"Auto-added peer after connection_accepted"
),
Err(e) => tracing::warn!(
from = %sanitize_log_string(from),
error = %e,
"Failed to auto-add peer on connection_accepted"
),
}
}
return Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(r#"{"ok":true,"handled":"connection_accepted"}"#),
));
}
}
let safe_from = sanitize_log_string(from);
let safe_msg = sanitize_log_string(&plaintext);
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
let clean_from = sanitize_html(from);
let clean_msg = sanitize_html(&plaintext);
node_msg::store_received(&clean_from, &clean_msg).await;
let clean_name = incoming.from_name.as_deref().map(sanitize_html);
node_msg::store_received(&clean_from, &clean_msg, clean_name.as_deref()).await;
}
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(r#"{"ok":true}"#)))
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(r#"{"ok":true}"#),
))
}
/// Federation-routed mesh typed envelope. Body:
/// `{from_pubkey, from_name?, typed_envelope_b64, signature}`
/// Signature is ed25519 over the raw wire bytes, verified against
/// from_pubkey before dispatch.
pub(super) async fn handle_mesh_typed_relay(
rpc_handler: Arc<RpcHandler>,
body: hyper::body::Bytes,
) -> Result<Response<hyper::Body>> {
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
#[derive(serde::Deserialize)]
struct Incoming {
from_pubkey: String,
#[serde(default)]
from_name: Option<String>,
typed_envelope_b64: String,
signature: String,
}
let incoming: Incoming = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(format!(r#"{{"error":"bad json: {}"}}"#, e)),
));
}
};
if !is_valid_pubkey_hex(&incoming.from_pubkey) {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"invalid pubkey"}"#),
));
}
let wire = match BASE64.decode(incoming.typed_envelope_b64.as_bytes()) {
Ok(v) => v,
Err(_) => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"bad base64"}"#),
));
}
};
match crate::identity::NodeIdentity::verify(
&incoming.from_pubkey,
&wire,
&incoming.signature,
) {
Ok(true) => {}
_ => {
return Ok(build_response(
StatusCode::FORBIDDEN,
"application/json",
hyper::Body::from(r#"{"error":"signature rejected"}"#),
));
}
}
// Inject into mesh state via the shared MeshService. Mirrors a radio
// receive, so the message lands in the same chat stream as LoRa-
// delivered messages from the same peer.
let service = rpc_handler.mesh_service_arc();
let svc_guard = service.read().await;
let Some(svc) = svc_guard.as_ref() else {
return Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(r#"{"error":"mesh not running"}"#),
));
};
if let Err(e) = svc
.inject_typed_from_federation(
&incoming.from_pubkey,
incoming.from_name.as_deref(),
wire,
)
.await
{
tracing::warn!("mesh-typed relay inject failed: {}", e);
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(format!(r#"{{"error":"{}"}}"#, e)),
));
}
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(r#"{"ok":true}"#),
))
}
}
+31 -16
View File
@@ -1,10 +1,11 @@
use super::build_response;
use crate::api::rpc::RpcHandler;
use super::build_response;use crate::electrs_status;
use crate::electrs_status;
use anyhow::Result;
use hyper::{Response, StatusCode};
use std::sync::Arc;
use super::{ApiHandler, is_valid_app_id};
use super::{is_valid_app_id, ApiHandler};
impl ApiHandler {
pub(super) async fn handle_container_logs_http(
@@ -16,16 +17,15 @@ impl ApiHandler {
.strip_prefix("/api/container/logs")
.and_then(|s| s.strip_prefix('?'))
.unwrap_or("");
let params: std::collections::HashMap<String, String> =
query
.split('&')
.filter_map(|p| {
let mut it = p.splitn(2, '=');
let k = it.next()?.to_string();
let v = it.next()?.to_string();
Some((k, v))
})
.collect();
let params: std::collections::HashMap<String, String> = query
.split('&')
.filter_map(|p| {
let mut it = p.splitn(2, '=');
let k = it.next()?.to_string();
let v = it.next()?.to_string();
Some((k, v))
})
.collect();
let app_id = params.get("app_id").map(|s| s.as_str()).unwrap_or("lnd");
@@ -33,7 +33,11 @@ impl ApiHandler {
if !is_valid_app_id(app_id) {
let body = serde_json::json!({ "error": "Invalid app_id" });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(body_bytes)));
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(body_bytes),
));
}
let lines = params
@@ -72,7 +76,11 @@ impl ApiHandler {
pub(super) async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
let status = electrs_status::get_electrs_sync_status().await;
let body = serde_json::to_vec(&status).unwrap_or_default();
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(body),
))
}
pub(super) async fn handle_lnd_connect_info(
@@ -81,7 +89,11 @@ impl ApiHandler {
match rpc.handle_lnd_connect_info().await {
Ok(val) => {
let body = serde_json::to_vec(&val).unwrap_or_default();
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(body),
))
}
Err(e) => Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
@@ -93,7 +105,10 @@ impl ApiHandler {
}
}
pub(super) async fn handle_lnd_proxy(path: &str, cors_origin: &str) -> Result<Response<hyper::Body>> {
pub(super) async fn handle_lnd_proxy(
path: &str,
cors_origin: &str,
) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
let url = format!("http://127.0.0.1:8080{}", suffix);
match reqwest::get(&url).await {
+172 -53
View File
@@ -4,7 +4,6 @@ use hyper::{Request, Response};
use hyper_ws_listener::WsStream;
use serde::Deserialize;
use std::time::Instant;
use tokio::process::Command;
use tokio::sync::broadcast;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info, warn};
@@ -14,27 +13,131 @@ use super::ApiHandler;
/// Allowed xdotool key names. Only these pass validation.
const ALLOWED_KEYS: &[&str] = &[
// Letters
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
"N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
"a",
"b",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"j",
"k",
"l",
"m",
"n",
"o",
"p",
"q",
"r",
"s",
"t",
"u",
"v",
"w",
"x",
"y",
"z",
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
// Numbers
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
// Navigation
"Up", "Down", "Left", "Right",
"Return", "Escape", "Tab", "BackSpace", "Delete",
"Home", "End", "Prior", "Next", // Prior=PageUp, Next=PageDown
"Up",
"Down",
"Left",
"Right",
"Return",
"Escape",
"Tab",
"BackSpace",
"Delete",
"Home",
"End",
"Prior",
"Next", // Prior=PageUp, Next=PageDown
// Modifiers (for combos like shift+a)
"space", "minus", "equal", "bracketleft", "bracketright",
"backslash", "semicolon", "apostrophe", "grave", "comma",
"period", "slash",
"space",
"minus",
"equal",
"bracketleft",
"bracketright",
"backslash",
"semicolon",
"apostrophe",
"grave",
"comma",
"period",
"slash",
// Function keys
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12",
"F1",
"F2",
"F3",
"F4",
"F5",
"F6",
"F7",
"F8",
"F9",
"F10",
"F11",
"F12",
// Symbols — xdotool names
"exclam", "at", "numbersign", "dollar", "percent", "asciicircum",
"ampersand", "asterisk", "parenleft", "parenright", "underscore",
"plus", "braceleft", "braceright", "bar", "colon", "quotedbl",
"less", "greater", "question", "asciitilde",
"exclam",
"at",
"numbersign",
"dollar",
"percent",
"asciicircum",
"ampersand",
"asterisk",
"parenleft",
"parenright",
"underscore",
"plus",
"braceleft",
"braceright",
"bar",
"colon",
"quotedbl",
"less",
"greater",
"question",
"asciitilde",
];
/// Validate a key name against the whitelist.
@@ -55,7 +158,14 @@ fn validate_key(key: &str) -> bool {
#[serde(tag = "t")]
enum InputCommand {
#[serde(rename = "k")]
Key { k: String },
Key {
k: String,
/// Optional player ID (1 or 2) for multi-player arcade games.
/// When absent, input is broadcast without player tagging.
#[serde(default)]
#[allow(dead_code)]
p: Option<u8>,
},
#[serde(rename = "m")]
MouseMove { x: i32, y: i32 },
#[serde(rename = "c")]
@@ -66,50 +176,28 @@ enum InputCommand {
Ping,
}
async fn xdotool(args: &[&str]) -> Result<()> {
let output = Command::new("xdotool")
.env("DISPLAY", ":0")
.args(args)
.output()
.await
.context("xdotool execution failed")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
debug!("xdotool error: {}", stderr);
}
Ok(())
}
/// Validate and acknowledge input — relay-only, no xdotool.
/// All input is forwarded to browser clients via the broadcast channel;
/// the browser's remote-relay.ts dispatches DOM events from there.
async fn handle_input(msg: &str) -> Result<Option<String>> {
let cmd: InputCommand = serde_json::from_str(msg)
.context("invalid input command")?;
let cmd: InputCommand = serde_json::from_str(msg).context("invalid input command")?;
match cmd {
InputCommand::Key { ref k } => {
InputCommand::Key { ref k, .. } => {
if !validate_key(k) {
warn!("rejected key: {}", k);
return Ok(Some(r#"{"t":"e","m":"invalid key"}"#.to_string()));
}
xdotool(&["key", "--clearmodifiers", k]).await?;
}
InputCommand::MouseMove { x, y } => {
let x = x.clamp(-50, 50);
let y = y.clamp(-50, 50);
let xs = x.to_string();
let ys = y.to_string();
xdotool(&["mousemove_relative", "--", &xs, &ys]).await?;
let _x = x.clamp(-50, 50);
let _y = y.clamp(-50, 50);
}
InputCommand::Click { b } => {
let b = b.clamp(1, 3);
let bs = b.to_string();
xdotool(&["click", &bs]).await?;
let _b = b.clamp(1, 3);
}
InputCommand::Scroll { y } => {
// xdotool: button 4 = scroll up, button 5 = scroll down
let btn = if y < 0 { "4" } else { "5" };
let count = y.unsigned_abs().clamp(1, 10).to_string();
xdotool(&["click", "--repeat", &count, btn]).await?;
let _y = y.clamp(-10, 10);
}
InputCommand::Ping => {
return Ok(Some(r#"{"t":"p"}"#.to_string()));
@@ -124,6 +212,15 @@ impl ApiHandler {
req: Request<hyper::Body>,
relay_tx: broadcast::Sender<String>,
) -> Result<Response<hyper::Body>> {
// Extract optional player ID from query string: /ws/remote-input?p=1
let player_id: Option<u8> = req
.uri()
.query()
.and_then(|q| q.split('&').find(|s| s.starts_with("p=")))
.and_then(|s| s.get(2..))
.and_then(|v| v.parse().ok())
.filter(|&p: &u8| p == 1 || p == 2);
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
@@ -185,8 +282,28 @@ impl ApiHandler {
continue; // silently drop
}
// Relay to connected browsers (best-effort, ignore if no receivers)
let _ = relay_tx.send(text.clone());
// Relay to browser clients. If this connection has a
// player ID from query string and the message is a key
// event without a player field, inject it so the browser
// can route input to the correct player.
let relay_text = if let Some(pid) = player_id {
if text.contains(r#""t":"k""#) && !text.contains(r#""p":"#) {
// Insert "p":N before the closing brace
if let Some(pos) = text.rfind('}') {
let mut tagged = text[..pos].to_string();
tagged.push_str(&format!(r#","p":{}"#, pid));
tagged.push('}');
tagged
} else {
text.clone()
}
} else {
text.clone()
}
} else {
text.clone()
};
let _ = relay_tx.send(relay_text);
match handle_input(&text).await {
Ok(Some(reply)) => {
@@ -219,11 +336,13 @@ impl ApiHandler {
}
}
info!("Remote input disconnected ({} messages processed)", msg_count);
info!(
"Remote input disconnected ({} messages processed)",
msg_count
);
});
}
Ok(response)
}
}
+93 -46
View File
@@ -75,7 +75,9 @@ impl RpcHandler {
let (data, _) = self.state_manager.get_snapshot().await;
let app_count = data.package_data.len();
let running_count = data.package_data.values()
let running_count = data
.package_data
.values()
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running))
.count();
@@ -88,7 +90,8 @@ impl RpcHandler {
.args(["MemTotal", "/proc/meminfo"])
.output()
.await;
let total_ram_mb = mem_output.ok()
let total_ram_mb = mem_output
.ok()
.and_then(|o| {
let s = String::from_utf8_lossy(&o.stdout);
s.split_whitespace().nth(1)?.parse::<u64>().ok()
@@ -139,46 +142,66 @@ impl RpcHandler {
// Anonymous node ID — SHA-256 hash of the DID (not the DID itself)
let node_id = {
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(data.server_info.pubkey.as_bytes());
hex::encode(hasher.finalize())[..16].to_string()
};
// Container states
let containers: Vec<serde_json::Value> = data.package_data.iter().map(|(id, pkg)| {
serde_json::json!({
"id": id,
"state": format!("{:?}", pkg.state),
"version": pkg.manifest.version,
let containers: Vec<serde_json::Value> = data
.package_data
.iter()
.map(|(id, pkg)| {
serde_json::json!({
"id": id,
"state": format!("{:?}", pkg.state),
"version": pkg.manifest.version,
})
})
}).collect();
.collect();
// System stats
let cpu_cores = std::thread::available_parallelism()
.map(|n| n.get()).unwrap_or(0);
.map(|n| n.get())
.unwrap_or(0);
let mem_output = tokio::process::Command::new("grep")
.args(["MemTotal", "/proc/meminfo"])
.output().await;
let total_ram_mb = mem_output.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().nth(1)?.parse::<u64>().ok())
.map(|kb| kb / 1024).unwrap_or(0);
.output()
.await;
let total_ram_mb = mem_output
.ok()
.and_then(|o| {
String::from_utf8_lossy(&o.stdout)
.split_whitespace()
.nth(1)?
.parse::<u64>()
.ok()
})
.map(|kb| kb / 1024)
.unwrap_or(0);
// Uptime
let uptime_secs = tokio::fs::read_to_string("/proc/uptime").await
let uptime_secs = tokio::fs::read_to_string("/proc/uptime")
.await
.ok()
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
.map(|f| f as u64)
.unwrap_or(0);
// Recent alerts from metrics store
let recent_alerts: Vec<serde_json::Value> = self.metrics_store.get_fired_alerts(10).await
let recent_alerts: Vec<serde_json::Value> = self
.metrics_store
.get_fired_alerts(10)
.await
.into_iter()
.map(|a| serde_json::json!({
"rule": format!("{:?}", a.kind),
"message": a.message,
"timestamp": a.timestamp,
}))
.map(|a| {
serde_json::json!({
"rule": format!("{:?}", a.kind),
"message": a.message,
"timestamp": a.timestamp,
})
})
.collect();
let report = serde_json::json!({
@@ -208,11 +231,15 @@ impl RpcHandler {
/// Receive a telemetry report from a fleet node.
/// Stores it in telemetry-fleet/ directory, indexed by node_id.
/// Does NOT require auth — called by remote nodes posting reports.
pub(super) async fn handle_telemetry_ingest(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
pub(super) async fn handle_telemetry_ingest(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let report = params.context("Missing telemetry report payload")?;
// Validate required fields
let node_id = report.get("node_id")
let node_id = report
.get("node_id")
.and_then(|v| v.as_str())
.context("Missing required field: node_id")?;
if node_id.is_empty() || node_id.len() > 64 {
@@ -222,39 +249,45 @@ impl RpcHandler {
if node_id.contains('/') || node_id.contains('\\') || node_id.contains("..") {
anyhow::bail!("Invalid node_id: contains disallowed characters");
}
let _version = report.get("version")
let _version = report
.get("version")
.and_then(|v| v.as_str())
.context("Missing required field: version")?;
let _reported_at = report.get("reported_at")
let _reported_at = report
.get("reported_at")
.and_then(|v| v.as_str())
.context("Missing required field: reported_at")?;
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
tokio::fs::create_dir_all(&fleet_dir).await
tokio::fs::create_dir_all(&fleet_dir)
.await
.context("Failed to create telemetry-fleet directory")?;
// Write latest report (overwrites previous)
let latest_path = fleet_dir.join(format!("{}.json", node_id));
let report_json = serde_json::to_string_pretty(&report)
.context("Failed to serialize report")?;
tokio::fs::write(&latest_path, &report_json).await
let report_json =
serde_json::to_string_pretty(&report).context("Failed to serialize report")?;
tokio::fs::write(&latest_path, &report_json)
.await
.context("Failed to write latest fleet report")?;
// Append to history file (cap at 200 entries)
let history_path = fleet_dir.join(format!("{}-history.json", node_id));
let mut history: Vec<serde_json::Value> = match tokio::fs::read_to_string(&history_path).await {
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
Err(_) => Vec::new(),
};
let mut history: Vec<serde_json::Value> =
match tokio::fs::read_to_string(&history_path).await {
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
Err(_) => Vec::new(),
};
history.push(report.clone());
// Keep only the last 200 entries
if history.len() > 200 {
let start = history.len() - 200;
history = history.split_off(start);
}
let history_json = serde_json::to_string_pretty(&history)
.context("Failed to serialize history")?;
tokio::fs::write(&history_path, &history_json).await
let history_json =
serde_json::to_string_pretty(&history).context("Failed to serialize history")?;
tokio::fs::write(&history_path, &history_json)
.await
.context("Failed to write fleet history")?;
debug!(node_id = %node_id, "Ingested fleet telemetry report");
@@ -274,7 +307,8 @@ impl RpcHandler {
}
let mut nodes: Vec<serde_json::Value> = Vec::new();
let mut entries = tokio::fs::read_dir(&fleet_dir).await
let mut entries = tokio::fs::read_dir(&fleet_dir)
.await
.context("Failed to read telemetry-fleet directory")?;
while let Some(entry) = entries.next_entry().await? {
@@ -290,7 +324,8 @@ impl RpcHandler {
match serde_json::from_str::<serde_json::Value>(&data) {
Ok(mut report) => {
// Compute online/offline status from reported_at
let is_online = report.get("reported_at")
let is_online = report
.get("reported_at")
.and_then(|v| v.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| {
@@ -300,7 +335,8 @@ impl RpcHandler {
.unwrap_or(false);
// Compute human-readable last_seen
let last_seen = report.get("reported_at")
let last_seen = report
.get("reported_at")
.and_then(|v| v.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| {
@@ -349,20 +385,29 @@ impl RpcHandler {
/// Get history for a specific fleet node.
/// Reads telemetry-fleet/{node_id}-history.json.
pub(super) async fn handle_telemetry_fleet_node_history(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
pub(super) async fn handle_telemetry_fleet_node_history(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let p = params.context("Missing params")?;
let node_id = p.get("node_id")
let node_id = p
.get("node_id")
.and_then(|v| v.as_str())
.context("Missing required field: node_id")?;
// Sanitize node_id
if node_id.is_empty() || node_id.len() > 64
|| node_id.contains('/') || node_id.contains('\\') || node_id.contains("..")
if node_id.is_empty()
|| node_id.len() > 64
|| node_id.contains('/')
|| node_id.contains('\\')
|| node_id.contains("..")
{
anyhow::bail!("Invalid node_id");
}
let history_path = self.config.data_dir
let history_path = self
.config
.data_dir
.join("telemetry-fleet")
.join(format!("{}-history.json", node_id));
@@ -387,7 +432,8 @@ impl RpcHandler {
}
let mut all_alerts: Vec<serde_json::Value> = Vec::new();
let mut entries = tokio::fs::read_dir(&fleet_dir).await
let mut entries = tokio::fs::read_dir(&fleet_dir)
.await
.context("Failed to read telemetry-fleet directory")?;
while let Some(entry) = entries.next_entry().await? {
@@ -407,7 +453,8 @@ impl RpcHandler {
Err(_) => continue,
};
let node_id = report.get("node_id")
let node_id = report
.get("node_id")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
+33 -1
View File
@@ -32,6 +32,26 @@ impl RpcHandler {
}
tracing::info!("[onboarding] login successful");
// Ensure NostrVPN config exists — covers the case where onboardingComplete
// was never called (e.g., user took the "already set up" shortcut).
let data_dir = self.config.data_dir.clone();
tokio::spawn(async move {
// Quick check: if config.toml already exists, skip
let config_path = data_dir.join("nostr-vpn/.config/nvpn/config.toml");
if config_path.exists() {
return;
}
// Identity must exist for VPN config
if !data_dir.join("identity/nostr_pubkey").exists() {
return;
}
match crate::vpn::configure_nostr_vpn(&data_dir).await {
Ok(()) => tracing::info!("[login] NostrVPN auto-configured on first login"),
Err(e) => tracing::debug!("[login] NostrVPN auto-config skipped: {}", e),
}
});
Ok(serde_json::Value::Null)
}
@@ -84,7 +104,9 @@ impl RpcHandler {
let is_setup = self.auth_manager.is_setup().await?;
if is_setup {
tracing::warn!("[onboarding] setup rejected — already set up");
return Err(anyhow::anyhow!("Already set up. Use auth.changePassword to change."));
return Err(anyhow::anyhow!(
"Already set up. Use auth.changePassword to change."
));
}
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
@@ -106,6 +128,16 @@ impl RpcHandler {
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
self.auth_manager.complete_onboarding().await?;
tracing::info!("[onboarding] onboarding marked complete");
// Auto-configure NostrVPN with the node's Nostr identity
let data_dir = self.config.data_dir.clone();
tokio::spawn(async move {
match crate::vpn::configure_nostr_vpn(&data_dir).await {
Ok(()) => tracing::info!("[onboarding] NostrVPN configured and started"),
Err(e) => tracing::warn!("[onboarding] NostrVPN setup (non-fatal): {}", e),
}
});
Ok(serde_json::json!(true))
}
+58 -18
View File
@@ -17,7 +17,11 @@ fn validate_s3_endpoint(endpoint: &str) -> Result<()> {
// Strip port if present (handle IPv6 bracket notation)
let host = if host_port.starts_with('[') {
// IPv6: [::1]:443
host_port.split(']').next().unwrap_or("").trim_start_matches('[')
host_port
.split(']')
.next()
.unwrap_or("")
.trim_start_matches('[')
} else {
host_port.split(':').next().unwrap_or("")
};
@@ -40,12 +44,12 @@ fn validate_s3_endpoint(endpoint: &str) -> Result<()> {
|| (v4.octets()[0] == 172 && (v4.octets()[1] & 0xf0) == 16) // 172.16.0.0/12
|| (v4.octets()[0] == 192 && v4.octets()[1] == 168) // 192.168.0.0/16
|| (v4.octets()[0] == 169 && v4.octets()[1] == 254) // 169.254.0.0/16
|| v4.is_unspecified() // 0.0.0.0
|| v4.is_unspecified() // 0.0.0.0
}
IpAddr::V6(v6) => {
v6.is_loopback() // ::1
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7
|| v6.is_unspecified() // ::
|| v6.is_unspecified() // ::
}
};
if is_private {
@@ -109,7 +113,13 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
// Validate backup ID to prevent path traversal
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
if id.is_empty()
|| id.len() > 128
|| id.contains('/')
|| id.contains('\\')
|| id.contains("..")
|| id.contains('\0')
{
anyhow::bail!("Invalid backup ID");
}
@@ -137,7 +147,13 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
// Validate backup ID to prevent path traversal
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
if id.is_empty()
|| id.len() > 128
|| id.contains('/')
|| id.contains('\\')
|| id.contains("..")
|| id.contains('\0')
{
anyhow::bail!("Invalid backup ID");
}
@@ -156,7 +172,13 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
// Validate backup ID to prevent path traversal
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
if id.is_empty()
|| id.len() > 128
|| id.contains('/')
|| id.contains('\\')
|| id.contains("..")
|| id.contains('\0')
{
anyhow::bail!("Invalid backup ID");
}
@@ -242,7 +264,13 @@ impl RpcHandler {
let _region = params["region"].as_str().unwrap_or("us-east-1");
// Validate backup ID
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
if id.is_empty()
|| id.len() > 128
|| id.contains('/')
|| id.contains('\\')
|| id.contains("..")
|| id.contains('\0')
{
anyhow::bail!("Invalid backup ID");
}
@@ -281,7 +309,11 @@ impl RpcHandler {
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("S3 upload failed ({}): {}", status, &body[..200.min(body.len())]);
anyhow::bail!(
"S3 upload failed ({}): {}",
status,
&body[..200.min(body.len())]
);
}
info!(id = %id, bucket = %bucket, size = %size, "Backup uploaded to S3");
@@ -317,7 +349,13 @@ impl RpcHandler {
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'secret_key' parameter"))?;
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
if id.is_empty()
|| id.len() > 128
|| id.contains('/')
|| id.contains('\\')
|| id.contains("..")
|| id.contains('\0')
{
anyhow::bail!("Invalid backup ID");
}
@@ -343,14 +381,19 @@ impl RpcHandler {
anyhow::bail!("S3 download failed ({})", status);
}
let bytes = response.bytes().await.context("Failed to read S3 response")?;
let bytes = response
.bytes()
.await
.context("Failed to read S3 response")?;
let size = bytes.len();
// Save to backups directory
let bak_dir = self.config.data_dir.join("backups");
tokio::fs::create_dir_all(&bak_dir).await?;
let bak_path = full::backup_file_path(&self.config.data_dir, id);
tokio::fs::write(&bak_path, &bytes).await.context("Failed to write backup file")?;
tokio::fs::write(&bak_path, &bytes)
.await
.context("Failed to write backup file")?;
info!(id = %id, bucket = %bucket, size = %size, "Backup downloaded from S3");
@@ -376,13 +419,10 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
let identity_dir = self.config.data_dir.join("identity");
let (did, pubkey) = crate::backup::restore_encrypted_backup(
&identity_dir,
backup,
passphrase,
)
.await
.context("Identity restore failed")?;
let (did, pubkey) =
crate::backup::restore_encrypted_backup(&identity_dir, backup, passphrase)
.await
.context("Identity restore failed")?;
info!(did = %did, "Identity restored from backup");
+57 -35
View File
@@ -57,16 +57,12 @@ impl RpcHandler {
let info = BitcoinInfo {
block_height: blockchain_info.blocks.unwrap_or(0),
sync_progress: blockchain_info
.verification_progress
.unwrap_or(0.0),
sync_progress: blockchain_info.verification_progress.unwrap_or(0.0),
chain: blockchain_info.chain.unwrap_or_else(|| "unknown".into()),
difficulty: blockchain_info.difficulty.unwrap_or(0.0),
mempool_size: mempool_info.bytes.unwrap_or(0),
mempool_tx_count: mempool_info.size.unwrap_or(0),
verification_progress: blockchain_info
.verification_progress
.unwrap_or(0.0),
verification_progress: blockchain_info.verification_progress.unwrap_or(0.0),
};
Ok(serde_json::to_value(info)?)
@@ -116,19 +112,24 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let password = params.get("password")
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?;
let wallet_name = params.get("wallet_name")
let wallet_name = params
.get("wallet_name")
.and_then(|v| v.as_str())
.unwrap_or("archipelago");
// Verify user password.
self.auth_manager.verify_password(password).await
self.auth_manager
.verify_password(password)
.await
.context("Password verification failed")?;
// Load encrypted seed.
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password).await
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password)
.await
.context("Failed to load encrypted seed")?;
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
@@ -142,25 +143,30 @@ impl RpcHandler {
.context("Failed to create HTTP client")?;
// Step 1: Create a blank descriptor wallet.
let create_result = self.bitcoin_rpc_call::<serde_json::Value>(
&client,
"createwallet",
&[
serde_json::json!(wallet_name), // wallet_name
serde_json::json!(false), // disable_private_keys
serde_json::json!(true), // blank
serde_json::json!(""), // passphrase
serde_json::json!(false), // avoid_reuse
serde_json::json!(true), // descriptors
],
).await;
let create_result = self
.bitcoin_rpc_call::<serde_json::Value>(
&client,
"createwallet",
&[
serde_json::json!(wallet_name), // wallet_name
serde_json::json!(false), // disable_private_keys
serde_json::json!(true), // blank
serde_json::json!(""), // passphrase
serde_json::json!(false), // avoid_reuse
serde_json::json!(true), // descriptors
],
)
.await;
match create_result {
Ok(_) => tracing::info!("Created blank descriptor wallet '{}'", wallet_name),
Err(e) => {
let msg = e.to_string();
if msg.contains("already exists") {
tracing::info!("Wallet '{}' already exists, importing descriptors", wallet_name);
tracing::info!(
"Wallet '{}' already exists, importing descriptors",
wallet_name
);
} else {
xprv_str.zeroize();
return Err(e.context("Failed to create wallet"));
@@ -174,18 +180,30 @@ impl RpcHandler {
let internal_desc = format!("wpkh({}/1/*)", xprv_str);
// Get checksums from Bitcoin Core.
let ext_info: serde_json::Value = self.bitcoin_rpc_call(
&client, "getdescriptorinfo", &[serde_json::json!(external_desc)],
).await.context("getdescriptorinfo failed for external descriptor")?;
let ext_info: serde_json::Value = self
.bitcoin_rpc_call(
&client,
"getdescriptorinfo",
&[serde_json::json!(external_desc)],
)
.await
.context("getdescriptorinfo failed for external descriptor")?;
let int_info: serde_json::Value = self.bitcoin_rpc_call(
&client, "getdescriptorinfo", &[serde_json::json!(internal_desc)],
).await.context("getdescriptorinfo failed for internal descriptor")?;
let int_info: serde_json::Value = self
.bitcoin_rpc_call(
&client,
"getdescriptorinfo",
&[serde_json::json!(internal_desc)],
)
.await
.context("getdescriptorinfo failed for internal descriptor")?;
let ext_desc_with_checksum = ext_info.get("descriptor")
let ext_desc_with_checksum = ext_info
.get("descriptor")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
let int_desc_with_checksum = int_info.get("descriptor")
let int_desc_with_checksum = int_info
.get("descriptor")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
@@ -206,14 +224,18 @@ impl RpcHandler {
}
]);
let _import_result: serde_json::Value = self.bitcoin_rpc_call(
&client, "importdescriptors", &[import_params],
).await.context("importdescriptors failed")?;
let _import_result: serde_json::Value = self
.bitcoin_rpc_call(&client, "importdescriptors", &[import_params])
.await
.context("importdescriptors failed")?;
// Zeroize the xprv string from memory.
xprv_str.zeroize();
tracing::info!("Bitcoin Core wallet '{}' initialized from master seed (BIP-84)", wallet_name);
tracing::info!(
"Bitcoin Core wallet '{}' initialized from master seed (BIP-84)",
wallet_name
);
Ok(serde_json::json!({
"initialized": true,
+79 -71
View File
@@ -1,5 +1,5 @@
use super::RpcHandler;
use super::package::validate_app_id;
use super::RpcHandler;
use anyhow::{Context, Result};
impl RpcHandler {
@@ -7,10 +7,9 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let manifest_path = params
@@ -43,8 +42,8 @@ impl RpcHandler {
let manifest_content = tokio::fs::read_to_string(&canonical)
.await
.context("Failed to read manifest file")?;
let manifest: archipelago_container::AppManifest = serde_yaml::from_str(&manifest_content)
.context("Failed to parse manifest")?;
let manifest: archipelago_container::AppManifest =
serde_yaml::from_str(&manifest_content).context("Failed to parse manifest")?;
let container_name = orchestrator
.install_container(&manifest, manifest_path)
@@ -58,10 +57,9 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
@@ -82,10 +80,9 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
@@ -106,10 +103,9 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
@@ -137,27 +133,33 @@ impl RpcHandler {
// between "installed" and "not-installed" in the UI.
let (data, _) = self.state_manager.get_snapshot().await;
if data.server_info.status_info.containers_scanned && !data.package_data.is_empty() {
let containers: Vec<serde_json::Value> = data.package_data.iter().map(|(id, pkg)| {
let state = match &pkg.state {
crate::data_model::PackageState::Running => "running",
crate::data_model::PackageState::Stopped => "stopped",
crate::data_model::PackageState::Exited => "exited",
crate::data_model::PackageState::Starting => "created",
_ => "unknown",
};
let lan = pkg.installed.as_ref()
.and_then(|i| i.interface_addresses.get("main"))
.and_then(|a| a.lan_address.as_deref());
serde_json::json!({
"id": id,
"name": id,
"state": state,
"image": "",
"created": "",
"ports": [],
"lan_address": lan,
let containers: Vec<serde_json::Value> = data
.package_data
.iter()
.map(|(id, pkg)| {
let state = match &pkg.state {
crate::data_model::PackageState::Running => "running",
crate::data_model::PackageState::Stopped => "stopped",
crate::data_model::PackageState::Exited => "exited",
crate::data_model::PackageState::Starting => "created",
_ => "unknown",
};
let lan = pkg
.installed
.as_ref()
.and_then(|i| i.interface_addresses.get("main"))
.and_then(|a| a.lan_address.as_deref());
serde_json::json!({
"id": id,
"name": id,
"state": state,
"image": "",
"created": "",
"ports": [],
"lan_address": lan,
})
})
}).collect();
.collect();
return Ok(serde_json::json!(containers));
}
@@ -185,8 +187,8 @@ impl RpcHandler {
return Ok(serde_json::json!([]));
}
let podman_containers: Vec<serde_json::Value> = serde_json::from_str(&stdout)
.unwrap_or_else(|_| Vec::new());
let podman_containers: Vec<serde_json::Value> =
serde_json::from_str(&stdout).unwrap_or_else(|_| Vec::new());
let containers: Vec<serde_json::Value> = podman_containers
.iter()
@@ -200,16 +202,25 @@ impl RpcHandler {
"paused" => "paused",
_ => "unknown",
};
let name = c.get("Names").and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or("");
let ports: Vec<String> = c.get("Ports")
let name = c
.get("Names")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|v| v.as_str())
.unwrap_or("");
let ports: Vec<String> = c
.get("Ports")
.and_then(|v| v.as_array())
.map(|a| {
a.iter().filter_map(|p| {
let host = p.get("host_port").and_then(|v| v.as_u64())?;
let container = p.get("container_port").and_then(|v| v.as_u64())?;
let proto = p.get("protocol").and_then(|v| v.as_str()).unwrap_or("tcp");
Some(format!("0.0.0.0:{}->{}/{}", host, container, proto))
}).collect()
a.iter()
.filter_map(|p| {
let host = p.get("host_port").and_then(|v| v.as_u64())?;
let container = p.get("container_port").and_then(|v| v.as_u64())?;
let proto =
p.get("protocol").and_then(|v| v.as_str()).unwrap_or("tcp");
Some(format!("0.0.0.0:{}->{}/{}", host, container, proto))
})
.collect()
})
.unwrap_or_default();
serde_json::json!({
@@ -231,10 +242,9 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
@@ -255,10 +265,9 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
@@ -266,10 +275,7 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
let lines = params
.get("lines")
.and_then(|v| v.as_u64())
.unwrap_or(100) as u32;
let lines = params.get("lines").and_then(|v| v.as_u64()).unwrap_or(100) as u32;
let logs = orchestrator
.get_container_logs(app_id, lines)
@@ -285,10 +291,9 @@ impl RpcHandler {
app_id: &str,
lines: u32,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let logs = orchestrator
.get_container_logs(app_id, lines)
@@ -302,10 +307,9 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
// If app_id is provided, get health for that app
if let Some(params) = params {
@@ -330,10 +334,14 @@ impl RpcHandler {
if let Some(app_id) = app_id.strip_suffix("-dev") {
match orchestrator.get_health_status(app_id).await {
Ok(health) => {
health_map.insert(app_id.to_string(), serde_json::Value::String(health));
health_map
.insert(app_id.to_string(), serde_json::Value::String(health));
}
Err(_) => {
health_map.insert(app_id.to_string(), serde_json::Value::String("unknown".to_string()));
health_map.insert(
app_id.to_string(),
serde_json::Value::String("unknown".to_string()),
);
}
}
}
+184 -40
View File
@@ -1,6 +1,7 @@
use super::RpcHandler;
use crate::content_server::{self, AccessControl, Availability, ContentItem};
use crate::network::dwn_store::DwnStore;
use crate::wallet::ecash;
use anyhow::{Context, Result};
use tracing::debug;
@@ -11,16 +12,16 @@ fn is_valid_v3_onion(addr: &str) -> bool {
return false;
}
let prefix = &addr[..56];
prefix.chars().all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c))
prefix
.chars()
.all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c))
}
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
impl RpcHandler {
/// List content I'm sharing.
pub(super) async fn handle_content_list_mine(
&self,
) -> Result<serde_json::Value> {
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
let catalog = content_server::load_catalog(&self.config.data_dir).await?;
Ok(serde_json::json!({ "items": catalog.items }))
}
@@ -45,7 +46,10 @@ impl RpcHandler {
anyhow::bail!("Invalid filename: absolute paths and hidden files not allowed");
}
// Reject any path segment starting with . (hidden dirs)
if filename.split('/').any(|seg| seg.starts_with('.') || seg.is_empty()) {
if filename
.split('/')
.any(|seg| seg.starts_with('.') || seg.is_empty())
{
anyhow::bail!("Invalid filename: hidden files/dirs or empty segments not allowed");
}
if filename.is_empty() || filename.len() > 512 {
@@ -191,14 +195,21 @@ impl RpcHandler {
.unwrap_or_default();
Availability::Specific { peers }
}
_ => return Err(anyhow::anyhow!("Invalid availability: {}", availability_type)),
_ => {
return Err(anyhow::anyhow!(
"Invalid availability: {}",
availability_type
))
}
};
content_server::set_availability(&self.config.data_dir, id, availability).await?;
Ok(serde_json::json!({ "updated": true }))
}
/// Download content from a peer over Tor, returning base64-encoded data.
/// Download content from a peer. Prefers FIPS when the peer is known
/// in our federation and has advertised a FIPS npub; falls back to
/// Tor on any network failure.
pub(super) async fn handle_content_download_peer(
&self,
params: Option<serde_json::Value>,
@@ -218,25 +229,20 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
.context("Failed to create SOCKS proxy")?;
let client = reqwest::Client::builder()
.proxy(socks_proxy)
.timeout(std::time::Duration::from_secs(120))
.build()
.context("Failed to build Tor HTTP client")?;
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let url = format!("http://{}/content/{}", onion, content_id);
let response = client
.get(&url)
.header("X-Federation-DID", &local_did)
.send()
.await
.context("Failed to connect to peer over Tor")?;
let path = format!("/content/{}", content_id);
let (response, _transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(120))
.send_get()
.await
.context("Failed to connect to peer")?;
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
let body: serde_json::Value = response.json().await.unwrap_or_default();
@@ -264,7 +270,8 @@ impl RpcHandler {
}))
}
/// Browse a peer's content catalog over Tor.
/// Browse a peer's content catalog. FIPS if the peer is federated,
/// otherwise Tor.
pub(super) async fn handle_content_browse_peer(
&self,
params: Option<serde_json::Value>,
@@ -280,24 +287,18 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
// Connect via Tor SOCKS proxy to the peer's content catalog endpoint
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
.context("Failed to create SOCKS proxy")?;
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let client = reqwest::Client::builder()
.proxy(socks_proxy)
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to build Tor HTTP client")?;
debug!("Browsing peer content at {} (fips={})", onion, fips_npub.is_some());
let url = format!("http://{}/content", onion);
debug!("Browsing peer content at {}", url);
let response = client
.get(&url)
.send()
.await
.context("Failed to connect to peer over Tor")?;
let (response, _transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
.send_get()
.await
.context("Failed to connect to peer")?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
@@ -313,4 +314,147 @@ impl RpcHandler {
Ok(body)
}
/// Download paid content from a peer: mint ecash token, send with request.
pub(super) async fn handle_content_download_peer_paid(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
let price_sats = params
.get("price_sats")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing price_sats"))?;
if price_sats == 0 {
return Err(anyhow::anyhow!("price_sats must be > 0"));
}
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
// Mint ecash payment token
let token_str = ecash::send_token(&self.config.data_dir, price_sats)
.await
.context("Failed to create ecash payment token — check wallet balance")?;
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}", content_id);
let (response, _transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str)
.timeout(std::time::Duration::from_secs(120))
.send_get()
.await
.context("Failed to connect to peer")?;
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
// Payment was rejected — token is spent but content not received
return Err(anyhow::anyhow!(
"Payment rejected by peer — token may have been insufficient or invalid"
));
}
if !response.status().is_success() {
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
}
let bytes = response
.bytes()
.await
.context("Failed to read response body")?;
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(serde_json::json!({
"data": encoded,
"size": bytes.len(),
"paid_sats": price_sats,
}))
}
/// Fetch a preview of paid content from a peer (no payment required).
pub(super) async fn handle_content_preview_peer(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}/preview", content_id);
debug!("Fetching content preview from {}{} (fips={})", onion, path, fips_npub.is_some());
let (response, _transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
.send_get()
.await
.context("Failed to connect to peer for preview")?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Peer returned error for preview: {}",
response.status()
));
}
let is_preview = response
.headers()
.get("X-Content-Preview")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let bytes = response
.bytes()
.await
.context("Failed to read preview response")?;
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(serde_json::json!({
"data": encoded,
"size": bytes.len(),
"content_type": content_type,
"preview_mode": is_preview,
}))
}
}
+16 -5
View File
@@ -71,7 +71,11 @@ impl RpcHandler {
)
.await?;
let status = if credentials::is_revoked(&vc) { "revoked" } else { "active" };
let status = if credentials::is_revoked(&vc) {
"revoked"
} else {
"active"
};
Ok(serde_json::json!({
"id": vc.id,
@@ -113,7 +117,11 @@ impl RpcHandler {
})
})?;
let status = if credentials::is_revoked(vc) { "revoked" } else { "active" };
let status = if credentials::is_revoked(vc) {
"revoked"
} else {
"active"
};
Ok(serde_json::json!({
"id": vc.id,
@@ -136,7 +144,11 @@ impl RpcHandler {
let items: Vec<serde_json::Value> = creds
.into_iter()
.map(|c| {
let status = if credentials::is_revoked(&c) { "revoked" } else { "active" };
let status = if credentials::is_revoked(&c) {
"revoked"
} else {
"active"
};
serde_json::json!({
"@context": c.context,
"id": c.id,
@@ -228,8 +240,7 @@ impl RpcHandler {
.get("presentation")
.ok_or_else(|| anyhow::anyhow!("Missing presentation"))?;
let vp: credentials::VerifiablePresentation =
serde_json::from_value(presentation.clone())?;
let vp: credentials::VerifiablePresentation = serde_json::from_value(presentation.clone())?;
let data_dir = self.config.data_dir.clone();
let result = credentials::verify_presentation(&vp, |did, bytes, signature| {
+148 -17
View File
@@ -15,7 +15,10 @@ impl RpcHandler {
"health" => self.handle_health().await,
"auth.login" => self.handle_auth_login(params).await,
"auth.logout" => self.handle_auth_logout().await,
"auth.changePassword" => self.handle_auth_change_password(params, session_token).await,
"auth.changePassword" => {
self.handle_auth_change_password(params, session_token)
.await
}
"auth.isSetup" => self.handle_auth_is_setup().await,
"auth.setup" => self.handle_auth_setup(params).await,
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
@@ -45,6 +48,7 @@ impl RpcHandler {
"package.stop" => self.handle_package_stop(params).await,
"package.restart" => self.handle_package_restart(params).await,
"package.uninstall" => self.handle_package_uninstall(params).await,
"package.update" => self.handle_package_update(params).await,
"app.filebrowser-token" => self.handle_filebrowser_token().await,
// Bundled app management (for pre-loaded container images)
@@ -74,6 +78,8 @@ impl RpcHandler {
"handshake.discover" => self.handle_handshake_discover().await,
"handshake.connect" => self.handle_handshake_connect(params).await,
"handshake.poll" => self.handle_handshake_poll().await,
"nostr.discovery-status" => self.handle_nostr_discovery_status().await,
"nostr.set-discovery" => self.handle_nostr_set_discovery(params).await,
// TOTP 2FA
"auth.totp.setup.begin" => self.handle_totp_setup_begin(params).await,
@@ -85,7 +91,9 @@ impl RpcHandler {
// Bitcoin & Lightning deep data
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await,
"bitcoin.init-wallet-from-seed" => self.handle_bitcoin_init_wallet_from_seed(params).await,
"bitcoin.init-wallet-from-seed" => {
self.handle_bitcoin_init_wallet_from_seed(params).await
}
"lnd.getinfo" => self.handle_lnd_getinfo().await,
"lnd.listchannels" => self.handle_lnd_listchannels().await,
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
@@ -112,7 +120,9 @@ impl RpcHandler {
"identity.verify" => self.handle_identity_verify(params).await,
"identity.resolve-did" => self.handle_identity_resolve_did(params).await,
"identity.resolve-remote-did" => self.handle_identity_resolve_remote_did(params).await,
"identity.verify-did-document" => self.handle_identity_verify_did_document(params).await,
"identity.verify-did-document" => {
self.handle_identity_verify_did_document(params).await
}
"identity.create-dht-did" => self.handle_identity_create_dht_did(params).await,
"identity.resolve-dht-did" => self.handle_identity_resolve_dht_did(params).await,
"identity.refresh-dht-did" => self.handle_identity_refresh_dht_did(params).await,
@@ -122,10 +132,18 @@ impl RpcHandler {
"identity.export-keys" => self.handle_identity_export_keys(params).await,
"identity.create-nostr-key" => self.handle_identity_create_nostr_key(params).await,
"identity.nostr-sign" => self.handle_identity_nostr_sign(params).await,
"identity.nostr-encrypt-nip04" => self.handle_identity_nostr_encrypt_nip04(params).await,
"identity.nostr-decrypt-nip04" => self.handle_identity_nostr_decrypt_nip04(params).await,
"identity.nostr-encrypt-nip44" => self.handle_identity_nostr_encrypt_nip44(params).await,
"identity.nostr-decrypt-nip44" => self.handle_identity_nostr_decrypt_nip44(params).await,
"identity.nostr-encrypt-nip04" => {
self.handle_identity_nostr_encrypt_nip04(params).await
}
"identity.nostr-decrypt-nip04" => {
self.handle_identity_nostr_decrypt_nip04(params).await
}
"identity.nostr-encrypt-nip44" => {
self.handle_identity_nostr_encrypt_nip44(params).await
}
"identity.nostr-decrypt-nip44" => {
self.handle_identity_nostr_decrypt_nip44(params).await
}
// Bitcoin domain names (NIP-05)
"identity.register-name" => self.handle_identity_register_name(params).await,
@@ -139,8 +157,12 @@ impl RpcHandler {
"identity.verify-credential" => self.handle_identity_verify_credential(params).await,
"identity.list-credentials" => self.handle_identity_list_credentials(params).await,
"identity.revoke-credential" => self.handle_identity_revoke_credential(params).await,
"identity.create-presentation" => self.handle_identity_create_presentation(params).await,
"identity.verify-presentation" => self.handle_identity_verify_presentation(params).await,
"identity.create-presentation" => {
self.handle_identity_create_presentation(params).await
}
"identity.verify-presentation" => {
self.handle_identity_verify_presentation(params).await
}
// Network overlay
"network.get-visibility" => self.handle_network_get_visibility().await,
@@ -186,12 +208,36 @@ impl RpcHandler {
// Ecash wallet
"wallet.ecash-balance" => self.handle_wallet_ecash_balance().await,
"wallet.ecash-mint" => self.handle_wallet_ecash_mint(params).await,
"wallet.ecash-mint-claim" => self.handle_wallet_ecash_mint_claim(params).await,
"wallet.ecash-melt" => self.handle_wallet_ecash_melt(params).await,
"wallet.ecash-melt-confirm" => self.handle_wallet_ecash_melt_confirm(params).await,
"wallet.ecash-send" => self.handle_wallet_ecash_send(params).await,
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
// Container registries
"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
"streaming.list-services" => self.handle_streaming_list_services().await,
"streaming.configure-service" => self.handle_streaming_configure_service(params).await,
"streaming.toggle-service" => self.handle_streaming_toggle_service(params).await,
"streaming.pay" => self.handle_streaming_pay(params).await,
"streaming.discover" => self.handle_streaming_discover().await,
"streaming.usage" => self.handle_streaming_usage(params).await,
"streaming.session" => self.handle_streaming_session(params).await,
"streaming.list-sessions" => self.handle_streaming_list_sessions().await,
"streaming.close-session" => self.handle_streaming_close_session(params).await,
"streaming.advertise" => self.handle_streaming_advertise().await,
"streaming.list-mints" => self.handle_streaming_list_mints().await,
"streaming.configure-mints" => self.handle_streaming_configure_mints(params).await,
"streaming.maintenance" => self.handle_streaming_maintenance().await,
// Content catalog management
"content.list-mine" => self.handle_content_list_mine().await,
"content.add" => self.handle_content_add(params).await,
@@ -200,6 +246,8 @@ impl RpcHandler {
"content.set-availability" => self.handle_content_set_availability(params).await,
"content.browse-peer" => self.handle_content_browse_peer(params).await,
"content.download-peer" => self.handle_content_download_peer(params).await,
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
"content.preview-peer" => self.handle_content_preview_peer(params).await,
// DWN (Decentralized Web Node)
"dwn.status" => self.handle_dwn_status().await,
@@ -232,14 +280,30 @@ impl RpcHandler {
"federation.get-state" => self.handle_federation_get_state().await,
"federation.peer-joined" => self.handle_federation_peer_joined(params).await,
"federation.deploy-app" => self.handle_federation_deploy_app(params).await,
"federation.peer-address-changed" => self.handle_federation_peer_address_changed(params).await,
"federation.notify-did-change" => self.handle_federation_notify_did_change(params).await,
"federation.peer-address-changed" => {
self.handle_federation_peer_address_changed(params).await
}
"federation.notify-did-change" => {
self.handle_federation_notify_did_change(params).await
}
"federation.peer-did-changed" => self.handle_federation_peer_did_changed(params).await,
"federation.list-pending-requests" => {
self.handle_federation_list_pending_requests().await
}
"federation.approve-request" => self.handle_federation_approve_request(params).await,
"federation.reject-request" => self.handle_federation_reject_request(params).await,
"federation.cancel-request" => self.handle_federation_cancel_request(params).await,
// VPN & Remote Access
"vpn.status" => self.handle_vpn_status().await,
"vpn.configure" => self.handle_vpn_configure(params).await,
"vpn.disconnect" => self.handle_vpn_disconnect().await,
"vpn.invite" => self.handle_vpn_invite(params).await,
"vpn.add-participant" => self.handle_vpn_add_participant(params).await,
"vpn.create-peer" => self.handle_vpn_create_peer(params).await,
"vpn.list-peers" => self.handle_vpn_list_peers().await,
"vpn.peer-config" => self.handle_vpn_peer_config(params).await,
"vpn.remove-peer" => self.handle_vpn_remove_peer(params).await,
"remote.setup" => self.handle_remote_setup(params).await,
// Marketplace
@@ -255,12 +319,34 @@ impl RpcHandler {
"mesh.status" => self.handle_mesh_status().await,
"mesh.peers" => self.handle_mesh_peers().await,
"mesh.messages" => self.handle_mesh_messages(params).await,
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
"mesh.send" => self.handle_mesh_send(params).await,
"mesh.send-channel" => self.handle_mesh_send_channel(params).await,
"mesh.broadcast" => self.handle_mesh_broadcast().await,
"mesh.configure" => self.handle_mesh_configure(params).await,
"mesh.send-invoice" => self.handle_mesh_send_invoice(params).await,
"mesh.send-coordinate" => self.handle_mesh_send_coordinate(params).await,
"mesh.send-alert" => self.handle_mesh_send_alert(params).await,
"mesh.send-content" => self.handle_mesh_send_content(params).await,
"mesh.send-content-inline" => self.handle_mesh_send_content_inline(params).await,
"mesh.transport-advice" => self.handle_mesh_transport_advice(params).await,
"mesh.fetch-content" => self.handle_mesh_fetch_content(params).await,
"mesh.send-reply" => self.handle_mesh_send_reply(params).await,
"mesh.send-reaction" => self.handle_mesh_send_reaction(params).await,
"mesh.send-read-receipt" => self.handle_mesh_send_read_receipt(params).await,
"mesh.forward-message" => self.handle_mesh_forward_message(params).await,
"mesh.edit-message" => self.handle_mesh_edit_message(params).await,
"mesh.delete-message" => self.handle_mesh_delete_message(params).await,
"mesh.send-psbt" => self.handle_mesh_send_psbt(params).await,
"mesh.broadcast-presence" => self.handle_mesh_broadcast_presence(params).await,
"mesh.presence-list" => self.handle_mesh_presence_list(params).await,
"mesh.contacts-list" => self.handle_mesh_contacts_list(params).await,
"mesh.contacts-save" => self.handle_mesh_contacts_save(params).await,
"mesh.contacts-block" => self.handle_mesh_contacts_block(params).await,
"mesh.send-channel-invite" => self.handle_mesh_send_channel_invite(params).await,
"conversations.list" => self.handle_conversations_list(params).await,
"conversations.messages" => self.handle_conversations_messages(params).await,
"mesh.clear-all" => self.handle_mesh_clear_all().await,
"mesh.outbox" => self.handle_mesh_outbox(params).await,
"mesh.session-status" => self.handle_mesh_session_status(params).await,
"mesh.rotate-prekeys" => self.handle_mesh_rotate_prekeys().await,
@@ -279,6 +365,8 @@ impl RpcHandler {
"transport.peers" => self.handle_transport_peers().await,
"transport.send" => self.handle_transport_send(params).await,
"transport.set-mode" => self.handle_transport_set_mode(params).await,
"transport.preferences" => self.handle_transport_preferences().await,
"transport.set-preference" => self.handle_transport_set_preference(params).await,
// Server settings
"server.set-name" => self.handle_server_set_name(params).await,
@@ -292,6 +380,8 @@ impl RpcHandler {
"system.disk-cleanup" => self.handle_system_disk_cleanup().await,
"system.reboot" => self.handle_system_reboot(params).await,
"system.factory-reset" => self.handle_system_factory_reset(params).await,
"system.settings.get" => self.handle_system_settings_get(params).await,
"system.settings.set" => self.handle_system_settings_set(params).await,
// Opt-in anonymous analytics
"analytics.get-status" => self.handle_analytics_get_status().await,
@@ -301,7 +391,9 @@ impl RpcHandler {
"telemetry.report" => self.handle_telemetry_report().await,
"telemetry.ingest" => self.handle_telemetry_ingest(params).await,
"telemetry.fleet-status" => self.handle_telemetry_fleet_status().await,
"telemetry.fleet-node-history" => self.handle_telemetry_fleet_node_history(params).await,
"telemetry.fleet-node-history" => {
self.handle_telemetry_fleet_node_history(params).await
}
"telemetry.fleet-alerts" => self.handle_telemetry_fleet_alerts().await,
// Real-time metrics monitoring
@@ -311,14 +403,52 @@ impl RpcHandler {
"monitoring.alerts" => self.handle_monitoring_alerts(params).await,
"monitoring.alert-rules" => self.handle_monitoring_alert_rules().await,
"monitoring.configure-alert" => self.handle_monitoring_configure_alert(params).await,
"monitoring.acknowledge-alert" => self.handle_monitoring_acknowledge_alert(params).await,
"monitoring.acknowledge-alert" => {
self.handle_monitoring_acknowledge_alert(params).await
}
"monitoring.export" => self.handle_monitoring_export(params).await,
// FIPS mesh transport
"fips.status" => self.handle_fips_status().await,
"fips.check-update" => self.handle_fips_check_update().await,
"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,
@@ -379,13 +509,14 @@ impl RpcHandler {
"webhook.configure" => self.handle_webhook_configure(params).await,
"webhook.test" => self.handle_webhook_test().await,
_ => {
Err(anyhow::anyhow!("Unknown method: {}", method))
}
_ => Err(anyhow::anyhow!("Unknown method: {}", method)),
}
}
pub(super) async fn handle_echo(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
pub(super) async fn handle_echo(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
if let Some(p) = params {
if let Some(msg) = p.get("message").and_then(|v| v.as_str()) {
return Ok(serde_json::json!({ "message": msg }));
+7 -4
View File
@@ -8,10 +8,13 @@ impl RpcHandler {
/// Get DWN status and sync state.
pub(super) async fn handle_dwn_status(&self) -> Result<serde_json::Value> {
let sync_state = dwn_sync::load_sync_state(&self.config.data_dir).await?;
let server_status = dwn_sync::get_dwn_status().await.unwrap_or(dwn_sync::DwnStatusResponse {
running: false,
version: String::new(),
});
let server_status =
dwn_sync::get_dwn_status()
.await
.unwrap_or(dwn_sync::DwnStatusResponse {
running: false,
version: String::new(),
});
let store = DwnStore::new(&self.config.data_dir).await?;
let stats = store.stats().await?;
@@ -1,33 +1,62 @@
use super::*;
use crate::api::rpc::RpcHandler;
use crate::credentials;
use crate::federation::{self, FederatedNode, TrustLevel};
use crate::federation::{self, pending, FederatedNode, TrustLevel};
use crate::identity;
use crate::mesh;
use crate::network::dwn_store::DwnStore;
use anyhow::{Context, Result};
use crate::nostr_handshake;
use anyhow::Result;
use tracing::{debug, info, warn};
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
impl RpcHandler {
/// Register a federation node with the running mesh service so it's
/// immediately addressable as a chat target. The mesh service seeds
/// federation peers at startup, but federation nodes added or rotated
/// later in the session would otherwise stay invisible to the mesh
/// chat UI until the next mesh restart, and `mesh.send` against the
/// frontend's synthesised contact_id would fail with "Unknown
/// federation peer". Best-effort: silently no-ops when mesh is off.
async fn register_federation_peer_in_mesh(
&self,
pubkey_hex: &str,
did: &str,
name: Option<&str>,
) {
let svc = self.mesh_service.read().await;
if let Some(svc) = svc.as_ref() {
mesh::upsert_federation_peer(&svc.shared_state(), pubkey_hex, did, name).await;
}
}
}
impl RpcHandler {
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
pub(in crate::api::rpc) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
let (data, _) = self.state_manager.get_snapshot().await;
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let onion = data
.server_info
.tor_address
.clone()
.unwrap_or_default();
let onion = data.server_info.tor_address.clone().unwrap_or_default();
let pubkey = data.server_info.pubkey.clone();
if onion.is_empty() {
anyhow::bail!("Tor address not available. Tor may not be running.");
}
let code = federation::create_invite(&self.config.data_dir, &did, &onion, &pubkey).await?;
let identity_dir = self.config.data_dir.join("identity");
let fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None);
info!(did = %did, "Generated federation invite");
let code = federation::create_invite(
&self.config.data_dir,
&did,
&onion,
&pubkey,
fips_npub.as_deref(),
)
.await?;
info!(did = %did, fips_advertised = fips_npub.is_some(), "Generated federation invite");
Ok(serde_json::json!({
"code": code,
"did": did,
@@ -50,21 +79,31 @@ 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?;
let local_fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None);
let node = federation::accept_invite(
&self.config.data_dir,
code,
&local_did,
&local_onion,
&local_pubkey,
local_fips_npub.as_deref(),
local_name.as_deref(),
|data| node_identity.sign(data),
)
.await?;
info!(peer_did = %node.did, "Joined federation with peer");
// Make the new peer immediately addressable from the mesh chat UI.
// Without this, the row exists in the federation list but `mesh.send`
// against it fails until the next mesh service restart re-seeds.
self.register_federation_peer_in_mesh(&node.pubkey, &node.did, node.name.as_deref())
.await;
// Store federation membership as DWN message
if let Ok(store) = DwnStore::new(&self.config.data_dir).await {
let dwn_data = serde_json::json!({
@@ -110,7 +149,9 @@ impl RpcHandler {
tokio::task::block_in_place(|| {
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
let id = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
let id =
crate::identity::NodeIdentity::load_or_create(&identity_dir)
.await?;
Ok(id.sign(bytes))
})
})
@@ -118,7 +159,9 @@ impl RpcHandler {
)
.await
{
Ok(vc) => debug!(vc_id = %vc.id, peer = %peer_did, "Issued federation trust VC"),
Ok(vc) => {
debug!(vc_id = %vc.id, peer = %peer_did, "Issued federation trust VC")
}
Err(e) => debug!(error = %e, "Federation trust VC issuance failed (non-fatal)"),
}
});
@@ -136,18 +179,24 @@ impl RpcHandler {
}
/// federation.list-nodes — List all federated nodes with their status, last state, and VC verification.
pub(in crate::api::rpc) async fn handle_federation_list_nodes(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_federation_list_nodes(
&self,
) -> Result<serde_json::Value> {
let nodes = federation::load_nodes(&self.config.data_dir).await?;
// Load credentials to check for federation VCs
let cred_store = credentials::load_credentials(&self.config.data_dir).await.ok();
let cred_store = credentials::load_credentials(&self.config.data_dir)
.await
.ok();
let vc_subjects: std::collections::HashSet<String> = cred_store
.as_ref()
.map(|s| {
s.credentials
.iter()
.filter(|vc| {
vc.credential_type.iter().any(|t| t == "FederationTrustCredential")
vc.credential_type
.iter()
.any(|t| t == "FederationTrustCredential")
&& !credentials::is_revoked(vc)
})
.map(|vc| vc.credential_subject.id.clone())
@@ -223,7 +272,10 @@ impl RpcHandler {
"trusted" => TrustLevel::Trusted,
"observer" => TrustLevel::Observer,
"untrusted" => TrustLevel::Untrusted,
_ => anyhow::bail!("Invalid trust level: {} (expected trusted/observer/untrusted)", trust_str),
_ => anyhow::bail!(
"Invalid trust level: {} (expected trusted/observer/untrusted)",
trust_str
),
};
federation::set_trust_level(&self.config.data_dir, did, trust).await?;
@@ -236,7 +288,9 @@ impl RpcHandler {
}
/// federation.sync-state — Manually trigger state sync with all federated peers.
pub(in crate::api::rpc) async fn handle_federation_sync_state(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_federation_sync_state(
&self,
) -> Result<serde_json::Value> {
let nodes = federation::load_nodes(&self.config.data_dir).await?;
if nodes.is_empty() {
@@ -263,12 +317,9 @@ impl RpcHandler {
}
let did_clone = local_did.clone();
match federation::sync_with_peer(
&self.config.data_dir,
node,
&did_clone,
|bytes| node_identity.sign(bytes),
)
match federation::sync_with_peer(&self.config.data_dir, node, &did_clone, |bytes| {
node_identity.sign(bytes)
})
.await
{
Ok(state) => {
@@ -298,7 +349,9 @@ impl RpcHandler {
}
/// federation.get-state — Return this node's state snapshot (called by peers during sync).
pub(in crate::api::rpc) async fn handle_federation_get_state(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_federation_get_state(
&self,
) -> Result<serde_json::Value> {
let (data, _) = self.state_manager.get_snapshot().await;
// Build app statuses from package_data
@@ -315,8 +368,57 @@ impl RpcHandler {
let tor_active = data.server_info.tor_address.is_some();
let server_name = data.server_info.name.clone().filter(|n| !n.is_empty());
// Encode our local Nostr identity as bech32 npub so federated peers
// can display it under our name in the mesh UI without each peer
// having to know how to convert hex → bech32 themselves.
let nostr_npub =
tokio::fs::read_to_string(self.config.data_dir.join("identity/nostr_pubkey"))
.await
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.and_then(|hex| nostr_sdk::PublicKey::from_hex(&hex).ok())
.and_then(|pk| nostr_sdk::ToBech32::to_bech32(&pk).ok());
// Pass the current federated-peer list so the snapshot can include
// a `federated_peers` hint for transitive federation — receivers
// who trust us learn our Trusted peers and can route to them
// over FIPS without a separate invite round-trip.
let federated_peers = federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
// Our own FIPS npub, so pre-v1.4 federation pairs (whose
// invite codes didn't carry it) can learn it on the next sync.
let identity_dir = self.config.data_dir.join("identity");
let own_fips_npub = crate::identity::fips_npub(&identity_dir)
.await
.ok()
.flatten()
.or_else(|| {
// Legacy/dev nodes without a seed-derived key fall back
// to the upstream daemon's public key on disk.
None
});
let own_fips_npub = match own_fips_npub {
Some(n) => Some(n),
None => crate::fips::service::read_upstream_npub().await.ok().flatten(),
};
let state = federation::build_local_state(
apps, 0.0, 0, 0, 0, 0, 0, tor_active, server_name,
apps,
0.0,
0,
0,
0,
0,
0,
tor_active,
server_name,
nostr_npub,
own_fips_npub,
&federated_peers,
);
Ok(serde_json::to_value(&state)?)
@@ -341,11 +443,47 @@ impl RpcHandler {
.get("pubkey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey'"))?;
// Optional, unsigned: peer's FIPS mesh npub. Carried for transport
// selection only; FIPS handshake re-authenticates the session.
let fips_npub = params
.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());
let signature = params.get("signature").and_then(|v| v.as_str());
match signature {
Some(sig) => {
let sign_data = format!("peer-joined:{}:{}:{}", did, onion, pubkey);
@@ -359,24 +497,36 @@ impl RpcHandler {
}
None => {
tracing::warn!(peer_did = %did, "Rejected peer-joined: missing signature");
anyhow::bail!("Missing signature — all federation peers must be cryptographically verified");
anyhow::bail!(
"Missing signature — all federation peers must be cryptographically verified"
);
}
}
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, update them
if existing.onion.is_empty() || existing.pubkey.is_empty() {
// 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();
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 existing.onion.is_empty() && !onion.is_empty() {
if needs_onion && !onion.is_empty() {
updated.onion = onion.to_string();
}
if existing.pubkey.is_empty() && !pubkey.is_empty() {
if needs_pubkey && !pubkey.is_empty() {
updated.pubkey = pubkey.to_string();
}
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 missing onion/pubkey");
info!(peer_did = %did, peer_onion = %onion, "Updated existing peer with fresh identity fields");
}
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
}
@@ -385,16 +535,53 @@ 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,
last_state: None,
fips_npub,
last_transport: None,
last_transport_at: None,
};
federation::add_node(&self.config.data_dir, node).await?;
info!(peer_did = %did, "Peer joined our federation");
// 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, 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 }))
}
@@ -476,7 +663,8 @@ impl RpcHandler {
Some(node) => {
// Verify signature using the peer's KNOWN pubkey (H3 security fix)
let sign_data = format!("address-changed:{}:{}", did, new_onion);
match identity::NodeIdentity::verify(&node.pubkey, sign_data.as_bytes(), signature) {
match identity::NodeIdentity::verify(&node.pubkey, sign_data.as_bytes(), signature)
{
Ok(true) => {}
_ => {
tracing::warn!(did = %did, "Rejected address change: invalid signature");
@@ -538,14 +726,6 @@ impl RpcHandler {
let nodes = federation::load_nodes(&self.config.data_dir).await?;
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
.context("Invalid Tor proxy")?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to build HTTP client")?;
let mut notified = 0u32;
let mut failed = 0u32;
let mut results = Vec::new();
@@ -556,13 +736,6 @@ impl RpcHandler {
continue;
}
let host = if node.onion.ends_with(".onion") {
node.onion.clone()
} else {
format!("{}.onion", node.onion)
};
let url = format!("http://{}/rpc/v1", host);
let body = serde_json::json!({
"method": "federation.peer-did-changed",
"params": {
@@ -574,23 +747,32 @@ impl RpcHandler {
}
});
match client.post(&url).json(&body).send().await {
Ok(resp) if resp.status().is_success() => {
let req = crate::fips::dial::PeerRequest::new(
node.fips_npub.as_deref(),
&node.onion,
"/rpc/v1",
)
.service(crate::settings::transport::PeerService::Peers)
.timeout(std::time::Duration::from_secs(30));
match req.send_json(&body).await {
Ok((resp, transport)) if resp.status().is_success() => {
notified += 1;
results.push(serde_json::json!({
"did": node.did,
"status": "ok",
"transport": transport.to_string(),
}));
info!(peer_did = %node.did, "Notified peer of DID rotation");
info!(peer_did = %node.did, transport = %transport, "Notified peer of DID rotation");
}
Ok(resp) => {
Ok((resp, transport)) => {
failed += 1;
results.push(serde_json::json!({
"did": node.did,
"status": "error",
"error": format!("Peer returned {}", resp.status()),
"error": format!("Peer returned {} (via {})", resp.status(), transport),
}));
warn!(peer_did = %node.did, status = %resp.status(), "Peer rejected DID rotation notification");
warn!(peer_did = %node.did, status = %resp.status(), transport = %transport, "Peer rejected DID rotation notification");
}
Err(e) => {
failed += 1;
@@ -667,9 +849,7 @@ impl RpcHandler {
// Verify the rotation proof: the old key signed
// "did-rotate:{old_did}:{new_did}:{timestamp}" and the sender
// forwards both the signature and the full proof_message.
let proof_message = params
.get("proof_message")
.and_then(|v| v.as_str());
let proof_message = params.get("proof_message").and_then(|v| v.as_str());
let verified = if let Some(msg) = proof_message {
// Verify the proof_message starts with the expected prefix
@@ -687,7 +867,11 @@ impl RpcHandler {
// Fallback: verify without timestamp (backwards-compatible)
let fallback_msg = format!("did-rotate:{}:{}", old_did, new_did);
matches!(
identity::NodeIdentity::verify(&node.pubkey, fallback_msg.as_bytes(), signature),
identity::NodeIdentity::verify(
&node.pubkey,
fallback_msg.as_bytes(),
signature
),
Ok(true)
)
};
@@ -698,11 +882,31 @@ impl RpcHandler {
}
let old_pubkey = node.pubkey.clone();
let rotated_name = node.name.clone();
node.did = new_did.to_string();
node.pubkey = new_pubkey.to_string();
node.last_seen = Some(chrono::Utc::now().to_rfc3339());
federation::save_nodes(&self.config.data_dir, &nodes).await?;
// Drop the stale mesh peer entry keyed by the old pubkey's
// synthetic contact_id, then upsert a fresh one under the
// new pubkey so the chat UI doesn't show two rows post-rotation.
{
let svc = self.mesh_service.read().await;
if let Some(svc) = svc.as_ref() {
let state = svc.shared_state();
let stale_id = mesh::federation_peer_contact_id(&old_pubkey);
state.peers.write().await.remove(&stale_id);
mesh::upsert_federation_peer(
&state,
new_pubkey,
new_did,
rotated_name.as_deref(),
)
.await;
}
}
info!(
old_did = %old_did,
new_did = %new_did,
@@ -725,4 +929,213 @@ impl RpcHandler {
}
}
}
/// federation.list-pending-requests — return the inbox of inbound peer
/// requests received over Nostr (and our outbound `Sent` rows). Each
/// row carries a stable `id` the FE refers to when calling
/// `federation.approve-request` / `federation.reject-request`.
pub(in crate::api::rpc) async fn handle_federation_list_pending_requests(
&self,
) -> Result<serde_json::Value> {
let requests = pending::load_pending(&self.config.data_dir).await?;
Ok(serde_json::json!({ "requests": requests }))
}
/// federation.approve-request — turn a pending peer request into a
/// federation invite, ship it back via NIP-44, and add the requester
/// to our federation list as `Observer` (NOT Trusted — the user must
/// explicitly promote afterwards via `federation.set-trust`).
///
/// This is the *only* code path that ever causes our onion to leave
/// this box over Nostr, and the onion only travels inside a NIP-44
/// ciphertext addressed to the requester's specific nostr pubkey.
pub(in crate::api::rpc) async fn handle_federation_approve_request(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
let req = pending::find_by_id(&self.config.data_dir, id)
.await?
.ok_or_else(|| anyhow::anyhow!("Pending request not found: {}", id))?;
if !matches!(req.state, pending::PendingState::Pending) || req.outbound {
anyhow::bail!(
"Pending request is not awaiting approval (state={:?})",
req.state
);
}
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let local_onion = data
.server_info
.tor_address
.clone()
.ok_or_else(|| anyhow::anyhow!("Tor address not available"))?;
let local_pubkey = data.server_info.pubkey.clone();
// Generate a one-shot federation invite. The code embeds OUR onion
// and OUR pubkey, but it leaves this box only inside the NIP-44
// ciphertext below.
let identity_dir = self.config.data_dir.join("identity");
let local_fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None);
let invite_code = federation::create_invite(
&self.config.data_dir,
&local_did,
&local_onion,
&local_pubkey,
local_fips_npub.as_deref(),
)
.await?;
// Pre-add the requester to OUR federation list as Observer so that
// when their `federation.peer-joined` callback arrives over Tor we
// already trust their pubkey enough to accept the join. Their DID
// and pubkey come from the request — we'll cross-check the pubkey
// against the eventual peer-joined signature in the existing
// verification path (handlers.rs line ~365).
if !req.from_did.is_empty() {
// We don't know the requester's onion or ed25519 pubkey yet —
// they'll send those in the federation.peer-joined callback
// after they apply our invite. Until then we can't add a real
// FederatedNode entry. We just store the pending row as
// Approved so the UI shows progress, and trust the existing
// peer-joined handler to admit them as Observer when they call.
//
// Caveat: peer-joined currently hardcodes TrustLevel::Trusted.
// We override that below by demoting on success.
debug!(
requester_did = %req.from_did,
"Approval pending — waiting for federation.peer-joined callback over Tor"
);
}
// Encrypt + send the invite over NIP-44 to the requester.
let identity_dir = self.config.data_dir.join("identity");
nostr_handshake::send_peer_invite(
&identity_dir,
&req.from_nostr_pubkey,
&invite_code,
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
pending::set_state(&self.config.data_dir, id, pending::PendingState::Approved).await?;
info!(
id = %id,
from = %req.from_nostr_pubkey,
"Approved peer request and shipped invite over NIP-44"
);
Ok(serde_json::json!({
"approved": true,
"id": id,
}))
}
/// federation.reject-request — drop a pending request and, if requested,
/// ship a NIP-44 `PeerReject` to the sender so their UI can update.
pub(in crate::api::rpc) async fn handle_federation_reject_request(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
let reason = params.get("reason").and_then(|v| v.as_str());
let notify = params
.get("notify")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let req = pending::find_by_id(&self.config.data_dir, id)
.await?
.ok_or_else(|| anyhow::anyhow!("Pending request not found: {}", id))?;
if !matches!(req.state, pending::PendingState::Pending) || req.outbound {
anyhow::bail!(
"Pending request is not awaiting approval (state={:?})",
req.state
);
}
if notify {
let identity_dir = self.config.data_dir.join("identity");
let _ = nostr_handshake::send_peer_reject(
&identity_dir,
&req.from_nostr_pubkey,
reason,
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await;
}
pending::set_state(&self.config.data_dir, id, pending::PendingState::Rejected).await?;
info!(id = %id, from = %req.from_nostr_pubkey, "Rejected peer request");
Ok(serde_json::json!({ "rejected": true, "id": id }))
}
/// federation.cancel-request — withdraw an outbound peer request we
/// sent but haven't heard back on. The local row is deleted and,
/// unless `notify=false`, a PeerCancel nostr DM is sent so the
/// target drops their inbound pending row.
pub(in crate::api::rpc) async fn handle_federation_cancel_request(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
let reason = params.get("reason").and_then(|v| v.as_str());
// Default TRUE — cancelling without notifying is a footgun (the
// recipient's UI keeps showing an unanswerable request).
let notify = params
.get("notify")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let req = pending::find_by_id(&self.config.data_dir, id)
.await?
.ok_or_else(|| anyhow::anyhow!("Pending request not found: {}", id))?;
if !req.outbound || !matches!(req.state, pending::PendingState::Sent) {
anyhow::bail!(
"Can only cancel outbound requests in Sent state (outbound={}, state={:?})",
req.outbound,
req.state
);
}
if notify {
let identity_dir = self.config.data_dir.join("identity");
// Best-effort: log but don't fail the cancel if the nostr
// relay is unreachable — the local row is still dropped.
if let Err(e) = nostr_handshake::send_peer_cancel(
&identity_dir,
&req.from_nostr_pubkey,
reason,
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await
{
tracing::warn!(
id = %id,
error = %e,
"peer-cancel DM failed; local row dropped anyway"
);
}
}
pending::delete(&self.config.data_dir, id).await?;
info!(id = %id, to = %req.from_nostr_pubkey, notified = notify, "Cancelled outbound peer request");
Ok(serde_json::json!({ "cancelled": true, "id": id, "notified": notify }))
}
}
@@ -14,4 +14,3 @@ pub(super) fn validate_did(did: &str) -> Result<()> {
}
Ok(())
}
+213
View File
@@ -0,0 +1,213 @@
//! RPC handlers for the FIPS mesh transport subsystem.
//!
//! Surface is deliberately thin: a read-only `fips.status`, a user-gated
//! `fips.check-update`, a stubbed `fips.apply-update`, and a
//! `fips.install` that (re-)materialises the daemon config + key and
//! activates the service. All writes go through `sudo` helpers in
//! `crate::fips`.
use super::RpcHandler;
use crate::fips;
use anyhow::Result;
impl RpcHandler {
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
let status = fips::FipsStatus::query(&self.config.data_dir).await;
Ok(serde_json::to_value(status)?)
}
pub(super) async fn handle_fips_check_update(&self) -> Result<serde_json::Value> {
let check = fips::update::check().await?;
Ok(serde_json::to_value(check)?)
}
pub(super) async fn handle_fips_apply_update(&self) -> Result<serde_json::Value> {
fips::update::apply().await?;
Ok(serde_json::json!({ "applied": true }))
}
/// Install config + key into /etc/fips and activate the service.
/// Intended to be called:
/// - once by the seed-onboarding flow, right after the FIPS key
/// is written to /data/identity/fips_key, and
/// - on user demand from the dashboard if something drifted.
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
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(&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> {
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<_>>(),
}))
}
}
+322 -80
View File
@@ -1,11 +1,117 @@
//! Nostr peer-discovery RPCs.
//!
//! `handshake.discover` — browse other nodes' presence events on configured
//! relays. Returns DID + nostr pubkey only; no onion is ever exposed.
//!
//! `handshake.connect` — send a `PeerRequest` to a discovered node's nostr
//! pubkey. Records the outbound request locally so the user can see what
//! they've sent. Does NOT include our onion address on the wire.
//!
//! `handshake.poll` — fetch new NIP-44 DMs addressed to our nostr pubkey
//! and dispatch them: inbound `PeerRequest` is queued in
//! `federation::pending` for manual approval; inbound `PeerInvite` is
//! applied via the existing federation invite-acceptance flow (which
//! adds the new peer as `Observer` — see federation.rs); inbound
//! `PeerReject` is recorded against the matching outbound row.
use super::RpcHandler;
use crate::{nostr_handshake, peers};
use anyhow::Result;
use crate::federation::pending::{self, PendingPeerRequest, PendingState};
use crate::nostr_handshake::{self, HandshakeMessage};
use anyhow::{Context, Result};
use nostr_sdk::FromBech32;
use serde::{Deserialize, Serialize};
const NOSTR_STATE_FILE: &str = "nostr_discovery_state.json";
/// Runtime override for `Config::nostr_discovery_enabled`. The OS-level
/// config file is read once at boot and is OFF by default; this state file
/// lets the user flip discoverability on/off at runtime via the Federation
/// UI without restarting the service. Both the boot-time presence publish
/// and the `handshake.poll` handler check this file before doing anything.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct NostrDiscoveryState {
#[serde(default)]
enabled: bool,
}
async fn load_discovery_state(data_dir: &std::path::Path) -> NostrDiscoveryState {
let path = data_dir.join(NOSTR_STATE_FILE);
match tokio::fs::read_to_string(&path).await {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => NostrDiscoveryState::default(),
}
}
async fn save_discovery_state(
data_dir: &std::path::Path,
state: &NostrDiscoveryState,
) -> Result<()> {
let path = data_dir.join(NOSTR_STATE_FILE);
let content = serde_json::to_string_pretty(state).context("serialize discovery state")?;
tokio::fs::write(&path, content)
.await
.context("write discovery state")?;
Ok(())
}
impl RpcHandler {
/// Discover nodes (presence-only — returns Nostr pubkeys + DIDs, no onion addresses).
/// Read the current runtime discoverability flag.
pub(super) async fn handle_nostr_discovery_status(&self) -> Result<serde_json::Value> {
let state = load_discovery_state(&self.config.data_dir).await;
Ok(serde_json::json!({ "enabled": state.enabled }))
}
/// Set the runtime discoverability flag. If turning ON, publish presence
/// once immediately so the user gets visible feedback that the relays
/// have been notified. If turning OFF, do NOT actively scrub the relays
/// here — `nostr_handshake::publish_presence` is replaceable, so the
/// next reboot's startup pass plus the existing legacy revocation in
/// `nostr_discovery::revoke_legacy_advertisements` are sufficient. A
/// future Layer 3 task adds an explicit "tombstone" publish if needed.
pub(super) async fn handle_nostr_set_discovery(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let enabled = params
.get("enabled")
.and_then(|v| v.as_bool())
.ok_or_else(|| anyhow::anyhow!("Missing enabled"))?;
save_discovery_state(&self.config.data_dir, &NostrDiscoveryState { enabled }).await?;
if enabled && !self.config.nostr_relays.is_empty() {
let (data, _) = self.state_manager.get_snapshot().await;
let identity_dir = self.config.data_dir.join("identity");
let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
.unwrap_or_default();
let version = data.server_info.version.clone();
let relays = self.config.nostr_relays.clone();
let tor_proxy = self.config.nostr_tor_proxy.clone();
tokio::spawn(async move {
if let Err(e) = nostr_handshake::publish_presence(
&identity_dir,
&did,
&version,
&relays,
tor_proxy.as_deref(),
)
.await
{
tracing::warn!("Initial presence publish failed: {}", e);
}
});
}
Ok(serde_json::json!({ "enabled": enabled }))
}
/// Discover discoverable nodes via Nostr presence events.
/// Returns (nostr_pubkey, npub, DID, version) only — never an onion.
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
// Discoverability gate: respect the runtime toggle. We allow `discover`
// to query relays as long as the user is actively browsing — they're
// an anonymous observer of presence events, not publishing anything.
let identity_dir = self.config.data_dir.join("identity");
let nodes = nostr_handshake::discover_nodes(
&identity_dir,
@@ -16,59 +122,90 @@ impl RpcHandler {
Ok(serde_json::json!({ "nodes": nodes }))
}
/// Send encrypted connection request to a peer's Nostr pubkey.
/// Params: { recipient_nostr_pubkey }
/// Send a `PeerRequest` to a discovered node. Onion is never sent.
/// Params: `{ recipient_nostr_pubkey, message?, name? }`.
pub(super) async fn handle_handshake_connect(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
// Accept either hex pubkey or npub1... bech32 format
let recipient_raw = params
.get("recipient_nostr_pubkey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing recipient_nostr_pubkey"))?;
let recipient = if recipient_raw.starts_with("npub1") {
let recipient_hex = if recipient_raw.starts_with("npub1") {
nostr_sdk::PublicKey::from_bech32(recipient_raw)
.map_err(|e| anyhow::anyhow!("Invalid npub: {}", e))?
.to_hex()
} else {
recipient_raw.to_string()
};
let recipient = recipient.as_str();
let recipient_npub = nostr_sdk::PublicKey::from_hex(&recipient_hex)
.ok()
.and_then(|pk| nostr_sdk::ToBech32::to_bech32(&pk).ok())
.unwrap_or_default();
let message = params.get("message").and_then(|v| v.as_str());
let optional_name = params.get("name").and_then(|v| v.as_str());
let (data, _) = self.state_manager.get_snapshot().await;
let our_onion = data
.server_info
.tor_address
.as_deref()
.ok_or_else(|| anyhow::anyhow!("No Tor address available — is Tor running?"))?;
let our_node_pubkey = &data.server_info.pubkey;
let our_did = crate::identity::did_key_from_pubkey_hex(our_node_pubkey)
.unwrap_or_default();
let our_did =
crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
let our_version = &data.server_info.version;
let our_name = data.server_info.name.as_deref();
let our_name = optional_name.or(data.server_info.name.as_deref());
let identity_dir = self.config.data_dir.join("identity");
nostr_handshake::send_connect_request(
nostr_handshake::send_peer_request(
&identity_dir,
recipient,
our_onion,
our_node_pubkey,
&recipient_hex,
&our_did,
our_version,
our_name,
message,
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
Ok(serde_json::json!({ "ok": true, "sent_to": recipient }))
// Record the outbound request so the user can see "Sent" status
// and so the eventual NIP-44 PeerInvite reply can be matched.
let row = pending::insert_outbound(
&self.config.data_dir,
recipient_hex.clone(),
recipient_npub,
String::new(), // remote DID unknown until they reply
None,
message.map(String::from),
)
.await?;
Ok(serde_json::json!({
"ok": true,
"sent_to": recipient_hex,
"id": row.id,
}))
}
/// Poll for incoming encrypted handshake messages (connect requests/responses).
/// Auto-adds peers and auto-responds to requests.
/// Poll relays for inbound NIP-44 handshake messages, then dispatch:
/// - `PeerRequest` → queue in `federation::pending` for approval
/// - `PeerInvite` → apply via federation invite flow (adds as Observer)
/// - `PeerReject` → mark matching outbound row as `Rejected`
///
/// Never auto-adds peers, never auto-responds, never sends our onion.
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
// Runtime gate: if the user hasn't enabled discoverability, don't
// touch the relays. The poll endpoint is a hard no-op until they
// explicitly opt in via the Federation UI toggle.
let state = load_discovery_state(&self.config.data_dir).await;
if !state.enabled {
return Ok(serde_json::json!({
"polled": 0,
"new_requests": Vec::<PendingPeerRequest>::new(),
"applied_invites": Vec::<String>::new(),
"rejected_outbound": Vec::<String>::new(),
"skipped": Vec::<String>::new(),
"discovery_disabled": true,
}));
}
let identity_dir = self.config.data_dir.join("identity");
let handshakes = nostr_handshake::poll_handshakes(
&identity_dir,
@@ -78,72 +215,177 @@ impl RpcHandler {
)
.await?;
let (data, _) = self.state_manager.get_snapshot().await;
let mut added_peers = Vec::new();
let mut new_requests: Vec<PendingPeerRequest> = Vec::new();
let mut applied_invites: Vec<String> = Vec::new();
let mut rejected_outbound: Vec<String> = Vec::new();
let mut cancelled_inbound: Vec<String> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for hs in &handshakes {
let (onion, node_pubkey, name) = match &hs.message {
nostr_handshake::HandshakeMessage::ConnectRequest {
onion,
node_pubkey,
match &hs.message {
HandshakeMessage::PeerRequest {
from_did,
version: _,
name,
..
message,
} => {
// Auto-respond with our details
if let Some(our_onion) = data.server_info.tor_address.as_deref() {
let our_did = crate::identity::did_key_from_pubkey_hex(
&data.server_info.pubkey,
)
.unwrap_or_default();
let _ = nostr_handshake::send_connect_response(
&identity_dir,
&hs.from_nostr_pubkey,
our_onion,
&data.server_info.pubkey,
&our_did,
&data.server_info.version,
data.server_info.name.as_deref(),
&self.config.nostr_relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await;
match pending::insert_inbound(
&self.config.data_dir,
hs.from_nostr_pubkey.clone(),
hs.from_nostr_npub.clone(),
from_did.clone(),
name.clone(),
message.clone(),
)
.await
{
Ok(Some(row)) => new_requests.push(row),
Ok(None) => skipped.push(hs.from_nostr_pubkey.clone()),
Err(e) => {
tracing::warn!(
from = %hs.from_nostr_pubkey,
error = %e,
"Dropped peer request (rate limit or storage error)"
);
skipped.push(hs.from_nostr_pubkey.clone());
}
}
(onion.clone(), node_pubkey.clone(), name.clone())
}
nostr_handshake::HandshakeMessage::ConnectResponse {
onion,
node_pubkey,
name,
..
} => (onion.clone(), node_pubkey.clone(), name.clone()),
};
HandshakeMessage::PeerInvite { invite_code } => {
// Match against an outbound Sent request from this nostr
// pubkey. If we never sent them anything, ignore — we
// don't accept unsolicited invites over Nostr.
let pendings = pending::load_pending(&self.config.data_dir).await?;
let matching = pendings.iter().find(|r| {
r.outbound
&& r.from_nostr_pubkey == hs.from_nostr_pubkey
&& matches!(r.state, PendingState::Sent)
});
let Some(row) = matching else {
tracing::warn!(
from = %hs.from_nostr_pubkey,
"Ignoring unsolicited PeerInvite — no matching Sent request"
);
continue;
};
let row_id = row.id.clone();
let (data, _) = self.state_manager.get_snapshot().await;
let local_did =
crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
.unwrap_or_default();
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
let local_pubkey = data.server_info.pubkey.clone();
// Auto-add as peer
let peer = peers::KnownPeer {
onion,
pubkey: node_pubkey.clone(),
name,
added_at: Some(chrono::Utc::now().to_rfc3339()),
};
let _ = peers::add_peer(&self.config.data_dir, peer).await;
added_peers.push(node_pubkey);
let identity_dir2 = self.config.data_dir.join("identity");
let node_identity =
crate::identity::NodeIdentity::load_or_create(&identity_dir2).await?;
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,
&local_did,
&local_onion,
&local_pubkey,
local_fips_npub.as_deref(),
local_name.as_deref(),
|bytes| node_identity.sign(bytes),
)
.await
{
Ok(node) => {
// Approved-by-them: their box already has us as Observer
// (their approval handler added us under that trust level
// before sending the invite). Demote our local entry to
// Observer too — accept_invite hardcodes Trusted, but the
// discovery flow should never auto-trust.
let _ = crate::federation::set_trust_level(
&self.config.data_dir,
&node.did,
crate::federation::TrustLevel::Observer,
)
.await;
// Mirror into the mesh peer table immediately so the
// chat UI can address the new peer without waiting
// for the next mesh restart.
let svc = self.mesh_service.read().await;
if let Some(svc) = svc.as_ref() {
crate::mesh::upsert_federation_peer(
&svc.shared_state(),
&node.pubkey,
&node.did,
node.name.as_deref(),
)
.await;
}
pending::set_state(
&self.config.data_dir,
&row_id,
PendingState::Approved,
)
.await?;
applied_invites.push(node.did);
}
Err(e) => {
tracing::warn!(
from = %hs.from_nostr_pubkey,
error = %e,
"Failed to apply PeerInvite"
);
}
}
}
HandshakeMessage::PeerReject { reason } => {
let pendings = pending::load_pending(&self.config.data_dir).await?;
if let Some(row) = pendings.iter().find(|r| {
r.outbound
&& r.from_nostr_pubkey == hs.from_nostr_pubkey
&& matches!(r.state, PendingState::Sent)
}) {
let row_id = row.id.clone();
pending::set_state(&self.config.data_dir, &row_id, PendingState::Rejected)
.await?;
rejected_outbound.push(row_id);
tracing::info!(
from = %hs.from_nostr_pubkey,
reason = ?reason,
"Outbound peer request rejected"
);
}
}
HandshakeMessage::PeerCancel { reason } => {
// Peer withdrew their PeerRequest — drop our matching
// inbound pending row so it disappears from the UI.
let pendings = pending::load_pending(&self.config.data_dir).await?;
if let Some(row) = pendings.iter().find(|r| {
!r.outbound
&& r.from_nostr_pubkey == hs.from_nostr_pubkey
&& matches!(r.state, PendingState::Pending)
}) {
let row_id = row.id.clone();
pending::delete(&self.config.data_dir, &row_id).await?;
cancelled_inbound.push(row_id);
tracing::info!(
from = %hs.from_nostr_pubkey,
reason = ?reason,
"Inbound peer request cancelled by sender"
);
}
}
}
}
let serialized: Vec<serde_json::Value> = handshakes
.iter()
.map(|hs| {
serde_json::json!({
"from_nostr_pubkey": hs.from_nostr_pubkey,
"from_nostr_npub": hs.from_nostr_npub,
"message": hs.message,
"timestamp": hs.timestamp,
})
})
.collect();
Ok(serde_json::json!({
"handshakes": serialized,
"added_peers": added_peers,
"polled": handshakes.len(),
"new_requests": new_requests,
"applied_invites": applied_invites,
"rejected_outbound": rejected_outbound,
"cancelled_inbound": cancelled_inbound,
"skipped": skipped,
}))
}
}
+141 -53
View File
@@ -246,7 +246,9 @@ impl RpcHandler {
.as_array()
.ok_or_else(|| anyhow::anyhow!("DID Document missing '@context' array"))?;
let has_did_context = context.iter().any(|c| c.as_str() == Some("https://www.w3.org/ns/did/v1"));
let has_did_context = context
.iter()
.any(|c| c.as_str() == Some("https://www.w3.org/ns/did/v1"));
if !has_did_context {
return Ok(serde_json::json!({
"valid": false,
@@ -272,12 +274,14 @@ impl RpcHandler {
match crate::identity::pubkey_bytes_from_did_key(did) {
Ok(pubkey_bytes) => {
// Check that at least one verification method has matching key
let pubkey_multibase = format!("z{}", bs58::encode(&pubkey_bytes).into_string());
let has_matching_key = verification_methods.iter().any(|vm| {
vm["publicKeyMultibase"].as_str() == Some(&pubkey_multibase)
});
let pubkey_multibase =
format!("z{}", bs58::encode(&pubkey_bytes).into_string());
let has_matching_key = verification_methods
.iter()
.any(|vm| vm["publicKeyMultibase"].as_str() == Some(&pubkey_multibase));
if !has_matching_key {
errors.push("No verificationMethod matches the DID's public key".to_string());
errors
.push("No verificationMethod matches the DID's public key".to_string());
}
}
Err(e) => {
@@ -287,7 +291,10 @@ impl RpcHandler {
}
// Check authentication is present
if document["authentication"].as_array().map_or(true, |a| a.is_empty()) {
if document["authentication"]
.as_array()
.is_none_or(|a| a.is_empty())
{
errors.push("Missing or empty 'authentication' field".to_string());
}
@@ -343,15 +350,20 @@ impl RpcHandler {
id.to_string()
} else {
// Prefer an identity with a Nostr key
records.iter()
records
.iter()
.find(|r| r.nostr_pubkey.is_some())
.map(|r| r.id.clone())
.ok_or_else(|| anyhow::anyhow!("No identity with Nostr key found"))?
};
let identity = records.iter().find(|r| r.id == id)
let identity = records
.iter()
.find(|r| r.id == id)
.ok_or_else(|| anyhow::anyhow!("Identity not found: {}", id))?;
let pubkey_hex = identity.nostr_pubkey.clone()
let pubkey_hex = identity
.nostr_pubkey
.clone()
.ok_or_else(|| anyhow::anyhow!("Identity has no Nostr key"))?;
if let Some(event_hash) = params.get("event_hash").and_then(|v| v.as_str()) {
@@ -361,22 +373,32 @@ impl RpcHandler {
}
// Full event signing: compute NIP-01 event hash
let event = params.get("event")
let event = params
.get("event")
.ok_or_else(|| anyhow::anyhow!("Missing 'event' or 'event_hash' parameter"))?;
let kind = event.get("kind").and_then(|v| v.as_u64()).unwrap_or(1);
let content = event.get("content").and_then(|v| v.as_str()).unwrap_or("");
let created_at = event.get("created_at").and_then(|v| v.as_u64())
.unwrap_or_else(|| std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs());
let tags = event.get("tags").cloned().unwrap_or_else(|| serde_json::json!([]));
let created_at = event
.get("created_at")
.and_then(|v| v.as_u64())
.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
});
let tags = event
.get("tags")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
// NIP-01 serialization: [0, pubkey, created_at, kind, tags, content]
let serialized = serde_json::json!([0, pubkey_hex, created_at, kind, tags, content]);
let serialized_str = serde_json::to_string(&serialized)?;
// SHA-256 hash
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
let hash = Sha256::digest(serialized_str.as_bytes());
let event_hash_hex = hex::encode(hash);
@@ -406,7 +428,8 @@ impl RpcHandler {
return Ok(default_id);
}
// Fall back to first identity with a Nostr key, or just the first identity
records.iter()
records
.iter()
.find(|i| i.nostr_pubkey.is_some())
.or(records.first())
.map(|i| i.id.clone())
@@ -420,9 +443,13 @@ impl RpcHandler {
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let id = self.resolve_identity_id(&params).await?;
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
let pubkey = params
.get("pubkey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
let plaintext = params.get("plaintext").and_then(|v| v.as_str())
let plaintext = params
.get("plaintext")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: plaintext"))?;
let manager = IdentityManager::new(&self.config.data_dir).await?;
@@ -438,9 +465,13 @@ impl RpcHandler {
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let id = self.resolve_identity_id(&params).await?;
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
let pubkey = params
.get("pubkey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
let ciphertext = params.get("ciphertext").and_then(|v| v.as_str())
let ciphertext = params
.get("ciphertext")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ciphertext"))?;
let manager = IdentityManager::new(&self.config.data_dir).await?;
@@ -456,9 +487,13 @@ impl RpcHandler {
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let id = self.resolve_identity_id(&params).await?;
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
let pubkey = params
.get("pubkey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
let plaintext = params.get("plaintext").and_then(|v| v.as_str())
let plaintext = params
.get("plaintext")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: plaintext"))?;
let manager = IdentityManager::new(&self.config.data_dir).await?;
@@ -474,9 +509,13 @@ impl RpcHandler {
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let id = self.resolve_identity_id(&params).await?;
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
let pubkey = params
.get("pubkey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
let ciphertext = params.get("ciphertext").and_then(|v| v.as_str())
let ciphertext = params
.get("ciphertext")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ciphertext"))?;
let manager = IdentityManager::new(&self.config.data_dir).await?;
@@ -528,10 +567,7 @@ impl RpcHandler {
.await
.context("Failed to connect to peer over Tor")?;
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse peer response")?;
let body: serde_json::Value = resp.json().await.context("Failed to parse peer response")?;
// Extract the DID Document from the RPC response
let document = body
@@ -539,9 +575,7 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Peer returned error or missing result"))?;
// Cache the resolved DID locally
let did = document["id"]
.as_str()
.unwrap_or("unknown");
let did = document["id"].as_str().unwrap_or("unknown");
let cache_dir = self.config.data_dir.join("did-cache");
tokio::fs::create_dir_all(&cache_dir).await.ok();
let cache_file = cache_dir.join(format!("{}.json", onion.replace('.', "_")));
@@ -550,9 +584,12 @@ impl RpcHandler {
"resolved_at": chrono::Utc::now().to_rfc3339(),
"onion": onion,
});
tokio::fs::write(&cache_file, serde_json::to_string_pretty(&cache_entry).unwrap_or_default())
.await
.ok();
tokio::fs::write(
&cache_file,
serde_json::to_string_pretty(&cache_entry).unwrap_or_default(),
)
.await
.ok();
Ok(serde_json::json!({
"document": document,
@@ -627,7 +664,9 @@ impl RpcHandler {
let record = manager.get(identity_id).await?;
if record.dht_did.is_none() {
anyhow::bail!("Identity has no did:dht — create one first with identity.create-dht-did");
anyhow::bail!(
"Identity has no did:dht — create one first with identity.create-dht-did"
);
}
let signing_key = manager.get_signing_key(identity_id).await?;
@@ -645,18 +684,41 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let id = params.get("id").and_then(|v| v.as_str())
let id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
validate_identity_id(id)?;
let profile = IdentityProfile {
display_name: params.get("display_name").and_then(|v| v.as_str()).map(String::from),
about: params.get("about").and_then(|v| v.as_str()).map(String::from),
picture: params.get("picture").and_then(|v| v.as_str()).map(String::from),
banner: params.get("banner").and_then(|v| v.as_str()).map(String::from),
website: params.get("website").and_then(|v| v.as_str()).map(String::from),
nip05: params.get("nip05").and_then(|v| v.as_str()).map(String::from),
lud16: params.get("lud16").and_then(|v| v.as_str()).map(String::from),
display_name: params
.get("display_name")
.and_then(|v| v.as_str())
.map(String::from),
about: params
.get("about")
.and_then(|v| v.as_str())
.map(String::from),
picture: params
.get("picture")
.and_then(|v| v.as_str())
.map(String::from),
banner: params
.get("banner")
.and_then(|v| v.as_str())
.map(String::from),
website: params
.get("website")
.and_then(|v| v.as_str())
.map(String::from),
nip05: params
.get("nip05")
.and_then(|v| v.as_str())
.map(String::from),
lud16: params
.get("lud16")
.and_then(|v| v.as_str())
.map(String::from),
};
let manager = IdentityManager::new(&self.config.data_dir).await?;
@@ -665,27 +727,53 @@ impl RpcHandler {
Ok(serde_json::json!({ "ok": true }))
}
/// Publish kind 0 (metadata) profile to the local Nostr relay.
/// Publish kind 0 (metadata) profile to every enabled Nostr relay
/// configured in Manage Relays. Callers can override the default
/// list by passing `relays: [..]` in params (e.g. to publish to a
/// single relay for testing).
pub(in crate::api::rpc) async fn handle_identity_publish_profile(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let id = params.get("id").and_then(|v| v.as_str())
let id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
validate_identity_id(id)?;
let relay_url = params.get("relay")
.and_then(|v| v.as_str())
.unwrap_or("ws://localhost:18081");
let relay_urls: Vec<String> = if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect()
} else if let Some(single) = params.get("relay").and_then(|v| v.as_str()) {
vec![single.to_string()]
} else {
// Default: every enabled relay in the user's Manage Relays list.
let statuses = crate::nostr_relays::list_relays(&self.config.data_dir)
.await
.unwrap_or_default();
statuses
.into_iter()
.filter(|s| s.enabled)
.map(|s| s.url)
.collect()
};
if relay_urls.is_empty() {
anyhow::bail!("No enabled relays configured; add one under Manage Relays");
}
let manager = IdentityManager::new(&self.config.data_dir).await?;
let event_id = manager.publish_profile(id, relay_url).await?;
let outcome = manager.publish_profile(id, &relay_urls).await?;
Ok(serde_json::json!({
"event_id": event_id,
"relay": relay_url,
"published": true,
"event_id": outcome.event_id,
"accepted": outcome.accepted,
"rejected": outcome.rejected,
"relays_attempted": relay_urls.len(),
"published": !outcome.accepted.is_empty(),
}))
}
+4 -2
View File
@@ -9,9 +9,11 @@ pub(super) fn validate_identity_id(id: &str) -> Result<()> {
if id.contains("..") || id.contains('/') || id.contains('\\') || id.contains('\0') {
anyhow::bail!("Invalid identity id: contains forbidden characters");
}
if !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':') {
if !id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':')
{
anyhow::bail!("Invalid identity id: must be alphanumeric, hyphens, underscores, or colons");
}
Ok(())
}
+14 -14
View File
@@ -77,14 +77,10 @@ impl RpcHandler {
configure_ethernet_dhcp(interface).await?;
}
"static" => {
let ip = params
.get("ip")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ip for static mode"))?;
let gateway = params
.get("gateway")
.and_then(|v| v.as_str())
.unwrap_or("");
let ip = params.get("ip").and_then(|v| v.as_str()).ok_or_else(|| {
anyhow::anyhow!("Missing required parameter: ip for static mode")
})?;
let gateway = params.get("gateway").and_then(|v| v.as_str()).unwrap_or("");
let dns = params
.get("dns")
.and_then(|v| v.as_str())
@@ -140,7 +136,10 @@ impl RpcHandler {
"quad9" => dns::DnsProvider::Quad9,
"mullvad" => dns::DnsProvider::Mullvad,
"custom" => dns::DnsProvider::Custom,
other => anyhow::bail!("Unknown DNS provider: {}. Use: system, cloudflare, google, quad9, mullvad, custom", other),
other => anyhow::bail!(
"Unknown DNS provider: {}. Use: system, cloudflare, google, quad9, mullvad, custom",
other
),
};
let custom_servers: Vec<String> = if provider == dns::DnsProvider::Custom {
@@ -211,10 +210,7 @@ async fn list_interfaces() -> Result<Vec<serde_json::Value>> {
.get("operstate")
.and_then(|v| v.as_str())
.unwrap_or("UNKNOWN");
let mac = iface
.get("address")
.and_then(|v| v.as_str())
.unwrap_or("");
let mac = iface.get("address").and_then(|v| v.as_str()).unwrap_or("");
// Get IPv4 addresses
let addrs: Vec<String> = iface
@@ -236,7 +232,11 @@ async fn list_interfaces() -> Result<Vec<serde_json::Value>> {
"wifi"
} else if name.starts_with("en") || name.starts_with("eth") {
"ethernet"
} else if name.starts_with("veth") || name.starts_with("br-") || name.starts_with("docker") || name.starts_with("podman") {
} else if name.starts_with("veth")
|| name.starts_with("br-")
|| name.starts_with("docker")
|| name.starts_with("podman")
{
"virtual"
} else {
"other"
+95 -25
View File
@@ -86,9 +86,21 @@ impl RpcHandler {
.unwrap_or_default()
.into_iter()
.map(|ch| {
let capacity: i64 = ch.capacity.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
let local: i64 = ch.local_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
let remote: i64 = ch.remote_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
let capacity: i64 = ch
.capacity
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let local: i64 = ch
.local_balance
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let remote: i64 = ch
.remote_balance
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
ChannelInfo {
chan_id: ch.chan_id.unwrap_or_default(),
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
@@ -96,7 +108,11 @@ impl RpcHandler {
local_balance: local,
remote_balance: remote,
active: ch.active.unwrap_or(false),
status: if ch.active.unwrap_or(false) { "active".into() } else { "inactive".into() },
status: if ch.active.unwrap_or(false) {
"active".into()
} else {
"inactive".into()
},
channel_point: ch.channel_point.unwrap_or_default(),
}
})
@@ -105,9 +121,21 @@ impl RpcHandler {
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
if let Some(ch) = pch.channel {
let capacity: i64 = ch.capacity.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
let local: i64 = ch.local_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
let remote: i64 = ch.remote_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
let capacity: i64 = ch
.capacity
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let local: i64 = ch
.local_balance
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let remote: i64 = ch
.remote_balance
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
pending_channels.push(ChannelInfo {
chan_id: String::new(),
remote_pubkey: ch.remote_node_pub.unwrap_or_default(),
@@ -136,25 +164,36 @@ impl RpcHandler {
Ok(serde_json::to_value(result)?)
}
pub(in crate::api::rpc) async fn handle_lnd_openchannel(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_lnd_openchannel(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let pubkey = params.get("pubkey")
let pubkey = params
.get("pubkey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey' parameter"))?;
let amount = params.get("amount")
let amount = params
.get("amount")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
// Validate pubkey: must be 66-char hex (compressed secp256k1)
if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("Invalid pubkey: must be 66-character hex string"));
return Err(anyhow::anyhow!(
"Invalid pubkey: must be 66-character hex string"
));
}
if amount < 20000 {
return Err(anyhow::anyhow!("Channel amount must be at least 20,000 sats"));
return Err(anyhow::anyhow!(
"Channel amount must be at least 20,000 sats"
));
}
if amount > 16_777_215 {
return Err(anyhow::anyhow!("Channel amount exceeds maximum (16,777,215 sats)"));
return Err(anyhow::anyhow!(
"Channel amount exceeds maximum (16,777,215 sats)"
));
}
info!(peer = pubkey, amount = amount, "Opening Lightning channel");
@@ -193,36 +232,61 @@ impl RpcHandler {
.context("Failed to open channel")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await.context("Failed to parse open channel response")?;
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse open channel response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to open channel: {}", msg));
}
Ok(body)
}
pub(in crate::api::rpc) async fn handle_lnd_closechannel(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_lnd_closechannel(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let channel_point = params.get("channel_point")
let channel_point = params
.get("channel_point")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'channel_point' parameter (txid:output_index)"))?;
.ok_or_else(|| {
anyhow::anyhow!("Missing 'channel_point' parameter (txid:output_index)")
})?;
let parts: Vec<&str> = channel_point.split(':').collect();
if parts.len() != 2 {
return Err(anyhow::anyhow!("Invalid channel_point format. Expected 'txid:output_index'"));
return Err(anyhow::anyhow!(
"Invalid channel_point format. Expected 'txid:output_index'"
));
}
// Validate txid is 64-char hex and output_index is numeric
if parts[0].len() != 64 || !parts[0].chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("Invalid txid in channel_point: must be 64-character hex"));
return Err(anyhow::anyhow!(
"Invalid txid in channel_point: must be 64-character hex"
));
}
if parts[1].parse::<u32>().is_err() {
return Err(anyhow::anyhow!("Invalid output_index in channel_point: must be a number"));
return Err(anyhow::anyhow!(
"Invalid output_index in channel_point: must be a number"
));
}
let force = params.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
info!(channel_point = channel_point, force = force, "Closing Lightning channel");
let force = params
.get("force")
.and_then(|v| v.as_bool())
.unwrap_or(false);
info!(
channel_point = channel_point,
force = force,
"Closing Lightning channel"
);
let (client, macaroon_hex) = self.lnd_client().await?;
@@ -239,10 +303,16 @@ impl RpcHandler {
.context("Failed to close channel")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await.context("Failed to parse close channel response")?;
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse close channel response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
}
+5 -5
View File
@@ -34,8 +34,7 @@ struct LndChannelBalanceResponse {
impl RpcHandler {
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
let macaroon_path =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let macaroon_bytes = tokio::fs::read(macaroon_path)
.await
@@ -115,8 +114,7 @@ impl RpcHandler {
/// for building lndconnect:// URIs in the frontend.
pub(crate) async fn handle_lnd_connect_info(&self) -> Result<serde_json::Value> {
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
let macaroon_path =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
// Read and encode TLS cert (PEM -> DER -> base64url)
let cert_pem = tokio::fs::read_to_string(cert_path)
@@ -182,7 +180,9 @@ impl RpcHandler {
/// lnd.export-channel-backup -- Export all channel static backups (SCB).
/// Returns base64-encoded multi-channel backup that can restore channels on a new node.
pub(in crate::api::rpc) async fn handle_lnd_export_channel_backup(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_lnd_export_channel_backup(
&self,
) -> Result<serde_json::Value> {
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let macaroon_bytes = tokio::fs::read(macaroon_path)
.await
+1 -2
View File
@@ -22,8 +22,7 @@ impl RpcHandler {
/// Returns an HTTP client configured for LND's self-signed TLS and the
/// hex-encoded admin macaroon for request headers.
pub(crate) async fn lnd_client(&self) -> Result<(reqwest::Client, String)> {
let macaroon_path =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let macaroon_bytes = tokio::fs::read(macaroon_path)
.await
.context("Failed to read LND admin macaroon — is LND installed?")?;
+30 -14
View File
@@ -4,9 +4,13 @@ use tracing::info;
impl RpcHandler {
/// Pay a Lightning invoice.
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let payment_request = params.get("payment_request")
let payment_request = params
.get("payment_request")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_request' parameter"))?;
@@ -15,8 +19,11 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid payment request length"));
}
let lower = payment_request.to_lowercase();
if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt") {
return Err(anyhow::anyhow!("Invalid payment request: must be a Lightning invoice (lnbc...)"));
if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt")
{
return Err(anyhow::anyhow!(
"Invalid payment request: must be a Lightning invoice (lnbc...)"
));
}
info!("Paying Lightning invoice");
@@ -36,26 +43,36 @@ impl RpcHandler {
.context("Failed to pay invoice")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse payment response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Payment failed: {}", msg));
}
let payment_error = body.get("payment_error").and_then(|v| v.as_str()).unwrap_or("");
let payment_error = body
.get("payment_error")
.and_then(|v| v.as_str())
.unwrap_or("");
if !payment_error.is_empty() {
return Err(anyhow::anyhow!("Payment failed: {}", payment_error));
}
let amount_sat = body.get("payment_route")
let amount_sat = body
.get("payment_route")
.and_then(|r| r.get("total_amt"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
let payment_hash = body.get("payment_hash")
let payment_hash = body
.get("payment_hash")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
@@ -68,7 +85,9 @@ impl RpcHandler {
/// List on-chain transactions from LND.
/// Returns all transactions, with incoming (amount > 0) flagged.
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(
&self,
) -> Result<serde_json::Value> {
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
@@ -148,10 +167,7 @@ impl RpcHandler {
.unwrap_or("")
.to_string();
let block_height: i64 = tx
.get("block_height")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let block_height: i64 = tx.get("block_height").and_then(|v| v.as_i64()).unwrap_or(0);
let direction = if amount > 0 { "incoming" } else { "outgoing" };
+149 -58
View File
@@ -16,10 +16,13 @@ impl RpcHandler {
.await
.context("LND REST connection failed")?;
let body: serde_json::Value = resp.json().await
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse newaddress response")?;
let address = body.get("address")
let address = body
.get("address")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
@@ -28,17 +31,24 @@ impl RpcHandler {
}
/// Send on-chain Bitcoin to an address.
pub(in crate::api::rpc) async fn handle_lnd_sendcoins(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_lnd_sendcoins(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let addr = params.get("addr")
let addr = params
.get("addr")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
let amount = params.get("amount")
let amount = params
.get("amount")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
if amount < 546 {
return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)"));
return Err(anyhow::anyhow!(
"Amount must be at least 546 sats (dust limit)"
));
}
if amount > 21_000_000 * 100_000_000 {
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
@@ -67,27 +77,35 @@ impl RpcHandler {
.context("Failed to send on-chain transaction")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await
.context("Failed to parse send response")?;
let body: serde_json::Value = resp.json().await.context("Failed to parse send response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to send: {}", msg));
}
let txid = body.get("txid").and_then(|v| v.as_str()).unwrap_or("").to_string();
let txid = body
.get("txid")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(serde_json::json!({ "txid": txid }))
}
/// Create a Lightning invoice.
pub(in crate::api::rpc) async fn handle_lnd_createinvoice(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_lnd_createinvoice(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let amount_sats = params.get("amount_sats")
let amount_sats = params
.get("amount_sats")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats' parameter"))?;
let memo = params.get("memo")
.and_then(|v| v.as_str())
.unwrap_or("");
let memo = params.get("memo").and_then(|v| v.as_str()).unwrap_or("");
if amount_sats < 0 {
return Err(anyhow::anyhow!("Amount must be non-negative"));
@@ -119,15 +137,21 @@ impl RpcHandler {
.context("Failed to create invoice")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse invoice response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to create invoice: {}", msg));
}
let payment_request = body.get("payment_request")
let payment_request = body
.get("payment_request")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
@@ -140,12 +164,18 @@ impl RpcHandler {
/// Create an unsigned PSBT for hardware wallet signing.
/// Uses LND's WalletKit.FundPsbt to select UTXOs and create a PSBT template.
pub(in crate::api::rpc) async fn handle_lnd_create_psbt(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_lnd_create_psbt(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let outputs = params.get("outputs")
let outputs = params
.get("outputs")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("Missing 'outputs' array (each: address + amount_sats)"))?;
.ok_or_else(|| {
anyhow::anyhow!("Missing 'outputs' array (each: address + amount_sats)")
})?;
if outputs.is_empty() {
return Err(anyhow::anyhow!("outputs must not be empty"));
@@ -155,28 +185,40 @@ impl RpcHandler {
let mut lnd_outputs: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
let mut total_amount: i64 = 0;
for output in outputs {
let addr = output.get("address")
let addr = output
.get("address")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Each output must have an 'address'"))?;
// Validate Bitcoin address format
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
if addr.len() < 14
|| addr.len() > 90
|| !addr.chars().all(|c| c.is_ascii_alphanumeric())
{
return Err(anyhow::anyhow!("Invalid Bitcoin address format in output"));
}
let amount = output.get("amount_sats")
let amount = output
.get("amount_sats")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("Each output must have 'amount_sats'"))?;
if amount < 546 {
return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)"));
return Err(anyhow::anyhow!(
"Amount must be at least 546 sats (dust limit)"
));
}
lnd_outputs.insert(addr.to_string(), serde_json::json!(amount));
total_amount += amount;
}
let sat_per_vbyte = params.get("fee_rate_sat_per_vbyte")
let sat_per_vbyte = params
.get("fee_rate_sat_per_vbyte")
.and_then(|v| v.as_u64())
.unwrap_or(10);
info!(total_amount = total_amount, fee_rate = sat_per_vbyte, "Creating PSBT for hardware wallet signing");
info!(
total_amount = total_amount,
fee_rate = sat_per_vbyte,
"Creating PSBT for hardware wallet signing"
);
let (client, macaroon_hex) = self.lnd_client().await?;
@@ -197,20 +239,24 @@ impl RpcHandler {
.context("Failed to create PSBT via LND")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await
.context("Failed to parse PSBT response")?;
let body: serde_json::Value = resp.json().await.context("Failed to parse PSBT response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to create PSBT: {}", msg));
}
let funded_psbt = body.get("funded_psbt")
let funded_psbt = body
.get("funded_psbt")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let change_output_index = body.get("change_output_index")
let change_output_index = body
.get("change_output_index")
.and_then(|v| v.as_i64())
.unwrap_or(-1);
@@ -224,9 +270,13 @@ impl RpcHandler {
/// Finalize a signed PSBT and broadcast the transaction.
/// Takes a PSBT that has been signed by a hardware wallet.
pub(in crate::api::rpc) async fn handle_lnd_finalize_psbt(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_lnd_finalize_psbt(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let signed_psbt = params.get("signed_psbt_base64")
let signed_psbt = params
.get("signed_psbt_base64")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'signed_psbt_base64'"))?;
@@ -247,15 +297,21 @@ impl RpcHandler {
.context("Failed to finalize PSBT via LND")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse finalize response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to finalize PSBT: {}", msg));
}
let raw_final_tx = body.get("raw_final_tx")
let raw_final_tx = body
.get("raw_final_tx")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
@@ -274,11 +330,16 @@ impl RpcHandler {
.context("Failed to broadcast transaction")?;
let pub_status = pub_resp.status();
let pub_body: serde_json::Value = pub_resp.json().await
let pub_body: serde_json::Value = pub_resp
.json()
.await
.context("Failed to parse broadcast response")?;
if !pub_status.is_success() {
let msg = pub_body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
let msg = pub_body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Transaction broadcast failed: {}", msg));
}
@@ -291,13 +352,18 @@ impl RpcHandler {
/// Create a signed raw transaction WITHOUT broadcasting.
/// Used for mesh relay: create the TX locally, then relay the hex to an
/// internet-connected peer who broadcasts it.
pub(in crate::api::rpc) async fn handle_lnd_create_raw_tx(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_lnd_create_raw_tx(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let addr = params.get("addr")
let addr = params
.get("addr")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'addr'"))?;
let amount_sats = params.get("amount_sats")
let amount_sats = params
.get("amount_sats")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats'"))?;
@@ -329,15 +395,18 @@ impl RpcHandler {
.context("Failed to fund PSBT via LND")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await
.context("Failed to parse fund response")?;
let body: serde_json::Value = resp.json().await.context("Failed to parse fund response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to create TX: {}", msg));
}
let funded_psbt = body.get("funded_psbt")
let funded_psbt = body
.get("funded_psbt")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No funded_psbt in response"))?;
@@ -355,16 +424,22 @@ impl RpcHandler {
.context("Failed to finalize PSBT")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse finalize response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to sign TX: {}", msg));
}
// raw_final_tx from LND is base64-encoded -- decode to hex for Bitcoin RPC
let raw_final_tx_b64 = body.get("raw_final_tx")
let raw_final_tx_b64 = body
.get("raw_final_tx")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No raw_final_tx in response"))?;
@@ -373,7 +448,12 @@ impl RpcHandler {
.context("Failed to decode raw_final_tx base64")?;
let raw_tx_hex = hex::encode(&tx_bytes);
info!(addr, amount_sats, tx_len = raw_tx_hex.len(), "Created raw TX for mesh relay (NOT broadcast)");
info!(
addr,
amount_sats,
tx_len = raw_tx_hex.len(),
"Created raw TX for mesh relay (NOT broadcast)"
);
Ok(serde_json::json!({
"raw_tx_hex": raw_tx_hex,
@@ -391,28 +471,34 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let password = params.get("password")
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?;
let wallet_password = params.get("wallet_password")
let wallet_password = params
.get("wallet_password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'wallet_password' for LND"))?;
// Verify user password before granting seed access.
self.auth_manager.verify_password(password).await
self.auth_manager
.verify_password(password)
.await
.context("Password verification failed")?;
// Load encrypted seed from disk.
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password).await
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password)
.await
.context("Failed to load encrypted seed. Was a seed phrase saved during onboarding?")?;
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
// Derive 16 bytes of LND entropy.
let mut entropy = crate::seed::derive_lnd_entropy(&seed)?;
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(&entropy);
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(entropy);
entropy.zeroize();
let wallet_password_b64 = base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes());
let wallet_password_b64 =
base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes());
// Call LND REST API to initialize wallet with derived entropy.
// LND must be running but NOT yet initialized (no existing wallet).
@@ -435,11 +521,16 @@ impl RpcHandler {
.context("LND initwallet request failed — is LND running and uninitialized?")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse initwallet response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("LND wallet init failed: {}", msg));
}
+15 -14
View File
@@ -18,7 +18,9 @@ impl RpcHandler {
.collect();
// Load federated DIDs for trust scoring
let fed_nodes = federation::load_nodes(&self.config.data_dir).await.unwrap_or_default();
let fed_nodes = federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
let federated_dids: Vec<String> = fed_nodes.iter().map(|n| n.did.clone()).collect();
let tor_proxy = std::env::var("ARCHIPELAGO_NOSTR_TOR_PROXY").ok();
@@ -42,8 +44,8 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let manifest: marketplace::AppManifest =
serde_json::from_value(params).map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?;
let manifest: marketplace::AppManifest = serde_json::from_value(params)
.map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?;
// Validate before publishing
let issues = marketplace::validate_manifest(&manifest);
@@ -112,8 +114,8 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let manifest: marketplace::AppManifest =
serde_json::from_value(params).map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?;
let manifest: marketplace::AppManifest = serde_json::from_value(params)
.map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?;
let issues = marketplace::validate_manifest(&manifest);
let (trust_score, trust_tier) = marketplace::calculate_trust_score(&manifest, 0, &[]);
@@ -147,9 +149,7 @@ impl RpcHandler {
"amount": amount_sats,
"memo": format!("Archipelago app: {}", app_id),
});
let invoice_result = self
.handle_lnd_createinvoice(Some(invoice_params))
.await?;
let invoice_result = self.handle_lnd_createinvoice(Some(invoice_params)).await?;
let payment_request = invoice_result
.get("payment_request")
@@ -181,15 +181,14 @@ impl RpcHandler {
// Validate r_hash is hex-encoded (LND payment hashes are 32 bytes = 64 hex chars)
if r_hash.len() != 64 || !r_hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("Invalid r_hash: must be 64-character hex string"));
return Err(anyhow::anyhow!(
"Invalid r_hash: must be 64-character hex string"
));
}
let (client, macaroon_hex) = self.lnd_client().await?;
let url = format!(
"https://127.0.0.1:8080/v1/invoice/{}",
r_hash
);
let url = format!("https://127.0.0.1:8080/v1/invoice/{}", r_hash);
let paid = match client
.get(&url)
.header("Grpc-Metadata-macaroon", &macaroon_hex)
@@ -198,7 +197,9 @@ impl RpcHandler {
{
Ok(r) if r.status().is_success() => {
let body: serde_json::Value = r.json().await.unwrap_or_default();
body.get("settled").and_then(|v| v.as_bool()).unwrap_or(false)
body.get("settled")
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
_ => false,
};
@@ -13,9 +13,7 @@ impl RpcHandler {
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing tx_hex"))?;
let relay_mode = params["relay_mode"]
.as_str()
.unwrap_or("archy");
let relay_mode = params["relay_mode"].as_str().unwrap_or("archy");
if tx_hex.len() < 20 || tx_hex.len() > 200_000 {
anyhow::bail!("Invalid tx_hex length");
@@ -26,11 +24,14 @@ impl RpcHandler {
}
let service = self.mesh_service.read().await;
let svc = service.as_ref()
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let request_id = chrono::Utc::now().timestamp() as u64;
svc.relay_tracker.track_tx_relay(request_id, svc.our_did()).await;
svc.relay_tracker
.track_tx_relay(request_id, svc.our_did())
.await;
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
@@ -44,7 +45,9 @@ impl RpcHandler {
// Encrypt with first available Archy peer's shared secret
// (any Archy node that receives it can try decrypting)
let payload = shared_secrets.values().next()
let payload = shared_secrets
.values()
.next()
.and_then(|secret| {
crate::mesh::crypto::encrypt(secret, &wire).ok().map(|ct| {
let mut encrypted = Vec::with_capacity(1 + ct.len());
@@ -60,32 +63,41 @@ impl RpcHandler {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
let _ = shared_state
.cmd_tx
.send(crate::mesh::listener::MeshCommand::BroadcastChannel {
.send_cmd(crate::mesh::listener::MeshCommand::BroadcastChannel {
channel: 0,
payload: b64.into_bytes(),
})
.await;
}
info!(request_id, tx_len = tx_hex.len(), "TX relay broadcast on mesh channel 0 (encrypted)");
info!(
request_id,
tx_len = tx_hex.len(),
"TX relay broadcast on mesh channel 0 (encrypted)"
);
} else {
// Archy mode: E2E encrypted per-peer, direct to known Archy nodes
let peers = svc.peers().await;
let shared_state = svc.shared_state();
let shared_secrets = shared_state.shared_secrets.read().await;
for peer in &peers {
if !peer.advert_name.starts_with("Archy-") { continue; }
if !peer.advert_name.starts_with("Archy-") {
continue;
}
if let Some(ref pk) = peer.pubkey_hex {
if let Ok(pk_bytes) = hex::decode(pk) {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id)
{
match crate::mesh::crypto::encrypt(secret, &wire) {
Ok(ciphertext) => {
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
let mut encrypted =
Vec::with_capacity(1 + ciphertext.len());
encrypted.push(
crate::mesh::message_types::ENCRYPTED_TYPED_MARKER,
);
encrypted.extend_from_slice(&ciphertext);
encrypted
}
@@ -95,9 +107,9 @@ impl RpcHandler {
wire.clone()
};
let _ = svc.shared_state()
.cmd_tx
.send(crate::mesh::listener::MeshCommand::SendRaw {
let _ = svc
.shared_state()
.send_cmd(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload,
})
@@ -108,7 +120,12 @@ impl RpcHandler {
}
}
drop(shared_secrets);
info!(request_id, tx_len = tx_hex.len(), archy_peers = sent_count, "TX relay sent to Archy peers (E2E encrypted)");
info!(
request_id,
tx_len = tx_hex.len(),
archy_peers = sent_count,
"TX relay sent to Archy peers (E2E encrypted)"
);
}
Ok(serde_json::json!({
"request_id": request_id,
@@ -128,7 +145,8 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing request_id"))?;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
// Check completed results first
@@ -169,7 +187,8 @@ impl RpcHandler {
.unwrap_or(10) as usize;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let headers = svc.block_header_cache.recent_headers(count).await;
@@ -206,14 +225,19 @@ impl RpcHandler {
}
let service = self.mesh_service.read().await;
let svc = service.as_ref()
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let request_id = chrono::Utc::now().timestamp() as u64;
svc.relay_tracker.track_lightning_relay(request_id, svc.our_did()).await;
svc.relay_tracker
.track_lightning_relay(request_id, svc.our_did())
.await;
let wire = crate::mesh::bitcoin_relay::build_lightning_relay_request(
bolt11, amount_sats, request_id,
bolt11,
amount_sats,
request_id,
)?;
// Send to Archipelago peers — E2E encrypted per-peer
@@ -222,7 +246,9 @@ impl RpcHandler {
let shared_secrets = shared_state.shared_secrets.read().await;
let mut sent_count = 0u32;
for peer in &peers {
if !peer.advert_name.starts_with("Archy-") { continue; }
if !peer.advert_name.starts_with("Archy-") {
continue;
}
if let Some(ref pk) = peer.pubkey_hex {
if let Ok(pk_bytes) = hex::decode(pk) {
if pk_bytes.len() >= 6 {
@@ -233,7 +259,8 @@ impl RpcHandler {
match crate::mesh::crypto::encrypt(secret, &wire) {
Ok(ciphertext) => {
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
encrypted
.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
encrypted.extend_from_slice(&ciphertext);
encrypted
}
@@ -243,9 +270,9 @@ impl RpcHandler {
wire.clone()
};
let _ = svc.shared_state()
.cmd_tx
.send(crate::mesh::listener::MeshCommand::SendRaw {
let _ = svc
.shared_state()
.send_cmd(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload,
})
@@ -257,7 +284,12 @@ impl RpcHandler {
}
drop(shared_secrets);
info!(request_id, amount_sats, archy_peers = sent_count, "Lightning relay sent (E2E encrypted)");
info!(
request_id,
amount_sats,
archy_peers = sent_count,
"Lightning relay sent (E2E encrypted)"
);
Ok(serde_json::json!({
"request_id": request_id,
"queued": true,
@@ -40,6 +40,39 @@ impl RpcHandler {
}))
}
/// mesh.send-channel — Send a text message to a mesh channel (broadcast).
pub(in crate::api::rpc) async fn handle_mesh_send_channel(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let channel = params.get("channel").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
if message.is_empty() {
anyhow::bail!("Message cannot be empty");
}
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
let msg = svc.send_channel_message(channel, message).await?;
info!(channel, "Sent mesh channel message");
Ok(serde_json::json!({
"sent": true,
"message_id": msg.id,
"channel": channel,
}))
}
/// mesh.broadcast — Broadcast our node identity over mesh.
pub(in crate::api::rpc) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
+37 -14
View File
@@ -36,9 +36,12 @@ impl RpcHandler {
}
/// mesh.deadman-status — Get dead man's switch status.
pub(in crate::api::rpc) async fn handle_mesh_deadman_status(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_mesh_deadman_status(
&self,
) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service.as_ref()
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let status = svc.dead_man_switch.status().await;
@@ -53,7 +56,8 @@ impl RpcHandler {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let mut config = svc.dead_man_switch.get_config().await;
@@ -71,7 +75,10 @@ impl RpcHandler {
params.get("lat").and_then(|v| v.as_f64()),
params.get("lng").and_then(|v| v.as_f64()),
) {
let label = params.get("label").and_then(|v| v.as_str()).map(|s| s.to_string());
let label = params
.get("label")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
config.last_gps = Some(Coordinate::from_degrees(lat, lng, label));
}
if let Some(contacts) = params.get("contacts").and_then(|v| v.as_array()) {
@@ -97,9 +104,12 @@ impl RpcHandler {
}
/// mesh.deadman-checkin — Heartbeat to reset the dead man's switch timer.
pub(in crate::api::rpc) async fn handle_mesh_deadman_checkin(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_mesh_deadman_checkin(
&self,
) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service.as_ref()
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
svc.dead_man_check_in().await;
@@ -112,7 +122,9 @@ impl RpcHandler {
}
/// mesh.rotate-prekeys — Force prekey rotation for X3DH.
pub(in crate::api::rpc) async fn handle_mesh_rotate_prekeys(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_mesh_rotate_prekeys(
&self,
) -> Result<serde_json::Value> {
// Load identity signing key
let identity_dir = self.config.data_dir.join("identity");
let node_key_path = identity_dir.join("node_key");
@@ -162,7 +174,8 @@ impl RpcHandler {
let count = params["count"].as_u64().unwrap_or(3) as usize;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let mut sent = 0usize;
@@ -176,7 +189,10 @@ impl RpcHandler {
"chunked" => {
// Send a TypedEnvelope that requires chunking (>140 base64 chars)
let fake_tx = "0".repeat(400); // simulates TX hex
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(&fake_tx, test_id as u64 + i as u64)?;
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(
&fake_tx,
test_id as u64 + i as u64,
)?;
// Send via SendRaw which handles base64 + chunking
let peers = svc.peers().await;
if let Some(peer) = peers.iter().find(|p| p.contact_id == contact_id) {
@@ -185,12 +201,13 @@ impl RpcHandler {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let _ = svc.shared_state().cmd_tx.send(
crate::mesh::listener::MeshCommand::SendRaw {
let _ = svc
.shared_state()
.send_cmd(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload: wire,
},
).await;
})
.await;
sent += 1;
}
}
@@ -206,7 +223,13 @@ impl RpcHandler {
// Send as plain text for ping/medium/large
let _msg = svc.send_message(contact_id, &payload).await?;
sent += 1;
info!(test_id, seq = i, mode, len = payload.len(), "Test message sent");
info!(
test_id,
seq = i,
mode,
len = payload.len(),
"Test message sent"
);
// Small delay between sends
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
+181 -1
View File
@@ -70,6 +70,111 @@ impl RpcHandler {
}
}
/// conversations.list — Unified inbox across mesh peers, mesh channels,
/// and federation nodes. Each conversation returns its latest message
/// timestamp + snippet + transport tag so the UI can render one sorted list.
pub(in crate::api::rpc) async fn handle_conversations_list(
&self,
_params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let mut conversations: Vec<serde_json::Value> = Vec::new();
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let peers = svc.peers().await;
let messages = svc.messages(None).await;
// Per-peer last message.
for peer in &peers {
let last = messages
.iter()
.rev()
.find(|m| m.peer_contact_id == peer.contact_id);
let is_federation = peer.contact_id & 0x8000_0000 != 0;
conversations.push(serde_json::json!({
"id": format!("{}:{}", if is_federation { "federation" } else { "mesh" }, peer.contact_id),
"transport": if is_federation { "federation" } else { "mesh" },
"contact_id": peer.contact_id,
"name": peer.advert_name,
"pubkey": peer.pubkey_hex,
"last_text": last.map(|m| m.plaintext.clone()),
"last_timestamp": last.map(|m| m.timestamp.clone()),
"last_direction": last.map(|m| format!("{:?}", m.direction).to_lowercase()),
}));
}
// Channel 0 ("Archipelago") as a synthetic conversation.
let channel_last = messages
.iter()
.rev()
.find(|m| m.message_type == "text" && m.peer_contact_id == 0);
conversations.push(serde_json::json!({
"id": "channel:0",
"transport": "channel",
"channel": 0,
"name": "Archipelago",
"last_text": channel_last.map(|m| m.plaintext.clone()),
"last_timestamp": channel_last.map(|m| m.timestamp.clone()),
}));
}
// Sort by last_timestamp desc (missing timestamps sink).
conversations.sort_by(|a, b| {
let at = a
.get("last_timestamp")
.and_then(|v| v.as_str())
.unwrap_or("");
let bt = b
.get("last_timestamp")
.and_then(|v| v.as_str())
.unwrap_or("");
bt.cmp(at)
});
Ok(serde_json::json!({ "conversations": conversations }))
}
/// conversations.messages — Return messages for a ConversationId string
/// (format: `mesh:<contact_id>` | `federation:<contact_id>` | `channel:<u8>`).
pub(in crate::api::rpc) async fn handle_conversations_messages(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let id = params["id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
let (kind, rest) = id
.split_once(':')
.ok_or_else(|| anyhow::anyhow!("Invalid conversation id"))?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let all = svc.messages(None).await;
let filtered: Vec<_> = match kind {
"mesh" | "federation" => {
let contact_id: u32 = rest.parse().unwrap_or(0);
all.into_iter()
.filter(|m| m.peer_contact_id == contact_id)
.collect()
}
"channel" => {
// For now the channel bucket keeps contact_id = 0.
all.into_iter().filter(|m| m.peer_contact_id == 0).collect()
}
_ => Vec::new(),
};
Ok(serde_json::json!({ "messages": filtered }))
}
/// mesh.debug-dump — Full in-memory state snapshot for debugging.
/// Returns peers, all messages, status, shared-secret peer ids, encrypt_relay
/// flag, and stego mode. Intended for smoke tests and bug investigation.
pub(in crate::api::rpc) async fn handle_mesh_debug_dump(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
Ok(svc.debug_dump().await)
} else {
Ok(serde_json::json!({ "running": false }))
}
}
/// mesh.session-status — Get ratchet session info for a peer.
pub(in crate::api::rpc) async fn handle_mesh_session_status(
&self,
@@ -84,7 +189,10 @@ impl RpcHandler {
let service = self.mesh_service.read().await;
let peer_did = if let Some(svc) = service.as_ref() {
let peers = svc.peers().await;
peers.iter().find(|p| p.contact_id == contact_id).and_then(|p| p.did.clone())
peers
.iter()
.find(|p| p.contact_id == contact_id)
.and_then(|p| p.did.clone())
} else {
None
};
@@ -118,4 +226,76 @@ impl RpcHandler {
}))
}
}
/// mesh.clear-all — Nuclear reset: wipe all mesh state files and restart
/// the service for a completely clean slate.
pub(in crate::api::rpc) async fn handle_mesh_clear_all(&self) -> Result<serde_json::Value> {
let data_dir = self.config.data_dir.clone();
// Delete all mesh state files
for filename in &[
"messages.json",
"mesh-contacts.json",
"sessions.json",
"mesh-outbox.json",
] {
let _ = tokio::fs::remove_file(data_dir.join(filename)).await;
}
// Clear in-memory state
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let state = svc.state();
// Snapshot the firmware pubkeys we currently know about, then
// add them to the radio-contact blocklist. MeshCore's on-device
// contact table is persistent and reads back stale rows on the
// next refresh_contacts, so without this step `clear-all` only
// wipes the app view for a few seconds before the old entries
// reappear. The blocklist is also saved to disk so the filter
// survives a restart.
let firmware_pubkeys: Vec<String> = state
.peers
.read()
.await
.values()
.filter_map(|p| {
// Federation-synthetic peers have their contact_id in the
// high half of u32 and carry the archipelago key — those
// aren't firmware contacts and must not go on the list.
if p.contact_id & 0x8000_0000 != 0 {
None
} else {
p.pubkey_hex.clone()
}
})
.collect();
{
let mut set = state.radio_contact_blocklist.write().await;
for pk in &firmware_pubkeys {
set.insert(pk.clone());
}
}
let persisted: Vec<String> = state
.radio_contact_blocklist
.read()
.await
.iter()
.cloned()
.collect();
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &persisted).await;
state.peers.write().await.clear();
state.messages.write().await.clear();
state.contacts.write().await.clear();
state.presence.write().await.clear();
state.chunk_buffer.write().await.clear();
state.shared_secrets.write().await.clear();
// Re-seed federation peers
crate::mesh::seed_federation_peers_into_mesh(state, &data_dir).await;
// Trigger a contact refresh from the radio device
let _ = state
.send_cmd(crate::mesh::listener::MeshCommand::RefreshContacts)
.await;
}
Ok(serde_json::json!({ "status": "cleared" }))
}
}
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -38,10 +38,7 @@ pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
];
/// Methods whose responses can be cached for a few seconds.
pub(super) const CACHEABLE_METHODS: &[&str] = &[
"system.stats",
"federation.list-nodes",
];
pub(super) const CACHEABLE_METHODS: &[&str] = &["system.stats", "federation.list-nodes"];
/// Sanitize error messages before returning to clients.
/// Keeps user-facing validation errors but strips internal system details.
@@ -69,7 +66,8 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
for prefix in &user_facing_prefixes {
if msg.starts_with(prefix) {
// Truncate long messages and strip file paths
let sanitized = msg.replace("/var/lib/archipelago/", "[data]/")
let sanitized = msg
.replace("/var/lib/archipelago/", "[data]/")
.replace("/usr/local/bin/", "[bin]/")
.replace("/etc/", "[config]/");
return if sanitized.len() > 200 {
+142 -44
View File
@@ -8,15 +8,16 @@ mod credentials;
mod dispatcher;
mod dwn;
mod federation;
mod fips;
mod handshake;
mod identity;
mod interfaces;
mod lnd;
mod marketplace;
mod mesh;
mod middleware;
mod monitoring;
mod names;
mod lnd;
mod mesh;
mod network;
mod node;
mod nostr;
@@ -24,12 +25,13 @@ mod package;
mod peers;
mod response;
mod router;
mod seed_rpc;
mod security;
mod tor;
mod transport;
mod totp;
mod seed_rpc;
mod streaming;
mod system;
mod tor;
mod totp;
mod transport;
mod update;
mod vpn;
mod wallet;
@@ -49,10 +51,10 @@ use std::sync::Arc;
use tracing::{debug, error};
use middleware::{
UNAUTHENTICATED_METHODS, CACHEABLE_METHODS,
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
CACHEABLE_METHODS, UNAUTHENTICATED_METHODS,
};
use response::{RpcRequest, RpcResponse, RpcError, ResponseCache, json_response, cookie_header};
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
/// Default dev password when no user is set up (matches mock-backend).
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
@@ -70,6 +72,13 @@ pub struct RpcHandler {
response_cache: ResponseCache,
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
/// Shared content-addressed blob store. Set by ApiHandler after construction
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
/// second instance and duplicated cap_key.
pub(crate) blob_store: Arc<tokio::sync::RwLock<Option<Arc<crate::blobs::BlobStore>>>>,
/// Our own Ed25519 pubkey hex — needed by ContentRef senders for cap scoping
/// and by ContentRef receivers to request caps scoped to themselves.
pub(crate) self_pubkey_hex: Arc<tokio::sync::RwLock<Option<String>>>,
}
impl RpcHandler {
@@ -87,7 +96,9 @@ impl RpcHandler {
} else {
None
};
let port_allocator = Arc::new(tokio::sync::Mutex::new(PortAllocator::new(&config.data_dir).await?));
let port_allocator = Arc::new(tokio::sync::Mutex::new(
PortAllocator::new(&config.data_dir).await?,
));
let login_rate_limiter = LoginRateLimiter::new();
let endpoint_rate_limiter = EndpointRateLimiter::new();
@@ -127,11 +138,19 @@ impl RpcHandler {
response_cache: ResponseCache::new(5),
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
})
}
/// Set the mesh service (called after identity is loaded).
pub async fn set_mesh_service(&self, service: crate::mesh::MeshService) {
// If the blob store is already initialised, propagate it into the
// freshly-started mesh state so the listener can persist inline
// attachments. Mirrors `set_blob_store`'s forward-propagation.
if let Some(store) = self.blob_store.read().await.as_ref().cloned() {
*service.shared_state().blob_store.write().await = Some(store);
}
*self.mesh_service.write().await = Some(service);
}
@@ -140,6 +159,22 @@ impl RpcHandler {
*self.transport_router.write().await = Some(router);
}
/// Share the blob store + our pubkey so mesh.send-content / fetch-content
/// can reach them. Called once from ApiHandler::new.
pub async fn set_blob_store(
&self,
store: Arc<crate::blobs::BlobStore>,
self_pubkey_hex: String,
) {
*self.blob_store.write().await = Some(store.clone());
*self.self_pubkey_hex.write().await = Some(self_pubkey_hex);
// Propagate into a running mesh service if one is already up — keeps
// `set_blob_store` and `set_mesh_service` order-independent.
if let Some(svc) = self.mesh_service.read().await.as_ref() {
*svc.shared_state().blob_store.write().await = Some(store);
}
}
/// Get reference to the mesh service Arc (for MeshTransport wrapper).
pub fn mesh_service_arc(&self) -> Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>> {
Arc::clone(&self.mesh_service)
@@ -162,20 +197,18 @@ impl RpcHandler {
""
}
pub async fn handle(
&self,
req: Request<hyper::Body>,
) -> Result<Response<hyper::Body>> {
pub async fn handle(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
// Extract session cookie before consuming the request
let (parts, body) = req.into_parts();
let session_token = session::extract_session_cookie(&parts.headers);
let secure_suffix = self.cookie_suffix_for_request(&parts.headers);
let body_bytes = hyper::body::to_bytes(body).await
let body_bytes = hyper::body::to_bytes(body)
.await
.context("Failed to read body")?;
let rpc_req: RpcRequest = serde_json::from_slice(&body_bytes)
.context("Invalid RPC request")?;
let rpc_req: RpcRequest =
serde_json::from_slice(&body_bytes).context("Invalid RPC request")?;
debug!("RPC method: {}", rpc_req.method);
@@ -202,7 +235,11 @@ impl RpcHandler {
}
if !authenticated {
let reason = if session_token.is_none() { "no session cookie" } else { "invalid/expired token" };
let reason = if session_token.is_none() {
"no session cookie"
} else {
"invalid/expired token"
};
tracing::warn!(method = %rpc_req.method, reason, "401 Unauthorized — rejecting RPC call");
return Ok(self.error_response(401, "Unauthorized", StatusCode::UNAUTHORIZED));
}
@@ -212,7 +249,11 @@ impl RpcHandler {
if !is_unauthenticated {
if let Ok(Some(user)) = self.auth_manager.get_user().await {
if !user.role.can_access(&rpc_req.method) {
return Ok(self.error_response(403, "Forbidden: insufficient permissions", StatusCode::FORBIDDEN));
return Ok(self.error_response(
403,
"Forbidden: insufficient permissions",
StatusCode::FORBIDDEN,
));
}
}
}
@@ -220,11 +261,19 @@ impl RpcHandler {
// CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token.
// Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery.
// Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie).
let csrf_exempt = matches!(rpc_req.method.as_str(),
"node-messages-received" | "server.echo" | "server.get-state"
| "system.stats" | "tor.status"
| "tor.onion-addresses" | "federation.list-nodes" | "system.get-settings"
| "system.get-node-key" | "system.get-metrics" | "system.get-version"
let csrf_exempt = matches!(
rpc_req.method.as_str(),
"node-messages-received"
| "server.echo"
| "server.get-state"
| "system.stats"
| "tor.status"
| "tor.onion-addresses"
| "federation.list-nodes"
| "system.get-settings"
| "system.get-node-key"
| "system.get-metrics"
| "system.get-version"
);
if !is_unauthenticated && new_session_cookies.is_none() && !csrf_exempt {
let csrf_header = parts
@@ -241,7 +290,9 @@ impl RpcHandler {
let secret = SessionStore::load_or_create_remember_secret().await;
let mut mac = match HmacSha256::new_from_slice(&secret) {
Ok(m) => m,
Err(_) => { return Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, b"{}")); }
Err(_) => {
return Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, b"{}"));
}
};
mac.update(format!("csrf:{}", token).as_bytes());
match hex::decode(header) {
@@ -271,7 +322,11 @@ impl RpcHandler {
"403 CSRF validation failed — rejecting RPC call"
);
}
return Ok(self.error_response(403, "CSRF token missing or invalid", StatusCode::FORBIDDEN));
return Ok(self.error_response(
403,
"CSRF token missing or invalid",
StatusCode::FORBIDDEN,
));
}
}
@@ -286,10 +341,16 @@ impl RpcHandler {
// Rate limit sensitive endpoints
{
let client_ip = extract_client_ip(&parts.headers);
if !self.endpoint_rate_limiter.check(&rpc_req.method, client_ip).await {
if !self
.endpoint_rate_limiter
.check(&rpc_req.method, client_ip)
.await
{
return Ok(self.rate_limit_response());
}
self.endpoint_rate_limiter.record(&rpc_req.method, client_ip).await;
self.endpoint_rate_limiter
.record(&rpc_req.method, client_ip)
.await;
}
// Extract params; clone for post-routing use (login 2FA check needs password)
@@ -325,7 +386,9 @@ impl RpcHandler {
let mut rpc_resp = match result {
Ok(data) => {
if is_cacheable {
self.response_cache.set(rpc_req.method.clone(), data.clone()).await;
self.response_cache
.set(rpc_req.method.clone(), data.clone())
.await;
}
RpcResponse {
result: Some(data),
@@ -346,8 +409,7 @@ impl RpcHandler {
}
};
let resp_body = serde_json::to_vec(&rpc_resp)
.context("Failed to serialize response")?;
let resp_body = serde_json::to_vec(&rpc_resp).context("Failed to serialize response")?;
let mut response = json_response(StatusCode::OK, &resp_body);
@@ -362,13 +424,19 @@ impl RpcHandler {
&new_session_cookies,
client_ip,
secure_suffix,
).await;
)
.await;
Ok(response)
}
/// Build a JSON error response with the given RPC error code and HTTP status.
fn error_response(&self, code: i32, message: &str, status: StatusCode) -> Response<hyper::Body> {
fn error_response(
&self,
code: i32,
message: &str,
status: StatusCode,
) -> Response<hyper::Body> {
let rpc_resp = RpcResponse {
result: None,
error: Some(RpcError {
@@ -393,7 +461,8 @@ impl RpcHandler {
};
let resp_body = serde_json::to_vec(&rpc_resp).unwrap_or_default();
let mut resp = json_response(StatusCode::TOO_MANY_REQUESTS, &resp_body);
resp.headers_mut().insert("Retry-After", cookie_header("60"));
resp.headers_mut()
.insert("Retry-After", cookie_header("60"));
resp
}
@@ -433,9 +502,8 @@ impl RpcHandler {
"result": { "requires_totp": true },
"error": null
});
*response.body_mut() = hyper::Body::from(
serde_json::to_vec(&totp_body).unwrap_or_default(),
);
*response.body_mut() =
hyper::Body::from(serde_json::to_vec(&totp_body).unwrap_or_default());
}
}
} else {
@@ -493,11 +561,17 @@ impl RpcHandler {
}
response.headers_mut().append(
"Set-Cookie",
cookie_header(&format!("session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)),
cookie_header(&format!(
"session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}",
secure_suffix
)),
);
response.headers_mut().append(
"Set-Cookie",
cookie_header(&format!("csrf_token=; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)),
cookie_header(&format!(
"csrf_token=; SameSite=Lax; Path=/; Max-Age=0{}",
secure_suffix
)),
);
}
@@ -508,24 +582,48 @@ impl RpcHandler {
}
}
fn set_session_cookie(&self, response: &mut Response<hyper::Body>, token: &str, secure_suffix: &str) {
fn set_session_cookie(
&self,
response: &mut Response<hyper::Body>,
token: &str,
secure_suffix: &str,
) {
response.headers_mut().append(
"Set-Cookie",
cookie_header(&format!("session={}; HttpOnly; SameSite=Lax; Path=/{}", token, secure_suffix)),
cookie_header(&format!(
"session={}; HttpOnly; SameSite=Lax; Path=/{}",
token, secure_suffix
)),
);
}
fn set_csrf_cookie(&self, response: &mut Response<hyper::Body>, csrf_token: &str, secure_suffix: &str) {
fn set_csrf_cookie(
&self,
response: &mut Response<hyper::Body>,
csrf_token: &str,
secure_suffix: &str,
) {
response.headers_mut().append(
"Set-Cookie",
cookie_header(&format!("csrf_token={}; SameSite=Lax; Path=/{}", csrf_token, secure_suffix)),
cookie_header(&format!(
"csrf_token={}; SameSite=Lax; Path=/{}",
csrf_token, secure_suffix
)),
);
}
fn set_remember_cookie(&self, response: &mut Response<hyper::Body>, remember_token: &str, secure_suffix: &str) {
fn set_remember_cookie(
&self,
response: &mut Response<hyper::Body>,
remember_token: &str,
secure_suffix: &str,
) {
response.headers_mut().append(
"Set-Cookie",
cookie_header(&format!("remember={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", remember_token, REMEMBER_TTL, secure_suffix)),
cookie_header(&format!(
"remember={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}",
remember_token, REMEMBER_TTL, secure_suffix
)),
);
}
}
+9 -9
View File
@@ -10,7 +10,9 @@ impl RpcHandler {
match self.metrics_store.latest().await {
Some(snapshot) => Ok(serde_json::to_value(snapshot)?),
None => Ok(serde_json::json!({ "status": "collecting", "message": "No metrics collected yet" })),
None => Ok(
serde_json::json!({ "status": "collecting", "message": "No metrics collected yet" }),
),
}
}
@@ -149,14 +151,12 @@ impl RpcHandler {
};
match format {
"json" => {
Ok(serde_json::json!({
"format": "json",
"resolution": resolution,
"count": data.len(),
"data": data,
}))
}
"json" => Ok(serde_json::json!({
"format": "json",
"resolution": resolution,
"count": data.len(),
"data": data,
})),
_ => {
// CSV format
let mut csv = String::from(
+93 -16
View File
@@ -1,8 +1,8 @@
//! RPC handlers for node network visibility and overlay controls.
use super::RpcHandler;
use crate::{identity, peers};
use crate::container::docker_packages;
use crate::{identity, peers};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio::fs;
@@ -94,19 +94,29 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let to_did = params.get("did").and_then(|v| v.as_str())
let to_did = params
.get("did")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: did"))?;
let to_onion = params.get("onion").and_then(|v| v.as_str())
let to_onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: onion"))?;
let to_pubkey = params.get("pubkey").and_then(|v| v.as_str())
let to_pubkey = params
.get("pubkey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
let message = params.get("message").and_then(|v| v.as_str()).map(String::from);
let message = params
.get("message")
.and_then(|v| v.as_str())
.map(String::from);
// Send a message to the peer over Tor with connection request
let (data, _) = self.state_manager.get_snapshot().await;
let my_pubkey = &data.server_info.pubkey;
let my_did = identity::did_key_from_pubkey_hex(my_pubkey)?;
let my_onion = docker_packages::read_tor_address("archipelago").await
let my_onion = docker_packages::read_tor_address("archipelago")
.await
.unwrap_or_default();
let req_msg = serde_json::json!({
@@ -117,13 +127,18 @@ impl RpcHandler {
"message": message,
});
let to_fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, to_onion).await;
crate::node_message::send_to_peer(
to_onion,
to_fips_npub.as_deref(),
my_pubkey,
&req_msg.to_string(),
None,
None,
).await?;
None,
)
.await?;
// Also add them as a pending peer locally
let req = ConnectionRequest {
@@ -145,18 +160,25 @@ impl RpcHandler {
Ok(serde_json::json!({ "requests": requests }))
}
/// Accept a connection request — add peer to trusted list.
/// Accept a connection request — add peer to trusted list AND send
/// a `connection_accepted` notification back to the requester so
/// their side auto-adds us without a second manual round-trip.
pub(super) async fn handle_network_accept_request(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let request_id = params.get("id").and_then(|v| v.as_str())
let request_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
let requests = self.load_requests().await?;
let req = requests.iter().find(|r| r.id == request_id)
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?;
let req = requests
.iter()
.find(|r| r.id == request_id)
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?
.clone();
// Add to known peers
let peer = peers::KnownPeer {
@@ -170,6 +192,47 @@ impl RpcHandler {
// Remove the request
self.delete_request(request_id).await?;
// Notify the requester we've accepted so their UI auto-adds us and
// clears its outbound pending row. Best-effort — if the peer is
// offline we don't fail the accept; the next connection_request
// retry on their side will resolve eventually.
let (data, _) = self.state_manager.get_snapshot().await;
let my_pubkey = data.server_info.pubkey.clone();
let my_did = crate::identity::did_key_from_pubkey_hex(&my_pubkey).ok();
let my_onion = crate::container::docker_packages::read_tor_address("archipelago")
.await
.unwrap_or_default();
let accept_msg = serde_json::json!({
"type": "connection_accepted",
"request_id": request_id,
"from_did": my_did,
"from_onion": my_onion,
"from_pubkey": my_pubkey,
});
let to_fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, &req.from_onion).await;
let identity_dir = self.config.data_dir.join("identity");
let signing_key = crate::identity::NodeIdentity::load_or_create(&identity_dir)
.await
.ok();
if let Err(e) = crate::node_message::send_to_peer(
&req.from_onion,
to_fips_npub.as_deref(),
&my_pubkey,
&accept_msg.to_string(),
signing_key.as_ref().map(|i| i.signing_key()),
Some(&req.from_pubkey),
data.server_info.name.as_deref(),
)
.await
{
tracing::warn!(
to = %req.from_did,
error = %e,
"connection_accepted notify failed (requester will still be able to see us on their next retry)"
);
}
tracing::info!("Accepted connection from {}", req.from_did);
Ok(serde_json::json!({ "ok": true }))
}
@@ -180,7 +243,9 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let request_id = params.get("id").and_then(|v| v.as_str())
let request_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
self.delete_request(request_id).await?;
@@ -200,7 +265,9 @@ impl RpcHandler {
async fn requests_dir(&self) -> Result<std::path::PathBuf> {
let dir = self.config.data_dir.join(REQUESTS_DIR);
fs::create_dir_all(&dir).await.context("Failed to create requests dir")?;
fs::create_dir_all(&dir)
.await
.context("Failed to create requests dir")?;
Ok(dir)
}
@@ -208,7 +275,9 @@ impl RpcHandler {
let dir = self.requests_dir().await?;
let path = dir.join(format!("{}.json", req.id));
let json = serde_json::to_string_pretty(req).context("Failed to serialize request")?;
fs::write(&path, json).await.context("Failed to write request")?;
fs::write(&path, json)
.await
.context("Failed to write request")?;
Ok(())
}
@@ -233,13 +302,21 @@ impl RpcHandler {
async fn delete_request(&self, id: &str) -> Result<()> {
// Validate ID to prevent path traversal
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
if id.is_empty()
|| id.len() > 128
|| id.contains('/')
|| id.contains('\\')
|| id.contains("..")
|| id.contains('\0')
{
anyhow::bail!("Invalid request ID");
}
let dir = self.requests_dir().await?;
let path = dir.join(format!("{}.json", id));
if path.exists() {
fs::remove_file(&path).await.context("Failed to delete request")?;
fs::remove_file(&path)
.await
.context("Failed to delete request")?;
}
Ok(())
}
+17 -7
View File
@@ -1,6 +1,6 @@
use super::RpcHandler;
use crate::{backup, identity, nostr_discovery};
use crate::container::docker_packages;
use crate::{backup, identity, nostr_discovery};
use anyhow::{Context, Result};
use ed25519_dalek::SigningKey;
use nostr_sdk::ToBech32;
@@ -103,21 +103,31 @@ impl RpcHandler {
let identity_dir = self.config.data_dir.join("identity");
let pubkey_hex = nostr_discovery::get_nostr_pubkey(&identity_dir).await?;
let event = params.get("event")
let event = params
.get("event")
.ok_or_else(|| anyhow::anyhow!("Missing 'event' parameter"))?;
let kind = event.get("kind").and_then(|v| v.as_u64()).unwrap_or(1);
let content = event.get("content").and_then(|v| v.as_str()).unwrap_or("");
let created_at = event.get("created_at").and_then(|v| v.as_u64())
.unwrap_or_else(|| std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs());
let tags = event.get("tags").cloned().unwrap_or_else(|| serde_json::json!([]));
let created_at = event
.get("created_at")
.and_then(|v| v.as_u64())
.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
});
let tags = event
.get("tags")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
// NIP-01 serialization: [0, pubkey, created_at, kind, tags, content]
let serialized = serde_json::json!([0, pubkey_hex, created_at, kind, tags, content]);
let serialized_str = serde_json::to_string(&serialized)?;
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
let hash = Sha256::digest(serialized_str.as_bytes());
let event_hash_hex = hex::encode(hash);
+307 -111
View File
@@ -4,26 +4,13 @@ use anyhow::{Context, Result};
/// Trusted Docker registries. Only images from these sources are allowed.
#[allow(dead_code)]
pub(super) const TRUSTED_REGISTRIES: &[&str] = &["docker.io/", "ghcr.io/", "localhost/", "80.71.235.15:3000/"];
/// Detect which Bitcoin container is running on archy-net for DNS resolution.
/// Returns the container name to use as the RPC host (e.g., "bitcoin-knots").
pub(super) fn detect_bitcoin_container_name() -> String {
// Synchronous check — called from get_app_config which is sync
let output = std::process::Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
.output();
if let Ok(out) = output {
let names = String::from_utf8_lossy(&out.stdout);
for candidate in &["bitcoin-knots", "bitcoin-core", "bitcoin"] {
if names.lines().any(|l| l.trim() == *candidate) {
return candidate.to_string();
}
}
}
// Default to bitcoin-knots (most common)
"bitcoin-knots".to_string()
}
pub(super) const TRUSTED_REGISTRIES: &[&str] = &[
"docker.io/",
"ghcr.io/",
"localhost/",
"git.tx1138.com/",
"23.182.128.160:3000/",
];
/// Validate Docker image against trusted registry allowlist.
pub(super) fn is_valid_docker_image(image: &str) -> bool {
@@ -40,7 +27,10 @@ pub(super) fn is_valid_docker_image(image: &str) -> bool {
Some(r) => r,
None => return false,
};
matches!(registry, "docker.io" | "ghcr.io" | "localhost" | "80.71.235.15:3000")
matches!(
registry,
"docker.io" | "ghcr.io" | "localhost" | "git.tx1138.com" | "23.182.128.160:3000"
)
}
/// Per-app Linux capabilities needed beyond the default cap-drop=ALL.
@@ -58,8 +48,7 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
"--cap-add=NET_BIND_SERVICE".to_string(),
"--cap-add=NET_RAW".to_string(),
],
"nextcloud" | "btcpay-server" | "btcpayserver"
| "portainer" => vec![
"nextcloud" | "btcpay-server" | "btcpayserver" | "portainer" => vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
@@ -83,16 +72,17 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
],
// Bitcoin and Lightning need file ownership ops + NET_BIND_SERVICE for port binding
// LND additionally needs NET_RAW for TLS certificate generation (netlinkrib interface enumeration)
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint"
| "fedimint-gateway" => vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=FOWNER".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
"--cap-add=NET_RAW".to_string(),
],
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint" | "fedimint-gateway" => {
vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=FOWNER".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
"--cap-add=NET_RAW".to_string(),
]
}
// Vaultwarden needs file ownership + NET_BIND_SERVICE (binds port 80 internally)
"vaultwarden" => vec![
"--cap-add=CHOWN".to_string(),
@@ -124,6 +114,12 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
// Nostr VPN and FIPS: mesh networking daemons need TUN + NET_ADMIN
// Note: --device=/dev/net/tun is added separately in install.rs
"nostr-vpn" | "fips" => vec![
"--cap-add=NET_ADMIN".to_string(),
"--cap-add=NET_RAW".to_string(),
],
// Default: standard capabilities for rootless podman containers
// Most apps need file ownership + port binding to function correctly
_ => vec![
@@ -158,8 +154,8 @@ pub(super) fn is_readonly_compatible(app_id: &str) -> bool {
/// Returns (health-cmd, interval, retries) args to append to run_args.
pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String> {
// bitcoin-cli reads the .cookie file from -datadir automatically (no plaintext creds needed)
let btc_health = "bitcoin-cli -datadir=/home/bitcoin/.bitcoin getblockchaininfo || exit 1"
.to_string();
let btc_health =
"bitcoin-cli -datadir=/home/bitcoin/.bitcoin getblockchaininfo || exit 1".to_string();
let (cmd, interval, retries) = match app_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (btc_health.as_str(), "30s", "3"),
"lnd" => ("lncli getinfo || exit 1", "30s", "3"),
@@ -182,11 +178,9 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"30s",
"3",
),
"homeassistant" | "home-assistant" => (
"curl -sf http://localhost:8123/api/ || exit 1",
"30s",
"3",
),
"homeassistant" | "home-assistant" => {
("curl -sf http://localhost:8123/api/ || exit 1", "30s", "3")
}
"grafana" => (
"curl -sf http://localhost:3000/api/health || exit 1",
"30s",
@@ -199,11 +193,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
),
"vaultwarden" => ("curl -sf http://localhost:80/alive || exit 1", "30s", "3"),
"uptime-kuma" => ("curl -sf http://localhost:3001/ || exit 1", "30s", "3"),
"filebrowser" => (
"curl -sf http://localhost:80/health || exit 1",
"30s",
"3",
),
"filebrowser" => ("curl -sf http://localhost:80/health || exit 1", "30s", "3"),
"searxng" => ("curl -sf http://localhost:8080/ || exit 1", "30s", "3"),
"photoprism" => (
"curl -sf http://localhost:2342/api/v1/status || exit 1",
@@ -226,19 +216,19 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"3",
),
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
"fedimint" => (
"curl -sf http://localhost:8174/health || exit 1",
"60s",
"3",
),
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
"fedimint-gateway" => ("curl -sf http://localhost:8176/ || exit 1", "60s", "3"),
"nostr-rs-relay" | "nostr-relay" => {
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
}
"nginx-proxy-manager" => (
"curl -sf http://localhost:81/api/ || exit 1",
"nginx-proxy-manager" => ("curl -sf http://localhost:81/api/ || exit 1", "30s", "3"),
"routstr" => (
"curl -sf http://localhost:8000/v1/models || exit 1",
"30s",
"3",
),
"nostr-vpn" => ("nvpn status || exit 1", "30s", "3"),
"fips" => ("fipsctl status || exit 1", "30s", "3"),
_ => return vec![],
};
@@ -279,6 +269,9 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
"dwn" => "256m",
"portainer" => "256m",
"nostr-rs-relay" | "nostr-relay" => "256m",
"routstr" => "512m",
"nostr-vpn" => "256m",
"fips" => "256m",
"nginx-proxy-manager" => "256m",
// Databases
"archy-btcpay-db" | "archy-mempool-db" | "mysql-mempool" => "512m",
@@ -300,49 +293,79 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
match package_id {
// Bitcoin: multiple historical names
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
"bitcoin-knots".into(), "bitcoin".into(), "bitcoin-core".into(),
"archy-bitcoin-knots".into(), "archy-bitcoin".into(),
"bitcoin-knots".into(),
"bitcoin".into(),
"bitcoin-core".into(),
"archy-bitcoin-knots".into(),
"archy-bitcoin".into(),
"bitcoin-ui".into(),
"archy-bitcoin-ui".into(),
],
// LND + UI
"lnd" => vec!["lnd".into(), "archy-lnd".into(), "archy-lnd-ui".into()],
// Electrumx: multiple aliases
"electrumx" | "electrs" | "mempool-electrs" => vec![
"electrumx".into(), "electrs".into(), "mempool-electrs".into(),
"archy-electrumx".into(), "archy-electrs-ui".into(),
"electrumx".into(),
"electrs".into(),
"mempool-electrs".into(),
"archy-electrumx".into(),
"archy-electrs-ui".into(),
],
// Mempool: multi-container stack
"mempool" | "mempool-web" => vec![
"mempool".into(), "mempool-web".into(), "mempool-api".into(),
"archy-mempool-web".into(), "archy-mempool-api".into(),
"archy-mempool-db".into(), "mysql-mempool".into(),
"mempool".into(),
"mempool-web".into(),
"mempool-api".into(),
"archy-mempool-web".into(),
"archy-mempool-api".into(),
"archy-mempool-db".into(),
"mysql-mempool".into(),
],
// BTCPay: multi-container + multiple aliases
"btcpay-server" | "btcpayserver" | "btcpay" => vec![
"btcpay-server".into(), "btcpay".into(), "btcpayserver".into(),
"archy-btcpay".into(), "archy-btcpay-db".into(), "archy-nbxplorer".into(),
"btcpay-server".into(),
"btcpay".into(),
"btcpayserver".into(),
"archy-btcpay".into(),
"archy-btcpay-db".into(),
"archy-nbxplorer".into(),
],
// Home Assistant: two naming conventions
"homeassistant" | "home-assistant" => vec![
"homeassistant".into(), "home-assistant".into(),
"homeassistant".into(),
"home-assistant".into(),
"archy-homeassistant".into(),
],
// Fedimint: multiple related containers
"fedimint" => vec![
"fedimint".into(), "fedimintd".into(),
"fedimint-ui".into(), "archy-fedimint".into(),
"fedimint".into(),
"fedimintd".into(),
"fedimint-ui".into(),
"archy-fedimint".into(),
"fedimint-gateway".into(),
],
"fedimint-gateway" => vec!["fedimint-gateway".into()],
// Immich: multi-container
"immich" => vec![
"immich_postgres".into(), "immich_redis".into(), "immich_server".into(),
"immich_postgres".into(),
"immich_redis".into(),
"immich_server".into(),
],
// Penpot: multi-container
"penpot" | "penpot-frontend" => vec![
"penpot-postgres".into(), "penpot-valkey".into(),
"penpot-backend".into(), "penpot-exporter".into(), "penpot-frontend".into(),
"penpot-postgres".into(),
"penpot-valkey".into(),
"penpot-backend".into(),
"penpot-exporter".into(),
"penpot-frontend".into(),
],
"nostr-vpn" => vec![
"nostr-vpn".into(),
"archy-nostr-vpn".into(),
"archy-nostr-vpn-ui".into(),
],
"fips" => vec!["fips".into(), "archy-fips".into(), "archy-fips-ui".into()],
"routstr" => vec!["routstr".into(), "archy-routstr".into()],
// Default: exact name + archy- prefix
_ => vec![base, archy],
}
@@ -386,10 +409,7 @@ pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
format!("{}/fedimint-gateway", base),
],
"fedimint-gateway" => vec![format!("{}/fedimint-gateway", base)],
"immich" => vec![
format!("{}/immich", base),
format!("{}/immich-db", base),
],
"immich" => vec![format!("{}/immich", base), format!("{}/immich-db", base)],
"penpot" | "penpot-frontend" => vec![
format!("{}/penpot-assets", base),
format!("{}/penpot-postgres", base),
@@ -407,6 +427,39 @@ fn read_secret(name: &str, default: &str) -> String {
.unwrap_or_else(|_| default.to_string())
}
/// Read a secret or generate and persist a random one if it doesn't exist.
pub(super) async fn read_or_generate_secret(name: &str) -> String {
let path = format!("/var/lib/archipelago/secrets/{}", name);
if let Ok(val) = tokio::fs::read_to_string(&path).await {
let trimmed = val.trim().to_string();
if !trimmed.is_empty() {
return trimmed;
}
}
// Generate a 24-byte random password (hex-encoded = 48 chars)
let mut buf = [0u8; 24];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf);
let secret = hex::encode(buf);
let _ = tokio::fs::create_dir_all("/var/lib/archipelago/secrets").await;
let _ = tokio::fs::write(&path, &secret).await;
secret
}
/// Read the node-level Nostr secret key (hex) for identity-aware apps.
/// Returns empty string if not yet generated.
fn read_nostr_secret_hex() -> String {
std::fs::read_to_string("/var/lib/archipelago/identity/nostr_secret")
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Read the node-level Nostr public key (hex).
fn read_nostr_pubkey_hex() -> String {
std::fs::read_to_string("/var/lib/archipelago/identity/nostr_pub")
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Get app-specific configuration
/// Returns: (ports, volumes, env_vars, custom_command, custom_args)
pub(super) async fn get_app_config(
@@ -430,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(),
@@ -457,9 +533,9 @@ pub(super) async fn get_app_config(
"--bitcoin.node=bitcoind".to_string(),
format!("--bitcoind.rpcuser={}", rpc_user),
format!("--bitcoind.rpcpass={}", rpc_pass),
"--bitcoind.rpchost=bitcoin-knots:8332".to_string(),
"--bitcoind.zmqpubrawblock=tcp://bitcoin-knots:28332".to_string(),
"--bitcoind.zmqpubrawtx=tcp://bitcoin-knots:28333".to_string(),
"--bitcoind.rpchost=host.containers.internal:8332".to_string(),
"--bitcoind.zmqpubrawblock=tcp://host.containers.internal:28332".to_string(),
"--bitcoind.zmqpubrawtx=tcp://host.containers.internal:28333".to_string(),
"--rpclisten=0.0.0.0:10009".to_string(),
"--restlisten=0.0.0.0:8080".to_string(),
"--listen=0.0.0.0:9735".to_string(),
@@ -494,7 +570,7 @@ pub(super) async fn get_app_config(
vec!["/var/lib/archipelago/mempool:/data".to_string()],
vec![
"MEMPOOL_BACKEND=electrum".to_string(),
"ELECTRUM_HOST=electrumx".to_string(),
"ELECTRUM_HOST=host.containers.internal".to_string(),
"ELECTRUM_PORT=50001".to_string(),
"ELECTRUM_TLS_ENABLED=false".to_string(),
format!("CORE_RPC_HOST={}", host_ip),
@@ -511,15 +587,13 @@ pub(super) async fn get_app_config(
None,
),
"electrumx" | "mempool-electrs" | "electrs" => {
// Detect which bitcoin container is running for archy-net DNS resolution
let bitcoin_host = detect_bitcoin_container_name();
(
vec!["50001:50001".to_string()],
vec!["/var/lib/archipelago/electrumx:/data".to_string()],
vec![
format!(
"DAEMON_URL=http://{}:{}@{}:8332/",
rpc_user, rpc_pass, bitcoin_host
"DAEMON_URL=http://{}:{}@host.containers.internal:8332/",
rpc_user, rpc_pass
),
"COIN=Bitcoin".to_string(),
"DB_DIRECTORY=/data".to_string(),
@@ -723,39 +797,45 @@ pub(super) async fn get_app_config(
Some(vec![
"--data-dir".to_string(),
"/data".to_string(),
format!("--bitcoind-url=http://{}:{}@bitcoin-knots:8332", rpc_user, rpc_pass),
]),
),
"fedimint-gateway" => (
vec!["8176:8176".to_string(), "9737:9737".to_string()],
vec!["/var/lib/archipelago/fedimint-gateway:/data".to_string()],
vec![],
None,
Some(vec![
"gatewayd".to_string(),
"--data-dir".to_string(),
"/data".to_string(),
"--listen".to_string(),
"0.0.0.0:8176".to_string(),
"--bcrypt-password-hash".to_string(),
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(),
"--network".to_string(),
"bitcoin".to_string(),
"--bitcoind-url".to_string(),
format!("http://{}:8332", host_ip),
"--bitcoind-username".to_string(),
rpc_user.to_string(),
"--bitcoind-password".to_string(),
rpc_pass.to_string(),
"ldk".to_string(),
"--ldk-lightning-port".to_string(),
"9737".to_string(),
"--ldk-alias".to_string(),
"archipelago-gateway".to_string(),
format!("--bitcoind-url=http://{}:{}@{}:8332", rpc_user, rpc_pass, host_ip),
]),
),
"fedimint-gateway" => {
let fedi_hash = read_secret(
"fedimint-gateway-hash",
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC",
);
(
vec!["8176:8176".to_string(), "9737:9737".to_string()],
vec!["/var/lib/archipelago/fedimint-gateway:/data".to_string()],
vec![],
None,
Some(vec![
"gatewayd".to_string(),
"--data-dir".to_string(),
"/data".to_string(),
"--listen".to_string(),
"0.0.0.0:8176".to_string(),
"--bcrypt-password-hash".to_string(),
fedi_hash,
"--network".to_string(),
"bitcoin".to_string(),
"--bitcoind-url".to_string(),
format!("http://{}:8332", host_ip),
"--bitcoind-username".to_string(),
rpc_user.to_string(),
"--bitcoind-password".to_string(),
rpc_pass.to_string(),
"ldk".to_string(),
"--ldk-lightning-port".to_string(),
"9737".to_string(),
"--ldk-alias".to_string(),
"archipelago-gateway".to_string(),
]),
)
}
"indeedhub" => (
vec!["8190:3000".to_string()],
vec!["7778:7777".to_string()],
vec![],
vec![
"NODE_ENV=production".to_string(),
@@ -771,6 +851,59 @@ pub(super) async fn get_app_config(
None,
None,
),
"routstr" => {
let nsec = read_nostr_secret_hex();
let mut env = vec![
"DATABASE_URL=sqlite:///app/data/keys.db".to_string(),
];
if !nsec.is_empty() {
env.push(format!("NSEC={}", nsec));
env.push(format!("NOSTR_PUBKEY={}", read_nostr_pubkey_hex()));
}
(
vec!["8200:8000".to_string()],
vec!["/var/lib/archipelago/routstr:/app/data".to_string()],
env,
None,
None,
)
}
"nostr-vpn" => {
let nsec = read_nostr_secret_hex();
let mut env = vec![];
if !nsec.is_empty() {
env.push(format!("NOSTR_SECRET={}", nsec));
env.push(format!("NOSTR_PUBKEY={}", read_nostr_pubkey_hex()));
}
(
vec!["51820:51820/udp".to_string()],
vec!["/var/lib/archipelago/nostr-vpn:/root/.config/nvpn".to_string()],
env,
None,
None,
)
}
"fips" => {
let nsec = read_nostr_secret_hex();
let mut env = vec![];
if !nsec.is_empty() {
env.push(format!("FIPS_NSEC={}", nsec));
env.push(format!("FIPS_NPUB={}", read_nostr_pubkey_hex()));
}
(
vec![
"2121:2121/udp".to_string(),
"8443:8443".to_string(),
],
vec![
"/var/lib/archipelago/fips/config:/etc/fips".to_string(),
"/var/lib/archipelago/fips/run:/run/fips".to_string(),
],
env,
None,
None,
)
}
"dwn" => (
vec!["3100:3000".to_string()],
vec!["/var/lib/archipelago/dwn:/dwn/data".to_string()],
@@ -783,6 +916,69 @@ pub(super) async fn get_app_config(
None,
None,
),
_ => (vec![], vec![], vec![], None, None),
"botfights" => {
let jwt_secret = read_or_generate_secret("botfights-jwt").await;
(
vec!["9100:9100".to_string()],
vec!["/var/lib/archipelago/botfights:/app/server/data".to_string()],
vec![
"NODE_ENV=production".to_string(),
"PORT=9100".to_string(),
format!("JWT_SECRET={}", jwt_secret),
"FIGHT_LOOP_ENABLED=true".to_string(),
"ARCHY_EMBEDDED=1".to_string(),
],
None,
None,
)
}
// Gitea binds to 3001 internally. Nginx on port 3000 strips X-Frame-Options
// so Gitea works in Archipelago's iframe. See nginx-gitea-iframe.conf.
"gitea" => (
vec!["3001:3000".to_string(), "2222:22".to_string()],
vec![
"/var/lib/archipelago/gitea/data:/data".to_string(),
"/var/lib/archipelago/gitea/config:/etc/gitea".to_string(),
],
vec![
"GITEA__database__DB_TYPE=sqlite3".to_string(),
"GITEA__server__SSH_PORT=2222".to_string(),
"GITEA__server__SSH_LISTEN_PORT=22".to_string(),
"GITEA__server__LFS_START_SERVER=true".to_string(),
"GITEA__packages__ENABLED=true".to_string(),
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true".to_string(),
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true".to_string(),
"GITEA__security__X_FRAME_OPTIONS=".to_string(),
],
None,
None,
),
_ => {
// Unknown app: try to load config from /var/lib/archipelago/app-configs/{id}.json
// This allows dynamic apps from the remote catalog to be installed
// without hardcoding their config here.
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
if let Ok(data) = tokio::fs::read_to_string(&config_path).await {
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&data) {
let ports = cfg.get("ports")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let volumes = cfg.get("volumes")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let env_vars = cfg.get("env")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
tracing::info!("Loaded dynamic config for app: {}", app_id);
return (ports, volumes, env_vars, None, None);
}
}
// No config found — use minimal defaults (container's own EXPOSE/VOLUME)
tracing::warn!("No config found for app: {} — using minimal defaults", app_id);
(vec![], vec![], vec![], None, None)
},
}
}
@@ -27,7 +27,7 @@ pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
let is_running = |names: &[&str]| {
running.lines().any(|l| {
let name = l.trim();
names.iter().any(|n| name == *n)
names.contains(&name)
})
};
@@ -42,12 +42,10 @@ pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
/// Returns an error with a user-friendly message if dependencies are missing.
pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result<()> {
match package_id {
"electrumx" | "mempool-electrs" | "electrs" if !deps.has_bitcoin => {
Err(anyhow::anyhow!(
"ElectrumX requires a running Bitcoin node (Bitcoin Knots). \
"electrumx" | "mempool-electrs" | "electrs" if !deps.has_bitcoin => Err(anyhow::anyhow!(
"ElectrumX requires a running Bitcoin node (Bitcoin Knots). \
Please install and start Bitcoin Knots first."
))
}
)),
"lnd" if !deps.has_bitcoin => Err(anyhow::anyhow!(
"LND requires a running Bitcoin node (Bitcoin Knots). \
Please install and start Bitcoin Knots first."
@@ -70,10 +68,10 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
missing.join(" and ")
))
}
"fedimint" if !deps.has_bitcoin => Err(anyhow::anyhow!(
"Fedimint requires a running Bitcoin node (Bitcoin Knots). \
Please install and start Bitcoin Knots first."
)),
"fedimint" if !deps.has_bitcoin => {
info!("Fedimint installing without local Bitcoin node — configure remote Bitcoin RPC in Fedimint guardian setup");
Ok(())
}
_ => Ok(()),
}
}
@@ -144,9 +142,7 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
/// Sort a list of container names according to the dependency-aware startup
/// order for the given app. Unknown containers sort to the end.
pub(super) async fn ordered_containers_for_start(
package_id: &str,
) -> Result<Vec<String>> {
pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result<Vec<String>> {
let containers = get_containers_for_app(package_id).await?;
if containers.is_empty() {
return Ok(vec![format!("archy-{}", package_id)]);
@@ -159,12 +155,7 @@ pub(super) async fn ordered_containers_for_start(
order
};
let mut sorted = containers;
sorted.sort_by_key(|c| {
effective_order
.iter()
.position(|o| *o == c)
.unwrap_or(99)
});
sorted.sort_by_key(|c| effective_order.iter().position(|o| *o == c).unwrap_or(99));
Ok(sorted)
}
@@ -179,12 +170,18 @@ pub(super) fn configure_fedimint_lnd(
rpc_pass: &str,
) {
let lnd_cert = "/var/lib/archipelago/lnd/tls.cert";
let lnd_macaroon =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
if std::path::Path::new(lnd_cert).exists()
&& std::path::Path::new(lnd_macaroon).exists()
{
let lnd_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
if std::path::Path::new(lnd_cert).exists() && std::path::Path::new(lnd_macaroon).exists() {
info!("LND detected with credentials — configuring gateway in lnd mode");
// Read bcrypt hash from secrets file, fall back to default
let fedi_hash =
std::fs::read_to_string("/var/lib/archipelago/secrets/fedimint-gateway-hash")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| {
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string()
});
ports.retain(|p| p != "9737:9737");
volumes.push(format!("{}:/lnd/tls.cert:ro", lnd_cert));
volumes.push(format!("{}:/lnd/admin.macaroon:ro", lnd_macaroon));
@@ -195,7 +192,7 @@ pub(super) fn configure_fedimint_lnd(
"--listen".to_string(),
"0.0.0.0:8176".to_string(),
"--bcrypt-password-hash".to_string(),
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(),
fedi_hash,
"--network".to_string(),
"bitcoin".to_string(),
"--bitcoind-url".to_string(),
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@ mod lifecycle;
mod progress;
mod runtime;
mod stacks;
mod update;
mod validation;
// Re-export items needed by sibling modules (container.rs, security.rs)
@@ -8,12 +8,7 @@ use crate::data_model::{
impl RpcHandler {
/// Set install progress for a package and broadcast the update.
/// Creates a minimal package entry if one doesn't exist yet.
pub(super) async fn set_install_progress(
&self,
package_id: &str,
downloaded: u64,
size: u64,
) {
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let entry = data
.package_data
@@ -33,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,
@@ -86,6 +93,8 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
},
installed: None,
install_progress: None,
uninstall_stage: None,
available_update: None,
}
}
@@ -114,25 +123,19 @@ fn parse_size_value(s: &str) -> Option<u64> {
let s = s.trim();
let (num_str, multiplier) = if let Some(pos) = s.rfind("GiB") {
(
s[..pos].trim().split_whitespace().last()?,
1024 * 1024 * 1024,
)
(s[..pos].split_whitespace().last()?, 1024 * 1024 * 1024)
} else if let Some(pos) = s.rfind("MiB") {
(s[..pos].trim().split_whitespace().last()?, 1024 * 1024)
(s[..pos].split_whitespace().last()?, 1024 * 1024)
} else if let Some(pos) = s.rfind("KiB") {
(s[..pos].trim().split_whitespace().last()?, 1024)
(s[..pos].split_whitespace().last()?, 1024)
} else if let Some(pos) = s.rfind("GB") {
(
s[..pos].trim().split_whitespace().last()?,
1_000_000_000,
)
(s[..pos].split_whitespace().last()?, 1_000_000_000)
} else if let Some(pos) = s.rfind("MB") {
(s[..pos].trim().split_whitespace().last()?, 1_000_000)
(s[..pos].split_whitespace().last()?, 1_000_000)
} else if let Some(pos) = s.rfind("KB") {
(s[..pos].trim().split_whitespace().last()?, 1_000)
(s[..pos].split_whitespace().last()?, 1_000)
} else if let Some(pos) = s.rfind('B') {
(s[..pos].trim().split_whitespace().last()?, 1)
(s[..pos].split_whitespace().last()?, 1)
} else {
return None;
};
+84 -17
View File
@@ -9,13 +9,15 @@ use anyhow::{Context, Result};
/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state,
/// indexers 300s for index flush, databases 120s for WAL/transaction commit.
pub fn stop_timeout_secs(container_name: &str) -> &'static str {
let id = container_name.strip_prefix("archy-").unwrap_or(container_name);
let id = container_name
.strip_prefix("archy-")
.unwrap_or(container_name);
match id {
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => "600",
"lnd" => "330",
"electrumx" | "electrs" | "mempool-electrs" => "300",
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres"
| "nextcloud-db" | "endurain-db" => "120",
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres" | "nextcloud-db"
| "endurain-db" => "120",
"btcpay-server" | "nbxplorer" | "fedimint" | "fedimint-gateway" => "60",
_ => "30",
}
@@ -46,7 +48,11 @@ impl RpcHandler {
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
}
install_log(&format!("START: {} (containers: {:?})", package_id, to_start)).await;
install_log(&format!(
"START: {} (containers: {:?})",
package_id, to_start
))
.await;
let mut errors = Vec::new();
for (i, name) in to_start.iter().enumerate() {
// Brief delay between dependent containers to allow initialization
@@ -130,7 +136,11 @@ impl RpcHandler {
return Err(anyhow::anyhow!("No containers found for {}", package_id));
}
install_log(&format!("STOP: {} (containers: {:?})", package_id, containers)).await;
install_log(&format!(
"STOP: {} (containers: {:?})",
package_id, containers
))
.await;
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
for name in &containers {
@@ -139,7 +149,11 @@ impl RpcHandler {
let mut errors = Vec::new();
for name in &containers {
tracing::info!("Stopping container: {} (timeout: {}s)", name, stop_timeout_secs(name));
tracing::info!(
"Stopping container: {} (timeout: {}s)",
name,
stop_timeout_secs(name)
);
let out = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
@@ -176,7 +190,11 @@ impl RpcHandler {
return Err(anyhow::anyhow!("No containers found for {}", package_id));
}
install_log(&format!("RESTART: {} (containers: {:?})", package_id, containers)).await;
install_log(&format!(
"RESTART: {} (containers: {:?})",
package_id, containers
))
.await;
let mut errors = Vec::new();
for name in &containers {
tracing::info!("Restarting container: {}", name);
@@ -188,7 +206,11 @@ impl RpcHandler {
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
tracing::warn!("podman restart {} failed: {}, trying stop+start", name, stderr);
tracing::warn!(
"podman restart {} failed: {}, trying stop+start",
name,
stderr
);
// Fallback: stop then start (handles rootless podman loopback issues)
let _ = tokio::process::Command::new("podman")
@@ -202,7 +224,9 @@ impl RpcHandler {
.context(format!("Failed to exec podman start {}", name))?;
if !start_out.status.success() {
let start_err = String::from_utf8_lossy(&start_out.stderr).trim().to_string();
let start_err = String::from_utf8_lossy(&start_out.stderr)
.trim()
.to_string();
tracing::error!("stop+start {} also failed: {}", name, start_err);
errors.push(format!("{}: {}", name, start_err));
} else {
@@ -237,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])
@@ -260,12 +300,7 @@ impl RpcHandler {
);
}
Err(e) => {
tracing::warn!(
"Uninstall {}: stop {} error: {}",
package_id,
name,
e
);
tracing::warn!("Uninstall {}: stop {} error: {}", package_id, name, e);
}
}
@@ -280,7 +315,12 @@ impl RpcHandler {
Ok(o) => {
// If normal rm fails (e.g., still running), force as fallback
let stderr = String::from_utf8_lossy(&o.stderr);
tracing::warn!("Uninstall {}: rm {} failed ({}), trying force", package_id, name, stderr.trim());
tracing::warn!(
"Uninstall {}: rm {} failed ({}), trying force",
package_id,
name,
stderr.trim()
);
let force_rm = tokio::process::Command::new("podman")
.args(["rm", "-f", name])
.output()
@@ -302,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"])
@@ -330,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);
@@ -374,6 +416,31 @@ impl RpcHandler {
removed
);
// Immediately remove from in-memory state so the UI updates without
// waiting for the scanner's absence threshold (3 scans × 60s each).
{
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let before = data.package_data.len();
data.package_data.remove(package_id);
// Also remove any alias keys (e.g. "bitcoin-knots" vs "bitcoin")
let aliases: Vec<String> = data
.package_data
.keys()
.filter(|k| {
super::config::all_container_names(package_id)
.iter()
.any(|c| c.strip_prefix("archy-").unwrap_or(c) == k.as_str())
})
.cloned()
.collect();
for alias in &aliases {
data.package_data.remove(alias);
}
if data.package_data.len() < before {
self.state_manager.update_data(data).await;
}
}
Ok(serde_json::json!({
"status": "uninstalled",
"stopped": stopped,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,327 @@
//! Per-app manual update handler.
//!
//! Flow: validate → set Updating state → graceful stop → pull new image(s) →
//! remove old container(s) → recreate via reconcile script → verify running.
//! Data volumes are preserved (bind mounts, not stored in container).
use super::config::get_containers_for_app;
use super::install::install_log;
use super::progress::parse_pull_progress;
use super::runtime::stop_timeout_secs;
use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler;
use crate::container::image_versions;
use crate::data_model::PackageState;
use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, BufReader};
use tracing::{error, info, warn};
impl RpcHandler {
/// Update a package to the version pinned in image-versions.sh.
/// This is a manual operation — the user clicks "Update" in the UI.
pub(in crate::api::rpc) async fn handle_package_update(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
// Verify an update is actually available
let pinned = image_versions::pinned_image_for_app(package_id)
.ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?;
// Reject if already updating
{
let (data, _) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get(package_id) {
if entry.state == PackageState::Updating {
return Err(anyhow::anyhow!("{} is already updating", package_id));
}
}
}
install_log(&format!("UPDATE: {}{}", package_id, pinned)).await;
// Set state to Updating
{
let (mut data, _) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = PackageState::Updating;
entry.available_update = None;
}
self.state_manager.update_data(data).await;
}
// Resolve images to pull — either a stack or single container
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
// Get all containers for this app
let containers = get_containers_for_app(package_id).await?;
if containers.is_empty() {
self.clear_update_state(package_id).await;
return Err(anyhow::anyhow!("No containers found for {}", package_id));
}
// Execute update — on failure, attempt rollback by restarting old containers
match self
.execute_update(package_id, &containers, &images_to_pull)
.await
{
Ok(()) => {
install_log(&format!("UPDATE OK: {}", package_id)).await;
self.clear_install_progress(package_id).await;
Ok(serde_json::json!({
"status": "updated",
"package_id": package_id,
}))
}
Err(e) => {
error!("Update {} failed: {}. Attempting rollback.", package_id, e);
install_log(&format!(
"UPDATE FAIL: {} — {}. Rolling back.",
package_id, e
))
.await;
self.rollback_update(package_id, &containers).await;
self.clear_install_progress(package_id).await;
self.clear_update_state(package_id).await;
Err(e.context(format!("Update {} failed, rolled back", package_id)))
}
}
}
/// Core update execution: stop → pull → remove → recreate → verify.
async fn execute_update(
&self,
package_id: &str,
containers: &[String],
images_to_pull: &[(String, String)],
) -> Result<()> {
// 1. Graceful stop all containers (reverse order for dependencies)
info!(
"Update {}: stopping {} containers",
package_id,
containers.len()
);
for name in containers.iter().rev() {
let timeout = stop_timeout_secs(name);
info!(
"Update {}: stopping {} (timeout: {}s)",
package_id, name, timeout
);
let out = tokio::process::Command::new("podman")
.args(["stop", "-t", timeout, name])
.output()
.await
.context(format!("Failed to stop {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
warn!(
"Update {}: stop {} failed: {}",
package_id,
name,
stderr.trim()
);
// Continue — container might already be stopped
}
}
// 2. Pull new images with progress
info!(
"Update {}: pulling {} images",
package_id,
images_to_pull.len()
);
for (i, (name, image)) in images_to_pull.iter().enumerate() {
info!(
"Update {}: pulling image {}/{} ({})",
package_id,
i + 1,
images_to_pull.len(),
image
);
self.pull_update_image(package_id, image)
.await
.context(format!("Failed to pull {} for {}", image, name))?;
}
// 3. Remove old containers
info!("Update {}: removing old containers", package_id);
for name in containers {
let out = tokio::process::Command::new("podman")
.args(["rm", name])
.output()
.await
.context(format!("Failed to remove {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
// Force remove as fallback
warn!(
"Update {}: rm {} failed ({}), forcing",
package_id,
name,
stderr.trim()
);
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", name])
.output()
.await;
}
}
// 4. Recreate via reconcile script (single source of truth for container specs)
info!("Update {}: recreating containers via reconcile", package_id);
for name in containers {
let out = tokio::process::Command::new("bash")
.args([
"/opt/archipelago/scripts/reconcile-containers.sh",
&format!("--container={}", name),
"--force",
])
.output()
.await
.context(format!("Failed to reconcile {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
error!(
"Update {}: reconcile {} failed:\nstdout: {}\nstderr: {}",
package_id,
name,
stdout.trim(),
stderr.trim()
);
return Err(anyhow::anyhow!(
"Reconcile failed for {}: {}",
name,
stderr.trim()
));
}
// Brief delay between containers for dependency initialization
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
// 5. Verify containers reached running state
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
for name in containers {
let status = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.State.Status}}"])
.output()
.await;
if let Ok(o) = status {
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
if state == "exited" {
warn!(
"Update {}: container {} exited after recreate",
package_id, name
);
}
}
}
Ok(())
}
/// Pull a single image with progress broadcasting (reuses install progress pattern).
async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> {
self.set_install_progress(package_id, 0, 0).await;
let mut child = tokio::process::Command::new("podman")
.args(["pull", image])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("Failed to start image pull")?;
if let Some(stderr) = child.stderr.take() {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
let pkg_id = package_id.to_string();
let state_mgr = self.state_manager.clone();
while let Ok(Some(line)) = lines.next_line().await {
if let Some((downloaded, total)) = parse_pull_progress(&line) {
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total).await;
}
}
}
let status = child
.wait()
.await
.context("Failed to wait for image pull")?;
if !status.success() {
return Err(anyhow::anyhow!("podman pull {} failed", image));
}
self.set_install_progress(package_id, 100, 100).await;
Ok(())
}
/// Determine which images need to be pulled for this update.
/// For multi-container stacks, pulls all component images.
/// For single-container apps, pulls just the pinned image.
fn resolve_images_to_pull(
&self,
package_id: &str,
pinned_primary: &str,
) -> Vec<(String, String)> {
let stack_images = image_versions::pinned_images_for_stack(package_id);
if stack_images.is_empty() {
// Single container app
vec![(package_id.to_string(), pinned_primary.to_string())]
} else {
stack_images
}
}
/// Rollback: restart old containers if they still exist.
/// Called when update fails partway through.
async fn rollback_update(&self, package_id: &str, containers: &[String]) {
warn!("Rolling back update for {}", package_id);
for name in containers {
// Try to start — works if container still exists (wasn't removed yet)
let out = tokio::process::Command::new("podman")
.args(["start", name])
.output()
.await;
match out {
Ok(o) if o.status.success() => {
info!("Rollback: restarted {}", name);
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
warn!("Rollback: could not restart {}: {}", name, stderr.trim());
// Container was already removed — try reconcile to recreate with old image
let _ = tokio::process::Command::new("bash")
.args([
"/opt/archipelago/scripts/reconcile-containers.sh",
&format!("--container={}", name),
"--force",
])
.output()
.await;
}
Err(e) => {
error!("Rollback: failed to restart {}: {}", name, e);
}
}
}
}
/// Clear the Updating state (used on failure/rollback).
async fn clear_update_state(&self, package_id: &str) {
let (mut data, _) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
// Don't overwrite state from scanner — just clear if still Updating
if entry.state == PackageState::Updating {
entry.state = PackageState::Stopped;
}
}
self.state_manager.update_data(data).await;
}
}
+43 -12
View File
@@ -1,6 +1,6 @@
use super::RpcHandler;
use crate::{federation, node_message, nostr_discovery, peers};
use crate::peers::KnownPeer;
use crate::{federation, node_message, nostr_discovery, peers};
use anyhow::Result;
impl RpcHandler {
@@ -17,7 +17,10 @@ impl RpcHandler {
.get("pubkey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?;
let name = params.get("name").and_then(|v| v.as_str()).map(String::from);
let name = params
.get("name")
.and_then(|v| v.as_str())
.map(String::from);
let peer = KnownPeer {
onion: onion.to_string(),
@@ -69,13 +72,17 @@ impl RpcHandler {
// Validate onion is a known peer or federated node to prevent SSRF
let known_peers = peers::load_peers(&self.config.data_dir).await?;
let is_known_peer = known_peers.iter().any(|p| {
p.onion == onion || p.onion == format!("{}.onion", onion)
p.onion == onion
|| p.onion == format!("{}.onion", onion)
|| format!("{}.onion", p.onion) == onion
});
let is_known_fed = if !is_known_peer {
let fed_nodes = federation::load_nodes(&self.config.data_dir).await.unwrap_or_default();
let fed_nodes = federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
fed_nodes.iter().any(|n| {
n.onion == onion || n.onion == format!("{}.onion", onion)
n.onion == onion
|| n.onion == format!("{}.onion", onion)
|| format!("{}.onion", n.onion) == onion
})
} else {
@@ -90,24 +97,44 @@ impl RpcHandler {
let (data, _) = self.state_manager.get_snapshot().await;
let pubkey = data.server_info.pubkey.clone();
// Skip sending to ourselves (prevents duplicate messages in group chat)
if let Some(ref our_onion) = data.server_info.tor_address {
let our = our_onion.trim_end_matches(".onion");
let their = onion.trim_end_matches(".onion");
if our == their {
return Ok(serde_json::json!({ "ok": true, "sent_to": onion, "skipped": "self" }));
}
}
// Load signing key for E2E encryption
let identity_dir = self.config.data_dir.join("identity");
let node_id = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
// Look up recipient's pubkey from federation nodes
let fed_nodes = federation::load_nodes(&self.config.data_dir).await.unwrap_or_default();
let recipient_pubkey = fed_nodes.iter()
.find(|n| n.onion == onion || n.onion == format!("{}.onion", onion)
|| format!("{}.onion", n.onion) == onion)
.map(|n| n.pubkey.clone());
let fed_nodes = federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
let recipient = fed_nodes.iter().find(|n| {
n.onion == onion
|| n.onion == format!("{}.onion", onion)
|| format!("{}.onion", n.onion) == onion
});
let recipient_pubkey = recipient.map(|n| n.pubkey.clone());
let recipient_fips_npub = recipient.and_then(|n| n.fips_npub.clone());
// Include our node name so the recipient can display it
let node_name = data.server_info.name.clone();
node_message::send_to_peer(
onion,
recipient_fips_npub.as_deref(),
&pubkey,
message,
Some(node_id.signing_key()),
recipient_pubkey.as_deref(),
).await?;
node_name.as_deref(),
)
.await?;
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
}
@@ -120,7 +147,11 @@ impl RpcHandler {
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
let reachable = node_message::check_peer_reachable(onion).await.unwrap_or(false);
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let reachable = node_message::check_peer_reachable(onion, fips_npub.as_deref())
.await
.unwrap_or(false);
Ok(serde_json::json!({ "onion": onion, "reachable": reachable }))
}
+9 -3
View File
@@ -22,7 +22,9 @@ pub(super) struct RpcError {
/// Simple TTL cache for read-only RPC responses.
pub(super) struct ResponseCache {
entries: tokio::sync::RwLock<std::collections::HashMap<String, (std::time::Instant, serde_json::Value)>>,
entries: tokio::sync::RwLock<
std::collections::HashMap<String, (std::time::Instant, serde_json::Value)>,
>,
ttl: std::time::Duration,
}
@@ -57,11 +59,15 @@ pub(super) fn json_response(status: StatusCode, body: &[u8]) -> Response<hyper::
.header("Content-Type", "application/json")
.body(hyper::Body::from(body.to_vec()))
.unwrap_or_else(|_| {
Response::new(hyper::Body::from(r#"{"error":{"code":500,"message":"Internal error"}}"#))
Response::new(hyper::Body::from(
r#"{"error":{"code":500,"message":"Internal error"}}"#,
))
})
}
/// Parse a Set-Cookie header value, returning a default if parsing fails.
pub(super) fn cookie_header(value: &str) -> hyper::header::HeaderValue {
value.parse().unwrap_or_else(|_| hyper::header::HeaderValue::from_static(""))
value
.parse()
.unwrap_or_else(|_| hyper::header::HeaderValue::from_static(""))
}
+5 -2
View File
@@ -47,11 +47,13 @@ impl RpcHandler {
let internal_port = params
.get("internal_port")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing internal_port"))? as u16;
.ok_or_else(|| anyhow::anyhow!("Missing internal_port"))?
as u16;
let external_port = params
.get("external_port")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing external_port"))? as u16;
.ok_or_else(|| anyhow::anyhow!("Missing external_port"))?
as u16;
let protocol = params
.get("protocol")
.and_then(|v| v.as_str())
@@ -98,6 +100,7 @@ impl RpcHandler {
"tor_connected": diag.tor_connected,
"dns_working": diag.dns_working,
"recommendations": diag.recommendations,
"wifi_ssid": diag.wifi_ssid,
}))
}
+3 -5
View File
@@ -1,5 +1,5 @@
use super::RpcHandler;
use super::package::validate_app_id;
use super::RpcHandler;
use anyhow::Result;
impl RpcHandler {
@@ -15,8 +15,7 @@ impl RpcHandler {
let secrets_dir = self.config.data_dir.join("secrets");
let encryption_key = self.get_secrets_key();
let mgr =
archipelago_security::SecretsManager::new(secrets_dir, encryption_key)?;
let mgr = archipelago_security::SecretsManager::new(secrets_dir, encryption_key)?;
let secret_ids = mgr.list_secrets(app_id).await?;
let mut rotated = Vec::new();
@@ -44,8 +43,7 @@ impl RpcHandler {
let secrets_dir = self.config.data_dir.join("secrets");
let encryption_key = self.get_secrets_key();
let mgr =
archipelago_security::SecretsManager::new(secrets_dir, encryption_key)?;
let mgr = archipelago_security::SecretsManager::new(secrets_dir, encryption_key)?;
let expiring = mgr.list_expiring(max_age_days).await?;
+82 -26
View File
@@ -26,12 +26,40 @@ impl Drop for OnboardingMnemonicState {
const MNEMONIC_TTL: std::time::Duration = std::time::Duration::from_secs(600); // 10 minutes
/// Best-effort: install fips.yaml + start archipelago-fips.service after the
/// seed onboarding has written the fips_key to disk. Runs in a detached task
/// so the user-facing RPC returns immediately — the systemctl calls can take
/// a few seconds the first time on slow hardware. Any failure is logged but
/// does not break onboarding; the user can still hit fips.install manually
/// from the dashboard as an escape hatch.
fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
tokio::spawn(async move {
let identity_dir = data_dir.join("identity");
if !crate::identity::fips_key_exists(&identity_dir) {
return;
}
// Touch load_fips_keys first so any legacy raw-byte file is migrated
// to bech32 before we copy it into /etc/fips/.
if let Err(e) = crate::identity::load_fips_keys(&identity_dir).await {
tracing::warn!("post-onboarding fips key load/migrate failed: {}", e);
return;
}
if let Err(e) = crate::fips::config::install(&identity_dir).await {
tracing::warn!("post-onboarding fips config install failed: {}", e);
return;
}
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
tracing::warn!("post-onboarding archipelago-fips activate failed: {}", e);
return;
}
tracing::info!("archipelago-fips auto-activated post-onboarding");
});
}
impl RpcHandler {
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
/// Returns the words for the user to write down.
pub(in crate::api::rpc) async fn handle_seed_generate(
&self,
) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_seed_generate(&self) -> Result<serde_json::Value> {
let (mnemonic, seed) = crate::seed::MasterSeed::generate()?;
// Derive and write node Ed25519 key.
@@ -49,12 +77,18 @@ impl RpcHandler {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&nostr_secret_path, std::fs::Permissions::from_mode(0o600)).await?;
tokio::fs::set_permissions(&nostr_secret_path, std::fs::Permissions::from_mode(0o600))
.await?;
}
// Initialize identity index at 0.
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
// fips_key is now on disk — auto-activate archipelago-fips so the
// user doesn't have to hit an "Activate" button. Detached task;
// the onboarding RPC returns immediately.
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
let words: Vec<&str> = mnemonic.words().collect();
// Hold mnemonic in memory for the verify step.
@@ -79,8 +113,12 @@ impl RpcHandler {
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let submitted_words: Vec<String> = serde_json::from_value(
params.get("words").cloned().ok_or_else(|| anyhow::anyhow!("Missing words"))?,
).context("Invalid words array")?;
params
.get("words")
.cloned()
.ok_or_else(|| anyhow::anyhow!("Missing words"))?,
)
.context("Invalid words array")?;
// Validate against the held mnemonic.
let mnemonic_str = {
@@ -89,7 +127,9 @@ impl RpcHandler {
Some(s) if s.created_at.elapsed() < MNEMONIC_TTL => s.words.clone(),
_ => {
*state = None;
anyhow::bail!("No pending seed generation or session expired. Please regenerate.");
anyhow::bail!(
"No pending seed generation or session expired. Please regenerate."
);
}
}
};
@@ -134,8 +174,12 @@ impl RpcHandler {
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let words: Vec<String> = serde_json::from_value(
params.get("words").cloned().ok_or_else(|| anyhow::anyhow!("Missing words"))?,
).context("Invalid words array")?;
params
.get("words")
.cloned()
.ok_or_else(|| anyhow::anyhow!("Missing words"))?,
)
.context("Invalid words array")?;
let phrase = words.join(" ");
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
@@ -149,14 +193,19 @@ impl RpcHandler {
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
let pubkey_hex_nostr = nostr_keys.public_key().to_hex();
tokio::fs::write(identity_dir.join("nostr_secret"), secret_hex.as_bytes()).await?;
tokio::fs::write(identity_dir.join("nostr_pubkey"), pubkey_hex_nostr.as_bytes()).await?;
tokio::fs::write(
identity_dir.join("nostr_pubkey"),
pubkey_hex_nostr.as_bytes(),
)
.await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(
identity_dir.join("nostr_secret"),
std::fs::Permissions::from_mode(0o600),
).await?;
)
.await?;
}
// Initialize identity index.
@@ -164,12 +213,14 @@ impl RpcHandler {
// Create default identity from seed.
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
manager.create_from_seed(
"Personal".to_string(),
crate::identity_manager::IdentityPurpose::Personal,
&seed,
&self.config.data_dir,
).await?;
manager
.create_from_seed(
"Personal".to_string(),
crate::identity_manager::IdentityPurpose::Personal,
&seed,
&self.config.data_dir,
)
.await?;
// Get DID and npub for the response.
let node_key = crate::seed::derive_node_ed25519(&seed)?;
@@ -177,6 +228,10 @@ impl RpcHandler {
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
// Same as seed.generate: the key is materialised, kick the FIPS
// service up without user interaction.
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
Ok(serde_json::json!({
"did": did,
"nostr_npub": nostr_npub,
@@ -190,14 +245,16 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let passphrase = params.get("passphrase")
let passphrase = params
.get("passphrase")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing passphrase"))?;
// Try to get mnemonic from in-memory state first.
let mnemonic_str = {
let state = ONBOARDING_MNEMONIC.lock().await;
state.as_ref()
state
.as_ref()
.filter(|s| s.created_at.elapsed() < MNEMONIC_TTL)
.map(|s| s.words.clone())
};
@@ -214,15 +271,14 @@ impl RpcHandler {
}
/// Return seed status information.
pub(in crate::api::rpc) async fn handle_seed_status(
&self,
) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_seed_status(&self) -> Result<serde_json::Value> {
let has_seed = crate::seed::seed_exists(&self.config.data_dir);
let has_node_key = crate::identity::NodeIdentity::key_exists(
&self.config.data_dir.join("identity"),
);
let has_node_key =
crate::identity::NodeIdentity::key_exists(&self.config.data_dir.join("identity"));
let is_legacy = has_node_key && !has_seed;
let next_index = crate::seed::load_identity_index(&self.config.data_dir).await.unwrap_or(0);
let next_index = crate::seed::load_identity_index(&self.config.data_dir)
.await
.unwrap_or(0);
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
let (identities, _) = manager.list().await?;
+409
View File
@@ -0,0 +1,409 @@
//! RPC handlers for streaming ecash payments.
//!
//! Endpoints for managing priced services, processing payments,
//! checking sessions/usage, and publishing service advertisements.
use super::RpcHandler;
use crate::streaming::{advertisement, gate, meter, pricing, session};
use crate::wallet::ecash;
use anyhow::Result;
impl RpcHandler {
// ── Service pricing management ──
/// List all configured streaming services and their pricing.
pub(super) async fn handle_streaming_list_services(&self) -> Result<serde_json::Value> {
let config = pricing::load_pricing(&self.config.data_dir).await?;
Ok(serde_json::json!({
"services": config.services,
}))
}
/// Configure pricing for a streaming service.
pub(super) async fn handle_streaming_configure_service(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let service_id = params
.get("service_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(service_id);
let metric_str = params
.get("metric")
.and_then(|v| v.as_str())
.unwrap_or("requests");
let step_size = params
.get("step_size")
.and_then(|v| v.as_u64())
.unwrap_or(1);
let price_per_step = params
.get("price_per_step")
.and_then(|v| v.as_u64())
.unwrap_or(1);
let min_steps = params
.get("min_steps")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let enabled = params
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let description = params
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
let metric = match metric_str {
"bytes" => pricing::Metric::Bytes,
"milliseconds" | "time" => pricing::Metric::Milliseconds,
"requests" => pricing::Metric::Requests,
_ => return Err(anyhow::anyhow!("Invalid metric: {}", metric_str)),
};
let accepted_mints: Vec<String> = params
.get("accepted_mints")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let service = pricing::ServicePricing {
service_id: service_id.to_string(),
name: name.to_string(),
metric,
step_size,
price_per_step,
min_steps,
enabled,
description: description.to_string(),
accepted_mints,
};
service.validate()?;
let mut config = pricing::load_pricing(&self.config.data_dir).await?;
// Update existing or add new
if let Some(existing) = config
.services
.iter_mut()
.find(|s| s.service_id == service_id)
{
*existing = service.clone();
} else {
config.services.push(service.clone());
}
pricing::save_pricing(&self.config.data_dir, &config).await?;
Ok(serde_json::json!({
"service": service,
"updated": true,
}))
}
/// Enable or disable a streaming service.
pub(super) async fn handle_streaming_toggle_service(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let service_id = params
.get("service_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
let enabled = params
.get("enabled")
.and_then(|v| v.as_bool())
.ok_or_else(|| anyhow::anyhow!("Missing enabled"))?;
let mut config = pricing::load_pricing(&self.config.data_dir).await?;
if let Some(service) = config
.services
.iter_mut()
.find(|s| s.service_id == service_id)
{
service.enabled = enabled;
pricing::save_pricing(&self.config.data_dir, &config).await?;
Ok(serde_json::json!({
"service_id": service_id,
"enabled": enabled,
}))
} else {
Err(anyhow::anyhow!("Service '{}' not found", service_id))
}
}
// ── Payment processing ──
/// Process a streaming payment — submit a Cashu token for a service.
/// Returns session details with allotment on success.
pub(super) async fn handle_streaming_pay(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let service_id = params
.get("service_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
let token = params
.get("token")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing token (cashuA token string)"))?;
let peer_id = params
.get("peer_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing peer_id"))?;
if token.is_empty() {
return Err(anyhow::anyhow!("Token cannot be empty"));
}
if peer_id.is_empty() {
return Err(anyhow::anyhow!("Peer ID cannot be empty"));
}
let result =
gate::check_gate(&self.config.data_dir, peer_id, service_id, Some(token), 0).await?;
match result {
gate::GateResult::PaidAndAllowed {
session_id,
allotment,
paid_sats,
} => Ok(serde_json::json!({
"status": "paid",
"session_id": session_id,
"allotment": allotment,
"paid_sats": paid_sats,
})),
gate::GateResult::InsufficientPayment {
provided_sats,
minimum_sats,
} => Ok(serde_json::json!({
"status": "insufficient",
"error": { "code": "insufficient_payment", "message": format!("Need {} sats, got {}", minimum_sats, provided_sats) },
"minimum_sats": minimum_sats,
"provided_sats": provided_sats,
})),
gate::GateResult::PaymentFailed { reason } => Ok(serde_json::json!({
"status": "failed",
"error": { "code": "payment_failed", "message": reason },
})),
gate::GateResult::ServiceUnavailable => {
Err(anyhow::anyhow!("Service '{}' not available", service_id))
}
_ => Err(anyhow::anyhow!("Unexpected gate result")),
}
}
/// Discover available streaming services (pricing info).
/// This is the unauthenticated discovery endpoint.
pub(super) async fn handle_streaming_discover(&self) -> Result<serde_json::Value> {
let config = pricing::load_pricing(&self.config.data_dir).await?;
let accepted_mints = ecash::load_accepted_mints(&self.config.data_dir).await?;
let services: Vec<serde_json::Value> = config
.services
.iter()
.filter(|s| s.enabled)
.map(|s| {
let mints = if s.accepted_mints.is_empty() {
&accepted_mints.mints
} else {
&s.accepted_mints
};
serde_json::json!({
"service_id": s.service_id,
"name": s.name,
"description": s.description,
"metric": s.metric,
"step_size": s.step_size,
"price_per_step": s.price_per_step,
"min_steps": s.min_steps,
"minimum_sats": s.minimum_payment(),
"accepted_mints": mints,
})
})
.collect();
Ok(serde_json::json!({
"services": services,
}))
}
// ── Session management ──
/// Check usage for a peer's active session.
pub(super) async fn handle_streaming_usage(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let peer_id = params
.get("peer_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing peer_id"))?;
let service_id = params
.get("service_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
match meter::get_peer_usage(&self.config.data_dir, peer_id, service_id).await? {
Some(usage) => Ok(serde_json::json!({ "usage": usage })),
None => Ok(serde_json::json!({
"usage": null,
"message": "No active session",
})),
}
}
/// Get details of a specific session by ID.
pub(super) async fn handle_streaming_session(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let session_id = params
.get("session_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing session_id"))?;
let store = session::load_sessions(&self.config.data_dir).await?;
match store.get(session_id) {
Some(s) => Ok(serde_json::json!({ "session": s })),
None => Err(anyhow::anyhow!("Session not found")),
}
}
/// List all active streaming sessions (admin view).
pub(super) async fn handle_streaming_list_sessions(&self) -> Result<serde_json::Value> {
let store = session::load_sessions(&self.config.data_dir).await?;
let active = store.active_sessions();
let revenue = store.total_revenue();
let by_service = store.revenue_by_service();
Ok(serde_json::json!({
"sessions": active,
"total_active": active.len(),
"total_revenue_sats": revenue,
"revenue_by_service": by_service,
}))
}
/// Close a specific session.
pub(super) async fn handle_streaming_close_session(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let session_id = params
.get("session_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing session_id"))?;
let mut store = session::load_sessions(&self.config.data_dir).await?;
if let Some(s) = store.get_mut(session_id) {
s.close();
session::save_sessions(&self.config.data_dir, &store).await?;
Ok(serde_json::json!({ "closed": true }))
} else {
Err(anyhow::anyhow!("Session not found"))
}
}
// ── Advertisement ──
/// Publish a streaming service advertisement to Nostr relays.
pub(super) async fn handle_streaming_advertise(&self) -> Result<serde_json::Value> {
let config = pricing::load_pricing(&self.config.data_dir).await?;
let accepted_mints = ecash::load_accepted_mints(&self.config.data_dir).await?;
let enabled_count = config.services.iter().filter(|s| s.enabled).count();
if enabled_count == 0 {
return Err(anyhow::anyhow!("No enabled services to advertise"));
}
// Get node's onion address for the endpoint tag
let onion = crate::container::docker_packages::read_tor_address("archipelago").await;
let tags = advertisement::build_advertisement_tags(
&config,
&accepted_mints.mints,
onion.as_deref(),
);
let content = advertisement::build_advertisement_content(&config);
Ok(serde_json::json!({
"kind": advertisement::KIND_SERVICE_ADVERTISEMENT,
"content": content,
"tags": tags,
"services_count": enabled_count,
"ready_to_publish": true,
}))
}
// ── Accepted mints management ──
/// List accepted mints for streaming payments.
pub(super) async fn handle_streaming_list_mints(&self) -> Result<serde_json::Value> {
let mints = ecash::load_accepted_mints(&self.config.data_dir).await?;
Ok(serde_json::json!({ "mints": mints.mints }))
}
/// Add or remove accepted mints.
pub(super) async fn handle_streaming_configure_mints(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let mints = params
.get("mints")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("Missing mints array"))?;
let mint_urls: Vec<String> = mints
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
if mint_urls.is_empty() {
return Err(anyhow::anyhow!("Must have at least one accepted mint"));
}
// Basic validation
for url in &mint_urls {
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(anyhow::anyhow!("Invalid mint URL: {}", url));
}
}
let config = ecash::AcceptedMints {
mints: mint_urls.clone(),
};
ecash::save_accepted_mints(&self.config.data_dir, &config).await?;
Ok(serde_json::json!({
"mints": mint_urls,
"updated": true,
}))
}
// ── Maintenance ──
/// Run streaming maintenance (close expired sessions, prune old records).
pub(super) async fn handle_streaming_maintenance(&self) -> Result<serde_json::Value> {
let closed = meter::maintenance(&self.config.data_dir).await?;
Ok(serde_json::json!({
"expired_closed": closed,
}))
}
}
+109 -7
View File
@@ -56,7 +56,11 @@ impl RpcHandler {
let (mem_used, mem_total) = read_meminfo().await.unwrap_or((0, 0));
// Prefer encrypted data partition if it exists
let data_path = std::path::Path::new("/var/lib/archipelago");
let df_target = if data_path.exists() { "/var/lib/archipelago" } else { "/" };
let df_target = if data_path.exists() {
"/var/lib/archipelago"
} else {
"/"
};
let (disk_used, disk_total) = read_disk_usage_path(df_target).await.unwrap_or((0, 0));
Ok(serde_json::json!({
@@ -91,7 +95,9 @@ impl RpcHandler {
}
/// system.detect-usb-devices — scan for known hardware wallet USB devices
pub(in crate::api::rpc) async fn handle_system_detect_usb_devices(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_system_detect_usb_devices(
&self,
) -> Result<serde_json::Value> {
debug!("Scanning for USB hardware wallets");
let devices = detect_usb_hardware_wallets().await.unwrap_or_default();
@@ -103,7 +109,11 @@ impl RpcHandler {
pub(in crate::api::rpc) async fn handle_system_disk_status(&self) -> Result<serde_json::Value> {
// Prefer the encrypted data partition if it exists
let data_path = std::path::Path::new("/var/lib/archipelago");
let df_target = if data_path.exists() { "/var/lib/archipelago" } else { "/" };
let df_target = if data_path.exists() {
"/var/lib/archipelago"
} else {
"/"
};
let (used, total) = read_disk_usage_path(df_target).await.unwrap_or((0, 0));
let percent = if total > 0 {
@@ -138,7 +148,9 @@ impl RpcHandler {
}
/// system.disk-cleanup — Remove old container images, stale logs, and temp files.
pub(in crate::api::rpc) async fn handle_system_disk_cleanup(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_system_disk_cleanup(
&self,
) -> Result<serde_json::Value> {
tracing::info!("Starting disk cleanup");
let mut freed_bytes: u64 = 0;
let mut actions: Vec<String> = Vec::new();
@@ -148,7 +160,10 @@ impl RpcHandler {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Pruned dangling images: {} freed", format_bytes(bytes)));
actions.push(format!(
"Pruned dangling images: {} freed",
format_bytes(bytes)
));
}
}
Err(e) => actions.push(format!("Image prune failed: {}", e)),
@@ -187,7 +202,11 @@ impl RpcHandler {
Err(e) => actions.push(format!("Build cache prune failed: {}", e)),
}
tracing::info!("Disk cleanup complete: {} freed ({} actions)", format_bytes(freed_bytes), actions.len());
tracing::info!(
"Disk cleanup complete: {} freed ({} actions)",
format_bytes(freed_bytes),
actions.len()
);
Ok(serde_json::json!({
"freed_bytes": freed_bytes,
@@ -226,7 +245,8 @@ impl RpcHandler {
let _ = tokio::fs::write(
"/var/lib/archipelago/tor-config/tor-action",
serde_json::to_string(&action).unwrap_or_default(),
).await;
)
.await;
});
Ok(serde_json::json!({ "rebooting": true }))
@@ -327,4 +347,86 @@ impl RpcHandler {
Ok(serde_json::json!({ "status": "resetting" }))
}
/// system.settings.get — Read a settings value
pub(in crate::api::rpc) async fn handle_system_settings_get(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let key = params
.get("key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing key"))?;
match key {
"claude_api_key_set" => {
let key_file = self.config.data_dir.join("secrets/claude-api-key");
let has_key = tokio::fs::metadata(&key_file).await.is_ok();
Ok(serde_json::json!({ "value": has_key }))
}
_ => Ok(serde_json::json!({ "value": null })),
}
}
/// system.settings.set — Write a settings value
pub(in crate::api::rpc) async fn handle_system_settings_set(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let key = params
.get("key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing key"))?;
let value = params.get("value").and_then(|v| v.as_str()).unwrap_or("");
match key {
"claude_api_key" => {
let secrets_dir = self.config.data_dir.join("secrets");
tokio::fs::create_dir_all(&secrets_dir)
.await
.context("Failed to create secrets dir")?;
let key_file = secrets_dir.join("claude-api-key");
if value.is_empty() {
// Remove key
tokio::fs::remove_file(&key_file).await.ok();
info!("Claude API key removed");
} else {
// Save key
tokio::fs::write(&key_file, value)
.await
.context("Failed to write API key")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600))
.ok();
}
info!("Claude API key saved");
}
// Update the claude-api-proxy environment and restart
let env_line = format!("ANTHROPIC_API_KEY={}", value);
let env_file = self.config.data_dir.join("secrets/claude-api-proxy.env");
tokio::fs::write(&env_file, &env_line).await.ok();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&env_file, std::fs::Permissions::from_mode(0o600))
.ok();
}
// Restart the proxy to pick up the new key
let _ = tokio::process::Command::new("sudo")
.args(["systemctl", "restart", "claude-api-proxy"])
.output()
.await;
Ok(serde_json::json!({ "saved": true }))
}
_ => anyhow::bail!("Unknown setting: {}", key),
}
}
}
+13 -16
View File
@@ -25,16 +25,17 @@ pub(super) async fn push_name_to_peers(
if node.trust_level == federation::TrustLevel::Untrusted {
continue;
}
match federation::sync_with_peer(
data_dir,
node,
&local_did,
|bytes| node_identity.sign(bytes),
)
match federation::sync_with_peer(data_dir, node, &local_did, |bytes| {
node_identity.sign(bytes)
})
.await
{
Ok(_) => synced += 1,
Err(e) => debug!("Sync with {} after rename: {}", node.did.chars().take(20).collect::<String>(), e),
Err(e) => debug!(
"Sync with {} after rename: {}",
node.did.chars().take(20).collect::<String>(),
e
),
}
}
info!("Pushed server name to {}/{} peers", synced, nodes.len());
@@ -267,7 +268,10 @@ pub(super) async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Valu
Err(_) => continue,
};
if let Some((_, name)) = KNOWN_HW_WALLETS.iter().find(|(known_vid, _)| *known_vid == vid) {
if let Some((_, name)) = KNOWN_HW_WALLETS
.iter()
.find(|(known_vid, _)| *known_vid == vid)
{
let pid_str = tokio::fs::read_to_string(&product_path)
.await
.map(|s| s.trim().to_string())
@@ -387,14 +391,7 @@ pub(super) async fn clean_temp_files() -> Result<u64> {
for dir in &["/tmp", "/var/tmp"] {
let output = tokio::process::Command::new("sudo")
.args([
"find",
dir,
"-type",
"f",
"-mtime",
"+7",
"-delete",
"-print",
"find", dir, "-type", "f", "-mtime", "+7", "-delete", "-print",
])
.output()
.await;
+21 -11
View File
@@ -4,9 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
impl RpcHandler {
/// List all configured hidden services with their .onion addresses.
pub(in crate::api::rpc) async fn handle_tor_list_services(
&self,
) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_tor_list_services(&self) -> Result<serde_json::Value> {
let config_dir = self.config.data_dir.join("tor-config");
let services = list_services(&config_dir).await?;
let tor_running = check_tor_running().await;
@@ -37,7 +35,10 @@ impl RpcHandler {
let local_port = if raw_port == 0 {
let detected = known_service_port(name);
if detected == 0 {
return Err(anyhow::anyhow!("Unknown app '{}' — specify local_port manually", name));
return Err(anyhow::anyhow!(
"Unknown app '{}' — specify local_port manually",
name
));
}
detected
} else {
@@ -68,7 +69,11 @@ impl RpcHandler {
sync_single_hostname(name, addr).await;
}
info!(service = name, port = local_port, "Created Tor hidden service");
info!(
service = name,
port = local_port,
"Created Tor hidden service"
);
Ok(serde_json::json!({
"created": true,
"name": name,
@@ -143,7 +148,10 @@ impl RpcHandler {
let old_onion = read_onion_address(name).await;
if old_onion.is_none() {
return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name));
return Err(anyhow::anyhow!(
"Service '{}' has no .onion address to rotate",
name
));
}
let timestamp = SystemTime::now()
@@ -184,7 +192,8 @@ impl RpcHandler {
&new_addr_clone,
old_onion_clone.as_deref(),
tor_proxy.as_deref(),
).await;
)
.await;
});
}
@@ -292,7 +301,10 @@ impl RpcHandler {
if !enabled {
delete_hidden_service_dir(app_id).await;
info!(app = app_id, "Disabled Tor access — removed hidden service dir");
info!(
app = app_id,
"Disabled Tor access — removed hidden service dir"
);
}
regenerate_torrc(&config).await?;
@@ -319,9 +331,7 @@ impl RpcHandler {
}
/// Restart Tor daemon (system or container).
pub(in crate::api::rpc) async fn handle_tor_restart(
&self,
) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_tor_restart(&self) -> Result<serde_json::Value> {
info!("Manual Tor restart requested");
let config_dir = self.config.data_dir.join("tor-config");
+107 -58
View File
@@ -23,12 +23,12 @@ pub(super) struct TorService {
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub(super) struct ServicesConfig {
pub(in crate::api::rpc) struct ServicesConfig {
pub services: Vec<TorServiceEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct TorServiceEntry {
pub(in crate::api::rpc) struct TorServiceEntry {
pub name: String,
pub local_port: u16,
#[serde(default)]
@@ -46,10 +46,15 @@ fn default_true() -> bool {
// ─── Validation ───────────────────────────────────────────────────
pub(super) fn validate_service_name(name: &str) -> Result<()> {
if name.is_empty() || name.len() > 64
|| !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
if name.is_empty()
|| name.len() > 64
|| !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
return Err(anyhow::anyhow!(
"Invalid service name (alphanumeric, hyphens, underscores only)"
));
}
Ok(())
}
@@ -64,7 +69,9 @@ pub(super) async fn dispatch_tor_action(action: serde_json::Value) -> Result<()>
let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await;
let content = serde_json::to_string(&action).context("Failed to serialize tor action")?;
let config_dir = Path::new(TOR_ACTION_FILE).parent().unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
let config_dir = Path::new(TOR_ACTION_FILE)
.parent()
.unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
tokio::fs::create_dir_all(config_dir).await.ok();
tokio::fs::write(TOR_ACTION_FILE, &content)
.await
@@ -78,20 +85,27 @@ pub(super) async fn dispatch_tor_action(action: serde_json::Value) -> Result<()>
if result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
return Ok(());
}
let err = result.get("error").and_then(|v| v.as_str()).unwrap_or("Unknown error");
let err = result
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Tor helper: {}", err));
}
return Ok(());
}
}
Err(anyhow::anyhow!("Tor helper timed out — is archipelago-tor-helper.path enabled?"))
Err(anyhow::anyhow!(
"Tor helper timed out — is archipelago-tor-helper.path enabled?"
))
}
pub(super) async fn delete_hidden_service_dir(name: &str) {
if let Err(e) = dispatch_tor_action(serde_json::json!({
"action": "delete-service",
"name": name,
})).await {
}))
.await
{
warn!("Failed to delete hidden service dir for {}: {}", name, e);
}
}
@@ -101,15 +115,18 @@ pub(super) async fn rename_hidden_service_dir(name: &str, timestamp: u64) {
"action": "rename-service",
"name": name,
"timestamp": timestamp,
})).await {
}))
.await
{
warn!("Failed to rename hidden service dir for {}: {}", name, e);
}
}
pub(super) async fn restart_tor() -> Result<()> {
pub(in crate::api::rpc) async fn restart_tor() -> Result<()> {
dispatch_tor_action(serde_json::json!({
"action": "write-torrc-and-restart",
})).await
}))
.await
}
pub(super) async fn check_tor_running() -> bool {
@@ -125,20 +142,23 @@ pub(super) fn detect_hidden_service_base() -> String {
return "/var/lib/tor".to_string();
}
let custom = tor_data_dir();
if Path::new(&custom).join("hidden_service_archipelago").exists() {
if Path::new(&custom)
.join("hidden_service_archipelago")
.exists()
{
return custom;
}
"/var/lib/tor".to_string()
}
pub(super) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
let base = detect_hidden_service_base();
let mut lines = Vec::new();
lines.push("# Auto-generated by Archipelago — do not edit manually".to_string());
lines.push("SocksPort 9050".to_string());
lines.push("# ControlPort disabled for security".to_string());
lines.push(String::new());
let mut lines = vec![
"# Auto-generated by Archipelago — do not edit manually".to_string(),
"SocksPort 9050".to_string(),
"# ControlPort disabled for security".to_string(),
String::new(),
];
for svc in &config.services {
if !svc.enabled {
@@ -149,7 +169,10 @@ pub(super) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
if is_protocol_service(&svc.name) {
let remote_port = svc.remote_port.unwrap_or(svc.local_port);
lines.push(format!("HiddenServicePort {} 127.0.0.1:{}", remote_port, svc.local_port));
lines.push(format!(
"HiddenServicePort {} 127.0.0.1:{}",
remote_port, svc.local_port
));
if svc.name == "lnd" {
lines.push("HiddenServicePort 9735 127.0.0.1:9735".to_string());
lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string());
@@ -163,19 +186,25 @@ pub(super) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
let content = lines.join("\n");
let staging = "/var/lib/archipelago/tor-config/torrc.staged";
let config_dir = Path::new(staging).parent().unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
let config_dir = Path::new(staging)
.parent()
.unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
tokio::fs::create_dir_all(config_dir).await.ok();
tokio::fs::write(staging, &content).await.context("Failed to write staged torrc")?;
tokio::fs::write(staging, &content)
.await
.context("Failed to write staged torrc")?;
debug!("Staged torrc with {} enabled services",
config.services.iter().filter(|s| s.enabled).count());
debug!(
"Staged torrc with {} enabled services",
config.services.iter().filter(|s| s.enabled).count()
);
Ok(())
}
// ─── Hostname Sync ───────────────────────────────────────────────
pub(super) async fn sync_single_hostname(name: &str, address: &str) {
pub(in crate::api::rpc) async fn sync_single_hostname(name: &str, address: &str) {
let hostnames_dir = Path::new("/var/lib/archipelago/tor-hostnames");
if let Err(e) = tokio::fs::create_dir_all(hostnames_dir).await {
warn!("Failed to create tor-hostnames dir: {}", e);
@@ -223,11 +252,11 @@ pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<To
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
if name.starts_with("hidden_service_")
&& !name.contains("_old_")
&& is_dir
{
let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string();
if name.starts_with("hidden_service_") && !name.contains("_old_") && is_dir {
let service_name = name
.strip_prefix("hidden_service_")
.unwrap_or(&name)
.to_string();
if seen.contains(&service_name) {
continue;
}
@@ -306,7 +335,7 @@ fn is_valid_v3_onion(s: &str) -> bool {
// ─── Known Ports ─────────────────────────────────────────────────
pub(super) fn known_service_port(name: &str) -> u16 {
pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
match name {
"archipelago" => 80,
"bitcoin" | "bitcoin-knots" => 8333,
@@ -331,8 +360,11 @@ pub(super) fn known_service_port(name: &str) -> u16 {
}
}
pub(super) fn is_protocol_service(name: &str) -> bool {
matches!(name, "bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd")
pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool {
matches!(
name,
"bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
)
}
// ─── Config I/O ──────────────────────────────────────────────────
@@ -341,7 +373,9 @@ fn tor_data_dir() -> String {
std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| TOR_DATA_DIR.to_string())
}
pub(super) async fn load_services_config(config_dir: &std::path::Path) -> ServicesConfig {
pub(in crate::api::rpc) async fn load_services_config(
config_dir: &std::path::Path,
) -> ServicesConfig {
let path = config_dir.join(SERVICES_CONFIG);
match tokio::fs::read_to_string(&path).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
@@ -349,11 +383,19 @@ pub(super) async fn load_services_config(config_dir: &std::path::Path) -> Servic
}
}
pub(super) async fn save_services_config(config_dir: &std::path::Path, config: &ServicesConfig) -> Result<()> {
tokio::fs::create_dir_all(config_dir).await.context("Failed to create tor config dir")?;
pub(in crate::api::rpc) async fn save_services_config(
config_dir: &std::path::Path,
config: &ServicesConfig,
) -> Result<()> {
tokio::fs::create_dir_all(config_dir)
.await
.context("Failed to create tor config dir")?;
let path = config_dir.join(SERVICES_CONFIG);
let content = serde_json::to_string_pretty(config).context("Failed to serialize services config")?;
tokio::fs::write(&path, content).await.context("Failed to write services config")?;
let content =
serde_json::to_string_pretty(config).context("Failed to serialize services config")?;
tokio::fs::write(&path, content)
.await
.context("Failed to write services config")?;
Ok(())
}
@@ -375,11 +417,13 @@ pub(super) async fn notify_federation_peers_address_change(
return;
}
};
let proxy = tor_proxy.unwrap_or("127.0.0.1:9050");
// `tor_proxy` is retained for API compat but unused — the FIPS
// fallback dial uses constants::TOR_SOCKS_PROXY internally.
let _ = tor_proxy;
match federation::load_nodes(data_dir).await {
Ok(peers) => {
for peer in peers {
if peer.onion.is_empty() {
if peer.onion.is_empty() && peer.fips_npub.is_none() {
continue;
}
let payload = serde_json::json!({
@@ -390,21 +434,20 @@ pub(super) async fn notify_federation_peers_address_change(
"old_onion": old_onion,
}
});
let url = format!("http://{}/rpc/v1", &peer.onion);
let client = match reqwest::Client::builder()
.proxy(match reqwest::Proxy::all(format!("socks5h://{}", proxy))
.or_else(|_| reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)) {
Ok(p) => p,
Err(_) => continue,
})
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(_) => continue,
};
match client.post(&url).json(&payload).send().await {
Ok(_) => info!(peer_did = %peer.did, "Notified peer of address change"),
// FIPS-preferred: peer's fips_npub is stable across
// onion rotation, so this notification reaches them
// even when their (or our) old onion is now stale.
let req = crate::fips::dial::PeerRequest::new(
peer.fips_npub.as_deref(),
&peer.onion,
"/rpc/v1",
)
.service(crate::settings::transport::PeerService::Peers)
.timeout(std::time::Duration::from_secs(30));
match req.send_json(&payload).await {
Ok((_, transport)) => {
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
}
Err(e) => warn!(peer_did = %peer.did, "Failed to notify peer: {}", e),
}
}
@@ -418,13 +461,19 @@ pub(super) async fn notify_federation_peers_address_change(
// ─── Hostname Waiting ────────────────────────────────────────────
pub(super) async fn wait_for_hostname(service_name: &str, max_secs: u64) -> Option<String> {
pub(in crate::api::rpc) async fn wait_for_hostname(
service_name: &str,
max_secs: u64,
) -> Option<String> {
for _ in 0..max_secs {
if let Some(addr) = read_onion_address(service_name).await {
return Some(addr);
}
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
warn!(service = service_name, "Timed out waiting for new .onion hostname");
warn!(
service = service_name,
"Timed out waiting for new .onion hostname"
);
None
}
+20 -7
View File
@@ -72,13 +72,14 @@ impl RpcHandler {
.session_store
.get_pending_secret(pending_token)
.await
.ok_or_else(|| anyhow::anyhow!("Setup session expired or invalid. Please start again."))?;
.ok_or_else(|| {
anyhow::anyhow!("Setup session expired or invalid. Please start again.")
})?;
let setup_json: serde_json::Value = serde_json::from_slice(&setup_bytes)?;
let totp_data: crate::totp::TotpData =
serde_json::from_value(setup_json["totp_data"].clone())?;
let backup_codes: Vec<String> =
serde_json::from_value(setup_json["backup_codes"].clone())?;
let backup_codes: Vec<String> = serde_json::from_value(setup_json["backup_codes"].clone())?;
// Decrypt and verify the TOTP code
let secret = crate::totp::decrypt_secret(&totp_data, password)?;
@@ -193,7 +194,10 @@ impl RpcHandler {
}
// Upgrade pending session to full (rotates token)
let new_token = self.session_store.upgrade_to_full(token).await
let new_token = self
.session_store
.upgrade_to_full(token)
.await
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
@@ -243,11 +247,20 @@ impl RpcHandler {
self.auth_manager.update_totp(totp_data).await?;
// Upgrade pending session to full (rotates token)
let new_token = self.session_store.upgrade_to_full(token).await
let new_token = self
.session_store
.upgrade_to_full(token)
.await
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
tracing::info!("Login via backup code (codes remaining: {})",
self.auth_manager.get_totp_data().await?.map(|d| d.backup_codes.len()).unwrap_or(0));
tracing::info!(
"Login via backup code (codes remaining: {})",
self.auth_manager
.get_totp_data()
.await?
.map(|d| d.backup_codes.len())
.unwrap_or(0)
);
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
}
+48 -4
View File
@@ -71,7 +71,9 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.as_ref().ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let params = params
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let did = params["did"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'did' param"))?
@@ -87,8 +89,8 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Transport router not initialized"))?;
let (data, _) = self.state_manager.get_snapshot().await;
let our_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
.unwrap_or_default();
let our_did =
crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
let message = TransportMessage {
from_did: our_did,
@@ -106,12 +108,54 @@ impl RpcHandler {
}))
}
/// transport.preferences — Return the user's per-service transport
/// preferences. The UI renders these as five FIPS/Auto/Tor rows.
pub(super) async fn handle_transport_preferences(&self) -> Result<serde_json::Value> {
let prefs = crate::settings::transport::snapshot().await;
Ok(serde_json::to_value(prefs)?)
}
/// transport.set-preference — Change a single service preference.
/// Persists to disk and hot-swaps the in-memory handle so future
/// calls see the new value without restart.
pub(super) async fn handle_transport_set_preference(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
use crate::settings::transport::{set, PeerService, TransportPref};
let params = params
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let service: PeerService = serde_json::from_value(
params
.get("service")
.cloned()
.ok_or_else(|| anyhow::anyhow!("Missing 'service' param"))?,
)
.map_err(|e| anyhow::anyhow!("Invalid service: {}", e))?;
let pref: TransportPref = serde_json::from_value(
params
.get("pref")
.cloned()
.ok_or_else(|| anyhow::anyhow!("Missing 'pref' param"))?,
)
.map_err(|e| anyhow::anyhow!("Invalid pref: {}", e))?;
set(&self.config.data_dir, service, pref).await?;
info!(service = ?service, pref = ?pref, "Transport preference updated");
let current = crate::settings::transport::snapshot().await;
Ok(serde_json::to_value(current)?)
}
/// transport.set-mode — Toggle mesh-only (off-grid) mode.
pub(super) async fn handle_transport_set_mode(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.as_ref().ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let params = params
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let mesh_only = params["mesh_only"]
.as_bool()
.ok_or_else(|| anyhow::anyhow!("Missing 'mesh_only' bool param"))?;
+160 -11
View File
@@ -6,12 +6,18 @@ impl RpcHandler {
/// Check for available system updates.
/// Tries git-based check first (if repo exists), falls back to manifest-based.
pub(super) async fn handle_update_check(&self) -> Result<serde_json::Value> {
// Try git-based check first (preferred for beta nodes)
// Manifest override: when ARCHIPELAGO_UPDATE_URL is explicitly set,
// the operator wants OTA via manifest — typically a dev box where
// ~/archy/.git exists but isn't the intended update surface.
// Without this short-circuit the dev box always advertises "Pull
// & Rebuild" and can never exercise the manifest OTA path.
let manifest_override = std::env::var("ARCHIPELAGO_UPDATE_URL").is_ok();
let repo_dir = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
)
.join("archy");
if repo_dir.join(".git").exists() {
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
)
.join("archy");
if !manifest_override && repo_dir.join(".git").exists() {
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
return Ok(git_status);
}
@@ -34,6 +40,7 @@ impl RpcHandler {
"last_check": state.last_check,
"update_available": update_info.is_some(),
"update": update_info,
"manifest_mirror": state.manifest_mirror,
}))
}
@@ -50,7 +57,10 @@ impl RpcHandler {
.context("git fetch failed")?;
if !fetch.status.success() {
anyhow::bail!("git fetch failed: {}", String::from_utf8_lossy(&fetch.stderr));
anyhow::bail!(
"git fetch failed: {}",
String::from_utf8_lossy(&fetch.stderr)
);
}
// Get local and remote HEADs
@@ -85,7 +95,13 @@ impl RpcHandler {
.unwrap_or(0);
let log = tokio::process::Command::new("git")
.args(["log", "HEAD..origin/main", "--oneline", "--no-merges", "-20"])
.args([
"log",
"HEAD..origin/main",
"--oneline",
"--no-merges",
"-20",
])
.current_dir(&repo_str)
.output()
.await?;
@@ -115,9 +131,9 @@ impl RpcHandler {
/// Apply git-based update: runs self-update.sh which pulls, builds, and restarts.
pub(super) async fn handle_update_git_apply(&self) -> Result<serde_json::Value> {
let script = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
)
.join("archy/scripts/self-update.sh");
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
)
.join("archy/scripts/self-update.sh");
if !script.exists() {
anyhow::bail!("self-update.sh not found at {}", script.display());
@@ -142,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,
@@ -149,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 },
}))
}
@@ -168,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?;
@@ -180,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?;
@@ -199,7 +345,10 @@ impl RpcHandler {
"manual" => update::UpdateSchedule::Manual,
"daily_check" => update::UpdateSchedule::DailyCheck,
"auto_apply" => update::UpdateSchedule::AutoApply,
_ => anyhow::bail!("Invalid schedule: '{}'. Use manual, daily_check, or auto_apply", schedule_str),
_ => anyhow::bail!(
"Invalid schedule: '{}'. Use manual, daily_check, or auto_apply",
schedule_str
),
};
update::set_schedule(&self.config.data_dir, schedule).await?;
+545 -10
View File
@@ -9,17 +9,99 @@ impl RpcHandler {
let status = vpn::get_status().await;
let config = vpn::load_config(&self.config.data_dir).await?;
// Check WireGuard wg0 interface for its IP
let wg_ip = match tokio::process::Command::new("ip")
.args(["-4", "addr", "show", "wg0"])
.output()
.await
{
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout).to_string();
let parsed = stdout
.lines()
.find(|l| l.contains("inet "))
.and_then(|l| l.split_whitespace().nth(1))
.map(|ip| ip.split('/').next().unwrap_or(ip).to_string());
if parsed.is_none() && !stdout.is_empty() {
tracing::debug!("wg0 exists but no inet address found");
}
// Fallback: if wg0 exists but has no server IP, read from config
parsed.or_else(|| {
// If wg0 link is up, report the static server IP
if stdout.contains("UP") || stdout.contains("POINTOPOINT") {
Some("10.44.0.1".to_string())
} else {
None
}
})
}
Err(_) => None,
};
let node_npub = vpn::read_nvpn_config_value("nostr", "public_key")
.await
.map(|k| vpn::ensure_npub(&k));
let (relay_onion, relay_direct) = vpn::get_relay_urls().await;
// Prefer onion (always works), fall back to direct IP
let relay_url = relay_onion.clone().or(relay_direct.clone());
// Standalone WireGuard public key
let wg_pubkey = tokio::fs::read_to_string("/var/lib/archipelago/wireguard/public.key")
.await
.ok()
.map(|s| s.trim().to_string());
// Check if nvpn0 tunnel interface actually exists and has an IP
let nvpn0_ip = tokio::process::Command::new("ip")
.args(["-4", "addr", "show", "nvpn0"])
.output()
.await
.ok()
.and_then(|o| {
let out = String::from_utf8_lossy(&o.stdout).to_string();
out.lines()
.find(|l| l.contains("inet "))
.and_then(|l| l.split_whitespace().nth(1))
.map(|s| s.split('/').next().unwrap_or(s).to_string())
});
// NostrVPN IP: only report if nvpn0 tunnel is actually up with its own IP,
// and that IP is distinct from the standalone WireGuard IP
let nvpn_ip = nvpn0_ip.as_ref().and_then(|ip| {
if wg_ip.as_deref() == Some(ip.as_str()) {
None
} else {
Some(ip.clone())
}
});
// NostrVPN is connected only if its dedicated tunnel (nvpn0) has a distinct IP
let nvpn_connected = status.provider.as_deref() == Some("nostr-vpn") && nvpn_ip.is_some();
// connected = NostrVPN tunnel is up OR another VPN provider is active OR standalone WireGuard is up
let is_connected = if status.provider.as_deref() == Some("nostr-vpn") {
nvpn_connected || wg_ip.is_some()
} else {
status.connected || wg_ip.is_some()
};
Ok(serde_json::json!({
"connected": status.connected,
"connected": is_connected,
"provider": status.provider,
"interface": status.interface,
"ip_address": status.ip_address,
"ip_address": nvpn_ip,
"hostname": status.hostname,
"peers_connected": status.peers_connected,
"bytes_in": status.bytes_in,
"bytes_out": status.bytes_out,
"configured": config.enabled,
"configured_provider": format!("{:?}", config.provider).to_lowercase(),
"wg_ip": wg_ip,
"wg_pubkey": wg_pubkey,
"node_npub": node_npub,
"relay_url": relay_url,
"relay_onion": relay_onion,
"relay_direct": relay_direct,
}))
}
@@ -87,13 +169,8 @@ impl RpcHandler {
None
};
let wg_config = vpn::configure_wireguard(
&self.config.data_dir,
address,
dns,
peer,
)
.await?;
let wg_config =
vpn::configure_wireguard(&self.config.data_dir, address, dns, peer).await?;
info!("WireGuard VPN configured");
Ok(serde_json::json!({
@@ -104,7 +181,10 @@ impl RpcHandler {
}))
}
_ => {
anyhow::bail!("Unknown provider: {} (expected tailscale or wireguard)", provider);
anyhow::bail!(
"Unknown provider: {} (expected tailscale or wireguard)",
provider
);
}
}
}
@@ -190,9 +270,464 @@ impl RpcHandler {
.output()
.await;
}
vpn::VpnProvider::NostrVpn => {
let _ = tokio::process::Command::new("systemctl")
.args(["stop", "nostr-vpn"])
.output()
.await;
}
}
info!("VPN disconnected");
Ok(serde_json::json!({ "disconnected": true }))
}
/// vpn.invite — Generate a NostrVPN invite URL + QR for the mobile app.
/// Optionally accepts `npub` param to add the phone as a participant in the same call.
pub(super) async fn handle_vpn_invite(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
// If an npub was provided, add it as a participant first
if let Some(ref p) = params {
if let Some(peer_npub) = p.get("npub").and_then(|v| v.as_str()) {
if !peer_npub.is_empty() {
// Reuse add-participant logic
self.handle_vpn_add_participant(Some(serde_json::json!({ "npub": peer_npub })))
.await?;
}
}
}
// Read nvpn config to build invite (convert hex to npub1 if needed)
let npub = vpn::read_nvpn_config_value("nostr", "public_key")
.await
.map(|k| vpn::ensure_npub(&k))
.ok_or_else(|| {
anyhow::anyhow!("No Nostr public key in nvpn config — VPN not configured")
})?;
// network_id is in [[networks]] array — read first entry
let network_id = vpn::read_nvpn_config_list_entry("networks", "network_id")
.await
.unwrap_or_else(|| "nostr-vpn".to_string());
// Read relays from config — filter out localhost relays (unreachable from phone)
let relays = vpn::read_nvpn_config_list("nostr", "relays").await;
let reachable: Vec<String> = relays
.iter()
.filter(|r| !r.contains("127.0.0.1") && !r.contains("localhost"))
.cloned()
.collect();
let invite_relays = if reachable.is_empty() {
vec![
"wss://relay.damus.io".to_string(),
"wss://relay.primal.net".to_string(),
]
} else {
reachable
};
// Build invite as base64-encoded JSON (nvpn v2 format, no padding)
use base64::Engine;
let invite_payload = serde_json::json!({
"v": 2,
"networkName": network_id,
"networkId": network_id,
"inviterNpub": npub,
"inviterNodeName": "archipelago",
"admins": [npub],
"participants": [npub],
"relays": invite_relays,
});
let invite_b64 = base64::engine::general_purpose::STANDARD_NO_PAD
.encode(invite_payload.to_string().as_bytes());
let invite_url = format!("nvpn://invite/{}", invite_b64);
// Generate QR code
let qr = qrcode::QrCode::new(invite_url.as_bytes())
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
let svg = qr
.render::<qrcode::render::svg::Color>()
.min_dimensions(256, 256)
.build();
Ok(serde_json::json!({
"invite_url": invite_url,
"qr_svg": svg,
"npub": npub,
"network_id": network_id,
"relays": invite_relays,
}))
}
/// vpn.add-participant — Add an npub to the mesh network.
pub(super) async fn handle_vpn_add_participant(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let npub = params
.get("npub")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'npub'"))?;
// Validate npub format
if !npub.starts_with("npub1") || npub.len() < 60 {
anyhow::bail!("Invalid npub format");
}
// Add participant by editing TOML config directly (nvpn set --participant replaces, not appends)
for config_path in vpn::NVPN_CONFIG_PATHS {
if let Ok(content) = tokio::fs::read_to_string(config_path).await {
if let Ok(mut table) = content.parse::<toml::Table>() {
if let Some(networks) = table.get_mut("networks").and_then(|v| v.as_array_mut())
{
for net in networks.iter_mut() {
if let Some(net_table) = net.as_table_mut() {
let participants = net_table
.entry("participants")
.or_insert_with(|| toml::Value::Array(vec![]));
if let Some(arr) = participants.as_array_mut() {
let npub_val = toml::Value::String(npub.to_string());
if !arr.contains(&npub_val) {
arr.push(npub_val);
}
}
}
}
}
if let Ok(new_content) = toml::to_string_pretty(&table) {
// Try direct write first; fall back to sudo cp for root-owned daemon config
if tokio::fs::write(config_path, &new_content).await.is_ok() {
info!("Added participant to {}", config_path);
} else {
// Write to temp file, then sudo cp to target
let tmp = format!("/tmp/.nvpn-config-{}", std::process::id());
if tokio::fs::write(&tmp, &new_content).await.is_ok() {
let cp = tokio::process::Command::new("sudo")
.args(["cp", &tmp, config_path])
.output()
.await;
let _ = tokio::fs::remove_file(&tmp).await;
match cp {
Ok(ref out) if out.status.success() => {
info!("Added participant to {} (via sudo)", config_path);
}
_ => {
tracing::warn!(
"Failed to write {} (even with sudo)",
config_path
);
}
}
}
}
}
}
}
}
// Restart daemon to pick up the new participant
let _ = tokio::process::Command::new("sudo")
.args(["systemctl", "restart", "nostr-vpn"])
.output()
.await;
info!("VPN participant added: {}", npub);
Ok(serde_json::json!({ "added": true, "npub": npub }))
}
/// vpn.create-peer — Generate a WireGuard peer config + QR code for mobile devices.
pub(super) async fn handle_vpn_create_peer(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or(serde_json::json!({}));
let name = params
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Mobile");
// Check that wg0 is up (standalone WireGuard)
let wg0_up = tokio::process::Command::new("ip")
.args(["link", "show", "wg0"])
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if !wg0_up {
anyhow::bail!("WireGuard (wg0) is not running. Wait for first-boot to complete.");
}
// Generate a keypair for the new peer using wg genkey/pubkey
let genkey = tokio::process::Command::new("wg")
.arg("genkey")
.output()
.await
.map_err(|e| anyhow::anyhow!("wg genkey failed: {}", e))?;
if !genkey.status.success() {
anyhow::bail!(
"wg genkey failed: {}",
String::from_utf8_lossy(&genkey.stderr)
);
}
let peer_private = String::from_utf8_lossy(&genkey.stdout).trim().to_string();
let mut pubkey_cmd = tokio::process::Command::new("wg");
pubkey_cmd.arg("pubkey");
pubkey_cmd.stdin(std::process::Stdio::piped());
pubkey_cmd.stdout(std::process::Stdio::piped());
let mut pubkey_child = pubkey_cmd
.spawn()
.map_err(|e| anyhow::anyhow!("wg pubkey spawn failed: {}", e))?;
if let Some(ref mut stdin) = pubkey_child.stdin {
use tokio::io::AsyncWriteExt;
stdin.write_all(peer_private.as_bytes()).await?;
stdin.shutdown().await?;
}
let pubkey_out = pubkey_child.wait_with_output().await?;
let peer_public = String::from_utf8_lossy(&pubkey_out.stdout)
.trim()
.to_string();
// Read server's WireGuard public key (standalone WG key, then fall back to nvpn)
let server_pubkey = if let Ok(key) =
tokio::fs::read_to_string("/var/lib/archipelago/wireguard/public.key").await
{
key.trim().to_string()
} else {
vpn::read_nvpn_config_value("node", "public_key")
.await
.ok_or_else(|| anyhow::anyhow!("Cannot read server public key"))?
};
// Detect host IP — prefer config, then nvpn, then system detection
let host_ip = if self.config.host_ip != "127.0.0.1" {
self.config.host_ip.clone()
} else {
// Fallback: get public IP via external service
tokio::process::Command::new("sh")
.arg("-c")
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| self.config.host_ip.clone())
};
let endpoint = format!("{}:51820", host_ip);
// Allocate a peer IP (simple: hash the peer name)
let peer_num = (name.bytes().map(|b| b as u32).sum::<u32>() % 253) + 2;
let peer_ip = format!("10.44.0.{}/32", peer_num);
// Build WireGuard config for the mobile device
let wg_config = format!(
"[Interface]\nPrivateKey = {}\nAddress = {}\nDNS = 1.1.1.1\n\n[Peer]\nPublicKey = {}\nEndpoint = {}\nAllowedIPs = 10.44.0.0/16\nPersistentKeepalive = 25\n",
peer_private, peer_ip, server_pubkey, endpoint
);
// Generate QR code as SVG
let qr = qrcode::QrCode::new(wg_config.as_bytes())
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
let svg = qr
.render::<qrcode::render::svg::Color>()
.min_dimensions(256, 256)
.build();
// Save peer info
let peers_dir = self.config.data_dir.join("nostr-vpn/peers");
tokio::fs::create_dir_all(&peers_dir).await.ok();
let peer_info = serde_json::json!({
"name": name,
"public_key": peer_public,
"ip": peer_ip,
"config": wg_config,
"created": chrono::Utc::now().to_rfc3339(),
});
tokio::fs::write(
peers_dir.join(format!("{}.json", name.to_lowercase().replace(' ', "-"))),
serde_json::to_string_pretty(&peer_info)?,
)
.await
.ok();
// Add this peer to the server's WireGuard interface (managed by nvpn).
// Try add-peer first; if wg0 doesn't exist, run setup then retry.
let peer_filename = format!("{}.json", name.to_lowercase().replace(' ', "-"));
let mut peer_added = false;
for attempt in 0..2 {
let add = tokio::process::Command::new("sudo")
.args(["archipelago-wg", "add-peer", &peer_public, &peer_ip])
.output()
.await;
match add {
Ok(ref out) if out.status.success() => {
peer_added = true;
break;
}
Ok(ref out) => {
let err = String::from_utf8_lossy(&out.stderr);
tracing::warn!("add-peer attempt {}: {}", attempt + 1, err);
if attempt == 0 {
// wg0 may not exist yet — try creating it
let server_privkey = vpn::read_nvpn_config_value("node", "private_key")
.await
.unwrap_or_default();
if !server_privkey.is_empty() {
let key_path = "/tmp/.wg-server-key";
tokio::fs::write(key_path, &server_privkey).await.ok();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
key_path,
std::fs::Permissions::from_mode(0o600),
)
.ok();
}
let _ = tokio::process::Command::new("sudo")
.args(["archipelago-wg", "setup", key_path])
.output()
.await;
tokio::fs::remove_file(key_path).await.ok();
}
// Brief pause before retry
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
Err(e) => {
tracing::warn!("add-peer command error: {}", e);
break;
}
}
}
if !peer_added {
let _ = tokio::fs::remove_file(peers_dir.join(&peer_filename)).await;
anyhow::bail!(
"Failed to register peer with WireGuard. Check that wg0 interface is up."
);
}
info!("VPN peer created: {} ({})", name, peer_ip);
Ok(serde_json::json!({
"name": name,
"peer_ip": peer_ip,
"config": wg_config,
"qr_svg": svg,
"public_key": peer_public,
}))
}
/// vpn.list-peers — List configured VPN peers (WireGuard + NostrVPN participants).
pub(super) async fn handle_vpn_list_peers(&self) -> Result<serde_json::Value> {
let peers_dir = self.config.data_dir.join("nostr-vpn/peers");
let mut peers = Vec::new();
// WireGuard manual peers (from JSON files)
if let Ok(mut entries) = tokio::fs::read_dir(&peers_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if entry
.path()
.extension()
.map(|e| e == "json")
.unwrap_or(false)
{
if let Ok(content) = tokio::fs::read_to_string(entry.path()).await {
if let Ok(mut peer) = serde_json::from_str::<serde_json::Value>(&content) {
peer.as_object_mut()
.map(|o| o.insert("type".to_string(), "wireguard".into()));
peers.push(peer);
}
}
}
}
}
// NostrVPN peer loading removed — standalone WireGuard only
Ok(serde_json::json!({ "peers": peers }))
}
/// vpn.peer-config — Retrieve stored config + QR for an existing peer.
pub(super) async fn handle_vpn_peer_config(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'name'"))?;
let filename = format!("{}.json", name.to_lowercase().replace(' ', "-"));
let peer_file = self.config.data_dir.join("nostr-vpn/peers").join(&filename);
let content = tokio::fs::read_to_string(&peer_file)
.await
.map_err(|_| anyhow::anyhow!("Peer '{}' not found", name))?;
let peer: serde_json::Value = serde_json::from_str(&content)?;
let config = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
anyhow::anyhow!(
"No config stored for peer '{}' — recreate the device to get a new QR code",
name
)
})?;
let qr = qrcode::QrCode::new(config.as_bytes())
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
let svg = qr
.render::<qrcode::render::svg::Color>()
.min_dimensions(256, 256)
.build();
Ok(serde_json::json!({
"name": name,
"peer_ip": peer.get("ip").and_then(|v| v.as_str()).unwrap_or(""),
"config": config,
"qr_svg": svg,
}))
}
/// vpn.remove-peer — Remove a VPN peer by name.
pub(super) async fn handle_vpn_remove_peer(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'name'"))?;
let filename = format!("{}.json", name.to_lowercase().replace(' ', "-"));
let peer_file = self.config.data_dir.join("nostr-vpn/peers").join(&filename);
// Read peer's public key before deleting, to remove from WireGuard interface
let peer_pubkey = tokio::fs::read_to_string(&peer_file)
.await
.ok()
.and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok())
.and_then(|v| {
v.get("public_key")
.and_then(|k| k.as_str())
.map(|s| s.to_string())
});
if tokio::fs::remove_file(&peer_file).await.is_ok() {
// Remove peer from WireGuard interface
if let Some(pubkey) = peer_pubkey {
let _ = tokio::process::Command::new("sudo")
.args(["archipelago-wg", "remove-peer", &pubkey])
.output()
.await;
}
info!("VPN peer removed: {}", name);
Ok(serde_json::json!({ "removed": true }))
} else {
anyhow::bail!("Peer '{}' not found", name);
}
}
}
+70 -19
View File
@@ -3,13 +3,12 @@ use crate::wallet::{ecash, profits};
use anyhow::Result;
impl RpcHandler {
pub(super) async fn handle_wallet_ecash_balance(
&self,
) -> Result<serde_json::Value> {
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
Ok(serde_json::json!({
"balance_sats": wallet.balance(),
"token_count": wallet.tokens.iter().filter(|t| !t.spent).count(),
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
"mint_url": wallet.mint_url,
}))
}
@@ -24,13 +23,41 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
if amount_sats == 0 || amount_sats > 1_000_000 {
return Err(anyhow::anyhow!("Amount must be between 1 and 1,000,000 sats"));
return Err(anyhow::anyhow!(
"Amount must be between 1 and 1,000,000 sats"
));
}
let token = ecash::mint_tokens(&self.config.data_dir, amount_sats).await?;
// Step 1: Get a mint quote (returns Lightning invoice)
let quote = ecash::mint_quote(&self.config.data_dir, amount_sats).await?;
Ok(serde_json::json!({
"token_id": token.id,
"amount_sats": token.amount_sats,
"quote_id": quote.quote,
"bolt11": quote.request,
"state": quote.state,
"amount_sats": amount_sats,
"message": "Pay the Lightning invoice, then call wallet.ecash-mint-claim with the quote_id",
}))
}
/// Claim minted tokens after paying the Lightning invoice.
pub(super) async fn handle_wallet_ecash_mint_claim(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let quote_id = params
.get("quote_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing quote_id"))?;
let amount_sats = params
.get("amount_sats")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
let minted = ecash::mint_tokens(&self.config.data_dir, quote_id, amount_sats).await?;
Ok(serde_json::json!({
"minted_sats": minted,
}))
}
@@ -39,14 +66,41 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let token_id = params
.get("token_id")
let bolt11 = params
.get("bolt11")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing token_id"))?;
.ok_or_else(|| anyhow::anyhow!("Missing bolt11 (Lightning invoice)"))?;
// Step 1: Get melt quote
let quote = ecash::melt_quote(&self.config.data_dir, bolt11).await?;
let amount = ecash::melt_tokens(&self.config.data_dir, token_id).await?;
Ok(serde_json::json!({
"melted_sats": amount,
"quote_id": quote.quote,
"amount_sats": quote.amount,
"fee_reserve_sats": quote.fee_reserve,
"total_needed_sats": quote.amount + quote.fee_reserve,
"message": "Call wallet.ecash-melt-confirm with quote_id and bolt11 to execute",
}))
}
/// Confirm and execute a melt (pay Lightning invoice with ecash).
pub(super) async fn handle_wallet_ecash_melt_confirm(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let quote_id = params
.get("quote_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing quote_id"))?;
let bolt11 = params
.get("bolt11")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
let melted = ecash::melt_tokens(&self.config.data_dir, quote_id, bolt11).await?;
Ok(serde_json::json!({
"melted_sats": melted,
}))
}
@@ -83,23 +137,20 @@ impl RpcHandler {
}))
}
pub(super) async fn handle_wallet_ecash_history(
&self,
) -> Result<serde_json::Value> {
pub(super) async fn handle_wallet_ecash_history(&self) -> Result<serde_json::Value> {
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
Ok(serde_json::json!({
"transactions": wallet.transactions,
}))
}
pub(super) async fn handle_wallet_networking_profits(
&self,
) -> Result<serde_json::Value> {
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
Ok(serde_json::json!({
"total_sats": summary.total_sats,
"content_sales_sats": summary.content_sales_sats,
"routing_fees_sats": summary.routing_fees_sats,
"streaming_revenue_sats": summary.streaming_revenue_sats,
"recent": summary.recent,
}))
}
+22 -10
View File
@@ -30,7 +30,8 @@ fn is_webhook_host_private(host: &str) -> bool {
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.octets()[0] == 100 && (64..=127).contains(&v4.octets()[1]) // CGNAT
|| v4.octets()[0] == 100 && (64..=127).contains(&v4.octets()[1])
// CGNAT
}
std::net::IpAddr::V6(v6) => {
if v6.is_loopback() || v6.is_unspecified() {
@@ -45,8 +46,8 @@ fn is_webhook_host_private(host: &str) -> bool {
}
// Unique local (fd00::/8, fc00::/7)
let segments = v6.segments();
(segments[0] & 0xfe00) == 0xfc00
|| (segments[0] & 0xffc0) == 0xfe80 // link-local
(segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80
// link-local
}
};
}
@@ -66,7 +67,8 @@ fn is_webhook_host_private(host: &str) -> bool {
let mut all_ok = true;
for (i, part) in parts.iter().enumerate() {
let val = if part.starts_with("0x") || part.starts_with("0X") {
u64::from_str_radix(part.trim_start_matches("0x").trim_start_matches("0X"), 16).ok()
u64::from_str_radix(part.trim_start_matches("0x").trim_start_matches("0X"), 16)
.ok()
} else if part.starts_with('0') && part.len() > 1 {
u64::from_str_radix(part, 8).ok()
} else {
@@ -74,12 +76,18 @@ fn is_webhook_host_private(host: &str) -> bool {
};
match val {
Some(v) if v <= 255 => octets[i] = v as u8,
_ => { all_ok = false; break; }
_ => {
all_ok = false;
break;
}
}
}
if all_ok {
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
return v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified();
}
}
}
@@ -118,8 +126,8 @@ impl RpcHandler {
anyhow::bail!("Webhook URL too long");
}
// Parse URL properly to handle edge cases (IPv6, userinfo, etc.)
let parsed = reqwest::Url::parse(url)
.map_err(|_| anyhow::anyhow!("Invalid webhook URL"))?;
let parsed =
reqwest::Url::parse(url).map_err(|_| anyhow::anyhow!("Invalid webhook URL"))?;
// Require https:// in production
if !self.config.dev_mode && parsed.scheme() != "https" {
anyhow::bail!("Webhook URL must use HTTPS in production");
@@ -152,14 +160,18 @@ impl RpcHandler {
};
}
if let Some(events) = params.get("events") {
if let Ok(parsed) = serde_json::from_value::<Vec<webhooks::WebhookEvent>>(events.clone())
if let Ok(parsed) =
serde_json::from_value::<Vec<webhooks::WebhookEvent>>(events.clone())
{
config.events = parsed;
}
}
webhooks::save_config(&self.config.data_dir, &config).await?;
info!("Webhook config updated: enabled={}, url={}", config.enabled, config.url);
info!(
"Webhook config updated: enabled={}, url={}",
config.enabled, config.url
);
Ok(serde_json::json!({
"configured": true,
+107 -104
View File
@@ -14,18 +14,14 @@ use crate::totp::TotpData;
/// - AppUser: access specific apps, no system configuration
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum UserRole {
#[default]
Admin,
Viewer,
AppUser,
}
impl Default for UserRole {
fn default() -> Self {
UserRole::Admin
}
}
impl UserRole {
/// Check if this role allows a given RPC method.
pub fn can_access(&self, method: &str) -> bool {
@@ -102,9 +98,13 @@ impl AuthManager {
if self.is_setup().await? {
return Ok(());
}
tracing::info!("[onboarding] no user found — creating default user (password: password123)");
tracing::info!(
"[onboarding] no user found — creating default user (password: password123)"
);
self.setup_user("password123").await?;
tracing::info!("[onboarding] default user created — user should change password after login");
tracing::info!(
"[onboarding] default user created — user should change password after login"
);
Ok(())
}
@@ -149,11 +149,7 @@ impl AuthManager {
// Persist to onboarding.json (works even before user/setup exists)
let onboarding_file = self.data_dir.join("onboarding.json");
let state = OnboardingState { complete: true };
fs::write(
&onboarding_file,
serde_json::to_string_pretty(&state)?,
)
.await?;
fs::write(&onboarding_file, serde_json::to_string_pretty(&state)?).await?;
// Also update user.json if it exists (keeps them in sync)
if let Some(mut user) = self.get_user().await? {
user.onboarding_complete = true;
@@ -168,11 +164,7 @@ impl AuthManager {
pub async fn reset_onboarding(&self) -> Result<()> {
let onboarding_file = self.data_dir.join("onboarding.json");
let state = OnboardingState { complete: false };
fs::write(
&onboarding_file,
serde_json::to_string_pretty(&state)?,
)
.await?;
fs::write(&onboarding_file, serde_json::to_string_pretty(&state)?).await?;
if let Some(mut user) = self.get_user().await? {
user.onboarding_complete = false;
let user_file = self.data_dir.join("user.json");
@@ -203,7 +195,11 @@ impl AuthManager {
/// Check if 2FA is enabled for the user.
pub async fn is_totp_enabled(&self) -> Result<bool> {
Ok(self.get_user().await?.map(|u| u.totp.is_some()).unwrap_or(false))
Ok(self
.get_user()
.await?
.map(|u| u.totp.is_some())
.unwrap_or(false))
}
/// Get the TOTP data (if 2FA is enabled).
@@ -213,7 +209,10 @@ impl AuthManager {
/// Save TOTP data to user.json (enable 2FA).
pub async fn save_totp(&self, totp_data: TotpData) -> Result<()> {
let mut user = self.get_user().await?.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
let mut user = self
.get_user()
.await?
.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
user.totp = Some(totp_data);
let user_file = self.data_dir.join("user.json");
fs::write(&user_file, serde_json::to_string_pretty(&user)?).await?;
@@ -222,7 +221,10 @@ impl AuthManager {
/// Remove TOTP data from user.json (disable 2FA).
pub async fn remove_totp(&self) -> Result<()> {
let mut user = self.get_user().await?.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
let mut user = self
.get_user()
.await?
.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
user.totp = None;
let user_file = self.data_dir.join("user.json");
fs::write(&user_file, serde_json::to_string_pretty(&user)?).await?;
@@ -320,6 +322,90 @@ fn validate_password_strength(password: &str) -> Result<()> {
Ok(())
}
/// Change the archipelago user's SSH/login password.
/// Uses usermod + openssl to bypass PAM (avoids "Authentication token manipulation" errors).
/// Uses absolute paths (/usr/bin/openssl, /usr/sbin/usermod) for systemd's minimal PATH.
async fn change_ssh_password(new_password: &str) -> Result<()> {
let ssh_user =
std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string());
// Generate crypt hash via openssl (SHA-512, compatible with /etc/shadow)
// Use /usr/bin/openssl - systemd services often have minimal PATH
let mut hash_child = tokio::process::Command::new("/usr/bin/openssl")
.args(["passwd", "-6", "-stdin"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to run openssl: {}. Is openssl installed?", e))?;
{
use tokio::io::AsyncWriteExt;
let mut stdin = hash_child
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to open openssl stdin"))?;
stdin.write_all(new_password.as_bytes()).await?;
stdin.flush().await?;
}
let hash_result = hash_child.wait_with_output().await?;
if !hash_result.status.success() {
let stderr = String::from_utf8_lossy(&hash_result.stderr);
anyhow::bail!("openssl passwd failed: {}", stderr);
}
let hash = String::from_utf8(hash_result.stdout)?.trim().to_string();
if hash.is_empty() {
anyhow::bail!("openssl passwd produced empty hash");
}
// usermod -p writes directly to /etc/shadow, bypassing PAM
// Use /usr/sbin/usermod - not always in systemd's PATH
let status = tokio::process::Command::new("/usr/sbin/usermod")
.args(["-p", &hash, &ssh_user])
.output()
.await?;
if !status.status.success() {
let stderr = String::from_utf8_lossy(&status.stderr);
anyhow::bail!("usermod failed: {}", stderr);
}
tracing::info!("SSH password updated for user {}", ssh_user);
Ok(())
}
/// Hash a password with Argon2id (memory-hard, GPU/ASIC resistant).
/// Uses PHC string format ($argon2id$v=19$m=65536,t=3,p=4$...) for self-describing storage.
fn argon2id_hash(password: &str) -> Result<String> {
use argon2::password_hash::SaltString;
use argon2::{Argon2, Params, PasswordHasher};
use rand::rngs::OsRng;
let salt = SaltString::generate(&mut OsRng);
let params = Params::new(65536, 3, 4, Some(32))
.map_err(|e| anyhow::anyhow!("Invalid Argon2 params: {}", e))?;
let hasher = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
let hash = hasher
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("Argon2id hash failed: {}", e))?;
Ok(hash.to_string())
}
/// Verify a password against an Argon2id PHC string hash.
fn argon2id_verify(password: &str, hash: &str) -> bool {
use argon2::password_hash::PasswordHash;
use argon2::{Argon2, PasswordVerifier};
let parsed = match PasswordHash::new(hash) {
Ok(h) => h,
Err(_) => return false,
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -399,86 +485,3 @@ mod tests {
assert!(validate_password_strength("MyPassword1234").is_err());
}
}
/// Change the archipelago user's SSH/login password.
/// Uses usermod + openssl to bypass PAM (avoids "Authentication token manipulation" errors).
/// Uses absolute paths (/usr/bin/openssl, /usr/sbin/usermod) for systemd's minimal PATH.
async fn change_ssh_password(new_password: &str) -> Result<()> {
let ssh_user = std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string());
// Generate crypt hash via openssl (SHA-512, compatible with /etc/shadow)
// Use /usr/bin/openssl - systemd services often have minimal PATH
let mut hash_child = tokio::process::Command::new("/usr/bin/openssl")
.args(["passwd", "-6", "-stdin"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to run openssl: {}. Is openssl installed?", e))?;
{
use tokio::io::AsyncWriteExt;
let mut stdin = hash_child
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to open openssl stdin"))?;
stdin.write_all(new_password.as_bytes()).await?;
stdin.flush().await?;
}
let hash_result = hash_child.wait_with_output().await?;
if !hash_result.status.success() {
let stderr = String::from_utf8_lossy(&hash_result.stderr);
anyhow::bail!("openssl passwd failed: {}", stderr);
}
let hash = String::from_utf8(hash_result.stdout)?
.trim()
.to_string();
if hash.is_empty() {
anyhow::bail!("openssl passwd produced empty hash");
}
// usermod -p writes directly to /etc/shadow, bypassing PAM
// Use /usr/sbin/usermod - not always in systemd's PATH
let status = tokio::process::Command::new("/usr/sbin/usermod")
.args(["-p", &hash, &ssh_user])
.output()
.await?;
if !status.status.success() {
let stderr = String::from_utf8_lossy(&status.stderr);
anyhow::bail!("usermod failed: {}", stderr);
}
tracing::info!("SSH password updated for user {}", ssh_user);
Ok(())
}
/// Hash a password with Argon2id (memory-hard, GPU/ASIC resistant).
/// Uses PHC string format ($argon2id$v=19$m=65536,t=3,p=4$...) for self-describing storage.
fn argon2id_hash(password: &str) -> Result<String> {
use argon2::{Argon2, Params, PasswordHasher};
use argon2::password_hash::SaltString;
use rand::rngs::OsRng;
let salt = SaltString::generate(&mut OsRng);
let params = Params::new(65536, 3, 4, Some(32))
.map_err(|e| anyhow::anyhow!("Invalid Argon2 params: {}", e))?;
let hasher = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
let hash = hasher
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("Argon2id hash failed: {}", e))?;
Ok(hash.to_string())
}
/// Verify a password against an Argon2id PHC string hash.
fn argon2id_verify(password: &str, hash: &str) -> bool {
use argon2::{Argon2, PasswordVerifier};
use argon2::password_hash::PasswordHash;
let parsed = match PasswordHash::new(hash) {
Ok(h) => h,
Err(_) => return false,
};
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
}
+203
View File
@@ -0,0 +1,203 @@
//! Deterministic default avatars derived from a Nostr/Ed25519 pubkey.
//!
//! Two flavours are generated as base64-encoded SVG data URLs so they can
//! live directly in `IdentityProfile.picture` without any blob-store round
//! trip:
//!
//! - [`identicon`] — a 5×5 symmetric grid (GitHub-style) for sub-identities.
//! - [`master_node_svg`] — a hexagonal-network motif for the primary
//! seed-derived identity (derivation index 0). Distinct at a glance from
//! the identicons so the user can tell their own node at 48 px.
//!
//! Both read the first 8 bytes of the hex pubkey, so the same key always
//! produces the same avatar — useful for reconstructing history without
//! storing the blob.
use base64::Engine;
/// Convert a byte to an HSL triple biased toward readable foregrounds on
/// dark backgrounds (saturation 6085%, lightness 5270%).
fn hue_color(seed: u8) -> String {
let hue = (seed as u16) * 360 / 256;
format!("hsl({}, 72%, 60%)", hue)
}
fn accent_color(seed: u8) -> String {
let hue = (seed as u16) * 360 / 256;
format!("hsl({}, 80%, 68%)", hue)
}
fn encode_svg(svg: &str) -> String {
let b64 = base64::engine::general_purpose::STANDARD.encode(svg.as_bytes());
format!("data:image/svg+xml;base64,{}", b64)
}
/// Parse the first 8 bytes from a hex pubkey. Returns `[0u8; 8]` if the
/// input is too short or malformed — callers get a consistent default
/// avatar rather than an error.
fn seed_bytes(pubkey_hex: &str) -> [u8; 8] {
let mut out = [0u8; 8];
let clean: String = pubkey_hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
for (i, byte) in out.iter_mut().enumerate() {
let lo = i * 2;
if clean.len() >= lo + 2 {
*byte = u8::from_str_radix(&clean[lo..lo + 2], 16).unwrap_or(0);
}
}
out
}
/// 5×5 mirrored identicon. ~700 bytes of SVG, ~1 KB as a data URL.
pub fn identicon(pubkey_hex: &str) -> String {
let bytes = seed_bytes(pubkey_hex);
let fg = hue_color(bytes[0]);
let bg = "#171a24";
// 15 bit slots (3 visible columns × 5 rows). Mirror to 5×5.
// Use bytes[1..=2] as 16 bits, drop the MSB so we get 15.
let bits = u16::from_be_bytes([bytes[1], bytes[2]]) & 0x7fff;
let mut cells = String::with_capacity(512);
let cell_px: u32 = 16;
for row in 0..5u32 {
for col in 0..5u32 {
let src_col = if col < 3 { col } else { 4 - col };
let bit_idx = row * 3 + src_col;
if (bits >> bit_idx) & 1 == 1 {
let x = col * cell_px;
let y = row * cell_px;
cells.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"/>",
x, y, cell_px, cell_px
));
}
}
}
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 80 80\" \
shape-rendering=\"crispEdges\">\
<rect width=\"80\" height=\"80\" fill=\"{bg}\"/>\
<g fill=\"{fg}\">{cells}</g>\
</svg>"
);
encode_svg(&svg)
}
/// Hex-network motif for the master (seed-index-0) identity. Central hex
/// plus six ring hexes connected by faint edges, with an accent colour
/// derived from the pubkey. Distinct silhouette from the 5×5 identicon so
/// the node identity reads differently at every size.
pub fn master_node_svg(pubkey_hex: &str) -> String {
let bytes = seed_bytes(pubkey_hex);
let accent = accent_color(bytes[0]);
let accent2 = accent_color(bytes[0].wrapping_add(64));
let pattern = bytes[3] & 0x3f; // 6 bits — one per ring hex
// Hexagon vertices (point-up) at radius 16, centred on (c, c).
let hex_path = |cx: f64, cy: f64, r: f64| -> String {
let mut pts = String::new();
for i in 0..6 {
let theta = std::f64::consts::FRAC_PI_3 * (i as f64) - std::f64::consts::FRAC_PI_2;
let x = cx + r * theta.cos();
let y = cy + r * theta.sin();
if i == 0 {
pts.push_str(&format!("M{:.2},{:.2}", x, y));
} else {
pts.push_str(&format!(" L{:.2},{:.2}", x, y));
}
}
pts.push_str(" Z");
pts
};
let c = 64.0;
let ring_r = 36.0;
// Ring centres (6 hexes at 60° intervals around centre).
let ring_centres: Vec<(f64, f64)> = (0..6)
.map(|i| {
let theta = std::f64::consts::FRAC_PI_3 * (i as f64) - std::f64::consts::FRAC_PI_2;
(c + ring_r * theta.cos(), c + ring_r * theta.sin())
})
.collect();
let mut ring_hexes = String::new();
let mut edges = String::new();
for (i, (rx, ry)) in ring_centres.iter().enumerate() {
// Alternate fill/stroke based on pattern bits so two nodes never
// share the same ring silhouette.
let filled = (pattern >> i) & 1 == 1;
let fill = if filled { &accent } else { "none" };
let stroke_w = if filled { 0.0 } else { 1.4 };
ring_hexes.push_str(&format!(
"<path d=\"{}\" fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\" opacity=\"0.92\"/>",
hex_path(*rx, *ry, 10.5),
fill,
&accent,
stroke_w
));
// Edge from centre to this ring node.
edges.push_str(&format!(
"<line x1=\"{:.2}\" y1=\"{:.2}\" x2=\"{:.2}\" y2=\"{:.2}\" \
stroke=\"{}\" stroke-width=\"1\" opacity=\"0.35\"/>",
c, c, rx, ry, &accent2
));
}
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 128 128\">\
<defs>\
<radialGradient id=\"bg\" cx=\"50%\" cy=\"50%\" r=\"60%\">\
<stop offset=\"0%\" stop-color=\"#1c2030\"/>\
<stop offset=\"100%\" stop-color=\"#0a0d16\"/>\
</radialGradient>\
</defs>\
<rect width=\"128\" height=\"128\" fill=\"url(#bg)\"/>\
{edges}\
{ring_hexes}\
<path d=\"{centre_hex}\" fill=\"{accent}\" stroke=\"#ffffff\" stroke-width=\"1.5\"/>\
</svg>",
centre_hex = hex_path(c, c, 16.0),
);
encode_svg(&svg)
}
/// Build a default [`IdentityProfile`]-shaped picture for the given
/// identity. The master (seed index 0) gets the node SVG; everyone else
/// gets the identicon.
pub fn default_picture(pubkey_hex: &str, is_master: bool) -> String {
if is_master {
master_node_svg(pubkey_hex)
} else {
identicon(pubkey_hex)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identicon_is_deterministic() {
let a = identicon("aabbccddeeff0011");
let b = identicon("aabbccddeeff0011");
assert_eq!(a, b);
assert!(a.starts_with("data:image/svg+xml;base64,"));
}
#[test]
fn master_is_distinct_from_identicon() {
let pk = "aabbccddeeff0011";
assert_ne!(identicon(pk), master_node_svg(pk));
}
#[test]
fn handles_short_or_malformed_hex() {
// Shouldn't panic, should still return a valid data URL.
let a = identicon("");
assert!(a.starts_with("data:image/svg+xml;base64,"));
let b = master_node_svg("xyz!!!");
assert!(b.starts_with("data:image/svg+xml;base64,"));
}
}
+25 -20
View File
@@ -109,8 +109,8 @@ pub async fn create_full_backup(
};
let meta_path = backups_dir.join(format!("{}.meta.json", metadata.id));
let meta_json = serde_json::to_string_pretty(&metadata)
.context("Failed to serialize metadata")?;
let meta_json =
serde_json::to_string_pretty(&metadata).context("Failed to serialize metadata")?;
fs::write(&meta_path, meta_json)
.await
.context("Failed to write metadata")?;
@@ -123,11 +123,7 @@ pub async fn create_full_backup(
///
/// Uses atomic staging: extracts to a temporary directory first, validates,
/// then swaps into place with rollback on failure.
pub async fn restore_full_backup(
data_dir: &Path,
backup_id: &str,
passphrase: &str,
) -> Result<()> {
pub async fn restore_full_backup(data_dir: &Path, backup_id: &str, passphrase: &str) -> Result<()> {
let backup_path = data_dir.join("backups").join(format!("{}.bak", backup_id));
if !backup_path.exists() {
anyhow::bail!("Backup not found: {}", backup_id);
@@ -146,7 +142,11 @@ pub async fn restore_full_backup(
.await
{
if let Ok(stdout) = String::from_utf8(output.stdout) {
if let Some(avail) = stdout.lines().nth(1).and_then(|l| l.trim().parse::<u64>().ok()) {
if let Some(avail) = stdout
.lines()
.nth(1)
.and_then(|l| l.trim().parse::<u64>().ok())
{
if avail < backup_size * 2 {
anyhow::bail!(
"Insufficient disk space for restore: need {}MB, have {}MB",
@@ -173,8 +173,8 @@ pub async fn restore_full_backup(
.context("Failed to create staging directory")?;
let staging_clone = staging_dir.clone();
if let Err(e) = tokio::task::spawn_blocking(move || extract_tar_gz(&staging_clone, &tar_gz_data))
.await?
if let Err(e) =
tokio::task::spawn_blocking(move || extract_tar_gz(&staging_clone, &tar_gz_data)).await?
{
let _ = fs::remove_dir_all(&staging_dir).await;
return Err(e).context("Failed to extract backup to staging");
@@ -273,7 +273,7 @@ pub async fn list_backups(data_dir: &Path) -> Result<Vec<BackupMetadata>> {
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("json")
&& path.to_str().map_or(false, |s| s.contains(".meta."))
&& path.to_str().is_some_and(|s| s.contains(".meta."))
{
let content = match fs::read_to_string(&path).await {
Ok(c) => c,
@@ -431,11 +431,7 @@ pub async fn list_usb_drives() -> Result<Vec<UsbDrive>> {
}
/// Copy a backup file to a mounted USB drive.
pub async fn backup_to_usb(
data_dir: &Path,
backup_id: &str,
mount_point: &str,
) -> Result<PathBuf> {
pub async fn backup_to_usb(data_dir: &Path, backup_id: &str, mount_point: &str) -> Result<PathBuf> {
let src = backup_file_path(data_dir, backup_id);
if !src.exists() {
anyhow::bail!("Backup not found: {}", backup_id);
@@ -551,7 +547,10 @@ fn extract_tar_gz(data_dir: &Path, tar_gz_data: &[u8]) -> Result<()> {
for entry_result in archive.entries().context("Failed to read tar entries")? {
let mut entry = entry_result.context("Failed to read tar entry")?;
let entry_path = entry.path().context("Failed to get entry path")?.to_path_buf();
let entry_path = entry
.path()
.context("Failed to get entry path")?
.to_path_buf();
// Reject entries with path traversal components
for component in entry_path.components() {
@@ -570,7 +569,9 @@ fn extract_tar_gz(data_dir: &Path, tar_gz_data: &[u8]) -> Result<()> {
target.canonicalize()?
} else if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
parent.canonicalize()?.join(target.file_name().unwrap_or_default())
parent
.canonicalize()?
.join(target.file_name().unwrap_or_default())
} else {
target.clone()
};
@@ -720,10 +721,14 @@ mod tests {
.await
.unwrap();
let result = verify_backup(dir.path(), &meta.id, "my-pass").await.unwrap();
let result = verify_backup(dir.path(), &meta.id, "my-pass")
.await
.unwrap();
assert!(result.valid);
let bad_result = verify_backup(dir.path(), &meta.id, "wrong-pass").await.unwrap();
let bad_result = verify_backup(dir.path(), &meta.id, "wrong-pass")
.await
.unwrap();
assert!(!bad_result.valid);
}
+10 -3
View File
@@ -78,7 +78,9 @@ pub async fn restore_encrypted_backup(
.get("blob")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'blob' in backup"))?;
let blob = BASE64.decode(blob_b64).context("Invalid base64 in backup blob")?;
let blob = BASE64
.decode(blob_b64)
.context("Invalid base64 in backup blob")?;
if blob.len() < SALT_LEN + NONCE_LEN {
anyhow::bail!("Backup blob too short");
@@ -110,7 +112,9 @@ pub async fn restore_encrypted_backup(
// Write the restored key
fs::create_dir_all(identity_dir).await?;
let key_path = identity_dir.join("node_key");
fs::write(&key_path, &plaintext).await.context("Writing restored key")?;
fs::write(&key_path, &plaintext)
.await
.context("Writing restored key")?;
// Set restrictive permissions
#[cfg(unix)]
@@ -122,7 +126,10 @@ pub async fn restore_encrypted_backup(
// Derive DID and pubkey from the restored key
let signing_key = ed25519_dalek::SigningKey::from_bytes(
plaintext.as_slice().try_into().map_err(|_| anyhow::anyhow!("Invalid key"))?,
plaintext
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("Invalid key"))?,
);
let pubkey = signing_key.verifying_key();
let pubkey_hex = hex::encode(pubkey.as_bytes());
+1 -1
View File
@@ -3,7 +3,7 @@
//! - `identity`: Encrypted DID identity key backup (existing).
//! - `full`: Full system backup — identity + app data + configs + settings.
mod identity;
pub mod full;
mod identity;
pub use identity::{create_encrypted_backup, restore_encrypted_backup};
-1
View File
@@ -70,4 +70,3 @@ pub async fn bitcoin_rpc_credentials() -> (String, String) {
.await;
(RPC_USER.to_string(), pass.clone())
}
+180
View File
@@ -0,0 +1,180 @@
//! Content-addressed blob store for attachments shared over mesh/federation.
//!
//! Blobs live at `${data_dir}/blobs/<cid>` where `cid` is the hex-encoded
//! SHA-256 of the content. A sibling `<cid>.meta` file holds JSON metadata
//! (mime, filename, size, created_at). Capability URLs are HMAC-signed tokens
//! scoped to a recipient pubkey and expiry, verified before serving.
use anyhow::{anyhow, Context, Result};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use tokio::fs;
use tokio::io::AsyncWriteExt;
type HmacSha256 = Hmac<Sha256>;
/// Default capability URL validity window.
pub const DEFAULT_CAP_TTL_SECS: u64 = 7 * 24 * 60 * 60;
/// Maximum blob size accepted by the store (64 MiB). Keep attachments
/// reasonable so /var/lib/archipelago doesn't balloon unnoticed.
pub const MAX_BLOB_SIZE: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlobMeta {
pub cid: String,
pub size: u64,
pub mime: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
pub created_at: String,
/// Optional raw thumbnail bytes (small — up to ~60 bytes is LoRa-safe).
/// Stored alongside meta so ContentRef senders don't re-fetch the blob.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thumb_bytes: Option<Vec<u8>>,
/// Public blobs (profile pictures, banners) are served at `/blob/<cid>`
/// without a capability check so external Nostr clients can fetch them.
/// Missing in legacy metadata = default false (cap required).
#[serde(default)]
pub public: bool,
}
pub struct BlobStore {
root: PathBuf,
/// HMAC key used to sign capability URLs. Derived from node identity;
/// callers pass it in so we don't duplicate key management here.
cap_key: [u8; 32],
}
impl BlobStore {
/// Create (or open) a blob store rooted at `data_dir/blobs`.
pub async fn open(data_dir: &Path, cap_key: [u8; 32]) -> Result<Self> {
let root = data_dir.join("blobs");
fs::create_dir_all(&root)
.await
.context("create blobs dir")?;
Ok(Self { root, cap_key })
}
fn path_for(&self, cid: &str) -> PathBuf {
self.root.join(cid)
}
fn meta_path_for(&self, cid: &str) -> PathBuf {
self.root.join(format!("{}.meta", cid))
}
/// Write bytes to the store, returning the CID and metadata. Idempotent:
/// identical bytes produce the same CID and short-circuit re-writes.
pub async fn put(
&self,
bytes: &[u8],
mime: &str,
filename: Option<String>,
thumb_bytes: Option<Vec<u8>>,
public: bool,
) -> Result<BlobMeta> {
if bytes.len() as u64 > MAX_BLOB_SIZE {
anyhow::bail!(
"Blob too large: {} bytes (max {})",
bytes.len(),
MAX_BLOB_SIZE
);
}
let mut hasher = Sha256::new();
hasher.update(bytes);
let cid = hex::encode(hasher.finalize());
let meta = BlobMeta {
cid: cid.clone(),
size: bytes.len() as u64,
mime: mime.to_string(),
filename,
created_at: chrono::Utc::now().to_rfc3339(),
thumb_bytes,
public,
};
let blob_path = self.path_for(&cid);
if !blob_path.exists() {
let mut f = fs::File::create(&blob_path).await.context("create blob")?;
f.write_all(bytes).await.context("write blob")?;
f.sync_all().await.ok();
}
let meta_json = serde_json::to_vec(&meta)?;
fs::write(self.meta_path_for(&cid), meta_json)
.await
.context("write blob meta")?;
Ok(meta)
}
/// Read raw bytes for a CID. Errors if missing.
pub async fn get(&self, cid: &str) -> Result<Vec<u8>> {
let path = self.path_for(cid);
fs::read(&path)
.await
.with_context(|| format!("blob not found: {}", cid))
}
/// Load metadata for a CID.
pub async fn meta(&self, cid: &str) -> Result<BlobMeta> {
let raw = fs::read(self.meta_path_for(cid))
.await
.with_context(|| format!("blob meta not found: {}", cid))?;
Ok(serde_json::from_slice(&raw)?)
}
/// Check whether a CID is held locally.
pub async fn has(&self, cid: &str) -> bool {
fs::try_exists(self.path_for(cid)).await.unwrap_or(false)
}
/// Sign a capability token: HMAC-SHA256(cid || peer_pubkey || expiry).
/// Returned token is hex — callers append `?cap=<token>&exp=<epoch>` to
/// the blob URL sent to the peer.
pub fn issue_capability(&self, cid: &str, peer_pubkey_hex: &str, expiry_epoch: u64) -> String {
let mut mac = HmacSha256::new_from_slice(&self.cap_key).expect("hmac key");
mac.update(cid.as_bytes());
mac.update(b"|");
mac.update(peer_pubkey_hex.as_bytes());
mac.update(b"|");
mac.update(&expiry_epoch.to_be_bytes());
hex::encode(mac.finalize().into_bytes())
}
/// Verify a capability token against (cid, peer_pubkey, expiry).
/// Returns Ok(()) on success, Err describing the failure otherwise.
/// Expired tokens fail even with a correct signature.
pub fn verify_capability(
&self,
cid: &str,
peer_pubkey_hex: &str,
expiry_epoch: u64,
token_hex: &str,
) -> Result<()> {
let now = chrono::Utc::now().timestamp() as u64;
if expiry_epoch < now {
return Err(anyhow!("capability expired"));
}
let expected = self.issue_capability(cid, peer_pubkey_hex, expiry_epoch);
// Constant-time compare via HMAC verify.
let token_bytes =
hex::decode(token_hex).map_err(|_| anyhow!("capability token not hex"))?;
let expected_bytes = hex::decode(&expected).unwrap();
if token_bytes.len() != expected_bytes.len() {
return Err(anyhow!("capability length mismatch"));
}
// hmac::Mac::verify is the idiomatic constant-time path, but we
// already computed `expected` so fall back to ct_eq via subtle.
let mut diff = 0u8;
for (a, b) in token_bytes.iter().zip(expected_bytes.iter()) {
diff |= a ^ b;
}
if diff == 0 {
Ok(())
} else {
Err(anyhow!("capability signature mismatch"))
}
}
}
+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)
}
+116 -36
View File
@@ -82,18 +82,21 @@ impl Config {
pub async fn load() -> Result<Self> {
// Default configuration
let mut config = Self::default();
// Detect if running from macOS app bundle
if let Ok(exe_path) = std::env::current_exe() {
if let Some(exe_str) = exe_path.to_str() {
if exe_str.contains(".app/Contents/MacOS") {
// Running from macOS bundle - use user's Library directory
if let Some(home) = std::env::var_os("HOME") {
let app_support = PathBuf::from(home)
.join("Library/Application Support/Archipelago");
let app_support =
PathBuf::from(home).join("Library/Application Support/Archipelago");
config.data_dir = app_support.join("data");
config.dev_data_dir = app_support.join("data");
tracing::info!("🍎 Detected macOS bundle, using: {}", app_support.display());
tracing::info!(
"🍎 Detected macOS bundle, using: {}",
app_support.display()
);
}
}
}
@@ -102,10 +105,11 @@ impl Config {
// Try to load from config file
let config_path = Path::new("/etc/archipelago/config.toml");
if config_path.exists() {
let content = fs::read_to_string(config_path).await
let content = fs::read_to_string(config_path)
.await
.context("Failed to read config file")?;
let file_config: Config = toml::de::from_str(&content)
.context("Failed to parse config file")?;
let file_config: Config =
toml::de::from_str(&content).context("Failed to parse config file")?;
config = file_config;
}
@@ -118,7 +122,8 @@ impl Config {
let parts: Vec<&str> = bind.split(':').collect();
if parts.len() == 2 {
config.bind_host = parts[0].to_string();
config.bind_port = parts[1].parse()
config.bind_port = parts[1]
.parse()
.context("Invalid port in ARCHIPELAGO_BIND")?;
}
}
@@ -137,7 +142,8 @@ impl Config {
}
if let Ok(offset) = std::env::var("ARCHIPELAGO_PORT_OFFSET") {
config.port_offset = offset.parse()
config.port_offset = offset
.parse()
.context("Invalid port offset in ARCHIPELAGO_PORT_OFFSET")?;
}
@@ -173,12 +179,14 @@ impl Config {
}
// Ensure data directory exists
fs::create_dir_all(&config.data_dir).await
fs::create_dir_all(&config.data_dir)
.await
.context("Failed to create data directory")?;
// Ensure dev data directory exists if in dev mode
if config.dev_mode {
fs::create_dir_all(&config.dev_data_dir).await
fs::create_dir_all(&config.dev_data_dir)
.await
.context("Failed to create dev data directory")?;
}
@@ -199,7 +207,12 @@ impl Default for Config {
port_offset: 10000,
bitcoin_simulation: BitcoinSimulation::Mock,
dev_data_dir: PathBuf::from("/tmp/archipelago-dev"),
nostr_discovery_enabled: true,
// Discoverability is opt-in. Until the user explicitly enables it
// (Settings UI / `nostr_discovery_enabled = true` in config), no
// presence event is ever published and `handshake.poll` never
// contacts a relay. This is the sole knob that controls whether
// we leak our DID + npub to the public Nostr relays.
nostr_discovery_enabled: false,
nostr_relays: vec![
"wss://relay.damus.io".into(),
"wss://relay.nostr.info".into(),
@@ -223,50 +236,110 @@ mod tests {
assert_eq!(config.host_ip, "127.0.0.1");
assert!(!config.dev_mode);
assert_eq!(config.port_offset, 10000);
assert!(config.nostr_discovery_enabled);
assert!(!config.nostr_discovery_enabled);
assert_eq!(config.nostr_relays.len(), 2);
assert_eq!(config.nostr_tor_proxy, Some("127.0.0.1:9050".to_string()));
}
#[test]
fn test_container_runtime_from_str_podman() {
assert!(matches!(ContainerRuntime::from_str("podman"), ContainerRuntime::Podman));
assert!(matches!(ContainerRuntime::from_str("Podman"), ContainerRuntime::Podman));
assert!(matches!(ContainerRuntime::from_str("PODMAN"), ContainerRuntime::Podman));
assert!(matches!(
ContainerRuntime::from_str("podman"),
ContainerRuntime::Podman
));
assert!(matches!(
ContainerRuntime::from_str("Podman"),
ContainerRuntime::Podman
));
assert!(matches!(
ContainerRuntime::from_str("PODMAN"),
ContainerRuntime::Podman
));
}
#[test]
fn test_container_runtime_from_str_docker() {
assert!(matches!(ContainerRuntime::from_str("docker"), ContainerRuntime::Docker));
assert!(matches!(ContainerRuntime::from_str("Docker"), ContainerRuntime::Docker));
assert!(matches!(ContainerRuntime::from_str("DOCKER"), ContainerRuntime::Docker));
assert!(matches!(
ContainerRuntime::from_str("docker"),
ContainerRuntime::Docker
));
assert!(matches!(
ContainerRuntime::from_str("Docker"),
ContainerRuntime::Docker
));
assert!(matches!(
ContainerRuntime::from_str("DOCKER"),
ContainerRuntime::Docker
));
}
#[test]
fn test_container_runtime_from_str_auto() {
assert!(matches!(ContainerRuntime::from_str("auto"), ContainerRuntime::Auto));
assert!(matches!(ContainerRuntime::from_str("Auto"), ContainerRuntime::Auto));
assert!(matches!(
ContainerRuntime::from_str("auto"),
ContainerRuntime::Auto
));
assert!(matches!(
ContainerRuntime::from_str("Auto"),
ContainerRuntime::Auto
));
// Unknown strings default to Auto
assert!(matches!(ContainerRuntime::from_str("unknown"), ContainerRuntime::Auto));
assert!(matches!(ContainerRuntime::from_str(""), ContainerRuntime::Auto));
assert!(matches!(
ContainerRuntime::from_str("unknown"),
ContainerRuntime::Auto
));
assert!(matches!(
ContainerRuntime::from_str(""),
ContainerRuntime::Auto
));
}
#[test]
fn test_bitcoin_simulation_from_str() {
assert!(matches!(BitcoinSimulation::from_str("mock"), BitcoinSimulation::Mock));
assert!(matches!(BitcoinSimulation::from_str("Mock"), BitcoinSimulation::Mock));
assert!(matches!(BitcoinSimulation::from_str("testnet"), BitcoinSimulation::Testnet));
assert!(matches!(BitcoinSimulation::from_str("Testnet"), BitcoinSimulation::Testnet));
assert!(matches!(BitcoinSimulation::from_str("mainnet"), BitcoinSimulation::Mainnet));
assert!(matches!(BitcoinSimulation::from_str("Mainnet"), BitcoinSimulation::Mainnet));
assert!(matches!(BitcoinSimulation::from_str("none"), BitcoinSimulation::None));
assert!(matches!(
BitcoinSimulation::from_str("mock"),
BitcoinSimulation::Mock
));
assert!(matches!(
BitcoinSimulation::from_str("Mock"),
BitcoinSimulation::Mock
));
assert!(matches!(
BitcoinSimulation::from_str("testnet"),
BitcoinSimulation::Testnet
));
assert!(matches!(
BitcoinSimulation::from_str("Testnet"),
BitcoinSimulation::Testnet
));
assert!(matches!(
BitcoinSimulation::from_str("mainnet"),
BitcoinSimulation::Mainnet
));
assert!(matches!(
BitcoinSimulation::from_str("Mainnet"),
BitcoinSimulation::Mainnet
));
assert!(matches!(
BitcoinSimulation::from_str("none"),
BitcoinSimulation::None
));
}
#[test]
fn test_bitcoin_simulation_unknown_defaults_to_none() {
assert!(matches!(BitcoinSimulation::from_str(""), BitcoinSimulation::None));
assert!(matches!(BitcoinSimulation::from_str("signet"), BitcoinSimulation::None));
assert!(matches!(BitcoinSimulation::from_str("garbage"), BitcoinSimulation::None));
assert!(matches!(
BitcoinSimulation::from_str(""),
BitcoinSimulation::None
));
assert!(matches!(
BitcoinSimulation::from_str("signet"),
BitcoinSimulation::None
));
assert!(matches!(
BitcoinSimulation::from_str("garbage"),
BitcoinSimulation::None
));
}
#[test]
@@ -280,7 +353,10 @@ mod tests {
assert_eq!(deserialized.log_level, config.log_level);
assert_eq!(deserialized.dev_mode, config.dev_mode);
assert_eq!(deserialized.port_offset, config.port_offset);
assert_eq!(deserialized.nostr_discovery_enabled, config.nostr_discovery_enabled);
assert_eq!(
deserialized.nostr_discovery_enabled,
config.nostr_discovery_enabled
);
assert_eq!(deserialized.nostr_relays, config.nostr_relays);
}
@@ -333,9 +409,13 @@ mod tests {
}
#[test]
fn test_config_nostr_discovery_enabled_by_default() {
fn test_config_nostr_discovery_disabled_by_default() {
// Discoverability is opt-in: nothing is published to public relays
// until the user explicitly turns it on. Flipping this back to
// `true` would silently start leaking the local DID + npub on every
// boot — guard rail.
let config = Config::default();
assert!(config.nostr_discovery_enabled);
assert!(!config.nostr_discovery_enabled);
assert!(config.nostr_tor_proxy.is_some());
}
+2 -3
View File
@@ -1,5 +1,5 @@
/// Centralized constants for the Archipelago backend.
/// Avoids hardcoded values scattered across the codebase.
//! Centralized constants for the Archipelago backend.
//! Avoids hardcoded values scattered across the codebase.
/// Bitcoin Core RPC endpoint (localhost only).
pub const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";
@@ -9,4 +9,3 @@ pub const DWN_HEALTH_URL: &str = "http://127.0.0.1:3100/health";
/// Tor SOCKS5 proxy for outbound onion connections.
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
@@ -34,7 +34,7 @@ impl DevDataManager {
// Map production path to dev path
// e.g., /var/lib/archipelago/bitcoin -> /tmp/archipelago-dev/bitcoin
let app_dir = self.get_app_data_dir(app_id);
// Extract the relative path from the production path
if let Some(relative) = volume_source.strip_prefix("/var/lib/archipelago/") {
app_dir.join(relative)
@@ -74,10 +74,10 @@ mod tests {
async fn test_map_volume_path() {
let temp_dir = std::env::temp_dir().join("test-archipelago");
let manager = DevDataManager::new(temp_dir.clone());
let dev_path = manager.map_volume_path("bitcoin-core", "/var/lib/archipelago/bitcoin");
assert!(dev_path.to_string_lossy().contains("bitcoin-core"));
// Cleanup
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
}
@@ -89,7 +89,7 @@ mod tests {
let app_dir = manager.create_app_data_dir("test-app").await.unwrap();
assert!(app_dir.exists());
// Cleanup
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
}
@@ -1,11 +1,11 @@
use archipelago_container::{
AppManifest, BitcoinSimulator, BitcoinSimulationMode, ContainerRuntime as ContainerRuntimeTrait,
ContainerStatus, PortManager,
};
use anyhow::{Context, Result};
use archipelago_container::{
AppManifest, BitcoinSimulationMode, BitcoinSimulator,
ContainerRuntime as ContainerRuntimeTrait, ContainerStatus, PortManager,
};
use std::sync::Arc;
use crate::config::{Config, ContainerRuntime, BitcoinSimulation};
use crate::config::{BitcoinSimulation, Config, ContainerRuntime};
use crate::container::data_manager::DevDataManager;
pub struct DevContainerOrchestrator {
@@ -28,26 +28,22 @@ impl DevContainerOrchestrator {
ContainerRuntime::Docker => {
Arc::new(archipelago_container::DockerRuntime::new(user.clone()))
}
ContainerRuntime::Auto => {
Arc::new(
archipelago_container::AutoRuntime::new(user.clone())
.await
.context("Failed to create auto runtime")?,
)
}
ContainerRuntime::Auto => Arc::new(
archipelago_container::AutoRuntime::new(user.clone())
.await
.context("Failed to create auto runtime")?,
),
};
let port_manager = Arc::new(PortManager::new(config.port_offset));
let bitcoin_simulator = Arc::new(BitcoinSimulator::new(
BitcoinSimulationMode::from(
match &config.bitcoin_simulation {
BitcoinSimulation::Mock => "mock",
BitcoinSimulation::Testnet => "testnet",
BitcoinSimulation::Mainnet => "mainnet",
BitcoinSimulation::None => "none",
}
),
));
let bitcoin_simulator = Arc::new(BitcoinSimulator::new(BitcoinSimulationMode::from(
match &config.bitcoin_simulation {
BitcoinSimulation::Mock => "mock",
BitcoinSimulation::Testnet => "testnet",
BitcoinSimulation::Mainnet => "mainnet",
BitcoinSimulation::None => "none",
},
)));
let data_manager = Arc::new(DevDataManager::new(config.dev_data_dir.clone()));
Ok(Self {
@@ -77,13 +73,11 @@ impl DevContainerOrchestrator {
version: _,
} = dep
{
if dep_id == "bitcoin-core" {
if !self.bitcoin_simulator.is_bitcoin_available() {
return Err(anyhow::anyhow!(
"Bitcoin Core dependency not satisfied (simulation: {:?})",
self.bitcoin_simulator.mode()
));
}
if dep_id == "bitcoin-core" && !self.bitcoin_simulator.is_bitcoin_available() {
return Err(anyhow::anyhow!(
"Bitcoin Core dependency not satisfied (simulation: {:?})",
self.bitcoin_simulator.mode()
));
}
}
}
@@ -213,7 +207,8 @@ impl DevContainerOrchestrator {
if let Some(app_id) = app_id.strip_suffix("-dev") {
if let Ok(Some(ports)) = self.port_manager.get_port_mapping(app_id) {
let mut container_with_ports = container.clone();
container_with_ports.ports = ports.iter().map(|p| p.to_string()).collect();
container_with_ports.ports =
ports.iter().map(|p| p.to_string()).collect();
result.push(container_with_ports);
} else {
result.push(container);
@@ -2,11 +2,14 @@
// Scans docker-compose containers and converts them to package data
use anyhow::Result;
use archipelago_container::{ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient};
use archipelago_container::{
ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient,
};
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{debug, info};
use super::image_versions;
use crate::data_model::{
Description, InstalledPackageDataEntry, InterfaceAddress, Interfaces, MainInterface, Manifest,
PackageDataEntry, PackageState, ServiceStatus, StaticFiles,
@@ -30,11 +33,11 @@ impl DockerPackageScanner {
return Ok(HashMap::new());
}
};
debug!("Found {} containers", containers.len());
let mut packages = HashMap::new();
// Backend services that should not appear as apps
let excluded_services = [
"btcpay-db",
@@ -64,13 +67,16 @@ impl DockerPackageScanner {
"indeedhub-build_relay_1",
"indeedhub-build_ffmpeg-worker_1",
];
// First pass: collect UI containers
let mut ui_containers: HashMap<String, String> = HashMap::new();
for container in &containers {
if container.name.ends_with("-ui") {
// Map fedimint-ui -> fedimint, lnd-ui -> lnd (normalize archy- prefix for lookup)
let parent_app = container.name.strip_suffix("-ui").unwrap_or(&container.name);
let parent_app = container
.name
.strip_suffix("-ui")
.unwrap_or(&container.name);
let canonical_id = parent_app
.strip_prefix("archy-")
.unwrap_or(parent_app)
@@ -82,14 +88,16 @@ impl DockerPackageScanner {
}
}
}
debug!("Found {} UI containers", ui_containers.len());
for container in containers {
// Extract app ID from container name
// Support both archy-* containers (docker-compose) and plain names (manual)
let app_id = if container.name.starts_with("archy-") {
container.name.strip_prefix("archy-")
container
.name
.strip_prefix("archy-")
.unwrap_or(&container.name)
.to_string()
} else {
@@ -102,7 +110,7 @@ impl DockerPackageScanner {
"immich_server" => "immich".to_string(),
_ => app_id,
};
// Skip backend services (databases, APIs, etc.)
if excluded_services.contains(&app_id.as_str()) {
debug!("Skipping backend service: {}", app_id);
@@ -121,10 +129,10 @@ impl DockerPackageScanner {
debug!("Skipping UI container: {}", app_id);
continue;
}
// Get metadata for this app
let metadata = get_app_metadata(&app_id);
// Resolve UI address: separate UI containers > static map > dynamic ports
let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) {
// Apps with separate UI containers (e.g. archy-bitcoin-ui, archy-lnd-ui)
@@ -141,18 +149,44 @@ impl DockerPackageScanner {
extract_lan_address(&container.ports)
.or_else(|| PodmanClient::lan_address_for(&app_id))
};
debug!("Container {}: ports={:?}, lan_address={:?}", app_id, container.ports, lan_address);
debug!(
"Container {}: ports={:?}, lan_address={:?}",
app_id, container.ports, lan_address
);
// Convert container state to package/service state
let (package_state, service_status) = convert_state(&container.state);
let tor_address = read_tor_address(&app_id).await;
// Extract actual version from container image tag
let running_version = image_versions::extract_version_from_image(&container.image);
// Check for available update by comparing running image vs pinned image
let available_update =
image_versions::pinned_image_for_app(&app_id).and_then(|pinned| {
if pinned != container.image {
let pinned_version = image_versions::extract_version_from_image(&pinned);
// Don't flag if both are "latest" — no meaningful diff
if pinned_version != "latest" || running_version != "latest" {
Some(pinned_version)
} else {
None
}
} else {
None
}
});
let package = PackageDataEntry {
state: package_state.clone(),
health: container.health.clone(),
exit_code: if package_state == PackageState::Exited { container.exit_code } else { None },
exit_code: if package_state == PackageState::Exited {
container.exit_code
} else {
None
},
static_files: StaticFiles {
license: "MIT".to_string(),
instructions: metadata.description.clone(),
@@ -161,7 +195,7 @@ impl DockerPackageScanner {
manifest: Manifest {
id: app_id.clone(),
title: metadata.title.clone(),
version: "1.0.0".to_string(),
version: running_version,
description: Description {
short: metadata.description.clone(),
long: metadata.description.clone(),
@@ -188,6 +222,7 @@ impl DockerPackageScanner {
None
},
},
available_update,
installed: Some(InstalledPackageDataEntry {
current_dependents: HashMap::new(),
current_dependencies: HashMap::new(),
@@ -202,7 +237,7 @@ impl DockerPackageScanner {
"main".to_string(),
InterfaceAddress {
tor_address: tor,
lan_address: lan_address,
lan_address,
},
);
addresses
@@ -212,12 +247,17 @@ impl DockerPackageScanner {
status: service_status,
}),
install_progress: None,
uninstall_stage: None,
};
packages.insert(app_id.clone(), package);
info!("Detected container: {} ({})", metadata.title, package_state_str(&package_state));
info!(
"Detected container: {} ({})",
metadata.title,
package_state_str(&package_state)
);
}
Ok(packages)
}
}
@@ -236,7 +276,9 @@ fn get_app_tier(app_id: &str) -> &'static str {
// Core: required for basic Bitcoin node
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "core",
"lnd" => "core",
"mempool" | "mempool-web" | "mempool-api" | "electrumx" | "mempool-electrs" | "electrs" => "core",
"mempool" | "mempool-web" | "mempool-api" | "electrumx" | "mempool-electrs" | "electrs" => {
"core"
}
"btcpay" | "btcpay-server" | "btcpayserver" => "core",
"dwn" => "core",
"filebrowser" => "core",
@@ -255,9 +297,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: "",
@@ -560,7 +609,8 @@ fn is_real_onion_address(s: &str) -> bool {
/// Uses TOR_DATA_DIR env var if set, else /var/lib/archipelago/tor.
pub async fn read_tor_address(app_id: &str) -> Option<String> {
let service = tor_service_name(app_id)?;
let base = std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| "/var/lib/archipelago/tor".to_string());
let base =
std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| "/var/lib/archipelago/tor".to_string());
// Try readable hostname copy first (when system Tor owns hidden_service dirs)
let hostnames_path = std::path::Path::new(&base)
@@ -627,5 +677,6 @@ fn package_state_str(state: &PackageState) -> &str {
PackageState::RestoringBackup => "restoring-backup",
PackageState::Removing => "removing",
PackageState::BackingUp => "backing-up",
PackageState::Updating => "updating",
}
}
@@ -0,0 +1,308 @@
//! Parser for image-versions.sh — single source of truth for pinned container images.
//!
//! Reads the deployed file at /opt/archipelago/image-versions.sh (or the repo-local
//! scripts/image-versions.sh as fallback) and exposes lookup functions so the container
//! scanner can compare running images against pinned targets.
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use std::time::SystemTime;
use tracing::debug;
/// Cached parse result, invalidated when file mtime changes.
static CACHE: Mutex<Option<CacheEntry>> = Mutex::new(None);
struct CacheEntry {
mtime: SystemTime,
images: HashMap<String, String>,
}
/// File search order — production path first, then repo-local for dev.
const PATHS: &[&str] = &[
"/opt/archipelago/image-versions.sh",
"scripts/image-versions.sh",
];
/// Parse image-versions.sh and return map of variable names to full image refs.
/// Result is cached and only re-parsed when the file's mtime changes.
fn load_image_versions() -> HashMap<String, String> {
let (path, mtime) = match find_file() {
Some(v) => v,
None => {
debug!("image-versions.sh not found in any search path");
return HashMap::new();
}
};
// Check cache
{
let cache = CACHE.lock().unwrap();
if let Some(ref entry) = *cache {
if entry.mtime == mtime {
return entry.images.clone();
}
}
}
// Parse fresh
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
debug!("Failed to read {}: {}", path, e);
return HashMap::new();
}
};
let images = parse_image_versions(&content);
debug!("Parsed {} image versions from {}", images.len(), path);
// Update cache
{
let mut cache = CACHE.lock().unwrap();
*cache = Some(CacheEntry {
mtime,
images: images.clone(),
});
}
images
}
fn find_file() -> Option<(String, SystemTime)> {
for p in PATHS {
let path = Path::new(p);
if let Ok(meta) = path.metadata() {
if let Ok(mtime) = meta.modified() {
return Some((p.to_string(), mtime));
}
}
}
None
}
/// Parse shell variable assignments, expanding $ARCHY_REGISTRY.
fn parse_image_versions(content: &str) -> HashMap<String, String> {
let mut vars = HashMap::new();
let mut registry = String::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
// Match VAR="value" or VAR=value
if let Some((key, val)) = parse_assignment(line) {
let expanded = val.replace("$ARCHY_REGISTRY", &registry);
if key == "ARCHY_REGISTRY" {
registry = expanded.clone();
}
vars.insert(key.to_string(), expanded);
}
}
// Keep only *_IMAGE entries
vars.retain(|k, _| k.ends_with("_IMAGE"));
vars
}
fn parse_assignment(line: &str) -> Option<(&str, &str)> {
let eq = line.find('=')?;
let key = &line[..eq];
// Validate key is a shell variable name
if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return None;
}
let val = &line[eq + 1..];
// Strip surrounding quotes
let val = val
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.unwrap_or(val);
Some((key, val))
}
/// Map app ID (as seen by the container scanner) to image variable name.
fn image_var_for_app(app_id: &str) -> Option<&'static str> {
match app_id {
// Bitcoin stack
"bitcoin-knots" | "bitcoin" | "bitcoin-core" => Some("BITCOIN_KNOTS_IMAGE"),
"lnd" => Some("LND_IMAGE"),
"electrumx" => Some("ELECTRUMX_IMAGE"),
"electrs" | "mempool-electrs" => Some("ELECTRUMX_IMAGE"),
// Mempool stack (primary = web)
"mempool" | "mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
// BTCPay stack (primary = server)
"btcpay" | "btcpay-server" | "btcpayserver" => Some("BTCPAY_IMAGE"),
// Apps
"homeassistant" | "home-assistant" => Some("HOMEASSISTANT_IMAGE"),
"grafana" => Some("GRAFANA_IMAGE"),
"uptime-kuma" => Some("UPTIME_KUMA_IMAGE"),
"jellyfin" => Some("JELLYFIN_IMAGE"),
"photoprism" => Some("PHOTOPRISM_IMAGE"),
"ollama" => Some("OLLAMA_IMAGE"),
"vaultwarden" => Some("VAULTWARDEN_IMAGE"),
"nextcloud" => Some("NEXTCLOUD_IMAGE"),
"searxng" => Some("SEARXNG_IMAGE"),
"cryptpad" => Some("CRYPTPAD_IMAGE"),
"filebrowser" => Some("FILEBROWSER_IMAGE"),
"nginx-proxy-manager" => Some("NPM_IMAGE"),
"portainer" => Some("PORTAINER_IMAGE"),
"tailscale" => Some("TAILSCALE_IMAGE"),
// Fedimint
"fedimint" | "fedimintd" => Some("FEDIMINT_IMAGE"),
"fedimint-gateway" => Some("FEDIMINT_GATEWAY_IMAGE"),
// Nostr / VPN
"nostr-rs-relay" => Some("NOSTR_RS_RELAY_IMAGE"),
"nostr-vpn" => Some("NOSTR_VPN_IMAGE"),
"fips" => Some("FIPS_IMAGE"),
// Immich (primary = server)
"immich" | "immich_server" => Some("IMMICH_SERVER_IMAGE"),
// Penpot (primary = frontend)
"penpot" | "penpot-frontend" => Some("PENPOT_FRONTEND_IMAGE"),
// DWN
"dwn" => Some("DWN_SERVER_IMAGE"),
// AI
"routstr" => Some("ROUTSTR_IMAGE"),
// Networking
"adguardhome" => Some("ADGUARDHOME_IMAGE"),
"tor" | "archy-tor" => Some("ALPINE_TOR_IMAGE"),
_ => None,
}
}
/// Get the full pinned image reference for an app ID.
pub fn pinned_image_for_app(app_id: &str) -> Option<String> {
let var = image_var_for_app(app_id)?;
let images = load_image_versions();
images.get(var).cloned()
}
/// Extract version tag from a full image reference.
/// e.g. "git.tx1138.com/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta"
/// Returns "latest" if no tag or tag is empty.
pub fn extract_version_from_image(image: &str) -> String {
// Split off the tag after the last colon, but only if it comes after the last slash
// (to avoid splitting on registry port like "registry.example.com:3000")
if let Some(slash_pos) = image.rfind('/') {
let after_slash = &image[slash_pos..];
if let Some(colon_pos) = after_slash.rfind(':') {
let tag = &after_slash[colon_pos + 1..];
if !tag.is_empty() {
return tag.to_string();
}
}
}
"latest".to_string()
}
/// Container names and their image variable names for multi-container stacks.
/// Returns empty vec for single-container apps.
pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
match app_id {
"mempool" | "mempool-web" => vec![
("archy-mempool-db", "MARIADB_IMAGE"),
("mempool-api", "MEMPOOL_BACKEND_IMAGE"),
("archy-mempool-web", "MEMPOOL_WEB_IMAGE"),
],
"btcpay" | "btcpay-server" | "btcpayserver" => vec![
("archy-btcpay-db", "BTCPAY_POSTGRES_IMAGE"),
("archy-nbxplorer", "NBXPLORER_IMAGE"),
("btcpay-server", "BTCPAY_IMAGE"),
],
"immich" | "immich_server" => vec![
("immich_postgres", "IMMICH_POSTGRES_IMAGE"),
("immich_redis", "REDIS_IMAGE"),
("immich_server", "IMMICH_SERVER_IMAGE"),
],
"penpot" | "penpot-frontend" => vec![
("penpot-postgres", "PENPOT_POSTGRES_IMAGE"),
("penpot-valkey", "PENPOT_VALKEY_IMAGE"),
("penpot-backend", "PENPOT_BACKEND_IMAGE"),
("penpot-exporter", "PENPOT_EXPORTER_IMAGE"),
("penpot-frontend", "PENPOT_FRONTEND_IMAGE"),
],
_ => vec![],
}
}
/// Get all pinned images for a stack update. Returns vec of (container_name, full_image_ref).
pub fn pinned_images_for_stack(app_id: &str) -> Vec<(String, String)> {
let images = load_image_versions();
containers_for_stack(app_id)
.into_iter()
.filter_map(|(name, var)| images.get(var).map(|img| (name.to_string(), img.clone())))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_version() {
assert_eq!(
extract_version_from_image("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"),
"v0.18.4-beta"
);
assert_eq!(
extract_version_from_image("git.tx1138.com/lfg2025/grafana:10.2.0"),
"10.2.0"
);
assert_eq!(
extract_version_from_image("localhost/myapp:latest"),
"latest"
);
assert_eq!(
extract_version_from_image("git.tx1138.com/lfg2025/bitcoin-knots:latest"),
"latest"
);
}
#[test]
fn test_parse_image_versions() {
let content = r#"
ARCHY_REGISTRY="git.tx1138.com/lfg2025"
LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta"
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
# comment
NOT_AN_IMAGE="something"
"#;
let parsed = parse_image_versions(content);
assert_eq!(
parsed.get("LND_IMAGE"),
Some(&"git.tx1138.com/lfg2025/lnd:v0.18.4-beta".to_string())
);
assert_eq!(
parsed.get("GRAFANA_IMAGE"),
Some(&"git.tx1138.com/lfg2025/grafana:10.2.0".to_string())
);
assert!(!parsed.contains_key("NOT_AN_IMAGE"));
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
}
#[test]
fn test_image_var_mapping() {
assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE"));
assert_eq!(
image_var_for_app("bitcoin-knots"),
Some("BITCOIN_KNOTS_IMAGE")
);
assert_eq!(image_var_for_app("unknown-app"), None);
}
}

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