Compare commits
46
Commits
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.4-alpha (2026-08-20)
|
||||
|
||||
- **Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the "app is restarting" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.
|
||||
|
||||
- **The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed.
|
||||
- **While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input.
|
||||
- **"Are you sure?" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote.
|
||||
- **A mesh radio now connects no matter which port it's plugged into — or replugged into.** Moving a radio to a different USB port could leave the mesh silently down: the node only checked a short fixed list of port names (a radio landing outside it was invisible), a hand-set serial-port override quietly outranked the device you'd just approved in the "Radio detected" window, and one whole family of boards (Espressif-based radios like recent Heltec/T-Deck models) never received a stable device name at all — the exact combination found live on a fleet machine this week. All three are fixed: every serial port is scanned, choosing a radio in the detection window clears any stale override, and Espressif boards get the same stable name as everyone else.
|
||||
- **Mesh signal strength is honest now.** Every peer heard over Reticulum radio reported a signal strength of exactly 0 — which is also what you'd see with no radio at all, and what peers reached over the internet showed. Real receptions now show their true signal reading, and anything that arrived over a relay or the internet says so by showing none — so "the radio is working" and "the internet is doing the radio's job" no longer look identical. (The reading depends on the radio's firmware reporting it; boards that don't report per-packet signal stats show "unknown" rather than a made-up number, and the new radio diagnostics show at a glance whether yours reports them.)
|
||||
- **A background error that repeated every 90 seconds, forever, is gone.** After setting up a node from its recovery phrase, the node kept introducing itself to its federation partners with its old temporary identity papers while signing with its new ones — every partner rejected the introduction, and both sides logged an error about it every minute and a half until the next restart. The identity switch now updates everything at once, a rejected introduction is no longer misreported as delivered, and a partner who has already answered is no longer re-asked on every cycle.
|
||||
|
||||
## v1.8.3-alpha (2026-08-14)
|
||||
|
||||
- **The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.
|
||||
|
||||
+16
-16
@@ -73,7 +73,7 @@
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
@@ -193,13 +193,13 @@
|
||||
{
|
||||
"id": "nostr-rs-relay",
|
||||
"title": "Nostr Relay (Rust)",
|
||||
"version": "0.8.0",
|
||||
"version": "0.10.0",
|
||||
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
|
||||
"icon": "/assets/img/app-icons/nostrudel.svg",
|
||||
"author": "Nostr RS Relay",
|
||||
"category": "community",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "scsibug/nostr-rs-relay:0.8.9",
|
||||
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
|
||||
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -223,7 +223,7 @@
|
||||
"author": "Vaultwarden",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -262,7 +262,7 @@
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
|
||||
"repoUrl": "https://github.com/fedimint/fedimint"
|
||||
},
|
||||
{
|
||||
@@ -285,7 +285,7 @@
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
|
||||
"repoUrl": "https://github.com/fedimint/fedimint",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -325,7 +325,7 @@
|
||||
"icon": "/assets/img/app-icons/jellyfin.webp",
|
||||
"author": "Jellyfin",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -356,7 +356,7 @@
|
||||
"icon": "/assets/img/app-icons/homeassistant.png",
|
||||
"author": "Home Assistant",
|
||||
"category": "home",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
|
||||
"repoUrl": "https://github.com/home-assistant/core",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -374,11 +374,11 @@
|
||||
"id": "pine",
|
||||
"title": "Pine",
|
||||
"version": "1.3.0",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
|
||||
"icon": "/assets/img/app-icons/pine.svg",
|
||||
"author": "Archipelago",
|
||||
"category": "home",
|
||||
"dockerImage": "docker.io/library/nginx:1.27-alpine",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
|
||||
"repoUrl": "https://github.com/rhasspy/wyoming"
|
||||
},
|
||||
{
|
||||
@@ -442,7 +442,7 @@
|
||||
"author": "Portainer",
|
||||
"category": "development",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
|
||||
"repoUrl": "https://github.com/portainer/portainer",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -459,12 +459,12 @@
|
||||
"id": "netbird",
|
||||
"title": "NetBird",
|
||||
"version": "2.38.0",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point \u2014 a TLS proxy in front of the dashboard + server.",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
|
||||
"icon": "/assets/img/app-icons/netbird.svg",
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/library/nginx:1.27-alpine",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
|
||||
"repoUrl": "https://github.com/netbirdio/netbird",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -552,19 +552,19 @@
|
||||
"id": "alby-hub",
|
||||
"title": "Alby Hub",
|
||||
"version": "1.23.0",
|
||||
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect \u2014 one hub, every app pays through it.",
|
||||
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
|
||||
"icon": "/assets/img/app-icons/alby-hub.svg",
|
||||
"author": "Alby",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.23.0",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
|
||||
"repoUrl": "https://github.com/getAlby/hub"
|
||||
},
|
||||
{
|
||||
"id": "phoenixd",
|
||||
"title": "phoenixd",
|
||||
"version": "0.9.0",
|
||||
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own \u2014 it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
|
||||
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
|
||||
"icon": "/assets/img/app-icons/phoenixd.svg",
|
||||
"author": "ACINQ",
|
||||
"category": "money",
|
||||
|
||||
@@ -2,6 +2,9 @@ app:
|
||||
id: aiui
|
||||
name: AI Assistant
|
||||
version: 0.1.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: Conversational AI interface for Archipelago. Quarantined — communicates only via context broker.
|
||||
internal: true # System-managed, not shown in App Store
|
||||
|
||||
|
||||
@@ -2,11 +2,17 @@ app:
|
||||
id: alby-hub
|
||||
name: Alby Hub
|
||||
version: 1.23.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: getAlby/hub
|
||||
description: Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.
|
||||
category: money
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/alby-hub:v1.23.0
|
||||
image: source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: archy-btcpay-db
|
||||
name: BTCPay Postgres
|
||||
version: "15.17"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: dockerhub
|
||||
repo: library/postgres
|
||||
description: Postgres backend for BTCPay and NBXplorer.
|
||||
|
||||
container:
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: archy-mempool-db
|
||||
name: Mempool MariaDB
|
||||
version: 11.4.10
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: dockerhub
|
||||
repo: library/mariadb
|
||||
description: MariaDB backend for the mempool explorer stack.
|
||||
|
||||
container:
|
||||
|
||||
@@ -2,11 +2,17 @@ app:
|
||||
id: archy-mempool-web
|
||||
name: Mempool Web
|
||||
version: 3.0.1
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: mempool/mempool
|
||||
description: Frontend web UI for mempool explorer.
|
||||
container_name: mempool
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: archy-nbxplorer
|
||||
name: NBXplorer
|
||||
version: 2.6.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: dgarage/NBXplorer
|
||||
description: BTCPay blockchain indexer service.
|
||||
|
||||
container:
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: bitcoin-core
|
||||
name: Bitcoin Core
|
||||
version: 28.4.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: bitcoin/bitcoin
|
||||
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
|
||||
|
||||
container_name: bitcoin-core
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: bitcoin-knots
|
||||
name: Bitcoin Knots
|
||||
version: 28.1.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: bitcoinknots/bitcoin
|
||||
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
|
||||
|
||||
container_name: bitcoin-knots
|
||||
|
||||
@@ -2,6 +2,9 @@ app:
|
||||
id: bitcoin-ui
|
||||
name: Bitcoin UI
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: |
|
||||
Archipelago-native HTTP proxy + static site for interacting with the
|
||||
Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container
|
||||
|
||||
@@ -2,6 +2,9 @@ app:
|
||||
id: botfights
|
||||
name: BotFights
|
||||
version: 1.2.11
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.
|
||||
category: community
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: btcpay-server
|
||||
name: BTCPay Server
|
||||
version: 2.4.2
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: btcpayserver/btcpayserver
|
||||
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
|
||||
|
||||
container:
|
||||
@@ -46,7 +52,17 @@ app:
|
||||
container: 49392
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# open, not gated: BTCPay has its own account system, and its public
|
||||
# surfaces (checkout/invoice pages, payment buttons, webhooks) must be
|
||||
# reachable by anonymous payers and machines — a dashboard login in
|
||||
# front of a checkout link breaks the product. The gate still fronts
|
||||
# the port; the operator can force the dashboard login back on from
|
||||
# Settings → BTCPay Server → Access control.
|
||||
auth: open
|
||||
auth_rationale: >-
|
||||
BTCPay enforces its own login for administration, and its checkout,
|
||||
invoice and webhook endpoints are designed to be reached by
|
||||
anonymous payers and payment processors.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: core-lightning
|
||||
name: Core Lightning (CLN)
|
||||
version: 23.08.2
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: ElementsProject/lightning
|
||||
description: Lightning Network implementation in C. Lightweight alternative to LND.
|
||||
|
||||
container:
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
app:
|
||||
id: cuprate
|
||||
name: Cuprate
|
||||
# Matches the crate's own Cargo.toml version (binaries/cuprated/Cargo.toml).
|
||||
# Cuprate has no stable release yet — this is explicitly work-in-progress
|
||||
# software (see upstream README). The image tag below pins the exact
|
||||
# commit built, since "0.1.0-preview" alone is not reproducible.
|
||||
version: 0.1.0-preview
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: Cuprate/cuprate
|
||||
description: Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.
|
||||
|
||||
container:
|
||||
# Built from the upstream Dockerfile at the tip of main, 18 commits past
|
||||
# the cuprated-0.1.0-preview tag (commit 618ff14, 2026-08-19) — there is
|
||||
# no newer tagged release as of this writing. Re-pin to a tagged release
|
||||
# once upstream cuts one.
|
||||
image: source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# The image's own ENTRYPOINT is ["/usr/local/bin/cuprated"]; these are
|
||||
# appended as its argv, matching the project's own systemd unit
|
||||
# (cuprated.service) invocation exactly.
|
||||
custom_args: ["--config-file", "/home/cuprate/Cuprated.toml"]
|
||||
# The image (FROM scratch) creates uid:gid 1000:1000 for the `cuprate`
|
||||
# user at build time and runs as it unconditionally (USER 1000:1000,
|
||||
# no shell to switch users at runtime) — same pattern as
|
||||
# apps/phoenixd, apps/electrumx, apps/nostr-rs-relay, apps/portainer,
|
||||
# apps/barkd. The bind-mounted data dir must be owned by that literal
|
||||
# uid or cuprated dies on a permission error the first time it writes.
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
|
||||
# month; cuprated's pruning support is not confirmed stable yet (the
|
||||
# `pruning` crate exists in the workspace but nothing in this config
|
||||
# surface toggles it), so this sizes for a full unpruned chain plus
|
||||
# headroom rather than assuming pruning is available.
|
||||
- storage: 300Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 300Gi
|
||||
|
||||
security:
|
||||
# FROM scratch, no package manager/shell, ownership fixed at build time
|
||||
# — unlike bitcoin-knots this needs no runtime chown/setuid dance, so it
|
||||
# can run fully read-only with an empty capability set.
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
# P2P. Cuprate's own default listen address is already 0.0.0.0
|
||||
# (p2p.clear_net.listen_on), so no config override is needed — only the
|
||||
# host-side port differs from Monero's canonical 18080 because that
|
||||
# number is already taken on this fleet by lnd's REST port.
|
||||
- host: 18183
|
||||
container: 18080
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Monero p2p gossip. Peers are anonymous by design and speak the Monero wire protocol, not HTTP.
|
||||
# Unrestricted RPC (full node control) is deliberately NOT published.
|
||||
# cuprated has no RPC authentication, and for a published port to reach
|
||||
# it the service would have to bind 0.0.0.0 inside the container — at
|
||||
# which point every other app can reach it directly on 18081, since
|
||||
# ports[].bind only restricts the HOST side and podman bridges route to
|
||||
# each other (verified live 2026-08-22: a peer container on archy-net
|
||||
# got an unauthenticated get_info, from a *different* network). That is
|
||||
# unlike bitcoin-knots, whose 0.0.0.0 RPC still demands the rpcuser /
|
||||
# rpcpassword it writes from generated secrets. So unrestricted RPC is
|
||||
# left at cuprated's own default — container loopback only, reachable by
|
||||
# nothing — which is also what upstream intends by refusing a non-local
|
||||
# bind without an explicit i_know_what_im_doing override.
|
||||
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
|
||||
# what wallets use when connecting to a "remote node". Disabled by
|
||||
# cuprated's own default; enabled via files[] below. A dashboard login
|
||||
# would break wallet clients connecting programmatically, same
|
||||
# reasoning as electrumx's port. The daemon still uses its canonical
|
||||
# container port 18089, but Penpot already owns host port 18089, so this
|
||||
# maps the public host port to the free 18090 instead.
|
||||
- host: 18090
|
||||
container: 18089
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/cuprate
|
||||
target: /home/cuprate
|
||||
options: [rw]
|
||||
|
||||
# Settings that need to differ from cuprated's own documented defaults
|
||||
# (verified against `cuprated --generate-config` and `--dry-run` locally,
|
||||
# 2026-08-21):
|
||||
# - target_max_memory: cuprated's own default auto-detects total *host*
|
||||
# RAM via sysinfo, which inside a memory-limited container would let
|
||||
# it size caches far past what resources.memory_limit above actually
|
||||
# grants — same class of problem bitcoin-knots' -dbcache sizing
|
||||
# comment addresses. Set explicitly, comfortably under the 4Gi limit.
|
||||
# - rpc.restricted.enable: cuprated ships this off by default; flip on
|
||||
# so the auth:none host port above actually serves something instead
|
||||
# of refusing every connection. port stays at its documented default
|
||||
# (canonical 18089), and advertise stays false — this node is not
|
||||
# opting in to being listed as a public remote node over the p2p
|
||||
# network, just reachable if someone points a wallet at it directly.
|
||||
# - rpc.unrestricted.address + the allow-public flag: cuprated's own
|
||||
# default (127.0.0.1) looks like the obviously-correct choice for a
|
||||
# port meant to stay loopback-only, but verified live (2026-08-21)
|
||||
# that a service bound literally to 127.0.0.1 *inside* the container
|
||||
# is unreachable through the host's published port — connections
|
||||
# reset regardless of how long the daemon has been up. Binding
|
||||
# 0.0.0.0 inside and letting ports[].bind: 127.0.0.1 below be the
|
||||
# actual restriction is the same pattern apps/bitcoin-knots already
|
||||
# uses for its own RPC port (-rpcbind=0.0.0.0:8332 internally, gate
|
||||
# restricts it externally) — not a new risk, the same one already
|
||||
# reviewed and accepted for Bitcoin's RPC.
|
||||
files:
|
||||
- path: /var/lib/archipelago/cuprate/Cuprated.toml
|
||||
content: |
|
||||
network = "Mainnet"
|
||||
target_max_memory = 3000000000
|
||||
|
||||
[rpc.restricted]
|
||||
enable = true
|
||||
overwrite: false
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
# Restricted RPC — the only RPC surface published now.
|
||||
endpoint: localhost:18090
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5m
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/cuprate.svg
|
||||
category: money
|
||||
tier: optional
|
||||
author: Cuprate
|
||||
repo: https://github.com/Cuprate/cuprate
|
||||
@@ -2,6 +2,9 @@ app:
|
||||
id: did-wallet
|
||||
name: Web5 DID Wallet
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
|
||||
|
||||
container:
|
||||
|
||||
@@ -2,6 +2,9 @@ app:
|
||||
id: electrs-ui
|
||||
name: Electrs UI
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: |
|
||||
Archipelago-native HTTP frontend for electrs/electrumx status. Runs
|
||||
nginx inside a container, serves static assets, and proxies
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: electrumx
|
||||
name: ElectrumX
|
||||
version: 1.18.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: spesmilo/electrumx
|
||||
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
|
||||
|
||||
container:
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: fedimint-clientd
|
||||
name: Fedimint Client
|
||||
version: 0.8.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: fedimint/fedimint-clientd
|
||||
description: Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.
|
||||
|
||||
container:
|
||||
|
||||
@@ -2,10 +2,16 @@ app:
|
||||
id: fedimint-gateway
|
||||
name: Fedimint Gateway
|
||||
version: 0.10.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: fedimint/fedimint
|
||||
description: Fedimint gateway service with automatic LND-or-LDK backend selection.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0
|
||||
image: source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
|
||||
@@ -2,10 +2,16 @@ app:
|
||||
id: fedimint
|
||||
name: Fedimint Guardian
|
||||
version: 0.10.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: fedimint/fedimint
|
||||
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0
|
||||
image: source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: filebrowser
|
||||
name: File Browser
|
||||
version: 2.27.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: filebrowser/filebrowser
|
||||
description: Baseline Archipelago file manager service.
|
||||
|
||||
container:
|
||||
|
||||
@@ -2,6 +2,9 @@ app:
|
||||
id: fips-ui
|
||||
name: FIPS Mesh
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: |
|
||||
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
|
||||
inside a container with host networking, serves a static dashboard on
|
||||
|
||||
+16
-1
@@ -2,6 +2,12 @@ app:
|
||||
id: gitea
|
||||
name: Gitea
|
||||
version: "1.23"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: go-gitea/gitea
|
||||
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
|
||||
category: development
|
||||
|
||||
@@ -27,7 +33,16 @@ app:
|
||||
container: 3000
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# open, not gated: Gitea carries a complete login of its own, and git
|
||||
# clients speak HTTP basic-auth — a cookie challenge in front of
|
||||
# git-over-HTTP breaks every clone/push. The gate still fronts the
|
||||
# port (iframe header fixes, retry page, Tor); the operator can force
|
||||
# the dashboard login back on from Settings → Gitea → Access control.
|
||||
auth: open
|
||||
auth_rationale: >-
|
||||
Gitea enforces its own account login on every page and API route;
|
||||
git clients authenticate with basic-auth/tokens and cannot complete
|
||||
a browser login challenge.
|
||||
- host: 2222
|
||||
container: 22
|
||||
protocol: tcp
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: grafana
|
||||
name: Grafana
|
||||
version: 10.2.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: grafana/grafana
|
||||
description: Analytics and monitoring platform. Visualize metrics and create dashboards.
|
||||
|
||||
container:
|
||||
|
||||
@@ -2,10 +2,16 @@ app:
|
||||
id: homeassistant
|
||||
name: Home Assistant
|
||||
version: 2026.7.3
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: home-assistant/core
|
||||
description: Open source home automation platform. Control and monitor your smart home devices.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3
|
||||
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: immich-redis
|
||||
name: Immich Redis
|
||||
version: "7-alpine"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: dockerhub
|
||||
repo: valkey/valkey
|
||||
description: Valkey (Redis-compatible) cache for Immich.
|
||||
|
||||
# Container named immich_redis (underscore) to match runtime per-app references
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: immich
|
||||
name: Immich
|
||||
version: "2.7.4"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: immich-app/immich
|
||||
description: Self-hosted photo and video backup with mobile apps and search.
|
||||
|
||||
# app_id "immich" = the user-facing launcher (matches the catalog entry's title
|
||||
|
||||
@@ -2,6 +2,9 @@ app:
|
||||
id: indeedhub-api
|
||||
name: IndeedHub API
|
||||
version: "1.0.0"
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: IndeedHub backend API (Nostr auth, media, payments).
|
||||
category: community
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ app:
|
||||
id: indeedhub-ffmpeg
|
||||
name: IndeedHub FFmpeg Worker
|
||||
version: "1.0.0"
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: IndeedHub background media transcoding worker.
|
||||
category: community
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: indeedhub-postgres
|
||||
name: IndeedHub Postgres
|
||||
version: "16.13-alpine"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: dockerhub
|
||||
repo: library/postgres
|
||||
description: Postgres database backend for IndeedHub.
|
||||
category: community
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: indeedhub-redis
|
||||
name: IndeedHub Redis
|
||||
version: "7.4.8-alpine"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: dockerhub
|
||||
repo: library/redis
|
||||
description: Redis queue/cache backend for IndeedHub.
|
||||
category: community
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: indeedhub-relay
|
||||
name: IndeedHub Nostr Relay
|
||||
version: "0.9.0"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: scsibug/nostr-rs-relay
|
||||
description: nostr-rs-relay backing IndeedHub's Nostr identity + comments.
|
||||
category: community
|
||||
|
||||
@@ -11,7 +17,7 @@ app:
|
||||
container_name: indeedhub-relay
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0
|
||||
image: source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.10.0
|
||||
pull_policy: if-not-present
|
||||
network: indeedhub-net
|
||||
network_aliases: [relay]
|
||||
|
||||
@@ -2,6 +2,9 @@ app:
|
||||
id: indeedhub
|
||||
name: IndeeHub
|
||||
version: "1.0.0"
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
|
||||
category: community
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@ app:
|
||||
id: jellyfin
|
||||
name: Jellyfin
|
||||
version: 10.8.13
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: jellyfin/jellyfin
|
||||
description: Free media server. Stream movies, music, and photos.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13
|
||||
image: source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ app:
|
||||
id: lnd-ui
|
||||
name: LND UI
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: |
|
||||
Archipelago-native HTTP frontend for LND. Runs nginx inside a
|
||||
container and serves static assets. LND connection info is fetched
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: lnd
|
||||
name: LND
|
||||
version: 0.18.4
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: lightningnetwork/lnd
|
||||
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
|
||||
|
||||
container:
|
||||
|
||||
@@ -2,10 +2,16 @@ app:
|
||||
id: mempool-api
|
||||
name: Mempool API
|
||||
version: 3.0.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: mempool/mempool
|
||||
description: Backend API for mempool explorer.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-backend:v3.0.0
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
|
||||
|
||||
@@ -2,10 +2,16 @@ app:
|
||||
id: mempool
|
||||
name: Mempool Explorer
|
||||
version: 3.0.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: mempool/mempool
|
||||
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ app:
|
||||
id: morphos-server
|
||||
name: MorphOS Server
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: MorphOS server platform. Decentralized application server.
|
||||
|
||||
container:
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: netbird-dashboard
|
||||
name: NetBird Dashboard
|
||||
version: "2.38.0"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: netbirdio/dashboard
|
||||
description: NetBird management dashboard (SPA). Internal stack member served through the netbird proxy.
|
||||
category: networking
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: netbird-server
|
||||
name: NetBird Server
|
||||
version: "0.71.2"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: netbirdio/netbird
|
||||
description: NetBird combined management / signal / relay server with an embedded identity provider and STUN. Backend for the self-hosted NetBird mesh VPN.
|
||||
category: networking
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: netbird
|
||||
name: NetBird
|
||||
version: "2.38.0"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: dockerhub
|
||||
repo: library/nginx
|
||||
description: Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.
|
||||
category: networking
|
||||
|
||||
@@ -12,7 +18,7 @@ app:
|
||||
container_name: netbird
|
||||
|
||||
container:
|
||||
image: docker.io/library/nginx:1.27-alpine
|
||||
image: docker.io/library/nginx:1.31.3-alpine
|
||||
pull_policy: if-not-present
|
||||
network: netbird-net
|
||||
# Self-signed TLS cert materialised before create — the dashboard needs a
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: nextcloud
|
||||
name: Nextcloud
|
||||
version: "29"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: nextcloud/server
|
||||
description: Your own private cloud. File sync, calendars, contacts.
|
||||
|
||||
container:
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
app:
|
||||
id: nostr-rs-relay
|
||||
name: Nostr Relay (Rust)
|
||||
version: 0.8.0
|
||||
version: 0.10.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: scsibug/nostr-rs-relay
|
||||
description: High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.
|
||||
|
||||
container:
|
||||
image: scsibug/nostr-rs-relay:0.8.9
|
||||
image: scsibug/nostr-rs-relay:0.10.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: verify-signature
|
||||
data_uid: "1000:1000"
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: phoenixd
|
||||
name: phoenixd
|
||||
version: 0.9.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: ACINQ/phoenixd
|
||||
description: Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.
|
||||
category: money
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: photoprism
|
||||
name: PhotoPrism
|
||||
version: "240915"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: photoprism/photoprism
|
||||
description: AI-powered photo management with facial recognition.
|
||||
|
||||
container:
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: pine-openwakeword
|
||||
name: Pine Wake Word (openWakeWord)
|
||||
version: "2.1.0"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: rhasspy/wyoming-openwakeword
|
||||
description: Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member — lets Assist pipelines run wake-word detection on the node (groundwork for the custom "Yo Archy" wake word; stock models like "ok nabu" ship with the image).
|
||||
category: home
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
app:
|
||||
id: pine-piper
|
||||
name: Pine Piper (TTS)
|
||||
version: "2.2.2"
|
||||
version: "2.4.2"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: rhasspy/wyoming-piper
|
||||
description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite.
|
||||
category: home
|
||||
|
||||
@@ -12,7 +18,7 @@ app:
|
||||
container_name: pine-piper
|
||||
|
||||
container:
|
||||
image: docker.io/rhasspy/wyoming-piper:2.2.2
|
||||
image: docker.io/rhasspy/wyoming-piper:2.4.2
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
network_aliases: [pine-piper]
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: pine
|
||||
name: Pine
|
||||
version: "1.3.0"
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: dockerhub
|
||||
repo: library/nginx
|
||||
description: A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.
|
||||
category: home
|
||||
|
||||
@@ -13,7 +19,7 @@ app:
|
||||
container_name: pine
|
||||
|
||||
container:
|
||||
image: docker.io/library/nginx:1.27-alpine
|
||||
image: docker.io/library/nginx:1.31.3-alpine
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
network_aliases: [pine]
|
||||
|
||||
@@ -2,11 +2,17 @@ app:
|
||||
id: portainer
|
||||
name: Portainer
|
||||
version: 2.19.4
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: portainer/portainer
|
||||
description: Container management web UI for the local Podman socket.
|
||||
category: development
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.1
|
||||
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.6
|
||||
pull_policy: if-not-present
|
||||
data_uid: "1000:1000"
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ app:
|
||||
id: router
|
||||
name: Mesh Router
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: Mesh routing and local network management. Provides device discovery, routing, and network topology visualization.
|
||||
|
||||
container:
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: searxng
|
||||
name: SearXNG
|
||||
version: 1.0.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: searxng/searxng
|
||||
description: Privacy-respecting metasearch engine. Search the web without tracking.
|
||||
|
||||
container:
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
app:
|
||||
id: strfry
|
||||
name: Strfry Nostr Relay
|
||||
version: 0.9.0
|
||||
version: 1.1.1
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: hoytech/strfry
|
||||
description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage.
|
||||
|
||||
container:
|
||||
image: dockurr/strfry:1.0.4
|
||||
image: dockurr/strfry:1.1.1
|
||||
image_signature: cosign://...
|
||||
pull_policy: verify-signature
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ app:
|
||||
id: uptime-kuma
|
||||
name: Uptime Kuma
|
||||
version: 1.23.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: louislam/uptime-kuma
|
||||
description: Self-hosted uptime monitoring.
|
||||
|
||||
container:
|
||||
|
||||
@@ -2,10 +2,16 @@ app:
|
||||
id: vaultwarden
|
||||
name: Vaultwarden
|
||||
version: 1.30.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: dani-garcia/vaultwarden
|
||||
description: Self-hosted password vault with zero-knowledge encryption.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine
|
||||
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
|
||||
Generated
+398
-58
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.3-alpha"
|
||||
version = "1.8.4-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
@@ -120,6 +120,7 @@ dependencies = [
|
||||
"blake3",
|
||||
"bs58",
|
||||
"bytes",
|
||||
"cashu",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"ciborium",
|
||||
@@ -186,7 +187,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hex",
|
||||
"hyper 0.14.32",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"reqwest 0.11.27",
|
||||
"serde",
|
||||
@@ -450,8 +451,8 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f"
|
||||
dependencies = [
|
||||
"bitcoin-internals 0.3.0",
|
||||
"bitcoin_hashes 0.14.1",
|
||||
"bitcoin-internals",
|
||||
"bitcoin_hashes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -499,11 +500,11 @@ checksum = "597bb81c80a54b6a4381b23faba8d7774b144c94cbd1d6fe3f1329bd776554ab"
|
||||
|
||||
[[package]]
|
||||
name = "bip39"
|
||||
version = "2.1.0"
|
||||
version = "2.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387"
|
||||
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
|
||||
dependencies = [
|
||||
"bitcoin_hashes 0.13.0",
|
||||
"bitcoin_hashes",
|
||||
"rand 0.8.5",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
@@ -526,27 +527,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026"
|
||||
dependencies = [
|
||||
"base58ck",
|
||||
"base64 0.21.7",
|
||||
"bech32",
|
||||
"bitcoin-internals 0.3.0",
|
||||
"bitcoin-internals",
|
||||
"bitcoin-io",
|
||||
"bitcoin-units",
|
||||
"bitcoin_hashes 0.14.1",
|
||||
"hex-conservative 0.2.2",
|
||||
"bitcoin_hashes",
|
||||
"hex-conservative",
|
||||
"hex_lit",
|
||||
"secp256k1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin-internals"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb"
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin-internals"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin-io"
|
||||
@@ -560,17 +560,8 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2"
|
||||
dependencies = [
|
||||
"bitcoin-internals 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin_hashes"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b"
|
||||
dependencies = [
|
||||
"bitcoin-internals 0.2.0",
|
||||
"hex-conservative 0.1.2",
|
||||
"bitcoin-internals",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -580,7 +571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b"
|
||||
dependencies = [
|
||||
"bitcoin-io",
|
||||
"hex-conservative 0.2.2",
|
||||
"hex-conservative",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -707,6 +698,32 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cashu"
|
||||
version = "0.17.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8bd7216af2b980e203d10677076d8c6c5c30610cbdea6549b8a9020cfa0cf47b"
|
||||
dependencies = [
|
||||
"bitcoin",
|
||||
"cbor-diag",
|
||||
"ciborium",
|
||||
"lightning",
|
||||
"lightning-invoice",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"strum 0.27.2",
|
||||
"strum_macros 0.27.2",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"unicode-normalization",
|
||||
"url",
|
||||
"uuid",
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
@@ -716,6 +733,25 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbor-diag"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc245b6ecd09b23901a4fbad1ad975701fd5061ceaef6afa93a2d70605a64429"
|
||||
dependencies = [
|
||||
"bs58",
|
||||
"chrono",
|
||||
"data-encoding",
|
||||
"half",
|
||||
"nom",
|
||||
"num-bigint",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"separator",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.54"
|
||||
@@ -1092,8 +1128,18 @@ version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
"darling_core 0.20.11",
|
||||
"darling_macro 0.20.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"darling_macro 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1110,13 +1156,37 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_core 0.20.11",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
@@ -1147,6 +1217,37 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -1187,6 +1288,9 @@ name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
@@ -1214,7 +1318,7 @@ version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.20.11",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
@@ -1314,6 +1418,18 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnssec-prover"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "869bf72abc8c654b350aa8d881c5d9957b85e1e1ed6569c10cd68e9f505d5435"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
@@ -1774,7 +1890,7 @@ dependencies = [
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -1793,7 +1909,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.4.0",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -1829,6 +1945,12 @@ dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1887,12 +2009,6 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hex-conservative"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20"
|
||||
|
||||
[[package]]
|
||||
name = "hex-conservative"
|
||||
version = "0.2.2"
|
||||
@@ -2391,6 +2507,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
@@ -2507,7 +2634,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"strum",
|
||||
"strum 0.28.0",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -2595,7 +2722,7 @@ dependencies = [
|
||||
"rand 0.10.1",
|
||||
"rustls 0.23.36",
|
||||
"simple-dns",
|
||||
"strum",
|
||||
"strum 0.28.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -2675,7 +2802,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"strum",
|
||||
"strum 0.28.0",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
@@ -2765,6 +2892,59 @@ version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"jiff-tzdb-platform",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
|
||||
dependencies = [
|
||||
"jiff-core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb-platform"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
|
||||
dependencies = [
|
||||
"jiff-tzdb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
@@ -2911,6 +3091,55 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightning"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ab16d2a714c0b26d7230bd388ac383a30fce231c8927c62752afc0471a36dc6"
|
||||
dependencies = [
|
||||
"bech32",
|
||||
"bitcoin",
|
||||
"dnssec-prover",
|
||||
"hashbrown 0.13.2",
|
||||
"libm",
|
||||
"lightning-invoice",
|
||||
"lightning-macros",
|
||||
"lightning-types",
|
||||
"possiblyrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightning-invoice"
|
||||
version = "0.34.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d83bd798e04ab9eecc8bbef1fa17d3808859bcdc0406bd16c55d51c8834444"
|
||||
dependencies = [
|
||||
"bech32",
|
||||
"bitcoin",
|
||||
"lightning-types",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightning-macros"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4c717494cdc2c8bb85bee7113031248f5f6c64f8802b33c1c9e2d98e594aa71"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightning-types"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c77c676d4a34cceb2ae3756916e446b4d17f9430a24107e099981f0f9aec77e6"
|
||||
dependencies = [
|
||||
"bitcoin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
@@ -3415,7 +3644,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bech32",
|
||||
"bip39",
|
||||
"bitcoin_hashes 0.14.1",
|
||||
"bitcoin_hashes",
|
||||
"cbc",
|
||||
"chacha20 0.9.1",
|
||||
"chacha20poly1305",
|
||||
@@ -3517,6 +3746,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -3896,7 +4136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
@@ -3934,6 +4174,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portmapper"
|
||||
version = "0.19.0"
|
||||
@@ -3973,6 +4222,15 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "possiblyrandom"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c564dbf654befd49035528299f1208a40508f6e07efb11c163444e304e4484f"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
@@ -4643,6 +4901,30 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@@ -4692,7 +4974,7 @@ version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
||||
dependencies = [
|
||||
"bitcoin_hashes 0.14.1",
|
||||
"bitcoin_hashes",
|
||||
"rand 0.8.5",
|
||||
"secp256k1-sys",
|
||||
"serde",
|
||||
@@ -4758,6 +5040,12 @@ version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
|
||||
|
||||
[[package]]
|
||||
name = "separator"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f97841a747eef040fcd2e7b3b9a220a7205926e60488e673d9e4926d27772ce5"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@@ -4842,13 +5130,46 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bs58",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.13.0",
|
||||
"jiff",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.2.2",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
@@ -5137,13 +5458,31 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
"strum_macros 0.28.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5606,7 +5945,7 @@ version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.11",
|
||||
@@ -5620,7 +5959,7 @@ version = "0.25.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.3",
|
||||
@@ -5823,9 +6162,9 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.22"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
@@ -5903,6 +6242,7 @@ checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -6080,7 +6420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
@@ -6119,7 +6459,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"semver",
|
||||
]
|
||||
|
||||
@@ -6678,7 +7018,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"prettyplease",
|
||||
"syn 2.0.114",
|
||||
"wasm-metadata",
|
||||
@@ -6709,7 +7049,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.0",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
@@ -6728,7 +7068,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -6959,7 +7299,7 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.3-alpha"
|
||||
version = "1.8.4-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
@@ -72,7 +72,7 @@ bs58 = "0.5"
|
||||
chrono = "0.4"
|
||||
|
||||
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation
|
||||
bip39 = { version = "=2.1.0", features = ["rand"] }
|
||||
bip39 = { version = "2.1", features = ["rand"] }
|
||||
bitcoin = { version = "=0.32.5", features = ["rand-std"] }
|
||||
|
||||
# Configuration
|
||||
@@ -143,6 +143,7 @@ async-trait = "0.1"
|
||||
iroh = { version = "1", optional = true }
|
||||
iroh-blobs = { version = "0.103", optional = true }
|
||||
lofty = "0.24.0"
|
||||
cashu = { version = "0.17.5", default-features = false, features = ["wallet"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
//! CDP input bridge — forwards companion remote input into the local kiosk
|
||||
//! Chromium as *trusted*, browser-level input via the DevTools protocol.
|
||||
//!
|
||||
//! Why this exists: the web relay path (`remote-relay.ts`) synthesizes DOM
|
||||
//! events in the top document, and synthetic events can never cross into a
|
||||
//! cross-origin iframe — so on the kiosk, companion taps/keys/scrolls died at
|
||||
//! the border of every containerized app's frame. CDP `Input.dispatch*`
|
||||
//! events enter the browser's real input pipeline: they hit-test through any
|
||||
//! frame, move focus, and insert text exactly like a physical device, which
|
||||
//! is the only correct way to drive app iframes (tracked in the unified task
|
||||
//! tracker; supersedes the earlier "no CDP" note in
|
||||
//! `docs/tv-input-iframe-apps.md`, which was about gamepad *keys* only).
|
||||
//!
|
||||
//! The kiosk launcher opens Chromium with `--remote-debugging-port=9222`
|
||||
//! bound to loopback. This keeper task discovers the page target, holds one
|
||||
//! WebSocket to it, and reconnects whenever the kiosk restarts (the launcher
|
||||
//! supervises Chromium in a loop, so the debugger URL changes under us).
|
||||
//! When the bridge is not connected (non-kiosk installs, kiosk booting),
|
||||
//! `is_active()` is false and callers fall back to the web relay unchanged.
|
||||
//!
|
||||
//! Security: the CDP port is loopback-only and Chromium's default origin
|
||||
//! check stands (we deliberately do NOT pass `--remote-allow-origins`, so
|
||||
//! browser pages can't open the debug socket; our raw client sends no
|
||||
//! Origin header and is accepted).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const CDP_HTTP: &str = "http://127.0.0.1:9222";
|
||||
/// Marker whose presence means this node drives a local kiosk display.
|
||||
const KIOSK_UNIT: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
||||
/// One relay scroll step ≈ this many CSS pixels (matches remote-relay.ts).
|
||||
const SCROLL_STEP_PX: f64 = 100.0;
|
||||
|
||||
/// Cloneable handle the WS handlers use to feed validated relay JSON into
|
||||
/// the keeper task.
|
||||
#[derive(Clone)]
|
||||
pub struct CdpBridge {
|
||||
tx: mpsc::Sender<String>,
|
||||
connected: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl CdpBridge {
|
||||
/// Spawn the session keeper and return the shared handle.
|
||||
pub fn spawn() -> Self {
|
||||
let (tx, rx) = mpsc::channel::<String>(256);
|
||||
let connected = Arc::new(AtomicBool::new(false));
|
||||
tokio::spawn(run_keeper(rx, connected.clone()));
|
||||
Self { tx, connected }
|
||||
}
|
||||
|
||||
/// True only while a live CDP session to the kiosk Chromium exists.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.connected.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Queue a validated relay input message (the exact JSON that goes to the
|
||||
/// broadcast channel) for CDP dispatch. Best-effort: if the keeper is
|
||||
/// behind or gone the message is dropped — input is transient by nature.
|
||||
pub fn send(&self, relay_json: &str) {
|
||||
let _ = self.tx.try_send(relay_json.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Virtual cursor state, server-side. The web relay keeps this in the kiosk
|
||||
/// page (`cursorX`/`cursorY`); CDP needs its own copy because trusted mouse
|
||||
/// events carry absolute viewport coordinates.
|
||||
struct Cursor {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
}
|
||||
|
||||
async fn run_keeper(mut rx: mpsc::Receiver<String>, connected: Arc<AtomicBool>) {
|
||||
loop {
|
||||
// Cheap gate: no kiosk unit on this node → nothing to drive. Keep
|
||||
// draining queued input so the channel never backs up.
|
||||
if tokio::fs::metadata(KIOSK_UNIT).await.is_err() {
|
||||
drain_for(&mut rx, Duration::from_secs(60)).await;
|
||||
continue;
|
||||
}
|
||||
let Some(ws_url) = discover_page_target().await else {
|
||||
// Kiosk configured but Chromium not up (or CDP flag not rolled
|
||||
// out yet) — retry gently.
|
||||
drain_for(&mut rx, Duration::from_secs(15)).await;
|
||||
continue;
|
||||
};
|
||||
match drive_session(&ws_url, &mut rx, &connected).await {
|
||||
Ok(()) => info!("CDP kiosk input session ended cleanly"),
|
||||
Err(e) => debug!(error = %e, "CDP kiosk input session dropped — will re-discover"),
|
||||
}
|
||||
connected.store(false, Ordering::Relaxed);
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Discard queued input for `d` — used while no kiosk session exists so the
|
||||
/// bounded channel can't fill with stale events.
|
||||
async fn drain_for(rx: &mut mpsc::Receiver<String>, d: Duration) {
|
||||
let _ = tokio::time::timeout(d, async { while rx.recv().await.is_some() {} }).await;
|
||||
}
|
||||
|
||||
/// Find the kiosk page target's WebSocket debugger URL. Prefers the page on
|
||||
/// localhost (the kiosk app) over e.g. devtools/extension targets.
|
||||
async fn discover_page_target() -> Option<String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(3))
|
||||
.build()
|
||||
.ok()?;
|
||||
let list: Vec<Value> = client
|
||||
.get(format!("{CDP_HTTP}/json/list"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
.json()
|
||||
.await
|
||||
.ok()?;
|
||||
let pages: Vec<&Value> = list
|
||||
.iter()
|
||||
.filter(|t| t.get("type").and_then(Value::as_str) == Some("page"))
|
||||
.collect();
|
||||
let preferred = pages
|
||||
.iter()
|
||||
.find(|t| {
|
||||
t.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|u| u.contains("localhost") || u.contains("127.0.0.1"))
|
||||
})
|
||||
.or_else(|| pages.first());
|
||||
preferred?
|
||||
.get("webSocketDebuggerUrl")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
async fn drive_session(
|
||||
ws_url: &str,
|
||||
rx: &mut mpsc::Receiver<String>,
|
||||
connected: &Arc<AtomicBool>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (ws, _) = tokio_tungstenite::connect_async(ws_url).await?;
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
let mut next_id: u64 = 0;
|
||||
let mut id = move || {
|
||||
next_id += 1;
|
||||
next_id
|
||||
};
|
||||
|
||||
// Viewport size for cursor clamping. Best-effort: fall back to 1080p if
|
||||
// the metrics call fails — clamping is a nicety, not a correctness need.
|
||||
sink.send(Message::Text(
|
||||
json!({"id": id(), "method": "Page.getLayoutMetrics"}).to_string(),
|
||||
))
|
||||
.await?;
|
||||
let (mut vw, mut vh) = (1920.0_f64, 1080.0_f64);
|
||||
if let Ok(Some(Ok(Message::Text(txt)))) =
|
||||
tokio::time::timeout(Duration::from_secs(3), stream.next()).await
|
||||
{
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&txt) {
|
||||
if let Some(vp) = v.pointer("/result/cssLayoutViewport") {
|
||||
vw = vp.get("clientWidth").and_then(Value::as_f64).unwrap_or(vw);
|
||||
vh = vp.get("clientHeight").and_then(Value::as_f64).unwrap_or(vh);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut cursor = Cursor {
|
||||
x: vw / 2.0,
|
||||
y: vh / 2.0,
|
||||
w: vw,
|
||||
h: vh,
|
||||
};
|
||||
|
||||
connected.store(true, Ordering::Relaxed);
|
||||
info!(viewport = %format!("{vw}x{vh}"), "CDP kiosk input bridge connected");
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
cmd = rx.recv() => {
|
||||
let Some(cmd) = cmd else { return Ok(()) };
|
||||
for frame in translate(&cmd, &mut cursor, &mut id) {
|
||||
sink.send(Message::Text(frame.to_string())).await?;
|
||||
}
|
||||
}
|
||||
msg = stream.next() => {
|
||||
match msg {
|
||||
// Responses/events — nothing to correlate, but a read
|
||||
// error or close means Chromium restarted.
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(e)) => return Err(e.into()),
|
||||
None => anyhow::bail!("CDP socket closed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate one validated relay input message into CDP command frames.
|
||||
fn translate(raw: &str, cursor: &mut Cursor, id: &mut impl FnMut() -> u64) -> Vec<Value> {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(raw) else {
|
||||
return vec![];
|
||||
};
|
||||
match msg.get("t").and_then(Value::as_str) {
|
||||
Some("m") => {
|
||||
let dx = msg.get("x").and_then(Value::as_i64).unwrap_or(0) as f64;
|
||||
let dy = msg.get("y").and_then(Value::as_i64).unwrap_or(0) as f64;
|
||||
cursor.x = (cursor.x + dx).clamp(0.0, cursor.w - 1.0);
|
||||
cursor.y = (cursor.y + dy).clamp(0.0, cursor.h - 1.0);
|
||||
vec![mouse_event(id(), "mouseMoved", cursor, "none", 0, 1)]
|
||||
}
|
||||
Some("c") => {
|
||||
let b = msg
|
||||
.get("b")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(1)
|
||||
.clamp(1, 3);
|
||||
let (button, buttons) = match b {
|
||||
2 => ("middle", 4),
|
||||
3 => ("right", 2),
|
||||
_ => ("left", 1),
|
||||
};
|
||||
vec![
|
||||
// Hover first so the press lands on current hit-test state.
|
||||
mouse_event(id(), "mouseMoved", cursor, "none", 0, 1),
|
||||
mouse_event(id(), "mousePressed", cursor, button, buttons, 1),
|
||||
mouse_event(id(), "mouseReleased", cursor, button, 0, 1),
|
||||
]
|
||||
}
|
||||
Some("s") => {
|
||||
let dy = msg.get("y").and_then(Value::as_i64).unwrap_or(0) as f64 * SCROLL_STEP_PX;
|
||||
vec![json!({
|
||||
"id": id(),
|
||||
"method": "Input.dispatchMouseEvent",
|
||||
"params": {
|
||||
"type": "mouseWheel",
|
||||
"x": cursor.x, "y": cursor.y,
|
||||
"deltaX": 0.0, "deltaY": dy,
|
||||
"pointerType": "mouse",
|
||||
}
|
||||
})]
|
||||
}
|
||||
Some("k") => {
|
||||
let Some(k) = msg.get("k").and_then(Value::as_str) else {
|
||||
return vec![];
|
||||
};
|
||||
key_events(k, id)
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_event(
|
||||
id: u64,
|
||||
kind: &str,
|
||||
cursor: &Cursor,
|
||||
button: &str,
|
||||
buttons: u32,
|
||||
clicks: u32,
|
||||
) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"method": "Input.dispatchMouseEvent",
|
||||
"params": {
|
||||
"type": kind,
|
||||
"x": cursor.x, "y": cursor.y,
|
||||
"button": button,
|
||||
"buttons": buttons,
|
||||
"clickCount": if kind == "mousePressed" || kind == "mouseReleased" { clicks } else { 0 },
|
||||
"pointerType": "mouse",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// xdotool named key → (DOM key, DOM code, Windows virtual-key code).
|
||||
fn named_key(k: &str) -> Option<(&'static str, &'static str, i32)> {
|
||||
Some(match k {
|
||||
"Return" => ("Enter", "Enter", 13),
|
||||
"BackSpace" => ("Backspace", "Backspace", 8),
|
||||
"Escape" => ("Escape", "Escape", 27),
|
||||
"Tab" => ("Tab", "Tab", 9),
|
||||
"Delete" => ("Delete", "Delete", 46),
|
||||
"Up" => ("ArrowUp", "ArrowUp", 38),
|
||||
"Down" => ("ArrowDown", "ArrowDown", 40),
|
||||
"Left" => ("ArrowLeft", "ArrowLeft", 37),
|
||||
"Right" => ("ArrowRight", "ArrowRight", 39),
|
||||
"Home" => ("Home", "Home", 36),
|
||||
"End" => ("End", "End", 35),
|
||||
"Prior" => ("PageUp", "PageUp", 33),
|
||||
"Next" => ("PageDown", "PageDown", 34),
|
||||
"F1" => ("F1", "F1", 112),
|
||||
"F2" => ("F2", "F2", 113),
|
||||
"F3" => ("F3", "F3", 114),
|
||||
"F4" => ("F4", "F4", 115),
|
||||
"F5" => ("F5", "F5", 116),
|
||||
"F6" => ("F6", "F6", 117),
|
||||
"F7" => ("F7", "F7", 118),
|
||||
"F8" => ("F8", "F8", 119),
|
||||
"F9" => ("F9", "F9", 120),
|
||||
"F10" => ("F10", "F10", 121),
|
||||
"F11" => ("F11", "F11", 122),
|
||||
"F12" => ("F12", "F12", 123),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// xdotool symbol name → printable char (the relay whitelist speaks xdotool).
|
||||
fn symbol_char(k: &str) -> Option<char> {
|
||||
Some(match k {
|
||||
"space" => ' ',
|
||||
"exclam" => '!',
|
||||
"at" => '@',
|
||||
"numbersign" => '#',
|
||||
"dollar" => '$',
|
||||
"percent" => '%',
|
||||
"asciicircum" => '^',
|
||||
"ampersand" => '&',
|
||||
"asterisk" => '*',
|
||||
"parenleft" => '(',
|
||||
"parenright" => ')',
|
||||
"underscore" => '_',
|
||||
"plus" => '+',
|
||||
"braceleft" => '{',
|
||||
"braceright" => '}',
|
||||
"bar" => '|',
|
||||
"colon" => ':',
|
||||
"quotedbl" => '"',
|
||||
"less" => '<',
|
||||
"greater" => '>',
|
||||
"question" => '?',
|
||||
"asciitilde" => '~',
|
||||
"minus" => '-',
|
||||
"equal" => '=',
|
||||
"bracketleft" => '[',
|
||||
"bracketright" => ']',
|
||||
"backslash" => '\\',
|
||||
"semicolon" => ';',
|
||||
"apostrophe" => '\'',
|
||||
"grave" => '`',
|
||||
"comma" => ',',
|
||||
"period" => '.',
|
||||
"slash" => '/',
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the CDP frame pair (keyDown, keyUp) for one relay key name,
|
||||
/// including `modifier+base` combos. A keyDown that carries `text` both
|
||||
/// fires real keydown/keypress AND inserts the character — exactly how a
|
||||
/// physical keystroke behaves, so games see the key and fields get the text.
|
||||
fn key_events(k: &str, id: &mut impl FnMut() -> u64) -> Vec<Value> {
|
||||
let (modifiers, base) = match k.split_once('+') {
|
||||
Some((m, b)) => (
|
||||
match m {
|
||||
"alt" => 1,
|
||||
"ctrl" => 2,
|
||||
"super" => 4,
|
||||
"shift" => 8,
|
||||
_ => 0,
|
||||
},
|
||||
b,
|
||||
),
|
||||
None => (0, k),
|
||||
};
|
||||
|
||||
let (key, code, vk, text): (String, Option<&str>, i32, Option<String>) =
|
||||
if let Some((key, code, vk)) = named_key(base) {
|
||||
// Enter carries "\r" like a real keyboard so single-line inputs
|
||||
// submit and textareas newline.
|
||||
let text = (key == "Enter").then(|| "\r".to_string());
|
||||
(key.to_string(), Some(code), vk, text)
|
||||
} else {
|
||||
let ch = if base.chars().count() == 1 {
|
||||
base.chars().next()
|
||||
} else {
|
||||
symbol_char(base)
|
||||
};
|
||||
let Some(mut ch) = ch else {
|
||||
return vec![];
|
||||
};
|
||||
if modifiers == 8 && ch.is_ascii_alphabetic() {
|
||||
ch = ch.to_ascii_uppercase();
|
||||
}
|
||||
let vk = ch.to_ascii_uppercase() as i32;
|
||||
// Ctrl/Alt/Super chords are shortcuts, not typing — no text.
|
||||
let text = (modifiers & !8 == 0).then(|| ch.to_string());
|
||||
(ch.to_string(), None, vk, text)
|
||||
};
|
||||
|
||||
let mut down = json!({
|
||||
"id": id(),
|
||||
"method": "Input.dispatchKeyEvent",
|
||||
"params": {
|
||||
"type": "keyDown",
|
||||
"key": key,
|
||||
"modifiers": modifiers,
|
||||
"windowsVirtualKeyCode": vk,
|
||||
"nativeVirtualKeyCode": vk,
|
||||
}
|
||||
});
|
||||
if let Some(code) = code {
|
||||
down["params"]["code"] = json!(code);
|
||||
}
|
||||
if let Some(t) = &text {
|
||||
down["params"]["text"] = json!(t);
|
||||
down["params"]["unmodifiedText"] = json!(t);
|
||||
}
|
||||
let mut up = json!({
|
||||
"id": id(),
|
||||
"method": "Input.dispatchKeyEvent",
|
||||
"params": {
|
||||
"type": "keyUp",
|
||||
"key": key,
|
||||
"modifiers": modifiers,
|
||||
"windowsVirtualKeyCode": vk,
|
||||
"nativeVirtualKeyCode": vk,
|
||||
}
|
||||
});
|
||||
if let Some(code) = code {
|
||||
up["params"]["code"] = json!(code);
|
||||
}
|
||||
vec![down, up]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod blob;
|
||||
mod cdp;
|
||||
mod content;
|
||||
mod dwn;
|
||||
mod model_proxy;
|
||||
@@ -51,6 +52,10 @@ pub struct ApiHandler {
|
||||
/// to the phone's default browser. Lets "open in external browser" apps —
|
||||
/// which the kiosk can't usefully open itself — launch on the controller.
|
||||
external_open_tx: broadcast::Sender<String>,
|
||||
/// Bridge that dispatches companion input into the local kiosk Chromium
|
||||
/// as trusted CDP events (reaches inside cross-origin app iframes).
|
||||
/// Inert (never connects) on nodes without a kiosk.
|
||||
cdp_bridge: cdp::CdpBridge,
|
||||
/// Content-addressed blob store for attachments shared over mesh/federation.
|
||||
blob_store: Arc<BlobStore>,
|
||||
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
|
||||
@@ -79,6 +84,7 @@ impl ApiHandler {
|
||||
);
|
||||
let (input_relay_tx, _) = broadcast::channel(64);
|
||||
let (external_open_tx, _) = broadcast::channel(16);
|
||||
let cdp_bridge = cdp::CdpBridge::spawn();
|
||||
|
||||
// Derive a blob-store capability key from the node's Ed25519 signing
|
||||
// key. SHA-256 domain-separated so rotating the identity rotates
|
||||
@@ -109,6 +115,7 @@ impl ApiHandler {
|
||||
session_store,
|
||||
input_relay_tx,
|
||||
external_open_tx,
|
||||
cdp_bridge,
|
||||
blob_store,
|
||||
self_pubkey_hex,
|
||||
})
|
||||
@@ -402,6 +409,7 @@ impl ApiHandler {
|
||||
req,
|
||||
self.input_relay_tx.clone(),
|
||||
self.external_open_tx.subscribe(),
|
||||
self.cdp_bridge.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -416,6 +424,7 @@ impl ApiHandler {
|
||||
req,
|
||||
self.input_relay_tx.subscribe(),
|
||||
self.external_open_tx.clone(),
|
||||
self.cdp_bridge.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -212,6 +212,7 @@ impl ApiHandler {
|
||||
req: Request<hyper::Body>,
|
||||
relay_tx: broadcast::Sender<String>,
|
||||
mut external_open_rx: broadcast::Receiver<String>,
|
||||
cdp_bridge: super::cdp::CdpBridge,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// Extract optional player ID from query string: /ws/remote-input?p=1
|
||||
let player_id: Option<u8> = req
|
||||
@@ -317,9 +318,20 @@ impl ApiHandler {
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
let _ = relay_tx.send(relay_text);
|
||||
let validation = handle_input(&text).await;
|
||||
let _ = relay_tx.send(relay_text.clone());
|
||||
// Trusted-input path: while the kiosk CDP
|
||||
// bridge is live, also dispatch validated
|
||||
// input into the kiosk Chromium so it
|
||||
// lands inside cross-origin app iframes
|
||||
// (the web relay above can't cross that
|
||||
// boundary; the kiosk subscriber mutes its
|
||||
// own DOM synthesis — remote_relay.rs).
|
||||
if matches!(validation, Ok(None)) && cdp_bridge.is_active() {
|
||||
cdp_bridge.send(&relay_text);
|
||||
}
|
||||
|
||||
match handle_input(&text).await {
|
||||
match validation {
|
||||
Ok(Some(reply)) => {
|
||||
let _ = tx.send(Message::Text(reply)).await;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,16 @@ impl ApiHandler {
|
||||
req: Request<hyper::Body>,
|
||||
mut relay_rx: broadcast::Receiver<String>,
|
||||
external_open_tx: broadcast::Sender<String>,
|
||||
cdp_bridge: super::cdp::CdpBridge,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// The kiosk browser self-identifies with ?kiosk=1 so we can suppress
|
||||
// its DOM-synthesis path while the CDP bridge delivers trusted input
|
||||
// (otherwise every key/click/scroll would apply twice). A remote
|
||||
// browser claiming kiosk=1 only mutes its own input — harmless.
|
||||
let is_kiosk = req
|
||||
.uri()
|
||||
.query()
|
||||
.is_some_and(|q| q.split('&').any(|s| s == "kiosk=1"));
|
||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||
|
||||
@@ -60,6 +69,19 @@ impl ApiHandler {
|
||||
msg = relay_rx.recv() => {
|
||||
match msg {
|
||||
Ok(text) => {
|
||||
// Kiosk + live CDP bridge: keys/clicks/
|
||||
// scrolls arrive as trusted browser input
|
||||
// via CDP; forward only cursor moves (the
|
||||
// on-screen cursor is drawn by the page)
|
||||
// so nothing applies twice.
|
||||
if is_kiosk && cdp_bridge.is_active() {
|
||||
let tag = serde_json::from_str::<serde_json::Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| v.get("t").and_then(|t| t.as_str().map(str::to_string)));
|
||||
if !matches!(tag.as_deref(), Some("m") | Some("o") | Some("p")) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if tx.send(Message::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,13 @@ impl RpcHandler {
|
||||
"port": g.port,
|
||||
"app_id": g.app_id,
|
||||
"app_name": g.app_name,
|
||||
// Is the login challenge active on this port right now
|
||||
// (manifest default + operator override, resolved)?
|
||||
"gate_enabled": g.auth_enabled,
|
||||
// Whether an operator override is recorded, and what the
|
||||
// manifest would do without it — the UI needs all three
|
||||
// to render a meaningful toggle.
|
||||
"override": crate::container::app_gate_config::gate_override(&g.app_id),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -56,4 +63,59 @@ impl RpcHandler {
|
||||
"exempt": exempt,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `security.set-app-gate` — the operator's per-app gate toggle.
|
||||
///
|
||||
/// Params: `{ id: "<app_id>", enabled: true | false | null }`.
|
||||
/// `enabled: null` clears the override so the manifest default applies
|
||||
/// again. Takes effect on the next request (the gate resolves per-request
|
||||
/// policy from the live port map) — no rebind, no restart.
|
||||
pub(in crate::api::rpc) async fn handle_set_app_gate(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing id"))?
|
||||
.to_string();
|
||||
let enabled = match params.get("enabled") {
|
||||
None | Some(serde_json::Value::Null) => None,
|
||||
Some(serde_json::Value::Bool(b)) => Some(*b),
|
||||
Some(other) => anyhow::bail!("enabled must be true, false or null, got {other}"),
|
||||
};
|
||||
|
||||
// Only apps the gate actually fronts have a challenge to toggle.
|
||||
// Writing an override for anything else would sit silently in the
|
||||
// config doing nothing — reject instead so a typo'd id is loud.
|
||||
let port_map = self.app_gate.port_map().await;
|
||||
if !port_map.gated_ports().any(|g| g.app_id == app_id) {
|
||||
anyhow::bail!(
|
||||
"'{app_id}' has no gate-fronted ports — nothing to toggle \
|
||||
(auth: none/local ports are manifest-declared, not runtime-toggled)"
|
||||
);
|
||||
}
|
||||
|
||||
crate::container::app_gate_config::write_gate_override(&app_id, enabled)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to persist gate override: {e}"))?;
|
||||
// Rebuild the port map now so the change is live on the next request
|
||||
// instead of after the next 60s sweep.
|
||||
self.app_gate.refresh().await;
|
||||
|
||||
let effective: Vec<serde_json::Value> = self
|
||||
.app_gate
|
||||
.port_map()
|
||||
.await
|
||||
.gated_ports()
|
||||
.filter(|g| g.app_id == app_id)
|
||||
.map(|g| serde_json::json!({ "port": g.port, "gate_enabled": g.auth_enabled }))
|
||||
.collect();
|
||||
tracing::info!(
|
||||
app = %app_id,
|
||||
override_ = ?enabled,
|
||||
"app gate override updated by operator"
|
||||
);
|
||||
Ok(serde_json::json!({ "id": app_id, "override": enabled, "ports": effective }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,12 @@ impl RpcHandler {
|
||||
"wallet.ecash-send" => self.handle_wallet_ecash_send(params).await,
|
||||
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
|
||||
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
|
||||
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
|
||||
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await,
|
||||
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
|
||||
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
|
||||
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
|
||||
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
|
||||
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
|
||||
// Fedimint ecash (via fedimint-clientd sidecar)
|
||||
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
|
||||
@@ -489,6 +495,7 @@ impl RpcHandler {
|
||||
|
||||
// System monitoring
|
||||
"security.app-gate-status" => self.handle_app_gate_status().await,
|
||||
"security.set-app-gate" => self.handle_set_app_gate(params).await,
|
||||
"system.get-hostname" => self.handle_system_get_hostname().await,
|
||||
"system.stats" => self.handle_system_stats().await,
|
||||
"system.processes" => self.handle_system_processes().await,
|
||||
|
||||
@@ -667,7 +667,11 @@ impl RpcHandler {
|
||||
.get("state")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s == "SETTLED")
|
||||
.unwrap_or_else(|| body.get("settled").and_then(|v| v.as_bool()).unwrap_or(false));
|
||||
.unwrap_or_else(|| {
|
||||
body.get("settled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let amt_paid_sat = body
|
||||
.get("amt_paid_sat")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -172,6 +172,20 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"No pending seed generation",
|
||||
"Submitted words",
|
||||
"Already set up",
|
||||
// Ecash backup phrase — these two ARE the feature's safety rails, and
|
||||
// masking them made it dangerous rather than merely opaque. "That is
|
||||
// not a valid BIP-39 recovery phrase… check for typos" is the whole
|
||||
// help someone gets when a pasted phrase has a bad word; and "This
|
||||
// wallet already has a backup phrase… reveal and write down the
|
||||
// current phrase first, then confirm to replace it" is the warning
|
||||
// that stops an operator orphaning the words their balance was minted
|
||||
// under. Behind "check server logs" the first is unactionable and the
|
||||
// second is invisible.
|
||||
"That is not a valid BIP-39",
|
||||
"This wallet already has a backup phrase",
|
||||
"This wallet has no backup phrase yet",
|
||||
// Restore against a mint that never implemented NUT-09.
|
||||
"This mint does not support restoring",
|
||||
];
|
||||
for prefix in &user_facing_prefixes {
|
||||
if msg.starts_with(prefix) {
|
||||
@@ -195,6 +209,27 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
mod sanitize_tests {
|
||||
use super::sanitize_error_message;
|
||||
|
||||
/// The ecash import errors are the feature's safety rails. If the
|
||||
/// sanitizer eats them, a bad paste gives no hint and — worse — the
|
||||
/// warning about replacing an established phrase never reaches the person
|
||||
/// about to do it.
|
||||
#[test]
|
||||
fn ecash_backup_phrase_errors_reach_the_operator() {
|
||||
for msg in [
|
||||
"That is not a valid BIP-39 recovery phrase: invalid checksum. Check for typos",
|
||||
"This wallet already has a backup phrase. Importing a different one means coins \
|
||||
minted under the current phrase will no longer be restorable from words",
|
||||
"This wallet has no backup phrase yet, so there is nothing to restore from.",
|
||||
"This mint does not support restoring from a backup phrase (NUT-09).",
|
||||
] {
|
||||
let out = sanitize_error_message(msg);
|
||||
assert_ne!(
|
||||
out, "Operation failed. Check server logs for details.",
|
||||
"swallowed: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_required_sentinel_passes_through_verbatim() {
|
||||
// The UI machine-reads this sentinel (isPasswordRequired checks
|
||||
|
||||
@@ -43,8 +43,12 @@ impl RpcHandler {
|
||||
// plus a blocking SSH verify per candidate. Inline, one click of
|
||||
// "scan for routers" held a tokio worker for that whole time.
|
||||
let routers = tokio::task::spawn_blocking(move || {
|
||||
tokio::runtime::Handle::current()
|
||||
.block_on(detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password))
|
||||
tokio::runtime::Handle::current().block_on(detect::scan_subnet(
|
||||
subnet,
|
||||
prefix,
|
||||
&ssh_user,
|
||||
&ssh_password,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.context("openwrt scan task")?;
|
||||
|
||||
@@ -365,8 +365,18 @@ impl RpcHandler {
|
||||
// after uninstall. The reconciler owns a manifest map independent of
|
||||
// podman state, so a raw `podman rm` alone is not enough.
|
||||
if let Some(orchestrator) = &self.orchestrator {
|
||||
let mut teardown_errors = Vec::new();
|
||||
for app_id in orchestrator_uninstall_app_ids(package_id) {
|
||||
let _ = orchestrator.remove(&app_id, preserve_data).await;
|
||||
if let Err(err) = orchestrator.remove(&app_id, preserve_data).await {
|
||||
teardown_errors.push(format!("{app_id}: {err:#}"));
|
||||
}
|
||||
}
|
||||
if !teardown_errors.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Uninstall {} aborted: failed to remove declarative app unit(s): {}",
|
||||
package_id,
|
||||
teardown_errors.join("; ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1405,6 +1415,14 @@ async fn repair_before_package_start(container_name: &str) {
|
||||
"nginx-proxy-manager" => repair_nginx_proxy_manager_container().await,
|
||||
_ => {}
|
||||
}
|
||||
// Reap this app's ghost containers before anything tries to start it.
|
||||
// A ghost (process tree alive, podman record gone) still owns the
|
||||
// published port and the data-dir file locks, so the replacement either
|
||||
// fails to bind (`address already in use`) or starts and dies on the
|
||||
// lock — and `Restart=always` loops it there forever. Ordered before
|
||||
// the port cleanup below: killing the owner is what actually frees the
|
||||
// port, and the port sweep alone cannot tell a ghost from a live app.
|
||||
crate::container::ghost_reaper::reap_for_app(container_name).await;
|
||||
cleanup_runtime_host_ports(container_name).await;
|
||||
}
|
||||
|
||||
@@ -2174,6 +2192,11 @@ mod tests {
|
||||
assert!(!is_missing_container_error("Error: OCI runtime error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_app_uninstall_targets_its_declarative_unit() {
|
||||
assert_eq!(orchestrator_uninstall_app_ids("cuprate"), vec!["cuprate"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_host_ports_are_manifest_derived_for_public_apps() {
|
||||
assert_eq!(runtime_host_ports("photoprism"), vec![2342]);
|
||||
|
||||
@@ -52,6 +52,18 @@ pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
|
||||
.parse()
|
||||
.context("Invalid mnemonic in memory")?;
|
||||
crate::seed::save_seed_encrypted(data_dir, &mnemonic, passphrase).await?;
|
||||
|
||||
// Establish the ecash wallet's NUT-13 phrase here too — this is the last
|
||||
// moment the master seed exists in plaintext during onboarding, and the
|
||||
// ecash wallet needs its own phrase on disk to mint restorable proofs
|
||||
// without a password prompt on every background swap. Best-effort: a node
|
||||
// that fails here still onboards, mints valid coins, and can establish the
|
||||
// phrase later from Settings → Back up ecash.
|
||||
let master = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
|
||||
if let Err(e) = crate::wallet::nut13::establish_from_master(data_dir, &master).await {
|
||||
tracing::warn!("Could not establish the ecash wallet phrase at onboarding: {e:#}");
|
||||
}
|
||||
|
||||
*state = None;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,51 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ecash-network` — which ecash network this node is on, and the
|
||||
/// balance sitting in the *other* one so the UI can say what switching
|
||||
/// would reveal rather than appearing to lose money.
|
||||
pub(super) async fn handle_wallet_ecash_network(&self) -> Result<serde_json::Value> {
|
||||
let current = ecash::load_network(&self.config.data_dir).await;
|
||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
"network": current,
|
||||
"is_test": current.is_test(),
|
||||
"mint_url": wallet.mint_url,
|
||||
"balance_sats": wallet.balance(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ecash-set-network` — switch between real and test ecash.
|
||||
///
|
||||
/// Each network keeps its own wallet file, so this never moves, merges or
|
||||
/// deletes coins: switching away parks the current balance and switching
|
||||
/// back finds it exactly as it was.
|
||||
pub(super) async fn handle_wallet_ecash_set_network(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let requested = params
|
||||
.get("network")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing network ('mainnet' or 'testnet')"))?;
|
||||
let network = match requested {
|
||||
"mainnet" => ecash::EcashNetwork::Mainnet,
|
||||
"testnet" => ecash::EcashNetwork::Testnet,
|
||||
other => anyhow::bail!("Unknown ecash network '{other}' — use 'mainnet' or 'testnet'"),
|
||||
};
|
||||
|
||||
ecash::save_network(&self.config.data_dir, network).await?;
|
||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||
tracing::info!(?network, "ecash network switched by operator");
|
||||
Ok(serde_json::json!({
|
||||
"network": network,
|
||||
"is_test": network.is_test(),
|
||||
"mint_url": wallet.mint_url,
|
||||
"balance_sats": wallet.balance(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_ecash_mint(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -161,10 +206,17 @@ impl RpcHandler {
|
||||
// (redeemed at the mint) or Fedimint notes (reissued via the fmcd
|
||||
// sidecar). Detect by prefix and route accordingly.
|
||||
if is_cashu_token(token) {
|
||||
// Which mint issued it, for the success screen. Ecash leaves no
|
||||
// public trace once redeemed, so the mint URL is the only thing a
|
||||
// person can quote later if the payment is ever disputed.
|
||||
let mint_url = crate::wallet::cashu::CashuToken::deserialize(token)
|
||||
.ok()
|
||||
.and_then(|t| t.mint_urls().first().map(|m| m.to_string()));
|
||||
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
|
||||
return Ok(serde_json::json!({
|
||||
"received_sats": amount,
|
||||
"kind": "cashu",
|
||||
"mint_url": mint_url,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -194,6 +246,181 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ecash-seed-status` — whether this wallet has a NUT-13 phrase
|
||||
/// yet, and therefore whether its coins can be restored at all.
|
||||
///
|
||||
/// Deliberately says nothing secret. `active: false` is the honest answer
|
||||
/// for a node that predates NUT-13: its existing proofs live in exactly one
|
||||
/// file and nothing can bring them back, which the UI needs to be able to
|
||||
/// say plainly rather than implying a backup exists.
|
||||
pub(super) async fn handle_wallet_ecash_seed_status(&self) -> Result<serde_json::Value> {
|
||||
let data_dir = &self.config.data_dir;
|
||||
let active = crate::wallet::nut13::seed_exists(data_dir);
|
||||
let source = match crate::wallet::nut13::load_seed(data_dir).await {
|
||||
Ok(Some(seed)) => Some(seed.source()),
|
||||
_ => None,
|
||||
};
|
||||
// A phrase can always be established. Whether the node has an
|
||||
// encrypted master seed decides only *which kind*: derived from it
|
||||
// (the node's 24 words already cover the ecash), or independent (the
|
||||
// phrase is the only copy). The UI needs both facts to set the right
|
||||
// expectation before the operator commits to writing something down.
|
||||
Ok(serde_json::json!({
|
||||
"active": active,
|
||||
"source": source,
|
||||
"can_activate": true,
|
||||
"derivable_from_node_seed": crate::seed::seed_exists(data_dir),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ecash-seed-reveal` — show the ecash wallet's 24 words, and
|
||||
/// establish them from the node's master seed if this is the first time.
|
||||
///
|
||||
/// Gated exactly like `seed.reveal` and `lnd.seed-reveal`: authenticated
|
||||
/// session, password re-verification, TOTP when enabled. The words are
|
||||
/// returned to the caller only and never logged.
|
||||
///
|
||||
/// Reveal doubles as activation because the master seed is encrypted at
|
||||
/// rest: this password prompt is the only moment the node can legitimately
|
||||
/// open it, so it is also the only moment the ecash phrase can be derived
|
||||
/// from it. A node that has never been here mints valid but unrecoverable
|
||||
/// proofs; one visit fixes that for every proof minted afterwards.
|
||||
pub(super) async fn handle_wallet_ecash_seed_reveal(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
use zeroize::Zeroize;
|
||||
|
||||
let params = params.unwrap_or_default();
|
||||
let data_dir = &self.config.data_dir;
|
||||
|
||||
let mut password = self.verify_reveal_auth(¶ms, "the ecash seed").await?;
|
||||
|
||||
// Already established: just open it. No master seed needed, so this
|
||||
// still works on a node whose backup passphrase has been forgotten.
|
||||
if let Some(seed) = crate::wallet::nut13::load_seed(data_dir).await? {
|
||||
password.zeroize();
|
||||
let words = seed.words();
|
||||
return Ok(serde_json::json!({
|
||||
"words": words,
|
||||
"word_count": words.len(),
|
||||
"source": seed.source(),
|
||||
"newly_activated": false,
|
||||
}));
|
||||
}
|
||||
|
||||
// No encrypted master seed to derive from — common on nodes onboarded
|
||||
// before that step existed. The choice here is not "derived or
|
||||
// independent", it is "independent or no backup at all", so we make
|
||||
// one and label it honestly. Every surface that shows an
|
||||
// `independent` phrase says the node's own recovery phrase does not
|
||||
// cover it.
|
||||
if !crate::seed::seed_exists(data_dir) {
|
||||
password.zeroize();
|
||||
let seed = crate::wallet::nut13::establish_independent(data_dir).await?;
|
||||
let words = seed.words();
|
||||
return Ok(serde_json::json!({
|
||||
"words": words,
|
||||
"word_count": words.len(),
|
||||
"source": seed.source(),
|
||||
"newly_activated": true,
|
||||
}));
|
||||
}
|
||||
|
||||
// The backup passphrase may differ from the login password — same
|
||||
// fallback `seed.reveal` uses.
|
||||
let passphrase = params
|
||||
.get("passphrase")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| password.clone());
|
||||
let master = crate::seed::load_seed_encrypted(data_dir, &passphrase).await;
|
||||
password.zeroize();
|
||||
let mnemonic = master.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Could not decrypt the saved seed. If you set a separate backup \
|
||||
passphrase during setup, enter that passphrase."
|
||||
)
|
||||
})?;
|
||||
let master = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
|
||||
let seed = crate::wallet::nut13::establish_from_master(data_dir, &master).await?;
|
||||
|
||||
let words = seed.words();
|
||||
Ok(serde_json::json!({
|
||||
"words": words,
|
||||
"word_count": words.len(),
|
||||
"source": seed.source(),
|
||||
"newly_activated": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ecash-seed-import` — adopt a phrase from another NUT-13 wallet.
|
||||
///
|
||||
/// Gated like every other route that touches key material. Replacing an
|
||||
/// established phrase additionally needs `confirm: true`, because coins
|
||||
/// minted under the old one stop being restorable from words — they stay
|
||||
/// spendable, but a restore will not find them. The old phrase is archived
|
||||
/// beside the wallet rather than overwritten.
|
||||
pub(super) async fn handle_wallet_ecash_seed_import(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
use zeroize::Zeroize;
|
||||
|
||||
let params = params.unwrap_or_default();
|
||||
let words = params
|
||||
.get("words")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("A recovery phrase is required"))?
|
||||
.to_string();
|
||||
let confirm = params
|
||||
.get("confirm")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut password = self.verify_reveal_auth(¶ms, "the ecash seed").await?;
|
||||
password.zeroize();
|
||||
|
||||
let seed =
|
||||
crate::wallet::nut13::import_mnemonic(&self.config.data_dir, &words, confirm).await?;
|
||||
Ok(serde_json::json!({
|
||||
"source": seed.source(),
|
||||
"word_count": seed.words().len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ecash-restore` — rebuild the wallet's coins from its NUT-13
|
||||
/// phrase by asking a mint which re-derived secrets it has signed.
|
||||
///
|
||||
/// Defaults to the wallet's own mint; `mint_url` targets another one, for
|
||||
/// a wallet whose coins were spread across mints.
|
||||
pub(super) async fn handle_wallet_ecash_restore(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let mint_url = match params.get("mint_url").and_then(|v| v.as_str()) {
|
||||
Some(url) if !url.trim().is_empty() => url.trim().to_string(),
|
||||
_ => {
|
||||
crate::wallet::ecash::load_wallet(&self.config.data_dir)
|
||||
.await?
|
||||
.mint_url
|
||||
}
|
||||
};
|
||||
|
||||
let outcome =
|
||||
crate::wallet::ecash::restore_from_seed(&self.config.data_dir, &mint_url).await?;
|
||||
Ok(serde_json::json!({
|
||||
"mint_url": mint_url,
|
||||
"recovered_sats": outcome.recovered_sats,
|
||||
"recovered_proofs": outcome.recovered_proofs,
|
||||
"already_spent": outcome.already_spent,
|
||||
"keysets_scanned": outcome.keysets_scanned,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
|
||||
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
|
||||
@@ -40,6 +40,15 @@ pub struct GatedPort {
|
||||
/// companion UIs proxy that cookie to the daemon's authenticated
|
||||
/// endpoints; for every other app the gate strips its own credential.
|
||||
pub session_passthrough: bool,
|
||||
/// Does the gate challenge for the dashboard login on this port?
|
||||
///
|
||||
/// Default comes from the manifest (`auth: gated`/undeclared → true,
|
||||
/// `auth: open` → false); the operator's runtime override
|
||||
/// (`app_gate_config`, Settings → app → App gate) wins over both.
|
||||
/// False does NOT release the port — the gate keeps binding and
|
||||
/// proxying (frame-header fixes, app-down page, Tor upstream); it just
|
||||
/// forwards every request to the app's own authentication.
|
||||
pub auth_enabled: bool,
|
||||
}
|
||||
|
||||
/// A port deliberately left unauthenticated, and the manifest's stated reason.
|
||||
@@ -187,6 +196,18 @@ pub fn build_port_map() -> PortMap {
|
||||
}
|
||||
}
|
||||
|
||||
// The operator's runtime override wins over the manifest default, in
|
||||
// both directions: un-gate an app that fronts its own login, or force
|
||||
// the challenge back onto an `auth: open` port. Overrides only toggle
|
||||
// the challenge on gate-fronted ports — they never bind or release
|
||||
// anything, so a stale override cannot expose or strand a port.
|
||||
let overrides = crate::container::app_gate_config::all_gate_overrides();
|
||||
for gp in map.gated.values_mut() {
|
||||
if let Some(enabled) = overrides.get(&gp.app_id) {
|
||||
gp.auth_enabled = *enabled;
|
||||
}
|
||||
}
|
||||
|
||||
map.exempt.sort_by_key(|e| e.port);
|
||||
map
|
||||
}
|
||||
@@ -229,7 +250,10 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
|
||||
// Explicit opt-in: the app is on loopback and the daemon
|
||||
// owns the external addresses. This is the ONLY way a
|
||||
// port gets bound by the gate, regardless of `bind`.
|
||||
PortAuth::Gated => {
|
||||
// `open` is the same takeover with the login challenge
|
||||
// defaulted off — the app fronts its own authentication
|
||||
// (rationale-required, see PortAuth::Open).
|
||||
PortAuth::Gated | PortAuth::Open => {
|
||||
map.gated.insert(
|
||||
port.host,
|
||||
GatedPort {
|
||||
@@ -239,6 +263,7 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
|
||||
icon: icon.clone(),
|
||||
declared: true,
|
||||
session_passthrough: port.session_passthrough,
|
||||
auth_enabled: port.auth_policy() == PortAuth::Gated,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -289,6 +314,10 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
|
||||
// An undeclared port never gets the node session —
|
||||
// passthrough is an explicit manifest opt-in only.
|
||||
session_passthrough: false,
|
||||
// Undeclared ports are challenged wherever the gate
|
||||
// can stand: reporting-and-protecting is the safe
|
||||
// default (operator override still applies below).
|
||||
auth_enabled: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,6 +139,14 @@ impl AppGate {
|
||||
app: &GatedPort,
|
||||
client_ip: IpAddr,
|
||||
) -> Response<Body> {
|
||||
// The accept loop captured its GatedPort at bind time; per-request
|
||||
// policy (the operator's gate on/off toggle, session_passthrough)
|
||||
// must come from the live map or a Settings change would only apply
|
||||
// to ports (re)bound after the next sweep. Falls back to the bound
|
||||
// snapshot when the port momentarily leaves the map mid-refresh.
|
||||
let live = self.port_map.read().await.gated(app.port).cloned();
|
||||
let app = live.as_ref().unwrap_or(app);
|
||||
|
||||
let path = req.uri().path().to_string();
|
||||
|
||||
if let Some(action) = path.strip_prefix(GATE_PREFIX) {
|
||||
@@ -169,6 +177,17 @@ impl AppGate {
|
||||
return proxy_to_app(req, app, true).await;
|
||||
}
|
||||
|
||||
// Gate challenge disabled for this app (manifest `auth: open`, or
|
||||
// the operator's Settings toggle): forward everything to the app's
|
||||
// own authentication. The Authorization header passes through
|
||||
// untouched — git clients speak basic-auth to Gitea, API clients
|
||||
// carry the app's own tokens. Gate cookies are still stripped in
|
||||
// proxy_to_app (an ungated app must never see the node session),
|
||||
// and the frame fixes / app-down page still apply.
|
||||
if !app.auth_enabled {
|
||||
return proxy_to_app(req, app, false).await;
|
||||
}
|
||||
|
||||
match self.authorize(req.headers(), &app.app_id).await {
|
||||
// The credential was a cookie (or none was needed): the
|
||||
// Authorization header, if any, belongs to the app. Forward it.
|
||||
@@ -1164,6 +1183,7 @@ mod tests {
|
||||
icon: None,
|
||||
declared: true,
|
||||
session_passthrough: false,
|
||||
auth_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -184,14 +184,17 @@ pub async fn ensure_doctor_installed() {
|
||||
Err(e) => warn!("nginx listener repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_ha_rpc_proxy_bind_repair().await {
|
||||
Ok(true) => info!(
|
||||
"HA bitcoind RPC forwarder rebound dynamically — survives network moves now"
|
||||
),
|
||||
Ok(true) => {
|
||||
info!("HA bitcoind RPC forwarder rebound dynamically — survives network moves now")
|
||||
}
|
||||
Ok(false) => debug!("HA bitcoind RPC forwarder absent or already dynamic"),
|
||||
Err(e) => warn!("HA RPC forwarder bind repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_pull_never_image_repair().await {
|
||||
Ok(n) if n > 0 => info!(retagged = n, "Healed quadlet image refs orphaned by registry rename"),
|
||||
Ok(n) if n > 0 => info!(
|
||||
retagged = n,
|
||||
"Healed quadlet image refs orphaned by registry rename"
|
||||
),
|
||||
Ok(_) => debug!("All quadlet image refs resolve locally"),
|
||||
Err(e) => warn!("Quadlet image ref repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
@@ -704,7 +707,12 @@ fn parse_socat_static_bind(exec_line: &str) -> Option<(String, String)> {
|
||||
}
|
||||
// Only rewrite units pinned to a concrete address; a unit already using
|
||||
// a computed bind (or none) needs no heal.
|
||||
let bind = after_listen.split("bind=").nth(1)?.split(',').next()?.trim();
|
||||
let bind = after_listen
|
||||
.split("bind=")
|
||||
.nth(1)?
|
||||
.split(',')
|
||||
.next()?
|
||||
.trim();
|
||||
if !bind.chars().all(|c| c.is_ascii_digit() || c == '.') || bind.starts_with("127.") {
|
||||
return None;
|
||||
}
|
||||
@@ -731,7 +739,10 @@ async fn run_ha_rpc_proxy_bind_repair() -> Result<bool> {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Ok(false), // node never grew the forwarder
|
||||
};
|
||||
let Some(exec_line) = unit.lines().find(|l| l.trim_start().starts_with("ExecStart=")) else {
|
||||
let Some(exec_line) = unit
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("ExecStart="))
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some((port, target)) = parse_socat_static_bind(exec_line) else {
|
||||
@@ -833,7 +844,11 @@ async fn run_pull_never_image_repair() -> Result<usize> {
|
||||
}
|
||||
|
||||
async fn podman_stdout(args: &[&str]) -> String {
|
||||
match tokio::process::Command::new("podman").args(args).output().await {
|
||||
match tokio::process::Command::new("podman")
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
_ => String::new(),
|
||||
}
|
||||
@@ -859,7 +874,8 @@ const NGINX_SITES: [&str; 2] = [
|
||||
"/etc/nginx/sites-available/archipelago-http",
|
||||
"/etc/nginx/sites-available/archipelago",
|
||||
];
|
||||
const NGINX_RESTART_DROPIN: &str = "/etc/systemd/system/nginx.service.d/10-archipelago-restart.conf";
|
||||
const NGINX_RESTART_DROPIN: &str =
|
||||
"/etc/systemd/system/nginx.service.d/10-archipelago-restart.conf";
|
||||
|
||||
/// Global IPv4 addresses on this host, minus Tailscale CGNAT (100.64/10) —
|
||||
/// the same exclusion `setup-node-ca.sh` applies, for the same reason.
|
||||
@@ -964,7 +980,10 @@ async fn run_nginx_listener_repair() -> Result<bool> {
|
||||
let status = host_sudo(&["sh", "-lc", &script]).await?;
|
||||
match status.code() {
|
||||
Some(0) => changed = true,
|
||||
Some(3) => warn!(site, "nginx listener repair failed its config test — rolled back"),
|
||||
Some(3) => warn!(
|
||||
site,
|
||||
"nginx listener repair failed its config test — rolled back"
|
||||
),
|
||||
_ => warn!(site, "nginx listener repair helper failed"),
|
||||
}
|
||||
}
|
||||
@@ -1826,7 +1845,10 @@ mod tests {
|
||||
let healed = retarget_https_listeners(cfg, &present).expect("must heal");
|
||||
assert!(healed.contains("listen 192.168.1.50:443 ssl;"));
|
||||
assert!(healed.contains("listen 10.44.0.1:443 ssl;"));
|
||||
assert!(!healed.contains("192.168.63.240"), "stale listener must be dropped");
|
||||
assert!(
|
||||
!healed.contains("192.168.63.240"),
|
||||
"stale listener must be dropped"
|
||||
);
|
||||
// Untouched lines survive, and the repair is idempotent.
|
||||
assert!(healed.contains("listen 80 default_server;"));
|
||||
assert!(healed.contains("ssl_certificate /x;"));
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Per-app operator override for the app gate's login requirement.
|
||||
//!
|
||||
//! The manifest declares each port's *default* policy (`auth: gated` = the
|
||||
//! gate challenges, the new `auth: open` = the gate fronts the port but does
|
||||
//! not challenge). This store holds the operator's runtime override — set
|
||||
//! from Settings → app details — so a node owner can un-gate an app that
|
||||
//! carries its own login (Gitea, BTCPay) or force the gate back onto an
|
||||
//! `open` app, without editing manifests or waiting for a catalog re-sign.
|
||||
//!
|
||||
//! Lives in the same merge-preserving per-app JSON files as the version
|
||||
//! preferences (`/var/lib/archipelago/app-configs/<app_id>.json`, key
|
||||
//! `"gateEnabled"`). Absent key = follow the manifest default.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
fn config_dir() -> PathBuf {
|
||||
let base = std::env::var("ARCHIPELAGO_DATA_DIR")
|
||||
.unwrap_or_else(|_| "/var/lib/archipelago".to_string());
|
||||
PathBuf::from(base).join("app-configs")
|
||||
}
|
||||
|
||||
fn config_path(app_id: &str) -> PathBuf {
|
||||
config_dir().join(format!("{app_id}.json"))
|
||||
}
|
||||
|
||||
fn read_raw(app_id: &str) -> Map<String, Value> {
|
||||
match std::fs::read_to_string(config_path(app_id)) {
|
||||
Ok(s) => serde_json::from_str::<Value>(&s)
|
||||
.ok()
|
||||
.and_then(|v| v.as_object().cloned())
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The operator's gate override for one app. `None` = no override recorded —
|
||||
/// the manifest default applies.
|
||||
pub fn gate_override(app_id: &str) -> Option<bool> {
|
||||
read_raw(app_id).get("gateEnabled").and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
/// Every recorded override, keyed by app id (the config file stem). Used by
|
||||
/// the gate's port-map build so one directory scan covers all apps.
|
||||
pub fn all_gate_overrides() -> HashMap<String, bool> {
|
||||
let mut out = HashMap::new();
|
||||
let Ok(entries) = std::fs::read_dir(config_dir()) else {
|
||||
return out;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Some(app_id) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(v) = gate_override(app_id) {
|
||||
out.insert(app_id.to_string(), v);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Set (`Some`) or clear (`None`) the override, preserving every other key in
|
||||
/// the app's config file. Temp+rename so a crash mid-write can't truncate.
|
||||
pub fn write_gate_override(app_id: &str, enabled: Option<bool>) -> std::io::Result<()> {
|
||||
let path = config_path(app_id);
|
||||
let mut obj = read_raw(app_id);
|
||||
match enabled {
|
||||
Some(v) => {
|
||||
obj.insert("gateEnabled".to_string(), Value::Bool(v));
|
||||
}
|
||||
None => {
|
||||
obj.remove("gateEnabled");
|
||||
}
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let serialized = serde_json::to_string_pretty(&Value::Object(obj))
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, serialized.as_bytes())?;
|
||||
std::fs::rename(&tmp, &path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn with_tmp_data_dir<T>(f: impl FnOnce() -> T) -> T {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
// Serialize env mutation across tests in this module.
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
std::env::set_var("ARCHIPELAGO_DATA_DIR", dir.path());
|
||||
let out = f();
|
||||
std::env::remove_var("ARCHIPELAGO_DATA_DIR");
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_file_means_no_override() {
|
||||
with_tmp_data_dir(|| {
|
||||
assert_eq!(gate_override("gitea"), None);
|
||||
assert!(all_gate_overrides().is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_read_clear_roundtrip_preserves_other_keys() {
|
||||
with_tmp_data_dir(|| {
|
||||
// Seed an existing config with an unrelated key.
|
||||
std::fs::create_dir_all(config_dir()).unwrap();
|
||||
std::fs::write(config_path("gitea"), r#"{"autoUpdate": true}"#).unwrap();
|
||||
|
||||
write_gate_override("gitea", Some(false)).unwrap();
|
||||
assert_eq!(gate_override("gitea"), Some(false));
|
||||
assert_eq!(all_gate_overrides().get("gitea"), Some(&false));
|
||||
|
||||
// The unrelated key survives.
|
||||
let raw = std::fs::read_to_string(config_path("gitea")).unwrap();
|
||||
let v: Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(v.get("autoUpdate"), Some(&Value::Bool(true)));
|
||||
|
||||
write_gate_override("gitea", None).unwrap();
|
||||
assert_eq!(gate_override("gitea"), None);
|
||||
let raw = std::fs::read_to_string(config_path("gitea")).unwrap();
|
||||
let v: Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(v.get("autoUpdate"), Some(&Value::Bool(true)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,12 @@ impl BootReconciler {
|
||||
}
|
||||
|
||||
async fn tick(&self) {
|
||||
// Sweep ghost containers first: a process tree podman has forgotten
|
||||
// still holds its app's ports and data locks, so reconcile would keep
|
||||
// restarting that app into the same wall (752 restarts on a fleet
|
||||
// node, 2026-08-10). Nothing else in the stack can see them —
|
||||
// every podman-level stop/rm misses a container podman lost.
|
||||
crate::container::ghost_reaper::reap_all().await;
|
||||
let report = self.orchestrator.reconcile_existing().await;
|
||||
Self::log_report(&report);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Ghost-container reaper.
|
||||
//!
|
||||
//! A *ghost* is a container whose process tree (conmon → the app's init → the
|
||||
//! app) is still running while podman has no record of it — `podman ps -a`
|
||||
//! does not list it, so every podman-level stop/rm/recreate misses it. They
|
||||
//! are produced by a cleanup race: the exit-command runs `container cleanup
|
||||
//! --rm`, the record is deleted, but conmon and the payload survive.
|
||||
//!
|
||||
//! A ghost is not merely untidy — it still owns the things the app needs:
|
||||
//!
|
||||
//! * the published host port, so the replacement container fails to start with
|
||||
//! `rootlessport listen tcp 127.0.0.1:<port>: bind: address already in use`;
|
||||
//! * file locks inside the app's data dir, so a container that does start dies
|
||||
//! at boot (Gitea: `unable to lock level db … resource temporarily
|
||||
//! unavailable` → fatal).
|
||||
//!
|
||||
//! `Restart=always` then re-runs the app straight back into the same wall —
|
||||
//! observed at 752 restarts on a fleet node (2026-08-10) and again on the dev
|
||||
//! box (2026-08-16), where the app finally disappeared from My Apps because no
|
||||
//! container existed to list. Both were cleared by hand; this module is the
|
||||
//! automation, because no podman-level release logic can reap a container
|
||||
//! podman does not know about.
|
||||
//!
|
||||
//! Safety rule, and the reason this is id-based rather than name-based: a
|
||||
//! process is only ever a reap candidate when its container id is **absent**
|
||||
//! from `podman ps -a --no-trunc -q`. Killing by container *name* would hit
|
||||
//! the live managed container, which is the opposite of the fix.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// A container process tree podman has no record of.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Ghost {
|
||||
/// conmon's pid — killed last, so it cannot re-parent the payload.
|
||||
pub conmon_pid: i32,
|
||||
/// Full 64-hex container id from conmon's `-c` argument.
|
||||
pub container_id: String,
|
||||
/// Container name from conmon's `-n` argument, when present. This is what
|
||||
/// ties a ghost to an app id for the pre-start reap.
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// Read a process's argv from /proc, NUL-separated.
|
||||
fn proc_argv(pid: i32) -> Option<Vec<String>> {
|
||||
let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
|
||||
Some(
|
||||
raw.split(|b| *b == 0)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| String::from_utf8_lossy(s).into_owned())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Every pid currently in /proc.
|
||||
fn all_pids() -> Vec<i32> {
|
||||
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter_map(|e| e.file_name().to_str().and_then(|s| s.parse::<i32>().ok()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Container ids podman currently knows about (running or stopped).
|
||||
async fn podman_known_ids() -> Option<HashSet<String>> {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--no-trunc", "-q"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
// A failed listing must NEVER be read as "podman knows nothing" —
|
||||
// that would make every running container look like a ghost and reap
|
||||
// the whole node. Absent knowledge = do nothing.
|
||||
warn!("ghost reaper: `podman ps` failed; skipping this pass");
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse a conmon argv into (container_id, name), if it is a conmon at all.
|
||||
fn parse_conmon(argv: &[String]) -> Option<(String, Option<String>)> {
|
||||
let exe = argv.first()?;
|
||||
if !exe.ends_with("conmon") {
|
||||
return None;
|
||||
}
|
||||
let mut id = None;
|
||||
let mut name = None;
|
||||
let mut it = argv.iter().peekable();
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"-c" => {
|
||||
if let Some(v) = it.peek() {
|
||||
// Only a full 64-hex id counts; anything else is not a
|
||||
// container id and must not drive a kill decision.
|
||||
if v.len() == 64 && v.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
id = Some((*v).clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
"-n" => name = it.peek().map(|v| (*v).clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some((id?, name))
|
||||
}
|
||||
|
||||
/// All ghost process trees on this host. Empty when podman cannot be listed
|
||||
/// (fail-closed: unknown state reaps nothing).
|
||||
pub async fn find_ghosts() -> Vec<Ghost> {
|
||||
let Some(known) = podman_known_ids().await else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut ghosts = Vec::new();
|
||||
for pid in all_pids() {
|
||||
let Some(argv) = proc_argv(pid) else { continue };
|
||||
let Some((container_id, name)) = parse_conmon(&argv) else {
|
||||
continue;
|
||||
};
|
||||
if known.contains(&container_id) {
|
||||
continue;
|
||||
}
|
||||
ghosts.push(Ghost {
|
||||
conmon_pid: pid,
|
||||
container_id,
|
||||
name,
|
||||
});
|
||||
}
|
||||
ghosts
|
||||
}
|
||||
|
||||
fn signal(pid: i32, sig: i32) {
|
||||
// SAFETY: kill(2) with a pid we read from /proc; a dead pid returns ESRCH,
|
||||
// which we ignore. Signals are the only way to reach a process podman has
|
||||
// disowned.
|
||||
unsafe {
|
||||
libc::kill(pid, sig);
|
||||
}
|
||||
}
|
||||
|
||||
fn alive(pid: i32) -> bool {
|
||||
std::path::Path::new(&format!("/proc/{pid}")).exists()
|
||||
}
|
||||
|
||||
/// Kill one ghost's process tree: the payload's process group first (so the
|
||||
/// app's own init can shut its children down), then conmon.
|
||||
///
|
||||
/// SIGTERM first with a short grace, then SIGKILL — a ghost has already
|
||||
/// out-lived its supervisor, and the Gitea case ignored SIGTERM outright.
|
||||
async fn kill_ghost(ghost: &Ghost) {
|
||||
// Children of conmon = the container's init (s6, tini, the app itself).
|
||||
let children: Vec<i32> = all_pids()
|
||||
.into_iter()
|
||||
.filter(|pid| {
|
||||
std::fs::read_to_string(format!("/proc/{pid}/stat"))
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
// ppid is field 4, after the comm field which may itself
|
||||
// contain spaces/parens — split on the last ')'.
|
||||
let tail = s.rsplit_once(')')?.1;
|
||||
tail.split_whitespace().nth(1)?.parse::<i32>().ok()
|
||||
})
|
||||
.is_some_and(|ppid| ppid == ghost.conmon_pid)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for pid in children.iter().copied() {
|
||||
signal(pid, libc::SIGTERM);
|
||||
}
|
||||
signal(ghost.conmon_pid, libc::SIGTERM);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
for pid in children.iter().copied() {
|
||||
if alive(pid) {
|
||||
signal(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
if alive(ghost.conmon_pid) {
|
||||
signal(ghost.conmon_pid, libc::SIGKILL);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let survivors = children.iter().filter(|p| alive(**p)).count();
|
||||
if survivors > 0 || alive(ghost.conmon_pid) {
|
||||
warn!(
|
||||
container_id = %&ghost.container_id[..12],
|
||||
name = ?ghost.name,
|
||||
survivors,
|
||||
"ghost reaper: some processes survived SIGKILL"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reap every ghost on the host. Returns how many trees were killed.
|
||||
///
|
||||
/// Call before a start/restart (so the replacement is not racing a dead
|
||||
/// twin for its port and locks) and from the periodic reconcile.
|
||||
pub async fn reap_all() -> usize {
|
||||
reap_matching(|_| true).await
|
||||
}
|
||||
|
||||
/// Reap only ghosts belonging to `app_id` — matched on the container name,
|
||||
/// which the orchestrator sets to the app id (companions carry it as a
|
||||
/// prefix, e.g. `archy-btcpay-db`).
|
||||
pub async fn reap_for_app(app_id: &str) -> usize {
|
||||
let app_id = app_id.to_string();
|
||||
reap_matching(move |g| {
|
||||
g.name.as_deref().is_some_and(|n| {
|
||||
n == app_id
|
||||
|| n.starts_with(&format!("{app_id}-"))
|
||||
|| n.ends_with(&format!("-{app_id}"))
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn reap_matching(pred: impl Fn(&Ghost) -> bool) -> usize {
|
||||
let ghosts: Vec<Ghost> = find_ghosts()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|g| pred(g))
|
||||
.collect();
|
||||
if ghosts.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
for ghost in &ghosts {
|
||||
warn!(
|
||||
container_id = %&ghost.container_id[..12],
|
||||
name = ?ghost.name,
|
||||
conmon_pid = ghost.conmon_pid,
|
||||
"ghost container found — podman has no record of it but its processes still \
|
||||
hold the app's ports and data locks; reaping"
|
||||
);
|
||||
kill_ghost(ghost).await;
|
||||
}
|
||||
info!(
|
||||
count = ghosts.len(),
|
||||
"ghost reaper: reaped ghost containers"
|
||||
);
|
||||
ghosts.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn argv(parts: &[&str]) -> Vec<String> {
|
||||
parts.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
const ID: &str = "8ea2fc65603a6db8d48701e26da1a18f1651e5f8b0e2dd4ec931356f4fab0081";
|
||||
|
||||
#[test]
|
||||
fn parses_a_real_conmon_invocation() {
|
||||
let a = argv(&[
|
||||
"/usr/bin/conmon",
|
||||
"--api-version",
|
||||
"1",
|
||||
"-c",
|
||||
ID,
|
||||
"-u",
|
||||
ID,
|
||||
"-n",
|
||||
"gitea",
|
||||
"--full-attach",
|
||||
]);
|
||||
let (id, name) = parse_conmon(&a).expect("conmon parsed");
|
||||
assert_eq!(id, ID);
|
||||
assert_eq!(name.as_deref(), Some("gitea"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_processes_that_are_not_conmon() {
|
||||
assert!(parse_conmon(&argv(&["/usr/local/bin/gitea", "web"])).is_none());
|
||||
// A shell whose *arguments* mention conmon must never be parsed as one
|
||||
// — the grep-based detection used by hand did exactly this.
|
||||
assert!(parse_conmon(&argv(&["/bin/bash", "-c", "pgrep -af conmon"])).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_or_missing_id_is_not_reapable() {
|
||||
assert!(
|
||||
parse_conmon(&argv(&["/usr/bin/conmon", "-c", "8ea2fc65", "-n", "gitea"])).is_none()
|
||||
);
|
||||
assert!(parse_conmon(&argv(&["/usr/bin/conmon", "--api-version", "1"])).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_matching_covers_companions_but_not_unrelated_apps() {
|
||||
let g = |n: &str| Ghost {
|
||||
conmon_pid: 1,
|
||||
container_id: ID.to_string(),
|
||||
name: Some(n.to_string()),
|
||||
};
|
||||
let matches = |app: &str, name: &str| {
|
||||
let app = app.to_string();
|
||||
let gh = g(name);
|
||||
gh.name.as_deref().is_some_and(|n| {
|
||||
n == app || n.starts_with(&format!("{app}-")) || n.ends_with(&format!("-{app}"))
|
||||
})
|
||||
};
|
||||
assert!(matches("gitea", "gitea"));
|
||||
assert!(matches("btcpay-server", "btcpay-server"));
|
||||
assert!(matches("immich", "immich-postgres"));
|
||||
assert!(matches("btcpay", "archy-btcpay"));
|
||||
// Substring coincidences must not match.
|
||||
assert!(!matches("pay", "btcpay-server"));
|
||||
assert!(!matches("gitea", "gitea2"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod app_catalog;
|
||||
pub mod app_gate_config;
|
||||
pub mod bitcoin_ui;
|
||||
pub mod boot_reconciler;
|
||||
pub mod companion;
|
||||
@@ -6,6 +7,7 @@ pub mod data_manager;
|
||||
pub mod dev_orchestrator;
|
||||
pub mod docker_packages;
|
||||
pub mod filebrowser;
|
||||
pub mod ghost_reaper;
|
||||
pub mod hooks;
|
||||
pub mod image_policy;
|
||||
pub mod image_versions;
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
|
||||
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
|
||||
18081, 18083, 23000, 32838, 50002,
|
||||
8089, 8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380,
|
||||
11434, 18081, 18083, 23000, 32838, 50002,
|
||||
];
|
||||
|
||||
@@ -538,8 +538,12 @@ async fn same_serial_device(a: &str, b: &str) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
let ra = fs::canonicalize(a).await.unwrap_or_else(|_| PathBuf::from(a));
|
||||
let rb = fs::canonicalize(b).await.unwrap_or_else(|_| PathBuf::from(b));
|
||||
let ra = fs::canonicalize(a)
|
||||
.await
|
||||
.unwrap_or_else(|_| PathBuf::from(a));
|
||||
let rb = fs::canonicalize(b)
|
||||
.await
|
||||
.unwrap_or_else(|_| PathBuf::from(b));
|
||||
ra == rb
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/v1";
|
||||
const FIPS_KEY_INFO: &[u8] = b"archipelago/fips/secp256k1/v1";
|
||||
const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1";
|
||||
const RELEASE_ROOT_ED25519_INFO: &[u8] = b"archipelago/release/root/ed25519/v1";
|
||||
const CASHU_ENTROPY_INFO: &[u8] = b"archipelago/cashu/bip39-entropy/v1";
|
||||
|
||||
// ─── MasterSeed ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -300,6 +301,30 @@ pub fn derive_lnd_entropy(seed: &MasterSeed) -> Result<[u8; 16]> {
|
||||
Ok(entropy)
|
||||
}
|
||||
|
||||
/// Derive the ecash (Cashu, NUT-13) wallet's own 24-word BIP-39 mnemonic.
|
||||
///
|
||||
/// The ecash wallet gets a **separate mnemonic** rather than being handed the
|
||||
/// node's own 24 words, and both halves of that matter:
|
||||
///
|
||||
/// - it is still covered by the node's recovery phrase, because it is derived
|
||||
/// from the master seed over a fixed domain-separated path — restore the
|
||||
/// node from its words and the same ecash wallet comes back, with nothing
|
||||
/// extra for the operator to write down;
|
||||
/// - but it is *portable*. NUT-13 is a standard, so these words restore the
|
||||
/// ecash in Minibits, Nutstash or `cdk-cli`. Showing the node seed here
|
||||
/// would have made "back up my ecash" and "hand over the key to the entire
|
||||
/// node" the same action.
|
||||
///
|
||||
/// One-way by construction: HKDF cannot be run backwards, so a leaked ecash
|
||||
/// mnemonic does not expose the master seed or any other derived key.
|
||||
pub fn derive_cashu_mnemonic(seed: &MasterSeed) -> Result<bip39::Mnemonic> {
|
||||
let mut entropy = hkdf_derive_32(seed.as_bytes(), CASHU_ENTROPY_INFO)?;
|
||||
let mnemonic = bip39::Mnemonic::from_entropy(&entropy)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to derive the ecash mnemonic: {}", e));
|
||||
entropy.zeroize();
|
||||
mnemonic
|
||||
}
|
||||
|
||||
// ─── Encrypted Seed Storage ─────────────────────────────────────────────
|
||||
|
||||
/// Encrypt `plaintext` with Argon2(passphrase) + ChaCha20-Poly1305.
|
||||
@@ -657,6 +682,42 @@ mod tests {
|
||||
assert_eq!(e1.len(), 16);
|
||||
}
|
||||
|
||||
/// The ecash mnemonic must be reproducible from the node's words alone —
|
||||
/// that reproducibility is the entire backup story ("your 24 words already
|
||||
/// cover your ecash").
|
||||
#[test]
|
||||
fn cashu_mnemonic_is_reproducible_from_the_node_seed() {
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let a = derive_cashu_mnemonic(&seed).unwrap();
|
||||
let b = derive_cashu_mnemonic(&seed).unwrap();
|
||||
assert_eq!(a.to_string(), b.to_string());
|
||||
assert_eq!(a.word_count(), 24);
|
||||
|
||||
// A different node seed must yield a different ecash wallet, or two
|
||||
// nodes would derive each other's coins.
|
||||
let (other_words, _) = MasterSeed::generate().unwrap();
|
||||
let (_, other_seed) = MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
|
||||
assert_ne!(
|
||||
a.to_string(),
|
||||
derive_cashu_mnemonic(&other_seed).unwrap().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
/// It must NOT be the node's own phrase. Restoring ecash into a
|
||||
/// third-party wallet means handing these words over, and that must never
|
||||
/// be the same as handing over the node.
|
||||
#[test]
|
||||
fn cashu_mnemonic_is_not_the_node_mnemonic() {
|
||||
let (node_mnemonic, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let cashu = derive_cashu_mnemonic(&seed).unwrap();
|
||||
assert_ne!(cashu.to_string(), node_mnemonic.to_string());
|
||||
|
||||
// And knowing the ecash words must not re-derive the node seed: they
|
||||
// are a one-way HKDF descendant, so the seeds they expand to differ.
|
||||
let cashu_seed = MasterSeed::from_mnemonic(&cashu);
|
||||
assert_ne!(cashu_seed.as_bytes(), seed.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_produces_24_words() {
|
||||
let (mnemonic, _seed) = MasterSeed::generate().unwrap();
|
||||
@@ -1033,4 +1094,43 @@ mod tests {
|
||||
"release-root public key KAT"
|
||||
);
|
||||
}
|
||||
|
||||
/// The node's whole identity hangs off `Mnemonic::to_seed("")`, so this
|
||||
/// pins that derivation to the BIP-39 specification vectors rather than to
|
||||
/// whatever the `bip39` crate happens to do today.
|
||||
///
|
||||
/// It exists because the crate is not version-pinned any more: the exact
|
||||
/// `=2.1.0` pin was relaxed to `"2.1"` in 2026-08 so the `cashu` crate
|
||||
/// could resolve (the pin transitively froze `unicode-normalization` at a
|
||||
/// version with no common solution). A bump that changed derivation would
|
||||
/// silently re-key every node on the fleet and orphan every existing
|
||||
/// backup, which no amount of code review reliably catches — this does.
|
||||
#[test]
|
||||
fn seed_derivation_matches_the_bip39_specification_vectors() {
|
||||
let words = "abandon abandon abandon abandon abandon abandon abandon \
|
||||
abandon abandon abandon abandon about"
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let mnemonic: bip39::Mnemonic = words.parse().expect("valid test mnemonic");
|
||||
|
||||
// Empty passphrase — exactly how MasterSeed::from_mnemonic derives.
|
||||
assert_eq!(
|
||||
hex::encode(mnemonic.to_seed("")),
|
||||
"5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1\
|
||||
9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
|
||||
.replace(['\n', ' '], ""),
|
||||
"BIP-39 seed derivation changed — every node's keys would move"
|
||||
);
|
||||
|
||||
// With a passphrase, where NFKD normalisation actually participates;
|
||||
// this is the arm a `unicode-normalization` change could disturb.
|
||||
assert_eq!(
|
||||
hex::encode(mnemonic.to_seed("TREZOR")),
|
||||
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553\
|
||||
1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04"
|
||||
.replace(['\n', ' '], ""),
|
||||
"BIP-39 passphrase normalisation changed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,8 +664,7 @@ impl Server {
|
||||
.map(|(a, _)| *a)
|
||||
.unwrap_or(0)
|
||||
+ 1;
|
||||
let delay =
|
||||
(90u64 << attempts.min(10)).min(86_400);
|
||||
let delay = (90u64 << attempts.min(10)).min(86_400);
|
||||
notify_backoff.insert(
|
||||
node.did.clone(),
|
||||
(attempts, now + Duration::from_secs(delay)),
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
//! Cashu token format (NUT-00) — serialization and deserialization.
|
||||
//!
|
||||
//! Emits the cashuA (V3) token format:
|
||||
//! cashuA<base64url_encoded_json>
|
||||
//! Reads and writes both wire versions:
|
||||
//!
|
||||
//! Token JSON structure:
|
||||
//! {
|
||||
//! "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>", "secret": "<str>", "C": "<hex>" }] }],
|
||||
//! "memo": "<optional>"
|
||||
//! }
|
||||
//! - **cashuA (V3)** — `cashuA<base64url_encoded_json>`, whose JSON is the
|
||||
//! structs below verbatim:
|
||||
//! ```text
|
||||
//! { "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>",
|
||||
//! "secret": "<str>", "C": "<hex>" }] }], "memo": "<optional>" }
|
||||
//! ```
|
||||
//! - **cashuB (V4)** — `cashuB<base64url_encoded_cbor>`, a CBOR map keyed by
|
||||
//! the spec's single letters (t/i/p/a/s/c/m/u/d/w) rather than the JSON
|
||||
//! names above, with the keyset id (`i`) and signature (`c`) as raw bytes.
|
||||
//! Those are hex-encoded into `Proof` on the way in so the rest of the
|
||||
//! wallet never has to know which version a token arrived in.
|
||||
//!
|
||||
//! Also accepts (decode-only) the cashuB (V4) CBOR format many wallets emit
|
||||
//! by default now:
|
||||
//! cashuB<base64url_encoded_cbor>
|
||||
//! CBOR map keys are the spec's single-letter names (t/i/p/a/s/c/m/u/d/w),
|
||||
//! not the JSON names above. `i` (keyset id) and `c` (signature) are raw
|
||||
//! bytes on the wire; we hex-encode them into `Proof` to match the V3
|
||||
//! convention so the rest of the wallet doesn't need to know which version
|
||||
//! a token arrived in.
|
||||
//! `serialize_v4` is what we emit — most wallets default to cashuB now —
|
||||
//! with `serialize` (cashuA) kept for older receivers and as the fallback
|
||||
//! for the one token shape V4 cannot express (multi-mint).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bitcoin::secp256k1::PublicKey;
|
||||
// Protocol types from the reference implementation (`cashu`, the crate CDK
|
||||
// itself is built on). Used for the parts of NUT-00/02 that move with the
|
||||
// spec — token parsing and keyset ids — while the structs below stay ours
|
||||
// because they are also the on-disk format (see docs/cashu-cdk-migration-plan.md).
|
||||
use cashu::nuts::nut00::{Proof as CdkProof, Token as CdkToken};
|
||||
use cashu::nuts::nut01::PublicKey as CdkPublicKey;
|
||||
use cashu::nuts::nut02::{
|
||||
Id as CdkId, KeySetInfo as CdkKeySetInfo, ShortKeysetId as CdkShortKeysetId,
|
||||
};
|
||||
use cashu::nuts::CurrencyUnit as CdkCurrencyUnit;
|
||||
use cashu::secret::Secret as CdkSecret;
|
||||
use cashu::{Amount as CdkAmount, MintUrl as CdkMintUrl};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Prefix for V3 (JSON) tokens.
|
||||
const CASHU_A_PREFIX: &str = "cashuA";
|
||||
@@ -141,6 +154,58 @@ impl CashuToken {
|
||||
Ok(format!("{}{}", CASHU_A_PREFIX, encoded))
|
||||
}
|
||||
|
||||
/// Encode as a cashuB (V4, CBOR) token string — the format most wallets
|
||||
/// default to today.
|
||||
///
|
||||
/// Built through the reference implementation rather than by hand. The V4
|
||||
/// envelope puts the keyset id and the signature on the wire as raw CBOR
|
||||
/// bytes under single-letter keys, and a token that is subtly wrong there
|
||||
/// is money the receiver cannot redeem — so upstream owns the encoding,
|
||||
/// the same way it owns keyset-id resolution.
|
||||
///
|
||||
/// V4 is single-mint by construction, so a multi-mint token — which only
|
||||
/// our internal plumbing ever builds — has no V4 form and is refused
|
||||
/// here; `send_token_at` falls back to cashuA for it.
|
||||
pub fn serialize_v4(&self) -> Result<String> {
|
||||
let entry = match self.token.as_slice() {
|
||||
[only] => only,
|
||||
[] => anyhow::bail!("Token has no entries"),
|
||||
many => anyhow::bail!(
|
||||
"cashuB carries one mint per token; this token spans {}",
|
||||
many.len()
|
||||
),
|
||||
};
|
||||
|
||||
let mint_url = CdkMintUrl::from_str(&entry.mint)
|
||||
.with_context(|| format!("Token has an unusable mint URL: {}", entry.mint))?;
|
||||
// `unit` is optional on our struct and on V3; V4 requires one. Every
|
||||
// proof this wallet holds is denominated in sats (the mint's SAT
|
||||
// keyset is selected explicitly at signing time), so that is the
|
||||
// right default rather than a guess.
|
||||
let unit = CdkCurrencyUnit::from_str(self.unit.as_deref().unwrap_or("sat"))
|
||||
.with_context(|| format!("Token has an unusable unit: {:?}", self.unit))?;
|
||||
|
||||
let proofs = entry
|
||||
.proofs
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let keyset_id = CdkId::from_str(&p.id).with_context(|| {
|
||||
format!("Proof carries a keyset id cashuB cannot encode: {}", p.id)
|
||||
})?;
|
||||
let c = CdkPublicKey::from_hex(&p.c)
|
||||
.context("Proof carries an unparseable signature C")?;
|
||||
Ok(CdkProof::new(
|
||||
CdkAmount::from(p.amount),
|
||||
keyset_id,
|
||||
CdkSecret::new(p.secret.clone()),
|
||||
c,
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
Ok(CdkToken::new(mint_url, proofs, self.memo.clone(), unit).to_string())
|
||||
}
|
||||
|
||||
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string.
|
||||
pub fn deserialize(token_str: &str) -> Result<Self> {
|
||||
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
|
||||
@@ -215,12 +280,76 @@ impl CashuToken {
|
||||
if proof.c.is_empty() {
|
||||
anyhow::bail!("Proof has empty C");
|
||||
}
|
||||
validate_keyset_id(&proof.id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a token's (possibly short) keyset id against the mint's keyset
|
||||
/// list, using the reference implementation's NUT-02 rules.
|
||||
///
|
||||
/// A v1 id is complete at 8 bytes; a v2 id is 33 bytes and may legitimately
|
||||
/// travel in a token as a shorter prefix, which only the mint's keyset list
|
||||
/// can expand. Upstream `Id::from_short_keyset_id` implements exactly that,
|
||||
/// including the "8 bytes but `0x01`-versioned" case that a wallet written
|
||||
/// against the old format produces (framework-pt, 2026-08-17).
|
||||
///
|
||||
/// Returns the full hex id to send to the mint, or `None` when the id is
|
||||
/// already complete or cannot be resolved — the caller passes those through
|
||||
/// untouched so the mint's own error still reaches the operator.
|
||||
pub fn resolve_keyset_id(id_hex: &str, mint_keysets: &[CdkKeySetInfo]) -> Option<String> {
|
||||
let bytes = hex::decode(id_hex).ok()?;
|
||||
let short = CdkShortKeysetId::from_bytes(&bytes).ok()?;
|
||||
let full = CdkId::from_short_keyset_id(&short, mint_keysets).ok()?;
|
||||
let full_hex = hex::encode(full.to_bytes());
|
||||
(full_hex != id_hex).then_some(full_hex)
|
||||
}
|
||||
|
||||
/// NUT-02 keyset ID: hex for either 8 bytes (v1, the `00…` short form) or
|
||||
/// 33 bytes (v2, version-byte + hash).
|
||||
///
|
||||
/// Checked when a token is decoded rather than left to the mint. Forwarding
|
||||
/// an out-of-spec id produced a swap the mint rejected with a bare
|
||||
/// `422 Unprocessable Entity`, which reached the operator as "check server
|
||||
/// logs" with nothing actionable in the UI (framework-pt, 2026-08-17,
|
||||
/// mint.minibits.cash: `inputs[0].id: NUT02: ID length invalid`). A local
|
||||
/// check can say which keyset format the token uses and that this wallet
|
||||
/// cannot spend it, before any network call.
|
||||
fn validate_keyset_id(id: &str) -> Result<()> {
|
||||
let bytes = hex::decode(id).map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Token uses a keyset id that is not hex ({id:?}) — this wallet supports \
|
||||
NUT-02 v1 (8-byte) and v2 (33-byte) hex keyset ids"
|
||||
)
|
||||
})?;
|
||||
match bytes.len() {
|
||||
// 8 bytes is v1's whole id, and also what a wallet that predates v2
|
||||
// leaves behind when it truncates one. Both are accepted here; the
|
||||
// truncated case is repaired against the mint's keyset list at swap
|
||||
// time (see MintClient::resolve_truncated_keyset_ids).
|
||||
8 | 33 => Ok(()),
|
||||
n => anyhow::bail!(
|
||||
"Token uses an unsupported keyset id format: {n}-byte id {id:?}. NUT-02 \
|
||||
defines 8-byte (v1) and 33-byte (v2) ids; the mint will reject a swap \
|
||||
carrying this one"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this the first 8 bytes of a NUT-02 **v2** keyset id rather than a
|
||||
/// complete v1 one?
|
||||
///
|
||||
/// A v1 id is 8 bytes beginning with the version byte `0x00`; a v2 id is 33
|
||||
/// bytes beginning with `0x01`. So an 8-byte id that starts with `0x01` is a
|
||||
/// v2 id some wallet cut to the old length — it cannot be spent as-is, but
|
||||
/// the full id can be recovered from the mint because the short form is a
|
||||
/// prefix of it.
|
||||
pub fn is_truncated_v2_keyset_id(id: &str) -> bool {
|
||||
id.len() == 16 && id.starts_with("01") && hex::decode(id).is_ok()
|
||||
}
|
||||
|
||||
/// Decode a token's base64 payload, trying URL-safe-no-pad first (the spec
|
||||
/// default) and falling back to other alphabets some implementations use.
|
||||
fn decode_token_base64(payload: &str) -> Result<Vec<u8>, base64::DecodeError> {
|
||||
@@ -237,16 +366,57 @@ pub struct KeysetInfo {
|
||||
pub id: String,
|
||||
pub unit: String,
|
||||
pub active: bool,
|
||||
/// NUT-02 input fee, in parts-per-thousand of a proof. A mint charges
|
||||
/// this per *input* on a swap/melt; zero at fee-free mints, which is why
|
||||
/// ignoring it went unnoticed against Minibits.
|
||||
#[serde(default)]
|
||||
pub input_fee_ppk: u64,
|
||||
}
|
||||
|
||||
/// NUT-02 swap fee for a set of inputs: the summed per-proof parts-per-
|
||||
/// thousand, rounded **up** to whole units. Inputs whose keyset the mint
|
||||
/// didn't list contribute nothing — the mint is the authority, and guessing
|
||||
/// high would silently burn the sender's coins.
|
||||
pub fn swap_fee_for(proofs: &[Proof], keysets: &[KeysetInfo]) -> u64 {
|
||||
let ppk: u64 = proofs
|
||||
.iter()
|
||||
.map(|p| {
|
||||
keysets
|
||||
.iter()
|
||||
.find(|k| k.id == p.id)
|
||||
.map(|k| k.input_fee_ppk)
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.sum();
|
||||
ppk.div_ceil(1000)
|
||||
}
|
||||
|
||||
/// Mint keyset: maps denomination amounts to public keys.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MintKeyset {
|
||||
pub id: String,
|
||||
/// Currency unit this keyset signs for ("sat", "usd", "eur", "msat"…).
|
||||
///
|
||||
/// Defaulted rather than required: a mint that omits it is sat-only in
|
||||
/// practice, and refusing to parse would break wallets against mints that
|
||||
/// predate multi-unit support.
|
||||
#[serde(default = "default_unit")]
|
||||
pub unit: String,
|
||||
/// Whether the mint will still sign with this keyset.
|
||||
#[serde(default = "default_true")]
|
||||
pub active: bool,
|
||||
/// Map of amount (as string) to hex-encoded public key.
|
||||
pub keys: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_unit() -> String {
|
||||
"sat".to_string()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl MintKeyset {
|
||||
/// Get the mint's public key for a given denomination amount.
|
||||
pub fn key_for_amount(&self, amount: u64) -> Result<PublicKey> {
|
||||
@@ -441,6 +611,111 @@ mod tests {
|
||||
assert_eq!(decoded.memo, Some("test token".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v4_token_we_emit_is_readable_by_our_own_v4_decoder() {
|
||||
// Cross-implementation check: upstream's encoder writes the CBOR,
|
||||
// our hand-written decoder reads it back. Agreement between two
|
||||
// independent implementations is the evidence that matters here —
|
||||
// a round trip through one codec would prove nothing about the wire.
|
||||
let token = CashuToken {
|
||||
token: vec![TokenEntry {
|
||||
mint: "https://testnut.cashu.space".to_string(),
|
||||
// Real curve points (G and 2G). The V3 codec never parses `C`,
|
||||
// so its tests get away with a plausible-looking hex string —
|
||||
// the V4 encoder hands it to the reference implementation,
|
||||
// which checks the point is actually on secp256k1.
|
||||
proofs: vec![
|
||||
Proof {
|
||||
amount: 8,
|
||||
id: "009a1f293253e41e".to_string(),
|
||||
secret: "abcdef1234567890".to_string(),
|
||||
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
||||
.to_string(),
|
||||
},
|
||||
Proof {
|
||||
amount: 2,
|
||||
id: "009a1f293253e41e".to_string(),
|
||||
secret: "fedcba0987654321".to_string(),
|
||||
c: "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"
|
||||
.to_string(),
|
||||
},
|
||||
],
|
||||
}],
|
||||
memo: Some("ten sats".to_string()),
|
||||
unit: Some("sat".to_string()),
|
||||
};
|
||||
|
||||
let encoded = token.serialize_v4().expect("V4 encoding must succeed");
|
||||
assert!(encoded.starts_with("cashuB"), "{encoded}");
|
||||
|
||||
let decoded = CashuToken::deserialize(&encoded).expect("our decoder must read it");
|
||||
assert_eq!(decoded.total_amount(), 10);
|
||||
assert_eq!(decoded.token[0].mint, "https://testnut.cashu.space");
|
||||
assert_eq!(decoded.memo, Some("ten sats".to_string()));
|
||||
|
||||
// Every proof survives byte-for-byte, including the hex convention we
|
||||
// impose on the raw-bytes CBOR fields.
|
||||
let mut got: Vec<_> = decoded
|
||||
.all_proofs()
|
||||
.iter()
|
||||
.map(|p| (p.amount, p.id.clone(), p.secret.clone(), p.c.clone()))
|
||||
.collect();
|
||||
got.sort();
|
||||
let mut want: Vec<_> = token
|
||||
.all_proofs()
|
||||
.iter()
|
||||
.map(|p| (p.amount, p.id.clone(), p.secret.clone(), p.c.clone()))
|
||||
.collect();
|
||||
want.sort();
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multi_mint_token_has_no_v4_form_and_says_so() {
|
||||
// V4 is single-mint by construction. `send_token_at` relies on this
|
||||
// failing (rather than silently dropping an entry) to fall back to
|
||||
// cashuA — the proofs are already spent by the time it serializes.
|
||||
let one = |mint: &str| TokenEntry {
|
||||
mint: mint.to_string(),
|
||||
proofs: vec![Proof {
|
||||
amount: 1,
|
||||
id: "009a1f293253e41e".to_string(),
|
||||
secret: "s".to_string(),
|
||||
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
|
||||
}],
|
||||
};
|
||||
let token = CashuToken {
|
||||
token: vec![one("https://mint-a.example"), one("https://mint-b.example")],
|
||||
memo: None,
|
||||
unit: Some("sat".to_string()),
|
||||
};
|
||||
|
||||
let err = token
|
||||
.serialize_v4()
|
||||
.expect_err("two mints cannot be one V4 token");
|
||||
assert!(err.to_string().contains("one mint per token"), "{err}");
|
||||
|
||||
// …and cashuA, the fallback, still carries it.
|
||||
assert!(token.serialize().unwrap().starts_with("cashuA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_keyset_id_is_refused_by_the_v4_encoder() {
|
||||
// The framework-pt case. A short v2 id is only resolvable against the
|
||||
// mint's keyset list, so it must never be baked into a token we emit.
|
||||
let token = CashuToken::new(
|
||||
"https://mint.minibits.cash/Bitcoin",
|
||||
vec![Proof {
|
||||
amount: 1,
|
||||
id: "01fc0ec0e59cd6fa".to_string(),
|
||||
secret: "s".to_string(),
|
||||
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
|
||||
}],
|
||||
);
|
||||
let err = token.serialize_v4().expect_err("short id must not encode");
|
||||
assert!(err.to_string().contains("keyset id"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amount_to_denominations() {
|
||||
assert_eq!(amount_to_denominations(0), Vec::<u64>::new());
|
||||
@@ -473,4 +748,72 @@ mod tests {
|
||||
};
|
||||
assert!(proof.c_as_pubkey().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyset_ids_of_both_nut02_versions_are_accepted() {
|
||||
// v1: 8 bytes / 16 hex chars.
|
||||
assert!(validate_keyset_id("009a1f293253e41e").is_ok());
|
||||
// v2: 33 bytes / 66 hex chars (version byte + 32-byte hash).
|
||||
let v2 = format!("01{}", "ab".repeat(32));
|
||||
assert!(validate_keyset_id(&v2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_v2_keyset_id_is_recognised_as_repairable() {
|
||||
// The real case: a Minibits token carried the first 8 bytes of the
|
||||
// mint's 33-byte v2 keyset id (2026-08-17).
|
||||
let short = "01fc0ec0e59cd6fa";
|
||||
let full = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
|
||||
assert!(is_truncated_v2_keyset_id(short));
|
||||
assert!(
|
||||
full.starts_with(short),
|
||||
"short form must prefix the full id"
|
||||
);
|
||||
// It must survive token validation so the swap path can repair it,
|
||||
// rather than being rejected as malformed.
|
||||
assert!(validate_keyset_id(short).is_ok());
|
||||
|
||||
// A genuine v1 id (version byte 00) is not "truncated".
|
||||
assert!(!is_truncated_v2_keyset_id("009a1f293253e41e"));
|
||||
// Neither is a complete v2 id.
|
||||
assert!(!is_truncated_v2_keyset_id(full));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_out_of_spec_keyset_id_is_rejected_locally_with_its_length() {
|
||||
// 9 bytes — what a legacy base64 keyset id decodes to, and neither
|
||||
// NUT-02 length. The mint answers this with a bare 422, so the
|
||||
// message has to come from here.
|
||||
let err = validate_keyset_id("00112233445566778899")
|
||||
.expect_err("9-byte keyset id must be rejected");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("10-byte") || msg.contains("unsupported keyset id"),
|
||||
"{msg}"
|
||||
);
|
||||
|
||||
// Non-hex ids (the original base64 keyset format) are named as such
|
||||
// rather than reported as a length problem.
|
||||
let err = validate_keyset_id("I2yN+iRYfkzT").expect_err("base64 id must be rejected");
|
||||
assert!(err.to_string().contains("not hex"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_carrying_an_unsupported_keyset_id_fails_to_decode() {
|
||||
let token = CashuToken {
|
||||
token: vec![TokenEntry {
|
||||
mint: "https://mint.example.com".to_string(),
|
||||
proofs: vec![Proof {
|
||||
amount: 1,
|
||||
id: "I2yN+iRYfkzT".to_string(),
|
||||
secret: "s".to_string(),
|
||||
c: "02".to_string(),
|
||||
}],
|
||||
}],
|
||||
memo: None,
|
||||
unit: None,
|
||||
};
|
||||
// The whole point: this must fail here, not at the mint.
|
||||
assert!(token.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use super::cashu::{amount_to_denominations, CashuToken, Proof};
|
||||
use super::mint_client::MintClient;
|
||||
use super::nut13::RecoverySource;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
@@ -14,6 +15,7 @@ use tracing::{debug, info, warn};
|
||||
|
||||
const WALLET_FILE: &str = "wallet/ecash.json";
|
||||
const MINTS_FILE: &str = "wallet/accepted_mints.json";
|
||||
const NETWORK_FILE: &str = "wallet/network.json";
|
||||
|
||||
/// Transaction type for history tracking.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -213,56 +215,191 @@ impl WalletState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which ecash network this node's wallet is operating on.
|
||||
///
|
||||
/// Cashu itself has no notion of a testnet — a "test" wallet is simply one
|
||||
/// pointed at a mint that issues valueless coins (the public `testnut` mint).
|
||||
/// Modelling it as a network setting rather than "just add a mint" matters
|
||||
/// because the two must never share a purse: test proofs and real proofs in
|
||||
/// one file would be spendable interchangeably, and a balance would be a lie.
|
||||
///
|
||||
/// So each network gets its own wallet and its own accepted-mints list.
|
||||
/// **Mainnet keeps the original filenames**, so an existing node's funds file
|
||||
/// is untouched by this feature and by switching back and forth.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EcashNetwork {
|
||||
#[default]
|
||||
Mainnet,
|
||||
Testnet,
|
||||
}
|
||||
|
||||
impl EcashNetwork {
|
||||
fn wallet_file(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Mainnet => WALLET_FILE,
|
||||
Self::Testnet => "wallet/ecash.testnet.json",
|
||||
}
|
||||
}
|
||||
|
||||
fn mints_file(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Mainnet => MINTS_FILE,
|
||||
Self::Testnet => "wallet/accepted_mints.testnet.json",
|
||||
}
|
||||
}
|
||||
|
||||
/// The mint a fresh wallet on this network starts out trusting.
|
||||
pub fn default_mint(&self) -> String {
|
||||
match self {
|
||||
Self::Mainnet => default_mint_url(),
|
||||
// Public test mint: issues coins with no monetary value, and hands
|
||||
// them out freely, so every route (mint/melt/send/receive/swap)
|
||||
// can be exercised end to end without risking real sats.
|
||||
Self::Testnet => "https://testnut.cashu.space".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_test(&self) -> bool {
|
||||
matches!(self, Self::Testnet)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the node's ecash network. Absent file = mainnet, so nodes that never
|
||||
/// touch this setting behave exactly as before.
|
||||
pub async fn load_network(data_dir: &Path) -> EcashNetwork {
|
||||
let path = data_dir.join(NETWORK_FILE);
|
||||
let Ok(content) = fs::read_to_string(&path).await else {
|
||||
return EcashNetwork::Mainnet;
|
||||
};
|
||||
serde_json::from_str::<NetworkConfig>(&content)
|
||||
.map(|c| c.network)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Switch the node's ecash network. The other network's wallet is left on
|
||||
/// disk untouched, so switching is reversible and loses nothing.
|
||||
pub async fn save_network(data_dir: &Path, network: EcashNetwork) -> 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(&NetworkConfig { network })
|
||||
.context("Failed to serialize ecash network")?;
|
||||
fs::write(data_dir.join(NETWORK_FILE), content)
|
||||
.await
|
||||
.context("Failed to write ecash network")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
struct NetworkConfig {
|
||||
#[serde(default)]
|
||||
network: EcashNetwork,
|
||||
}
|
||||
|
||||
/// Load wallet state from disk.
|
||||
pub async fn load_wallet(data_dir: &Path) -> Result<WalletState> {
|
||||
let path = data_dir.join(WALLET_FILE);
|
||||
let network = load_network(data_dir).await;
|
||||
let path = data_dir.join(network.wallet_file());
|
||||
if !path.exists() {
|
||||
return Ok(WalletState {
|
||||
mint_url: default_mint_url(),
|
||||
mint_url: network.default_mint(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read wallet file")?;
|
||||
let mut wallet: WalletState = serde_json::from_str(&content).unwrap_or_default();
|
||||
|
||||
// An empty file is a legitimate "nothing here yet" (a create that never
|
||||
// got its first write); anything else that fails to parse is a damaged
|
||||
// purse and must NOT be read as an empty one.
|
||||
//
|
||||
// This used to be `unwrap_or_default()`, which turned a truncated file
|
||||
// into a zero balance — and because the very next operation saves the
|
||||
// wallet back, that empty state was then written over the only copy of
|
||||
// the proofs. Failing here keeps the damaged file intact so the coins
|
||||
// can still be recovered from it (or from a backup) by hand.
|
||||
let mut wallet: WalletState = if content.trim().is_empty() {
|
||||
WalletState::default()
|
||||
} else {
|
||||
serde_json::from_str(&content).with_context(|| {
|
||||
format!(
|
||||
"Ecash wallet file {} is damaged and was NOT overwritten — your coins are \
|
||||
still in it. Restore it from a backup, or move it aside to start empty.",
|
||||
path.display()
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
// Set default mint URL if empty
|
||||
if wallet.mint_url.is_empty() {
|
||||
wallet.mint_url = default_mint_url();
|
||||
wallet.mint_url = network.default_mint();
|
||||
}
|
||||
|
||||
Ok(wallet)
|
||||
}
|
||||
|
||||
/// Write `content` to `path` without ever leaving a half-written file there.
|
||||
///
|
||||
/// Writes a sibling temp file, flushes it to the platter, then renames over
|
||||
/// the target — rename is atomic within a filesystem, so a crash or power cut
|
||||
/// leaves either the old file or the new one, never a truncated one. The
|
||||
/// previous plain write truncated the real file first, which is precisely how
|
||||
/// a wallet ends up unparseable.
|
||||
async fn write_file_atomically(path: &Path, content: &str) -> Result<()> {
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let mut f = fs::File::create(&tmp)
|
||||
.await
|
||||
.with_context(|| format!("Failed to create {}", tmp.display()))?;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
f.write_all(content.as_bytes())
|
||||
.await
|
||||
.context("Failed to write wallet temp file")?;
|
||||
f.sync_all().await.context("Failed to flush wallet file")?;
|
||||
drop(f);
|
||||
fs::rename(&tmp, path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to replace {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save wallet state to disk.
|
||||
pub async fn save_wallet(data_dir: &Path, wallet: &WalletState) -> Result<()> {
|
||||
let dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create wallet dir")?;
|
||||
let path = data_dir.join(WALLET_FILE);
|
||||
let path = data_dir.join(load_network(data_dir).await.wallet_file());
|
||||
let content = serde_json::to_string_pretty(wallet).context("Failed to serialize wallet")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write wallet file")?;
|
||||
write_file_atomically(&path, &content).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load accepted mints list.
|
||||
pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> {
|
||||
let path = data_dir.join(MINTS_FILE);
|
||||
let network = load_network(data_dir).await;
|
||||
let path = data_dir.join(network.mints_file());
|
||||
if !path.exists() {
|
||||
return Ok(AcceptedMints {
|
||||
mints: vec![default_mint_url()],
|
||||
mints: vec![network.default_mint()],
|
||||
});
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read accepted mints")?;
|
||||
let mints: AcceptedMints = serde_json::from_str(&content).unwrap_or(AcceptedMints {
|
||||
mints: vec![default_mint_url()],
|
||||
});
|
||||
// A damaged mint list must not silently become "trust only the default"
|
||||
// — that would reject perfectly good tokens from mints the operator
|
||||
// added. Empty file is still a legitimate fresh state.
|
||||
let mints: AcceptedMints = if content.trim().is_empty() {
|
||||
AcceptedMints {
|
||||
mints: vec![network.default_mint()],
|
||||
}
|
||||
} else {
|
||||
serde_json::from_str(&content)
|
||||
.with_context(|| format!("Accepted-mints file {} is damaged", path.display()))?
|
||||
};
|
||||
Ok(mints)
|
||||
}
|
||||
|
||||
@@ -272,22 +409,33 @@ pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Resu
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create wallet dir")?;
|
||||
let path = data_dir.join(MINTS_FILE);
|
||||
let path = data_dir.join(load_network(data_dir).await.mints_file());
|
||||
let content =
|
||||
serde_json::to_string_pretty(mints).context("Failed to serialize accepted mints")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write accepted mints")?;
|
||||
write_file_atomically(&path, &content).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a mint client whose proofs are **restorable from the wallet phrase**.
|
||||
///
|
||||
/// Every output such a client creates has its secret derived via NUT-13
|
||||
/// (`wallet/nut13.rs`) rather than drawn from randomness, so the coins can be
|
||||
/// re-derived and re-claimed if `wallet/ecash.json` is ever lost. That is the
|
||||
/// only difference from `MintClient::new`, and it is the reason this wallet
|
||||
/// has a backup story at all — so every mint/swap path in this module goes
|
||||
/// through here. On a node with no phrase yet the source is absent and the
|
||||
/// behaviour is exactly as it was before: valid proofs, no backup.
|
||||
async fn mint_client(data_dir: &Path, mint_url: &str) -> Result<MintClient> {
|
||||
Ok(MintClient::new(mint_url)?.with_recovery(RecoverySource::load(data_dir).await))
|
||||
}
|
||||
|
||||
/// Request a mint quote — returns a Lightning invoice to pay.
|
||||
pub async fn mint_quote(
|
||||
data_dir: &Path,
|
||||
amount_sats: u64,
|
||||
) -> Result<super::mint_client::MintQuote> {
|
||||
let wallet = load_wallet(data_dir).await?;
|
||||
let client = MintClient::new(&wallet.mint_url)?;
|
||||
let client = mint_client(data_dir, &wallet.mint_url).await?;
|
||||
client.mint_quote(amount_sats).await
|
||||
}
|
||||
|
||||
@@ -295,7 +443,7 @@ pub async fn mint_quote(
|
||||
pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> Result<u64> {
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mint_url = wallet.mint_url.clone();
|
||||
let client = MintClient::new(&mint_url)?;
|
||||
let client = mint_client(data_dir, &mint_url).await?;
|
||||
|
||||
let result = client.mint_tokens(quote_id, amount_sats).await?;
|
||||
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
|
||||
@@ -317,7 +465,7 @@ pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> R
|
||||
/// Request a melt quote — how much to pay a Lightning invoice with ecash.
|
||||
pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result<super::mint_client::MeltQuote> {
|
||||
let wallet = load_wallet(data_dir).await?;
|
||||
let client = MintClient::new(&wallet.mint_url)?;
|
||||
let client = mint_client(data_dir, &wallet.mint_url).await?;
|
||||
client.melt_quote(bolt11).await
|
||||
}
|
||||
|
||||
@@ -325,7 +473,7 @@ pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result<super::mint_cli
|
||||
pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Result<u64> {
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mint_url = wallet.mint_url.clone();
|
||||
let client = MintClient::new(&mint_url)?;
|
||||
let client = mint_client(data_dir, &mint_url).await?;
|
||||
|
||||
// Get the melt quote to know the amount needed
|
||||
let quote = client.melt_quote(bolt11).await?;
|
||||
@@ -448,8 +596,8 @@ pub async fn swap_between_mints(
|
||||
);
|
||||
}
|
||||
|
||||
let from = MintClient::new(from_mint)?;
|
||||
let to = MintClient::new(to_mint)?;
|
||||
let from = mint_client(data_dir, from_mint).await?;
|
||||
let to = mint_client(data_dir, to_mint).await?;
|
||||
|
||||
// 1. Mint quote on the target → invoice to pay.
|
||||
let mint_quote = to
|
||||
@@ -587,13 +735,13 @@ async fn wait_for_mint_quote_paid(client: &MintClient, quote_id: &str) -> Result
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a cashuA token string to send to a peer, drawing from the home mint.
|
||||
/// Create an ecash token string to send to a peer, drawing from the home mint.
|
||||
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
|
||||
let mint_url = load_wallet(data_dir).await?.mint_url;
|
||||
send_token_at(data_dir, &mint_url, amount_sats).await
|
||||
}
|
||||
|
||||
/// Create a cashuA token denominated in a specific mint's tokens.
|
||||
/// Create an ecash token denominated in a specific mint's tokens.
|
||||
///
|
||||
/// Used by the payer-side cross-mint flow: after `swap_between_mints` lands value
|
||||
/// on the seeder's accepted mint, we send a token from *that* mint so the seeder
|
||||
@@ -620,7 +768,7 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
||||
|
||||
// If there's overpayment, swap to get exact change
|
||||
let send_proofs = if overpayment > 0 {
|
||||
let client = MintClient::new(&mint_url)?;
|
||||
let client = mint_client(data_dir, &mint_url).await?;
|
||||
let send_denoms = amount_to_denominations(amount_sats);
|
||||
let change_denoms = amount_to_denominations(overpayment);
|
||||
|
||||
@@ -669,9 +817,20 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
||||
selected_proofs
|
||||
};
|
||||
|
||||
// Serialize as cashuA token
|
||||
// Emit cashuB (V4) — what Minibits, Nutstash and cdk-cli read by default.
|
||||
// cashuA stays the fallback rather than the default: it is still valid and
|
||||
// every wallet accepts it, so a token this wallet cannot express in V4 is
|
||||
// worth sending in V3 rather than failing the send outright. The warning
|
||||
// exists so that never happens silently — at this point in `send_token_at`
|
||||
// the proofs are already marked spent.
|
||||
let token = CashuToken::new(&mint_url, send_proofs);
|
||||
let token_str = token.serialize()?;
|
||||
let token_str = match token.serialize_v4() {
|
||||
Ok(v4) => v4,
|
||||
Err(e) => {
|
||||
warn!("Falling back to a cashuA token — cashuB encoding failed: {e:#}");
|
||||
token.serialize()?
|
||||
}
|
||||
};
|
||||
|
||||
wallet.record_tx(
|
||||
TransactionType::Send,
|
||||
@@ -763,7 +922,7 @@ fn plan_payment(
|
||||
PaymentPlan::Insufficient
|
||||
}
|
||||
|
||||
/// Build a cashuA token to pay a seeder `amount_sats`, denominated in one of the
|
||||
/// Build an ecash token to pay a seeder `amount_sats`, denominated in one of the
|
||||
/// seeder's `accepted_mints`. Auto-swaps across mints (up to `max_fee_sats`) when
|
||||
/// we don't already hold the right mint. Returns the token string ready to send.
|
||||
///
|
||||
@@ -883,7 +1042,7 @@ pub async fn resume_pending_swaps(data_dir: &Path) -> Result<u64> {
|
||||
let pending = load_pending_swaps(data_dir).await?;
|
||||
let mut reclaimed = 0u64;
|
||||
for swap in pending {
|
||||
let to = match MintClient::new(&swap.to_mint) {
|
||||
let to = match mint_client(data_dir, &swap.to_mint).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -1016,7 +1175,7 @@ fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Receive a cashuA token from a peer — swaps proofs at the mint for fresh ones.
|
||||
/// Receive a Cashu token from a peer — swaps proofs at the mint for fresh ones.
|
||||
pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
// Handle legacy format for backwards compatibility
|
||||
if token_str.starts_with("cashuSend_") {
|
||||
@@ -1049,7 +1208,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
|
||||
// Swap proofs at each mint
|
||||
for entry in &token.token {
|
||||
let client = MintClient::new(&entry.mint)?;
|
||||
let client = mint_client(data_dir, &entry.mint).await?;
|
||||
match client.receive_token(&token).await {
|
||||
Ok(new_proofs) => {
|
||||
let amount: u64 = new_proofs.iter().map(|p| p.amount).sum();
|
||||
@@ -1165,7 +1324,7 @@ pub async fn verify_and_receive_payment(
|
||||
return Ok(received);
|
||||
}
|
||||
|
||||
// Parse and validate cashuA token
|
||||
// Parse and validate the token (cashuA or cashuB)
|
||||
let token = CashuToken::deserialize(token_str)?;
|
||||
let total = token.total_amount();
|
||||
|
||||
@@ -1190,7 +1349,7 @@ pub async fn verify_and_receive_payment(
|
||||
let mut received_total = 0u64;
|
||||
|
||||
for entry in &token.token {
|
||||
let client = MintClient::new(&entry.mint)?;
|
||||
let client = mint_client(data_dir, &entry.mint).await?;
|
||||
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(entry_total);
|
||||
|
||||
@@ -1226,6 +1385,235 @@ pub async fn verify_and_receive_payment(
|
||||
Ok(received_total)
|
||||
}
|
||||
|
||||
// ── Restore from the NUT-13 phrase ─────────────────────────────────────────
|
||||
|
||||
/// How many counters to probe per `/v1/restore` call.
|
||||
const RESTORE_BATCH: u32 = 100;
|
||||
/// How many consecutive empty batches end a keyset's scan.
|
||||
///
|
||||
/// Counters are consumed in order but gaps happen: a reservation is persisted
|
||||
/// before the mint call, so any failed mint or swap burns its counters. Three
|
||||
/// empty batches is 300 unused counters in a row — far beyond any realistic
|
||||
/// run of failures, while still terminating quickly on a fresh wallet.
|
||||
const RESTORE_GAP_BATCHES: u32 = 3;
|
||||
|
||||
/// What a restore found.
|
||||
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||
pub struct RestoreOutcome {
|
||||
/// Sats recovered and added to the wallet.
|
||||
pub recovered_sats: u64,
|
||||
/// Proofs added.
|
||||
pub recovered_proofs: usize,
|
||||
/// Proofs the mint had signed but which are already spent — the wallet's
|
||||
/// history, not its balance. Reported because "found nothing" and "found
|
||||
/// only coins you already spent" mean very different things to someone
|
||||
/// staring at an empty balance.
|
||||
pub already_spent: usize,
|
||||
/// Keysets scanned at the mint.
|
||||
pub keysets_scanned: usize,
|
||||
}
|
||||
|
||||
/// Rebuild this wallet's proofs from its NUT-13 phrase by asking a mint which
|
||||
/// of the re-derived secrets it has signed.
|
||||
///
|
||||
/// This is the half of the backup that cannot be done offline. The phrase
|
||||
/// re-derives every secret the wallet ever used, but a secret alone is not
|
||||
/// money — the mint's signature over it is. `/v1/restore` returns those
|
||||
/// signatures, and unblinding them reconstitutes the proofs.
|
||||
///
|
||||
/// Additive and idempotent by design: proofs already in the wallet are skipped
|
||||
/// by secret, and anything the mint reports as spent is counted but not added.
|
||||
/// So a restore can be run against a *working* wallet without duplicating
|
||||
/// coins or resurrecting spent ones, which matters because the most likely
|
||||
/// time to press this button is when something already looks wrong.
|
||||
pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<RestoreOutcome> {
|
||||
let recovery = RecoverySource::load(data_dir).await.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"This wallet has no backup phrase yet, so there is nothing to restore from. \
|
||||
Set one up in Settings → Ecash backup phrase."
|
||||
)
|
||||
})?;
|
||||
|
||||
let client = MintClient::new(mint_url)?;
|
||||
// Every keyset, not just the active one: coins signed by a retired keyset
|
||||
// are still spendable, and skipping it would leave them behind.
|
||||
let keysets: Vec<_> = client
|
||||
.get_keysets()
|
||||
.await
|
||||
.context("Could not list the mint's keysets")?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let known_secrets: std::collections::HashSet<String> = wallet
|
||||
.proofs
|
||||
.iter()
|
||||
.map(|p| p.proof.secret.clone())
|
||||
.collect();
|
||||
|
||||
let mut outcome = RestoreOutcome::default();
|
||||
let mut found: Vec<Proof> = Vec::new();
|
||||
|
||||
for keyset in &keysets {
|
||||
// The mint's public keys for this keyset — needed to unblind.
|
||||
let keys = match client.get_keyset(&keyset.id).await {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
warn!("Skipping keyset {} during restore: {e:#}", keyset.id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !keys.unit.eq_ignore_ascii_case("sat") {
|
||||
continue;
|
||||
}
|
||||
outcome.keysets_scanned += 1;
|
||||
|
||||
let mut counter = 0u32;
|
||||
let mut empty_batches = 0u32;
|
||||
let mut highest_seen: Option<u32> = None;
|
||||
|
||||
while empty_batches < RESTORE_GAP_BATCHES {
|
||||
// Re-derive this batch's outputs. The amount is deliberately 0:
|
||||
// the mint matches a restore on the blinded message `B_` alone and
|
||||
// returns the true amount in its signature — we do not know what
|
||||
// denomination each counter was used for, and guessing would be
|
||||
// wrong for most of them.
|
||||
let mut derived = Vec::with_capacity(RESTORE_BATCH as usize);
|
||||
let mut outputs = Vec::with_capacity(RESTORE_BATCH as usize);
|
||||
for i in 0..RESTORE_BATCH {
|
||||
let n = counter + i;
|
||||
let (secret, r) = match recovery.derive_at(&keyset.id, n) {
|
||||
Ok(pair) => pair,
|
||||
// A keyset id NUT-13 cannot address — nothing was ever
|
||||
// derived for it, so there is nothing to find.
|
||||
Err(e) => {
|
||||
debug!("Cannot derive for keyset {}: {e:#}", keyset.id);
|
||||
break;
|
||||
}
|
||||
};
|
||||
let blinded = super::bdhke::blind_message(&secret, &r)?;
|
||||
outputs.push(super::cashu::BlindedMessageRequest {
|
||||
amount: 0,
|
||||
id: keyset.id.clone(),
|
||||
b_prime: hex::encode(blinded.b_prime.serialize()),
|
||||
});
|
||||
derived.push((n, secret, r, hex::encode(blinded.b_prime.serialize())));
|
||||
}
|
||||
if outputs.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let restored = client.restore(&outputs).await?;
|
||||
if restored.is_empty() {
|
||||
empty_batches += 1;
|
||||
counter += RESTORE_BATCH;
|
||||
continue;
|
||||
}
|
||||
empty_batches = 0;
|
||||
|
||||
for (b_prime, sig) in restored {
|
||||
let Some((n, secret, r, _)) = derived.iter().find(|(_, _, _, b)| *b == b_prime)
|
||||
else {
|
||||
warn!("Mint restored an output we did not send — ignoring");
|
||||
continue;
|
||||
};
|
||||
let mint_key = match keys.key_for_amount(sig.amount) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Restored a {} sat output with no matching key: {e:#}",
|
||||
sig.amount
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let c_prime = sig.c_prime_as_pubkey()?;
|
||||
let c = super::bdhke::unblind_signature(&c_prime, r, &mint_key)?;
|
||||
|
||||
highest_seen = Some(highest_seen.map_or(*n, |h: u32| h.max(*n)));
|
||||
let secret = String::from_utf8_lossy(secret).to_string();
|
||||
if known_secrets.contains(&secret) {
|
||||
continue; // already in the wallet
|
||||
}
|
||||
found.push(Proof {
|
||||
amount: sig.amount,
|
||||
id: keyset.id.clone(),
|
||||
secret,
|
||||
c: hex::encode(c.serialize()),
|
||||
});
|
||||
}
|
||||
counter += RESTORE_BATCH;
|
||||
}
|
||||
|
||||
// Never hand out a counter this keyset has already used. The scan may
|
||||
// have found coins beyond where the counter file thought we were —
|
||||
// reusing those would mint proofs that collide with existing ones.
|
||||
if let Some(highest) = highest_seen {
|
||||
if let Err(e) =
|
||||
super::nut13::advance_counter_to(data_dir, &keyset.id, highest + 1).await
|
||||
{
|
||||
warn!("Could not advance the NUT-13 counter after restore: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if found.is_empty() {
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
// Only unspent proofs are money. The mint signed every one of these at
|
||||
// some point, including the ones already spent — adding those would
|
||||
// inflate the balance with coins that fail on first use.
|
||||
let states = client
|
||||
.check_state(&found)
|
||||
.await
|
||||
.context("Could not check which restored coins are still unspent")?;
|
||||
// NUT-07 answers in request order. Insist on that rather than assuming it:
|
||||
// a mismatched length would pair a proof with someone else's verdict and
|
||||
// credit spent coins as spendable.
|
||||
if states.len() != found.len() {
|
||||
anyhow::bail!(
|
||||
"Mint returned {} proof states for {} restored coins — refusing to \
|
||||
decide which are spendable",
|
||||
states.len(),
|
||||
found.len()
|
||||
);
|
||||
}
|
||||
|
||||
let mut keep = Vec::new();
|
||||
for (proof, state) in found.iter().zip(states.iter()) {
|
||||
if state.state.eq_ignore_ascii_case("UNSPENT") {
|
||||
keep.push(proof.clone());
|
||||
} else {
|
||||
outcome.already_spent += 1;
|
||||
}
|
||||
}
|
||||
|
||||
outcome.recovered_sats = keep.iter().map(|p| p.amount).sum();
|
||||
outcome.recovered_proofs = keep.len();
|
||||
|
||||
if !keep.is_empty() {
|
||||
wallet.add_proofs(mint_url, keep);
|
||||
wallet.record_tx(
|
||||
TransactionType::Receive,
|
||||
outcome.recovered_sats,
|
||||
&format!(
|
||||
"Restored {} sats from the backup phrase",
|
||||
outcome.recovered_sats
|
||||
),
|
||||
mint_url,
|
||||
"",
|
||||
);
|
||||
save_wallet(data_dir, &wallet).await?;
|
||||
info!(
|
||||
"Restored {} sats ({} proofs) from the ecash backup phrase",
|
||||
outcome.recovered_sats, outcome.recovered_proofs
|
||||
);
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Check the wallet balance.
|
||||
pub async fn get_balance(data_dir: &Path) -> Result<u64> {
|
||||
let wallet = load_wallet(data_dir).await?;
|
||||
@@ -1885,4 +2273,173 @@ mod tests {
|
||||
other => panic!("expected swap into liquid target, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ecash_network_defaults_to_mainnet_and_leaves_files_alone() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
assert_eq!(load_network(dir).await, EcashNetwork::Mainnet);
|
||||
// A node that never touches this setting has no new file.
|
||||
assert!(!dir.join(NETWORK_FILE).exists());
|
||||
assert_eq!(
|
||||
load_wallet(dir).await.unwrap().mint_url,
|
||||
default_mint_url(),
|
||||
"mainnet must keep the original default mint"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn switching_to_testnet_uses_a_separate_purse_and_test_mint() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
|
||||
// Put real coins in the mainnet wallet.
|
||||
let mut real = load_wallet(dir).await.unwrap();
|
||||
real.proofs.push(StoredProof {
|
||||
proof: Proof {
|
||||
amount: 1000,
|
||||
id: "009a1f293253e41e".into(),
|
||||
secret: "real".into(),
|
||||
c: "02".into(),
|
||||
},
|
||||
mint_url: default_mint_url(),
|
||||
spent: false,
|
||||
reserved: false,
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
});
|
||||
save_wallet(dir, &real).await.unwrap();
|
||||
assert_eq!(load_wallet(dir).await.unwrap().balance(), 1000);
|
||||
|
||||
// Switching to testnet must show an EMPTY purse pointed at the test
|
||||
// mint — never the real coins.
|
||||
save_network(dir, EcashNetwork::Testnet).await.unwrap();
|
||||
let test_wallet = load_wallet(dir).await.unwrap();
|
||||
assert_eq!(
|
||||
test_wallet.balance(),
|
||||
0,
|
||||
"test wallet must not see real coins"
|
||||
);
|
||||
assert!(test_wallet.mint_url.contains("testnut"));
|
||||
assert!(load_accepted_mints(dir).await.unwrap().mints[0].contains("testnut"));
|
||||
|
||||
// Test coins are written to their own file...
|
||||
let mut t = test_wallet;
|
||||
t.proofs.push(StoredProof {
|
||||
proof: Proof {
|
||||
amount: 7,
|
||||
id: "009a1f293253e41e".into(),
|
||||
secret: "test".into(),
|
||||
c: "02".into(),
|
||||
},
|
||||
mint_url: EcashNetwork::Testnet.default_mint(),
|
||||
spent: false,
|
||||
reserved: false,
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
});
|
||||
save_wallet(dir, &t).await.unwrap();
|
||||
assert!(dir.join("wallet/ecash.testnet.json").exists());
|
||||
|
||||
// ...and switching back finds the real balance exactly as it was.
|
||||
save_network(dir, EcashNetwork::Mainnet).await.unwrap();
|
||||
let back = load_wallet(dir).await.unwrap();
|
||||
assert_eq!(back.balance(), 1000, "real funds must survive a round trip");
|
||||
assert_eq!(back.proofs.len(), 1);
|
||||
assert_eq!(back.proofs[0].proof.secret, "real");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_damaged_wallet_file_fails_loudly_and_is_left_on_disk() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join("wallet")).unwrap();
|
||||
|
||||
// A truncated file — what a crash mid-write used to leave behind.
|
||||
let damaged = r#"{"proofs":[{"amount":1000,"id":"009a1f293253e41e","secr"#;
|
||||
let path = dir.join("wallet/ecash.json");
|
||||
std::fs::write(&path, damaged).unwrap();
|
||||
|
||||
// It must NOT read as an empty wallet: that is what caused the real
|
||||
// balance to be overwritten with nothing on the next save.
|
||||
let err = load_wallet(dir)
|
||||
.await
|
||||
.expect_err("damaged wallet must error");
|
||||
assert!(
|
||||
err.to_string().contains("damaged"),
|
||||
"error should name the problem: {err}"
|
||||
);
|
||||
|
||||
// And the bytes must still be there for recovery.
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), damaged);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_empty_wallet_file_is_treated_as_a_fresh_wallet() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join("wallet")).unwrap();
|
||||
std::fs::write(dir.join("wallet/ecash.json"), " \n").unwrap();
|
||||
// A create that never got its first write is not damage.
|
||||
let w = load_wallet(dir)
|
||||
.await
|
||||
.expect("empty file is a fresh wallet");
|
||||
assert_eq!(w.balance(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saving_leaves_no_temp_file_and_round_trips() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
let mut w = load_wallet(dir).await.unwrap();
|
||||
w.proofs.push(StoredProof {
|
||||
proof: Proof {
|
||||
amount: 21,
|
||||
id: "009a1f293253e41e".into(),
|
||||
secret: "s".into(),
|
||||
c: "02".into(),
|
||||
},
|
||||
mint_url: default_mint_url(),
|
||||
spent: false,
|
||||
reserved: false,
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
});
|
||||
save_wallet(dir, &w).await.unwrap();
|
||||
|
||||
assert_eq!(load_wallet(dir).await.unwrap().balance(), 21);
|
||||
// The atomic write must not litter, or the next reader could find it.
|
||||
assert!(!dir.join("wallet/ecash.json.tmp").exists());
|
||||
}
|
||||
|
||||
/// The exact shape a pre-update node has on disk, parsed by the current
|
||||
/// code. Guards the on-disk contract: an update must never strand funds.
|
||||
#[tokio::test]
|
||||
async fn a_pre_update_wallet_file_still_loads_with_its_balance() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join("wallet")).unwrap();
|
||||
// Verbatim shape from a live node (proof fields flattened, capital C,
|
||||
// spent/reserved flags, RFC-3339 created_at, lowercase tx type).
|
||||
let legacy = r#"{
|
||||
"proofs": [
|
||||
{"amount": 2, "id": "00107937db0cc865", "secret": "9b4bdb0e", "C": "030391",
|
||||
"mint_url": "https://mint.minibits.cash/Bitcoin", "spent": true,
|
||||
"reserved": false, "created_at": "2026-07-23T19:49:42.468773802+00:00"},
|
||||
{"amount": 512, "id": "00107937db0cc865", "secret": "aa11", "C": "0322",
|
||||
"mint_url": "https://mint.minibits.cash/Bitcoin", "spent": false,
|
||||
"reserved": false, "created_at": "2026-07-23T19:49:42.468773802+00:00"}
|
||||
],
|
||||
"transactions": [
|
||||
{"id": "8f14e45f", "tx_type": "receive", "amount_sats": 512,
|
||||
"timestamp": "2026-07-23T19:49:42+00:00", "description": "",
|
||||
"mint_url": "https://mint.minibits.cash/Bitcoin", "peer": ""}
|
||||
],
|
||||
"mint_url": "https://mint.minibits.cash/Bitcoin"
|
||||
}"#;
|
||||
std::fs::write(dir.join("wallet/ecash.json"), legacy).unwrap();
|
||||
|
||||
let w = load_wallet(dir).await.expect("pre-update wallet must load");
|
||||
assert_eq!(w.balance(), 512, "spendable balance must survive an update");
|
||||
assert_eq!(w.proofs.len(), 2, "spent proofs are retained too");
|
||||
assert_eq!(w.transactions.len(), 1);
|
||||
assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
|
||||
use super::bdhke;
|
||||
use super::cashu::{
|
||||
amount_to_denominations, BlindSignature, BlindedMessageRequest, CashuToken, MintKeyset, Proof,
|
||||
amount_to_denominations, is_truncated_v2_keyset_id, BlindSignature, BlindedMessageRequest,
|
||||
CashuToken, KeysetInfo, MintKeyset, Proof,
|
||||
};
|
||||
use super::nut13::RecoverySource;
|
||||
use anyhow::{Context, Result};
|
||||
use bitcoin::secp256k1;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Default timeout for mint API calls.
|
||||
const MINT_TIMEOUT_SECS: u64 = 10;
|
||||
@@ -129,10 +132,19 @@ fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Erro
|
||||
pub struct MintClient {
|
||||
url: String,
|
||||
client: reqwest::Client,
|
||||
/// NUT-13 output source. When set, every proof this client creates has a
|
||||
/// secret derived from the wallet's phrase and is therefore restorable;
|
||||
/// when absent, secrets are random and live only in `wallet/ecash.json`.
|
||||
recovery: Option<RecoverySource>,
|
||||
}
|
||||
|
||||
impl MintClient {
|
||||
/// Create a new mint client for the given mint URL.
|
||||
///
|
||||
/// Proofs minted through a client built this way are **not** recoverable
|
||||
/// from the wallet phrase. Prefer `ecash::mint_client`, which attaches the
|
||||
/// NUT-13 source; this stays for callers with no data directory (probes,
|
||||
/// keyset lookups, tests).
|
||||
pub fn new(mint_url: &str) -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS))
|
||||
@@ -142,6 +154,7 @@ impl MintClient {
|
||||
Ok(Self {
|
||||
url: mint_url.trim_end_matches('/').to_string(),
|
||||
client,
|
||||
recovery: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,13 +163,70 @@ impl MintClient {
|
||||
Self {
|
||||
url: mint_url.trim_end_matches('/').to_string(),
|
||||
client,
|
||||
recovery: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive this client's blinded outputs from the wallet's NUT-13 phrase,
|
||||
/// so the proofs it creates can be restored from those words.
|
||||
pub fn with_recovery(mut self, recovery: Option<RecoverySource>) -> Self {
|
||||
self.recovery = recovery;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
/// Build the blinded messages for a batch of output amounts, together with
|
||||
/// the `(secret, blinding factor, amount)` needed to unblind the mint's
|
||||
/// signatures afterwards.
|
||||
///
|
||||
/// Prefers NUT-13 derivation so the resulting proofs are restorable. Falls
|
||||
/// back to random secrets when this wallet has no phrase yet, or when the
|
||||
/// keyset id is one NUT-13 cannot address — a random secret still mints a
|
||||
/// perfectly valid, spendable proof, so refusing here would break the
|
||||
/// wallet to protect a backup that does not exist.
|
||||
async fn blinded_outputs(
|
||||
&self,
|
||||
keyset_id: &str,
|
||||
amounts: &[u64],
|
||||
) -> Result<(
|
||||
Vec<BlindedMessageRequest>,
|
||||
Vec<(Vec<u8>, secp256k1::SecretKey, u64)>,
|
||||
)> {
|
||||
let derived = match &self.recovery {
|
||||
Some(source) => match source.next_outputs(keyset_id, amounts.len()).await {
|
||||
Ok(pairs) => Some(pairs),
|
||||
Err(e) => {
|
||||
warn!("Minting unrecoverable proofs — NUT-13 derivation failed: {e:#}");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut blinded_messages = Vec::with_capacity(amounts.len());
|
||||
let mut blinding_data = Vec::with_capacity(amounts.len());
|
||||
|
||||
for (i, &amount) in amounts.iter().enumerate() {
|
||||
let (secret, r) = match &derived {
|
||||
Some(pairs) => pairs[i].clone(),
|
||||
None => (bdhke::generate_secret(), bdhke::random_blinding_factor()),
|
||||
};
|
||||
let blinded = bdhke::blind_message(&secret, &r)?;
|
||||
|
||||
blinded_messages.push(BlindedMessageRequest {
|
||||
amount,
|
||||
id: keyset_id.to_string(),
|
||||
b_prime: hex::encode(blinded.b_prime.serialize()),
|
||||
});
|
||||
blinding_data.push((secret, r, amount));
|
||||
}
|
||||
|
||||
Ok((blinded_messages, blinding_data))
|
||||
}
|
||||
|
||||
// ── Keyset discovery (NUT-01, NUT-02) ──
|
||||
|
||||
/// Fetch the active keyset from the mint.
|
||||
@@ -184,16 +254,76 @@ impl MintClient {
|
||||
Ok(keysets)
|
||||
}
|
||||
|
||||
/// List the mint's keysets (NUT-02 `GET /v1/keysets`) — ids and status
|
||||
/// only, no public keys. Unlike `/v1/keys` this includes *inactive*
|
||||
/// keysets, which a received token may well reference: coins from a
|
||||
/// retired keyset stay spendable.
|
||||
pub async fn get_keysets(&self) -> Result<Vec<KeysetInfo>> {
|
||||
let url = format!("{}/v1/keysets", self.url);
|
||||
let res = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fetch mint keysets")?;
|
||||
if !res.status().is_success() {
|
||||
anyhow::bail!("Mint keysets request failed: {}", res.status());
|
||||
}
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse mint keysets")?;
|
||||
let keysets: Vec<KeysetInfo> = serde_json::from_value(
|
||||
body.get("keysets")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!([])),
|
||||
)
|
||||
.context("Failed to parse keyset list")?;
|
||||
Ok(keysets)
|
||||
}
|
||||
|
||||
/// Fetch one keyset's public keys by id (NUT-01 `GET /v1/keys/{id}`).
|
||||
///
|
||||
/// `/v1/keys` returns only what the mint will still *sign* with, but a
|
||||
/// restore has to unblind signatures made by keysets that have since been
|
||||
/// retired — those coins are still spendable, and skipping their keysets
|
||||
/// would quietly leave money behind.
|
||||
pub async fn get_keyset(&self, keyset_id: &str) -> Result<MintKeyset> {
|
||||
let url = format!("{}/v1/keys/{}", self.url, keyset_id);
|
||||
let res = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fetch a mint keyset")?;
|
||||
if !res.status().is_success() {
|
||||
anyhow::bail!("Mint keyset request failed: {}", res.status());
|
||||
}
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse mint keyset")?;
|
||||
let keysets: Vec<MintKeyset> = serde_json::from_value(
|
||||
body.get("keysets")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!([])),
|
||||
)
|
||||
.context("Failed to parse keyset")?;
|
||||
keysets
|
||||
.into_iter()
|
||||
.find(|k| k.id == keyset_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Mint did not return keyset {keyset_id}"))
|
||||
}
|
||||
|
||||
/// Get the active keyset for the "sat" unit.
|
||||
pub async fn get_active_sat_keyset(&self) -> Result<MintKeyset> {
|
||||
let keysets = self.get_keys().await?;
|
||||
// Must be a *sat* keyset, not merely the first one with keys. A
|
||||
// multi-unit mint answers /v1/keys with usd/eur/msat keysets too, and
|
||||
// whichever came first would then sign sat-denominated requests —
|
||||
// the mint rejects that with `11013 Unit unsupported` (seen against
|
||||
// testnut.cashu.space, 2026-08-17). Sat-only mints omit the field
|
||||
// entirely and default to "sat", so this stays correct for them.
|
||||
keysets
|
||||
.into_iter()
|
||||
.find(|k| {
|
||||
// Find active sat keyset — check keys map is non-empty
|
||||
!k.keys.is_empty()
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("No active keyset found at mint {}", self.url))
|
||||
.filter(|k| !k.keys.is_empty() && k.unit.eq_ignore_ascii_case("sat"))
|
||||
// Prefer a keyset the mint will still sign with.
|
||||
.max_by_key(|k| k.active)
|
||||
.ok_or_else(|| anyhow::anyhow!("No active sat keyset found at mint {}", self.url))
|
||||
}
|
||||
|
||||
// ── Mint quotes (NUT-04) ──
|
||||
@@ -243,21 +373,8 @@ impl MintClient {
|
||||
let keyset = self.get_active_sat_keyset().await?;
|
||||
let denominations = amount_to_denominations(amount);
|
||||
|
||||
let mut blinded_messages = Vec::new();
|
||||
let mut blinding_data = Vec::new(); // (secret, blinding_factor, amount)
|
||||
|
||||
for &denom in &denominations {
|
||||
let secret = bdhke::generate_secret();
|
||||
let r = bdhke::random_blinding_factor();
|
||||
let blinded = bdhke::blind_message(&secret, &r)?;
|
||||
|
||||
blinded_messages.push(BlindedMessageRequest {
|
||||
amount: denom,
|
||||
id: keyset.id.clone(),
|
||||
b_prime: hex::encode(blinded.b_prime.serialize()),
|
||||
});
|
||||
blinding_data.push((secret, r, denom));
|
||||
}
|
||||
let (blinded_messages, blinding_data) =
|
||||
self.blinded_outputs(&keyset.id, &denominations).await?;
|
||||
|
||||
let url = format!("{}/v1/mint/bolt11", self.url);
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -372,21 +489,39 @@ impl MintClient {
|
||||
pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result<SwapResult> {
|
||||
let keyset = self.get_active_sat_keyset().await?;
|
||||
|
||||
let mut blinded_messages = Vec::new();
|
||||
let mut blinding_data = Vec::new();
|
||||
// NUT-02: a mint may charge a per-input fee, and it rejects the swap
|
||||
// outright unless outputs == inputs - fee (`11005 Transaction inputs
|
||||
// should equal outputs less fee`). Applied here rather than at each
|
||||
// call site so send, receive and cross-mint swaps are all covered.
|
||||
// Fee-free mints (Minibits) compute 0 and are unaffected.
|
||||
let inputs_total: u64 = inputs.iter().map(|p| p.amount).sum();
|
||||
let fee = match self.get_keysets().await {
|
||||
Ok(ks) => super::cashu::swap_fee_for(inputs, &ks),
|
||||
Err(e) => {
|
||||
debug!("Could not read keyset fees ({e:#}) — assuming fee-free mint");
|
||||
0
|
||||
}
|
||||
};
|
||||
let spendable = inputs_total.saturating_sub(fee);
|
||||
let requested: u64 = target_amounts.iter().sum();
|
||||
let owned_targets: Vec<u64>;
|
||||
let target_amounts: &[u64] = if requested > spendable {
|
||||
if spendable == 0 {
|
||||
anyhow::bail!(
|
||||
"The mint's fee ({fee} sat) consumes this whole amount — nothing would be left"
|
||||
);
|
||||
}
|
||||
debug!(
|
||||
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
|
||||
);
|
||||
owned_targets = amount_to_denominations(spendable);
|
||||
&owned_targets
|
||||
} else {
|
||||
target_amounts
|
||||
};
|
||||
|
||||
for &amount in target_amounts {
|
||||
let secret = bdhke::generate_secret();
|
||||
let r = bdhke::random_blinding_factor();
|
||||
let blinded = bdhke::blind_message(&secret, &r)?;
|
||||
|
||||
blinded_messages.push(BlindedMessageRequest {
|
||||
amount,
|
||||
id: keyset.id.clone(),
|
||||
b_prime: hex::encode(blinded.b_prime.serialize()),
|
||||
});
|
||||
blinding_data.push((secret, r, amount));
|
||||
}
|
||||
let (blinded_messages, blinding_data) =
|
||||
self.blinded_outputs(&keyset.id, target_amounts).await?;
|
||||
|
||||
let url = format!("{}/v1/swap", self.url);
|
||||
let res = self
|
||||
@@ -481,8 +616,163 @@ impl MintClient {
|
||||
Ok(states)
|
||||
}
|
||||
|
||||
// ── Restore (NUT-09) ──
|
||||
|
||||
/// Ask the mint which of a batch of blinded messages it has signed before,
|
||||
/// and hand back its signatures for those.
|
||||
///
|
||||
/// This is the half of the backup story the mint owns. A NUT-13 phrase can
|
||||
/// re-derive every secret this wallet ever used, but not the mint's
|
||||
/// signature over them — without that a re-derived secret is not yet money.
|
||||
/// `/v1/restore` closes the gap: send the blinded messages again, get back
|
||||
/// the signatures the mint already issued, unblind, and the proofs exist
|
||||
/// again.
|
||||
///
|
||||
/// The response echoes the subset of `outputs` it recognised alongside the
|
||||
/// matching `signatures`, so the caller matches on `B_` rather than
|
||||
/// assuming positions line up — mints are free to return fewer, and
|
||||
/// assuming otherwise would pair a signature with the wrong secret and
|
||||
/// silently produce unspendable proofs.
|
||||
pub async fn restore(
|
||||
&self,
|
||||
outputs: &[BlindedMessageRequest],
|
||||
) -> Result<Vec<(String, BlindSignature)>> {
|
||||
if outputs.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let url = format!("{}/v1/restore", self.url);
|
||||
let res = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "outputs": outputs }))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to ask the mint to restore outputs")?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
// NUT-09 is optional. A mint that never implemented it answers 404
|
||||
// or 405, which `mint_error` would render as "mint returned 404
|
||||
// with no further detail" — true, and useless to someone trying to
|
||||
// get their coins back. Name the actual limitation instead.
|
||||
if matches!(status.as_u16(), 404 | 405 | 501) {
|
||||
anyhow::bail!(
|
||||
"This mint does not support restoring from a backup phrase (NUT-09). \
|
||||
Your coins are safe, but they can only be recovered from a wallet \
|
||||
file backup while they stay at {}",
|
||||
self.url
|
||||
);
|
||||
}
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
return Err(mint_error("Restore", status, &body));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = res
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse the mint's restore response")?;
|
||||
|
||||
let echoed: Vec<BlindedMessageRequest> = serde_json::from_value(
|
||||
body.get("outputs")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!([])),
|
||||
)
|
||||
.context("Failed to parse restored outputs")?;
|
||||
let signatures: Vec<BlindSignature> = serde_json::from_value(
|
||||
body.get("signatures")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!([])),
|
||||
)
|
||||
.context("Failed to parse restored signatures")?;
|
||||
|
||||
if echoed.len() != signatures.len() {
|
||||
anyhow::bail!(
|
||||
"Mint restored {} outputs but {} signatures — refusing to pair them",
|
||||
echoed.len(),
|
||||
signatures.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(echoed
|
||||
.into_iter()
|
||||
.map(|o| o.b_prime)
|
||||
.zip(signatures)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Receive a CashuToken by swapping its proofs for fresh ones.
|
||||
/// This prevents double-spend and ensures only we can spend the new proofs.
|
||||
/// Repair proofs whose keyset id is a truncated NUT-02 **v2** id.
|
||||
///
|
||||
/// A v2 keyset id is 33 bytes (version byte `0x01` + 32-byte hash), but
|
||||
/// wallets written against the original 8-byte format truncate it when
|
||||
/// they build a token. The mint then reads the `0x01` version, expects 33
|
||||
/// bytes, and rejects the swap — reported as
|
||||
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
|
||||
/// a Minibits-issued token, 2026-08-17).
|
||||
///
|
||||
/// The id only names which keyset signed the proof, so restoring the full
|
||||
/// id the mint advertises is exactly what the sender meant. It is also
|
||||
/// safe to attempt: an id that names the wrong keyset fails signature
|
||||
/// verification at the mint and no coins move. Anything already valid, or
|
||||
/// with no unambiguous match, is passed through untouched so the mint's
|
||||
/// own error is what the operator sees.
|
||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
|
||||
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
||||
if !needs_repair {
|
||||
return proofs.to_vec();
|
||||
}
|
||||
|
||||
// The mint's own keyset list, in the reference implementation's shape
|
||||
// so its NUT-02 resolver can consume it directly.
|
||||
let known = match self.get_cdk_keysets().await {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
debug!("Could not list keysets to repair truncated keyset ids: {e:#}");
|
||||
return proofs.to_vec();
|
||||
}
|
||||
};
|
||||
|
||||
proofs
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut p| {
|
||||
if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) {
|
||||
debug!("Expanded short keyset id {} to {} for swap", p.id, full);
|
||||
p.id = full;
|
||||
}
|
||||
p
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The mint's keysets as upstream `KeySetInfo`, for NUT-02 id resolution.
|
||||
async fn get_cdk_keysets(&self) -> Result<Vec<cashu::nuts::nut02::KeySetInfo>> {
|
||||
let url = format!("{}/v1/keysets", self.url);
|
||||
let res = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fetch mint keysets")?;
|
||||
if !res.status().is_success() {
|
||||
anyhow::bail!("Mint keysets request failed: {}", res.status());
|
||||
}
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse mint keysets")?;
|
||||
// Deserialize per-entry and keep what parses: a mint may advertise a
|
||||
// keyset in a unit or format this build doesn't model, and one such
|
||||
// entry must not block resolving the id we actually need.
|
||||
let list = body
|
||||
.get("keysets")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(list
|
||||
.into_iter()
|
||||
.filter_map(|v| serde_json::from_value::<cashu::nuts::nut02::KeySetInfo>(v).ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn receive_token(&self, token: &CashuToken) -> Result<Vec<Proof>> {
|
||||
let mut all_new_proofs = Vec::new();
|
||||
|
||||
@@ -498,7 +788,8 @@ impl MintClient {
|
||||
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(total);
|
||||
|
||||
let result = self.swap(&entry.proofs, &target_amounts).await?;
|
||||
let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await;
|
||||
let result = self.swap(&proofs, &target_amounts).await?;
|
||||
all_new_proofs.extend(result.new_proofs);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,4 +7,5 @@ pub mod cashu;
|
||||
pub mod ecash;
|
||||
pub mod fedimint_client;
|
||||
pub mod mint_client;
|
||||
pub mod nut13;
|
||||
pub mod profits;
|
||||
|
||||
@@ -0,0 +1,785 @@
|
||||
//! NUT-13 deterministic secrets — what makes the ecash wallet restorable.
|
||||
//!
|
||||
//! Until this module existed, every Cashu proof this node held was backed by a
|
||||
//! secret drawn from `OsRng` and written to exactly one file. Losing
|
||||
//! `wallet/ecash.json` lost the coins outright: there was no phrase to write
|
||||
//! down, and no amount of talking to the mint could reconstruct them. Ecash is
|
||||
//! a bearer instrument, so "one file, no backup" was the sharpest edge in the
|
||||
//! wallet.
|
||||
//!
|
||||
//! [NUT-13] fixes that by deriving each proof's secret and blinding factor
|
||||
//! from `(wallet seed, keyset id, counter)` instead of from randomness. The
|
||||
//! wallet is then a *phrase*, and the coins can be re-derived and re-claimed
|
||||
//! from the mint — here, or in any other NUT-13 wallet.
|
||||
//!
|
||||
//! Three pieces live here:
|
||||
//!
|
||||
//! - **The wallet seed** (`wallet/cashu_seed.json`) — a 24-word BIP-39
|
||||
//! mnemonic derived from the node's master seed, so the node's own recovery
|
||||
//! phrase already covers the ecash. See [`crate::seed::derive_cashu_mnemonic`]
|
||||
//! for why it is a *separate* phrase rather than the node's own.
|
||||
//! - **The counters** (`wallet/cashu_counters.json`) — the next unused counter
|
||||
//! per keyset. Recovery metadata, not funds: losing it costs a restore scan,
|
||||
//! never coins.
|
||||
//! - **The derivation itself** — delegated to the reference implementation, so
|
||||
//! the secrets a third-party wallet re-derives from these words are the same
|
||||
//! ones we did.
|
||||
//!
|
||||
//! ## Why the seed sits on disk in the clear
|
||||
//!
|
||||
//! The node's master seed is encrypted at rest and needs the operator's
|
||||
//! password to open, which no background mint/swap can ask for. This file is
|
||||
//! not encrypted, and that is deliberate: it lives in the same directory as
|
||||
//! `wallet/ecash.json`, which already holds spendable bearer secrets in
|
||||
//! plaintext. A NUT-13 seed regenerates exactly those same secrets, so it is
|
||||
//! the same sensitivity class as the file beside it — encrypting one and not
|
||||
//! the other would buy nothing. It is written 0600, matching
|
||||
//! `identity/nostr_secret`, which is derived and persisted the same way.
|
||||
//!
|
||||
//! [NUT-13]: https://github.com/cashubtc/nuts/blob/main/13.md
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bitcoin::secp256k1::SecretKey;
|
||||
use cashu::nuts::nut01::SecretKey as CdkSecretKey;
|
||||
use cashu::nuts::nut02::Id as CdkId;
|
||||
use cashu::secret::Secret as CdkSecret;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use tokio::fs;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// The wallet's BIP-39 phrase. One file for both networks: NUT-13 derivation
|
||||
/// is keyed by keyset id, and a testnet mint's keysets never collide with a
|
||||
/// real mint's, so the two purses cannot derive each other's secrets.
|
||||
const SEED_FILE: &str = "wallet/cashu_seed.json";
|
||||
/// Next-unused counter per keyset.
|
||||
const COUNTER_FILE: &str = "wallet/cashu_counters.json";
|
||||
|
||||
/// Serialises counter reservation within this process. Reservation is a
|
||||
/// read-modify-write of one small file, and two concurrent mints handing out
|
||||
/// the same counter would mean two proofs with the same secret — the mint
|
||||
/// signs both and only one is ever spendable.
|
||||
static COUNTER_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// On-disk shape of `wallet/cashu_seed.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct StoredSeed {
|
||||
/// The 24-word BIP-39 phrase.
|
||||
mnemonic: String,
|
||||
/// How this wallet got its phrase — see [`SeedSource`].
|
||||
#[serde(default)]
|
||||
source: SeedSource,
|
||||
/// When it was first written, for the operator's benefit.
|
||||
#[serde(default)]
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
/// Where an ecash wallet's phrase came from, which decides what restoring the
|
||||
/// *node* gets you back.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum SeedSource {
|
||||
/// Derived from the node's master seed. The node's 24 words restore this
|
||||
/// ecash wallet too — nothing extra to write down.
|
||||
#[default]
|
||||
NodeSeed,
|
||||
/// Generated independently of the node seed. Still a perfectly good
|
||||
/// NUT-13 wallet, but restoring the node from its recovery phrase will
|
||||
/// *not* bring it back — only these words will.
|
||||
Independent,
|
||||
/// Supplied by the operator, from another NUT-13 wallet. Same caveat as
|
||||
/// `Independent` — the node's recovery phrase does not cover it — but it
|
||||
/// is worth telling apart, because these words exist somewhere else too
|
||||
/// and the operator already knows where.
|
||||
Imported,
|
||||
}
|
||||
|
||||
impl SeedSource {
|
||||
/// Does restoring the *node* from its recovery phrase bring this wallet
|
||||
/// back? Only a derived phrase can promise that.
|
||||
pub fn covered_by_node_seed(&self) -> bool {
|
||||
matches!(self, Self::NodeSeed)
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded ecash wallet seed, ready to derive secrets from.
|
||||
#[derive(Clone)]
|
||||
pub struct EcashSeed {
|
||||
/// BIP-39 seed bytes — the NUT-13 input.
|
||||
seed: [u8; 64],
|
||||
mnemonic: bip39::Mnemonic,
|
||||
source: SeedSource,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EcashSeed {
|
||||
/// Never let the phrase or the seed bytes reach a log line.
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("EcashSeed")
|
||||
.field("source", &self.source)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl EcashSeed {
|
||||
fn from_mnemonic(mnemonic: bip39::Mnemonic, source: SeedSource) -> Self {
|
||||
Self {
|
||||
seed: mnemonic.to_seed(""),
|
||||
mnemonic,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/// The 24 words, for the backup screen. Everything else about this type
|
||||
/// keeps them out of reach.
|
||||
pub fn words(&self) -> Vec<String> {
|
||||
self.mnemonic.words().map(|w| w.to_string()).collect()
|
||||
}
|
||||
|
||||
pub fn source(&self) -> SeedSource {
|
||||
self.source
|
||||
}
|
||||
|
||||
/// Derive the NUT-13 secret and blinding factor for one output.
|
||||
///
|
||||
/// Delegated to the reference implementation rather than reimplemented:
|
||||
/// NUT-13 uses BIP-32 for v1 keyset ids and an HMAC-SHA256 KDF for v2, and
|
||||
/// getting either subtly wrong yields a wallet whose words restore
|
||||
/// *nothing* — a failure that only shows up on the day it matters.
|
||||
pub fn derive_output(&self, keyset_id: &str, counter: u32) -> Result<(Vec<u8>, SecretKey)> {
|
||||
let id = CdkId::from_str(keyset_id)
|
||||
.with_context(|| format!("Keyset id {keyset_id} is not one NUT-13 can derive for"))?;
|
||||
|
||||
let secret = CdkSecret::from_seed(&self.seed, id, counter)
|
||||
.context("NUT-13 secret derivation failed")?;
|
||||
let blinding = CdkSecretKey::from_seed(&self.seed, id, counter)
|
||||
.context("NUT-13 blinding-factor derivation failed")?;
|
||||
let blinding = SecretKey::from_slice(&blinding.to_secret_bytes())
|
||||
.context("NUT-13 produced a blinding factor secp256k1 rejects")?;
|
||||
|
||||
Ok((secret.to_bytes(), blinding))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EcashSeed {
|
||||
fn drop(&mut self) {
|
||||
use zeroize::Zeroize;
|
||||
self.seed.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(SEED_FILE)
|
||||
}
|
||||
|
||||
/// Is this wallet backed by a phrase yet?
|
||||
pub fn seed_exists(data_dir: &Path) -> bool {
|
||||
seed_path(data_dir).exists()
|
||||
}
|
||||
|
||||
/// Load the wallet seed, or `None` if this node has never established one.
|
||||
///
|
||||
/// A *damaged* seed file is an error, not a `None`: silently treating it as
|
||||
/// "no seed" would send the wallet back to unrecoverable random secrets while
|
||||
/// telling the operator their backup was fine.
|
||||
pub async fn load_seed(data_dir: &Path) -> Result<Option<EcashSeed>> {
|
||||
let path = seed_path(data_dir);
|
||||
let Ok(content) = fs::read_to_string(&path).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
let stored: StoredSeed = serde_json::from_str(&content)
|
||||
.with_context(|| format!("The ecash seed file is damaged: {}", path.display()))?;
|
||||
let mnemonic: bip39::Mnemonic = stored
|
||||
.mnemonic
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("The stored ecash phrase is not valid BIP-39: {e}"))?;
|
||||
Ok(Some(EcashSeed::from_mnemonic(mnemonic, stored.source)))
|
||||
}
|
||||
|
||||
/// Establish the wallet seed from the node's master seed, writing it if this
|
||||
/// node does not have one yet.
|
||||
///
|
||||
/// Idempotent, and deliberately **never overwrites**: an existing phrase is
|
||||
/// the only thing that can re-derive the proofs already minted under it, so a
|
||||
/// re-derivation that disagreed (a different master seed after a restore from
|
||||
/// different words, say) must not be allowed to replace it. The existing seed
|
||||
/// is returned instead, and the mismatch is logged.
|
||||
pub async fn establish_from_master(
|
||||
data_dir: &Path,
|
||||
master: &crate::seed::MasterSeed,
|
||||
) -> Result<EcashSeed> {
|
||||
let derived = crate::seed::derive_cashu_mnemonic(master)?;
|
||||
|
||||
if let Some(existing) = load_seed(data_dir).await? {
|
||||
if existing.mnemonic != derived {
|
||||
warn!(
|
||||
"The ecash wallet's phrase does not match the one this node's master seed \
|
||||
derives — keeping the existing phrase, because it is what the current \
|
||||
proofs were minted under. Back it up from Settings; the node's own \
|
||||
recovery phrase does not cover this wallet."
|
||||
);
|
||||
}
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
write_seed(data_dir, &derived, SeedSource::NodeSeed).await?;
|
||||
debug!("Established the ecash wallet seed from the node master seed");
|
||||
Ok(EcashSeed::from_mnemonic(derived, SeedSource::NodeSeed))
|
||||
}
|
||||
|
||||
/// Establish a wallet seed that is **not** derived from the node's master
|
||||
/// seed, for a node that has no encrypted master seed to derive from.
|
||||
///
|
||||
/// Plenty of nodes are in that position: `identity/master_seed.enc` is written
|
||||
/// during onboarding, and any node onboarded before that step existed simply
|
||||
/// does not have one. The choice there is not "derived phrase or independent
|
||||
/// phrase" — it is "independent phrase or **no backup at all**", and a wallet
|
||||
/// whose coins can be restored from words the operator holds is strictly
|
||||
/// better than one whose coins die with a single file.
|
||||
///
|
||||
/// The cost is stated plainly rather than hidden: the phrase is recorded as
|
||||
/// [`SeedSource::Independent`], and every surface that shows it says that
|
||||
/// restoring the node will *not* bring this wallet back — only these words
|
||||
/// will. That is a real obligation on the operator, so it must never be the
|
||||
/// silent default when derivation was possible; [`establish_from_master`] is
|
||||
/// what a node with a master seed gets.
|
||||
pub async fn establish_independent(data_dir: &Path) -> Result<EcashSeed> {
|
||||
if let Some(existing) = load_seed(data_dir).await? {
|
||||
return Ok(existing);
|
||||
}
|
||||
// Same guarded generation path as the node's own seed: a named CSPRNG and
|
||||
// the degenerate-entropy check, not a dependency's default (KEY-05).
|
||||
let (mnemonic, _seed) = crate::seed::MasterSeed::generate()?;
|
||||
write_seed(data_dir, &mnemonic, SeedSource::Independent).await?;
|
||||
warn!(
|
||||
"Established an INDEPENDENT ecash backup phrase: this node has no encrypted \
|
||||
master seed to derive one from, so restoring the node will not restore this \
|
||||
ecash wallet — only the phrase itself will."
|
||||
);
|
||||
Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Independent))
|
||||
}
|
||||
|
||||
/// Adopt a phrase the operator supplies, from another NUT-13 wallet.
|
||||
///
|
||||
/// This is the "bring your own" path: it points the wallet at someone else's
|
||||
/// derivation, which is what makes coins held in Minibits, Nutstash or
|
||||
/// `cdk-cli` restorable here.
|
||||
///
|
||||
/// Replacing a phrase is the one genuinely lossy thing this module can do.
|
||||
/// Coins already in `wallet/ecash.json` stay spendable — they are proofs, not
|
||||
/// derivations, and nothing here touches them — but they were minted under
|
||||
/// the *old* phrase, so a future restore will no longer find them. The old
|
||||
/// phrase is therefore archived rather than overwritten, and replacing an
|
||||
/// established one needs `confirm`. An operator who imports by mistake must
|
||||
/// not lose the only copy of the words their balance was minted under.
|
||||
///
|
||||
/// Counters are deliberately left alone. They are per-keyset and seed-
|
||||
/// relative, so under a new seed they merely start high — which costs nothing,
|
||||
/// since a restore scans from zero regardless. Resetting them would be the
|
||||
/// dangerous choice if the imported phrase turned out to be the one already
|
||||
/// in use.
|
||||
pub async fn import_mnemonic(data_dir: &Path, words: &str, confirm: bool) -> Result<EcashSeed> {
|
||||
let mnemonic: bip39::Mnemonic = words
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.parse()
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"That is not a valid BIP-39 recovery phrase: {e}. Check for typos — \
|
||||
every word must come from the BIP-39 word list, and the phrase as \
|
||||
a whole carries a checksum."
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(existing) = load_seed(data_dir).await? {
|
||||
if existing.mnemonic == mnemonic {
|
||||
// Importing the phrase already in use: nothing to do, and
|
||||
// certainly nothing to archive.
|
||||
return Ok(existing);
|
||||
}
|
||||
if !confirm {
|
||||
anyhow::bail!(
|
||||
"This wallet already has a backup phrase. Importing a different one \
|
||||
means coins minted under the current phrase will no longer be \
|
||||
restorable from words — they stay spendable, but a restore will \
|
||||
not find them. Reveal and write down the current phrase first, \
|
||||
then confirm to replace it."
|
||||
);
|
||||
}
|
||||
archive_seed(data_dir).await?;
|
||||
}
|
||||
|
||||
write_seed(data_dir, &mnemonic, SeedSource::Imported).await?;
|
||||
warn!("Ecash backup phrase REPLACED by an imported one (the previous phrase, if any, was archived)");
|
||||
Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Imported))
|
||||
}
|
||||
|
||||
/// Move the current seed file aside, timestamped, before it is replaced.
|
||||
///
|
||||
/// Never deleted and never overwritten: this file may be the last copy of the
|
||||
/// words a balance was minted under, and the whole point of the module is that
|
||||
/// such a thing is not casually destroyed.
|
||||
async fn archive_seed(data_dir: &Path) -> Result<()> {
|
||||
let from = seed_path(data_dir);
|
||||
if !from.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
|
||||
let to = data_dir.join(format!("wallet/cashu_seed.replaced-{stamp}.json"));
|
||||
fs::rename(&from, &to).await.with_context(|| {
|
||||
format!(
|
||||
"Could not archive the previous ecash phrase to {}",
|
||||
to.display()
|
||||
)
|
||||
})?;
|
||||
warn!("Previous ecash phrase archived to {}", to.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the seed file at 0600, creating the wallet directory if needed.
|
||||
async fn write_seed(data_dir: &Path, mnemonic: &bip39::Mnemonic, source: SeedSource) -> Result<()> {
|
||||
let path = seed_path(data_dir);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.context("Failed to create the wallet directory")?;
|
||||
}
|
||||
let stored = StoredSeed {
|
||||
mnemonic: mnemonic.to_string(),
|
||||
source,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
let content =
|
||||
serde_json::to_string_pretty(&stored).context("Failed to serialize the ecash seed")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write the ecash seed")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.await
|
||||
.context("Failed to restrict permissions on the ecash seed")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Counters ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// On-disk shape of `wallet/cashu_counters.json`.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct StoredCounters {
|
||||
/// keyset id → next unused counter.
|
||||
#[serde(default)]
|
||||
counters: BTreeMap<String, u32>,
|
||||
}
|
||||
|
||||
/// Reserve `count` consecutive counters for `keyset_id` and return the first.
|
||||
///
|
||||
/// Written to disk **before** the outputs are used, and never rolled back on
|
||||
/// failure. A gap in the sequence costs a restore scan a few extra probes; a
|
||||
/// *reused* counter costs a coin, because two proofs with the same secret can
|
||||
/// only ever be spent once. So the asymmetry is resolved in favour of gaps.
|
||||
pub async fn reserve_counters(data_dir: &Path, keyset_id: &str, count: usize) -> Result<u32> {
|
||||
let _guard = COUNTER_LOCK.lock().await;
|
||||
let path = data_dir.join(COUNTER_FILE);
|
||||
|
||||
let mut state: StoredCounters = match fs::read_to_string(&path).await {
|
||||
Ok(content) if !content.trim().is_empty() => serde_json::from_str(&content)
|
||||
.with_context(|| format!("The ecash counter file is damaged: {}", path.display()))?,
|
||||
_ => StoredCounters::default(),
|
||||
};
|
||||
|
||||
let start = *state.counters.get(keyset_id).unwrap_or(&0);
|
||||
let next = start
|
||||
.checked_add(u32::try_from(count).context("Absurd output count")?)
|
||||
.context("NUT-13 counter space exhausted for this keyset")?;
|
||||
state.counters.insert(keyset_id.to_string(), next);
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.context("Failed to create the wallet directory")?;
|
||||
}
|
||||
let content =
|
||||
serde_json::to_string_pretty(&state).context("Failed to serialize ecash counters")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to persist ecash counters")?;
|
||||
|
||||
Ok(start)
|
||||
}
|
||||
|
||||
/// Read the next-unused counter for a keyset without reserving anything.
|
||||
pub async fn counter_for(data_dir: &Path, keyset_id: &str) -> u32 {
|
||||
let path = data_dir.join(COUNTER_FILE);
|
||||
let Ok(content) = fs::read_to_string(&path).await else {
|
||||
return 0;
|
||||
};
|
||||
serde_json::from_str::<StoredCounters>(&content)
|
||||
.ok()
|
||||
.and_then(|s| s.counters.get(keyset_id).copied())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Move a keyset's counter forward to at least `next`, so a restore that found
|
||||
/// coins beyond the recorded point cannot hand the same counters out again.
|
||||
pub async fn advance_counter_to(data_dir: &Path, keyset_id: &str, next: u32) -> Result<()> {
|
||||
let current = counter_for(data_dir, keyset_id).await;
|
||||
if next > current {
|
||||
reserve_counters(data_dir, keyset_id, (next - current) as usize).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── The source handed to the mint client ───────────────────────────────────
|
||||
|
||||
/// Supplies NUT-13 outputs to [`crate::wallet::mint_client::MintClient`].
|
||||
///
|
||||
/// Holds the data directory as well as the seed because reserving a counter is
|
||||
/// a disk write that has to happen before the outputs are handed out.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecoverySource {
|
||||
seed: EcashSeed,
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RecoverySource {
|
||||
/// Build a recovery source for this node, or `None` when the wallet has no
|
||||
/// seed yet. Callers fall back to random secrets in that case, which is
|
||||
/// exactly the pre-NUT-13 behaviour — correct, just not restorable.
|
||||
pub async fn load(data_dir: &Path) -> Option<Self> {
|
||||
match load_seed(data_dir).await {
|
||||
Ok(Some(seed)) => Some(Self {
|
||||
seed,
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
}),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!("Ecash wallet seed unusable, minting unrecoverable proofs: {e:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserve and derive `count` outputs for `keyset_id`.
|
||||
pub async fn next_outputs(
|
||||
&self,
|
||||
keyset_id: &str,
|
||||
count: usize,
|
||||
) -> Result<Vec<(Vec<u8>, SecretKey)>> {
|
||||
// Fail the derivation *before* burning counters if this keyset id is
|
||||
// one NUT-13 cannot address.
|
||||
let start = reserve_counters(&self.data_dir, keyset_id, count).await?;
|
||||
(0..count)
|
||||
.map(|i| self.seed.derive_output(keyset_id, start + i as u32))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Derive one output at an explicit counter, without reserving — the
|
||||
/// restore scan's probe, which must be able to re-derive the past.
|
||||
pub fn derive_at(&self, keyset_id: &str, counter: u32) -> Result<(Vec<u8>, SecretKey)> {
|
||||
self.seed.derive_output(keyset_id, counter)
|
||||
}
|
||||
|
||||
pub fn data_dir(&self) -> &Path {
|
||||
&self.data_dir
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::seed::MasterSeed;
|
||||
|
||||
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
|
||||
/// A real NUT-02 v1 keyset id (the one in the NUT test vectors).
|
||||
const V1_KEYSET: &str = "009a1f293253e41e";
|
||||
/// A NUT-02 v2 keyset id — 33 bytes, version byte 0x01. The two versions
|
||||
/// take different derivation paths in the spec, so both need covering.
|
||||
const V2_KEYSET: &str = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
|
||||
|
||||
fn seed() -> EcashSeed {
|
||||
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let mnemonic = crate::seed::derive_cashu_mnemonic(&master).unwrap();
|
||||
EcashSeed::from_mnemonic(mnemonic, SeedSource::NodeSeed)
|
||||
}
|
||||
|
||||
/// The whole promise of NUT-13: the same phrase and counter must give back
|
||||
/// the same secret, or a restore finds nothing.
|
||||
#[test]
|
||||
fn the_same_phrase_and_counter_rederive_the_same_output() {
|
||||
let a = seed();
|
||||
let b = seed();
|
||||
for keyset in [V1_KEYSET, V2_KEYSET] {
|
||||
let (s1, r1) = a.derive_output(keyset, 7).unwrap();
|
||||
let (s2, r2) = b.derive_output(keyset, 7).unwrap();
|
||||
assert_eq!(s1, s2, "secret must be reproducible ({keyset})");
|
||||
assert_eq!(
|
||||
r1.secret_bytes(),
|
||||
r2.secret_bytes(),
|
||||
"blinding factor must be reproducible ({keyset})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Different counters — and different keysets — must not collide, or two
|
||||
/// proofs would share a secret and only one could ever be spent.
|
||||
#[test]
|
||||
fn different_counters_and_keysets_give_different_outputs() {
|
||||
let s = seed();
|
||||
let (a, _) = s.derive_output(V1_KEYSET, 0).unwrap();
|
||||
let (b, _) = s.derive_output(V1_KEYSET, 1).unwrap();
|
||||
let (c, _) = s.derive_output(V2_KEYSET, 0).unwrap();
|
||||
assert_ne!(a, b, "counter must separate secrets");
|
||||
assert_ne!(a, c, "keyset must separate secrets");
|
||||
}
|
||||
|
||||
/// The secret must look like the one the rest of the wallet expects: a
|
||||
/// 32-byte value, hex-encoded, carried as ASCII bytes — the same shape
|
||||
/// `bdhke::generate_secret` produces.
|
||||
#[test]
|
||||
fn a_derived_secret_has_the_shape_the_wallet_already_uses() {
|
||||
let (secret, _) = seed().derive_output(V1_KEYSET, 0).unwrap();
|
||||
assert_eq!(secret.len(), 64, "32 bytes, hex-encoded");
|
||||
let text = String::from_utf8(secret).expect("secret must be ASCII hex");
|
||||
assert!(hex::decode(&text).is_ok(), "{text}");
|
||||
}
|
||||
|
||||
/// A truncated v2 id cannot address a keyset, and must fail loudly rather
|
||||
/// than deriving from a prefix that means nothing.
|
||||
#[test]
|
||||
fn an_unaddressable_keyset_id_is_refused() {
|
||||
let err = seed()
|
||||
.derive_output("01fc0ec0e59cd6fa", 0)
|
||||
.expect_err("short v2 id must not derive");
|
||||
assert!(err.to_string().contains("NUT-13"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn counters_are_reserved_in_order_and_never_reused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
|
||||
assert_eq!(reserve_counters(d, V1_KEYSET, 3).await.unwrap(), 0);
|
||||
assert_eq!(reserve_counters(d, V1_KEYSET, 2).await.unwrap(), 3);
|
||||
assert_eq!(counter_for(d, V1_KEYSET).await, 5);
|
||||
|
||||
// A second keyset counts independently.
|
||||
assert_eq!(reserve_counters(d, V2_KEYSET, 1).await.unwrap(), 0);
|
||||
assert_eq!(counter_for(d, V1_KEYSET).await, 5);
|
||||
}
|
||||
|
||||
/// Reservation must survive a process restart — the file is the state.
|
||||
#[tokio::test]
|
||||
async fn reserved_counters_persist_across_reloads() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
reserve_counters(d, V1_KEYSET, 4).await.unwrap();
|
||||
// Nothing cached in memory: read it back cold.
|
||||
assert_eq!(counter_for(d, V1_KEYSET).await, 4);
|
||||
assert_eq!(reserve_counters(d, V1_KEYSET, 1).await.unwrap(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn establishing_the_seed_is_idempotent_and_never_overwrites() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
|
||||
assert!(!seed_exists(d));
|
||||
let first = establish_from_master(d, &master).await.unwrap();
|
||||
assert!(seed_exists(d));
|
||||
assert_eq!(first.source(), SeedSource::NodeSeed);
|
||||
|
||||
let second = establish_from_master(d, &master).await.unwrap();
|
||||
assert_eq!(first.words(), second.words());
|
||||
|
||||
// A *different* master seed must not replace the phrase the existing
|
||||
// proofs were minted under.
|
||||
let (other_words, _) = MasterSeed::generate().unwrap();
|
||||
let (_, other_master) = MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
|
||||
let third = establish_from_master(d, &other_master).await.unwrap();
|
||||
assert_eq!(
|
||||
first.words(),
|
||||
third.words(),
|
||||
"an established ecash phrase must never be silently replaced"
|
||||
);
|
||||
}
|
||||
|
||||
/// A phrase from another wallet must derive that wallet's secrets — that
|
||||
/// is the entire point of importing one.
|
||||
#[tokio::test]
|
||||
async fn an_imported_phrase_derives_the_other_wallets_secrets() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
|
||||
// Stand in for the other wallet: a known phrase and what it derives.
|
||||
let theirs: bip39::Mnemonic = TEST_MNEMONIC.parse().unwrap();
|
||||
let expected = EcashSeed::from_mnemonic(theirs.clone(), SeedSource::Imported)
|
||||
.derive_output(V1_KEYSET, 3)
|
||||
.unwrap();
|
||||
|
||||
let imported = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
|
||||
assert_eq!(imported.source(), SeedSource::Imported);
|
||||
assert!(!imported.source().covered_by_node_seed());
|
||||
assert_eq!(imported.derive_output(V1_KEYSET, 3).unwrap().0, expected.0);
|
||||
|
||||
// And it is what the wallet uses from now on.
|
||||
let reloaded = load_seed(d).await.unwrap().expect("persisted");
|
||||
assert_eq!(reloaded.words(), imported.words());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn importing_over_an_established_phrase_needs_confirmation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let original = establish_from_master(d, &master).await.unwrap();
|
||||
let original_words = original.words();
|
||||
|
||||
// Refused without confirmation — replacing a phrase silently would
|
||||
// orphan every coin minted under it.
|
||||
let (other, _) = MasterSeed::generate().unwrap();
|
||||
let err = import_mnemonic(d, &other.to_string(), false)
|
||||
.await
|
||||
.expect_err("must not replace without confirmation");
|
||||
assert!(
|
||||
err.to_string().contains("already has a backup phrase"),
|
||||
"{err}"
|
||||
);
|
||||
assert_eq!(
|
||||
load_seed(d).await.unwrap().unwrap().words(),
|
||||
original_words,
|
||||
"a refused import must change nothing"
|
||||
);
|
||||
|
||||
// Confirmed: replaced, and the old phrase archived rather than lost.
|
||||
import_mnemonic(d, &other.to_string(), true).await.unwrap();
|
||||
assert_eq!(
|
||||
load_seed(d).await.unwrap().unwrap().words(),
|
||||
other.words().map(|w| w.to_string()).collect::<Vec<_>>()
|
||||
);
|
||||
let archived: Vec<_> = std::fs::read_dir(d.join("wallet"))
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("cashu_seed.replaced-")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(archived.len(), 1, "the replaced phrase must be kept");
|
||||
}
|
||||
|
||||
/// Re-importing the phrase already in use is a no-op, not a replacement —
|
||||
/// it must not archive anything or churn the file.
|
||||
#[tokio::test]
|
||||
async fn importing_the_current_phrase_changes_nothing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
let first = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
|
||||
let again = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
|
||||
assert_eq!(first.words(), again.words());
|
||||
let archived = std::fs::read_dir(d.join("wallet"))
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("cashu_seed.replaced-")
|
||||
})
|
||||
.count();
|
||||
assert_eq!(archived, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_malformed_phrase_is_refused_with_something_actionable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
// Right shape, wrong checksum — the commonest real mistake.
|
||||
let bad = TEST_MNEMONIC.replace(" art", " abandon");
|
||||
let err = import_mnemonic(d, &bad, false).await.expect_err("checksum");
|
||||
assert!(err.to_string().contains("BIP-39"), "{err}");
|
||||
assert!(!seed_exists(d), "a rejected phrase must not be written");
|
||||
|
||||
assert!(import_mnemonic(d, "not a phrase", false).await.is_err());
|
||||
assert!(import_mnemonic(d, "", false).await.is_err());
|
||||
}
|
||||
|
||||
/// Whitespace and casing vary wildly in what people paste out of other
|
||||
/// wallets; the words are what matter.
|
||||
#[tokio::test]
|
||||
async fn a_pasted_phrase_survives_untidy_whitespace() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
let messy = format!(" {} ", TEST_MNEMONIC.replace(' ', "\n "));
|
||||
let imported = import_mnemonic(d, &messy, false).await.unwrap();
|
||||
assert_eq!(imported.words().len(), 24);
|
||||
assert_eq!(imported.words().join(" "), TEST_MNEMONIC);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_seed_file_is_owner_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
establish_from_master(d, &master).await.unwrap();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(seed_path(d))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600, "the ecash phrase must be owner-only");
|
||||
}
|
||||
}
|
||||
|
||||
/// A damaged seed file must not read back as "this wallet has no backup" —
|
||||
/// that would quietly return the wallet to unrecoverable random secrets.
|
||||
#[tokio::test]
|
||||
async fn a_damaged_seed_file_is_an_error_not_an_absence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
fs::create_dir_all(d.join("wallet")).await.unwrap();
|
||||
fs::write(seed_path(d), "{ truncated").await.unwrap();
|
||||
|
||||
assert!(load_seed(d).await.is_err());
|
||||
assert!(
|
||||
RecoverySource::load(d).await.is_none(),
|
||||
"an unusable seed must not be presented as a working one"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_recovery_source_hands_out_consecutive_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
establish_from_master(d, &master).await.unwrap();
|
||||
|
||||
let source = RecoverySource::load(d).await.expect("seed was established");
|
||||
let first = source.next_outputs(V1_KEYSET, 2).await.unwrap();
|
||||
let second = source.next_outputs(V1_KEYSET, 2).await.unwrap();
|
||||
|
||||
assert_eq!(first.len(), 2);
|
||||
// Counters advanced, so no secret repeats across the two batches.
|
||||
let secrets: std::collections::HashSet<_> = first
|
||||
.iter()
|
||||
.chain(second.iter())
|
||||
.map(|(s, _)| s.clone())
|
||||
.collect();
|
||||
assert_eq!(secrets.len(), 4, "counters must not be handed out twice");
|
||||
|
||||
// And the batch is exactly what re-deriving counters 0..4 gives.
|
||||
for (i, (secret, _)) in first.iter().chain(second.iter()).enumerate() {
|
||||
let (expected, _) = source.derive_at(V1_KEYSET, i as u32).unwrap();
|
||||
assert_eq!(secret, &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,6 +561,21 @@ pub enum PortAuth {
|
||||
/// manifest to say so means the loopback pin and the daemon takeover
|
||||
/// ship together, atomically, and a stale manifest fails safe.
|
||||
Gated,
|
||||
/// Like `gated` — loopback-pinned app, daemon owns the external
|
||||
/// addresses — but the gate does NOT require the dashboard login.
|
||||
///
|
||||
/// For apps that carry a complete login of their own and are broken by
|
||||
/// an upstream challenge: Gitea (git clients speak basic-auth, not
|
||||
/// cookies), BTCPay (checkout pages must be reachable by anonymous
|
||||
/// payers). The gate still fronts the port — frame-header neutralising,
|
||||
/// the app-down retry page, the Tor upstream — it just lets every
|
||||
/// request through to the app's own authentication. Requires
|
||||
/// `auth_rationale`, exactly like `none`: an unchallenged surface nobody
|
||||
/// can explain is one nobody reviewed. The operator can flip any
|
||||
/// gate-fronted app between `gated` and `open` behaviour at runtime
|
||||
/// (Settings → app → App gate; stored node-side, manifest sets the
|
||||
/// default).
|
||||
Open,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -1154,6 +1169,20 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
|
||||
"ports[{i}].auth_rationale cannot be empty"
|
||||
)));
|
||||
}
|
||||
// `open` serves the app without the gate's login challenge, so it
|
||||
// carries the same burden of proof as `none`.
|
||||
(PortAuth::Open, None) => {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports[{i}] sets auth: open but no auth_rationale — a port served \
|
||||
without the gate's login must state why (typically: the app \
|
||||
enforces its own authentication)"
|
||||
)));
|
||||
}
|
||||
(PortAuth::Open, Some(rationale)) if rationale.trim().is_empty() => {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports[{i}].auth_rationale cannot be empty"
|
||||
)));
|
||||
}
|
||||
// A rationale on a gated port means the author wrote an
|
||||
// exemption and did not get one. Silently keeping the port
|
||||
// protected would be safe but misleading, so say so.
|
||||
@@ -1717,6 +1746,21 @@ app:
|
||||
}
|
||||
}
|
||||
exempt.sort();
|
||||
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
|
||||
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
|
||||
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
|
||||
// upstream's own safe-for-public
|
||||
// subset that wallets connect to directly as a "remote node" over
|
||||
// plain HTTP JSON-RPC — same reasoning as electrumx's 50001).
|
||||
// cuprate's unrestricted RPC (full node control) stays loopback-only
|
||||
// (auth: local), not in this set.
|
||||
//
|
||||
// 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a
|
||||
// loopback-only JSON API whose own generated http password
|
||||
// authenticates every request (added with the phoenixd onboarding,
|
||||
// which did not update this count — exactly the drift this test
|
||||
// exists to catch).
|
||||
//
|
||||
// 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2),
|
||||
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
|
||||
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack
|
||||
@@ -1727,11 +1771,50 @@ app:
|
||||
// stage timed out that cycle, so the count here lagged at 17.
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
25,
|
||||
28,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `auth: open` ports are served by the gate WITHOUT its login challenge,
|
||||
/// so they are the second unauthenticated-by-the-gate surface and get the
|
||||
/// same review guard as `auth: none`. Each one must be an app that
|
||||
/// enforces a real login of its own.
|
||||
#[test]
|
||||
fn gate_open_ports_are_all_accounted_for() {
|
||||
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
|
||||
let Ok(entries) = std::fs::read_dir(&apps) else {
|
||||
return;
|
||||
};
|
||||
let mut open: Vec<(String, u16)> = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let manifest = entry.path().join("manifest.yml");
|
||||
if !manifest.is_file() {
|
||||
continue;
|
||||
}
|
||||
let yaml = std::fs::read_to_string(&manifest).expect("manifest readable");
|
||||
let parsed = AppManifest::parse(&yaml).expect("manifest valid");
|
||||
for port in &parsed.app.ports {
|
||||
if port.auth_policy() == PortAuth::Open {
|
||||
open.push((parsed.app.id.clone(), port.host));
|
||||
}
|
||||
}
|
||||
}
|
||||
open.sort();
|
||||
// Gitea 3001 (git clients speak basic-auth, not browser cookies) and
|
||||
// BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable
|
||||
// by anonymous payers). Both enforce their own account login, and an
|
||||
// operator can re-gate either from Settings → Access control.
|
||||
assert_eq!(
|
||||
open,
|
||||
vec![
|
||||
("btcpay-server".to_string(), 23000u16),
|
||||
("gitea".to_string(), 3001u16)
|
||||
],
|
||||
"gate-open port set changed — every entry must be an app with its own login"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undeclared_port_classifies_as_session_but_is_not_declared() {
|
||||
// Two different questions, and conflating them caused both gate
|
||||
|
||||
@@ -29,8 +29,10 @@ impl Router {
|
||||
.with_context(|| format!("no address for {}", addr))?;
|
||||
let tcp = TcpStream::connect_timeout(&resolved, std::time::Duration::from_secs(5))
|
||||
.with_context(|| format!("TCP connect to {}", addr))?;
|
||||
tcp.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok();
|
||||
tcp.set_write_timeout(Some(std::time::Duration::from_secs(30))).ok();
|
||||
tcp.set_read_timeout(Some(std::time::Duration::from_secs(30)))
|
||||
.ok();
|
||||
tcp.set_write_timeout(Some(std::time::Duration::from_secs(30)))
|
||||
.ok();
|
||||
Ok(tcp)
|
||||
}
|
||||
|
||||
|
||||
@@ -190,6 +190,16 @@
|
||||
.text-white-40 { color: rgba(255,255,255,0.4); }
|
||||
.text-green { color: #4ade80; } .text-orange { color: #fb923c; } .text-red { color: #f87171; }
|
||||
.text-purple { color: #a78bfa; } .text-yellow { color: #facc15; } .text-btc { color: #f7931a; }
|
||||
|
||||
/* Pixel readout shown in place of a balance that isn't known yet.
|
||||
An unloaded balance used to render as "0 sats" — zero is a number,
|
||||
not a loading state, and it is the one number that frightens
|
||||
people. It inherits currentColor, so each tile shimmers in its own
|
||||
rail colour. */
|
||||
.bal-pixels { display: inline-grid; grid-auto-flow: column; grid-template-rows: repeat(3, 4px); grid-auto-columns: 4px; gap: 1px; vertical-align: 0.1em; }
|
||||
.bal-pixels i { width: 4px; height: 4px; border-radius: 0.5px; background: currentColor; opacity: 0.16; animation: bal-pixel-scan 1.6s ease-in-out infinite; }
|
||||
@keyframes bal-pixel-scan { 0%, 70%, 100% { opacity: 0.16; } 25% { opacity: 1; } 45% { opacity: 0.42; } }
|
||||
@media (prefers-reduced-motion: reduce) { .bal-pixels i { animation: none; opacity: 0.35; } }
|
||||
.bg-green { background: #4ade80; } .bg-yellow { background: #facc15; } .bg-red { background: #f87171; }
|
||||
.bg-grey { background: rgba(255,255,255,0.35); }
|
||||
|
||||
@@ -1070,6 +1080,25 @@
|
||||
}
|
||||
|
||||
function setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; }
|
||||
|
||||
// 28 cells = 14 columns x 2 rows, delays staggered so the lit column
|
||||
// travels across the matrix.
|
||||
// 14 columns x 3 rows, laid out column-first so the three cells of a
|
||||
// column share a delay and the lit column scans across as one line.
|
||||
const BAL_PIXELS = '<span class="bal-pixels" role="status" aria-label="Loading balance">' +
|
||||
Array.from({ length: 42 }, function (_, i) {
|
||||
return '<i style="animation-delay:' + (Math.floor(i / 3) * 55) + 'ms"></i>';
|
||||
}).join('') + '</span>';
|
||||
|
||||
// Render a balance, or the pixel readout when it is not known yet.
|
||||
// `sats` must be null/undefined for "not loaded" — passing 0 here
|
||||
// means the node genuinely has nothing, and says so.
|
||||
function setBalance(id, sats) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
if (sats === null || sats === undefined) { el.innerHTML = BAL_PIXELS; return; }
|
||||
el.textContent = fmtAmount(sats);
|
||||
}
|
||||
function setHtml(id, html) { const el = document.getElementById(id); if (el) el.innerHTML = html; }
|
||||
|
||||
// Classify a peer address the way Umbrel's peers table does.
|
||||
@@ -1216,12 +1245,22 @@
|
||||
const lnRemote = num(cb && ((cb.remote_balance && cb.remote_balance.sat) ?? 0));
|
||||
const lnPending = num(cb && ((cb.pending_open_local_balance && cb.pending_open_local_balance.sat) ?? 0));
|
||||
|
||||
setText('balTotal', fmtAmount(onchainConfirmed + lnLocal));
|
||||
setText('balTotalSub', state.onchain || cb ? 'on-chain + lightning' : 'balances unavailable');
|
||||
setText('balLightning', fmtAmount(lnLocal));
|
||||
setText('balLightningSub', lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
|
||||
setText('balOnchain', fmtAmount(onchainConfirmed));
|
||||
setText('balOnchainSub', onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
|
||||
// This function runs on every poll, including before the first
|
||||
// response lands — at which point `state.onchain`/`state.chanbal`
|
||||
// are still null and every figure below computed to 0. The tiles
|
||||
// therefore claimed a zero balance on load. A rail with no data
|
||||
// yet gets the pixel readout instead.
|
||||
const haveOnchain = !!state.onchain;
|
||||
const haveChan = !!cb;
|
||||
|
||||
setBalance('balTotal', haveOnchain || haveChan ? onchainConfirmed + lnLocal : null);
|
||||
setText('balTotalSub', haveOnchain || haveChan ? 'on-chain + lightning' : 'waiting for LND');
|
||||
setBalance('balLightning', haveChan ? lnLocal : null);
|
||||
setText('balLightningSub', !haveChan ? 'waiting for LND'
|
||||
: lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
|
||||
setBalance('balOnchain', haveOnchain ? onchainConfirmed : null);
|
||||
setText('balOnchainSub', !haveOnchain ? 'waiting for LND'
|
||||
: onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
|
||||
|
||||
setText('liqLocal', fmtAmount(lnLocal));
|
||||
setText('liqRemote', fmtAmount(lnRemote));
|
||||
|
||||
@@ -152,6 +152,21 @@ know the mechanics:
|
||||
policy, only its framing policy. You do not need a bespoke reverse proxy,
|
||||
header patches, or app config to be embeddable.
|
||||
|
||||
### Apps with their own login: `auth: open`
|
||||
|
||||
If your app carries a complete account system of its own — and especially if
|
||||
non-browser clients must reach it (git over HTTP, mobile apps, payment
|
||||
webhooks) — declare its gated port `auth: open` with an `auth_rationale`
|
||||
instead of `auth: gated`. The daemon still fronts the port exactly like a
|
||||
gated one (loopback pin, external binds, frame-header fixes, retry page,
|
||||
Tor onion), but serves it without the dashboard-login challenge, so your
|
||||
app's own authentication is the one users and API clients meet. Gitea and
|
||||
BTCPay Server ship this way. The node operator can override your default in
|
||||
either direction at runtime (Settings → app → Access control), so never
|
||||
treat the gate as your app's authorization layer — enforce your own auth on
|
||||
every sensitive route regardless. See “Ports & the app gate” in
|
||||
[`app-manifest-spec.md`](app-manifest-spec.md).
|
||||
|
||||
Set `metadata.launch.open_in_new_tab: true` only when embedding is broken by
|
||||
things headers can't fix — the app frame-busts in JavaScript, requires being
|
||||
the top-level origin (OAuth redirect flows, WebAuthn), or sets
|
||||
|
||||
@@ -17,6 +17,45 @@ orchestrator code rather than a per-app installer, but it is not
|
||||
manifest-declared, and the direction of travel is to replace each case with a
|
||||
reusable manifest primitive.
|
||||
|
||||
|
||||
## Upstream tracking
|
||||
|
||||
A node only offers an app update when the signed catalog pins a newer image
|
||||
than the one running. That works — but nothing was telling *us* when upstream
|
||||
had shipped something new, because a manifest records only our mirror
|
||||
(`source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0`), which says
|
||||
nothing about the project it was mirrored from. So a pin could sit still for
|
||||
months while every node in the fleet correctly reported "up to date".
|
||||
|
||||
`upstream` closes that loop. It is metadata for the release process, never
|
||||
read by the orchestrator:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
id: fedimint
|
||||
version: 0.10.0
|
||||
upstream:
|
||||
kind: github # github | dockerhub | internal | manual
|
||||
repo: fedimint/fedimint
|
||||
```
|
||||
|
||||
| `kind` | Meaning | Needs |
|
||||
|--------|---------|-------|
|
||||
| `github` | Watch a project's releases, then its tags. | `repo: owner/name` |
|
||||
| `dockerhub` | Watch a Docker Hub repository's tags. | `repo: namespace/name` |
|
||||
| `internal` | Built by this project — there is no upstream feed. | — |
|
||||
| `manual` | Has releases, but not anywhere machine-readable. | `url:` for a human |
|
||||
|
||||
`scripts/check-upstream-releases.py` reads these and prints what is behind;
|
||||
it exits non-zero when anything tracked has fallen behind, so a release pass
|
||||
can gate on it. Export `GITHUB_TOKEN` first — a full sweep needs more than
|
||||
GitHub's 60-per-hour anonymous quota.
|
||||
|
||||
An app with **no** `upstream` block is reported as `UNTRACKED` rather than
|
||||
skipped: silently skipping unknowns is exactly how this gap stayed invisible.
|
||||
Leaving it out is therefore fine and honest; guessing a wrong `repo` is not,
|
||||
because a wrong source produces a confident wrong verdict.
|
||||
|
||||
## Top-level fields (`app:`)
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
@@ -29,7 +68,7 @@ reusable manifest primitive.
|
||||
| `dependencies` | list | — | `- storage: "10GB"`, `- { app_id: bitcoin, version: … }`, or a bare string. |
|
||||
| `resources` | ResourceLimits | — | `cpu_limit` (int), `memory_limit` (e.g. `"512m"`), `disk_limit`. |
|
||||
| `security` | SecurityPolicy | — | See [Security](#security). |
|
||||
| `ports` | list of PortMapping | — | `- { host: 8080, container: 80, protocol: tcp }`. |
|
||||
| `ports` | list of PortMapping | — | See [Ports & the app gate](#ports--the-app-gate). |
|
||||
| `volumes` | list of Volume | — | See [Volumes](#volumes). |
|
||||
| `files` | list of GeneratedFile | — | Config files written before create: `{ path, content, overwrite }`. `path` must sit under a declared bind mount. |
|
||||
| `environment` | list of string | — | `- KEY=value` pairs (static). |
|
||||
@@ -37,6 +76,7 @@ reusable manifest primitive.
|
||||
| `devices` | list of string | — | Host device paths; must start with `/dev/`. |
|
||||
| `interfaces` | map | — | Launch surfaces, keyed by name (`main`): `{ name, description, type, port, protocol, path }`. |
|
||||
| `hooks` | LifecycleHooks | — | Allow-listed lifecycle hooks. See [Hooks](#hooks). |
|
||||
| `upstream` | UpstreamSource | — | Where the app comes from, so release tooling can tell when the pin has fallen behind. See [Upstream tracking](#upstream-tracking). |
|
||||
| _anything else_ | — | — | Unknown keys are absorbed into an `extensions` map (serde flatten) and treated as transitional metadata — e.g. `container_name`, `metadata`, `category`, `bitcoin_integration`, `lightning_integration`. These are **not** typed schema; do not rely on them being validated. |
|
||||
|
||||
## `container:` (ContainerConfig)
|
||||
@@ -82,6 +122,57 @@ Validation (enforced at `AppManifest::validate()`):
|
||||
`secret_env`/`generated_secrets` names must be bare filenames.
|
||||
- Hook steps are validated against the hook allow-list (below).
|
||||
|
||||
## Ports & the app gate
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- host: 3001 # host port your app is reachable on
|
||||
container: 3000
|
||||
protocol: tcp # default tcp
|
||||
bind: 127.0.0.1 # empty = all interfaces
|
||||
auth: gated # session | none | local | gated | open
|
||||
auth_rationale: "…" # REQUIRED for auth: none and auth: open
|
||||
session_passthrough: false
|
||||
```
|
||||
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| `bind` | Host address for the publish. Empty = all interfaces. List the same host/container pair twice with different binds to serve several addresses. |
|
||||
| `auth` | The port's authentication policy — see below. **Absent means "no instruction"**: the daemon reports on the port but never changes how it is published. |
|
||||
| `auth_rationale` | Why the port is safe without the gate's login. Required for `none` and `open`, rejected elsewhere. |
|
||||
| `session_passthrough` | Forward the node session cookie to the app on authorised requests. First-party companion UIs only; the gate otherwise strips its own credential. Only meaningful on `auth: gated`. |
|
||||
|
||||
### `auth` policies
|
||||
|
||||
- **`session`** (default when declared): the app gate authenticates every
|
||||
connection — node session cookie or an app-scoped bearer token.
|
||||
- **`gated`**: the app publishes on loopback **only** (`bind: 127.0.0.1`) and
|
||||
the daemon owns the external addresses: it binds them, authenticates every
|
||||
connection, fixes frame-blocking headers so the app embeds in the
|
||||
dashboard, serves a retrying page while the app is down, and fronts the
|
||||
Tor onion for the port. This is the migrated end state for most apps.
|
||||
- **`open`**: same daemon takeover as `gated` — loopback pin, external
|
||||
binds, header fixes, retry page, Tor — but **no dashboard login
|
||||
challenge**. For apps that carry a complete login of their own and are
|
||||
broken by an upstream challenge: Gitea (git clients speak basic-auth, not
|
||||
cookies), BTCPay (checkout pages must be reachable by anonymous payers).
|
||||
Requires `auth_rationale`.
|
||||
- **`none`**: the gate does not touch the port at all. Only for protocols
|
||||
that authenticate themselves (LND macaroons, TLS client certs) or where a
|
||||
login page is meaningless (p2p gossip). Requires `auth_rationale`.
|
||||
- **`local`**: host-local by intent — the gate must never bind or expose
|
||||
this port anywhere (e.g. Bitcoin RPC).
|
||||
|
||||
### Runtime override
|
||||
|
||||
The manifest sets the **default**. The node operator can flip any
|
||||
gate-fronted app between `gated` and `open` behaviour at runtime from
|
||||
**Settings → app → Access control** (RPC `security.set-app-gate`), stored
|
||||
per-app on the node (`app-configs/<id>.json`, key `gateEnabled`). The
|
||||
override wins over the manifest in both directions and applies on the next
|
||||
request — your app cannot assume the gate is or isn't in front of it, so it
|
||||
must always enforce its own authorization for sensitive operations.
|
||||
|
||||
## Volumes
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Cashu: move to the reference implementation (`cashu` / CDK)
|
||||
|
||||
Status: **planned** (2026-08-17). Scoped from the framework-pt keyset incident.
|
||||
|
||||
## Why
|
||||
|
||||
The node's Cashu support is hand-rolled: the NUT-00 token codec
|
||||
(`wallet/cashu.rs`), the blind-DH crypto (`wallet/bdhke.rs`) and the mint HTTP
|
||||
client (`wallet/mint_client.rs`) are ours. That was fine while the protocol
|
||||
was small; it isn't any more.
|
||||
|
||||
The cost showed up on 2026-08-17: a Minibits token could not be redeemed
|
||||
because it carried a **NUT-02 v2 keyset id truncated to 8 bytes**. The mint —
|
||||
itself CDK-based — rejected the swap with `NUT02: ID length invalid`. The
|
||||
reference implementation has carried the resolver for this exact case
|
||||
(`Id::from_short_keyset_id`) since v0.11, and requires the mint's keyset list
|
||||
to turn a token into proofs, so it *cannot* forward a truncated id. We shipped
|
||||
an equivalent repair by hand (`2277fc46`); the general lesson is that we are
|
||||
tracking a moving spec on our own, and we keep finding out where we lag from
|
||||
production failures.
|
||||
|
||||
Other gaps we carry today:
|
||||
|
||||
- **No DLEQ verification.** V4 tokens' `d`/`w` fields are parsed and discarded,
|
||||
so we cannot prove a mint signed with the keyset it claims.
|
||||
- **No deterministic secrets (NUT-13).** `bdhke::generate_secret` is pure
|
||||
randomness, which means **the ecash wallet cannot be restored from a seed** —
|
||||
losing `wallet/ecash.json` loses the coins.
|
||||
- We emit only `cashuA` (V3); most wallets now default to `cashuB` (V4).
|
||||
|
||||
## What we adopt
|
||||
|
||||
**The `cashu` crate alone** (MIT, from cashubtc — the crate CDK itself is built
|
||||
on), *not* the full `cdk` wallet with its `WalletDatabase`.
|
||||
|
||||
That gives us, from audited upstream code:
|
||||
|
||||
- `nut00::Token` — encode **and** decode both `cashuA` and `cashuB`, plus
|
||||
`to_v3_string()` for older wallets
|
||||
- `nut02::{Id, KeySetVersion, KeySetInfo}` — correct v1 (8-byte) and v2
|
||||
(33-byte) ids, and `Id::from_short_keyset_id` for the truncated case
|
||||
- `dhke::{hash_to_curve, blind_message, unblind_message}` — the crypto, with
|
||||
upstream's test vectors
|
||||
- DLEQ verification
|
||||
- NUT-13 deterministic secrets
|
||||
|
||||
It is a light dependency: ~7 transitive crates, no `reqwest`, no runtime
|
||||
opinions. `bitcoin ^0.32.2` matches our pinned `=0.32.5`, so secp256k1 stays a
|
||||
single copy in the tree.
|
||||
|
||||
### What we deliberately keep
|
||||
|
||||
- **`wallet/ecash.rs` and every on-disk file, unchanged.** `wallet/ecash.json`,
|
||||
`accepted_mints.json`, `pending_swaps.json`, `swap_liquidity.json` keep their
|
||||
exact schemas — including `StoredProof`'s flattened shape and the capital-`C`
|
||||
field. Balances, history and trusted mints survive the update untouched.
|
||||
- **`EcashTransaction`** — Fedimint and Ark write the same struct; it is a
|
||||
cross-backend contract, not Cashu-private.
|
||||
- **`MintClient`'s HTTP surface**, its Tor-capable `with_client` seam, and the
|
||||
NUT error-code → plain-English table (which is better UX than upstream's raw
|
||||
errors).
|
||||
- **Our multi-mint logic** — `swap_between_mints`, `plan_payment`, the
|
||||
liquidity cache and the crash-safe swap journal have no upstream equivalent.
|
||||
|
||||
### What we do NOT adopt, and why
|
||||
|
||||
`cdk::wallet::Wallet` requires implementing `WalletDatabase` — ~50 methods.
|
||||
Roughly 30 have no home in our format (keyset caches, NUT-13 counters, sagas,
|
||||
uuid-keyed reservations, KV, P2PK), and the ~20 that do map are lossy in both
|
||||
directions on exactly the records that hold real money (proof state, and a
|
||||
transaction type shared with two other backends). A permanent compatibility
|
||||
shim over live funds is the wrong trade. If we ever want the full wallet, it
|
||||
should come with a one-way format migration, decided separately.
|
||||
|
||||
## Seed backup, derived from the node seed
|
||||
|
||||
Today the ecash wallet has no seed and cannot be recovered. With NUT-13 it can,
|
||||
and it should not introduce a second thing for the operator to write down:
|
||||
|
||||
- Derive the Cashu wallet seed from the **existing node master seed** over a
|
||||
dedicated BIP32 path, alongside the node's other derived keys. The node's
|
||||
24 words then already back up the ecash wallet — nothing new to record, and a
|
||||
restored node re-derives the same secrets.
|
||||
- Persist the per-keyset NUT-13 counter (upstream's `increment_keyset_counter`)
|
||||
in a new sidecar file. It is recovery metadata, not funds: a lost counter
|
||||
costs a restore scan, not coins.
|
||||
- Surface it in the UI the way the node seed already is: a "back up / restore
|
||||
ecash" path that states plainly that the node's recovery phrase covers it.
|
||||
|
||||
### The words we show, and why they are their own mnemonic
|
||||
|
||||
Derive a **dedicated BIP-39 mnemonic for the Cashu wallet** from the node
|
||||
master seed (deterministic, fixed path), rather than showing the node's own 24
|
||||
words. Both properties matter:
|
||||
|
||||
- it is still covered by the node's recovery phrase — a restored node
|
||||
re-derives the same ecash wallet, nothing extra to write down;
|
||||
- but it is *portable*: the operator can restore their ecash in any NUT-13
|
||||
wallet (Minibits, Nutstash, `cdk-cli`) **without handing over the node's
|
||||
master seed**. Showing the node seed here would make "back up my ecash" and
|
||||
"expose the key to everything" the same action.
|
||||
|
||||
### Where it appears — the existing seed-reveal pattern, unchanged
|
||||
|
||||
Mirror the Lightning seed backup exactly; do not invent a second pattern.
|
||||
|
||||
| Piece | Lightning (existing) | Ecash (to build) |
|
||||
|---|---|---|
|
||||
| Shared UI | `components/SeedRevealPanel.vue` (`:words`, Words/QR tabs, tap-to-reveal blur, SeedQR) | same component, reused as-is |
|
||||
| App detail page | `views/appDetails/LndSeedBackup.vue`, rendered from `AppDetails.vue:22` when `packageKey === 'lnd' && pkg.installed` | `views/appDetails/EcashSeedBackup.vue`, rendered the same way for the ecash-bearing app |
|
||||
| Settings | `views/settings/BackupSection.vue:352` | same section, a panel beside it |
|
||||
| Status RPC | `lnd.seed-backup-status` | `wallet.ecash-seed-status` |
|
||||
| Reveal RPC | `lnd.seed-reveal` | `wallet.ecash-seed-reveal` |
|
||||
| Auth gate | `verify_reveal_auth(¶ms, "the Lightning seed")` — password re-entry | same helper, `"the ecash seed"` |
|
||||
|
||||
Pass `SeedRevealPanel` **without** the `aezeed` flag: unlike LND's aezeed, this
|
||||
is standard BIP-39, so the SeedQR tab works and third-party wallets can consume
|
||||
it.
|
||||
|
||||
**Open question for the operator:** whether to *also* allow importing an
|
||||
externally generated ecash mnemonic (bring-your-own, breaking the
|
||||
derived-from-node link). Default should be derived-from-node.
|
||||
|
||||
## Options this unlocks (worth considering, not committed)
|
||||
|
||||
- **Run our own mint.** Upstream ships `cdk-mintd`. Packaged as an app it would
|
||||
make a node its own Cashu mint — the same shape as the `fedimint-clientd`
|
||||
sidecar, and launchable from the dashboard if it has a UI. Independent of the
|
||||
wallet work here.
|
||||
- **P2PK / locked tokens** (NUT-11) — send ecash only a specific pubkey can
|
||||
redeem, which fits the mesh/federation identity we already have.
|
||||
- **Multi-unit** support beyond sats.
|
||||
|
||||
## Sequencing
|
||||
|
||||
1. ~~Repair truncated v2 keyset ids so the failing case works now~~ — done,
|
||||
`2277fc46`.
|
||||
2. Swap `wallet/cashu.rs` + `wallet/bdhke.rs` internals for the `cashu` crate,
|
||||
keeping every public signature and the on-disk contract. Existing tests in
|
||||
both modules stay as the regression net; add upstream's DLEQ vectors.
|
||||
3. Emit `cashuB` (V4) by default, keep `cashuA` for compatibility.
|
||||
4. NUT-13 deterministic secrets + node-seed derivation, the reveal/restore
|
||||
surfaces above (app detail page **and** Settings), and a restore-from-words
|
||||
path so a wallet can be rebuilt from the mnemonic alone.
|
||||
5. Then reconsider `cdk-mintd` and NUT-11 as separate features.
|
||||
|
||||
Verify each step against a real mint on a test node before the fleet, and keep
|
||||
`wallet/ecash.json` from a pre-migration node to prove it still loads.
|
||||
@@ -66,9 +66,16 @@ packaging docs; never required for an app to be usable.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- ❌ CDP (`--remote-debugging-port` + Input.dispatchKeyEvent): works but adds
|
||||
a privileged debug port to the kiosk and a daemon↔browser coupling; the
|
||||
uinput route gets the same result at kernel level with no attack surface.
|
||||
- ⚠️ **Scope update 2026-08-16:** the "no CDP" rule below still holds for
|
||||
*gamepad keys* (uinput remains their path). But companion **pointer input**
|
||||
(tap at coordinates, scroll, focus-then-type inside cross-origin app
|
||||
iframes) has no uinput equivalent that survives hit-testing across frames,
|
||||
so the kiosk now runs `--remote-debugging-port=9222` (loopback-only, default
|
||||
origin check intact) feeding the backend CDP bridge in
|
||||
`core/archipelago/src/api/handler/cdp.rs`.
|
||||
- ❌ CDP for gamepad keys (`Input.dispatchKeyEvent` for the NES pad): the
|
||||
uinput route gets the same result at kernel level with no daemon↔browser
|
||||
coupling; keep gamepads on uinput.
|
||||
- ❌ Per-app nav scripts injected into iframes: cross-origin makes this
|
||||
impossible for most apps, and it's exactly the per-app hack the requirement
|
||||
rules out.
|
||||
|
||||
@@ -291,7 +291,14 @@ while true; do
|
||||
--disable-metrics-reporting \
|
||||
--disable-domain-reliability \
|
||||
--js-flags="--max-old-space-size=256" \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir=/var/lib/archipelago/chromium-kiosk
|
||||
# --remote-debugging-port binds to 127.0.0.1 only. It feeds the
|
||||
# backend's CDP input bridge (api/handler/cdp.rs), which dispatches
|
||||
# companion-app remote input as TRUSTED browser events — the only way
|
||||
# taps/keys/scrolls reach inside cross-origin app iframes. Chromium's
|
||||
# default origin check stays on (no --remote-allow-origins), so web
|
||||
# pages cannot open the debug socket.
|
||||
sleep 3
|
||||
done
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.3-alpha",
|
||||
"version": "1.8.4-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.3-alpha",
|
||||
"version": "1.8.4-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.3-alpha",
|
||||
"version": "1.8.4-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.2 KiB |
@@ -73,7 +73,7 @@
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
@@ -193,13 +193,13 @@
|
||||
{
|
||||
"id": "nostr-rs-relay",
|
||||
"title": "Nostr Relay (Rust)",
|
||||
"version": "0.8.0",
|
||||
"version": "0.10.0",
|
||||
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
|
||||
"icon": "/assets/img/app-icons/nostrudel.svg",
|
||||
"author": "Nostr RS Relay",
|
||||
"category": "community",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "scsibug/nostr-rs-relay:0.8.9",
|
||||
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
|
||||
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -223,7 +223,7 @@
|
||||
"author": "Vaultwarden",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -262,7 +262,7 @@
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
|
||||
"repoUrl": "https://github.com/fedimint/fedimint"
|
||||
},
|
||||
{
|
||||
@@ -285,7 +285,7 @@
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
|
||||
"repoUrl": "https://github.com/fedimint/fedimint",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -325,7 +325,7 @@
|
||||
"icon": "/assets/img/app-icons/jellyfin.webp",
|
||||
"author": "Jellyfin",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -356,7 +356,7 @@
|
||||
"icon": "/assets/img/app-icons/homeassistant.png",
|
||||
"author": "Home Assistant",
|
||||
"category": "home",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
|
||||
"repoUrl": "https://github.com/home-assistant/core",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -374,11 +374,11 @@
|
||||
"id": "pine",
|
||||
"title": "Pine",
|
||||
"version": "1.3.0",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
|
||||
"icon": "/assets/img/app-icons/pine.svg",
|
||||
"author": "Archipelago",
|
||||
"category": "home",
|
||||
"dockerImage": "docker.io/library/nginx:1.27-alpine",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
|
||||
"repoUrl": "https://github.com/rhasspy/wyoming"
|
||||
},
|
||||
{
|
||||
@@ -442,7 +442,7 @@
|
||||
"author": "Portainer",
|
||||
"category": "development",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
|
||||
"repoUrl": "https://github.com/portainer/portainer",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -459,12 +459,12 @@
|
||||
"id": "netbird",
|
||||
"title": "NetBird",
|
||||
"version": "2.38.0",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point \u2014 a TLS proxy in front of the dashboard + server.",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
|
||||
"icon": "/assets/img/app-icons/netbird.svg",
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/library/nginx:1.27-alpine",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
|
||||
"repoUrl": "https://github.com/netbirdio/netbird",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -552,19 +552,19 @@
|
||||
"id": "alby-hub",
|
||||
"title": "Alby Hub",
|
||||
"version": "1.23.0",
|
||||
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect \u2014 one hub, every app pays through it.",
|
||||
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
|
||||
"icon": "/assets/img/app-icons/alby-hub.svg",
|
||||
"author": "Alby",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.23.0",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
|
||||
"repoUrl": "https://github.com/getAlby/hub"
|
||||
},
|
||||
{
|
||||
"id": "phoenixd",
|
||||
"title": "phoenixd",
|
||||
"version": "0.9.0",
|
||||
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own \u2014 it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
|
||||
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
|
||||
"icon": "/assets/img/app-icons/phoenixd.svg",
|
||||
"author": "ACINQ",
|
||||
"category": "money",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
<MeshDeviceSetupModal />
|
||||
<ExternalExplorerModal />
|
||||
|
||||
<!-- In-app confirm() replacement — native browser dialogs block the JS
|
||||
event loop and freeze companion remote input (see useAppConfirm) -->
|
||||
<AppConfirmModal />
|
||||
|
||||
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
|
||||
<LndSeedBackupPrompt />
|
||||
|
||||
@@ -102,6 +106,7 @@ import HelpGuideModal from './components/HelpGuideModal.vue'
|
||||
import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
|
||||
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
|
||||
import ExternalExplorerModal from './components/ExternalExplorerModal.vue'
|
||||
import AppConfirmModal from './components/AppConfirmModal.vue'
|
||||
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
|
||||
import LightningRequiredModal from './components/LightningRequiredModal.vue'
|
||||
import { useMeshStore } from './stores/mesh'
|
||||
|
||||
@@ -53,15 +53,30 @@ let cursorX = typeof window !== 'undefined' ? window.innerWidth / 2 : 0
|
||||
let cursorY = typeof window !== 'undefined' ? window.innerHeight / 2 : 0
|
||||
let cursorHideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/**
|
||||
* While the companion is actively driving input, suppress the other visible
|
||||
* pointer: the native OS cursor sitting wherever the physical mouse left it
|
||||
* reads as a second, dead cursor next to the companion's virtual one
|
||||
* (user request 2026-08-16). Restores automatically when the companion goes
|
||||
* quiet (same 30s window as `companionActive`).
|
||||
*/
|
||||
function setNativeCursorSuppressed(on: boolean) {
|
||||
document.documentElement.classList.toggle('companion-input-active', on)
|
||||
}
|
||||
|
||||
function markCompanionActive() {
|
||||
companionActive.value = true
|
||||
companionInputActive.value = true
|
||||
setNativeCursorSuppressed(true)
|
||||
|
||||
if (inputFlickerTimeout) clearTimeout(inputFlickerTimeout)
|
||||
inputFlickerTimeout = setTimeout(() => { companionInputActive.value = false }, 200)
|
||||
|
||||
if (companionTimeout) clearTimeout(companionTimeout)
|
||||
companionTimeout = setTimeout(() => { companionActive.value = false }, 30_000)
|
||||
companionTimeout = setTimeout(() => {
|
||||
companionActive.value = false
|
||||
setNativeCursorSuppressed(false)
|
||||
}, 30_000)
|
||||
}
|
||||
|
||||
function createCursor(): HTMLDivElement {
|
||||
@@ -333,7 +348,14 @@ function doConnect() {
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const url = `${protocol}//${window.location.host}/ws/remote-relay`
|
||||
// The kiosk self-identifies so the backend can mute this subscriber's
|
||||
// key/click/scroll messages while the CDP bridge delivers them as trusted
|
||||
// browser input (cursor moves still arrive so the on-screen cursor draws).
|
||||
// Same three-way predicate as App.vue's isKiosk.
|
||||
const isKiosk = localStorage.getItem('kiosk') === 'true'
|
||||
|| new URLSearchParams(window.location.search).has('kiosk')
|
||||
|| window.location.pathname === '/kiosk'
|
||||
const url = `${protocol}//${window.location.host}/ws/remote-relay${isKiosk ? '?kiosk=1' : ''}`
|
||||
|
||||
ws = new WebSocket(url)
|
||||
|
||||
@@ -405,6 +427,7 @@ export function stopRemoteRelay() {
|
||||
if (cursorHideTimer) { clearTimeout(cursorHideTimer); cursorHideTimer = null }
|
||||
if (ws) { ws.onclose = null; ws.close(); ws = null }
|
||||
if (cursorEl) { cursorEl.remove(); cursorEl = null }
|
||||
setNativeCursorSuppressed(false)
|
||||
relayConnected.value = false
|
||||
companionActive.value = false
|
||||
companionInputActive.value = false
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user