Compare commits

...
Author SHA1 Message Date
DorianandClaude Fable 5 46dd853614 fix(ui): heavier scrim over the bright web5/server backdrop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:07:30 +01:00
DorianandClaude Fable 5 977b8d06b8 fix(ui): Connect to Mesh routes to /dashboard/mesh, not the 404 /mesh
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:07:30 +01:00
DorianandClaude Fable 5 f08f34ca10 fix(ui): card actions live at the bottom on every screen size
Retire the >=1800px compact-header variant globally (Monitoring Details,
Federation Find Nodes/Fleet, Identities Create, VPN Add Device, Network
Interfaces Scan WiFi, Tor Restart/Add Service). Tor service rows get a
50/50 Delete|Rotate row underneath — Delete on the left, away from the
enable toggle, so no missclicks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:07:30 +01:00
DorianandClaude Fable 5 aaa3477d5e feat(ui): Networking Profits becomes a dashboard
Dashboard tab (default): stat tiles per earning source, 7-day earnings
LineChart (one line per source, daily buckets — earnings are sparse
events so finer buckets would draw noise), active-session count and
revenue-by-service bars from streaming.list-sessions. Configure tab
keeps the old settings screen with the intro copy boxed in a card, plus
a new TollGate WiFi entry (price per minute via openwrt.provision-
tollgate, with a set-up-gateway hint when no router is paired). Demo
mocks gain profit history entries and streaming sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:07:30 +01:00
lfg2025 90bedc2a25 Merge pull request 'feat(ui): WireGuard remote-access onboarding + Connected Nodes cleanup' (#95) from identities-mobile-polish into main
Demo images / Build & push demo images (push) Successful in 2m38s
2026-07-17 01:39:02 +00:00
DorianandClaude Fable 5 a66e4bac6d fix(ui): Connected Nodes — consistent bottom actions
Drop the 'Discover Nodes on Nostr' button and the >=1800px header
buttons; Find Nodes + Refresh are now a 50/50 bottom pair on every
screen size, with Refresh acting on the active tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:38:07 +01:00
DorianandClaude Fable 5 af2dfd0bd6 chore(ui): mirror official WireGuard Android APK for the pairing flow
com.wireguard.android 1.0.20260315 (versionCode 519) from
download.wireguard.com, signer WireGuard LLC (cert sha256 84a13fa2…),
file sha256 f92971bc804f4c448e8845ae97e426095eab06ec09a2473a3fa2cfe7288e3298.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:32:02 +01:00
DorianandClaude Fable 5 68c25534d4 feat(ui): remote-access steps in the companion pairing flow
After 'I've installed it' the modal now walks WireGuard setup before
pairing: install WireGuard (hosted APK + QR), scan the node's tunnel
config (auto-provisioned 'companion-phone' peer via the existing
vpn.create-peer/peer-config RPCs, rendered client-side), switch it on,
then pair. Real nodes pair against http://10.44.0.1 — the tunnel only
routes AllowedIPs 10.44.0.0/16, so the LAN origin is unreachable
remotely. The demo fakes the tunnel via mock RPCs and keeps its public
pairing URL. REMOTE_ACCESS_STEPS=false restores the old two-screen flow.
Phones get the config as a downloadable .conf (can't scan own screen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:32:02 +01:00
lfg2025 45c9def94a Merge pull request 'fix(ui): web5 mobile polish — identity profile cards, visibility card, demo avatars, title alignment' (#94) from identities-mobile-polish into main
Demo images / Build & push demo images (push) Successful in 2m32s
2026-07-17 01:13:56 +00:00
DorianandClaude Fable 5 299f7d8f39 fix(ui): center OpenWrt Gateway title with its back button
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:12:54 +01:00
DorianandClaude Fable 5 bb231e82b4 fix(ui): web5 cards mobile polish + demo identity avatars
- Identities render as profile cards (banner, overlapping avatar, bio)
  in narrow containers via container query; wide/desktop row untouched
- Demo identities get purpose-tinted SVG avatars (public/demo-avatars)
  in both mock identity.list/get branches
- Node Visibility: description stacks under icon+title on mobile,
  Refresh is a full-width bottom action on all sizes, discoverable
  warning line removed, and the demo no longer exposes an onion address

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:12:54 +01:00
lfg2025 e2309cc2ac Merge pull request 'fix(android): pairing QR now actually scannable (v0.4.14)' (#93) from companion-qr-scan-fix into main
Demo images / Build & push demo images (push) Successful in 2m31s
2026-07-17 01:04:08 +00:00
Dorian a48499daeb chore(android): update companion apk download 2026-07-17 02:03:27 +01:00
DorianandClaude Fable 5 6df9afe684 fix(android): pairing QR now actually scannable
Camera decode failed because every lever was at its minimum: the web
modal generated the QR at 112px (~2.5px per module before upscaling),
the analysis stream was CameraX's 640x480 default, and ZXing ran
without TRY_HARDER. Generate the QR at 512px (displayed 192px with a
real quiet zone), analyze at 1280x720, decode with TRY_HARDER plus an
inverted-luminance fallback. v0.4.14 (versionCode 18).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:03:11 +01:00
lfg2025 9d21dd5111 Merge pull request 'feat(android): companion QR pairing — scan, deep link, auto-login (v0.4.13)' (#92) from companion-qr-pairing into main
Demo images / Build & push demo images (push) Successful in 2m34s
2026-07-17 00:46:47 +00:00
Dorian ddbfaf1d00 chore(android): update companion apk download 2026-07-17 01:46:24 +01:00
archipelagoandClaude Fable 5 2e5f67a59a feat(ui): companion app banner in the App Store + APK download unblocked
Demo images / Build & push demo images (push) Has been cancelled
The App Store gains a "Your Node. In Your Pocket." hero banner for the
mobile companion app, in the same format as the featured-app banner, with
the app running on a phone mockup rising out of the right edge. Install
(or clicking anywhere on it) opens the Remote Companion download/pairing
modal — now openable on demand via a shared trigger instead of only
auto-showing once.

Also fixes the companion APK download on PWA installs: /packages/ now
bypasses the service worker's SPA fallback, which was answering the
download click with index.html instead of the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:46:08 -04:00
DorianandClaude Fable 5 51d1c89137 fix(android): center connect-screen content vertically
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:46:07 +01:00
Dorian 785fb3d2be chore(android): update companion apk download 2026-07-17 01:41:59 +01:00
DorianandClaude Fable 5 2b3a18f189 feat(android): QR pairing — scan, deep link, one-step auto-login
Companion side of docs/companion-pairing-qr.md (v1 contract):
- Connect screen lands on Scan Node's QR / Enter Manually; NESMenu gains
  an add-server-by-QR icon button
- QrScannerOverlay: CameraX + ZXing core (pinned, on-device, no ML Kit)
- ServerQrParser: archipelago://pair?v=1&url=..[&pw=..]; unknown major v
  -> 'update the app'; upsert by origin so re-scans never duplicate
- archipelago:// deep link registered (singleTask + onNewIntent) for the
  modal's 'Open in companion app' button
- WebView auto-fills and submits Login.vue's password form when a stored
  password exists — demo QR is one step to a logged-in session
- Web modal + handoff doc copy synced to the real label: Scan Node's QR
- v0.4.13 (versionCode 17)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:41:35 +01:00
archipelagoandClaude Fable 5 3028685e6b feat(ui): companion modal pairing screen — scan a QR to auto-connect the app
Demo images / Build & push demo images (push) Successful in 2m45s
The Remote Companion intro modal gains a second screen: an "I've installed
it" button next to the download button slides over to a pairing QR the
companion app scans to load the server details automatically, with a Back
button to return. The QR is an archipelago://pair deep link carrying the
server URL — the demo embeds https://demo.archipelago-foundation.org plus
the shared demo password so a scan lands in a logged-in demo session; real
nodes send only the address (kiosk advertises the mDNS .local name since
its own origin is localhost). Phones also get a tap-to-open deep-link
button since you can't scan your own screen.

App-side contract + requirements handed off in docs/companion-pairing-qr.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:07:56 -04:00
archipelagoandClaude Fable 5 1a3170f1c3 fix(ui): companion popup waits for sustained calm; connection banner stops crying wolf
Demo images / Build & push demo images (push) Successful in 2m36s
Two "unpolished" moments, worst on the demo:

- CompanionIntroOverlay gated on a point-in-time check of the reveal
  cinematic flag. On a cold cache the entrance video can start buffering
  after the 5s base delay, so the flag was still false when sampled and
  the popup cut into the cinematic anyway. It now shows only after the
  scene has been continuously calm for the full grace window.

- ConnectionBanner's flat 2.5s debounce fired on every tab-return and on
  first dashboard paint: a dead WebSocket is the NORMAL state right then
  (browsers kill background-tab sockets; first paint races the initial
  connect), and reconnects routinely exceed 2.5s over real links. Those
  moments now get a 10s runway (15s window after load/resume); genuine
  mid-session drops keep the 2.5s response. On the demo the blip banner
  is suppressed entirely — it runs against a local mock, so "Connection
  lost" there is pure noise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:42:57 -04:00
archipelagoandClaude Fable 5 547f674ac8 fix(pwa): defer update reloads until the intro cinematic finishes
Demo images / Build & push demo images (push) Successful in 3m23s
A genuine service-worker update still reloaded the page the instant the
new worker claimed it. The kiosk and the public demo replay the intro on
every boot/visit, so an update landing mid-sequence restarted the whole
cinematic — the "typing intro resets to the start after several seconds"
report. Hold the reload until the splash is complete and the
dashboard-reveal cinematic is over, then apply it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 16:48:56 -04:00
archipelagoandClaude Fable 5 92d221bbf1 fix(ui): show the controller focus ring only during actual arrow/gamepad navigation
Demo images / Build & push demo images (push) Failing after 1m42s
The orange "gamepad selection" lift+glow was plain CSS :focus styling on
[data-controller-container] — any tap on a tabindex card lit it up (worst
on mobile), and useControllerNav's autoFocusMain programmatically focused
the first card on mount and every route change, painting the ring with no
controller input at all.

Gate it behind html.controller-nav, toggled by actual input modality:
arrow-key navigation and gamepad connection turn it on, any pointerdown
turns it off. autoFocusMain now no-ops outside directional-nav mode (a
first arrow press focuses the first container instead of spatially
navigating from <body>), so pointer users never get surprise rings while
keyboard/gamepad users keep the exact same navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:38:46 -04:00
archipelagoandClaude Fable 5 e9cfc234cd fix(kiosk): flatten the onboarding/login background too + roomier renderer heap
The 454c4bb2 kiosk flattening only covered the Dashboard's background
stack, but the kiosk boots into the login/onboarding screen —
OnboardingWrapper.vue — whose scoped 3D stack (perspective +
preserve-3d bg layers + mix-blend-mode glitch overlays) has the exact
compositing pattern that fails to repaint under the kiosk's software
compositor, leaving the black body fill visible. Extend the same
html.kiosk-mode flattening to this screen via :global() rules: 2D
opacity crossfades stay, 3D transforms / blur filters / blend-mode
glitch layers go.

Also raise the kiosk chromium JS heap cap 128→256MB: the intro
cinematic (video + audio + app) inside a 128MB V8 old-space risks
renderer OOM, and a crashed kiosk tab auto-reloads — indistinguishable
from the reported "flickers and refreshes".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:38:22 -04:00
archipelagoandClaude Fable 5 06186ab30c fix(intro): restore the typing intro on fresh kiosk installs + seamless splash-to-onboarding handoff
Three regressions of the fresh-install cinematic, all in App.vue:

1. The kiosk chromium opens /kiosk (a marker route that stamps
   localStorage.kiosk and redirects to /). The deep-route splash
   suppression added in PR #91 reads window.location.pathname, so the
   kiosk boot was classified as a deep route and the typing intro →
   "Welcome Noderunner" → onboarding sequence never played. Normalize
   /kiosk to / for the splash decision.

2. The pre-splash onboarding-status check runs a ~30s retry ladder;
   against a still-booting backend it held the black !isReady screen the
   whole time. Bound it with a 2.5s race — unknown means "play the
   intro" (a fresh install IS the slow-backend case; onboarded nodes
   answer in milliseconds so their suppression is unaffected).

3. handleSplashComplete revealed RouterView before routing, flashing
   RootRedirect's spinner at '/' between the intro's final frame and the
   onboarding screen. Commit the destination route first, then reveal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:35:47 -04:00
archipelagoandClaude Fable 5 e5c7210663 fix(install): stall-aware, retried image pulls in the orchestrator runtime
PodmanClient::pull_image had a single attempt with a hard 600s wall
clock — on a fresh node, any image that legitimately takes >10 min to
download got killed mid-pull and failed the install. Podman keeps the
completed layers, so the user's retry started half-done and succeeded:
the exact "first install fails, second works" pattern reported for
btcpay and indeedhub (and earlier for LND, whose fix in 72d7fa07 only
covered the legacy install path — this is the same cure ported to the
orchestrator path both fresh installs and upgrades use).

A pull is now only killed when nothing is observably happening — no
stderr output AND no staged-byte growth in TMPDIR for 180s — or at a
1800s absolute ceiling, with 3 attempts and backoff on top. Dead
registries still fail fast (no bytes ever land). Failures now surface
podman's actual stderr tail instead of a bare exit status, and the
pull stages via the user's containers tmp like every other pull path
(rootless /var/tmp is read-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:02:53 -04:00
archipelagoandClaude Fable 5 500472944c fix(install): stream per-member progress for orchestrator stack installs
Orchestrator-installed stacks (indeedhub, btcpay, netbird, immich,
mempool) sat at "Preparing… 5%" for the entire install — the
install_stack_via_orchestrator path never called set_install_phase /
set_install_progress after the initial Preparing, so a 7-image,
multi-gigabyte indeedhub pull gave the user zero feedback ("just says
preparing for ages"). The legacy installers already report phases and
the frontend already interpolates X-of-N across the PullingImage band;
the new path simply never fed it.

Now: PullingImage phase up front, an X-of-N counter plus a per-member
"Installing postgres (2 of 7)…" message as each member installs, and
the legacy installers' truthful PostInstall→Done settle at the end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:02:05 -04:00
archipelagoandClaude Fable 5 a687df9bd9 feat(backup): include secrets (LND seed key) in backups + Download button
Demo images / Build & push demo images (push) Failing after 1m32s
The full backup carried identity/lnd_aezeed.enc but NOT the per-node
wallet secret that encrypts it (secrets/lnd-wallet-password), so a
restored node could never decrypt its Lightning seed. The secrets dir
now rides the backup (archive v3; the .bak itself is
passphrase-encrypted). Restore gains a staging-presence guard so
restoring an older archive without a secrets dir can no longer displace
and delete the node's live secrets — covered by two new tests.

Backups can now also be downloaded through the browser: a session-gated
GET /api/blob/backup/<id> endpoint (under the existing nginx /api/blob
prefix, so no fleet nginx changes) plus a Download button next to
Verify / USB / Restore / Delete in Settings → Backup & Restore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:49:37 -04:00
archipelagoandClaude Fable 5 0aa9463b36 feat(iso): append auto-incrementing _RCn suffix to ISO filenames
Rebuilds of the same version overwrote the previous ISO under an
identical filename, making a flashed stick indistinguishable from a
newer build. The builder now keeps a per-version RC counter (next to
the build counter) and names outputs like
archipelago-installer-1.7.101-alpha-unbundled-x86_64_RC2.iso.
Override with RC=n env when re-cutting a specific candidate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:17:11 -04:00
archipelagoandClaude Fable 5 5cb53ade66 fix(health): stop raw-probing ports of containers with own healthchecks
Demo images / Build & push demo images (push) Failing after 1m34s
The health monitor opened a bare TCP connection to every published host
port each cycle. Against TLS listeners that connect+close logs noise on
the app side — LND printed 'http: TLS handshake error … EOF' endlessly.
Containers that define a podman healthcheck (LND uses lncli getinfo)
now rely on it alone; bare containers keep the port probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:06:03 -04:00
archipelagoandClaude Fable 5 47dea8cd55 fix(lnd): make the seed-backup notification actually open the backup flow
Clicking 'Back up now' on the Lightning seed banner only navigated to
the LND app page — and the backup card there hid itself permanently if
its single status fetch failed (common right after a fresh install),
so the click appeared to do nothing.

The banner now deep-links with ?seed-backup=1, which the LND page uses
to open the reveal flow directly, and the card retries its status fetch
with backoff instead of giving up after one failed RPC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:06:03 -04:00
archipelagoandClaude Fable 5 72d7fa07ff fix(install): never fail a first-time LND install over transient conditions
Two confirmed fresh-node failure modes made 'install LND' fail on the
first try and succeed on the second:

- The dependency gate failed fast when Bitcoin was still mid-install
  (image pulling, container not created yet): DepProbe.existing only
  covered 'podman ps -a', so an in-flight dependency looked identical to
  'not installed at all'. The probe now also reports packages in
  Installing/Updating state, and the gate waits up to 30 minutes for an
  in-flight dependency install to finish (surfacing 'Waiting for X to
  finish installing…' on the card) before its normal 3-minute
  started-but-not-running window.

- Image pulls had a hard 300s per-URL wall clock, which killed
  slow-but-progressing pulls of large images (LND on fresh-node WiFi);
  the retry then succeeded off the warm layer cache. Pulls are now
  stall-aware: killed only after 3 minutes with no podman output AND no
  byte movement in the staging dir, with a 30-minute absolute ceiling.
  Dead registries still fail in ~3 minutes.

Adds dep-gate unit tests for the in-flight-install wait and the
neither-installed-nor-installing fail-fast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 08:42:06 -04:00
archipelagoandClaude Fable 5 6dcdada371 fix(iso): make baseline apps work on a fresh install with no internet
Demo images / Build & push demo images (push) Failing after 1m36s
Fresh offline installs came up without fedimint-clientd and filebrowser
(they only appeared after connecting internet). Three root causes:

- archipelago-load-images.service ran 'podman load' as root, but every
  container runs rootless as archipelago — bundled images landed in
  root's storage where the rootless runtime can't see them, so all
  container creation silently depended on registry pulls. The loader now
  loads into the archipelago user's storage (with linger + runtime-dir
  wait + system migrate).
- The unbundled ISO bundled only filebrowser.tar; fmcd (fedimint-clientd)
  is a baseline first-boot app too and is now part of the unbundled core
  bundle.
- first-boot's pull_with_fallback always hit the network; it now uses an
  already-loaded local image first and skips the pull entirely.

Also: fedimint-clientd added to the UI's hardcoded curated-app fallback
list (it was missing when all catalog fetches fail offline), plus its
INSTALLED_ALIASES entry, and the stale fmcd bundling comment in
image-versions.sh corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 07:31:28 -04:00
archipelagoandClaude Fable 5 454c4bb25c fix(kiosk): flatten background compositing so backgrounds don't go black
Demo images / Build & push demo images (push) Failing after 1m40s
On kiosk, chromium runs software-composited (--in-process-gpu or
--disable-gpu, one raster thread). The dashboard's 3D-transformed
will-change background layers and the infinite mix-blend-mode:multiply
fixed overlays routinely fail to repaint there after the first
interaction/route change, leaving the container's black fill (or a stuck
multiply layer) covering the screen.

A kiosk-mode class is now set on <html> (localStorage kiosk flag, ?kiosk
param, or the /kiosk boot path) and gates CSS overrides: background
layers become plain 2D opacity crossfades (no preserve-3d/translateZ/
will-change) and the animated multiply overlays are disabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 07:06:12 -04:00
archipelagoandClaude Fable 5 66fd121748 fix(kiosk): no reload on first service-worker claim + patient boot health check
Two causes of the fresh-install 'loads, refreshes, loads again' jank:

- The PWA autoUpdate service worker claims the page right after the very
  first load, firing controllerchange, which unconditionally reloaded the
  page. Now only a *replaced* controller (a real update) triggers reload.
- A single 2s RPC echo decided server-down on first boot, flashing the
  BootScreen and then hard-reloading seconds later. A second, 6s attempt
  now runs before concluding the backend is down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 07:05:56 -04:00
archipelagoandClaude Fable 5 fa91faa67c fix(intro): hold companion popup until dashboard reveal cinematic ends
Demo images / Build & push demo images (push) Failing after 1m42s
The remote-companion popup fired on a blind 5s timer while the
first-visit dashboard entrance cinematic runs for 8s, so on a fresh
install it appeared mid-reveal and broke the intro. The cinematic now
publishes an introCinematicPlaying flag via the loginTransition store,
and the popup waits for it to clear (plus a 2s grace) before showing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 06:57:42 -04:00
archipelagoandClaude Fable 5 e9c3311eff chore: sign v1.7.101-alpha OTA manifest
Demo images / Build & push demo images (push) Successful in 2m37s
Also folds the Cargo.lock version bump the release commit missed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:18:46 -04:00
archipelago 338ae9a6de chore: release v1.7.101-alpha 2026-07-15 11:11:08 -04:00
archipelagoandClaude Fable 5 8f397b03f9 docs(release): fold PR #91 (audible entrance, no splash hijack on deep routes) into v1.7.101-alpha notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:07:57 -04:00
lfg2025 48d5fd0045 Merge pull request 'fix(intro): reliable first-visit cinematic — audible oomph, no splash hijack on deep routes, e2e suite' (#91) from intro-reliability-video-perf into main
Demo images / Build & push demo images (push) Successful in 2m34s
2026-07-15 14:29:23 +00:00
DorianandClaude Fable 5 a44cbe478a fix(intro): audible entrance oomph, splash off deep routes, finale on intro shortcuts — with e2e cinematic suite
Three first-visit-experience bugs, all caught by the new e2e suite
(e2e/intro-experience.spec.ts — 5 tests against the demo stack on real
Chrome, instrumenting HTMLMediaElement/WebAudio/MutationObserver since
headless can't hear or see the cinematic):

- playDashboardLoadOomph was always silent: the login→dashboard route
  change closes the AudioContext (stopAllAudio), and the oomph only
  fetched the dead context. It now recreates one, riding the login
  click's sticky user activation.
- A direct /login load in the demo hijacked into the splash: App.vue
  read route.path before the router resolved the initial navigation, so
  every deep-route boot looked like a root boot. The splash decision now
  reads window.location.pathname.
- Replayed intros ran mute (enableCinematicSounds override) and the
  intro's shortcut exits skipped the dashboard finale (flag now armed on
  both demo and login exits).

Verified: 5/5 e2e, 673/673 unit, vue-tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:13:39 +01:00
archipelagoandClaude Fable 5 519b4b209a docs(release): fold PR #90 (splash reliability + lighter intro video) into v1.7.101-alpha notes
Demo images / Build & push demo images (push) Successful in 2m39s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 06:58:55 -04:00
ai 2c82b498f0 Merge pull request 'fix(intro): splash reliability on fresh visits + 3x lighter video' (#90) from intro-reliability-video-perf into main
Demo images / Build & push demo images (push) Successful in 2m33s
2026-07-15 10:54:21 +00:00
DorianandClaude Fable 5 efd1ae41de fix(intro): splash always honors App's decision + video streams 3x lighter
- SplashScreen no longer re-checks neode_intro_seen itself: it silently
  skipped the whole logo-tap/typing sequence for any browser that had
  seen the intro before, even when App.vue explicitly requested it (demo
  fresh visits, Replay Intro). App.vue is the single decision point.
- Intro video re-encoded 12.7MB/12.6Mbps → 4.3MB/4.2Mbps (1920x1080 CRF23
  faststart, muted track dropped) — visually identical on this
  illustrated content; cache-buster bumped to v=8.
- Video is <link rel=preload>ed the moment the splash is scheduled (the
  <video> element only mounts ~20s in, so cold caches used to start the
  download mid-sequence and stutter).
- nginx demo cache regex now covers mp4/webm/mp3/webp/woff2 so repeat
  visits play from browser cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:53:14 +01:00
archipelagoandClaude Fable 5 0c591a997e docs(release): fold PRs #87-#89 into the v1.7.101-alpha notes
Demo images / Build & push demo images (push) Successful in 2m34s
Login video continuity, cloud bottom-bar music player, demo intro on
every visit — regenerated the What's New block to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 06:37:17 -04:00
ai 91e1059f56 Merge pull request 'fix(ui): login video/background continuity — video until first login, rotation after' (#89) from login-bg-continuity into main
Demo images / Build & push demo images (push) Successful in 2m31s
2026-07-15 10:34:40 +00:00
DorianandClaude Fable 5 3d986b81a0 fix(ui): login keeps the intro video until the first login; rotation after
The login page sometimes swapped to a random bg-intro-N still on load,
breaking the seamless handoff from the intro video. Now: until anyone
has logged in / set a password (neode_first_login_done, stamped by every
Login success path incl. TOTP and setup), /login keeps the VIDEO
background — one continuous shot from the splash. From the second login
onward the lock screen rotates through the static backgrounds as
designed, and if the static path is ever hit pre-first-login it pins to
bg-intro.jpg (the video's end frame) instead of rotating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:34:06 +01:00
ai 2efed23afd Merge pull request 'fix(cloud): music opens the bottom-bar player, never the lightbox' (#88) from audio-bottom-bar into main
Demo images / Build & push demo images (push) Successful in 2m36s
2026-07-15 10:24:19 +00:00
DorianandClaude Fable 5 df403c5a69 fix(cloud): music opens the bottom-bar player, never the lightbox
FileCard audio clicks now emit play (global GlobalAudioPlayer bar) —
wired through the FileGrid list view, the Cloud My Files list and own
search results. Peer Files already used the bottom bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:23:57 +01:00
ai a34634e00f Merge pull request 'feat(demo): intro on every fresh visit + neutral desktop Cloud tabs' (#87) from demo-intro-every-visit into main
Demo images / Build & push demo images (push) Successful in 2m35s
2026-07-15 10:13:30 +00:00
DorianandClaude Fable 5 c13ee58edf feat(demo): typing splash + intro on every fresh visit; neutral desktop Cloud tabs
- Demo boots at '/' (first visit or a browser refresh) always start with
  the typing splash into the onboarding intro — the per-calendar-day
  gate is gone. In-session SPA navigation never replays it, and
  deep-route refreshes keep the visitor's place.
- Cloud top-level tabs return to the standard mode-switcher style on
  desktop; the orange full-width switcher stays mobile-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:12:49 +01:00
archipelagoandClaude Fable 5 6e5a99ef71 fix(tests): CloudPeersRefresh mounts Cloud with Pinia (useCloudStore in setup)
Demo images / Build & push demo images (push) Successful in 2m33s
The cloud tabs rework added useCloudStore() to Cloud.vue's setup; this
older test mounted without a Pinia instance and failed the release gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 06:05:53 -04:00
archipelagoandClaude Fable 5 749c351e5e docs(release): v1.7.101-alpha notes + What's New sync
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:59:53 -04:00
archipelagoandClaude Fable 5 dc897064cd fix(iso): derive ARCHIPELAGO_HOST_IP from the default route, not hostname -I
Demo images / Build & push demo images (push) Successful in 2m39s
The unit's ExecStartPre baked the first hostname -I token into
host-ip.env, which config.rs prefers over its own detection — once a
VPN/bridge interface existed (netbird's wg tunnel), every host_ip
consumer (install configs, VPN, mint URLs) got the tunnel IP. Read the
main-table default route's src address instead, falling back to
hostname -I on routeless hosts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:56:22 -04:00
archipelagoandClaude Fable 5 178ba85c5c fix(system): hostname rename updates /etc/hosts and re-announces mDNS
server.set-name ran hostnamectl but left Debian's 127.0.1.1 line on the
old name (breaking sudo's 'unable to resolve host' and hostname -f) and
waited on avahi to notice the change by itself. Sync /etc/hosts and
avahi-set-host-name (fallback: try-restart avahi-daemon) right after a
successful rename, so http(s)://<hostname>.local works immediately —
best-effort, never blocks the rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:56:22 -04:00
archipelagoandClaude Fable 5 0f5ea9ae15 fix(tor): resolve app ports dynamically + auto-create hidden service on install
'Add Service' only worked for the 19 apps in the hardcoded
known_service_port map — everything else failed with 'Unknown app'.
tor.create-service and tor.toggle-app now fall back to the app's live
launch address from the scanner state, so any manifest-driven app gets
a .onion without a per-app entry (the static map still wins for
protocol services like bitcoin, which must expose 8333, not a UI port).
toggle-app also no longer writes a broken port-0 HiddenServicePort for
unknown apps.

Every successful package.install now auto-creates the app's hidden
service (detached, best-effort, retries while the scanner derives the
port; skips protocol services and existing entries).

The demo mock gains stateful tor.create-service/tor.delete-service so
the demo round-trips instead of erroring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:56:22 -04:00
archipelagoandClaude Fable 5 5d4d40e9e8 style: cargo fmt the Ark wallet merge (release-gate fmt guard)
Formatting only — no logic changes to PR #78.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 05:56:22 -04:00
ai 8caa4c7d07 Merge pull request 'fix(ui): restore first-login dashboard entrance + working Replay Intro' (#86) from intro-entrance-fixes into main
Demo images / Build & push demo images (push) Successful in 2m36s
2026-07-15 09:49:14 +00:00
DorianandClaude Fable 5 4983ba4e20 fix(ui): first-login dashboard entrance restored + working Replay Intro
- playDashboardLoadOomph gated itself silent on the one entry that
  should be loud: onboarding is already flagged complete by the time the
  post-wizard dashboard mounts. force flag restores the full oomph there
  while keeping ordinary re-logins quiet (the intentional limit).
- OnboardingDone now hands Login a one-shot finale flag, so the login
  right after the wizard gets the full zoom + oomph even as a regular
  password login — which is exactly the demo flow (and TOTP logins).
- 'Replay intro' on the login screen actually replays: App.vue was
  instantly re-marking the intro as seen on onboarded nodes; an explicit
  one-shot replay flag now overrides every suppression rule (unit
  tested).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:48:39 +01:00
ai 994795e4d2 Merge pull request 'feat(cloud): tab/list feedback + rich demo peer content' (#85) from cloud-feedback-demo-content into main
Demo images / Build & push demo images (push) Successful in 2m33s
2026-07-15 09:36:33 +00:00
DorianandClaude Fable 5 4226b5ec0a feat(demo): rich peer content library with never-broken previews
24-item catalog across films/series/books/music/photography/documents/
software with full metadata; deterministic per-peer subsets so every
node shares a different mix. Posters/covers/photos are committed JPEGs
(demo/peer-media, sourced via picsum.photos); previews always serve real
image bytes, and free music/photos/documents stream the actual committed
files so playback genuinely works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:35:42 +01:00
DorianandClaude Fable 5 8f6312fe7b feat(cloud): Folders/My Files/Peer Files polish + readable file rows
- Top-level Cloud tabs get their own orange-tinted switcher (clearly
  distinct from the category pills); full-width thirds on mobile
- 'All Files' tab renamed Folders; categories only show on the file-list
  tabs (My Files / Peer Files / search)
- My Files is now a flat list of every own file rendered with FileCard —
  real actions (share/download/delete) and clicking opens the FILE
  (media lightbox incl. audio, downloads for documents), never a folder;
  own search results get the same treatment
- Folder list rows get card backgrounds (readable over the wallpaper),
  same info as before

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:35:42 +01:00
ai bdf283fcff Merge pull request 'feat(cloud): Apps-style tabs, categories and federated file search' (#84) from cloud-tabs-search into main
Demo images / Build & push demo images (push) Successful in 2m37s
2026-07-15 09:02:56 +00:00
DorianandClaude Fable 5 df90cdaac9 fix(cloud): satisfy noUncheckedIndexedAccess in categoryMeta fallback
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:02:35 +01:00
DorianandClaude Fable 5 0cf91136f9 feat(cloud): Apps-style tabs, categories and federated file search
Cloud gets the same header layout as Apps: All Files / My Files / Peer
Files tabs, Photos & Video / Music / Documents category filter, and a
search bar that queries your own FileBrowser sections (depth-limited
walk) plus every federation peer's shared catalog (content.browse-peer
fan-out, cached), with a loader while it runs. Peer Files lists the
actual files from all peers with attribution. Mobile uses the Apps
category-pill strips (which already opt out of the page-switch swipe)
with search below — no fixed top tabs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:01:36 +01:00
ai 6d7b39578e Merge pull request 'feat(wallet-ui): Ark send/receive/pay-with + mobile tx modal height' (#83) from ark-wallet-ui-demo into main
Demo images / Build & push demo images (push) Successful in 2m32s
2026-07-15 08:54:24 +00:00
DorianandClaude Fable 5 13909e28bf fix(ui): cap transactions modal at 60% of the live viewport on mobile
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:53:53 +01:00
DorianandClaude Fable 5 97488c83f7 feat(wallet-ui): Ark send/receive tabs + Ark pay-with option on peer files
Send modal gains an Ark tab (address/invoice/lightning-address via
wallet.ark-send), Receive gains an Ark address + QR tab, and the peer
file purchase confirm offers Ark alongside Cashu/Fedimint. Demo mock
exposes ark_sats in wallet.ecash-balance and deducts the chosen rail on
paid downloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:53:53 +01:00
ai eaf8b7b63e Merge pull request 'fix(demo): nginx cache regex was 404ing all /app/ shell assets' (#82) from demo-nginx-app-assets into main
Demo images / Build & push demo images (push) Successful in 2m35s
2026-07-15 08:45:44 +00:00
DorianandClaude Fable 5 46f7ac3fcf fix(demo): stop the static-cache regex from swallowing /app/ assets
nginx regex locations outrank prefix locations, so app-shell assets like
/app/bitcoin-core/tailwind.css and /app/lnd/qrcode.js hit the .css/.js
cache block and 404'd from the web root instead of proxying to the mock
backend — the actual cause of the unstyled Bitcoin UI (and the shabby
lnd-ui) on the demo. ^~ on the /app/ prefixes disables regex matching
for those paths. Config validated with nginx -t.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:44:56 +01:00
ai 76ad14ef64 Merge pull request 'fix(demo): Bitcoin UI styling + placeholder LND dashboard' (#81) from demo-ui-fixes into main
Demo images / Build & push demo images (push) Successful in 2m32s
2026-07-15 08:27:50 +00:00
DorianandClaude Fable 5 15b99a65e0 feat(demo): placeholder LND dashboard instead of the real lnd-ui shell
The official shell reads poorly inside the demo iframe; serve a
demoAppShell dashboard (balances, channels, node URI) consistent with
the /proxy/lnd/v1/* mocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:25:38 +01:00
DorianandClaude Fable 5 c49de3eb01 fix(demo): relative asset paths in bitcoin-ui/fedimint-ui shells
The shells are served at / in their containers but under /app/<id>/ in
the public demo, where absolute /tailwind.css and /assets/... 404 — the
Bitcoin UI rendered unstyled with a giant unconstrained SVG. Relative
paths resolve correctly in both contexts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:25:38 +01:00
archipelagoandClaude Fable 5 2f78fb6907 fix(ui): DNS apply no longer blanks the page + WiFi/DNS modals cover the whole app
Demo images / Build & push demo images (push) Successful in 2m39s
Applying DNS assigned the RPC response's fields into networkData without
guarding the shape; the demo mock answered {success:true} with no
servers array, dnsServers became undefined, and the dnsDisplayLabel
computed crashed the whole page render on .length. Guard the assignment,
teach the mock to round-trip DNS state per session, and Teleport the
WiFi + DNS modals to body so they overlay the full app instead of just
the right panel (position:fixed is containing-block-relative inside the
dashboard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 04:21:39 -04:00
archipelagoandClaude Fable 5 2fce4fb842 fix(network): derive the host IP from the default route, not hostname -I order
On a fresh ISO node, NetBird's own WireGuard tunnel (10.44.0.1) sorted
ahead of the real NIC in hostname -I, so the NetBird launch URL (and the
{{HOST_IP}} baked into its cert/config) pointed at the tunnel instead of
the LAN address. The main routing table's default route names the
physical uplink even while a VPN is active (NetBird/Tailscale steer
traffic via policy-routing rules, not the main table), so read src/dev
from 'ip -4 route show default' first, then fall back to a connected UDP
socket's source address, then to the old hostname -I scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 04:21:39 -04:00
ai 24be2e9e69 Merge pull request 'feat(wallet): Ark protocol support (barkd sidecar) + wallet UI' (#78) from ark-wallet-barkd into main
Demo images / Build & push demo images (push) Successful in 2m38s
2026-07-14 21:09:58 +00:00
DorianandClaude Fable 5 768c358546 feat(ui): Ark chip in transaction rail filter (shown when Ark txs exist)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:08:56 +01:00
Dorian 128c13e965 Merge remote-tracking branch 'origin/main' into ark-merge 2026-07-14 22:08:55 +01:00
DorianandClaude Fable 5 5642aae530 chore(ui): drop unused DISPLAY_MODE_KEY that broke vue-tsc build
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:57:12 +01:00
DorianandClaude Fable 5 6fa0aa46a9 feat(ui): Ark wallet tab, balances, history badge + demo mocks
Ark tab in Wallet Settings (status, balances, receive address,
board/offboard, server config via wallet.ark-*), teal Ark badge in the
transactions modal, Ark row on the home wallet card (hidden until barkd
reports a balance), official bark icon, and wallet.ark-* mocks so the
public demo renders the tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:57:12 +01:00
DorianandClaude Fable 5 bdb9826aba feat(wallet): Ark protocol support via barkd sidecar
barkd (Ark wallet daemon, pinned 0.3.0, checksum-verified release binary)
packaged as an installable app; thin HTTP bridge in wallet/ark_client.rs
mirrors the fedimint_client pattern — the bark SDK stays out of the node
binary. wallet.ark-* RPCs cover status/balance/address/send/invoice/
board/offboard/history/configure; Ark movements merge into the unified
ecash history (kind="ark") and spendable Ark sats into total_sats.
Signet defaults (Second's public Ark server) until Ark matures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:56:21 +01:00
archipelagoandClaude Fable 5 c3a4745d78 chore: sign v1.7.100-alpha OTA manifest (release-root)
Demo images / Build & push demo images (push) Successful in 2m43s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:39:40 -04:00
archipelago 93c3aad06a chore: release v1.7.100-alpha 2026-07-14 16:07:24 -04:00
archipelagoandClaude Fable 5 db4de0ac96 style: cargo fmt the workspace (release-gate cargo-fmt was red)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:38:32 -04:00
archipelagoandClaude Fable 5 412251ac9a docs(release): v1.7.100-alpha notes — fold in unshipped v1.8.00-alpha section + What's New sync
The v1.8.00-alpha changelog section never shipped as an OTA (no tag, live
manifest stayed 1.7.99-alpha); its content ships now as v1.7.100-alpha.
First 10 bullets are the OTA manifest changelog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:37:20 -04:00
archipelagoandClaude Fable 5 69dc9ee27e fix(ci): demo-images workflow pushes to the plain-HTTP demo registry
Demo images / Build & push demo images (push) Successful in 2m42s
docker login and buildkit both refused the HTTP registry. Host daemon on vps2
now lists it under insecure-registries (SIGHUP reload, no downtime); the
workflow configures buildkit with http=true for the registry host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:48:23 -04:00
archipelagoandClaude Fable 5 bf3c38c7a1 fix(demo): stop proxying /app/mempool/ to mempool.guide — placeholder never showed
Demo images / Build & push demo images (push) Failing after 29s
The mempool placeholder page (777d54ea) is served by the mock backend, but
the demo web nginx still had a location block reverse-proxying /app/mempool/
to mempool.guide, so it always won and the placeholder was unreachable.
Drop the block so mempool falls through to the generic /app/ backend proxy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:44:36 -04:00
archipelagoandClaude Fable 5 79564486d3 fix(demo): never leak the release-server IP in the public demo
Demo images / Build & push demo images (push) Failing after 5m0s
- Companion QR overlay: demo builds encode the demo's own origin
  (/packages/archipelago-companion.apk ships in the web image) instead of
  the vps2 raw URL.
- Dockerfile.web/.backend: build-time scrub replaces every remaining
  occurrence of the release-server address with registry.demo.internal and
  fails the build if any survives.
- Mock backend update-mirror list, sideload help text, and changelog prose
  no longer name the server address.
- Copy demo-images workflow into .gitea/workflows/ — Gitea ignores
  .github/workflows when .gitea/workflows exists, so the CI never ran.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:38:51 -04:00
archipelagoandClaude Fable 5 ad3dae9983 chore(demo): document DEMO_REGISTRY_HOST var in demo-images workflow
Also serves as the first push that exercises the workflow now that the
vps2 runner and registry vars/secrets are configured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:09:51 -04:00
177 changed files with 6822 additions and 1093 deletions
+74
View File
@@ -0,0 +1,74 @@
name: Demo images
# Builds and pushes the public-demo images on every change to the UI / mock
# backend, so the separated `archy-demo` Portainer stack auto-tracks the real
# code (see demo-deploy/ and docs/demo-deployment-design.md).
#
# Required repo configuration:
# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025
# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix)
# secrets.DEMO_REGISTRY_USER
# secrets.DEMO_REGISTRY_TOKEN
# Optional:
# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push
on:
push:
branches: [main]
paths:
- 'neode-ui/**'
- 'docker-compose.demo.yml'
- '.github/workflows/demo-images.yml'
workflow_dispatch:
jobs:
build:
name: Build & push demo images
runs-on: ubuntu-latest
# Skip cleanly on forks / before registry config is set.
if: ${{ vars.DEMO_REGISTRY != '' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# The demo registry is plain HTTP — teach buildkit to push without TLS
# (the host docker daemon needs it in insecure-registries for login too).
buildkitd-config-inline: |
[registry."${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}"]
http = true
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}
username: ${{ secrets.DEMO_REGISTRY_USER }}
password: ${{ secrets.DEMO_REGISTRY_TOKEN }}
- name: Build & push backend
uses: docker/build-push-action@v6
with:
context: .
file: neode-ui/Dockerfile.backend
push: true
tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }}
- name: Build & push web
uses: docker/build-push-action@v6
with:
context: .
file: neode-ui/Dockerfile.web
push: true
build-args: |
VITE_DEMO=1
tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
- name: Trigger Portainer redeploy
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
+7
View File
@@ -6,6 +6,7 @@ name: Demo images
#
# Required repo configuration:
# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025
# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix)
# secrets.DEMO_REGISTRY_USER
# secrets.DEMO_REGISTRY_TOKEN
# Optional:
@@ -32,6 +33,12 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# The demo registry is plain HTTP — teach buildkit to push without TLS
# (the host docker daemon needs it in insecure-registries for login too).
buildkitd-config-inline: |
[registry."${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}"]
http = true
- name: Log in to registry
uses: docker/login-action@v3
+8 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 16
versionName = "0.4.12"
versionCode = 18
versionName = "0.4.14"
vectorDrawables {
useSupportLibrary = true
@@ -111,6 +111,12 @@ dependencies {
// OkHttp for WebSocket (remote input)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// CameraX + ZXing (Apache-2.0, on-device, no telemetry) for pairing-QR scanning
implementation("androidx.camera:camera-camera2:1.3.4")
implementation("androidx.camera:camera-lifecycle:1.3.4")
implementation("androidx.camera:camera-view:1.3.4")
implementation("com.google.zxing:core:3.5.3")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
+12
View File
@@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Pairing-QR scanner. Camera is optional: manual entry still works without one. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<application
android:name=".ArchipelagoApp"
@@ -19,6 +22,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.Archipelago.Splash"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
@@ -26,6 +30,14 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Pairing deep link from the web UI's Companion popup:
archipelago://pair?v=1&url=...[&pw=...] (docs/companion-pairing-qr.md) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="archipelago" android:host="pair" />
</intent-filter>
</activity>
</application>
@@ -1,22 +1,41 @@
package com.archipelago.app
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.archipelago.app.ui.navigation.AppNavHost
import com.archipelago.app.ui.theme.ArchipelagoTheme
import kotlinx.coroutines.flow.MutableStateFlow
class MainActivity : ComponentActivity() {
// Pairing deep link (archipelago://pair?...) from the launch intent or a
// later one (launchMode=singleTask). Consumed by AppNavHost.
private val pendingPairUri = MutableStateFlow<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
pendingPairUri.value = intent?.dataString
setContent {
ArchipelagoTheme {
AppNavHost()
val pairUri by pendingPairUri.collectAsState()
AppNavHost(
pairUri = pairUri,
onPairUriConsumed = { pendingPairUri.value = null },
)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
pendingPairUri.value = intent.dataString
}
}
@@ -143,6 +143,39 @@ class ServerPreferences(private val context: Context) {
}
}
/**
* Add a server, or update the entry with the same connection identity
* (address/port/scheme) — used by QR pairing so re-scanning a node never
* duplicates it. A blank incoming password/name keeps the stored value
* (a real node's QR never carries the password). Returns the merged entry.
*/
suspend fun upsertServer(server: ServerEntry): ServerEntry {
var merged = server
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val existing = current.mapNotNull { ServerEntry.deserialize(it) }.firstOrNull {
it.address == server.address &&
it.port == server.port &&
it.useHttps == server.useHttps
}
if (existing != null) {
merged = server.copy(
password = server.password.ifBlank { existing.password },
name = server.name.ifBlank { existing.name },
)
}
val filtered = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
}.toSet()
prefs[savedServersKey] = filtered + merged.serialize()
}
return merged
}
suspend fun removeSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
@@ -0,0 +1,67 @@
package com.archipelago.app.data
import android.net.Uri
/**
* Result of parsing a pairing QR / deep link.
*
* UnsupportedVersion means the payload is structurally a pairing URI but its
* major version is newer than this app understands — the UI should tell the
* user to update the app rather than call the code invalid.
*/
sealed class PairResult {
data class Success(val server: ServerEntry) : PairResult()
object UnsupportedVersion : PairResult()
object Invalid : PairResult()
}
/**
* Parser for the companion pairing QR / OS deep link. Contract:
* docs/companion-pairing-qr.md (repo root).
*
* archipelago://pair?v=1&url=<percent-encoded origin>[&pw=<password>]
*
* - `url` is a full origin including scheme (http for LAN/mDNS nodes, https
* for the public demo); trailing slashes are normalized away.
* - `pw` is only ever present for the public demo.
* - Unknown extra query params are tolerated (forward compat under v=1 —
* `name` is already honored if present).
*/
object ServerQrParser {
private const val SUPPORTED_MAJOR = 1
fun parse(raw: String): PairResult {
val uri = try {
Uri.parse(raw.trim())
} catch (_: Exception) {
return PairResult.Invalid
}
if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return PairResult.Invalid
if (uri.isOpaque || !"pair".equals(uri.host, ignoreCase = true)) return PairResult.Invalid
val major = uri.getQueryParameter("v")
?.trim()
?.takeWhile { it.isDigit() }
?.toIntOrNull()
?: return PairResult.Invalid
if (major != SUPPORTED_MAJOR) return PairResult.UnsupportedVersion
val serverUrl = uri.getQueryParameter("url")?.trim()?.trimEnd('/')
if (serverUrl.isNullOrBlank()) return PairResult.Invalid
val server = Uri.parse(serverUrl)
val scheme = server.scheme?.lowercase()
if (scheme != "http" && scheme != "https") return PairResult.Invalid
val host = server.host
if (host.isNullOrBlank()) return PairResult.Invalid
return PairResult.Success(
ServerEntry(
address = host,
useHttps = scheme == "https",
port = if (server.port != -1) server.port.toString() else "",
password = uri.getQueryParameter("pw") ?: "",
name = uri.getQueryParameter("name") ?: "",
)
)
}
}
@@ -24,6 +24,9 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
@@ -36,6 +39,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -44,6 +48,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
@@ -75,6 +80,7 @@ fun NESMenu(
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)? = null,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit,
@@ -88,7 +94,7 @@ fun NESMenu(
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
}
}
}
@@ -103,6 +109,7 @@ private fun MenuPanel(
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)?,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit,
@@ -235,7 +242,30 @@ private fun MenuPanel(
}
}
} else {
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.weight(1f)) {
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
}
if (onScanQr != null) {
// Add server by scanning the node's pairing QR
Box(
Modifier
.size(ROW_H)
.clip(RoundedCornerShape(ROW_R))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.clickable { onScanQr() },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = stringResource(R.string.add_server_qr),
tint = BitcoinOrange,
modifier = Modifier.size(24.dp),
)
}
}
}
}
Spacer(Modifier.height(2.dp))
@@ -0,0 +1,316 @@
package com.archipelago.app.ui.components
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.archipelago.app.R
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.ui.screens.GlassButton
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.NotFoundException
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import kotlinx.coroutines.delay
import java.util.concurrent.Executors
/**
* Full-screen camera overlay that scans the node pairing QR
* (docs/companion-pairing-qr.md) and reports the decoded server entry.
* Handles the camera permission itself; foreign/invalid codes show a hint
* and scanning continues.
*/
@Composable
fun QrScannerOverlay(
visible: Boolean,
onDismiss: () -> Unit,
onServerScanned: (ServerEntry) -> Unit,
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
var hintRes by remember { mutableStateOf<Int?>(null) }
var handled by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
LaunchedEffect(visible) {
if (visible) {
handled = false
hintRes = null
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
hasPermission = granted
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
// Foreign-code hint fades after a moment so scanning feels live again.
LaunchedEffect(hintRes) {
if (hintRes != null) {
delay(2500)
hintRes = null
}
}
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
BackHandler { onDismiss() }
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
) {
if (hasPermission) {
CameraQrPreview(
onDecoded = { text ->
if (!handled) {
when (val result = ServerQrParser.parse(text)) {
is PairResult.Success -> {
handled = true
onServerScanned(result.server)
}
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
}
}
},
)
// Aim frame
Box(
Modifier
.align(Alignment.Center)
.size(260.dp)
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
)
} else {
Column(
Modifier
.align(Alignment.Center)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.camera_permission_needed),
color = TextPrimary,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
GlassButton(
text = stringResource(R.string.grant_camera_access),
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(56.dp),
)
}
}
// Top bar: title + close
Row(
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.scan_node_qr),
color = TextPrimary,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 12.dp),
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary)
}
}
// Bottom hints
Column(
Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 32.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
hintRes?.let { res ->
Text(
text = stringResource(res),
color = BitcoinOrange,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
}
if (hasPermission) {
Text(
text = stringResource(R.string.scan_qr_hint),
color = TextMuted,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
}
}
}
@Composable
private fun CameraQrPreview(onDecoded: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnDecoded by rememberUpdatedState(onDecoded)
val previewView = remember {
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
}
DisposableEffect(Unit) {
val analysisExecutor = Executors.newSingleThreadExecutor()
val mainExecutor = ContextCompat.getMainExecutor(context)
val providerFuture = ProcessCameraProvider.getInstance(context)
var provider: ProcessCameraProvider? = null
providerFuture.addListener({
val p = providerFuture.get()
provider = p
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// CameraX's analysis default is 640x480 — too few pixels per module
// to decode a modal-sized QR at arm's length. 1280x720 more than
// doubles the pixel density at negligible analysis cost.
@Suppress("DEPRECATION")
val analysis = ImageAnalysis.Builder()
.setTargetResolution(android.util.Size(1280, 720))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(
analysisExecutor,
QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } },
)
}
try {
p.unbindAll()
p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
} catch (_: Exception) {
// Camera unavailable — the user can dismiss and enter details manually.
}
}, mainExecutor)
onDispose {
provider?.unbindAll()
analysisExecutor.shutdown()
}
}
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
}
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
private val reader = MultiFormatReader().apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
// Screen-displayed QRs come with moiré, glare, and soft focus at
// close range — the exhaustive search is worth the milliseconds.
DecodeHintType.TRY_HARDER to true,
)
)
}
override fun analyze(image: ImageProxy) {
try {
val plane = image.planes[0]
val buffer = plane.buffer
// Copy into a rowStride-wide array; the last row of the plane buffer
// may be short of the full stride, so the tail stays zero-padded.
val data = ByteArray(plane.rowStride * image.height)
buffer.get(data, 0, minOf(buffer.remaining(), data.size))
val source = PlanarYUVLuminanceSource(
data, plane.rowStride, image.height,
0, 0, image.width, image.height,
false,
)
val result = try {
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
} catch (_: NotFoundException) {
// Dark-themed pages can render light-on-dark QRs — retry inverted.
reader.reset()
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
}
onDecoded(result.text)
} catch (_: NotFoundException) {
// No QR in this frame — keep scanning.
} catch (_: Exception) {
// Malformed frame; skip it.
} finally {
reader.reset()
image.close()
}
}
}
@@ -1,15 +1,21 @@
package com.archipelago.app.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.ui.screens.IntroScreen
import com.archipelago.app.ui.screens.RemoteInputScreen
import com.archipelago.app.ui.screens.ServerConnectScreen
@@ -24,7 +30,10 @@ object Routes {
}
@Composable
fun AppNavHost() {
fun AppNavHost(
pairUri: String? = null,
onPairUriConsumed: () -> Unit = {},
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val navController = rememberNavController()
@@ -33,8 +42,41 @@ fun AppNavHost() {
val introSeen by prefs.introSeen.collectAsState(initial = null)
val activeServer by prefs.activeServer.collectAsState(initial = null)
// Pairing entry from a deep link that carried no password — prefills the
// connect form so the user lands on the password prompt for that server.
var pairPrefill by remember { mutableStateOf<ServerEntry?>(null) }
if (introSeen == null) return
// Declared after the introSeen gate so it can't fire before the NavHost
// below has set the nav graph; pairUri stays pending until consumed here.
LaunchedEffect(pairUri) {
val raw = pairUri ?: return@LaunchedEffect
onPairUriConsumed()
when (val result = ServerQrParser.parse(raw)) {
is PairResult.Success -> {
// Pairing implies the app is installed and in use — skip the intro.
prefs.markIntroSeen()
val merged = prefs.upsertServer(result.server)
if (merged.password.isNotBlank()) {
// Demo flow: password came with the link — connect in one step.
prefs.setActiveServer(merged)
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
} else {
pairPrefill = merged
navController.navigate(Routes.SERVER_CONNECT) {
popUpTo(0) { inclusive = true }
}
}
}
else -> {
// Invalid or too-new pairing link — ignore; normal startup continues.
}
}
}
val startDestination = when {
introSeen == false -> Routes.INTRO
activeServer != null -> Routes.WEB_VIEW
@@ -65,6 +107,7 @@ fun AppNavHost() {
popUpTo(Routes.SERVER_CONNECT) { inclusive = true }
}
},
initialServer = pairPrefill,
)
}
@@ -81,6 +124,7 @@ fun AppNavHost() {
} else {
WebViewScreen(
serverUrl = server.toUrl(),
serverPassword = server.password,
onDisconnect = {
scope.launch {
prefs.clearActiveServer()
@@ -43,6 +43,7 @@ import com.archipelago.app.ui.components.NESController
import com.archipelago.app.ui.components.NESKeyboard
import com.archipelago.app.ui.components.NESMenu
import com.archipelago.app.ui.components.NESPortraitController
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.components.Trackpad
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
@@ -63,6 +64,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
var isGamepadMode by remember { mutableStateOf(true) }
var showModal by remember { mutableStateOf(false) }
var showQrScanner by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
@@ -216,6 +218,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
onAddServer = { server ->
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
},
onScanQr = { showQrScanner = true },
onEditServer = { original, updated ->
scope.launch {
prefs.updateSavedServer(original, updated)
@@ -247,5 +250,19 @@ fun RemoteInputScreen(onBack: () -> Unit) {
},
onBackToWebView = { showModal = false; onBack() },
)
// Pairing-QR scan launched from the menu's Add Server row. The menu stays
// open behind the scanner so the new entry appears as soon as it closes.
QrScannerOverlay(
visible = showQrScanner,
onDismiss = { showQrScanner = false },
onServerScanned = { server ->
showQrScanner = false
scope.launch {
val merged = prefs.upsertServer(server)
if (activeServer == null) prefs.setActiveServer(merged)
}
},
)
}
}
@@ -43,6 +43,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -72,6 +73,7 @@ import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed
import com.archipelago.app.ui.theme.SurfaceBlack
@@ -93,6 +95,9 @@ import javax.net.ssl.X509TrustManager
fun ServerConnectScreen(
onConnected: (String) -> Unit,
onRemoteInput: () -> Unit = {},
// Prefill from a pairing deep link (archipelago://pair) that carried no
// password — opens the manual form on the password prompt for that server.
initialServer: ServerEntry? = null,
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
@@ -109,6 +114,9 @@ fun ServerConnectScreen(
var errorMessage by remember { mutableStateOf<String?>(null) }
// The saved server currently being edited, or null when adding/connecting.
var editingServer by remember { mutableStateOf<ServerEntry?>(null) }
// Landing shows Scan/Manual choice; the form appears in manual mode or while editing.
var manualMode by remember { mutableStateOf(false) }
var showScanner by remember { mutableStateOf(false) }
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
@@ -173,6 +181,37 @@ fun ServerConnectScreen(
}
}
fun prefill(server: ServerEntry) {
name = server.name
address = server.address
port = server.port
password = server.password
useHttps = server.useHttps
}
// Pairing QR scanned: dedupe against saved servers, then either auto-connect
// (payload carried a password — the demo flow) or land on the password
// prompt with everything else filled in (real nodes never embed one).
fun onQrScanned(scanned: ServerEntry) {
showScanner = false
scope.launch {
val merged = prefs.upsertServer(scanned)
prefill(merged)
if (merged.password.isNotBlank()) {
connect(merged)
} else {
manualMode = true
}
}
}
LaunchedEffect(initialServer) {
if (initialServer != null) {
prefill(prefs.upsertServer(initialServer))
manualMode = true
}
}
Box(
modifier = Modifier
.fillMaxSize()
@@ -208,7 +247,10 @@ fun ServerConnectScreen(
.padding(horizontal = 24.dp)
.padding(top = 48.dp, bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
// Center the content vertically — the landing (logo + two buttons) is
// short and looks stranded at the top otherwise. Taller content (the
// manual form, saved servers) still scrolls from the top as normal.
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
// Circular badge logo
Image(
@@ -226,8 +268,10 @@ fun ServerConnectScreen(
textAlign = TextAlign.Center,
)
val showForm = manualMode || editingServer != null
Text(
text = stringResource(R.string.server_address_hint),
text = if (showForm) stringResource(R.string.server_address_hint) else stringResource(R.string.connect_landing_hint),
style = MaterialTheme.typography.bodyMedium,
color = TextMuted,
textAlign = TextAlign.Center,
@@ -235,8 +279,25 @@ fun ServerConnectScreen(
Spacer(modifier = Modifier.height(4.dp))
if (!showForm) {
// Landing: scan the pairing QR, or fall back to manual entry
GlassButton(
text = stringResource(R.string.scan_node_qr),
onClick = { showScanner = true },
modifier = Modifier.fillMaxWidth().height(56.dp),
)
GlassButton(
text = stringResource(R.string.enter_manually),
onClick = {
errorMessage = null
manualMode = true
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
}
// Glass card with form
Box(
if (showForm) Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
@@ -458,16 +519,30 @@ fun ServerConnectScreen(
modifier = Modifier.weight(1f).height(56.dp),
)
}
} else {
// Connect button — glass style
GlassButton(
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
onClick = {
keyboard?.hide()
connect(ServerEntry(address, useHttps, port, password, name))
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
} else if (manualMode) {
// Back to the Scan/Manual landing + Connect
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
GlassButton(
text = stringResource(R.string.back),
onClick = {
keyboard?.hide()
manualMode = false
clearForm()
},
modifier = Modifier.weight(1f).height(56.dp),
)
GlassButton(
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
onClick = {
keyboard?.hide()
connect(ServerEntry(address, useHttps, port, password, name))
},
modifier = Modifier.weight(2f).height(56.dp),
)
}
}
if (isConnecting) {
@@ -499,6 +574,12 @@ fun ServerConnectScreen(
}
}
}
QrScannerOverlay(
visible = showScanner,
onDismiss = { showScanner = false },
onServerScanned = { onQrScanned(it) },
)
}
}
@@ -136,6 +136,10 @@ fun WebViewScreen(
serverUrl: String,
onDisconnect: () -> Unit,
onRemoteInput: () -> Unit = {},
// Stored password for this server (from QR pairing or manual entry). When
// non-blank, the login page is auto-filled and submitted — the one-step
// demo flow from docs/companion-pairing-qr.md.
serverPassword: String = "",
) {
var isLoading by remember { mutableStateOf(true) }
var loadProgress by remember { mutableIntStateOf(0) }
@@ -310,6 +314,12 @@ fun WebViewScreen(
""".trimIndent(),
null,
)
// Auto-login with the stored password (QR pairing /
// saved server) — only on our own server's pages.
if (serverPassword.isNotBlank() && url != null && url.startsWith(serverUrl)) {
view.evaluateJavascript(buildAutoLoginScript(serverPassword), null)
}
}
override fun onReceivedError(
@@ -700,3 +710,39 @@ private fun InAppBrowser(
}
}
}
/**
* JS that fills the web UI login form (Login.vue's #login-password) with the
* stored password and submits it once the form is interactive — the one-step
* pairing flow. No-ops when the login step never appears (already
* authenticated, first-boot setup, TOTP). At most two attempts per page load,
* then it stops for good so a wrong stored password can't spam the node.
*/
private fun buildAutoLoginScript(password: String): String {
val quoted = org.json.JSONObject.quote(password)
return """
(function () {
if (window.__archyAutoLogin) return;
window.__archyAutoLogin = true;
var pw = $quoted;
var attempts = 0;
var started = Date.now();
var timer = setInterval(function () {
if (Date.now() - started > 45000) { clearInterval(timer); return; }
var el = document.getElementById('login-password');
if (!el) {
// Field gone after we submitted = success or step change - stop.
if (attempts > 0) clearInterval(timer);
return;
}
if (el.disabled) return; // form waits on serverReady
if (attempts >= 2) { clearInterval(timer); return; }
attempts++;
var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, pw);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
}, 1500);
})();
""".trimIndent()
}
@@ -29,6 +29,15 @@
<string name="server_name_label">Server Name (optional)</string>
<string name="server_name_placeholder">My Archipelago</string>
<string name="edit_server">Edit</string>
<string name="scan_node_qr">Scan Node\'s QR</string>
<string name="enter_manually">Enter Manually</string>
<string name="connect_landing_hint">Scan the pairing QR from your node\'s Companion popup, or enter the address manually</string>
<string name="scan_qr_hint">Point the camera at the pairing QR shown in the Companion popup</string>
<string name="camera_permission_needed">Camera access is needed to scan the pairing QR. You can also enter the server details manually.</string>
<string name="grant_camera_access">Grant Camera Access</string>
<string name="invalid_pairing_qr">Not an Archipelago pairing code</string>
<string name="update_app_for_qr">This pairing code needs a newer app version — please update the companion app</string>
<string name="add_server_qr">Add server by QR</string>
<string name="edit_server_title">Edit Server</string>
<string name="save_changes">Save Changes</string>
<string name="cancel">Cancel</string>
+38 -24
View File
@@ -1,33 +1,47 @@
# Changelog
## v1.8.00-alpha (2026-06-18)
## v1.7.101-alpha (2026-07-15)
Polishes the mesh AI assistant and Fedimint, on top of all the v1.7.99 features (kept listed below so you can still see what's new).
- The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip.
- Every app you install now automatically gets its own private .onion address — your apps are reachable over Tor a few seconds after install, with no manual "Add Service" step.
- "Add Service" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs".
- Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.
- The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.
- Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, a search that also finds files shared by your federated peer nodes — and music now opens in the bottom-bar player instead of a broken preview window.
- The first-login experience flows again: the dashboard entrance animation is back — and you can actually hear it now (its sound was silently swallowed before, including on replays) — "Replay Intro" in Settings actually replays it, opening a direct link to an inner page no longer detours through the splash screen, the login screen keeps the intro video until your first login (switching to rotating backgrounds after), and the intro video streams three times lighter so it starts instantly.
- Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.
- The public demo is richer and truer: the intro plays on every fresh visit, Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.
- Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).
- The off-grid mesh radio no longer posts cryptic identity codes to the shared public channel. Your node was announcing a line starting with "ARCHY:" to the public channel about once a minute, which everyone else on that channel saw as spam; that broadcast has been removed.
- You can now use your node's AI assistant straight from a normal chat. Send "!ai <your question>" in a direct message to an AI-enabled node and the answer comes right back in the same conversation — whether your message travelled over the internet or the LoRa radio. Before, the reply could be sent on the wrong path and never arrive.
- The Mesh AI Assistant panel is easier to set up: pick the Claude model from a dropdown (Haiku, Sonnet, or Opus) instead of typing it, and add specific contacts to an "always allow" list so chosen people can use "!ai" even when the assistant is set to trusted-nodes-only.
- Fedimint federations show up in Wallet Settings again. The Fedimint client app wasn't starting because of a configuration error, so the federation your node auto-joins never appeared; the client is fixed and runs again.
- In Settings, "App Updates" and "App Registry" now sit directly under your Account section for quicker access.
- In Mesh chat, scrolling the conversation no longer also scrolls the contact list behind it.
- Mesh direct messages are now private and end-to-end encrypted to the recipient — they're sent as real radio DMs instead of being broadcast on the public channel, so other people on the mesh no longer see them, and the answer arrives intact (even on standard meshcore phone apps).
- You can now message standard meshcore apps (like the phone companion) and they can message you — text shows up readable on both sides, and your node's AI answers come back as a private reply rather than on the public channel.
- New contacts you hear on the radio are added automatically, so people show up in your Peers list without any extra steps.
- "Clear All" now actually removes contacts (rather than hiding them forever); a contact comes back on its own the next time it's in range. Each contact also shows a reachability dot so you can see who's currently reachable.
- The Peers list has a search box (with a clear button) to quickly filter your contacts by name, DID, npub, or key.
## v1.7.100-alpha (2026-07-14)
All the v1.7.99-alpha features are included as well:
- Bitcoin now supports multiple versions of both Bitcoin Core and Bitcoin Knots: install the version you want, switch between them, pin a version, or let it auto-update — and switching is designed to be safe, with no surprise resyncs.
- Lightning grew up: your LND wallet's recovery seed is captured at setup and kept as an encrypted backup you can reveal from Settings, there's a new Channels tab with a fee control when opening channels, and on-chain and Lightning balances now show side by side.
- Installing Lightning (and other Bitcoin-dependent apps) on a fresh node no longer fails repeatedly — the node now waits until Bitcoin is genuinely ready to answer before starting them, and Bitcoin sizes its storage to your actual disk and its memory cache to your RAM, so small machines stop swapping and stalling.
- The wallet understands more money: Cashu v4 tokens are supported, you can pay for a peer's files from either your Cashu or Fedimint ecash, and the Transactions view now shows your Lightning, Cashu, and Fedimint activity together — with a payment confirmation screen and an automatic refund if a purchase fails.
- Mesh radios got a major upgrade: Meshtastic direct messages are now true end-to-end-encrypted radio messages that interoperate with off-the-shelf Meshtastic phone apps, your radio's region and a shared channel are provisioned automatically, and a new setup window appears when a radio is plugged in — with board pictures, full radio settings, and signal-strength indicators.
- Reticulum joins as a third mesh radio protocol with RNode LoRa hardware support, including sending images and voice messages over the radio — and every chat message now carries a small pill showing how it travelled (Mesh, FIPS, or Tor).
- Your node can manage an OpenWrt router: set up its internet uplink from the UI with a Wi-Fi network scan, turn it into a TollGate pay-for-Wi-Fi hotspot with a real captive portal, and sweep the router's earnings into your node's wallet. The gateway's status appears on the Home screen's Network tile.
- Peering is now trust-aware: "Invite a Peer" grants view-only Observer access while "Link Your Nodes" grants Trusted access, incoming requests ask for your confirmation with an optional message, Node Visibility is a single clear switch plus a list of discoverable nodes you can peer with, and the Fleet view shows your trusted nodes' health.
- Updates and apps are verified end-to-end: release updates are cryptographically signed and checked against a key baked into your node, app definitions arrive via the signed catalog, and container images are checked against trusted sources before anything installs or runs.
- Dozens of reliability fixes: failed installs no longer leave phantom app cards, uninstalling can't hang forever, apps you stopped stay stopped, crashed apps heal themselves (even "running" containers whose process actually died), the login page no longer refresh-loops, and the mobile layout fits real phone screens instead of hiding the last row behind the browser bar.
- Your node can now hold Fedimint ecash as well as Cashu, with tabbed Wallet Settings for each and both balances shown side by side on the home wallet card.
- You can buy files shared by another node right from their cloud, paying from this node's ecash, your Lightning wallet, on-chain, or by scanning a Lightning QR with any outside wallet.
- Your node can act as an AI assistant on the off-grid mesh: peers ask by starting a message with "!ai" and get an answer back over the radio, with a panel to turn it on or off.
- You can view your node's 24-word recovery phrase any time from Settings, behind a password (and 2FA) confirmation and a tap-to-show blur.
- Setting up a brand-new node is smoother: it waits and retries quietly instead of flashing errors, and shows a gentle "securing your private connection…" status that turns to "ready" on its own.
- The NetBird VPN app now logs in (it's served over HTTPS and opens in a browser tab).
- Phone remote-control of a node's screen now supports two-finger scrolling inside apps, and external-browser apps open on your phone.
- You can choose whether your node shares Bitcoin block headers over the mesh, and your choices are remembered.
- Version numbers display cleanly everywhere (no more doubled "v"), and "Back" buttons look and behave consistently across desktop and mobile.
- For advanced testing, Settings includes an optional update & app source choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode, with the trusted origin remaining the default.
### Also in this release
- Ask your node things over the radio: send "!archy" for node status with no AI involved, or "!ai <your question>" in a direct message for an AI answer that comes back on the same path it arrived — with a model dropdown (Haiku, Sonnet, or Opus) and an "always allow" list in the Mesh AI Assistant panel.
- The off-grid mesh radio no longer posts cryptic identity codes ("ARCHY:") to the shared public channel every minute.
- Mesh contacts take care of themselves: new radios you hear are added automatically, "Clear All" really removes contacts (they return when in range), each contact shows a reachability dot, and the Peers list has a search box.
- You can message standard meshcore phone apps and they can message you — readable text both ways, private replies instead of public-channel broadcasts.
- Federated Archipelago nodes now appear on the Mesh Map.
- Apps open as an overlay on top of whatever page you're on, in every display mode, instead of yanking you to a different screen; the Services tab groups apps by category with proper icons.
- BTCPay Server keeps its plugins across restarts, connects to your node's own LND out of the box, and its invoices stay payable over private Lightning channels.
- Fedimint federations show up in Wallet Settings again (the client app's configuration error is fixed), and Wallet Settings has tabbed sections for Cashu and Fedimint.
- The phone companion app can upload and download files, edit saved server entries, opens non-embeddable apps in an in-app browser, and got a proper round launcher icon.
- Six placeholder "apps" that were just web bookmarks (484.kitchen, arch-presentation, call-the-operator, nwnn, syntropy-institute, t-zero) are gone from the store.
- The Bitcoin dashboard works fully offline (no more loading its styling from the internet), Gitea opens on the right port, and mempool, strfry, and Electrum stopped their restart/health-check loops.
- Kiosk displays: HDMI audio no longer stutters, and a bad display-clone state no longer sticks after reboot.
- Consistent dropdowns, toggles, tabs, and modal styling across Settings, Federation, and the rest of the UI; in Mesh chat, scrolling the conversation no longer also scrolls the contact list; "App Updates" and "App Registry" sit directly under Account in Settings.
- A fresh node no longer reinstalls apps just because their definition file exists on disk — only apps you actually installed come back.
## v1.7.99-alpha (2026-06-17)
+19
View File
@@ -298,6 +298,25 @@
]
}
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"containerConfig": {
"ports": [
"3535:3535"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
]
}
},
{
"id": "jellyfin",
"title": "Jellyfin",
+32
View File
@@ -0,0 +1,32 @@
# barkd — Ark protocol wallet daemon (https://gitlab.com/ark-bitcoin/bark).
# No official upstream image exists (their GitLab registry is empty), so we
# package the pinned, checksum-verified release binary ourselves and push to
# the node registry — same approach as fmcd. Keep the version in lockstep with
# the REST shapes coded in core/archipelago/src/wallet/ark_client.rs (0.3.0).
FROM debian:bookworm-slim
ARG BARKD_VERSION=0.3.0
ARG BARKD_SHA256=8562fa27386bae666ed62fa95c92d40f7bdb20d22525f75799adfc16adaaedb3
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl && \
curl -fsSL "https://gitlab.com/api/v4/projects/ark-bitcoin%2Fbark/packages/generic/release-assets/bark-${BARKD_VERSION}/barkd-${BARKD_VERSION}-linux-x86_64" \
-o /usr/local/bin/barkd && \
echo "${BARKD_SHA256} /usr/local/bin/barkd" | sha256sum -c - && \
chmod a+x /usr/local/bin/barkd && \
apt-get purge -y curl && apt-get autoremove -y && \
apt-get clean && rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh
RUN chmod a+x /entrypoint.sh
# The wallet itself is created over REST by the node's Ark bridge
# (wallet.ark-* RPCs) — the container just runs the daemon.
ENV BARKD_DATADIR=/data \
BARKD_BIND_HOST=0.0.0.0 \
BARKD_BIND_PORT=3535
EXPOSE 3535
VOLUME /data
ENTRYPOINT ["/entrypoint.sh"]
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Install the node-provided auth secret (64-char hex from the manifest's
# generated barkd-secret) so the wallet bridge can derive the matching Bearer
# token, then start the daemon. Without BARKD_SECRET, barkd generates its own
# random token in the datadir and the bridge won't authenticate — so treat a
# failed refresh as fatal rather than starting an unreachable daemon.
set -eu
if [ -n "${BARKD_SECRET:-}" ]; then
# `secret refresh` prints the Bearer token on stdout — never log it.
barkd secret refresh --secret "$BARKD_SECRET" >/dev/null
unset BARKD_SECRET
fi
exec barkd
+76
View File
@@ -0,0 +1,76 @@
app:
id: barkd
name: Ark Wallet
version: 0.3.0
description: Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.
container:
# barkd packaged from the pinned upstream release binary (no usable
# upstream image exists — their registry is empty). Built from
# apps/barkd/Dockerfile and pushed to the node registry. Pin the tag to
# match the REST shapes coded in core/archipelago/src/wallet/ark_client.rs
# (validated against barkd 0.3.0 on signet, 2026-07-14).
image: 146.59.87.168:3000/lfg2025/barkd:0.3.0
pull_policy: if-not-present
network: archy-net
# The entrypoint installs the shared secret below via `barkd secret
# refresh` (so the wallet bridge can derive the matching Bearer token) and
# execs the daemon. The Ark wallet itself is created over REST by the
# bridge on first use (wallet.ark-* RPCs) with the node's ark_config
# (default: Second's public signet server) — no host provisioning needed.
generated_secrets:
- name: barkd-secret
kind: hex32
secret_env:
- key: BARKD_SECRET
secret_file: barkd-secret
data_uid: "1000:1000"
dependencies:
- storage: 1Gi
resources:
# barkd is a single wallet daemon (SQLite + a gRPC conn to the Ark server
# + esplora polling); steady state is tiny. Cap it so a stuck sync can't
# starve the node.
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 1Gi
security:
readonly_root: true
# Needs outbound HTTPS to the Ark server (ark.signet.2nd.dev) and the
# esplora chain source, plus the published REST port for the wallet
# bridge. No inbound requirements beyond that.
network_policy: bridge
ports:
# barkd REST bound to 3535 in-container (BARKD_BIND_PORT); 3535 is free on
# the host (see port_allocator.rs). The Rust bridge targets
# http://127.0.0.1:3535.
- host: 3535
container: 3535
protocol: tcp
volumes:
# Holds the wallet DB, mnemonic and auth token. ARK funds are recoverable
# on-chain from this datadir (unilateral exit) — include it in backups.
- type: bind
source: /var/lib/archipelago/barkd
target: /data
options: [rw]
environment:
- BARKD_DATADIR=/data
- BARKD_BIND_HOST=0.0.0.0
- BARKD_BIND_PORT=3535
# All /api/v1/* routes require the Bearer token, so an HTTP probe would 401
# forever — use a TCP probe like fmcd (the host-side lifecycle layer
# verifies reachability).
health_check:
type: tcp
endpoint: localhost:3535
interval: 30s
timeout: 5s
retries: 3
+1 -1
View File
@@ -95,7 +95,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.100-alpha"
version = "1.7.101-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.100-alpha"
version = "1.7.101-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+43
View File
@@ -194,6 +194,39 @@ impl ApiHandler {
))
}
/// Serve an encrypted backup archive (`<data_dir>/backups/<id>.bak`) as a
/// browser download. The archive is passphrase-encrypted at rest; the
/// session gate at the route controls who can fetch it.
async fn handle_backup_download(&self, path: &str) -> Result<Response<hyper::Body>> {
let id = path.strip_prefix("/api/blob/backup/").unwrap_or("");
// Backup ids are UUIDs — reject anything that could traverse paths.
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"invalid backup id"}"#),
));
}
let file = self.config.data_dir.join("backups").join(format!("{id}.bak"));
match tokio::fs::read(&file).await {
Ok(bytes) => Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/octet-stream")
.header(
"Content-Disposition",
format!("attachment; filename=\"archipelago-backup-{id}.bak\""),
)
.header("Content-Length", bytes.len())
.body(hyper::Body::from(bytes))
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))),
Err(_) => Ok(build_response(
StatusCode::NOT_FOUND,
"application/json",
hyper::Body::from(r#"{"error":"backup not found"}"#),
)),
}
}
/// Build a 401 Unauthorized JSON response.
fn unauthorized() -> Response<hyper::Body> {
let body = serde_json::json!({ "error": "Unauthorized" });
@@ -434,6 +467,16 @@ impl ApiHandler {
Self::handle_mesh_typed_relay(self.rpc_handler.clone(), body_bytes).await
}
// Backup archive download — session-gated. Lives under /api/blob/
// so the existing nginx `location /api/blob` prefix proxies it on
// every fleet node without a config change.
(Method::GET, p) if p.starts_with("/api/blob/backup/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
self.handle_backup_download(p).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") => {
+229
View File
@@ -0,0 +1,229 @@
//! Ark protocol RPCs — bridge to the `barkd` sidecar.
//!
//! Companion to the Cashu RPCs in [`super::wallet`] and the Fedimint RPCs in
//! [`super::fedimint`]. Holding VTXOs, joining rounds and unilateral exits are
//! delegated to the barkd container via [`crate::wallet::ark_client::ArkClient`];
//! here we expose the node's JSON-RPC surface. barkd keeps its own movement
//! history, so unlike Fedimint there is no local transaction log.
use super::RpcHandler;
use crate::wallet::ark_client::{self, ArkClient};
use anyhow::Result;
impl RpcHandler {
/// `wallet.ark-status` — sidecar reachability, wallet fingerprint, network
/// and Ark server parameters. Soft-fails into `available: false` so the
/// settings UI can render an install/enable hint instead of an error.
pub(super) async fn handle_wallet_ark_status(&self) -> Result<serde_json::Value> {
let config = ark_client::load_config(&self.config.data_dir).await;
let client = match ArkClient::from_node(&self.config.data_dir).await {
Ok(c) => c,
Err(_) => {
return Ok(serde_json::json!({
"available": false,
"wallet_ready": false,
"config": config,
}))
}
};
// Make sure the wallet exists before reporting (idempotent, cheap once
// created).
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
let wallet = client.wallet_info().await.ok();
let info = client.ark_info().await.ok();
Ok(serde_json::json!({
"available": true,
"wallet_ready": wallet.is_some(),
"wallet": wallet,
"ark_info": info,
"config": config,
}))
}
/// `wallet.ark-balance` — off-chain (spendable + pending) and on-chain
/// sats. Soft-fails to zeros so unified balances still render.
pub(super) async fn handle_wallet_ark_balance(&self) -> Result<serde_json::Value> {
let client = match ArkClient::from_node(&self.config.data_dir).await {
Ok(c) => c,
Err(_) => {
return Ok(serde_json::json!({
"balance_sats": 0,
"spendable_sats": 0,
"pending_sats": 0,
"onchain_sats": 0,
}))
}
};
let bal = client
.balance()
.await
.unwrap_or_else(|_| serde_json::json!({}));
let sat = |key: &str| bal.get(key).and_then(|v| v.as_u64()).unwrap_or(0);
let spendable = sat("spendable_sat");
let pending = sat("pending_in_round_sat")
+ sat("pending_board_sat")
+ sat("pending_lightning_send_sat")
+ sat("claimable_lightning_receive_sat")
+ bal
.get("pending_exit_sat")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let onchain = client
.onchain_balance()
.await
.ok()
.and_then(|b| {
b.get("total_sat")
.or_else(|| b.get("confirmed_sat"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0);
Ok(serde_json::json!({
"balance_sats": spendable,
"spendable_sats": spendable,
"pending_sats": pending,
"onchain_sats": onchain,
}))
}
/// `wallet.ark-address` — fresh Ark (`tark1…`) receive address; pass
/// `{"onchain": true}` for an on-chain boarding address instead.
pub(super) async fn handle_wallet_ark_address(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
let client = ArkClient::from_node(&self.config.data_dir).await?;
let onchain = params
.as_ref()
.and_then(|p| p.get("onchain"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let address = if onchain {
client.onchain_address().await?
} else {
client.ark_address().await?
};
Ok(serde_json::json!({ "address": address, "onchain": onchain }))
}
/// `wallet.ark-send` — pay an Ark address, BOLT11 invoice, LNURL or
/// lightning address from Ark funds.
pub(super) async fn handle_wallet_ark_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let destination = params
.get("destination")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("Missing destination"))?;
// Optional for BOLT11 invoices that carry their own amount.
let amount_sats = params.get("amount_sats").and_then(|v| v.as_u64());
if amount_sats == Some(0) {
return Err(anyhow::anyhow!("Amount must be greater than zero"));
}
let comment = params.get("comment").and_then(|v| v.as_str());
let client = ArkClient::from_node(&self.config.data_dir).await?;
let movement = client.send(destination, amount_sats, comment).await?;
Ok(serde_json::json!({
"sent": true,
"movement": movement,
}))
}
/// `wallet.ark-invoice` — BOLT11 invoice that lands as Ark funds when paid.
pub(super) async fn handle_wallet_ark_invoice(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let amount_sats = params
.get("amount_sats")
.and_then(|v| v.as_u64())
.filter(|&v| v > 0)
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
let client = ArkClient::from_node(&self.config.data_dir).await?;
let res = client.lightning_invoice(amount_sats).await?;
Ok(res)
}
/// `wallet.ark-board` — lift on-chain funds into Ark VTXOs. Omitting
/// `amount_sats` boards everything.
pub(super) async fn handle_wallet_ark_board(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let amount_sats = params
.as_ref()
.and_then(|p| p.get("amount_sats"))
.and_then(|v| v.as_u64());
if amount_sats == Some(0) {
return Err(anyhow::anyhow!("Amount must be greater than zero"));
}
let client = ArkClient::from_node(&self.config.data_dir).await?;
let res = client.board(amount_sats).await?;
Ok(res)
}
/// `wallet.ark-offboard` — collaboratively move all VTXOs back on-chain,
/// optionally to a provided address (defaults to the wallet's own).
pub(super) async fn handle_wallet_ark_offboard(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let address = params
.as_ref()
.and_then(|p| p.get("address"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let client = ArkClient::from_node(&self.config.data_dir).await?;
let res = client.offboard_all(address).await?;
Ok(res)
}
/// `wallet.ark-history` — barkd movements mapped to the unified
/// transaction shape (kind = "ark"), newest first.
pub(super) async fn handle_wallet_ark_history(&self) -> Result<serde_json::Value> {
let mut transactions = ark_client::load_ark_txs(&self.config.data_dir).await;
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(serde_json::json!({ "transactions": transactions }))
}
/// `wallet.ark-configure` — set the Ark server / esplora / network used
/// when the barkd wallet is (re)created. Does NOT migrate an existing
/// wallet: barkd binds a wallet to its Ark server at creation.
pub(super) async fn handle_wallet_ark_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let mut config = ark_client::load_config(&self.config.data_dir).await;
for (key, field) in [
("network", &mut config.network as &mut String),
("ark_server", &mut config.ark_server),
("esplora", &mut config.esplora),
] {
if let Some(v) = params.get(key).and_then(|v| v.as_str()) {
let v = v.trim();
if !v.is_empty() {
*field = v.to_string();
}
}
}
if !matches!(config.network.as_str(), "signet" | "mainnet" | "regtest") {
return Err(anyhow::anyhow!(
"network must be one of: signet, mainnet, regtest"
));
}
ark_client::save_config(&self.config.data_dir, &config).await?;
Ok(serde_json::json!({ "config": config }))
}
}
+1 -1
View File
@@ -804,7 +804,7 @@ async fn http_launch_url_reachable(url: &str) -> bool {
}
}
fn port_from_url(url: &str) -> Option<u16> {
pub(in crate::api::rpc) fn port_from_url(url: &str) -> Option<u16> {
let after_colon = url.rsplit_once(':')?.1;
let port = after_colon
.chars()
@@ -258,6 +258,17 @@ impl RpcHandler {
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
// Ark protocol (via barkd sidecar)
"wallet.ark-status" => self.handle_wallet_ark_status().await,
"wallet.ark-balance" => self.handle_wallet_ark_balance().await,
"wallet.ark-address" => self.handle_wallet_ark_address(params).await,
"wallet.ark-send" => self.handle_wallet_ark_send(params).await,
"wallet.ark-invoice" => self.handle_wallet_ark_invoice(params).await,
"wallet.ark-board" => self.handle_wallet_ark_board(params).await,
"wallet.ark-offboard" => self.handle_wallet_ark_offboard(params).await,
"wallet.ark-history" => self.handle_wallet_ark_history().await,
"wallet.ark-configure" => self.handle_wallet_ark_configure(params).await,
// Container registries
"registry.list" => self.handle_registry_list().await,
"registry.add" => self.handle_registry_add(params).await,
@@ -64,8 +64,9 @@ impl RpcHandler {
.and_then(|p| p.get("trust_level"))
.and_then(|v| v.as_str())
.map(|s| {
TrustLevel::parse(s)
.ok_or_else(|| anyhow::anyhow!("Invalid trust_level: {s} (expected trusted|observer)"))
TrustLevel::parse(s).ok_or_else(|| {
anyhow::anyhow!("Invalid trust_level: {s} (expected trusted|observer)")
})
})
.transpose()?
.unwrap_or(TrustLevel::Trusted);
+1 -3
View File
@@ -292,9 +292,7 @@ impl RpcHandler {
let r_hash_hex = inv
.get("r_hash")
.and_then(|v| v.as_str())
.and_then(|b64| {
base64::engine::general_purpose::STANDARD.decode(b64).ok()
})
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
.map(hex::encode)
.unwrap_or_default();
transactions.push(serde_json::json!({
@@ -56,9 +56,8 @@ impl RpcHandler {
let words =
crate::seed::load_lnd_aezeed_encrypted(&self.config.data_dir, &node_secret).await;
node_secret.zeroize();
let words = words.map_err(|_| {
anyhow::anyhow!("Could not decrypt the saved Lightning seed backup")
})?;
let words = words
.map_err(|_| anyhow::anyhow!("Could not decrypt the saved Lightning seed backup"))?;
let word_count = words.len();
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
@@ -478,7 +478,8 @@ impl RpcHandler {
bytes,
};
let payload = message_types::encode_payload(&content)?;
let envelope = TypedEnvelope::new(MeshMessageType::ContentInline, payload).with_seq(seq);
let envelope =
TypedEnvelope::new(MeshMessageType::ContentInline, payload).with_seq(seq);
let wire = envelope.to_wire()?;
if use_resource_transfer {
svc.send_content_resource(
@@ -583,8 +584,7 @@ impl RpcHandler {
.map(|d| nodes.iter().any(|n| &n.did == d))
.unwrap_or(false);
let est_seconds =
(size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum;
let (tier, reason) = if size <= MESH_AUTO_MAX {
@@ -596,7 +596,10 @@ impl RpcHandler {
("auto-mesh", "No Tor path — sending inline over mesh")
}
} else if is_reticulum && size <= RETICULUM_RESOURCE_MAX {
("resource-mesh", "Sending directly over LoRa via a Reticulum resource transfer")
(
"resource-mesh",
"Sending directly over LoRa via a Reticulum resource transfer",
)
} else if size <= TOR_LARGE_WARN {
if has_tor {
("tor-only", "Too large for mesh — Tor only")
+4 -6
View File
@@ -214,8 +214,9 @@ pub(super) fn extract_client_ip(parts: &hyper::http::request::Parts) -> IpAddr {
Some(ip) => ip,
// No socket info recorded (shouldn't happen in the server path);
// fall back to the pre-extension behavior.
None => forwarded_client_ip(&parts.headers)
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
None => {
forwarded_client_ip(&parts.headers).unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
}
}
}
@@ -234,10 +235,7 @@ mod client_ip_tests {
use super::*;
use std::net::SocketAddr;
fn parts_with(
peer: Option<&str>,
real_ip: Option<&str>,
) -> hyper::http::request::Parts {
fn parts_with(peer: Option<&str>, real_ip: Option<&str>) -> hyper::http::request::Parts {
let mut builder = hyper::Request::builder().uri("/rpc/v1");
if let Some(ip) = real_ip {
builder = builder.header("x-real-ip", ip);
+2 -1
View File
@@ -1,4 +1,5 @@
mod analytics;
mod ark;
mod auth;
mod backup_rpc;
mod bitcoin;
@@ -54,11 +55,11 @@ use hyper::{Request, Response, StatusCode};
use std::sync::Arc;
use tracing::{debug, error};
pub use middleware::PeerAddr;
use middleware::{
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
CACHEABLE_METHODS, UNAUTHENTICATED_METHODS,
};
pub use middleware::PeerAddr;
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
/// Default dev password when no user is set up (matches mock-backend).
+133 -48
View File
@@ -1,13 +1,12 @@
use super::RpcHandler;
use crate::network::router as net_router;
use anyhow::Result;
use archipelago_openwrt::{
detect,
router::Router,
tollgate::{self, TollGateConfig},
wan,
wifi_scan,
wan, wifi_scan,
};
use crate::network::router as net_router;
/// Default port for the local Cashu mint (nutshell / cashu-mint app).
const LOCAL_MINT_PORT: u16 = 3338;
@@ -23,7 +22,9 @@ impl RpcHandler {
) -> Result<serde_json::Value> {
let p = params.unwrap_or_default();
let subnet: [u8; 4] = parse_ipv4(
p.get("subnet").and_then(|v| v.as_str()).unwrap_or("192.168.1.0"),
p.get("subnet")
.and_then(|v| v.as_str())
.unwrap_or("192.168.1.0"),
)?;
let prefix = p.get("prefix").and_then(|v| v.as_u64()).unwrap_or(24) as u8;
let ssh_user = p
@@ -59,8 +60,18 @@ impl RpcHandler {
.get("host")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
.or_else(|| {
if saved.configured {
Some(saved.address.clone())
} else {
None
}
})
.ok_or_else(|| {
anyhow::anyhow!(
"No router configured — provide host or call router.configure first"
)
})?;
let ssh_user = p
.get("ssh_user")
@@ -92,11 +103,14 @@ impl RpcHandler {
None,
Some(&ssh_user),
Some(&ssh_password),
).await;
)
.await;
}
// System info
let release = router.run_ok("cat /etc/openwrt_release").unwrap_or_default();
let release = router
.run_ok("cat /etc/openwrt_release")
.unwrap_or_default();
let hostname = router
.uci_get("system.@system[0].hostname")
.unwrap_or_else(|_| "unknown".into());
@@ -170,8 +184,18 @@ impl RpcHandler {
.get("host")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
.or_else(|| {
if saved.configured {
Some(saved.address.clone())
} else {
None
}
})
.ok_or_else(|| {
anyhow::anyhow!(
"No router configured — provide host or call router.configure first"
)
})?;
let ssh_user = p
.get("ssh_user")
.and_then(|v| v.as_str())
@@ -200,10 +224,7 @@ impl RpcHandler {
.get("step_size_ms")
.and_then(|v| v.as_u64())
.unwrap_or(60_000),
min_steps: p
.get("min_steps")
.and_then(|v| v.as_u64())
.unwrap_or(1) as u32,
min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
};
@@ -229,13 +250,34 @@ impl RpcHandler {
let saved = net_router::load_router_config(&self.config.data_dir).await?;
let p = params.unwrap_or_default();
let host = p.get("host").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
let ssh_user = p.get("ssh_user").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| saved.username.clone()).unwrap_or_else(|| "root".to_string());
let ssh_password = p.get("ssh_password").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| saved.password.clone()).unwrap_or_default();
let host = p
.get("host")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| {
if saved.configured {
Some(saved.address.clone())
} else {
None
}
})
.ok_or_else(|| {
anyhow::anyhow!(
"No router configured — provide host or call router.configure first"
)
})?;
let ssh_user = p
.get("ssh_user")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| saved.username.clone())
.unwrap_or_else(|| "root".to_string());
let ssh_password = p
.get("ssh_password")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| saved.password.clone())
.unwrap_or_default();
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
@@ -243,13 +285,15 @@ impl RpcHandler {
let networks = wifi_scan::scan_networks(&router)?;
let result: Vec<serde_json::Value> = networks
.iter()
.map(|n| serde_json::json!({
"ssid": n.ssid,
"bssid": n.bssid,
"signal": n.signal,
"channel": n.channel,
"encryption": n.encryption,
}))
.map(|n| {
serde_json::json!({
"ssid": n.ssid,
"bssid": n.bssid,
"signal": n.signal,
"channel": n.channel,
"encryption": n.encryption,
})
})
.collect();
Ok(serde_json::json!({ "networks": result }))
@@ -265,18 +309,50 @@ impl RpcHandler {
let saved = net_router::load_router_config(&self.config.data_dir).await?;
let p = params.unwrap_or_default();
let host = p.get("host").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
let ssh_user = p.get("ssh_user").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| saved.username.clone()).unwrap_or_else(|| "root".to_string());
let ssh_password = p.get("ssh_password").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| saved.password.clone()).unwrap_or_default();
let host = p
.get("host")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| {
if saved.configured {
Some(saved.address.clone())
} else {
None
}
})
.ok_or_else(|| {
anyhow::anyhow!(
"No router configured — provide host or call router.configure first"
)
})?;
let ssh_user = p
.get("ssh_user")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| saved.username.clone())
.unwrap_or_else(|| "root".to_string());
let ssh_password = p
.get("ssh_password")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| saved.password.clone())
.unwrap_or_default();
let ssid = p.get("ssid").and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required field: ssid"))?.to_string();
let password = p.get("password").and_then(|v| v.as_str()).unwrap_or("").to_string();
let encryption = p.get("encryption").and_then(|v| v.as_str()).unwrap_or("psk2").to_string();
let ssid = p
.get("ssid")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required field: ssid"))?
.to_string();
let password = p
.get("password")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let encryption = p
.get("encryption")
.and_then(|v| v.as_str())
.unwrap_or("psk2")
.to_string();
let dhcp_start = p.get("dhcp_start").and_then(|v| v.as_u64()).unwrap_or(100) as u32;
let dhcp_limit = p.get("dhcp_limit").and_then(|v| v.as_u64()).unwrap_or(150) as u32;
let masq = p.get("masq").and_then(|v| v.as_bool()).unwrap_or(true);
@@ -284,7 +360,14 @@ impl RpcHandler {
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
let config = wan::WispConfig { ssid: ssid.clone(), password, encryption, dhcp_start, dhcp_limit, masq };
let config = wan::WispConfig {
ssid: ssid.clone(),
password,
encryption,
dhcp_start,
dhcp_limit,
masq,
};
wan::configure_wisp(&router, &config)?;
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
@@ -325,14 +408,16 @@ fn parse_wifi_interfaces(raw: &str) -> Vec<serde_json::Value> {
let mut ifaces: Vec<serde_json::Value> = sections
.into_iter()
.filter(|(_, f)| f.get("mode").map(|m| m == "ap").unwrap_or(false))
.map(|(name, f)| serde_json::json!({
"section": name,
"ssid": f.get("ssid").cloned().unwrap_or_default(),
"device": f.get("device").cloned().unwrap_or_default(),
"encryption": f.get("encryption").cloned().unwrap_or_else(|| "none".into()),
"network": f.get("network").cloned().unwrap_or_default(),
"disabled": f.get("disabled").map(|v| v == "1").unwrap_or(false),
}))
.map(|(name, f)| {
serde_json::json!({
"section": name,
"ssid": f.get("ssid").cloned().unwrap_or_default(),
"device": f.get("device").cloned().unwrap_or_default(),
"encryption": f.get("encryption").cloned().unwrap_or_else(|| "none".into()),
"network": f.get("network").cloned().unwrap_or_default(),
"disabled": f.get("disabled").map(|v| v == "1").unwrap_or(false),
})
})
.collect();
ifaces.sort_by_key(|v| v["section"].as_str().unwrap_or("").to_string());
@@ -110,6 +110,14 @@ impl RpcHandler {
)
.await;
handler.clear_install_progress(&package_id_spawn).await;
// Auto-expose the app over Tor (best-effort, detached) —
// every installed app gets its .onion without a manual
// "Add Service" step.
let tor_handler = Arc::clone(&handler);
let tor_app = package_id_spawn.clone();
tokio::spawn(async move {
tor_handler.auto_add_tor_service(&tor_app).await;
});
}
Err(e) => {
error!("package.install {} failed: {:#}", package_id_spawn, e);
@@ -246,6 +246,11 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
pub(super) const DEP_WAIT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
/// 36 × 5s = 3 minutes of bounded waiting.
pub(super) const DEP_WAIT_MAX_ATTEMPTS: u32 = 36;
/// Separate, much larger budget while a dependency is still INSTALLING
/// (image pulling, container not created yet): 360 × 5s = 30 minutes. A
/// fresh-node Bitcoin pull routinely takes >3 minutes, and rejecting LND
/// mid-pull was the confirmed "first install fails, second works" failure.
pub(super) const DEP_INSTALLING_WAIT_MAX_ATTEMPTS: u32 = 360;
/// Marker error: the install was rejected by the dependency gate BEFORE any
/// resource (container, image, data dir) was created for the package. The
@@ -317,8 +322,12 @@ pub(super) struct DepProbe {
/// Which dependency services are currently Running.
pub running: RunningDeps,
/// Container/package names that EXIST in any state — installed, but
/// possibly not running yet (`podman ps -a` package-state entries).
/// possibly not running yet (`podman ps -a`).
pub existing: Vec<String>,
/// Package ids currently mid-install (state Installing/Updating) —
/// no container exists yet, but one is on the way, so the gate must
/// wait for the install to finish instead of failing fast.
pub installing: Vec<String>,
}
/// All container names known to podman in any state (`podman ps -a`).
@@ -366,8 +375,13 @@ where
LF: std::future::Future<Output = ()>,
{
let mut waited_attempts = 0u32;
let mut installing_attempts = 0u32;
loop {
let DepProbe { running, existing } = probe().await?;
let DepProbe {
running,
existing,
installing,
} = probe().await?;
let missing = missing_install_deps(package_id, &running);
if missing.is_empty() {
// Keep behavior in lockstep with the canonical gate (covers any
@@ -377,10 +391,13 @@ where
}
// Fail fast if any missing dependency has no installed container
// under any name variant — waiting cannot satisfy it.
let some_dep_not_installed = missing
.iter()
.any(|dep| !dep.containers.iter().any(|c| existing.iter().any(|e| e == c)));
// under any name variant AND no install in flight — waiting cannot
// satisfy it.
let some_dep_not_installed = missing.iter().any(|dep| {
!dep.containers.iter().any(|c| {
existing.iter().any(|e| e == c) || installing.iter().any(|i| i == c)
})
});
if some_dep_not_installed {
let msg = match check_install_deps(package_id, &running) {
Err(e) => e.to_string(),
@@ -389,8 +406,38 @@ where
return Err(anyhow::Error::new(DependencyGateError(msg)));
}
// A dependency still mid-install (no container yet) gets its own,
// much larger budget: a fresh-node image pull can take far longer
// than the started-but-not-running window.
let some_dep_installing = missing.iter().any(|dep| {
dep.containers
.iter()
.any(|c| installing.iter().any(|i| i == c))
});
let labels = join_dep_labels(&missing);
if some_dep_installing {
if installing_attempts >= DEP_INSTALLING_WAIT_MAX_ATTEMPTS {
return Err(anyhow::Error::new(DependencyGateError(format!(
"{labels} is still installing after {} seconds. Wait for it \
to finish, then install {package_id} again.",
u64::from(DEP_INSTALLING_WAIT_MAX_ATTEMPTS) * interval.as_secs()
))));
}
installing_attempts += 1;
if installing_attempts == 1 {
info!(
"Install {package_id}: dependency {labels} is still installing — \
waiting up to {}s for it to finish",
u64::from(DEP_INSTALLING_WAIT_MAX_ATTEMPTS) * interval.as_secs()
);
}
on_waiting(format!("Waiting for {labels} to finish installing…")).await;
tokio::time::sleep(interval).await;
continue;
}
if waited_attempts >= max_attempts {
let labels = join_dep_labels(&missing);
return Err(anyhow::Error::new(DependencyGateError(format!(
"{labels} is installed but did not reach the running state within \
{} seconds. Start {labels}, then install {package_id} again.",
@@ -399,7 +446,6 @@ where
}
waited_attempts += 1;
let labels = join_dep_labels(&missing);
if waited_attempts == 1 {
info!(
"Install {package_id}: dependency {labels} installed but not running yet — \
@@ -872,15 +918,27 @@ mod tests {
}
fn probe(has_bitcoin: bool, has_electrumx: bool, existing: &[&str]) -> DepProbe {
probe_with_installing(has_bitcoin, has_electrumx, existing, &[])
}
fn probe_with_installing(
has_bitcoin: bool,
has_electrumx: bool,
existing: &[&str],
installing: &[&str],
) -> DepProbe {
DepProbe {
running: deps(has_bitcoin, has_electrumx),
existing: existing.iter().map(|s| s.to_string()).collect(),
installing: installing.iter().map(|s| s.to_string()).collect(),
}
}
/// Collects "Waiting for X to start…" labels emitted during the wait.
fn label_sink() -> (Arc<Mutex<Vec<String>>>, impl FnMut(String) -> std::future::Ready<()>)
{
fn label_sink() -> (
Arc<Mutex<Vec<String>>>,
impl FnMut(String) -> std::future::Ready<()>,
) {
let labels = Arc::new(Mutex::new(Vec::new()));
let sink = {
let labels = Arc::clone(&labels);
@@ -930,7 +988,8 @@ mod tests {
// so async_lifecycle removes the optimistic Installing entry.
assert!(err.downcast_ref::<DependencyGateError>().is_some());
assert!(
err.to_string().contains("LND requires a running Bitcoin node"),
err.to_string()
.contains("LND requires a running Bitcoin node"),
"unexpected message: {err}"
);
}
@@ -960,6 +1019,71 @@ mod tests {
assert!(labels.iter().all(|l| l == "Waiting for Bitcoin to start…"));
}
#[tokio::test]
async fn waits_while_dependency_is_still_installing_then_passes() {
// Bitcoin has NO container yet (image still pulling) but its
// package state is Installing. Old behavior failed fast here —
// the confirmed fresh-node LND "first install fails" bug. The
// gate must wait past the normal started-but-not-running budget
// (max_attempts=1 below) while the install is in flight.
let calls = Arc::new(AtomicU32::new(0));
let (labels, sink) = label_sink();
let probe_calls = Arc::clone(&calls);
let result = wait_for_install_deps(
"lnd",
move || {
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
async move {
Ok(match n {
// pulling: no container, package Installing
0 | 1 => probe_with_installing(false, false, &[], &["bitcoin-knots"]),
// container created, starting
2 => probe(false, false, &["bitcoin-knots"]),
// running
_ => probe(true, false, &["bitcoin-knots"]),
})
}
},
sink,
1,
Duration::ZERO,
)
.await;
assert!(result.is_ok(), "{result:?}");
assert_eq!(calls.load(Ordering::SeqCst), 4);
let labels = labels.lock().unwrap();
assert_eq!(
labels.as_slice(),
[
"Waiting for Bitcoin to finish installing…",
"Waiting for Bitcoin to finish installing…",
"Waiting for Bitcoin to start…",
]
);
}
#[tokio::test]
async fn fails_fast_when_dependency_neither_installed_nor_installing() {
// installing list has unrelated packages only — no reason to wait.
let calls = AtomicU32::new(0);
let (labels, sink) = label_sink();
let err = wait_for_install_deps(
"lnd",
|| {
calls.fetch_add(1, Ordering::SeqCst);
async { Ok(probe_with_installing(false, false, &[], &["grafana"])) }
},
sink,
36,
Duration::ZERO,
)
.await
.unwrap_err();
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(labels.lock().unwrap().is_empty());
assert!(err.downcast_ref::<DependencyGateError>().is_some());
}
#[tokio::test]
async fn times_out_when_installed_dependency_never_runs() {
let (labels, sink) = label_sink();
+125 -30
View File
@@ -984,6 +984,24 @@ impl RpcHandler {
}))
}
/// Package ids currently mid-install/update per the state manager — used
/// by the dependency gate so an app whose dependency is still pulling its
/// image (no container yet) waits instead of failing fast.
async fn packages_currently_installing(&self) -> Vec<String> {
let (data, _) = self.state_manager.get_snapshot().await;
data.package_data
.iter()
.filter(|(_, entry)| {
matches!(
entry.state,
crate::data_model::PackageState::Installing
| crate::data_model::PackageState::Updating
)
})
.map(|(id, _)| id.clone())
.collect()
}
async fn running_deps_for_install(&self, package_id: &str) -> Result<RunningDeps> {
let (data, _) = self.state_manager.get_snapshot().await;
let cached = detect_running_deps_from_package_data(&data.package_data);
@@ -1006,6 +1024,7 @@ impl RpcHandler {
Ok(DepProbe {
running: self.running_deps_for_install(package_id).await?,
existing: detect_existing_containers().await,
installing: self.packages_currently_installing().await,
})
},
|msg| async move { self.set_install_message(package_id, &msg).await },
@@ -1089,46 +1108,97 @@ impl RpcHandler {
.spawn()
.context("Failed to start image pull")?;
// 5-minute per-URL budget. A full install tries each configured mirror
// once, so a two-registry setup fails visibly in roughly 10 minutes
// instead of staying in Installing for up to an hour.
const PULL_URL_TIMEOUT_SECS: u64 = 300;
let pull_result = tokio::time::timeout(
std::time::Duration::from_secs(PULL_URL_TIMEOUT_SECS),
async {
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();
// Stall-aware budget instead of a hard wall clock. The old fixed
// 300s cap killed slow-but-progressing pulls (LND on fresh-node
// WiFi routinely needs longer), which surfaced as "first install
// fails, second succeeds" once the layer cache was warm. A pull is
// only killed when NOTHING is observably happening — no stderr
// output and no byte growth in podman's staging dir — for
// PULL_STALL_TIMEOUT_SECS, or at a generous absolute ceiling.
// Dead registries still fail fast: no bytes ever land, so the
// stall window (3 min) is the effective bound.
const PULL_STALL_TIMEOUT_SECS: u64 = 180;
const PULL_URL_MAX_SECS: u64 = 1800;
const PULL_POLL_INTERVAL_SECS: u64 = 5;
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 started = std::time::Instant::now();
// Seconds-since-start of the last stderr line, updated by the
// reader task. u64::MAX sentinel = no line seen yet (treated as
// activity at t=0 so the stall window starts at spawn).
let last_line_at = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
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();
let line_clock = std::sync::Arc::clone(&last_line_at);
let started_reader = started;
tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
line_clock.store(
started_reader.elapsed().as_secs(),
std::sync::atomic::Ordering::Relaxed,
);
if let Some((downloaded, total)) = parse_pull_progress(&line) {
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total)
.await;
}
}
child.wait().await
},
)
.await;
});
}
match pull_result {
Ok(Ok(status)) => Ok(status.success()),
Ok(Err(e)) => {
tracing::warn!("Image pull process error on {}: {}", url, e);
Ok(false)
let mut last_staged_bytes = dir_size_bytes(user_tmp);
let mut last_staged_change = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => return Ok(status.success()),
Ok(None) => {}
Err(e) => {
tracing::warn!("Image pull process error on {}: {}", url, e);
let _ = child.kill().await;
let _ = child.wait().await;
return Ok(false);
}
}
Err(_) => {
tokio::time::sleep(std::time::Duration::from_secs(PULL_POLL_INTERVAL_SECS)).await;
if started.elapsed() > std::time::Duration::from_secs(PULL_URL_MAX_SECS) {
tracing::warn!(
"Image pull timed out after {}s: {}",
PULL_URL_TIMEOUT_SECS,
"Image pull exceeded absolute {}s ceiling: {}",
PULL_URL_MAX_SECS,
url
);
let _ = child.kill().await;
let _ = child.wait().await; // reap zombie
Ok(false)
return Ok(false);
}
// Activity signal 1: stderr output from podman.
let line_age = started
.elapsed()
.as_secs()
.saturating_sub(last_line_at.load(std::sync::atomic::Ordering::Relaxed));
// Activity signal 2: podman stages layer downloads in TMPDIR
// (user_tmp) — any size change there means bytes are moving.
let staged = dir_size_bytes(user_tmp);
if staged != last_staged_bytes {
last_staged_bytes = staged;
last_staged_change = std::time::Instant::now();
}
let stalled = line_age > PULL_STALL_TIMEOUT_SECS
&& last_staged_change.elapsed()
> std::time::Duration::from_secs(PULL_STALL_TIMEOUT_SECS);
if stalled {
tracing::warn!(
"Image pull stalled ({}s with no output and no staged bytes): {}",
PULL_STALL_TIMEOUT_SECS,
url
);
let _ = child.kill().await;
let _ = child.wait().await; // reap zombie
return Ok(false);
}
}
}
@@ -2690,6 +2760,31 @@ async fn persist_install_version_selection(app_id: &str, version: &str) {
}
}
/// Total bytes under `path` (bounded recursive walk). Used as a coarse
/// "bytes are moving" signal for pull stall detection — exact size doesn't
/// matter, only whether it CHANGES between polls. Errors count as 0.
fn dir_size_bytes(path: &str) -> u64 {
fn walk(dir: &std::path::Path, depth: u32) -> u64 {
if depth > 8 {
return 0;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut total = 0u64;
for entry in entries.flatten() {
let Ok(meta) = entry.metadata() else { continue };
if meta.is_dir() {
total = total.saturating_add(walk(&entry.path(), depth + 1));
} else {
total = total.saturating_add(meta.len());
}
}
total
}
walk(std::path::Path::new(path), 0)
}
fn should_try_orchestrator_install(package_id: &str, orchestrator_available: bool) -> bool {
orchestrator_available && uses_orchestrator_install_flow(package_id)
}
@@ -63,7 +63,9 @@ impl RpcHandler {
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
vec![orchestrator_app_id(package_id).to_string()]
} else if let Some(members) = orchestrator_stack_members(self.orchestrator.is_some(), package_id) {
} else if let Some(members) =
orchestrator_stack_members(self.orchestrator.is_some(), package_id)
{
members
} else {
ordered_containers_for_start(package_id).await?
@@ -170,11 +172,12 @@ impl RpcHandler {
// fallback to a raw `podman stop` that races systemd over the unit
// (immich, gate 2026-07-09).
let to_stop_ids = if !single_orchestrator_app {
orchestrator_stack_members(self.orchestrator.is_some(), package_id)
.map(|mut members| {
orchestrator_stack_members(self.orchestrator.is_some(), package_id).map(
|mut members| {
members.reverse();
members
})
},
)
} else {
None
};
@@ -280,7 +283,9 @@ impl RpcHandler {
let companion_app_id = package_id_owned.clone();
let to_restart = if single_orchestrator_app {
vec![orchestrator_app_id(package_id).to_string()]
} else if let Some(members) = orchestrator_stack_members(self.orchestrator.is_some(), package_id) {
} else if let Some(members) =
orchestrator_stack_members(self.orchestrator.is_some(), package_id)
{
// Restart stacks via member APP ids: restarting by live container
// name podman-stops the quadlet container (systemd --rm removes
// it) and the start half then finds no such container — a 5-min
@@ -2160,7 +2165,9 @@ mod tests {
assert!(is_missing_container_error(
"Error: no such object: \"mempool\""
));
assert!(is_missing_container_error("Error: no such container mempool"));
assert!(is_missing_container_error(
"Error: no such container mempool"
));
assert!(is_missing_container_error(
"Error: no container with name or id \"x\" found"
));
+40 -1
View File
@@ -621,8 +621,33 @@ async fn install_stack_via_orchestrator(
))
.await;
// Phase: PullingImage — each member's orchestrator.install covers its
// whole pipeline (pull + create + start + health wait), and on a fresh
// node the pull dominates wall-clock. The X-of-N counter below is what
// the UI interpolates across the PullingImage band (20→70%); without
// these updates an orchestrator-installed stack sat at "Preparing… 5%"
// for the entire multi-gigabyte pull (indeedhub: 7 images).
let total = app_ids.len() as u64;
handler
.set_install_phase(stack_name, InstallPhase::PullingImage)
.await;
let mut installed = 0usize;
for app_id in app_ids {
for (idx, app_id) in app_ids.iter().enumerate() {
handler
.set_install_progress(stack_name, idx as u64, total)
.await;
// Message after progress: set_install_progress resets the label,
// set_install_message preserves the phase + counters just written.
let component = app_id
.strip_prefix(&format!("{stack_name}-"))
.unwrap_or(app_id);
handler
.set_install_message(
stack_name,
&format!("Installing {} ({} of {})…", component, idx + 1, total),
)
.await;
match orchestrator.install(app_id).await {
Ok(container_name) => {
installed += 1;
@@ -672,6 +697,20 @@ async fn install_stack_via_orchestrator(
}
}
// Truthful end-of-install signal, mirroring the legacy stack installers:
// the real readiness gate is the scanner's next sweep, this just settles
// the bar at 95→100→done instead of leaving it mid-band.
handler
.set_install_progress(stack_name, total, total)
.await;
handler
.set_install_phase(stack_name, InstallPhase::PostInstall)
.await;
handler
.set_install_phase(stack_name, InstallPhase::Done)
.await;
handler.clear_install_progress(stack_name).await;
install_log(&format!("INSTALL ORCH OK: {} stack", stack_name)).await;
Ok(Some(serde_json::json!({
"success": true,
@@ -53,6 +53,7 @@ impl RpcHandler {
// hit a hostname-mismatch warning on top of the usual self-signed one
// the moment a node is renamed.
if hostname_updated {
sync_hostname_side_effects(&hostname).await;
if let Err(e) = regenerate_tls_cert(&hostname).await {
warn!(hostname = %hostname, "TLS cert regen after rename failed: {}", e);
}
@@ -100,7 +101,8 @@ impl RpcHandler {
}
let location_file = self.config.data_dir.join("server-location.json");
let payload = serde_json::json!({ "lat": lat, "lon": lon, "share_location": share_location });
let payload =
serde_json::json!({ "lat": lat, "lon": lon, "share_location": share_location });
tokio::fs::write(&location_file, serde_json::to_vec(&payload)?)
.await
.context("Failed to write server location")?;
@@ -130,7 +132,9 @@ impl RpcHandler {
/// resolves to on the LAN (avahi-daemon advertises `<hostname>.local`).
/// Lets Settings show users where to reach this node over HTTPS for
/// features (mic/camera access) that require a secure context.
pub(in crate::api::rpc) async fn handle_system_get_hostname(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_system_get_hostname(
&self,
) -> Result<serde_json::Value> {
let hostname = tokio::fs::read_to_string("/etc/hostname")
.await
.map(|s| s.trim().to_string())
@@ -372,6 +376,46 @@ pub(super) fn hostname_from_server_name(name: &str) -> String {
}
}
/// Post-rename side effects that keep the OS consistent with the new
/// hostname — all best-effort, the rename itself has already succeeded.
/// Debian resolves the local hostname via the 127.0.1.1 line in /etc/hosts;
/// leaving the old name there breaks `sudo` ("unable to resolve host") and
/// `hostname -f`. And while avahi eventually follows the kernel hostname,
/// re-announcing immediately makes http(s)://<hostname>.local links work
/// right after the rename instead of minutes later.
async fn sync_hostname_side_effects(hostname: &str) {
// hostname_from_server_name guarantees [a-z0-9-], safe to interpolate.
let script = format!(
"if grep -q '^127\\.0\\.1\\.1' /etc/hosts; then sed -i 's/^127\\.0\\.1\\.1.*/127.0.1.1\\t{h}/' /etc/hosts; else printf '127.0.1.1\\t{h}\\n' >> /etc/hosts; fi",
h = hostname
);
match tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/bin/sh", "-c", &script])
.output()
.await
{
Ok(o) if o.status.success() => {}
Ok(o) => warn!(
"/etc/hosts hostname sync failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => warn!("/etc/hosts hostname sync failed: {}", e),
}
let republished = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/avahi-set-host-name", hostname])
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if !republished {
let _ = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/systemctl", "try-restart", "avahi-daemon"])
.output()
.await;
}
}
async fn set_system_hostname(hostname: &str) -> Result<()> {
let output = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/hostnamectl", "set-hostname", hostname])
@@ -401,7 +445,8 @@ async fn set_system_hostname(hostname: &str) -> Result<()> {
/// top once a node has been renamed away from the install-time default.
async fn regenerate_tls_cert(hostname: &str) -> Result<()> {
let subj = format!("/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN={hostname}");
let san = format!("subjectAltName=DNS:{hostname},DNS:{hostname}.local,DNS:localhost,IP:127.0.0.1");
let san =
format!("subjectAltName=DNS:{hostname},DNS:{hostname}.local,DNS:localhost,IP:127.0.0.1");
let output = tokio::process::Command::new("/usr/bin/sudo")
.args([
"-n",
+72 -8
View File
@@ -33,14 +33,12 @@ impl RpcHandler {
validate_service_name(name)?;
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",
self.resolve_app_local_port(name).await.ok_or_else(|| {
anyhow::anyhow!(
"No local web port found for '{}' — the app isn't running or exposes no UI port; specify local_port manually",
name
));
}
detected
)
})?
} else {
raw_port
};
@@ -286,7 +284,12 @@ impl RpcHandler {
"changed": false,
}));
}
let port = known_service_port(app_id);
let port = self.resolve_app_local_port(app_id).await.ok_or_else(|| {
anyhow::anyhow!(
"No local web port found for '{}' — the app isn't running or exposes no UI port",
app_id
)
})?;
let is_proto = is_protocol_service(app_id);
config.services.push(TorServiceEntry {
name: app_id.to_string(),
@@ -330,6 +333,67 @@ impl RpcHandler {
}))
}
/// The host-local port Tor should forward to for an app: the static map
/// first (protocol apps like bitcoin must expose 8333, not a UI port),
/// then the live launch address the scanner derived from the app's
/// published container ports — so any manifest-driven app works without
/// a per-app entry.
pub(in crate::api::rpc) async fn resolve_app_local_port(&self, name: &str) -> Option<u16> {
let known = known_service_port(name);
if known != 0 {
return Some(known);
}
let (data, _) = self.state_manager.get_snapshot().await;
let lan = data
.package_data
.get(name)?
.installed
.as_ref()?
.interface_addresses
.get("main")?
.lan_address
.clone()?;
crate::api::rpc::container::port_from_url(&lan)
}
/// Best-effort auto-exposure of a freshly installed app as a Tor hidden
/// service. Skips protocol services (bitcoin/lnd keep their explicit
/// flows), the node's own service, apps that already have one, and apps
/// with no resolvable web port. Runs detached after install — it never
/// fails the caller, it only logs.
pub(in crate::api::rpc) async fn auto_add_tor_service(&self, app_id: &str) {
if app_id == "archipelago" || is_protocol_service(app_id) {
return;
}
let config_dir = self.config.data_dir.join("tor-config");
// The scanner may still be deriving the launch address on slower
// nodes; retry for up to ~5 minutes before giving up quietly.
for _ in 0..10u32 {
let config = load_services_config(&config_dir).await;
if config.services.iter().any(|s| s.name == app_id) {
return;
}
if let Some(port) = self.resolve_app_local_port(app_id).await {
let params = serde_json::json!({ "name": app_id, "local_port": port });
match self.handle_tor_create_service(Some(params)).await {
Ok(v) => info!(
app = app_id,
port,
onion = ?v.get("onion_address"),
"Auto-created Tor hidden service after install"
),
Err(e) => warn!(app = app_id, "Auto Tor service creation failed: {}", e),
}
return;
}
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
}
debug!(
app = app_id,
"No web port resolved — skipping auto Tor service"
);
}
/// Restart Tor daemon (system or container).
pub(in crate::api::rpc) async fn handle_tor_restart(&self) -> Result<serde_json::Value> {
info!("Manual Tor restart requested");
+8 -3
View File
@@ -1,5 +1,5 @@
use super::RpcHandler;
use crate::wallet::{ecash, fedimint_client, profits};
use crate::wallet::{ark_client, ecash, fedimint_client, profits};
use anyhow::Result;
/// A Cashu token (NUT-00 `cashuA`/`cashuB`, or our legacy `cashuSend_` form)
@@ -21,13 +21,16 @@ impl RpcHandler {
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
Err(_) => 0,
};
// Spendable Ark (barkd) balance, same best-effort contract.
let ark_sats = ark_client::spendable_sats_or_zero(&self.config.data_dir).await;
Ok(serde_json::json!({
// `balance_sats` stays Cashu-only for back-compat; `total_sats` is the
// spendable amount across Cashu + Fedimint.
// spendable amount across Cashu + Fedimint + Ark.
"balance_sats": cashu_sats,
"cashu_sats": cashu_sats,
"fedimint_sats": fedimint_sats,
"total_sats": cashu_sats + fedimint_sats,
"ark_sats": ark_sats,
"total_sats": cashu_sats + fedimint_sats + ark_sats,
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
"mint_url": wallet.mint_url,
}))
@@ -181,6 +184,8 @@ impl RpcHandler {
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
let mut transactions = wallet.transactions;
transactions.extend(fedimint_client::load_fedimint_txs(&self.config.data_dir).await);
// Ark movements from barkd (kind="ark"), best-effort like Fedimint.
transactions.extend(ark_client::load_ark_txs(&self.config.data_dir).await);
// Sort by RFC-3339 timestamp descending (string compare is valid for
// same-offset RFC-3339), newest first.
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+1 -3
View File
@@ -53,9 +53,7 @@ pub fn stack_member_app_ids(package_id: &str) -> &'static [&'static str] {
// The legacy umbrella id maps to the split stack (the orchestrator's
// umbrella alias handles this too; listing it here keeps the RPC
// layer's fan-out explicit).
"mempool" | "mempool-web" => {
&["archy-mempool-db", "mempool-api", "archy-mempool-web"]
}
"mempool" | "mempool-web" => &["archy-mempool-db", "mempool-api", "archy-mempool-web"],
_ => &[],
}
}
+59 -1
View File
@@ -29,6 +29,11 @@ const BACKUP_DIRS: &[&str] = &[
"credentials",
"tor-config",
"content",
// Per-node service secrets. Without these a restored node can't
// decrypt identity/lnd_aezeed.enc (the Lightning seed backup is
// encrypted with secrets/lnd-wallet-password) or reuse its service
// credentials. The archive itself is passphrase-encrypted.
"secrets",
];
/// Files within data_dir to include in a full backup.
@@ -101,7 +106,9 @@ pub async fn create_full_backup(
// Step 4: Write metadata
let metadata = BackupMetadata {
id: backup_id,
version: 2,
// v3: archive additionally carries the `secrets` dir (needed to
// decrypt identity/lnd_aezeed.enc on a restored node).
version: 3,
created_at: timestamp,
encrypted: true,
size_bytes: encrypted.len() as u64,
@@ -193,6 +200,14 @@ pub async fn restore_full_backup(data_dir: &Path, backup_id: &str, passphrase: &
.context("Failed to create rollback directory")?;
for dir_name in BACKUP_DIRS {
// Only displace live data the backup will actually replace —
// older archives don't contain every current BACKUP_DIRS entry
// (e.g. `secrets` was added later), and moving a live dir to
// rollback with nothing staged to take its place would DELETE it
// at cleanup time.
if !staging_dir.join(dir_name).exists() {
continue;
}
let src = data_dir.join(dir_name);
if src.exists() {
let dst = rollback_dir.join(dir_name);
@@ -207,6 +222,9 @@ pub async fn restore_full_backup(data_dir: &Path, backup_id: &str, passphrase: &
}
}
for file_name in BACKUP_FILES {
if !staging_dir.join(file_name).exists() {
continue;
}
let src = data_dir.join(file_name);
if src.exists() {
let dst = rollback_dir.join(file_name);
@@ -732,6 +750,46 @@ mod tests {
assert!(!bad_result.valid);
}
#[tokio::test]
async fn secrets_dir_rides_backup_and_restore() {
// The Lightning seed backup (identity/lnd_aezeed.enc) is encrypted
// with secrets/lnd-wallet-password — a backup that omits it can't
// recover the Lightning wallet on restore.
let dir = TempDir::new().unwrap();
setup_data_dir(dir.path());
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "s3cret").unwrap();
let meta = create_full_backup(dir.path(), "pass", None).await.unwrap();
std::fs::remove_dir_all(dir.path().join("secrets")).unwrap();
restore_full_backup(dir.path(), &meta.id, "pass").await.unwrap();
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
assert_eq!(pw, "s3cret");
}
#[tokio::test]
async fn restoring_old_backup_without_secrets_keeps_live_secrets() {
// Archives created before `secrets` joined BACKUP_DIRS don't stage
// one — the restore must NOT displace (and then delete) the node's
// live secrets in that case.
let dir = TempDir::new().unwrap();
setup_data_dir(dir.path());
// Backup taken while no secrets dir existed (mimics an old archive).
let meta = create_full_backup(dir.path(), "pass", None).await.unwrap();
// Live secrets appear afterwards.
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "keep-me").unwrap();
restore_full_backup(dir.path(), &meta.id, "pass").await.unwrap();
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
assert_eq!(pw, "keep-me");
}
#[tokio::test]
async fn backup_and_restore() {
let dir = TempDir::new().unwrap();
+3 -1
View File
@@ -131,7 +131,9 @@ pub async fn ensure_doctor_installed() {
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
}
match run_apps_dir_repair().await {
Ok(true) => info!("Populated /opt/archipelago/apps from installer copy at /etc/archipelago/apps"),
Ok(true) => {
info!("Populated /opt/archipelago/apps from installer copy at /etc/archipelago/apps")
}
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
}
+3 -11
View File
@@ -80,19 +80,11 @@ pub struct Config {
}
impl Config {
/// Detect primary host IP (first non-loopback IPv4)
/// Detect primary host IP (default-route interface, not `hostname -I` order)
async fn detect_host_ip() -> Result<String> {
let output = tokio::process::Command::new("hostname")
.args(["-I"])
.output()
Ok(crate::host_ip::primary_host_ipv4()
.await
.context("Failed to run hostname -I")?;
let s = String::from_utf8_lossy(&output.stdout);
let ip = s
.split_whitespace()
.find(|s| !s.starts_with("127.") && s.contains('.'))
.unwrap_or("127.0.0.1");
Ok(ip.to_string())
.unwrap_or_else(|| "127.0.0.1".to_string()))
}
pub async fn load() -> Result<Self> {
@@ -400,10 +400,7 @@ pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh>
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable")))
}
async fn fetch_one(
client: &reqwest::Client,
url: &str,
) -> anyhow::Result<(AppCatalog, String)> {
async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<(AppCatalog, String)> {
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("HTTP {}", resp.status());
@@ -510,7 +507,10 @@ mod tests {
// on the apps HashMap's nondeterministic key order (seen live on .228).
let dir = tempfile::tempdir().unwrap();
let body = r#"{"schema":1,"apps":{"demo":{"version":"1.0.0"}}}"#;
assert!(write_cache(dir.path(), body).unwrap(), "first write is a change");
assert!(
write_cache(dir.path(), body).unwrap(),
"first write is a change"
);
assert!(
!write_cache(dir.path(), body).unwrap(),
"identical rewrite is not a change"
@@ -372,6 +372,13 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/minmoto/fmcd".to_string(),
tier: "",
},
"barkd" | "bark" => AppMetadata {
title: "Ark Wallet".to_string(),
description: "Ark protocol wallet daemon (barkd) — self-custodial off-chain bitcoin via an Ark server (signet)".to_string(),
icon: "/assets/img/app-icons/bark.png".to_string(),
repo: "https://gitlab.com/ark-bitcoin/bark".to_string(),
tier: "",
},
"morphos" | "morphos-server" => AppMetadata {
title: "Morphos".to_string(),
description: "Self-hosted file converter".to_string(),
@@ -689,22 +696,11 @@ async fn netbird_configured_launch_url() -> Option<String> {
PodmanClient::lan_address_for("netbird")
}
/// First address from `hostname -I` — the node's primary host IP. Mirrors the
/// orchestrator's `detect_host_ip` so launch URLs match the cert/config the
/// orchestrator renders for `{{HOST_IP}}`.
/// The node's primary host IP. Mirrors the orchestrator's `detect_host_ip`
/// so launch URLs match the cert/config the orchestrator renders for
/// `{{HOST_IP}}`.
async fn first_host_ip() -> Option<String> {
let out = tokio::process::Command::new("hostname")
.arg("-I")
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.next()
.map(ToOwned::to_owned)
crate::host_ip::primary_host_ipv4().await
}
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
@@ -7,12 +7,8 @@
/// Registries images may be pulled from with an explicit host part.
/// (git.tx1138.com was removed 2026-07-10: the host is retired and must
/// never be pulled through again.)
pub const TRUSTED_REGISTRIES: &[&str] = &[
"docker.io",
"ghcr.io",
"localhost",
"146.59.87.168:3000",
];
pub const TRUSTED_REGISTRIES: &[&str] =
&["docker.io", "ghcr.io", "localhost", "146.59.87.168:3000"];
/// Validate a container image reference.
///
+14 -8
View File
@@ -727,14 +727,12 @@ pub async fn ensure_btcpay_lnd_connection_secret(secrets_dir: &std::path::Path)
Ok(s) => s,
Err(_) => return Ok(()), // LND not installed/provisioned yet
};
let thumbprint =
cert_sha256_thumbprint(&pem).context("computing LND tls.cert thumbprint")?;
let thumbprint = cert_sha256_thumbprint(&pem).context("computing LND tls.cert thumbprint")?;
let target = secrets_dir.join(BTCPAY_LND_CONNECTION_SECRET);
// Fast path (no sudo): existing secret already pins the current cert.
if let Ok(existing) = fs::read_to_string(&target).await {
if !existing.trim().is_empty()
&& existing.contains(&format!("certthumbprint={thumbprint}"))
if !existing.trim().is_empty() && existing.contains(&format!("certthumbprint={thumbprint}"))
{
return Ok(());
}
@@ -782,7 +780,9 @@ mod tests {
conf_path: tmp.path().join("lnd/lnd.conf"),
};
let out = ensure_config(&paths, "secret", "bitcoin-knots").await.unwrap();
let out = ensure_config(&paths, "secret", "bitcoin-knots")
.await
.unwrap();
assert_eq!(out, EnsureOutcome::Written);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
assert!(conf.contains("bitcoin.active=true"));
@@ -801,11 +801,15 @@ mod tests {
};
assert_eq!(
ensure_config(&paths, "first", "bitcoin-knots").await.unwrap(),
ensure_config(&paths, "first", "bitcoin-knots")
.await
.unwrap(),
EnsureOutcome::Written
);
assert_eq!(
ensure_config(&paths, "second", "bitcoin-knots").await.unwrap(),
ensure_config(&paths, "second", "bitcoin-knots")
.await
.unwrap(),
EnsureOutcome::Written
);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
@@ -854,7 +858,9 @@ mod tests {
.unwrap();
assert_eq!(
ensure_config(&paths, "repaired", "bitcoin-knots").await.unwrap(),
ensure_config(&paths, "repaired", "bitcoin-knots")
.await
.unwrap(),
EnsureOutcome::Written
);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
@@ -1468,9 +1468,7 @@ impl ProdContainerOrchestrator {
);
}
}
for (port, a, b) in
host_port_collisions(state.manifests.values().map(|lm| &lm.manifest))
{
for (port, a, b) in host_port_collisions(state.manifests.values().map(|lm| &lm.manifest)) {
tracing::error!(
port,
app_a = %a,
@@ -1666,7 +1664,10 @@ impl ProdContainerOrchestrator {
// and left the unit down for minutes (.228 mempool frontend, gate
// 2026-07-09). Skip this cycle; the worker owns the outcome.
if crate::app_ops::lifecycle_op_in_flight(&app_id) {
report.record(&app_id, ReconcileAction::Left("lifecycle-op-in-flight".into()));
report.record(
&app_id,
ReconcileAction::Left("lifecycle-op-in-flight".into()),
);
crate::crash_recovery::pending_boot_start_done(&app_id);
crate::crash_recovery::pending_boot_start_done(&container_name);
continue;
@@ -1773,10 +1774,9 @@ impl ProdContainerOrchestrator {
{
let state = self.state.read().await;
for (app, dep) in degraded_running_apps(
&report,
state.manifests.values().map(|lm| &lm.manifest),
) {
for (app, dep) in
degraded_running_apps(&report, state.manifests.values().map(|lm| &lm.manifest))
{
tracing::error!(
app_id = %app,
dependency = %dep,
@@ -3087,16 +3087,7 @@ impl ProdContainerOrchestrator {
}
async fn detect_host_ip() -> Option<String> {
let output = tokio::process::Command::new("hostname")
.arg("-I")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.split_whitespace().next().map(|s| s.to_string())
crate::host_ip::primary_host_ipv4().await
}
async fn detect_host_mdns() -> String {
@@ -3184,8 +3175,7 @@ impl ProdContainerOrchestrator {
// `optional` secret_env — btcpay must still start when LND is
// absent or the derivation fails, so log-and-continue.
if let Err(e) =
crate::container::lnd::ensure_btcpay_lnd_connection_secret(&self.secrets_dir)
.await
crate::container::lnd::ensure_btcpay_lnd_connection_secret(&self.secrets_dir).await
{
tracing::warn!(error = %e, "btcpay-lnd-connection secret not generated; btcpay will run without the internal LND node");
}
@@ -3260,20 +3250,17 @@ impl ProdContainerOrchestrator {
manifest.app.container.secret_env_refs = Vec::new();
manifest.app.container.secret_env_hash = None;
} else {
let hash =
archipelago_container::manifest::secret_env_content_hash(&secret_bearing);
let hash = archipelago_container::manifest::secret_env_content_hash(&secret_bearing);
let app_id = manifest.app.id.clone();
manifest.app.container.secret_env_refs = secret_bearing
.into_iter()
.map(|(key, value)| archipelago_container::manifest::SecretEnvRef {
secret_name: format!(
"archy-env-{}-{}",
app_id,
key.to_ascii_lowercase()
),
env_key: key,
value,
})
.map(
|(key, value)| archipelago_container::manifest::SecretEnvRef {
secret_name: format!("archy-env-{}-{}", app_id, key.to_ascii_lowercase()),
env_key: key,
value,
},
)
.collect();
manifest.app.container.secret_env_hash = Some(hash.clone());
@@ -3281,12 +3268,7 @@ impl ProdContainerOrchestrator {
// the steady-state reconcile free: podman is only consulted when
// the resolved content actually changed (or on first touch after
// boot). Mock runtimes no-op via the trait default.
let cached = self
.env_secret_cache
.lock()
.await
.get(&app_id)
.cloned();
let cached = self.env_secret_cache.lock().await.get(&app_id).cloned();
if cached.as_deref() != Some(hash.as_str()) {
self.runtime
.ensure_env_secrets(&manifest.app.container.secret_env_refs)
@@ -3926,7 +3908,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn start(&self, app_id: &str) -> Result<()> {
if let Some(members) = self.mempool_umbrella_members(app_id).await {
tracing::info!(app_id, "starting legacy umbrella id via split-stack members");
tracing::info!(
app_id,
"starting legacy umbrella id via split-stack members"
);
for (i, member) in members.iter().enumerate() {
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
@@ -3969,7 +3954,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn stop(&self, app_id: &str) -> Result<()> {
if let Some(members) = self.mempool_umbrella_members(app_id).await {
tracing::info!(app_id, "stopping legacy umbrella id via split-stack members");
tracing::info!(
app_id,
"stopping legacy umbrella id via split-stack members"
);
for member in members.iter().rev() {
Box::pin(self.stop(member))
.await
@@ -4036,7 +4024,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn restart(&self, app_id: &str) -> Result<()> {
if let Some(members) = self.mempool_umbrella_members(app_id).await {
tracing::info!(app_id, "restarting legacy umbrella id via split-stack members");
tracing::info!(
app_id,
"restarting legacy umbrella id via split-stack members"
);
for (i, member) in members.iter().enumerate() {
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
@@ -4239,9 +4230,8 @@ fn command_argv_drifted(
// therefore Config.Cmd) has them as spaces. Normalize both sides so the
// same script never reads as drift over line breaks alone (bitcoin-knots
// and fedimint-gateway on .228, 2026-07-08).
let norm = |v: &[String]| -> Vec<String> {
v.iter().map(|s| s.replace(['\r', '\n'], " ")).collect()
};
let norm =
|v: &[String]| -> Vec<String> { v.iter().map(|s| s.replace(['\r', '\n'], " ")).collect() };
let current_cmd = norm(current_cmd);
let expected_args = norm(expected_args);
let Some(expected_entry) = expected_entry else {
@@ -5508,14 +5498,20 @@ app:
("bitcoin-knots", ReconcileAction::Installed),
("lnd", ReconcileAction::NoOp),
]);
assert_eq!(cascade_pairs_for_report(&r, &none), vec![("bitcoin-knots", "lnd")]);
assert_eq!(
cascade_pairs_for_report(&r, &none),
vec![("bitcoin-knots", "lnd")]
);
// Backend merely started from stopped also moves the IP → cascade.
let r = report(vec![
("bitcoin-core", ReconcileAction::Started),
("lnd", ReconcileAction::NoOp),
]);
assert_eq!(cascade_pairs_for_report(&r, &none), vec![("bitcoin-core", "lnd")]);
assert_eq!(
cascade_pairs_for_report(&r, &none),
vec![("bitcoin-core", "lnd")]
);
// Backend untouched → no cascade.
let r = report(vec![
@@ -6016,7 +6012,9 @@ app:
let calls = rt.calls();
for name in ["archy-mempool-db", "mempool-api", "archy-mempool-web"] {
assert!(
calls.iter().any(|c| c == &format!("start_container:{name}")),
calls
.iter()
.any(|c| c == &format!("start_container:{name}")),
"{name} not started: {calls:?}"
);
}
+4 -1
View File
@@ -1258,7 +1258,10 @@ app:
assert!(u.read_only_root);
assert!(u.no_new_privileges);
assert_eq!(u.cap_add, vec!["NET_BIND_SERVICE"]);
assert_eq!(u.ports, vec![(8332, 8332, "tcp".to_string(), String::new())]);
assert_eq!(
u.ports,
vec![(8332, 8332, "tcp".to_string(), String::new())]
);
assert_eq!(u.environment, vec!["BITCOIN_NETWORK=mainnet"]);
assert_eq!(u.bind_mounts.len(), 1);
assert_eq!(
+3 -8
View File
@@ -607,14 +607,9 @@ mod prune_missing_content_tests {
availability: Availability::AllPeers,
added_at: "2026-01-01T00:00:00Z".to_string(),
};
save_catalog(
data_dir,
&ContentCatalog {
items: vec![item],
},
)
.await
.unwrap();
save_catalog(data_dir, &ContentCatalog { items: vec![item] })
.await
.unwrap();
// File was never written to disk under content/files/ or filebrowser/.
let result = serve_content(data_dir, "missing-item", None, None, None, None)
+8 -7
View File
@@ -60,8 +60,9 @@ pub fn is_recovery_complete() -> bool {
// the outcome — a container that truly failed goes back to showing its
// real state on the next scan.
static PENDING_BOOT_STARTS: std::sync::LazyLock<std::sync::RwLock<std::collections::HashSet<String>>> =
std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashSet::new()));
static PENDING_BOOT_STARTS: std::sync::LazyLock<
std::sync::RwLock<std::collections::HashSet<String>>,
> = std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashSet::new()));
/// Register container/app names an active recovery or reconcile pass
/// intends to start.
@@ -182,7 +183,10 @@ pub async fn load_user_uninstalled(data_dir: &Path) -> std::collections::HashSet
}
/// Save the set of user-uninstalled app/container names to disk.
pub async fn save_user_uninstalled(data_dir: &Path, uninstalled: &std::collections::HashSet<String>) {
pub async fn save_user_uninstalled(
data_dir: &Path,
uninstalled: &std::collections::HashSet<String>,
) {
let path = data_dir.join(USER_UNINSTALLED_FILE);
if let Ok(json) = serde_json::to_string_pretty(uninstalled) {
let _ = fs::write(&path, json).await;
@@ -244,10 +248,7 @@ pub async fn check_for_crash(data_dir: &Path) -> Result<Option<Vec<RunningContai
// that is not us and whose cmdline looks like the archipelago binary.
if !old_pid.is_empty() {
if let Ok(pid) = old_pid.parse::<u32>() {
if pid != std::process::id()
&& is_process_running(pid)
&& process_is_archipelago(pid)
{
if pid != std::process::id() && is_process_running(pid) && process_is_archipelago(pid) {
warn!(
"Previous process (PID {}) is still running — not a crash, skipping recovery",
pid
+4 -4
View File
@@ -360,8 +360,8 @@ mod tests {
None,
TrustLevel::Trusted,
)
.await
.unwrap();
.await
.unwrap();
assert!(code.starts_with("fed1:"));
let parsed = parse_invite(&code).unwrap();
@@ -384,8 +384,8 @@ mod tests {
Some(fips),
TrustLevel::Trusted,
)
.await
.unwrap();
.await
.unwrap();
let parsed = parse_invite(&code).unwrap();
assert_eq!(parsed.fips_npub.as_deref(), Some(fips));
}
+15 -1
View File
@@ -403,7 +403,21 @@ mod tests {
last_transport_at: None,
},
];
let state = build_local_state(vec![], 0.0, 0, 0, 0, 0, 0, true, None, None, None, &peers, None);
let state = build_local_state(
vec![],
0.0,
0,
0,
0,
0,
0,
true,
None,
None,
None,
&peers,
None,
);
assert_eq!(state.federated_peers.len(), 1);
assert_eq!(state.federated_peers[0].did, "did:key:zTrusted");
assert_eq!(
+10 -2
View File
@@ -475,7 +475,12 @@ async fn check_containers() -> Vec<ContainerHealth> {
let podman_health = parse_podman_health(c, &state);
let host_ports = host_tcp_ports_from_container(c);
let host_port_ready = if host_ports.is_empty() {
// Only raw-probe published ports for containers WITHOUT their own
// podman healthcheck. The healthcheck is the better signal, and the
// bare TCP connect+close is noisy against TLS listeners — LND logged
// "http: TLS handshake error … EOF" on every monitor cycle because
// this probe hit its REST/gRPC ports and hung up mid-handshake.
let host_port_ready = if host_ports.is_empty() || podman_health.is_some() {
None
} else {
Some(host_ports_ready(&host_ports).await)
@@ -1486,7 +1491,10 @@ mod tests {
#[tokio::test]
async fn lifecycle_guard_covers_name_app_id_and_legacy_underscores() {
assert!(!lifecycle_op_covers_container("hm-guard-app", "hm-guard-app"));
assert!(!lifecycle_op_covers_container(
"hm-guard-app",
"hm-guard-app"
));
// Held package lock covers the container directly and via stack
// membership; the '_'→'-' probe covers legacy underscore names.
+135
View File
@@ -0,0 +1,135 @@
//! Primary host LAN IPv4 detection.
//!
//! `hostname -I` lists addresses in interface-creation order, so once a VPN
//! or bridge interface exists (NetBird's WireGuard tunnel, br-tollgate, …)
//! its address can sort ahead of the real NIC — a fresh-ISO node handed out
//! `https://10.44.0.1:8087` as NetBird's launch URL instead of the LAN IP.
//! The main routing table's default route names the physical uplink even when
//! a VPN is active (NetBird/Tailscale steer traffic via policy-routing rules
//! in separate tables, not by replacing the main-table default), so that is
//! the authoritative source, with `hostname -I` kept only as the last resort
//! for hosts with no default route at all.
/// The node's primary LAN IPv4, as a string.
///
/// Resolution order:
/// 1. `src`/`dev` of the main-table default route (`ip -4 route show default`)
/// 2. source address of a connected UDP socket (never transmits)
/// 3. first non-loopback IPv4 from `hostname -I` (legacy behaviour)
pub(crate) async fn primary_host_ipv4() -> Option<String> {
if let Some(ip) = default_route_ip().await {
return Some(ip);
}
if let Some(ip) = udp_route_ip() {
return Some(ip);
}
hostname_i_ip().await
}
async fn default_route_ip() -> Option<String> {
let out = tokio::process::Command::new("ip")
.args(["-4", "route", "show", "default"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
let route = String::from_utf8_lossy(&out.stdout);
if let Some(ip) = parse_route_src(&route) {
return Some(ip);
}
// No `src` hint on the route — resolve the device's global address.
let dev = parse_route_dev(&route)?;
let out = tokio::process::Command::new("ip")
.args(["-4", "-o", "addr", "show", "dev", &dev, "scope", "global"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
parse_addr_inet(&String::from_utf8_lossy(&out.stdout))
}
fn parse_route_src(route: &str) -> Option<String> {
field_after(route.lines().next()?, "src")
}
fn parse_route_dev(route: &str) -> Option<String> {
field_after(route.lines().next()?, "dev")
}
fn field_after(line: &str, key: &str) -> Option<String> {
let mut words = line.split_whitespace();
while let Some(w) = words.next() {
if w == key {
return words.next().map(ToOwned::to_owned);
}
}
None
}
fn parse_addr_inet(out: &str) -> Option<String> {
let cidr = field_after(out.lines().next()?, "inet")?;
Some(cidr.split('/').next().unwrap_or(&cidr).to_string())
}
/// A connected UDP socket's local address is the source IP the kernel would
/// use to reach the peer; nothing is sent. Can still land on a tunnel IP when
/// a VPN policy-routes all traffic, hence only a fallback.
fn udp_route_ip() -> Option<String> {
let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("8.8.8.8:80").ok()?;
match sock.local_addr().ok()?.ip() {
std::net::IpAddr::V4(v4) if !v4.is_loopback() && !v4.is_unspecified() => {
Some(v4.to_string())
}
_ => None,
}
}
async fn hostname_i_ip() -> Option<String> {
let out = tokio::process::Command::new("hostname")
.arg("-I")
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.find(|s| !s.starts_with("127.") && s.contains('.'))
.map(ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_src_wins() {
let route = "default via 192.168.1.254 dev wlp3s0 proto dhcp src 192.168.1.116 metric 600";
assert_eq!(parse_route_src(route).as_deref(), Some("192.168.1.116"));
}
#[test]
fn route_dev_without_src() {
let route = "default via 192.168.1.1 dev enp0s31f6 proto static";
assert_eq!(parse_route_src(route), None);
assert_eq!(parse_route_dev(route).as_deref(), Some("enp0s31f6"));
}
#[test]
fn addr_inet_strips_prefix() {
let out = "3: wlp3s0 inet 192.168.1.65/24 brd 192.168.1.255 scope global dynamic noprefixroute wlp3s0\\ valid_lft 85328sec preferred_lft 85328sec";
assert_eq!(parse_addr_inet(out).as_deref(), Some("192.168.1.65"));
}
#[test]
fn empty_route_table() {
assert_eq!(parse_route_src(""), None);
assert_eq!(parse_route_dev(""), None);
}
}
+2 -2
View File
@@ -50,6 +50,7 @@ mod electrs_status;
mod federation;
mod fips;
mod health_monitor;
mod host_ip;
mod identity;
mod identity_manager;
mod marketplace;
@@ -107,8 +108,7 @@ async fn main() -> Result<()> {
// RUST_LOG=archipelago=debug) to get debug logs back when debugging.
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
@@ -162,7 +162,10 @@ async fn status_line() -> String {
} else {
format!("electrum {:.0}%", e.progress_pct)
};
format!("Archipelago OS v{}: {btc}, {elec}.", env!("CARGO_PKG_VERSION"))
format!(
"Archipelago OS v{}: {btc}, {elec}.",
env!("CARGO_PKG_VERSION")
)
}
#[cfg(test)]
+45 -28
View File
@@ -141,7 +141,9 @@ impl MeshRadioDevice {
anyhow::bail!("Native image send is Reticulum-only")
}
Self::Reticulum(device) => {
device.send_native_image(dest_pubkey_prefix, mime, bytes, caption).await
device
.send_native_image(dest_pubkey_prefix, mime, bytes, caption)
.await
}
}
}
@@ -297,7 +299,9 @@ async fn auto_detect_and_open(
info!(path = %path, "Found Reticulum (RNode) device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Reticulum(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Reticulum daemon failed to initialize"),
Err(e) => {
debug!(path = %path, error = %e, "Reticulum daemon failed to initialize")
}
},
Err(e) => debug!(path = %path, error = %e, "Not a Reticulum RNode"),
}
@@ -323,7 +327,9 @@ async fn auto_detect_and_open(
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
Err(e) => {
debug!(path = %path, error = %e, "Could not open serial port for Meshtastic")
}
}
}
}
@@ -399,7 +405,9 @@ async fn open_preferred_path(
{
Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Reticulum(dev), info)),
Err(e) => debug!(path = %path, error = %e, "Preferred path is not a working Reticulum RNode"),
Err(e) => {
debug!(path = %path, error = %e, "Preferred path is not a working Reticulum RNode")
}
},
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Reticulum"),
}
@@ -431,26 +439,22 @@ async fn open_reticulum_tcp(
our_x25519_pubkey_hex: &str,
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
let mut dev = match cfg {
ReticulumTcpConfig::Server { bind } => {
ReticulumLink::open_tcp_server(
bind,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
.context("Could not open Reticulum TCP server interface")?
}
ReticulumTcpConfig::Client { connect } => {
ReticulumLink::open_tcp_client(
connect,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
.context("Could not open Reticulum TCP client interface")?
}
ReticulumTcpConfig::Server { bind } => ReticulumLink::open_tcp_server(
bind,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
.context("Could not open Reticulum TCP server interface")?,
ReticulumTcpConfig::Client { connect } => ReticulumLink::open_tcp_client(
connect,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
.context("Could not open Reticulum TCP client interface")?,
};
let info = dev
.initialize()
@@ -790,7 +794,9 @@ fn meshtastic_contact_id(public_key_hex: &str) -> Option<u32> {
fn reticulum_contact_id(public_key_hex: &str) -> Option<u32> {
let bytes = hex::decode(public_key_hex).ok()?;
let hash: [u8; 16] = bytes.try_into().ok()?;
Some(super::super::reticulum::reticulum_contact_id_from_hash(&hash))
Some(super::super::reticulum::reticulum_contact_id_from_hash(
&hash,
))
}
/// Drain any queued messages from the device.
@@ -873,12 +879,23 @@ pub(super) async fn run_mesh_session(
"Preferred path {} probe failed: {} — trying auto-detect",
path, e
);
auto_detect_and_open(data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, device_kind)
.await?
auto_detect_and_open(
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
device_kind,
)
.await?
}
}
} else {
auto_detect_and_open(data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, device_kind).await?
auto_detect_and_open(
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
device_kind,
)
.await?
};
// Update status
+30 -13
View File
@@ -414,7 +414,11 @@ impl MeshtasticDevice {
// tx_power defaults to max, which is what we want for a stock mesh.
let mut lora = Vec::new();
encode_varint_field_into(LORA_USE_PRESET_FIELD, 1, &mut lora);
encode_varint_field_into(LORA_MODEM_PRESET_FIELD, LORA_MODEM_PRESET_LONG_FAST, &mut lora);
encode_varint_field_into(
LORA_MODEM_PRESET_FIELD,
LORA_MODEM_PRESET_LONG_FAST,
&mut lora,
);
encode_varint_field_into(LORA_REGION_FIELD, region_code as u64, &mut lora);
encode_varint_field_into(LORA_HOP_LIMIT_FIELD, 3, &mut lora);
encode_varint_field_into(LORA_TX_ENABLED_FIELD, 1, &mut lora);
@@ -772,7 +776,9 @@ impl MeshtasticDevice {
if let Err(e) = self.send_to_radio(&encode_want_config()).await {
warn!("Failed to re-request config after radio reboot: {}", e);
} else {
info!("Re-requested Meshtastic config after reboot — packet stream resubscribed");
info!(
"Re-requested Meshtastic config after reboot — packet stream resubscribed"
);
}
}
if let Some(inbound) = inbound {
@@ -890,7 +896,10 @@ impl MeshtasticDevice {
if let Some((region, modem_preset)) = parse_config_lora_region(value) {
self.current_region = Some(region);
self.current_modem_preset = Some(modem_preset);
debug!(region, modem_preset, "Meshtastic LoRa region/preset from device config");
debug!(
region,
modem_preset, "Meshtastic LoRa region/preset from device config"
);
}
None
}
@@ -1066,7 +1075,10 @@ fn packet_to_inbound_frame(
if packet.portnum == POSITION_APP {
if let Some((lat, lon)) = parse_position_lat_lon(&packet.payload) {
debug!(from = format!("!{:08x}", from), lat, lon, "Meshtastic position update");
debug!(
from = format!("!{:08x}", from),
lat, lon, "Meshtastic position update"
);
contact.lat = Some(lat);
contact.lon = Some(lon);
}
@@ -1811,10 +1823,10 @@ mod tests {
encode_fixed32_field(1, 0x1111_2222, &mut packet); // from
encode_len_field(4, &decoded, &mut packet); // decoded
encode_fixed32_field(8, (-7.5f32).to_bits(), &mut packet); // rx_snr
// int32 rx_rssi=-92 dBm, protobuf varint-encodes a negative int32 as
// the 10-byte two's-complement-extended varint; truncating back to
// i32 after decode recovers the original value (see parse_mesh_packet's
// `v as i32` cast — confirmed by this roundtrip, not just asserted).
// int32 rx_rssi=-92 dBm, protobuf varint-encodes a negative int32 as
// the 10-byte two's-complement-extended varint; truncating back to
// i32 after decode recovers the original value (see parse_mesh_packet's
// `v as i32` cast — confirmed by this roundtrip, not just asserted).
encode_varint_field_into(12, (-92i32) as u32 as u64, &mut packet);
let parsed = parse_mesh_packet(&packet).expect("packet should parse");
@@ -1901,7 +1913,10 @@ mod tests {
// must still update the contact's signal/position bookkeeping.
let frame =
packet_to_inbound_frame(&packet, Some(0x1111_1111), &mut contacts, &mut peer_pubkeys);
assert!(frame.is_none(), "POSITION_APP must not surface as a chat frame");
assert!(
frame.is_none(),
"POSITION_APP must not surface as a chat frame"
);
let contact = contacts.get(&from).expect("contact should be tracked");
assert_eq!(contact.snr, Some(-6.0));
assert_eq!(contact.rssi, Some(-80));
@@ -1930,7 +1945,10 @@ mod tests {
// A `to == BROADCAST_NUM` text is a channel broadcast (3ccc on public
// LongFast), so it routes to the channel thread, carrying its sender.
assert_eq!(frame.code, protocol::RESP_MESHTASTIC_CHANNEL_TEXT);
assert_eq!(frame.data[0], 0, "no channel field set => primary/public (0)");
assert_eq!(
frame.data[0], 0,
"no channel field set => primary/public (0)"
);
assert_eq!(&frame.data[1..7], &[0xcc, 0x3c, 0x00, 0x00, 0x6d, 0x65]);
assert_eq!(&frame.data[7..], b"hello from 3ccc");
assert!(contacts.contains_key(&from));
@@ -1954,9 +1972,8 @@ mod tests {
encode_len_field(4, &decoded, &mut packet);
encode_fixed32_field(7, 12_345, &mut packet);
let frame =
packet_to_inbound_frame(&packet, Some(me), &mut contacts, &mut peer_pubkeys)
.expect("directed DM must surface");
let frame = packet_to_inbound_frame(&packet, Some(me), &mut contacts, &mut peer_pubkeys)
.expect("directed DM must surface");
assert_eq!(frame.code, protocol::RESP_CONTACT_MSG_V3);
let (sender_prefix, payload, _snr) =
protocol::parse_contact_msg_v3_raw(&frame.data).unwrap();
+14 -6
View File
@@ -213,7 +213,11 @@ pub struct TypedEnvelope {
/// Unix timestamp (seconds since epoch).
pub ts: u32,
/// Optional Ed25519 signature of (t || v || ts_bytes) — for signed messages.
#[serde(default, skip_serializing_if = "Option::is_none", with = "compact_bytes_opt")]
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "compact_bytes_opt"
)]
pub sig: Option<Vec<u8>>,
/// Message sequence number (per-sender, monotonically increasing).
#[serde(default)]
@@ -564,7 +568,11 @@ pub struct ContentRefPayload {
pub mime: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "base64_opt_bytes")]
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "base64_opt_bytes"
)]
pub thumb_bytes: Option<Vec<u8>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
@@ -860,8 +868,8 @@ mod tests {
use rand::rngs::OsRng;
let key = SigningKey::generate(&mut OsRng);
let envelope = TypedEnvelope::new(MeshMessageType::Alert, b"test".to_vec())
.signed_with_seq(42, &key);
let envelope =
TypedEnvelope::new(MeshMessageType::Alert, b"test".to_vec()).signed_with_seq(42, &key);
assert!(envelope.verify_signature(&key.verifying_key()).unwrap());
// v2 binds seq: replaying the signed envelope under a different
@@ -880,8 +888,8 @@ mod tests {
// preimage, no seq) and allocate seq afterwards; verify must still
// accept that.
let key = SigningKey::generate(&mut OsRng);
let envelope = TypedEnvelope::new_signed(MeshMessageType::Alert, b"test".to_vec(), &key)
.with_seq(7);
let envelope =
TypedEnvelope::new_signed(MeshMessageType::Alert, b"test".to_vec(), &key).with_seq(7);
assert!(envelope.verify_signature(&key.verifying_key()).unwrap());
}
+4 -1
View File
@@ -2327,7 +2327,10 @@ mod tests {
// Force the dev fallback even if a packaged binary happens to exist
// on this machine's PATH convention — this test wants exactly the
// freshly-built venv daemon under test.
std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", "/nonexistent-force-dev-fallback");
std::env::set_var(
"ARCHY_RETICULUM_DAEMON_BIN",
"/nonexistent-force-dev-fallback",
);
let data_dir = root.path().join("node");
std::fs::create_dir_all(data_dir.join("identity")).unwrap();
+50 -19
View File
@@ -270,7 +270,9 @@ impl ReticulumLink {
our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>,
) -> Result<Self> {
probe_rnode(path).await.context("RNode KISS detect failed")?;
probe_rnode(path)
.await
.context("RNode KISS detect failed")?;
Self::spawn(
ReticulumInterface::Serial(path),
data_dir,
@@ -342,8 +344,9 @@ impl ReticulumLink {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = tokio::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700))
.await;
let _ =
tokio::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700))
.await;
}
let label = iface.label();
let iface_key = label.replace(['/', ' ', ':', ','], "_");
@@ -553,7 +556,11 @@ impl ReticulumLink {
Ok(())
}
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], payload: &[u8]) -> Result<()> {
pub async fn send_text_msg(
&mut self,
dest_pubkey_prefix: &[u8; 6],
payload: &[u8],
) -> Result<()> {
let dest_hash = self
.prefix_to_hash
.get(dest_pubkey_prefix)
@@ -738,11 +745,9 @@ impl ReticulumLink {
async fn drain_events(&mut self) {
loop {
let mut line = String::new();
let read = tokio::time::timeout(
Duration::from_millis(20),
self.reader.read_line(&mut line),
)
.await;
let read =
tokio::time::timeout(Duration::from_millis(20), self.reader.read_line(&mut line))
.await;
let n = match read {
Ok(Ok(n)) => n,
_ => break, // timeout (no data) or read error — stop draining
@@ -840,7 +845,8 @@ impl ReticulumLink {
// to survive a restart — give it a placeholder name (the real
// one, if any, arrives via a later "announce" and overwrites
// this) so its routing entry alone doesn't get lost.
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash) {
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash)
{
e.insert(ReticulumPeer {
dest_hash: source_hash,
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
@@ -858,13 +864,22 @@ impl ReticulumLink {
// UI (dispatch.rs's existing ContentInline handling, zero new
// frontend code) instead of the plain text bytes below.
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let caption = ev.get("content").and_then(Value::as_str).filter(|s| !s.trim().is_empty());
let caption = ev
.get("content")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty());
if let (Some(fmt), Some(b64)) = (
ev.get("image_format").and_then(Value::as_str),
ev.get("image_b64").and_then(Value::as_str),
) {
if let Ok(bytes) = B64.decode(b64) {
match build_content_inline_frame(&prefix, image_format_to_mime(fmt), None, caption, bytes) {
match build_content_inline_frame(
&prefix,
image_format_to_mime(fmt),
None,
caption,
bytes,
) {
Ok(frame) => {
self.inbound.push_back(frame);
return;
@@ -905,7 +920,8 @@ impl ReticulumLink {
},
None => content_str.as_bytes().to_vec(),
};
self.inbound.push_back(build_synthetic_frame(&prefix, &content));
self.inbound
.push_back(build_synthetic_frame(&prefix, &content));
}
Some("resource_recv") => {
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else {
@@ -916,7 +932,8 @@ impl ReticulumLink {
};
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, source_hash);
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash) {
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash)
{
e.insert(ReticulumPeer {
dest_hash: source_hash,
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
@@ -939,7 +956,8 @@ impl ReticulumLink {
// Resources are already a binary-safe whole-blob transfer),
// so this is the same payload shape `decode.rs` already
// accepts for a single-frame (non-chunked) typed envelope.
self.inbound.push_back(build_synthetic_frame(&prefix, &data));
self.inbound
.push_back(build_synthetic_frame(&prefix, &data));
}
Some("resource_progress") => {
debug!(
@@ -1138,7 +1156,14 @@ mod tests {
#[test]
fn detect_resp_found_in_kiss_stream() {
let stream = [0x10, 0x20, KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP, 0x99];
let stream = [
0x10,
0x20,
KISS_FEND,
KISS_CMD_DETECT,
KISS_DETECT_RESP,
0x99,
];
assert!(contains_detect_resp(&stream));
}
@@ -1154,7 +1179,9 @@ mod tests {
async fn probe_rnode_detects_real_hardware() {
let port = std::env::var("ARCHY_RNODE_TEST_PORT")
.expect("set ARCHY_RNODE_TEST_PORT to the RNode's serial path");
probe_rnode(&port).await.expect("KISS detect probe failed against real hardware");
probe_rnode(&port)
.await
.expect("KISS detect probe failed against real hardware");
}
#[test]
@@ -1181,7 +1208,8 @@ mod tests {
arch_pubkey_hex: Some("abcdef".to_string()),
}];
let dir = std::env::temp_dir().join(format!("archy-reticulum-peers-test-{}", std::process::id()));
let dir =
std::env::temp_dir().join(format!("archy-reticulum-peers-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("peers.json");
std::fs::write(&path, serde_json::to_vec(&persisted).unwrap()).unwrap();
@@ -1207,7 +1235,10 @@ mod tests {
h
};
let id = reticulum_contact_id_from_hash(&hash_high_bit);
assert!(id < 0x8000_0000, "must not collide with federation-synthetic space");
assert!(
id < 0x8000_0000,
"must not collide with federation-synthetic space"
);
assert_ne!(id, 0);
let zero_hash = [0u8; 16];
+1
View File
@@ -19,6 +19,7 @@ const RESERVED_PORTS: &[u16] = &[
23000, // BTCPay
8173, 8174, 8175, // Fedimint
8178, // Fedimint client daemon (fedimint-clientd REST)
3535, // Ark wallet daemon (barkd REST)
8123, // Home Assistant
3000, // Grafana
11434, // Ollama
+3 -1
View File
@@ -614,7 +614,9 @@ mod tests {
#[tokio::test]
async fn test_lnd_aezeed_empty_rejected() {
let dir = tempfile::tempdir().unwrap();
assert!(save_lnd_aezeed_encrypted(dir.path(), &[], "x").await.is_err());
assert!(save_lnd_aezeed_encrypted(dir.path(), &[], "x")
.await
.is_err());
}
#[tokio::test]
+8 -4
View File
@@ -89,8 +89,10 @@ impl Server {
if let Ok(loc) = serde_json::from_slice::<serde_json::Value>(&bytes) {
data.server_info.lat = loc.get("lat").and_then(|v| v.as_f64());
data.server_info.lon = loc.get("lon").and_then(|v| v.as_f64());
data.server_info.share_location =
loc.get("share_location").and_then(|v| v.as_bool()).unwrap_or(false);
data.server_info.share_location = loc
.get("share_location")
.and_then(|v| v.as_bool())
.unwrap_or(false);
}
}
data.server_info.tor_address = docker_packages::read_tor_address("archipelago").await;
@@ -1265,8 +1267,9 @@ fn merge_preserving_transitional(
/// scan is the owner: once podman reports a settled state and the id is no
/// longer queued for a boot start, the fresh state wins immediately instead
/// of being preserved for the transitional-stuck timeout.
static SCANNER_RESTARTING: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
static SCANNER_RESTARTING: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashSet<String>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
fn take_scanner_restarting(id: &str) -> bool {
SCANNER_RESTARTING
@@ -1594,6 +1597,7 @@ fn fallback_package_port(app_id: &str) -> Option<u16> {
match app_id {
"fedimint" | "fedimintd" => Some(8175),
"fedimint-clientd" => Some(8178),
"barkd" => Some(3535),
"filebrowser" => Some(8083),
"indeedhub" => Some(7778),
"nginx-proxy-manager" => Some(8081),
+4 -1
View File
@@ -67,7 +67,10 @@ pub async fn sweep_once(data_dir: &Path) -> Result<u64> {
match ecash::receive_token(data_dir, token).await {
Ok(amount) => {
received_total += amount;
info!(amount_sats = amount, "swept TollGate ecash into local wallet");
info!(
amount_sats = amount,
"swept TollGate ecash into local wallet"
);
}
Err(e) => {
// The token is still in this log line if this happens — not
+7 -5
View File
@@ -355,8 +355,7 @@ fn parse_and_verify_manifest(raw: serde_json::Value) -> Result<(UpdateManifest,
crate::trust::SignatureStatus::Verified { anchored, .. } => anchored,
crate::trust::SignatureStatus::Unsigned => false,
};
let manifest: UpdateManifest =
serde_json::from_value(raw).context("parse update manifest")?;
let manifest: UpdateManifest = serde_json::from_value(raw).context("parse update manifest")?;
Ok((manifest, signed))
}
@@ -625,8 +624,8 @@ pub async fn verify_pending_update(data_dir: &Path) {
// .116 during the v1.7.40 rollout recovery.
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
let deadline = std::time::Instant::now()
+ std::time::Duration::from_secs(PENDING_VERIFY_WINDOW_SECS);
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(PENDING_VERIFY_WINDOW_SECS);
while std::time::Instant::now() < deadline {
attempt += 1;
@@ -2087,7 +2086,10 @@ mod tests {
key
}
fn sign_value(key: &ed25519_dalek::SigningKey, mut doc: serde_json::Value) -> serde_json::Value {
fn sign_value(
key: &ed25519_dalek::SigningKey,
mut doc: serde_json::Value,
) -> serde_json::Value {
let (sig, did) = crate::trust::signed_doc::sign_detached(key, &doc).unwrap();
let obj = doc.as_object_mut().unwrap();
obj.insert("signed_by".into(), serde_json::json!(did));
+486
View File
@@ -0,0 +1,486 @@
//! Thin HTTP bridge to the `barkd` sidecar container (Ark protocol).
//!
//! Same shape as [`super::fedimint_client`]: the heavy `bark-wallet` SDK stays
//! OUT of this binary. The `barkd` daemon (in `apps/barkd`) holds the Ark
//! wallet (VTXOs, rounds, unilateral exits) and we speak its REST API
//! (`/api/v1/*`, Bearer auth). Endpoint/JSON shapes target barkd 0.3.0 and
//! must be pinned to the vendored image tag.
//!
//! ARK is on-chain-anchored: VTXOs expire (`vtxo_expiry_delta` blocks) and the
//! barkd daemon refreshes them by joining rounds on its own — the bridge never
//! has to schedule anything. Unlike Cashu/Fedimint, funds survive the sidecar
//! dying (the wallet mnemonic in barkd's datadir can unilaterally exit
//! on-chain), so back up `/var/lib/archipelago/barkd`.
use anyhow::{Context, Result};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
const BARKD_TIMEOUT_SECS: u64 = 15;
/// Send/board/offboard can wait on Ark round participation (signet rounds run
/// every 5 minutes), so give mutating calls generous room.
const BARKD_HEAVY_TIMEOUT_SECS: u64 = 120;
/// Default host port the `barkd` container is mapped to (its in-container
/// REST port; 3535 is unused elsewhere on the node — see `port_allocator`).
const DEFAULT_BARKD_URL: &str = "http://127.0.0.1:3535";
/// Shared secret between the barkd container and this bridge. The barkd
/// manifest generates it via `generated_secrets: [{barkd-secret, hex32}]`; the
/// container entrypoint installs it with `barkd secret refresh --secret` and
/// the bridge derives the matching Bearer token from the same file.
const BARKD_SECRET: &str = "barkd-secret";
/// Wallet configuration used when the bridge has to create the barkd wallet
/// (first use). Persisted so operators can point at their own Ark server.
/// Defaults target Second's public signet deployment while Ark matures —
/// mainnet needs an explicit opt-in edit of `wallet/ark_config.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArkConfig {
pub network: String,
pub ark_server: String,
pub esplora: String,
}
impl Default for ArkConfig {
fn default() -> Self {
Self {
network: "signet".to_string(),
ark_server: "https://ark.signet.2nd.dev".to_string(),
esplora: "https://esplora.signet.2nd.dev".to_string(),
}
}
}
const ARK_CONFIG_FILE: &str = "wallet/ark_config.json";
pub async fn load_config(data_dir: &Path) -> ArkConfig {
match fs::read_to_string(data_dir.join(ARK_CONFIG_FILE)).await {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => ArkConfig::default(),
}
}
pub async fn save_config(data_dir: &Path, config: &ArkConfig) -> Result<()> {
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
.context("Failed to create wallet dir")?;
let content = serde_json::to_string_pretty(config).context("Failed to serialize ark config")?;
fs::write(data_dir.join(ARK_CONFIG_FILE), content)
.await
.context("Failed to write ark config")?;
Ok(())
}
/// Encode barkd's Bearer token from the raw 32-byte shared secret:
/// base64url-nopad of `<version 0x00><32-byte secret>` (see barkd `AuthToken`).
fn encode_auth_token(secret: &[u8; 32]) -> String {
let mut buf = Vec::with_capacity(33);
buf.push(0u8);
buf.extend_from_slice(secret);
URL_SAFE_NO_PAD.encode(&buf)
}
fn secret_hex_to_token(hex: &str) -> Result<String> {
let hex = hex.trim();
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
anyhow::bail!("barkd-secret must be exactly 64 hex characters");
}
let mut secret = [0u8; 32];
for (i, byte) in secret.iter_mut().enumerate() {
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).expect("validated hex");
}
Ok(encode_auth_token(&secret))
}
/// HTTP client for a `barkd` instance.
pub struct ArkClient {
base_url: String,
token: String,
client: reqwest::Client,
}
impl ArkClient {
pub fn new(base_url: &str, token: &str) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(BARKD_HEAVY_TIMEOUT_SECS))
.build()
.context("Failed to build HTTP client for barkd")?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_string(),
token: token.to_string(),
client,
})
}
/// Resolve URL + auth token from env / node secret, with sane defaults.
/// URL: `BARKD_URL` else the default mapped port. Token: `BARKD_TOKEN`
/// (already-encoded Bearer token) else derived from the shared
/// `barkd-secret` the manifest generated for the container.
pub async fn from_node(data_dir: &Path) -> Result<Self> {
let base_url = std::env::var("BARKD_URL").unwrap_or_else(|_| DEFAULT_BARKD_URL.to_string());
let token = match std::env::var("BARKD_TOKEN") {
Ok(t) if !t.is_empty() => t,
_ => {
let path = data_dir.join("secrets").join(BARKD_SECRET);
let hex = fs::read_to_string(&path).await.context(
"Ark wallet not configured (no BARKD_TOKEN and no barkd-secret \
secret). Install the Ark (barkd) app.",
)?;
secret_hex_to_token(&hex)?
}
};
Self::new(&base_url, &token)
}
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
req.bearer_auth(&self.token)
}
async fn get(&self, path: &str) -> Result<serde_json::Value> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.auth(self.client.get(&url))
.timeout(std::time::Duration::from_secs(BARKD_TIMEOUT_SECS))
.send()
.await
.with_context(|| format!("barkd GET {path} failed (is it running?)"))?;
Self::parse(resp, path).await
}
async fn post(&self, path: &str, body: serde_json::Value) -> Result<serde_json::Value> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.auth(self.client.post(&url))
.json(&body)
.send()
.await
.with_context(|| format!("barkd POST {path} failed (is it running?)"))?;
Self::parse(resp, path).await
}
async fn parse(resp: reqwest::Response, path: &str) -> Result<serde_json::Value> {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
// barkd errors are `{"message": "..."}`; surface the message.
let msg = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
.unwrap_or(text);
anyhow::bail!("barkd {path} returned {status}: {msg}");
}
if text.is_empty() {
return Ok(serde_json::json!({}));
}
serde_json::from_str(&text)
.with_context(|| format!("barkd {path} returned non-JSON: {text}"))
}
/// `GET /api/v1/wallet` — wallet info (fingerprint, network, config).
/// Errors with "No wallet set" until `create_wallet` has run.
pub async fn wallet_info(&self) -> Result<serde_json::Value> {
self.get("/api/v1/wallet").await
}
/// `POST /api/v1/wallet/create` — create (or restore, with a mnemonic) the
/// barkd wallet. Idempotent guard is on the caller (`ensure_wallet`).
pub async fn create_wallet(&self, config: &ArkConfig) -> Result<serde_json::Value> {
self.post(
"/api/v1/wallet/create",
serde_json::json!({
"network": config.network,
"ark_server": config.ark_server,
"chain_source": { "esplora": { "url": config.esplora } },
}),
)
.await
}
/// `GET /api/v1/wallet/balance` — off-chain balance breakdown, in sats.
pub async fn balance(&self) -> Result<serde_json::Value> {
self.get("/api/v1/wallet/balance").await
}
/// Spendable off-chain sats (0 on any missing field, never an error once
/// the call itself succeeds).
pub async fn spendable_sats(&self) -> Result<u64> {
let bal = self.balance().await?;
Ok(bal
.get("spendable_sat")
.and_then(|v| v.as_u64())
.unwrap_or(0))
}
/// `GET /api/v1/onchain/balance` — the wallet's on-chain (boarding) funds.
pub async fn onchain_balance(&self) -> Result<serde_json::Value> {
self.get("/api/v1/onchain/balance").await
}
/// `POST /api/v1/wallet/addresses/next` — fresh Ark (`tark1…`) address.
pub async fn ark_address(&self) -> Result<String> {
let res = self
.post("/api/v1/wallet/addresses/next", serde_json::json!({}))
.await?;
res.get("address")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("barkd address: no address in response"))
}
/// `POST /api/v1/onchain/addresses/next` — fresh on-chain boarding address.
pub async fn onchain_address(&self) -> Result<String> {
let res = self
.post("/api/v1/onchain/addresses/next", serde_json::json!({}))
.await?;
res.get("address")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("barkd onchain address: no address in response"))
}
/// `POST /api/v1/wallet/send` — pay an Ark address, BOLT11 invoice, LNURL
/// or lightning address from off-chain funds. Returns the movement barkd
/// reports for the payment.
pub async fn send(
&self,
destination: &str,
amount_sats: Option<u64>,
comment: Option<&str>,
) -> Result<serde_json::Value> {
self.post(
"/api/v1/wallet/send",
serde_json::json!({
"destination": destination,
"amount_sat": amount_sats,
"comment": comment,
}),
)
.await
}
/// `POST /api/v1/lightning/receives/invoice` — BOLT11 invoice that lands
/// as an Ark VTXO when paid.
pub async fn lightning_invoice(&self, amount_sats: u64) -> Result<serde_json::Value> {
self.post(
"/api/v1/lightning/receives/invoice",
serde_json::json!({ "amount_sat": amount_sats }),
)
.await
}
/// `POST /api/v1/boards/board-amount` (or `board-all` when `amount_sats`
/// is None) — lift on-chain funds into Ark VTXOs.
pub async fn board(&self, amount_sats: Option<u64>) -> Result<serde_json::Value> {
match amount_sats {
Some(sats) => {
self.post(
"/api/v1/boards/board-amount",
serde_json::json!({ "amount_sat": sats }),
)
.await
}
None => {
self.post("/api/v1/boards/board-all", serde_json::json!({}))
.await
}
}
}
/// `POST /api/v1/wallet/offboard/all` — move all VTXOs back on-chain via a
/// collaborative round.
pub async fn offboard_all(&self, address: Option<&str>) -> Result<serde_json::Value> {
self.post(
"/api/v1/wallet/offboard/all",
serde_json::json!({ "address": address }),
)
.await
}
/// `GET /api/v1/wallet/movements` — barkd's own movement history. This is
/// authoritative (includes receives we never initiated), so unlike the
/// Fedimint bridge there is no local tx log to maintain.
pub async fn movements(&self) -> Result<Vec<serde_json::Value>> {
let res = self.get("/api/v1/wallet/movements").await?;
Ok(res.as_array().cloned().unwrap_or_default())
}
/// `GET /api/v1/wallet/ark-info` — connected Ark server parameters.
pub async fn ark_info(&self) -> Result<serde_json::Value> {
self.get("/api/v1/wallet/ark-info").await
}
}
/// Idempotently make sure barkd has a wallet, creating one with the node's
/// Ark config on first use. Best-effort no-op when the sidecar isn't
/// installed/running yet — mirrors `fedimint_client::ensure_default_federation`.
pub async fn ensure_wallet(data_dir: &Path) -> Result<()> {
let client = match ArkClient::from_node(data_dir).await {
Ok(c) => c,
Err(_) => return Ok(()), // barkd not configured yet
};
if client.wallet_info().await.is_ok() {
return Ok(());
}
let config = load_config(data_dir).await;
match client.create_wallet(&config).await {
Ok(_) => {
tracing::info!(
"created barkd Ark wallet ({} via {})",
config.network,
config.ark_server
);
// Persist the effective config so the settings UI shows what the
// wallet was actually created with.
let _ = save_config(data_dir, &config).await;
}
Err(e) => tracing::debug!("barkd wallet auto-create skipped: {e}"),
}
Ok(())
}
/// Total spendable Ark sats, soft-failing to 0 when the sidecar is not
/// installed or unreachable so unified balances still render.
pub async fn spendable_sats_or_zero(data_dir: &Path) -> u64 {
match ArkClient::from_node(data_dir).await {
Ok(client) => client.spendable_sats().await.unwrap_or(0),
Err(_) => 0,
}
}
/// Map barkd movements into unified [`EcashTransaction`] history entries
/// (kind = "ark"). Best-effort: empty on any error, never blocks history.
pub async fn load_ark_txs(data_dir: &Path) -> Vec<crate::wallet::ecash::EcashTransaction> {
let client = match ArkClient::from_node(data_dir).await {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let movements = match client.movements().await {
Ok(m) => m,
Err(_) => return Vec::new(),
};
movements.iter().filter_map(movement_to_tx).collect()
}
/// Convert one barkd `Movement` into an [`EcashTransaction`]. `None` for
/// zero-delta movements (e.g. internal refreshes) so history stays meaningful.
fn movement_to_tx(m: &serde_json::Value) -> Option<crate::wallet::ecash::EcashTransaction> {
use crate::wallet::ecash::{EcashTransaction, TransactionType};
let delta = m.get("effective_balance_sat").and_then(|v| v.as_i64())?;
if delta == 0 {
return None;
}
let tx_type = if delta < 0 {
TransactionType::Send
} else {
TransactionType::Receive
};
// `time` holds created/updated/completed; prefer the completion time.
let timestamp = m
.get("time")
.and_then(|t| {
t.get("completed_at")
.or_else(|| t.get("updated_at"))
.or_else(|| t.get("created_at"))
})
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
// Describe via the recipient list (send) or receive source when present.
let peer = m
.get("sent_to")
.or_else(|| m.get("received_on"))
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|d| {
d.get("destination")
.or_else(|| d.get("address"))
.or_else(|| d.get("invoice"))
.and_then(|v| v.as_str())
})
.unwrap_or_default()
.to_string();
let subsystem = m
.get("subsystem")
.map(|s| match s {
serde_json::Value::String(v) => v.clone(),
other => other
.as_object()
.and_then(|o| o.keys().next().cloned())
.unwrap_or_default(),
})
.unwrap_or_default();
let description = if delta < 0 {
format!("Sent via Ark{}", suffix(&subsystem))
} else {
format!("Received via Ark{}", suffix(&subsystem))
};
Some(EcashTransaction {
id: format!("ark-{}", m.get("id").and_then(|v| v.as_u64()).unwrap_or(0)),
tx_type,
amount_sats: delta.unsigned_abs(),
timestamp,
description,
mint_url: String::new(),
peer,
kind: "ark".to_string(),
})
}
fn suffix(subsystem: &str) -> String {
if subsystem.is_empty() {
String::new()
} else {
format!(" ({subsystem})")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_encoding_matches_barkd_format() {
// barkd token = base64url-nopad(0x00 || secret); 33 bytes -> 44 chars.
let token = secret_hex_to_token(&"ab".repeat(32)).unwrap();
assert_eq!(token.len(), 44);
let bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
assert_eq!(bytes.len(), 33);
assert_eq!(bytes[0], 0);
assert_eq!(&bytes[1..], &[0xabu8; 32]);
}
#[test]
fn token_rejects_bad_secret() {
assert!(secret_hex_to_token("deadbeef").is_err(), "too short");
assert!(secret_hex_to_token(&"zz".repeat(32)).is_err(), "not hex");
}
#[test]
fn movement_maps_to_history_entry() {
let m = serde_json::json!({
"id": 7,
"effective_balance_sat": -1500,
"time": { "completed_at": "2026-07-14T12:00:00Z" },
"sent_to": [{ "destination": "tark1abc" }],
"subsystem": "arkoor",
});
let tx = movement_to_tx(&m).expect("mapped");
assert_eq!(tx.amount_sats, 1500);
assert_eq!(tx.kind, "ark");
assert_eq!(tx.peer, "tark1abc");
assert!(matches!(
tx.tx_type,
crate::wallet::ecash::TransactionType::Send
));
// Zero-delta refresh movements are dropped.
let refresh = serde_json::json!({ "id": 8, "effective_balance_sat": 0 });
assert!(movement_to_tx(&refresh).is_none());
}
}
+13 -16
View File
@@ -171,19 +171,18 @@ impl CashuToken {
let v4: TokenV4 =
ciborium::from_reader(decoded.as_slice()).context("Invalid CBOR in cashuB token")?;
let proofs = v4
.t
.into_iter()
.flat_map(|entry| {
let keyset_id = hex::encode(&entry.i);
entry.p.into_iter().map(move |p| Proof {
amount: p.a,
id: keyset_id.clone(),
secret: p.s,
c: hex::encode(&p.c),
let proofs =
v4.t.into_iter()
.flat_map(|entry| {
let keyset_id = hex::encode(&entry.i);
entry.p.into_iter().map(move |p| Proof {
amount: p.a,
id: keyset_id.clone(),
secret: p.s,
c: hex::encode(&p.c),
})
})
})
.collect();
.collect();
let token = CashuToken {
token: vec![TokenEntry { mint: v4.m, proofs }],
@@ -407,10 +406,8 @@ mod tests {
use ciborium::value::Value;
let keyset_id = vec![0x00u8, 0x9a, 0x1f, 0x29, 0x32, 0x53, 0xe4, 0x1e];
let sig = hex::decode(
"02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24",
)
.unwrap();
let sig = hex::decode("02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24")
.unwrap();
let proof = Value::Map(vec![
(Value::from("a"), Value::from(8u64)),
+1
View File
@@ -1,6 +1,7 @@
// WIP Cashu/ecash wallet — many helpers defined for future callers.
#![allow(dead_code)]
pub mod ark_client;
pub mod bdhke;
pub mod cashu;
pub mod ecash;
+10 -2
View File
@@ -58,7 +58,13 @@ pub async fn verify_declared_signature(
sig_ref: &str,
allow_insecure_registry: bool,
) -> Result<()> {
verify_with_key_path(image, sig_ref, &pinned_pubkey_path(), allow_insecure_registry).await
verify_with_key_path(
image,
sig_ref,
&pinned_pubkey_path(),
allow_insecure_registry,
)
.await
}
async fn verify_with_key_path(
@@ -192,7 +198,9 @@ mod tests {
)
.await
.unwrap_err();
assert!(err.to_string().contains("pinned cosign public key is missing"));
assert!(err
.to_string()
.contains("pinned cosign public key is missing"));
}
#[tokio::test]
+180 -21
View File
@@ -263,28 +263,38 @@ impl PodmanClient {
)
.await?;
// Image pull uses CLI — it's a streaming operation that the API handles differently
let mut cmd = tokio::process::Command::new("podman");
cmd.arg("pull");
if image_uses_insecure_registry(image) {
cmd.arg("--tls-verify=false");
// Stall-aware + retried, mirroring the legacy installer's
// pull_one_url_with_progress. The old single-attempt hard 600s wall
// clock killed slow-but-progressing pulls, which every orchestrator
// stack (btcpay, indeedhub, …) surfaced as "first install fails,
// second succeeds" once the layer cache was warm. Podman keeps
// completed layers between attempts, so retries resume cheaply.
const MAX_ATTEMPTS: u32 = 3;
const BACKOFF_SECS: [u64; 2] = [5, 15];
let mut last_err = anyhow::anyhow!("podman pull {image}: no attempt ran");
for attempt in 1..=MAX_ATTEMPTS {
match pull_image_stall_aware(image).await {
Ok(()) => return Ok(()),
Err(e) => {
tracing::warn!(
"Image pull failed for {} (attempt {}/{}): {:#}",
image,
attempt,
MAX_ATTEMPTS,
e
);
last_err = e;
if attempt < MAX_ATTEMPTS {
tokio::time::sleep(std::time::Duration::from_secs(
BACKOFF_SECS[(attempt - 1) as usize],
))
.await;
}
}
}
}
cmd.arg(image);
let output = tokio::time::timeout(
std::time::Duration::from_secs(600), // 10 min for large images
cmd.output(),
)
.await
.map_err(|_| anyhow::anyhow!("Image pull timed out after 10 minutes"))?
.context("Failed to execute podman pull")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to pull image: {}", stderr));
}
Ok(())
Err(last_err)
}
pub async fn create_container(&self, manifest: &AppManifest, name: &str) -> Result<String> {
@@ -710,6 +720,155 @@ fn podman_network_settings(
// ─── Helpers ─────────────────────────────────────────────────────
/// One `podman pull` attempt with a stall-aware budget instead of a hard
/// wall clock. A pull is only killed when NOTHING is observably happening —
/// no stderr output AND no byte growth in podman's TMPDIR staging dir — for
/// PULL_STALL_TIMEOUT_SECS, or at a generous absolute ceiling. Dead
/// registries still fail fast (no bytes ever land, so the 3-minute stall
/// window is the effective bound), while a slow-but-moving multi-GB pull is
/// left alone. Same design as the legacy installer's
/// pull_one_url_with_progress (LND "first install fails" fix).
async fn pull_image_stall_aware(image: &str) -> Result<()> {
const PULL_STALL_TIMEOUT_SECS: u64 = 180;
const PULL_MAX_SECS: u64 = 1800;
const PULL_POLL_INTERVAL_SECS: u64 = 5;
// Rootless podman's user namespace makes /var/tmp read-only; stage into
// the user's containers tmp (same dir every other pull path uses) — it
// doubles as the "bytes are moving" signal for stall detection.
let user_tmp = format!(
"{}/.local/share/containers/tmp",
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string())
);
let _ = std::fs::create_dir_all(&user_tmp);
let mut cmd = tokio::process::Command::new("podman");
cmd.arg("pull");
if image_uses_insecure_registry(image) {
cmd.arg("--tls-verify=false");
}
cmd.arg(image);
cmd.env("TMPDIR", &user_tmp);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd.kill_on_drop(true);
let mut child = cmd.spawn().context("Failed to start podman pull")?;
let started = std::time::Instant::now();
// Seconds-since-start of the last stderr line, updated by the reader
// task. Starts at 0 so the stall window opens at spawn.
let last_line_at = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
// Ring of recent stderr lines so a failed pull reports podman's actual
// error, not just "exit status 125".
let recent_stderr = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<
String,
>::with_capacity(8)));
if let Some(stderr) = child.stderr.take() {
use tokio::io::AsyncBufReadExt;
let mut lines = tokio::io::BufReader::new(stderr).lines();
let line_clock = std::sync::Arc::clone(&last_line_at);
let stderr_ring = std::sync::Arc::clone(&recent_stderr);
let started_reader = started;
tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
line_clock.store(
started_reader.elapsed().as_secs(),
std::sync::atomic::Ordering::Relaxed,
);
if let Ok(mut ring) = stderr_ring.lock() {
if ring.len() >= 8 {
ring.pop_front();
}
ring.push_back(line);
}
}
});
}
let stderr_tail = |ring: &std::sync::Mutex<std::collections::VecDeque<String>>| {
ring.lock()
.map(|r| r.iter().cloned().collect::<Vec<_>>().join(" | "))
.unwrap_or_default()
};
let mut last_staged_bytes = dir_size_bytes(&user_tmp);
let mut last_staged_change = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => return Ok(()),
Ok(Some(status)) => {
anyhow::bail!(
"podman pull {image} failed ({status}): {}",
stderr_tail(&recent_stderr)
);
}
Ok(None) => {}
Err(e) => {
let _ = child.kill().await;
let _ = child.wait().await;
return Err(anyhow::anyhow!("podman pull {image} process error: {e}"));
}
}
tokio::time::sleep(std::time::Duration::from_secs(PULL_POLL_INTERVAL_SECS)).await;
if started.elapsed() > std::time::Duration::from_secs(PULL_MAX_SECS) {
let _ = child.kill().await;
let _ = child.wait().await; // reap zombie
anyhow::bail!("podman pull {image} exceeded absolute {PULL_MAX_SECS}s ceiling");
}
// Activity signal 1: stderr output from podman.
let line_age = started
.elapsed()
.as_secs()
.saturating_sub(last_line_at.load(std::sync::atomic::Ordering::Relaxed));
// Activity signal 2: staged layer bytes growing in TMPDIR.
let staged = dir_size_bytes(&user_tmp);
if staged != last_staged_bytes {
last_staged_bytes = staged;
last_staged_change = std::time::Instant::now();
}
if line_age > PULL_STALL_TIMEOUT_SECS
&& last_staged_change.elapsed()
> std::time::Duration::from_secs(PULL_STALL_TIMEOUT_SECS)
{
let _ = child.kill().await;
let _ = child.wait().await; // reap zombie
anyhow::bail!(
"podman pull {image} stalled ({PULL_STALL_TIMEOUT_SECS}s with no output and no staged bytes): {}",
stderr_tail(&recent_stderr)
);
}
}
}
/// Total bytes under `path` (bounded recursive walk). Used as a coarse
/// "bytes are moving" signal for pull stall detection — exact size doesn't
/// matter, only whether it CHANGES between polls. Errors count as 0.
fn dir_size_bytes(path: &str) -> u64 {
fn walk(dir: &std::path::Path, depth: u32) -> u64 {
if depth > 8 {
return 0;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut total = 0u64;
for entry in entries.flatten() {
let Ok(meta) = entry.metadata() else { continue };
if meta.is_dir() {
total = total.saturating_add(walk(&entry.path(), depth + 1));
} else {
total = total.saturating_add(meta.len());
}
}
total
}
walk(std::path::Path::new(path), 0)
}
fn parse_port_bindings(bindings: &serde_json::Value) -> Vec<String> {
let mut ports = Vec::new();
if let Some(obj) = bindings.as_object() {
+1 -3
View File
@@ -158,9 +158,7 @@ impl ContainerRuntime for PodmanRuntime {
.podman_cli(&["secret", "inspect", &r.secret_name, "--format", &fmt])
.await
{
if out.status.success()
&& String::from_utf8_lossy(&out.stdout).trim() == hash
{
if out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == hash {
continue;
}
}
+5 -1
View File
@@ -32,7 +32,11 @@ pub async fn scan_subnet(
}
}
info!("{} hosts with TCP/22 open in /{}", candidates.len(), prefix_len);
info!(
"{} hosts with TCP/22 open in /{}",
candidates.len(),
prefix_len
);
let mut routers = Vec::new();
for ip in candidates {
+13 -3
View File
@@ -48,10 +48,17 @@ impl Router {
return Ok(PkgManager::Opkg);
}
if add_out.contains("no such package") || add_out.contains("unable to select") {
info!("[{}] opkg not in apk repos — staying in apk-native mode", self.host);
info!(
"[{}] opkg not in apk repos — staying in apk-native mode",
self.host
);
return Ok(PkgManager::ApkNative);
}
anyhow::bail!("apk add opkg failed (exit {}): {}", add_code, add_out.trim());
anyhow::bail!(
"apk add opkg failed (exit {}): {}",
add_code,
add_out.trim()
);
}
anyhow::bail!(
@@ -70,7 +77,10 @@ impl Router {
/// Install a package, skipping if already installed.
pub fn opkg_install(&self, package: &str) -> Result<()> {
// Check if already installed to avoid unnecessary network traffic.
let (_, code) = self.run(&format!("/usr/bin/opkg list-installed | grep -q '^{} '", package))?;
let (_, code) = self.run(&format!(
"/usr/bin/opkg list-installed | grep -q '^{} '",
package
))?;
if code == 0 {
info!("[{}] {} already installed", self.host, package);
return Ok(());
+11 -6
View File
@@ -16,8 +16,7 @@ impl Router {
/// Connect to an OpenWrt router via SSH using a private key.
pub fn connect(host: &str, port: u16, user: &str, key_path: &Path) -> Result<Self> {
let addr = format!("{}:{}", host, port);
let tcp = TcpStream::connect(&addr)
.with_context(|| format!("TCP connect to {}", addr))?;
let tcp = TcpStream::connect(&addr).with_context(|| format!("TCP connect to {}", addr))?;
let mut session = Session::new().context("create SSH session")?;
session.set_tcp_stream(tcp);
@@ -36,8 +35,7 @@ impl Router {
/// Connect using a password (fallback for routers not yet provisioned with a key).
pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result<Self> {
let addr = format!("{}:{}", host, port);
let tcp = TcpStream::connect(&addr)
.with_context(|| format!("TCP connect to {}", addr))?;
let tcp = TcpStream::connect(&addr).with_context(|| format!("TCP connect to {}", addr))?;
let mut session = Session::new().context("create SSH session")?;
session.set_tcp_stream(tcp);
@@ -58,7 +56,9 @@ impl Router {
debug!("ssh [{}] $ {}", self.host, cmd);
let mut channel = self.session.channel_session().context("open channel")?;
channel.exec(cmd).with_context(|| format!("exec: {}", cmd))?;
channel
.exec(cmd)
.with_context(|| format!("exec: {}", cmd))?;
let mut stdout = String::new();
channel.read_to_string(&mut stdout).context("read stdout")?;
@@ -72,7 +72,12 @@ impl Router {
pub fn run_ok(&self, cmd: &str) -> Result<String> {
let (out, code) = self.run(cmd)?;
if code != 0 {
anyhow::bail!("command `{}` exited with code {}: {}", cmd, code, out.trim());
anyhow::bail!(
"command `{}` exited with code {}: {}",
cmd,
code,
out.trim()
);
}
Ok(out)
}
+46 -27
View File
@@ -46,8 +46,14 @@ pub fn install_tollgate(router: &Router) -> Result<()> {
)
})?;
info!("[{}] Downloading TollGate for {} from GitHub releases", router.host, arch);
router.run_ok(&format!("wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1", url))?;
info!(
"[{}] Downloading TollGate for {} from GitHub releases",
router.host, arch
);
router.run_ok(&format!(
"wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1",
url
))?;
install_ipk(router, "/tmp/tollgate.ipk")
}
@@ -56,7 +62,10 @@ pub fn install_tollgate(router: &Router) -> Result<()> {
/// Downloads the .ipk from GitHub releases and extracts it manually using
/// BusyBox `ar` and `tar` (both present on all OpenWrt images).
pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
info!("[{}] Installing {} (apk-native mode)", router.host, TOLLGATE_PACKAGE);
info!(
"[{}] Installing {} (apk-native mode)",
router.host, TOLLGATE_PACKAGE
);
// Already installed? The service binary is /usr/bin/tollgate-wrt (per its
// init.d script) — TOLLGATE_PACKAGE is only the opkg/apk package name,
@@ -80,14 +89,14 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
&& a=\"${DISTRIB_ARCH:-${OPENWRT_ARCH:-}}\" \
&& [ -n \"$a\" ] && echo \"$a\" \
|| /usr/bin/apk --print-arch 2>/dev/null \
|| uname -m"
|| uname -m",
)?;
// Normalise: uname -m returns bare "mipsel"/"mips"; map to 24kc variant
// which is the standard for home-router MIPS builds.
let arch = match arch_raw.trim() {
"mipsel" => "mipsel_24kc",
"mips" => "mips_24kc",
other => other,
"mips" => "mips_24kc",
other => other,
};
info!("[{}] detected arch: {:?}", router.host, arch);
if arch.is_empty() {
@@ -102,11 +111,15 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
)
})?;
info!("[{}] Downloading TollGate for {} from GitHub releases", router.host, arch);
info!(
"[{}] Downloading TollGate for {} from GitHub releases",
router.host, arch
);
// --no-check-certificate: fresh OpenWrt 25.x images ship without a CA bundle;
// GitHub serves releases over HTTPS so wget would otherwise reject the cert.
let (dl_out, dl_code) = router.run(&format!(
"wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1", url
"wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1",
url
))?;
if dl_code != 0 {
anyhow::bail!("TollGate download failed: {}", dl_out.trim());
@@ -149,9 +162,8 @@ fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> {
let (_, ar_found) = router.run("command -v ar >/dev/null 2>&1")?;
if ar_found != 0 {
info!("[{}] ar not found, installing binutils", router.host);
let (pkg_out, pkg_code) = router.run(
"apk add binutils 2>&1 || opkg install binutils 2>&1"
)?;
let (pkg_out, pkg_code) =
router.run("apk add binutils 2>&1 || opkg install binutils 2>&1")?;
if pkg_code != 0 {
anyhow::bail!(
"TollGate installation failed: ar not available and binutils install failed: {}",
@@ -161,9 +173,8 @@ fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> {
}
// Try standard opkg ar format first (ar archive → data.tar.gz inside).
let (ar_out, ar_code) = router.run(&format!(
"cd /tmp/_tg_install && ar x {} 2>&1", ipk_path
))?;
let (ar_out, ar_code) =
router.run(&format!("cd /tmp/_tg_install && ar x {} 2>&1", ipk_path))?;
if ar_code != 0 {
// Fallback: some builds produce the .ipk as a gzip tarball rather than
@@ -173,17 +184,21 @@ fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> {
// flat tarball of the real package files with no ipk structure at
// all. Extract to the scratch dir and check which shape it is before
// deciding how to install it.
info!("[{}] ar failed ({}), trying tar -xzf", router.host, ar_out.trim());
info!(
"[{}] ar failed ({}), trying tar -xzf",
router.host,
ar_out.trim()
);
// List contents first — validates format without writing anything.
let (list_out, list_code) = router.run(&format!(
"tar -tzf {} 2>&1 | head -30", ipk_path
))?;
let (list_out, list_code) =
router.run(&format!("tar -tzf {} 2>&1 | head -30", ipk_path))?;
if list_code != 0 {
anyhow::bail!(
"TollGate installation failed: file is not an ar archive or gzip tar.\n\
ar: {}\ntar -t: {}",
ar_out.trim(), list_out.trim()
ar_out.trim(),
list_out.trim()
);
}
info!("[{}] ipk contents:\n{}", router.host, list_out.trim());
@@ -206,14 +221,17 @@ fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> {
}
let (cp_out, cp_code) = router.run("cp -a /tmp/_tg_install/. / 2>&1")?;
if cp_code != 0 {
anyhow::bail!("TollGate installation failed: file copy failed: {}", cp_out.trim());
anyhow::bail!(
"TollGate installation failed: file copy failed: {}",
cp_out.trim()
);
}
// No package-manager postinst ran for these files either — see
// the uci-defaults note below.
router.run_ok(
"for f in /etc/uci-defaults/*; do \
[ -f \"$f\" ] && ( cd \"$(dirname \"$f\")\" && . \"$f\" ) && rm -f \"$f\"; \
done; uci commit 2>/dev/null; true"
done; uci commit 2>/dev/null; true",
)?;
router.run_ok(&format!("rm -rf /tmp/_tg_install {}", ipk_path))?;
return Ok(());
@@ -223,18 +241,19 @@ fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> {
// Unpack data.tar.gz (the real payload) from either the `ar`-extracted or
// gzip-tar-extracted scratch dir, then run control.tar.gz's postinst.
let (tar_out, tar_code) = router.run(
"tar -xzf /tmp/_tg_install/data.tar.gz -C / 2>&1"
)?;
let (tar_out, tar_code) = router.run("tar -xzf /tmp/_tg_install/data.tar.gz -C / 2>&1")?;
if tar_code != 0 {
anyhow::bail!("TollGate installation failed: data extract failed: {}", tar_out.trim());
anyhow::bail!(
"TollGate installation failed: data extract failed: {}",
tar_out.trim()
);
}
// Run postinst if present (optional — failures are non-fatal).
router.run_ok(
"if tar -xzf /tmp/_tg_install/control.tar.gz -C /tmp/_tg_install 2>/dev/null; then \
chmod +x /tmp/_tg_install/postinst 2>/dev/null; \
/tmp/_tg_install/postinst configure 2>/dev/null || true; \
fi"
fi",
)?;
// `default_postinst` (what most packages' postinst calls, including
// this one) only runs pending /etc/uci-defaults/* scripts for packages
@@ -246,7 +265,7 @@ fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> {
router.run_ok(
"for f in /etc/uci-defaults/*; do \
[ -f \"$f\" ] && ( cd \"$(dirname \"$f\")\" && . \"$f\" ) && rm -f \"$f\"; \
done; uci commit 2>/dev/null; true"
done; uci commit 2>/dev/null; true",
)?;
router.run_ok(&format!("rm -rf /tmp/_tg_install {}", ipk_path))?;
+2 -4
View File
@@ -84,9 +84,7 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
fn restart_services(router: &Router, enabled: bool) -> Result<()> {
if enabled {
router.run_ok("/etc/init.d/tollgate-wrt enable")?;
router.run_ok(
"/etc/init.d/tollgate-wrt restart || /etc/init.d/tollgate-wrt start"
)?;
router.run_ok("/etc/init.d/tollgate-wrt restart || /etc/init.d/tollgate-wrt start")?;
} else {
router.run_ok("/etc/init.d/tollgate-wrt stop || true")?;
router.run_ok("/etc/init.d/tollgate-wrt disable || true")?;
@@ -115,7 +113,7 @@ fn restart_services(router: &Router, enabled: bool) -> Result<()> {
echo \"br-tollgate has no kernel-level IPv4 address after cycle $i, retrying\"; \
done; \
ip -4 addr show br-tollgate 2>/dev/null | grep -q 'inet ' || \
{ echo 'br-tollgate never got a kernel-level IPv4 address after 5 cycles'; exit 1; }"
{ echo 'br-tollgate never got a kernel-level IPv4 address after 5 cycles'; exit 1; }",
)?;
Ok(())
}
+5 -2
View File
@@ -63,7 +63,7 @@ pub fn install_captive_portal_symlink(router: &Router) -> Result<()> {
fi; \
rm -rf /etc/nodogsplash/htdocs; \
ln -sf /etc/tollgate/tollgate-captive-portal-site /etc/nodogsplash/htdocs; \
fi"
fi",
)?;
Ok(())
}
@@ -91,7 +91,10 @@ pub fn configure(router: &Router, cfg: &TollGateConfig) -> Result<()> {
router.uci_set("nodogsplash.main", "nodogsplash")?;
router.uci_set("nodogsplash.main.enabled", "1")?;
router.uci_set("nodogsplash.main.gatewayinterface", "br-tollgate")?;
router.uci_set("nodogsplash.main.gatewayname", &format!("{} Portal", cfg.ssid))?;
router.uci_set(
"nodogsplash.main.gatewayname",
&format!("{} Portal", cfg.ssid),
)?;
router.uci_set("nodogsplash.main.gatewaydomainname", "TollGate.lan")?;
router.uci_set("nodogsplash.main.gatewayport", "2050")?;
+7 -8
View File
@@ -27,7 +27,10 @@ pub fn provision_ssid(router: &Router, cfg: &TollGateConfig) -> Result<()> {
("wireless.tollgate.ieee80211r", "0"),
// Stop broadcasting entirely when disabled, rather than leaving an
// open SSID up that leads nowhere once the backend is stopped.
("wireless.tollgate.disabled", if cfg.enabled { "0" } else { "1" }),
(
"wireless.tollgate.disabled",
if cfg.enabled { "0" } else { "1" },
),
],
)?;
@@ -117,13 +120,9 @@ fn provision_firewall(router: &Router) -> Result<()> {
/// Return the first available wireless radio device name (e.g. "radio0").
fn detect_radio(router: &Router) -> Result<String> {
let out = router.run_ok("uci show wireless | grep -o 'wireless\\.radio[0-9]*\\.type' | head -1")?;
let out =
router.run_ok("uci show wireless | grep -o 'wireless\\.radio[0-9]*\\.type' | head -1")?;
// Extract "radioN" from "wireless.radioN.type"
let radio = out
.trim()
.split('.')
.nth(1)
.unwrap_or("radio0")
.to_string();
let radio = out.trim().split('.').nth(1).unwrap_or("radio0").to_string();
Ok(radio)
}
+41 -14
View File
@@ -1,14 +1,14 @@
use crate::Router;
use anyhow::Result;
use tracing::info;
use crate::Router;
pub struct WispConfig {
pub ssid: String,
pub password: String,
pub encryption: String, // psk2 | psk | sae | none
pub dhcp_start: u32, // first address in DHCP pool (default 100 → .100)
pub dhcp_limit: u32, // pool size (default 150 → .100.249)
pub masq: bool, // enable NAT on WAN zone (almost always true)
pub encryption: String, // psk2 | psk | sae | none
pub dhcp_start: u32, // first address in DHCP pool (default 100 → .100)
pub dhcp_limit: u32, // pool size (default 150 → .100.249)
pub masq: bool, // enable NAT on WAN zone (almost always true)
}
pub fn configure_wisp(router: &Router, config: &WispConfig) -> Result<()> {
@@ -54,11 +54,21 @@ pub fn configure_wisp(router: &Router, config: &WispConfig) -> Result<()> {
// "wifi reload" is not enough on some drivers — it keeps stale state.
let (down_out, down_code) = router.run("wifi down 2>&1")?;
if down_code != 0 {
info!("[{}] wifi down failed ({}): {}", router.host, down_code, down_out.trim());
info!(
"[{}] wifi down failed ({}): {}",
router.host,
down_code,
down_out.trim()
);
}
let (up_out, up_code) = router.run("wifi up 2>&1")?;
if up_code != 0 {
info!("[{}] wifi up failed ({}): {} — falling back to network restart", router.host, up_code, up_out.trim());
info!(
"[{}] wifi up failed ({}): {} — falling back to network restart",
router.host,
up_code,
up_out.trim()
);
router.run_ok("/etc/init.d/network restart 2>&1")?;
}
@@ -72,7 +82,9 @@ pub fn get_wan_status(router: &Router) -> serde_json::Value {
.unwrap_or(false);
let ssid = router.uci_get("wireless.wwan.ssid").unwrap_or_default();
let encryption = router.uci_get("wireless.wwan.encryption").unwrap_or_default();
let encryption = router
.uci_get("wireless.wwan.encryption")
.unwrap_or_default();
let radio0_disabled = router
.uci_get("wireless.radio0.disabled")
.map(|v| v == "1")
@@ -85,7 +97,10 @@ pub fn get_wan_status(router: &Router) -> serde_json::Value {
// Interface operstate (up / down / absent)
let sta_state = if !sta_iface.is_empty() {
router
.run_ok(&format!("cat /sys/class/net/{}/operstate 2>/dev/null", sta_iface))
.run_ok(&format!(
"cat /sys/class/net/{}/operstate 2>/dev/null",
sta_iface
))
.unwrap_or_else(|_| "unknown".into())
.trim()
.to_string()
@@ -108,10 +123,18 @@ pub fn get_wan_status(router: &Router) -> serde_json::Value {
.to_string();
// LAN info for the DHCP setup display
let lan_ip = router.uci_get("network.lan.ipaddr").unwrap_or_else(|_| "192.168.1.1".into());
let lan_netmask = router.uci_get("network.lan.netmask").unwrap_or_else(|_| "255.255.255.0".into());
let dhcp_start = router.uci_get("dhcp.lan.start").unwrap_or_else(|_| "100".into());
let dhcp_limit = router.uci_get("dhcp.lan.limit").unwrap_or_else(|_| "150".into());
let lan_ip = router
.uci_get("network.lan.ipaddr")
.unwrap_or_else(|_| "192.168.1.1".into());
let lan_netmask = router
.uci_get("network.lan.netmask")
.unwrap_or_else(|_| "255.255.255.0".into());
let dhcp_start = router
.uci_get("dhcp.lan.start")
.unwrap_or_else(|_| "100".into());
let dhcp_limit = router
.uci_get("dhcp.lan.limit")
.unwrap_or_else(|_| "150".into());
// Masquerade: check WAN zone
let masq = {
@@ -126,7 +149,11 @@ pub fn get_wan_status(router: &Router) -> serde_json::Value {
info!("[{}] WAN status: configured={} ssid={:?} assoc={:?} sta_iface={:?} sta_state={:?} ip={:?} lan={} masq={}",
router.host, configured, ssid, assoc_ssid, sta_iface, sta_state, ip, lan_ip, masq);
if !wifi_log.is_empty() {
info!("[{}] wifi_log: {}", router.host, wifi_log.replace('\n', " | "));
info!(
"[{}] wifi_log: {}",
router.host,
wifi_log.replace('\n', " | ")
);
}
serde_json::json!({
+30 -7
View File
@@ -1,5 +1,5 @@
use anyhow::Result;
use crate::Router;
use anyhow::Result;
pub struct ScannedNetwork {
pub ssid: String,
@@ -42,7 +42,12 @@ fn scan_via_mtk_site_survey(router: &Router, iface: &str) -> Result<Vec<ScannedN
fn parse_mtk_site_survey(output: &str) -> Result<Vec<ScannedNetwork>> {
let mut networks = Vec::new();
for line in output.lines() {
if !line.trim_start().as_bytes().first().is_some_and(u8::is_ascii_digit) {
if !line
.trim_start()
.as_bytes()
.first()
.is_some_and(u8::is_ascii_digit)
{
continue; // skip header/summary lines; data rows start with an index
}
let ssid = line.get(8..41).unwrap_or("").trim().to_string();
@@ -51,8 +56,14 @@ fn parse_mtk_site_survey(output: &str) -> Result<Vec<ScannedNetwork>> {
}
let bssid = line.get(41..61).unwrap_or("").trim().to_string();
let security = line.get(61..84).unwrap_or("");
let channel: u8 = line.get(4..8).and_then(|s| s.trim().parse().ok()).unwrap_or(0);
let signal: i32 = line.get(84..92).and_then(|s| s.trim().parse().ok()).unwrap_or(-100);
let channel: u8 = line
.get(4..8)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
let signal: i32 = line
.get(84..92)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(-100);
networks.push(ScannedNetwork {
ssid,
bssid,
@@ -96,7 +107,11 @@ fn find_wireless_iface(router: &Router) -> Result<(String, bool)> {
// Create a temporary managed interface directly on the PHY. This bypasses
// netifd entirely so it works even when there are no wifi-iface sections in
// UCI (common on a freshly-flashed device).
tracing::info!("[{}] Creating temporary scan interface on {}", router.host, phy);
tracing::info!(
"[{}] Creating temporary scan interface on {}",
router.host,
phy
);
// Remove any stale scan0 from a previous attempt, then add fresh
let _ = router.run("iw dev scan0 del 2>/dev/null");
router.run_ok(&format!(
@@ -119,7 +134,12 @@ fn parse_iwinfo_scan(output: &str) -> Result<Vec<ScannedNetwork>> {
networks.push(n);
}
}
let bssid = line.split("Address:").nth(1).unwrap_or("").trim().to_string();
let bssid = line
.split("Address:")
.nth(1)
.unwrap_or("")
.trim()
.to_string();
current = Some(ScannedNetwork {
ssid: String::new(),
bssid,
@@ -132,7 +152,10 @@ fn parse_iwinfo_scan(output: &str) -> Result<Vec<ScannedNetwork>> {
n.ssid = rest.trim().trim_matches('"').to_string();
} else if line.contains("Channel:") && !line.starts_with("Encryption") {
if let Some(ch_part) = line.split("Channel:").nth(1) {
n.channel = ch_part.trim().split_whitespace().next()
n.channel = ch_part
.trim()
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

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