Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef35d65b7 | ||
|
|
3b3500a7dd | ||
|
|
2f0f7fd388 | ||
|
|
b300a720db | ||
|
|
5b6d278c46 | ||
|
|
699669a5f7 | ||
|
|
54431fc856 | ||
|
|
3409db569e | ||
|
|
cb71c25ea0 | ||
|
|
c5eeb31055 | ||
|
|
966db4810a | ||
|
|
51a5473e22 | ||
|
|
1872fc20ee | ||
|
|
cbd463e980 | ||
|
|
9df580bf2b | ||
|
|
aee7ecaac1 | ||
|
|
e51ceaa250 | ||
|
|
7c9559aa57 | ||
|
|
7b88ba59b2 | ||
|
|
b12d1d3826 | ||
|
|
698e915df2 | ||
|
|
a179df66d8 | ||
|
|
771ff0d28b | ||
|
|
92111385b7 | ||
|
|
a9e52fa310 | ||
|
|
b4714f1773 | ||
|
|
d79ca54019 | ||
|
|
758332d63d | ||
|
|
ee5123af68 | ||
|
|
a624d11b6a | ||
|
|
2c984fbd49 | ||
|
|
c188d9de78 | ||
|
|
37a82fd2f9 | ||
|
|
a9a30406df | ||
|
|
f1b5d2d267 | ||
|
|
d6b48ce095 | ||
|
|
9c5164372e | ||
|
|
e38b148d8e | ||
|
|
e03a2fed89 | ||
|
|
3d7de3e902 | ||
|
|
60c1db98bb | ||
|
|
f5b112c508 | ||
|
|
5ccef0ac2f | ||
|
|
e79ab37da7 | ||
|
|
d75963de10 | ||
|
|
c788dff42d | ||
|
|
bd299318c0 | ||
|
|
8bc161f40f | ||
|
|
c19c411b91 | ||
|
|
7d6e52537a | ||
|
|
86923f05a5 | ||
|
|
ee40880ce5 | ||
|
|
c1a79fdd69 | ||
|
|
59440ef1ac | ||
|
|
603291008b | ||
|
|
212e349b19 | ||
|
|
fa6fe32ef9 | ||
|
|
fc98c1d8dd | ||
|
|
e30516316b | ||
|
|
cbbd20e22e | ||
|
|
eb48eab946 | ||
|
|
59fffc809f | ||
|
|
579287ba48 |
+46
-6
@@ -1,6 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.4-alpha (draft — date set at cut)
|
||||
## v1.8.5-alpha (2026-08-30)
|
||||
|
||||
- **Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.
|
||||
|
||||
- **A frozen node now explains itself — and comes back on its own.** The host now captures a memory dump into /var/crash when the kernel panics *or* wedges (a hung kiosk used to sit dead until someone power-cycled it; now it dumps, reboots itself, and leaves the evidence behind), and records failing-memory signals (ECC errors) into a database as they happen. This is the first change delivered by a new host-update channel: the node's own updater now carries OS-level packages and settings to already-deployed machines — the crash-kernel's memory reservation is the one part that waits for a reboot, and the node says so rather than pretending.
|
||||
|
||||
- **Uninstalling an app can no longer report success when it failed.** The declarative path used to swallow every teardown error and report the app uninstalled, leaving the tile behind and the truth in the logs. A failed uninstall now stops and shows the real per-app errors, so "still there" is never presented as "gone".
|
||||
|
||||
- **Pictures to internet-only mesh contacts work now.** Sending an attachment inline always took the radio path and failed with "Peer is federation-only (no radio twin)" for contacts reachable only over the internet — and the size-adviser kept recommending a radio transfer those peers can't receive. Both fixed: inline sends route over the federation when that's the only way to reach the peer, and the advice no longer offers radio-only transfers to radio-unreachable contacts.
|
||||
|
||||
- **Disk cleanup finally has honest numbers.** Space "free" on a drive was counted including the slice the filesystem keeps reserved for root — roughly 5% of the disk, 92 GB on one dev box — so the automatic cleanup that's supposed to kick in at 90% never triggered and stale container images piled up unnoticed. Reserved space now counts as used, which is what the threshold was always meant to measure.
|
||||
|
||||
- **Three small screens that were lying to you, fixed.** The "Bitcoin is synced — fund your wallet" toast no longer appears on a node where the wallet it means (LND) isn't installed — it points at installing LND instead. The seed-reveal screen hides its third prompt unless the password actually fails to decrypt (the backup passphrase only exists if you set one). And multi-version store cards stop quoting a version number you'll be asked to choose on the next screen anyway.
|
||||
|
||||
- **Mesh notifications survive a refresh, and a stale router no longer hides the fix.** Radio message unread counts are now remembered per contact instead of guessed from session state (the "one new message showed 11 unread" bug), cover Meshtastic, MeshCore and Reticulum alike, and deep-link to the right conversation; a single new message announces itself once. Separately, when the cached router address goes stale, the error card gains a "Reconfigure router" action instead of a Retry loop that can never succeed.
|
||||
|
||||
- **The app updater now knows what upstream shipped.** Every app's manifest records where it comes from — including the odd corners (GitLab-only projects, ghcr-only images) — and a checker sweeps all of them against upstream releases, so a pin that quietly rots for months is now visible instead of invisible. The first full sweep found 27 pins behind; the safe patch-level ones shipped with this release (strfry, BTCPay Server 2.4.3, the two nginx frontends), and the major jumps that may carry data migrations are deliberately held for their own careful passes.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -291,6 +309,12 @@
|
||||
- More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.
|
||||
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.
|
||||
|
||||
## v1.7.107-alpha (2026-07-20)
|
||||
|
||||
- Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.
|
||||
- Your node rejoins the mesh faster after an update. Applying this update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried. It now notices the restart and reconnects within seconds.
|
||||
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle — two separate faults that had been failing the build.
|
||||
|
||||
## v1.7.106-alpha (2026-07-20)
|
||||
|
||||
- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.
|
||||
@@ -670,11 +694,13 @@
|
||||
|
||||
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
|
||||
- Live diagnostics on a fleet node confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
|
||||
- The gap this closes: apps launched through the orchestrator previously skipped the legacy start-time repair path entirely, so the same stale metadata the old flow cleaned up silently broke the new one. Both paths now converge on the same repairs.
|
||||
|
||||
## v1.7.64-alpha (2026-05-18)
|
||||
|
||||
- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows.
|
||||
- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing.
|
||||
- For operators mid-incident this changes the recovery loop: a failed apply can now be retried immediately from the System Update page instead of waiting out a throttle window while a node sits half-updated.
|
||||
|
||||
## v1.7.63-alpha (2026-05-18)
|
||||
|
||||
@@ -784,6 +810,18 @@
|
||||
- Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages.
|
||||
- Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`.
|
||||
|
||||
## v1.7.51-alpha (2026-04-30)
|
||||
|
||||
- Stack installs now adopt containers that already exist instead of failing on them — a repair or reinstall over leftover containers completes, and the adopted container's readiness is waited on like any fresh start.
|
||||
- Failed installs come with evidence: the install path waits for its containers, and when one doesn't become healthy it captures that container's logs, so the error on screen names the real culprit instead of a bare timeout.
|
||||
- Bitcoin RPC bindings are ensured as part of install, and the startup self-heal path gained additional ground for already-deployed nodes.
|
||||
|
||||
## v1.7.50-alpha (2026-04-30)
|
||||
|
||||
- The OTA bridge older nodes needed: deployed binaries only knew how to apply two artifacts (the backend binary and the frontend archive), so the scripts, app specs and docker assets newer releases carry never reached them. This release packs those payloads inside the frontend tarball — the one channel old binaries do apply — and the new backend promotes them into /opt once it starts.
|
||||
- Runtime payloads are staged into timestamped directories and promoted atomically; a failed extraction cleans up its staging area instead of leaving half-written state for the next update to trip over.
|
||||
- This is the release that un-sticks the fleet's update pipeline: from here on, an OTA can carry more than the two artifacts, and app installs on updated nodes use the specs that match their backend.
|
||||
|
||||
## v1.7.49-alpha (2026-04-30)
|
||||
|
||||
- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up.
|
||||
@@ -795,12 +833,15 @@
|
||||
|
||||
## v1.7.48-alpha (2026-04-29)
|
||||
|
||||
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where /run/containers wasn't pre-created. ExecStartPre now creates it. Existing nodes need a one-time `systemctl edit archipelago` to add the mkdir; ISO installs from this version forward have the fix baked in.
|
||||
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where that runtime directory wasn't pre-created — the failure surfaced in systemd's mount-namespace setup before the service itself ever ran.
|
||||
- ExecStartPre now creates /run/containers before the service starts, so the node's service manager finds the directory it needs on every boot; ISO installs from this version forward have the fix baked in.
|
||||
- Existing nodes pick the fix up with a one-time `systemctl edit archipelago` adding the mkdir — after which the boot failure does not recur.
|
||||
|
||||
## v1.7.47-alpha (2026-04-29)
|
||||
|
||||
- Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed.
|
||||
- ElectrumX initial indexing is faster too. Its CPU cap is removed, container memory is 4GB, and its internal cache is now 3GB (default was 1.2GB).
|
||||
- The result: a fresh node's first hours are measurably shorter — initial block download and ElectrumX indexing were the two longest post-install waits, and both now run at the hardware's limit.
|
||||
|
||||
## v1.7.46-alpha (2026-04-29)
|
||||
|
||||
@@ -823,10 +864,9 @@
|
||||
|
||||
## v1.7.44-alpha (2026-04-28)
|
||||
|
||||
43de3b73 feat(orchestrator): complete container migration and release hardening
|
||||
ce39430b feat(self-update): sync and rebuild UI containers on OTA
|
||||
72dec5aa fix(lnd-ui): align container port across all specs
|
||||
83aacdf2 chore(release): archive ISO build recipes, tarball-only releases
|
||||
- Container orchestration migration completed, with release hardening across the app lifecycle — installs, updates and removals now run through one orchestrator path instead of the split legacy/Podman flows.
|
||||
- OTA updates now rebuild and sync the app UI containers they carry, so an updated app serves the UI image that matches its backend instead of whatever happened to be on disk.
|
||||
- LND UI port handling is aligned across all runtime specs, and release packaging moved to tarball-only payloads with the ISO build recipes archived — update payloads now carry only the files existing nodes need.
|
||||
|
||||
|
||||
All notable changes to Archipelago will be documented in this file.
|
||||
|
||||
+30
-18
@@ -52,13 +52,13 @@
|
||||
{
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "2.4.2",
|
||||
"version": "2.4.3",
|
||||
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
|
||||
"icon": "/assets/img/app-icons/btcpay-server.png",
|
||||
"author": "BTCPay Server Foundation",
|
||||
"category": "commerce",
|
||||
"tier": "core",
|
||||
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.2",
|
||||
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
@@ -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.4-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.4-alpine",
|
||||
"repoUrl": "https://github.com/netbirdio/netbird",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -552,25 +552,37 @@
|
||||
"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",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
|
||||
"repoUrl": "https://github.com/ACINQ/phoenixd"
|
||||
},
|
||||
{
|
||||
"id": "cuprate",
|
||||
"title": "Cuprate",
|
||||
"version": "0.1.0-preview",
|
||||
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
|
||||
"icon": "/assets/img/app-icons/cuprate.svg",
|
||||
"author": "Cuprate contributors",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
|
||||
"repoUrl": "https://github.com/Cuprate/cuprate"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,14 @@ app:
|
||||
id: barkd
|
||||
name: Ark Wallet
|
||||
version: 0.3.0
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. bark ships on GitLab only
|
||||
# (no GitHub mirror), so the gitlab fetcher is the one that can see it.
|
||||
# NOTE: a version bump is code work, not a pin move — the REST shapes are
|
||||
# coded in core/archipelago/src/wallet/ark_client.rs (see Dockerfile note).
|
||||
upstream:
|
||||
kind: gitlab
|
||||
repo: ark-bitcoin/bark
|
||||
description: Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.
|
||||
|
||||
container:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
app:
|
||||
id: btcpay-server
|
||||
name: BTCPay Server
|
||||
version: 2.4.2
|
||||
version: 2.4.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: btcpayserver/btcpayserver
|
||||
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
|
||||
|
||||
container:
|
||||
image: docker.io/btcpayserver/btcpayserver:2.4.2
|
||||
image: docker.io/btcpayserver/btcpayserver:2.4.3
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
|
||||
@@ -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,157 @@
|
||||
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.
|
||||
category: money
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/cuprate.svg
|
||||
repo: https://github.com/Cuprate/cuprate
|
||||
tier: optional
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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-postgres
|
||||
name: Immich Postgres
|
||||
version: "14-vectorchord0.4.3-pgvectors0.2.0"
|
||||
# Upstream is the Immich-built Postgres image, published only on ghcr.io
|
||||
# (no GitHub release tags, no Docker Hub repo) — the ghcr fetcher in
|
||||
# scripts/check-upstream-releases.py is the only one that can see it.
|
||||
upstream:
|
||||
kind: ghcr
|
||||
repo: immich-app/postgres
|
||||
description: Postgres (pgvecto.rs / vectorchord) backend for Immich.
|
||||
|
||||
# Container named immich_postgres (underscore) to match the runtime's existing
|
||||
|
||||
@@ -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-minio
|
||||
name: IndeedHub MinIO
|
||||
version: "RELEASE.2024-11-07T00-52-20Z"
|
||||
# MinIO's release tags are date-opaque (RELEASE.YYYY-MM-DD…), so the
|
||||
# checker reports them as UNCOMPARABLE rather than ordering them — the
|
||||
# latest tag is still shown for hand comparison, which is the point.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: minio/minio
|
||||
description: MinIO S3-compatible object storage for IndeedHub media.
|
||||
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,12 @@ app:
|
||||
id: lightning-stack
|
||||
name: Lightning Stack
|
||||
version: 0.12.0
|
||||
# No public listing exists for lightninglabs/lightning-stack (checked
|
||||
# docker.io, ghcr.io and github.com) — nothing can be queried automatically,
|
||||
# so this one is tracked by hand.
|
||||
upstream:
|
||||
kind: manual
|
||||
url: no public listing for lightninglabs/lightning-stack — verify by hand
|
||||
description: Complete Lightning Network implementation. Includes LND, CLN, and management tools.
|
||||
|
||||
container:
|
||||
|
||||
@@ -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.4-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]
|
||||
|
||||
@@ -6,6 +6,14 @@ app:
|
||||
# pick up the args change; the pre-release form "3.4.1-1" would compare
|
||||
# LOWER than 3.4.1 under semver and never roll out.
|
||||
version: "3.4.2"
|
||||
# Tracks the rhasspy/wyoming-whisper image we pin (Docker Hub — the
|
||||
# project's GitHub tags are not the image tags). NOTE: this manifest
|
||||
# deliberately ships an args-tuned revision AHEAD of the image tag (see
|
||||
# comment above) — BEHIND here means the image tag moved and the tuned
|
||||
# revision needs re-basing onto it, not just a pin bump.
|
||||
upstream:
|
||||
kind: dockerhub
|
||||
repo: rhasspy/wyoming-whisper
|
||||
description: Wyoming-protocol faster-whisper speech-to-text engine. Internal Pine voice-assistant stack member — turns speech captured by a PineVoice satellite into text for Home Assistant Assist.
|
||||
category: home
|
||||
|
||||
|
||||
@@ -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.4-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.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: 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.2
|
||||
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
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.3-alpha"
|
||||
version = "1.8.5-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.3-alpha"
|
||||
version = "1.8.5-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -105,10 +105,7 @@ async fn run_keeper(mut rx: mpsc::Receiver<String>, connected: Arc<AtomicBool>)
|
||||
/// 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;
|
||||
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
|
||||
@@ -219,7 +216,11 @@ fn translate(raw: &str, cursor: &mut Cursor, id: &mut impl FnMut() -> u64) -> Ve
|
||||
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 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),
|
||||
@@ -255,7 +256,14 @@ fn translate(raw: &str, cursor: &mut Cursor, id: &mut impl FnMut() -> u64) -> Ve
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_event(id: u64, kind: &str, cursor: &Cursor, button: &str, buttons: u32, clicks: u32) -> Value {
|
||||
fn mouse_event(
|
||||
id: u64,
|
||||
kind: &str,
|
||||
cursor: &Cursor,
|
||||
button: &str,
|
||||
buttons: u32,
|
||||
clicks: u32,
|
||||
) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"method": "Input.dispatchMouseEvent",
|
||||
|
||||
@@ -268,6 +268,10 @@ impl RpcHandler {
|
||||
"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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -405,9 +405,17 @@ impl RpcHandler {
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let device_type = svc.shared_state().status.read().await.device_type;
|
||||
// Resource transfer is a native RNS transfer over LoRa — it needs an
|
||||
// actual radio route to this contact, not just a Reticulum device on
|
||||
// our end. A federation-only peer with no radio twin fits the size
|
||||
// and device-type checks but has no dest_prefix to send to; without
|
||||
// this check the send falls into send_content_resource and fails
|
||||
// with "Peer is federation-only (no radio twin)" (picture-send,
|
||||
// 2026-08-07) instead of falling back to the federation path below.
|
||||
let use_resource_transfer = bytes.len() > INLINE_HARD_MAX
|
||||
&& device_type == crate::mesh::types::DeviceType::Reticulum
|
||||
&& bytes.len() <= RETICULUM_RESOURCE_MAX;
|
||||
&& bytes.len() <= RETICULUM_RESOURCE_MAX
|
||||
&& svc.has_radio_route(contact_id).await;
|
||||
|
||||
if bytes.len() > INLINE_HARD_MAX && !use_resource_transfer {
|
||||
anyhow::bail!(
|
||||
@@ -492,15 +500,58 @@ impl RpcHandler {
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
svc.send_typed_wire(
|
||||
contact_id,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
)
|
||||
.await?
|
||||
// Federation-only peers have no radio twin for
|
||||
// send_typed_wire's LoRa dest-prefix resolution — route over
|
||||
// Tor federation instead, mirroring mesh.send-content's onion
|
||||
// lookup, or the send fails with "Peer is federation-only (no
|
||||
// radio twin)" (picture-send from a federation-only contact,
|
||||
// 2026-08-07).
|
||||
let federation_onion = {
|
||||
let state = svc.shared_state();
|
||||
let peers = state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.map(|p| (p.pubkey_hex.clone(), p.did.clone()))
|
||||
};
|
||||
let federation_onion = match federation_onion {
|
||||
Some((Some(pubkey_hex), did)) => {
|
||||
let nodes = crate::federation::load_nodes(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
nodes
|
||||
.iter()
|
||||
.find(|n| n.pubkey == pubkey_hex)
|
||||
.map(|n| n.onion.clone())
|
||||
.or_else(|| {
|
||||
did.as_ref().and_then(|d| {
|
||||
nodes.iter().find(|n| &n.did == d).map(|n| n.onion.clone())
|
||||
})
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(onion) = federation_onion {
|
||||
svc.send_typed_wire_via_federation(
|
||||
contact_id,
|
||||
&onion,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
svc.send_typed_wire(
|
||||
contact_id,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -590,6 +641,16 @@ impl RpcHandler {
|
||||
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
|
||||
|
||||
let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum;
|
||||
// A Reticulum device on our end doesn't mean THIS peer is radio
|
||||
// reachable — a federation-only contact (no radio twin) has no dest
|
||||
// prefix for a resource transfer, even though it's small enough and
|
||||
// our device type qualifies. Without this check the frontend was
|
||||
// steered into mesh.send-content-inline's resource-transfer path,
|
||||
// which fails with "Peer is federation-only (no radio twin)"
|
||||
// (picture-send, 2026-08-07); the tier below now defers to the
|
||||
// has_tor branches for such peers, which route via mesh.send-content
|
||||
// (federation) instead.
|
||||
let has_radio_route = is_reticulum && svc.has_radio_route(contact_id).await;
|
||||
let (tier, reason) = if size <= MESH_AUTO_MAX {
|
||||
("auto-mesh", "Small enough to send inline over mesh")
|
||||
} else if size <= MESH_HARD_MAX {
|
||||
@@ -598,7 +659,7 @@ impl RpcHandler {
|
||||
} else {
|
||||
("auto-mesh", "No Tor path — sending inline over mesh")
|
||||
}
|
||||
} else if is_reticulum && size <= RETICULUM_RESOURCE_MAX {
|
||||
} else if has_radio_route && size <= RETICULUM_RESOURCE_MAX {
|
||||
(
|
||||
"resource-mesh",
|
||||
"Sending directly over LoRa via a Reticulum resource transfer",
|
||||
|
||||
@@ -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("; ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2182,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)
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ pub(super) async fn read_disk_usage() -> Result<(u64, u64)> {
|
||||
/// Read disk usage via `df` for a given path.
|
||||
pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> {
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["--block-size=1", "--output=used,size", path])
|
||||
.args(["--block-size=1", "--output=used,size,avail", path])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run df")?;
|
||||
@@ -189,11 +189,22 @@ pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing used"))?
|
||||
.parse()
|
||||
.context("parse df used")?;
|
||||
let total: u64 = parts
|
||||
// Raw `size` includes the filesystem's root-reserved blocks (5% by default
|
||||
// on ext4 — 92 GiB of this node's 1.8 TiB), which nothing can allocate.
|
||||
// Reporting it as capacity told the dashboard there were 251 GiB free when
|
||||
// only 159 GiB were writable. Callers derive free as total - used, so total
|
||||
// must mean "what can actually be used".
|
||||
let _size: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing total"))?
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing size"))?
|
||||
.parse()
|
||||
.context("parse df total")?;
|
||||
.context("parse df size")?;
|
||||
let avail: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing avail"))?
|
||||
.parse()
|
||||
.context("parse df avail")?;
|
||||
let total = used.saturating_add(avail);
|
||||
|
||||
Ok((used, total))
|
||||
}
|
||||
|
||||
@@ -246,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!({
|
||||
|
||||
@@ -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;"));
|
||||
|
||||
@@ -216,14 +216,20 @@ 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}"))
|
||||
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();
|
||||
let ghosts: Vec<Ghost> = find_ghosts()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|g| pred(g))
|
||||
.collect();
|
||||
if ghosts.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
@@ -237,7 +243,10 @@ async fn reap_matching(pred: impl Fn(&Ghost) -> bool) -> usize {
|
||||
);
|
||||
kill_ghost(ghost).await;
|
||||
}
|
||||
info!(count = ghosts.len(), "ghost reaper: reaped ghost containers");
|
||||
info!(
|
||||
count = ghosts.len(),
|
||||
"ghost reaper: reaped ghost containers"
|
||||
);
|
||||
ghosts.len()
|
||||
}
|
||||
|
||||
@@ -280,7 +289,9 @@ mod tests {
|
||||
|
||||
#[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", "-c", "8ea2fc65", "-n", "gitea"])).is_none()
|
||||
);
|
||||
assert!(parse_conmon(&argv(&["/usr/bin/conmon", "--api-version", "1"])).is_none());
|
||||
}
|
||||
|
||||
@@ -295,9 +306,7 @@ mod tests {
|
||||
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}"))
|
||||
n == app || n.starts_with(&format!("{app}-")) || n.ends_with(&format!("-{app}"))
|
||||
})
|
||||
};
|
||||
assert!(matches("gitea", "gitea"));
|
||||
|
||||
@@ -4,9 +4,19 @@
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Parse df output into (used_bytes, total_bytes, used_percent).
|
||||
/// Expects output from `df --block-size=1 --output=used,size /` which has a header line
|
||||
/// followed by a data line with two whitespace-separated numbers.
|
||||
/// Parse df output into (used_bytes, usable_total_bytes, used_percent).
|
||||
/// Expects `df --block-size=1 --output=used,size,avail <path>`: a header line
|
||||
/// followed by used, size and avail.
|
||||
///
|
||||
/// `size` is deliberately NOT the denominator. ext4 reserves 5% of the
|
||||
/// filesystem for root — 92 GiB on archi-dev-box's 1.8 TiB disk — which `size`
|
||||
/// counts but no ordinary process can ever allocate. Dividing by `size`
|
||||
/// under-reports usage by about five points: on 2026-08-22 that disk was
|
||||
/// genuinely 90.8% full (159 GiB usable left) while this returned 86.2%, so the
|
||||
/// 90% auto-cleanup below had never once fired and ~72 GB of dangling images
|
||||
/// had accumulated. It also meant the dashboard advertised 251 GiB free when
|
||||
/// only 159 GiB could actually be written. used/(used+avail) is what `df`
|
||||
/// itself prints and what the operator can actually spend.
|
||||
fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
|
||||
let data_line = stdout
|
||||
.lines()
|
||||
@@ -18,11 +28,19 @@ fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing used"))?
|
||||
.parse()
|
||||
.context("parse df used")?;
|
||||
let total: u64 = parts
|
||||
// Parsed to keep the column contract explicit, then intentionally unused —
|
||||
// see the note above on why raw size is the wrong denominator.
|
||||
let _size: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing total"))?
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing size"))?
|
||||
.parse()
|
||||
.context("parse df total")?;
|
||||
.context("parse df size")?;
|
||||
let avail: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing avail"))?
|
||||
.parse()
|
||||
.context("parse df avail")?;
|
||||
let total = used.saturating_add(avail);
|
||||
|
||||
let percent = if total > 0 {
|
||||
(used as f64 / total as f64) * 100.0
|
||||
@@ -44,7 +62,7 @@ pub async fn check_disk_usage() -> Result<(u64, u64, f64)> {
|
||||
"/"
|
||||
};
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["--block-size=1", "--output=used,size", data_path])
|
||||
.args(["--block-size=1", "--output=used,size,avail", data_path])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run df")?;
|
||||
@@ -257,8 +275,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_normal() {
|
||||
// Simulates typical df --block-size=1 --output=used,size / output
|
||||
let output = " Used Size\n 500000000000 1000000000000\n";
|
||||
// df --block-size=1 --output=used,size,avail : used, size, avail
|
||||
let output = " Used Size Avail\n 500000000000 1000000000000 500000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 500_000_000_000);
|
||||
assert_eq!(total, 1_000_000_000_000);
|
||||
@@ -267,16 +285,35 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_high_usage() {
|
||||
let output = " Used Size\n 900000000000 1000000000000\n";
|
||||
let output = " Used Size Avail\n 900000000000 1000000000000 100000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 900_000_000_000);
|
||||
assert_eq!(total, 1_000_000_000_000);
|
||||
assert!((percent - 90.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// The bug this function existed to hide: reserved blocks are counted by
|
||||
/// `size` but are not available to anyone. Real numbers from archi-dev-box,
|
||||
/// 2026-08-22 — 1.8 TiB disk, ext4 5% reserve, genuinely 90.8% full. The old
|
||||
/// used/size math returned 86.2%, so the 90% auto-cleanup never triggered.
|
||||
#[test]
|
||||
fn reserved_blocks_are_not_counted_as_free() {
|
||||
let output = "Used Size Avail\n1681459122176 1951249276928 170581372928\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 1_681_459_122_176);
|
||||
// Total is what can actually be written, not the raw device size.
|
||||
assert_eq!(total, 1_852_040_495_104);
|
||||
assert!(
|
||||
total < 1_951_249_276_928,
|
||||
"raw size must not be the denominator"
|
||||
);
|
||||
assert!((percent - 90.8).abs() < 0.1, "got {percent}");
|
||||
assert!(percent >= 90.0, "must cross the auto-cleanup threshold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_almost_full() {
|
||||
let output = "Used Size\n999 1000\n";
|
||||
let output = "Used Size Avail\n999 1000 1\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 999);
|
||||
assert_eq!(total, 1000);
|
||||
@@ -285,7 +322,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_empty_disk() {
|
||||
let output = "Used Size\n0 1000000000000\n";
|
||||
let output = "Used Size Avail\n0 1000000000000 1000000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 0);
|
||||
assert_eq!(total, 1_000_000_000_000);
|
||||
@@ -295,7 +332,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_parse_df_output_zero_total() {
|
||||
// Edge case: total is 0 (should not happen but should not panic/divide-by-zero)
|
||||
let output = "Used Size\n0 0\n";
|
||||
let output = "Used Size Avail\n0 0 0\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 0);
|
||||
assert_eq!(total, 0);
|
||||
@@ -338,21 +375,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_extra_whitespace() {
|
||||
let output = " Used Size \n 123456 7890000 \n";
|
||||
let output = " Used Size Avail \n 123456 7890000 7766544 \n";
|
||||
let (used, total, _) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 123456);
|
||||
assert_eq!(total, 7890000);
|
||||
assert_eq!(total, 7_890_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_real_world_format() {
|
||||
// Closer to real df output with header padding
|
||||
let output = " Used Size\n 328000000000 1800000000000\n";
|
||||
// Real df output carries a reserved-block gap: size here is 1.8 TB but
|
||||
// only 1.382 TB is available, so usable total is used + avail.
|
||||
let output = " Used Size Avail\n 328000000000 1800000000000 1382000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 328_000_000_000);
|
||||
assert_eq!(total, 1_800_000_000_000);
|
||||
// ~18.2%
|
||||
assert!(percent > 18.0 && percent < 19.0);
|
||||
assert_eq!(total, 1_710_000_000_000);
|
||||
// ~19.2% against usable space, not 18.2% against the raw device.
|
||||
assert!(percent > 19.0 && percent < 20.0, "got {percent}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
//! Host-level fixups: OS packages, kernel parameters and system services the
|
||||
//! node needs, delivered by the same signed-binary OTA that ships everything
|
||||
//! else (docs/system-level-ota-design.md).
|
||||
//!
|
||||
//! Scope and posture — read before adding anything here:
|
||||
//!
|
||||
//! * **Idempotent + non-fatal.** Every step is a no-op when the host already
|
||||
//! has the desired state, and a failure (offline box, locked dpkg, missing
|
||||
//! package in the release's Debian suite) logs a warning and moves on. A
|
||||
//! host fixup must never be able to stop the node from starting.
|
||||
//! * **Curated, pinned intent — not dist-upgrade automation.** We deliver the
|
||||
//! specific packages and settings a release deliberately adds (crash
|
||||
//! capture, hardware-error logging, later: unattended-upgrades posture, host
|
||||
//! firewall). Regular Debian upgrades stay with the operator; this channel
|
||||
//! never silently swaps a kernel or a libc.
|
||||
//! * **Fresh installs converge too.** The ISO bakes the same end state in
|
||||
//! (Dockerfile.rootfs, auto-install.sh cmdline), so the fixup is a no-op on
|
||||
//! new machines and only does real work on already-deployed nodes.
|
||||
//! * **Kernel cmdline can't move at runtime.** `crashkernel=` reserves memory
|
||||
//! at boot; the fixup writes GRUB and update-grub so the change lands on the
|
||||
//! next reboot, and says so in the log. Everything else (packages, sysctls,
|
||||
//! services) applies immediately.
|
||||
//!
|
||||
//! First payload (#144, docs/kdump-rasdaemon-design.md): kdump + rasdaemon —
|
||||
//! post-mortem and hardware-error capture:
|
||||
//! * kdump-tools/kexec-tools/rasdaemon installed
|
||||
//! * /etc/default/kdump-tools: USE_KDUMP=1, dumps to /var/crash, compressed
|
||||
//! core collector
|
||||
//! * /etc/sysctl.d/99-archipelago-kdump.conf: a wedged node dumps and
|
||||
//! reboots rather than sitting dead until power-cycled
|
||||
//! * crashkernel=256M appended to the installed GRUB cmdline (next reboot)
|
||||
//! * /var/crash pruned to the two newest dumps
|
||||
//!
|
||||
//! The module is skipped on dev boxes (same guard bootstrap::run uses) and on
|
||||
//! hosts without dpkg.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::update::host_sudo;
|
||||
|
||||
/// Packages the node's host must have. Keep this list short and justified —
|
||||
/// every entry is state we now own on the fleet's OS images.
|
||||
const HOST_PACKAGES: &[&str] = &["kdump-tools", "kexec-tools", "makedumpfile", "rasdaemon"];
|
||||
|
||||
/// Crash-kernel reservation. 256M covers the capture kernel plus makedumpfile
|
||||
/// on the fleet's 16–64GB amd64 machines (~1–2% of RAM, permanently reserved).
|
||||
/// The arm image (RPi) is out of scope for phase 1 — see the design doc.
|
||||
const CRASHKERNEL_PARAM: &str = "crashkernel=256M";
|
||||
|
||||
const KDUMP_SYSDROPIN_PATH: &str = "/etc/sysctl.d/99-archipelago-kdump.conf";
|
||||
const KDUMP_SYSDROPIN: &str = "\
|
||||
# Archipelago kdump policy (#144). A wedged kiosk is useless until someone
|
||||
# power-cycles it — capture the evidence, then reboot by itself. Dumps land in
|
||||
# /var/crash (see docs/kdump-rasdaemon-design.md); keep-2 pruning is done by
|
||||
# the host fixup pass, not a timer.
|
||||
kernel.panic = 10
|
||||
kernel.panic_on_oops = 1
|
||||
kernel.hung_task_panic = 1
|
||||
kernel.hardlockup_panic = 1
|
||||
";
|
||||
|
||||
/// How many dumps to keep in /var/crash. Two ≈ 4 GiB worst case on the 30 GiB
|
||||
/// unencrypted root — the partition usage itself is tracked by disk_monitor.
|
||||
const KEEP_DUMPS: usize = 2;
|
||||
|
||||
/// Entry point, spawned from main.rs at startup like the other ensure_* heals.
|
||||
pub async fn ensure_host_fixups() {
|
||||
// Dev-box guard (same rationale as bootstrap::run): on contributor
|
||||
// machines /home/archipelago/archy is a symlink into a git checkout and
|
||||
// the host is the contributor's own OS — never touch it.
|
||||
let home_archy = std::path::Path::new("/home/archipelago/archy");
|
||||
if tokio::fs::symlink_metadata(home_archy)
|
||||
.await
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
debug!("/home/archipelago/archy is a symlink — skipping host fixups (dev box)");
|
||||
return;
|
||||
}
|
||||
// Non-Debian hosts: nothing we manage here applies.
|
||||
if tokio::fs::symlink_metadata("/usr/bin/dpkg").await.is_err() {
|
||||
debug!("no dpkg on this host — skipping host fixups");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = run_host_fixups().await {
|
||||
warn!("host fixups failed (non-fatal): {:#}", e);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_host_fixups() -> Result<()> {
|
||||
// 1. Packages — install only what's missing; a locked/offline apt must
|
||||
// never block anything downstream (steps below degrade to no-ops).
|
||||
match ensure_packages().await {
|
||||
Ok(true) => info!("host fixups: installed missing packages"),
|
||||
Ok(false) => debug!("host fixups: all packages present"),
|
||||
Err(e) => warn!("host fixups: package install failed (non-fatal): {:#}", e),
|
||||
}
|
||||
|
||||
// 2. kdump config + sysctl drop-in + GRUB cmdline + services. One helper
|
||||
// per concern so a failure in one logs and leaves the others running.
|
||||
if let Err(e) = ensure_kdump_sysdropin().await {
|
||||
warn!(
|
||||
"host fixups: kdump sysctl drop-in failed (non-fatal): {:#}",
|
||||
e
|
||||
);
|
||||
}
|
||||
if let Err(e) = ensure_kdump_defaults().await {
|
||||
warn!(
|
||||
"host fixups: kdump-tools config failed (non-fatal): {:#}",
|
||||
e
|
||||
);
|
||||
}
|
||||
match ensure_crashkernel_cmdline().await? {
|
||||
true => {
|
||||
warn!("host fixups: crashkernel= written to GRUB — takes effect on the NEXT reboot")
|
||||
}
|
||||
false => debug!("host fixups: crashkernel already in GRUB cmdline"),
|
||||
}
|
||||
if let Err(e) = ensure_rasdaemon_enabled().await {
|
||||
warn!("host fixups: rasdaemon enable failed (non-fatal): {:#}", e);
|
||||
}
|
||||
if let Err(e) = prune_crash_dumps().await {
|
||||
debug!("host fixups: /var/crash prune skipped: {:#}", e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True if any package was installed. Mirrors the polkit repair's apt posture:
|
||||
/// install without `apt-get update` first; only if that fails (fresh suite,
|
||||
/// stale index), update once and retry. Both under timeout, both non-fatal.
|
||||
async fn ensure_packages() -> Result<bool> {
|
||||
// Package names are a fixed internal allowlist. Do not embed shell quote
|
||||
// characters in WANTED: quotes produced by variable expansion are data,
|
||||
// so dpkg-query would look for a package literally named 'kdump-tools'.
|
||||
let wanted = HOST_PACKAGES.join(" ");
|
||||
let script = format!(
|
||||
r#"
|
||||
set -u
|
||||
WANTED="{wanted}"
|
||||
MISSING=""
|
||||
for p in $WANTED; do
|
||||
dpkg-query -W -f='${{Status}}' "$p" 2>/dev/null | grep -q 'install ok installed' || MISSING="$MISSING $p"
|
||||
done
|
||||
[ -z "$MISSING" ] && exit 0
|
||||
timeout 240 apt-get install -y --no-install-recommends $MISSING >/dev/null 2>&1 \
|
||||
|| timeout 240 sh -c 'apt-get update >/dev/null 2>&1 && apt-get install -y --no-install-recommends $MISSING >/dev/null 2>&1' \
|
||||
|| exit 3
|
||||
exit 2
|
||||
"#
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("install host packages")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => Ok(true),
|
||||
code => anyhow::bail!("host package install exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the sysctl drop-in and apply it live (these four keys are all
|
||||
/// runtime-settable, so the hang/panic policy takes effect without a reboot).
|
||||
async fn ensure_kdump_sysdropin() -> Result<()> {
|
||||
let script = format!(
|
||||
r#"
|
||||
set -u
|
||||
PATH_FILE='{KDUMP_SYSDROPIN_PATH}'
|
||||
CONTENT_FILE=/tmp/archy-kdump-sysctl.$$.tmp
|
||||
cat > "$CONTENT_FILE" <<'SYSEOF'
|
||||
{KDUMP_SYSDROPIN}SYSEOF
|
||||
if [ -f "$PATH_FILE" ] && cmp -s "$CONTENT_FILE" "$PATH_FILE"; then
|
||||
rm -f "$CONTENT_FILE"
|
||||
exit 0
|
||||
fi
|
||||
mv "$CONTENT_FILE" "$PATH_FILE"
|
||||
chmod 644 "$PATH_FILE"
|
||||
sysctl --system >/dev/null 2>&1 || true
|
||||
exit 2
|
||||
"#
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("write kdump sysctl drop-in")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(()),
|
||||
Some(2) => {
|
||||
info!("host fixups: installed {KDUMP_SYSDROPIN_PATH} (hang/panic policy)");
|
||||
Ok(())
|
||||
}
|
||||
code => anyhow::bail!("kdump sysctl drop-in exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Point kdump-tools at /var/crash with a compressed core collector. Works on
|
||||
/// the package's shipped defaults file (USE_KDUMP=0, commented KDUMP_COREDIR)
|
||||
/// and on any state we already wrote — pure line surgery, idempotent.
|
||||
fn kdump_defaults_script(conf: &str) -> String {
|
||||
r#"
|
||||
set -u
|
||||
CONF='@@CONF@@'
|
||||
[ -f "$CONF" ] || exit 3
|
||||
CHANGED=0
|
||||
# Remove the one malformed line emitted by the old systemd-run environment
|
||||
# expansion bug before it was disabled. It makes every kdump-config invocation
|
||||
# print an error while sourcing this file.
|
||||
if grep -Fqx '=""' "$CONF"; then
|
||||
sed -i '/^=""$/d' "$CONF"
|
||||
CHANGED=1
|
||||
fi
|
||||
set_kv() {
|
||||
# Canonicalise KEY to one double-quoted assignment. Older fixup versions
|
||||
# could append duplicates because their exact-value check did not accept
|
||||
# double quotes; collapsing them also makes future passes idempotent.
|
||||
KEY="$1"; VAL="$2"
|
||||
EXPECTED="${KEY}=\"${VAL}\""
|
||||
COUNT=$(grep -c "^${KEY}=" "$CONF" 2>/dev/null || true)
|
||||
if [ "$COUNT" -eq 1 ] && grep -Fqx "$EXPECTED" "$CONF"; then
|
||||
return
|
||||
fi
|
||||
sed -i "/^${KEY}=/d" "$CONF"
|
||||
printf '\n%s\n' "$EXPECTED" >> "$CONF"
|
||||
CHANGED=1
|
||||
}
|
||||
set_kv USE_KDUMP 1
|
||||
set_kv KDUMP_COREDIR /var/crash
|
||||
set_kv CORE_COLLECTOR 'makedumpfile -l --message-level 1 -d 31'
|
||||
[ "$CHANGED" -eq 1 ] || exit 0
|
||||
systemctl enable kdump-tools >/dev/null 2>&1 || true
|
||||
exit 2
|
||||
"#
|
||||
.replace("@@CONF@@", conf)
|
||||
}
|
||||
|
||||
async fn ensure_kdump_defaults() -> Result<()> {
|
||||
let script = kdump_defaults_script("/etc/default/kdump-tools");
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("configure kdump-tools")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(()),
|
||||
Some(2) => {
|
||||
info!("host fixups: kdump-tools configured (USE_KDUMP=1, /var/crash)");
|
||||
Ok(())
|
||||
}
|
||||
code => anyhow::bail!("kdump-tools config exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the installed GRUB cmdline to one fixed `crashkernel=` reservation and
|
||||
/// run update-grub. Debian's kdump-tools package installs a grub.d snippet that
|
||||
/// otherwise appends its own range-based reservation after ours; on amd64 that
|
||||
/// silently wins and reserves only 192M instead of the intended 256M.
|
||||
/// The reservation itself only exists after the next reboot — memory cannot
|
||||
/// be set aside at runtime — so the caller must log the reboot caveat.
|
||||
/// Returns true if the generated cmdline changed.
|
||||
async fn ensure_crashkernel_cmdline() -> Result<bool> {
|
||||
let script = format!(
|
||||
r#"
|
||||
set -u
|
||||
GRUB=/etc/default/grub
|
||||
KDUMP_GRUB=/etc/default/grub.d/kdump-tools.cfg
|
||||
PARAM='{CRASHKERNEL_PARAM}'
|
||||
[ -f "$GRUB" ] || exit 3
|
||||
CHANGED=0
|
||||
# kdump-tools sources this after /etc/default/grub and unconditionally appends
|
||||
# crashkernel=512M-:192M. Neutralize that package default: Archipelago owns the
|
||||
# explicit fixed reservation in GRUB_CMDLINE_LINUX_DEFAULT below.
|
||||
if [ -f "$KDUMP_GRUB" ] && grep -qE '^[^#]*crashkernel=' "$KDUMP_GRUB"; then
|
||||
printf '%s\n' '# Archipelago owns crashkernel sizing in /etc/default/grub.' > "$KDUMP_GRUB"
|
||||
CHANGED=1
|
||||
fi
|
||||
LINE=$(grep -E '^GRUB_CMDLINE_LINUX_DEFAULT=' "$GRUB" | head -1)
|
||||
[ -n "$LINE" ] || exit 3
|
||||
# Remove any prior value before appending ours, so repeated fixups can never
|
||||
# create conflicting parameters whose kernel precedence is easy to misread.
|
||||
NEWLINE=$(printf '%s' "$LINE" | sed -E "s/[[:space:]]+crashkernel=[^ \"']+//g; s/\"$/ $PARAM\"/")
|
||||
if [ "$NEWLINE" != "$LINE" ]; then
|
||||
sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|$NEWLINE|" "$GRUB"
|
||||
CHANGED=1
|
||||
fi
|
||||
[ "$CHANGED" -eq 1 ] || exit 0
|
||||
timeout 120 update-grub >/dev/null 2>&1 || true
|
||||
exit 2
|
||||
"#
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("set crashkernel= in GRUB")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => Ok(true),
|
||||
code => anyhow::bail!("crashkernel cmdline fixup exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_rasdaemon_enabled() -> Result<()> {
|
||||
let status = host_sudo(&["systemctl", "enable", "--now", "rasdaemon"])
|
||||
.await
|
||||
.context("enable rasdaemon")?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("systemctl enable --now rasdaemon exited with {status}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep only the newest [`KEEP_DUMPS`] dumps in /var/crash. Called on every
|
||||
/// fixup pass rather than by a timer: the pass runs at every startup, which is
|
||||
/// exactly the cadence at which new dumps appear (a dump ends in a reboot).
|
||||
fn crash_dump_prune_script() -> String {
|
||||
format!(
|
||||
r#"
|
||||
set -u
|
||||
DIR=${{ARCHIPELAGO_CRASH_DIR:-/var/crash}}
|
||||
[ -d "$DIR" ] || exit 0
|
||||
KEEP={KEEP_DUMPS}
|
||||
# kdump-tools keeps its lock and kexec command files beside timestamped dump
|
||||
# directories. Count and prune directories only: treating those bookkeeping
|
||||
# files as dumps can delete the sole freshly captured vmcore on startup.
|
||||
COUNT=$(find "$DIR" -mindepth 1 -maxdepth 1 -type d -printf . | wc -c)
|
||||
[ "$COUNT" -gt "$KEEP" ] || exit 0
|
||||
find "$DIR" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\0' \
|
||||
| sort -zrn \
|
||||
| tail -z -n +"$((KEEP + 1))" \
|
||||
| cut -z -d ' ' -f 2- \
|
||||
| xargs -0r rm -rf --
|
||||
exit 2
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
async fn prune_crash_dumps() -> Result<()> {
|
||||
let script = crash_dump_prune_script();
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("prune /var/crash")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(()),
|
||||
Some(2) => {
|
||||
info!("host fixups: pruned old dumps in /var/crash (keep {KEEP_DUMPS})");
|
||||
Ok(())
|
||||
}
|
||||
code => anyhow::bail!("/var/crash prune exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sysctl_dropin_carries_the_full_hang_capture_policy() {
|
||||
for key in [
|
||||
"kernel.panic = 10",
|
||||
"kernel.panic_on_oops = 1",
|
||||
"kernel.hung_task_panic = 1",
|
||||
"kernel.hardlockup_panic = 1",
|
||||
] {
|
||||
assert!(KDUMP_SYSDROPIN.contains(key), "drop-in missing {key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_list_is_exactly_the_kdump_rasdaemon_set() {
|
||||
assert_eq!(
|
||||
HOST_PACKAGES,
|
||||
&["kdump-tools", "kexec-tools", "makedumpfile", "rasdaemon"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crashkernel_param_is_sized_and_unprefixed() {
|
||||
assert_eq!(CRASHKERNEL_PARAM, "crashkernel=256M");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_dumps_is_two() {
|
||||
assert_eq!(KEEP_DUMPS, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kdump_defaults_repairs_old_malformed_line_and_is_idempotent() {
|
||||
use std::{fs, process::Command};
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let conf = root.path().join("kdump-tools");
|
||||
let bin = root.path().join("bin");
|
||||
fs::create_dir(&bin).unwrap();
|
||||
fs::write(bin.join("systemctl"), "#!/bin/sh\nexit 0\n").unwrap();
|
||||
assert!(Command::new("chmod")
|
||||
.args(["+x"])
|
||||
.arg(bin.join("systemctl"))
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
fs::write(
|
||||
&conf,
|
||||
"# package defaults\n=\"\"\nUSE_KDUMP=0\nUSE_KDUMP=\"1\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let script = kdump_defaults_script(conf.to_str().unwrap());
|
||||
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap());
|
||||
let first = Command::new("sh")
|
||||
.args(["-lc", &script])
|
||||
.env("PATH", &path)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert_eq!(first.code(), Some(2));
|
||||
let repaired = fs::read_to_string(&conf).unwrap();
|
||||
assert!(!repaired.lines().any(|line| line == "=\"\""));
|
||||
assert_eq!(repaired.matches("USE_KDUMP=").count(), 1);
|
||||
assert!(repaired.contains("USE_KDUMP=\"1\""));
|
||||
assert!(repaired.contains("KDUMP_COREDIR=\"/var/crash\""));
|
||||
assert!(repaired.contains("CORE_COLLECTOR=\"makedumpfile -l --message-level 1 -d 31\""));
|
||||
|
||||
let second = Command::new("sh")
|
||||
.args(["-lc", &script])
|
||||
.env("PATH", path)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(second.success());
|
||||
assert_eq!(fs::read_to_string(conf).unwrap(), repaired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_pruning_ignores_kdump_bookkeeping_files() {
|
||||
use std::{fs, process::Command};
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let crash = root.path();
|
||||
fs::write(crash.join("kdump_lock"), []).unwrap();
|
||||
fs::write(crash.join("kexec_cmd"), "kexec -p").unwrap();
|
||||
|
||||
for (name, epoch) in [("old dump", "100"), ("middle", "200"), ("newest", "300")] {
|
||||
let path = crash.join(name);
|
||||
fs::create_dir(&path).unwrap();
|
||||
fs::write(path.join("vmcore"), name).unwrap();
|
||||
assert!(Command::new("touch")
|
||||
.args(["-d", &format!("@{epoch}")])
|
||||
.arg(&path)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
}
|
||||
|
||||
let status = Command::new("sh")
|
||||
.args(["-lc", &crash_dump_prune_script()])
|
||||
.env("ARCHIPELAGO_CRASH_DIR", crash)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert_eq!(status.code(), Some(2));
|
||||
assert!(!crash.join("old dump").exists());
|
||||
assert!(crash.join("middle").join("vmcore").exists());
|
||||
assert!(crash.join("newest").join("vmcore").exists());
|
||||
assert!(crash.join("kdump_lock").exists());
|
||||
assert!(crash.join("kexec_cmd").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_pruning_does_nothing_when_only_bookkeeping_files_exist() {
|
||||
use std::{fs, process::Command};
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for name in ["kdump_lock", "kexec_cmd", "another-marker"] {
|
||||
fs::write(root.path().join(name), []).unwrap();
|
||||
}
|
||||
let status = Command::new("sh")
|
||||
.args(["-lc", &crash_dump_prune_script()])
|
||||
.env("ARCHIPELAGO_CRASH_DIR", root.path())
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 3);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ mod entropy;
|
||||
mod federation;
|
||||
mod fips;
|
||||
mod health_monitor;
|
||||
mod host_fixups;
|
||||
mod host_ip;
|
||||
mod identity;
|
||||
mod identity_manager;
|
||||
@@ -435,6 +436,12 @@ async fn main() -> Result<()> {
|
||||
// iframe on kiosk nodes (docs/tv-input-iframe-apps.md).
|
||||
tokio::spawn(bootstrap::ensure_gamepad_keys());
|
||||
|
||||
// Host-level fixups (#144 + docs/system-level-ota-design.md): kdump +
|
||||
// rasdaemon — crash/hardware-error capture delivered to already-deployed
|
||||
// nodes over the signed binary OTA. Idempotent, non-fatal, background;
|
||||
// the crashkernel= GRUB edit lands on the next reboot.
|
||||
tokio::spawn(host_fixups::ensure_host_fixups());
|
||||
|
||||
// Mesh access: mirror IPv4-published app ports onto [::] so direct-port
|
||||
// app URLs (http://[<fips0 ULA>]:<port>) work from the companion.
|
||||
tokio::spawn(mesh_ports::run_mesh_port_mirror());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1218,6 +1222,19 @@ impl MeshService {
|
||||
Ok(dest_prefix)
|
||||
}
|
||||
|
||||
/// True if `contact_id` is reachable over the mesh radio right now — the
|
||||
/// same peer/twin resolution `peer_dest_prefix` performs, exposed as a
|
||||
/// cheap bool so RPC handlers can gate radio-only transports (LXMF
|
||||
/// native image, Reticulum resource transfer) without duplicating the
|
||||
/// twin-resolution logic. A federation-only contact_id with no matching
|
||||
/// radio twin returns false here — offering "resource-mesh" or native
|
||||
/// image to such a peer sends it straight into `peer_dest_prefix`'s
|
||||
/// "federation-only (no radio twin)" error (picture-send from a
|
||||
/// federation-only contact, 2026-08-07).
|
||||
pub async fn has_radio_route(&self, contact_id: u32) -> bool {
|
||||
self.peer_dest_prefix(contact_id).await.is_ok()
|
||||
}
|
||||
|
||||
/// Split an oversized wire payload into MC-framed base64 chunks and send
|
||||
/// each via the mesh device. Matches the receive-side reassembly in
|
||||
/// `mesh/listener/decode.rs::handle_chunked_frame` (header `MCIIXXTT`,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -1487,6 +1487,11 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
|
||||
"--quiet",
|
||||
"--collect",
|
||||
"--pipe",
|
||||
// Shell snippets passed as one argument must reach the child intact.
|
||||
// systemd-run otherwise expands $VAR/${VAR} against the manager's
|
||||
// environment before `sh -lc` can see them (and usually replaces them
|
||||
// with empty strings).
|
||||
"--expand-environment=no",
|
||||
"--",
|
||||
];
|
||||
full.extend_from_slice(args);
|
||||
@@ -1506,6 +1511,7 @@ pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Outp
|
||||
"--quiet",
|
||||
"--collect",
|
||||
"--pipe",
|
||||
"--expand-environment=no",
|
||||
"--",
|
||||
];
|
||||
full.extend_from_slice(args);
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
//! 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;
|
||||
@@ -24,10 +24,16 @@ use bitcoin::secp256k1::PublicKey;
|
||||
// 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";
|
||||
@@ -148,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) {
|
||||
@@ -553,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());
|
||||
@@ -602,7 +765,10 @@ mod tests {
|
||||
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");
|
||||
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());
|
||||
@@ -621,7 +787,10 @@ mod tests {
|
||||
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}");
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
@@ -396,9 +397,8 @@ pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> {
|
||||
mints: vec![network.default_mint()],
|
||||
}
|
||||
} else {
|
||||
serde_json::from_str(&content).with_context(|| {
|
||||
format!("Accepted-mints file {} is damaged", path.display())
|
||||
})?
|
||||
serde_json::from_str(&content)
|
||||
.with_context(|| format!("Accepted-mints file {} is damaged", path.display()))?
|
||||
};
|
||||
Ok(mints)
|
||||
}
|
||||
@@ -416,13 +416,26 @@ pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Resu
|
||||
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
|
||||
}
|
||||
|
||||
@@ -430,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();
|
||||
@@ -452,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
|
||||
}
|
||||
|
||||
@@ -460,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?;
|
||||
@@ -583,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
|
||||
@@ -722,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
|
||||
@@ -755,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);
|
||||
|
||||
@@ -804,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,
|
||||
@@ -898,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.
|
||||
///
|
||||
@@ -1018,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!(
|
||||
@@ -1151,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_") {
|
||||
@@ -1184,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();
|
||||
@@ -1300,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();
|
||||
|
||||
@@ -1325,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);
|
||||
|
||||
@@ -1361,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?;
|
||||
@@ -2061,7 +2314,11 @@ mod tests {
|
||||
// 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_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"));
|
||||
|
||||
@@ -2090,7 +2347,6 @@ mod tests {
|
||||
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();
|
||||
@@ -2104,7 +2360,9 @@ mod tests {
|
||||
|
||||
// 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");
|
||||
let err = load_wallet(dir)
|
||||
.await
|
||||
.expect_err("damaged wallet must error");
|
||||
assert!(
|
||||
err.to_string().contains("damaged"),
|
||||
"error should name the problem: {err}"
|
||||
@@ -2121,7 +2379,9 @@ mod tests {
|
||||
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");
|
||||
let w = load_wallet(dir)
|
||||
.await
|
||||
.expect("empty file is a fresh wallet");
|
||||
assert_eq!(w.balance(), 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@ use super::cashu::{
|
||||
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;
|
||||
@@ -130,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))
|
||||
@@ -143,6 +154,7 @@ impl MintClient {
|
||||
Ok(Self {
|
||||
url: mint_url.trim_end_matches('/').to_string(),
|
||||
client,
|
||||
recovery: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -151,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.
|
||||
@@ -210,6 +279,36 @@ impl MintClient {
|
||||
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?;
|
||||
@@ -224,9 +323,7 @@ impl MintClient {
|
||||
.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)
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("No active sat keyset found at mint {}", self.url))
|
||||
}
|
||||
|
||||
// ── Mint quotes (NUT-04) ──
|
||||
@@ -276,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()
|
||||
@@ -427,28 +511,17 @@ impl MintClient {
|
||||
"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");
|
||||
debug!(
|
||||
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
|
||||
);
|
||||
owned_targets = amount_to_denominations(spendable);
|
||||
&owned_targets
|
||||
} else {
|
||||
target_amounts
|
||||
};
|
||||
|
||||
let mut blinded_messages = Vec::new();
|
||||
let mut blinding_data = Vec::new();
|
||||
|
||||
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
|
||||
@@ -543,6 +616,90 @@ 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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1746,6 +1746,15 @@ 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,
|
||||
@@ -1762,7 +1771,7 @@ app:
|
||||
// stage timed out that cycle, so the count here lagged at 17.
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
26,
|
||||
28,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# HANDOFF — companion-agent work queue (2026-08-30)
|
||||
|
||||
**For: the companion agent.** Compiled from the 2026-08-30 issue-triage
|
||||
session. The tracker now labels the companion-owned issues `companion-agent`
|
||||
(#128, #139); this document adds the pointers and one small residual that
|
||||
isn't worth its own issue until it's being fixed.
|
||||
|
||||
## Pointers
|
||||
|
||||
- **App source:** `Android/` in this repo (Kotlin/Gradle). Release notes
|
||||
live in `Android/COMPANION_RELEASE.md`.
|
||||
- **Served artifact:** `neode-ui/public/packages/archipelago-companion.apk`
|
||||
+ `archipelago-companion.json` (currently **0.5.27 / versionCode 47**).
|
||||
Shipping a companion change means refreshing both in the same commit
|
||||
(versionCode +1) plus a COMPANION_RELEASE.md entry; nodes serve the file
|
||||
from the web bundle. The deploy/verify pipeline (aapt badging, size
|
||||
checks, node redeploy) is documented in
|
||||
`docs/HANDOFF-2026-07-23-companion-apk-deploy.md`.
|
||||
- **Web bridge:** `window.ArchipelagoNative` (JS interface the WebView
|
||||
injects); `isCompanionApp()` in `neode-ui/src/utils/openExternal.ts` is
|
||||
the canonical detection helper; `appLauncher.ts` shows the gating pattern.
|
||||
|
||||
## Work queue
|
||||
|
||||
### 1. Residual of #61 — companion-gate the store banner + intro overlay (small)
|
||||
|
||||
What #61 fixed was the AUTO-popup: `CompanionIntroOverlay` skips its
|
||||
mounted auto-show when `IN_COMPANION_APP` (the `ArchipelagoNative` bridge
|
||||
is present). Two paths are still ungated, so a user already inside the
|
||||
companion WebView still gets "install the companion" pitches:
|
||||
|
||||
- `<CompanionBanner />` in `neode-ui/src/views/Discover.vue:156` renders
|
||||
unconditionally.
|
||||
- `openCompanionIntro()` (`neode-ui/src/composables/useCompanionIntro.ts`)
|
||||
is an explicit trigger that intentionally bypasses the once-per-browser
|
||||
gate — but nothing companion-checks its callers.
|
||||
|
||||
Fix: gate the banner render and the intro-trigger entry points on
|
||||
`isCompanionApp()`, same pattern as `appLauncher.ts` (lines ~236/~341).
|
||||
Verify inside the companion WebView (banner absent, no manual path can pop
|
||||
the overlay). Land it in the web UI here; the APK doesn't change.
|
||||
|
||||
### 2. #128 — GrapheneOS phone backup & restore (feature)
|
||||
|
||||
Reporter's problem: losing your phone, or wiping it to cross a border.
|
||||
Reporter's suggestion: "part of the companion app or passport prime combo".
|
||||
The companion owns the phone side: trigger a GrapheneOS backup, transport
|
||||
it, and restore it onto a wiped device — coordinated with the node's
|
||||
existing encrypted-backup envelope (ADR-005: ChaCha20-Poly1305 +
|
||||
Argon2id, `core/archipelago/src/backup.rs`). **Reuse that envelope; do not
|
||||
invent a second backup format.** Node-side storage/quota/scheduling is
|
||||
tracked separately on the roadmap — coordinate before assuming node-side
|
||||
surface beyond the existing backup RPCs.
|
||||
|
||||
### 3. #139 — Nostr Bunker: companion-side remote signer (feature)
|
||||
|
||||
"Remote signer with companion app?" — the phone side of NIP-46: a bunker
|
||||
client in the companion (pairing with a node-side bunker service via
|
||||
QR/URI, a signature approve/deny UX that makes what's being signed legible,
|
||||
and saved-remote-bunker management). Background research already exists:
|
||||
`docs/nostr-signer-login-research.md`. The node-side bunker hosting is
|
||||
roadmap-tracked separately; this issue's companion label covers the
|
||||
phone-side integration.
|
||||
|
||||
## Working rules (same as the node repo)
|
||||
|
||||
- Small commits, pushed immediately; vitest for web-side changes; the
|
||||
Kotlin app's on-device flows get verified on a real device before the
|
||||
APK ships.
|
||||
- Node-side Rust changes are out of scope for the companion queue —
|
||||
anything that needs them goes through the labeled issues on the tracker.
|
||||
- Done = artifact refreshed (APK + json meta) so a web-bundle deploy can
|
||||
serve it, plus the issue updated with what shipped.
|
||||
@@ -54,6 +54,9 @@ step-by-step guides, and some predate the current implementation.
|
||||
- [Dual Ecash](dual-ecash-design.md)
|
||||
- [Hardware Signer](hardware-signer-design.md)
|
||||
- [Manifest Hooks](manifest-hooks-design.md)
|
||||
- [Peering & Federation Trust](peering-trust-model.md) — naming/semantics of trust levels vs discovery (#134)
|
||||
- [kdump + rasdaemon Troubleshooting](kdump-rasdaemon-design.md) — post-mortem and hardware-error capture on nodes (#144)
|
||||
- [System-Level OTA](system-level-ota-design.md) — how host-level packages/config reach already-deployed nodes
|
||||
- [Meshroller Integration](meshroller-integration-design.md)
|
||||
- [Nostr Git Source Hosting](nostr-git-source-hosting.md)
|
||||
- [Nostr Identity Import](nostr-identity-import-plan.md) · [Nostr Signer Login (research)](nostr-signer-login-research.md)
|
||||
@@ -86,4 +89,5 @@ file.
|
||||
## Roadmap & history
|
||||
|
||||
- [Roadmap](ROADMAP.md) — where the project is going
|
||||
- [TODO](TODO.md) — working backlog of unscoped forward-looking items
|
||||
- [archive/](archive/README.md) — superseded design and status documents, kept for provenance
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
# Release Notes Backlog
|
||||
|
||||
## Next Release Required Work
|
||||
## Required Work — completed 2026-08-30, before the v1.8.5-alpha cut
|
||||
|
||||
- Backfill missing or thin historical release notes before cutting the next release.
|
||||
- Audit every `CHANGELOG.md` section from `v1.7.44-alpha` through the current release.
|
||||
- Replace raw commit-hash entries with user/operator-facing bullets that explain behavior changes, operational impact, validation, and known limitations.
|
||||
- Ensure `releases/manifest.json` changelog entries come from curated `CHANGELOG.md` notes only.
|
||||
- [x] Backfill missing or thin historical release notes before cutting the next release.
|
||||
Eight sections backfilled, sourced from the Settings "What's New" blocks,
|
||||
the old-lineage release commits, and the diffs of the self-contained
|
||||
hotfix releases: **v1.7.44** (was raw commit-hash lines), **v1.7.47,
|
||||
v1.7.48, v1.7.64, v1.7.65** (were thin), and **v1.7.50, v1.7.51,
|
||||
v1.7.107** (sections were missing entirely — real releases with tags but
|
||||
no changelog section; v1.7.107 was restored verbatim from the curated
|
||||
version that existed at `35e9c624` and was later lost). The What's New
|
||||
modal blocks for the three restored versions were generated by
|
||||
`scripts/sync-whats-new.py`, which now passes with all 92 versions.
|
||||
- [x] Audit every `CHANGELOG.md` section from `v1.7.44-alpha` through the
|
||||
current release. Mechanical inventory of all 92 sections in range:
|
||||
every section carries ≥3 curated bullets, zero raw commit-hash entries.
|
||||
- [x] Replace raw commit-hash entries with user/operator-facing bullets
|
||||
that explain behavior changes, operational impact, validation, and
|
||||
known limitations. The only offender was v1.7.44 (four raw hash lines,
|
||||
now curated).
|
||||
- [x] Ensure `releases/manifest.json` changelog entries come from curated
|
||||
`CHANGELOG.md` notes only. Satisfied by construction:
|
||||
`create-release-manifest.sh` reads the changelog from `CHANGELOG.md`,
|
||||
and `check-release-manifest.sh` rejects manifests with fewer than three
|
||||
bullets or raw git-log lines before publishing.
|
||||
|
||||
## Release Note Policy
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# TODO
|
||||
|
||||
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
||||
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
||||
|
||||
## Dev & build process (priority)
|
||||
|
||||
- Formalize the contributor workflow: releases, CI, maintainers, automated
|
||||
builds, PR/issue flow, branch naming, and reproducible builds.
|
||||
|
||||
## Federation & peering
|
||||
|
||||
- Peering trust model — define tiers (trusted / public / private / peered)
|
||||
on top of the existing federation DID trust levels.
|
||||
- Federation architecture built on the above peering model.
|
||||
|
||||
## Distributed git & OTA
|
||||
|
||||
- Nostr-hosted git for the alpha (see
|
||||
[`nostr-git-source-hosting.md`](nostr-git-source-hosting.md)).
|
||||
- Distributed git beyond the nostr-hosting case.
|
||||
- Distributed OTA / app delivery.
|
||||
|
||||
## Nostr integration
|
||||
|
||||
- Nostr signer integration.
|
||||
|
||||
## Platform / OS
|
||||
|
||||
- Source-availability ISO — define the build/distribution story.
|
||||
- HW/OS update pipeline.
|
||||
- Deeper OpenWRT integration.
|
||||
- GrapheneOS integration — backups, attestation, profiles.
|
||||
|
||||
## App ecosystem
|
||||
|
||||
- Full pass testing every app in the catalog; expect issues across the board.
|
||||
- App update strategy — finalize the update policy referenced in
|
||||
[`app-developer-guide.md`](app-developer-guide.md) (pinned vs. mutable
|
||||
tags, catalog-vs-disk precedence, rollout/rollback).
|
||||
- App wishlist — candidates not yet packaged: Cashu wallet, phoenixd.
|
||||
(CLN is already shipped as `apps/core-lightning`.)
|
||||
|
||||
## Access & security
|
||||
|
||||
- SSH access strategy — define the access model (keys, rotation, recovery
|
||||
path, remote-support access).
|
||||
|
||||
## Observability
|
||||
|
||||
- Capture error logs to troubleshoot customer issues.
|
||||
- Stats & visualization for traffic, blocked attacks, VPNs, routing.
|
||||
@@ -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 |
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# kdump + rasdaemon — post-mortem and hardware-error capture (#144)
|
||||
|
||||
Status: IMPLEMENTED (phase 1) — decisions approved 2026-08-30: hang capture ON,
|
||||
crashkernel=256M, ship the backfill with this release, phase-2 UI deferred.
|
||||
Delivery: image-recipe (Dockerfile.rootfs, auto-install.sh cmdline) +
|
||||
`core/archipelago/src/host_fixups.rs` (existing nodes, see
|
||||
docs/system-level-ota-design.md) + `tests/lifecycle/os-audit.sh` section D.
|
||||
Owner: node image (image-recipe) + lifecycle gate
|
||||
Issue: #144 — "Configure kdump and rasdaemon for troubleshooting"
|
||||
|
||||
## The problem
|
||||
|
||||
When a fleet node hard-locks or a memory stick starts failing, today we get
|
||||
nothing: a frozen kiosk is power-cycled and the evidence is gone; a DIMM
|
||||
throwing correctable ECC errors for weeks is invisible until it starts
|
||||
corrupting things. Two standard kernel mechanisms capture this evidence:
|
||||
|
||||
- **kdump** — reserves a small crash kernel at boot; on a kernel panic (or,
|
||||
configured so, a hang) the running kernel hands the machine over to the
|
||||
crash kernel, which writes a compressed dump of memory to disk and
|
||||
reboots. The node comes back by itself *and* leaves a post-mortem.
|
||||
- **rasdaemon** — a userspace daemon that records hardware error events
|
||||
(correctable/uncorrectable ECC per DIMM, PCIe AER) from EDAC/sysfs into a
|
||||
sqlite database: persistent evidence of degrading hardware with no crash
|
||||
required.
|
||||
|
||||
## Facts the design rests on
|
||||
|
||||
- Installed-disk layout (auto-install.sh): BIOS boot 1MiB · EFI 512MiB ·
|
||||
**root ext4 30GiB, unencrypted** · data (rest, LUKS).
|
||||
- The data partition is LUKS and unlocked late by the node itself — the
|
||||
crash kernel must never be asked to handle key material.
|
||||
- The installed system's kernel command line is written by
|
||||
auto-install.sh:1810 (`GRUB_CMDLINE_LINUX_DEFAULT="quiet splash …"`).
|
||||
- Packages land via `Dockerfile.rootfs` (trixie) with `systemctl enable`
|
||||
in the same RUN block (nginx/tor/avahi pattern).
|
||||
- Kernel cmdline cannot be changed by OTA — it lives in GRUB. Existing
|
||||
nodes need a backfill step (bootstrap) plus a deliberate reboot.
|
||||
|
||||
## Design
|
||||
|
||||
### kdump
|
||||
|
||||
- **Packages:** `kdump-tools kexec-tools` added to Dockerfile.rootfs.
|
||||
- **Command line:** append `crashkernel=256M` to
|
||||
`GRUB_CMDLINE_LINUX_DEFAULT` in auto-install.sh. 256M covers the capture
|
||||
kernel plus makedumpfile on the fleet's 16–64GB amd64 machines (~1–2% of
|
||||
RAM reserved, permanently). The arm image (RPi, config.txt boot) is out
|
||||
of scope for phase 1.
|
||||
- **Dump target:** `local filesystem /var/crash` — on the unencrypted 30GiB
|
||||
root, deliberately *not* the encrypted data partition. No key handling
|
||||
in the crash initramfs, no dependency on the node's own unlock logic.
|
||||
- **Core collector:** `makedumpfile -l --message-level 1 -d 31`
|
||||
(compressed, zero/free pages excluded) — a dump lands at roughly 5–15%
|
||||
of RAM, i.e. ~1–2 GiB on a 16 GiB machine.
|
||||
- **Retention:** keep the **2 newest** dumps only. A small systemd timer
|
||||
(or kdump-tools' `KDUMP_POST_SCRIPT`) prunes older vmcores; a full root
|
||||
partition is already caught by disk_monitor's usage tracking. Two dumps
|
||||
≈ 4 GiB worst case on 30 GiB root — safe.
|
||||
- **When to dump — the deliberate trade-off (decision needed):**
|
||||
- Baseline: dump on real panics (`kernel.panic` path) — no behavioral
|
||||
change to a wedged node.
|
||||
- Recommended for this fleet: also enable hang capture
|
||||
(`kernel.hung_task_panic=1`, hardlockup via NMI watchdog). A kiosk
|
||||
that hard-locks is useless until power-cycled anyway; converting the
|
||||
hang into "dump + automatic reboot" turns every freeze into evidence
|
||||
*and* self-heals the node. Cost: a genuinely-busy-but-alive machine
|
||||
that trips the watchdog reboots — the threshold is kernel-default
|
||||
conservative (40s), so this should be rare.
|
||||
|
||||
### rasdaemon
|
||||
|
||||
- **Packages:** `rasdaemon`; `systemctl enable rasdaemon` in the
|
||||
Dockerfile.rootfs enable block (same pattern as nginx).
|
||||
- **Storage:** its default sqlite DB at
|
||||
`/var/lib/rasdaemon/ras-mc_event.db` on the unencrypted root.
|
||||
- **Human access today:** `ras-mc-ctl --summary` / `--errors` over SSH.
|
||||
No UI in phase 1.
|
||||
|
||||
### Surfacing (phase 2 — separate follow-up, not in this cut)
|
||||
|
||||
A small read-only `system.diagnostics` surface: last-crash timestamp and
|
||||
vmcore sizes from `/var/crash`, plus ECC error totals per DIMM from the
|
||||
rasdaemon DB — shown in Settings → System. Deliberately deferred: capture
|
||||
first, UI once there is something to show and a node in the fleet has
|
||||
actually produced a dump.
|
||||
|
||||
### Existing nodes (phase 1.5 backfill)
|
||||
|
||||
The OTA cannot change the bootloader. Bootstrap (which already delivers
|
||||
fixes to existing nodes) appends `crashkernel=256M` (and the chosen
|
||||
panic/hang params) to `/etc/default/grub` on machines that don't have it,
|
||||
and enables `rasdaemon` via the node's package install path. **Takes
|
||||
effect on the next reboot** — the operator reboots nodes when applying the
|
||||
release; no special ceremony needed beyond that.
|
||||
|
||||
## Testing
|
||||
|
||||
- Image: the new packages appear in the ISO; QEMU boot smoke
|
||||
(build-iso-release.sh stage 5) still green.
|
||||
- Lifecycle gate additions (bats, archi-dev-box first): `kdump-config show`
|
||||
reports a loaded crash kernel reservation; `systemctl is-active
|
||||
rasdaemon`; `/etc/default/grub` carries `crashkernel=`.
|
||||
- Live drill (once, on archi-dev-box, not in the gate): trigger
|
||||
`sysrq c` → vmcore appears in `/var/crash`, node reboots itself,
|
||||
second boot is clean. Keep this manual — it reboots the box.
|
||||
|
||||
## Implementation touchpoints
|
||||
|
||||
1. `image-recipe/build/auto-installer/Dockerfile.rootfs` — packages +
|
||||
`systemctl enable rasdaemon`.
|
||||
2. `image-recipe/build/auto-installer/installer-iso/archipelago/auto-install.sh:1810`
|
||||
— append `crashkernel=256M` (+ hang params if approved) to
|
||||
`GRUB_CMDLINE_LINUX_DEFAULT`.
|
||||
3. `kdump-tools` config: `/etc/default/kdump-tools` (dump target
|
||||
`/var/crash`, core_collector line, `KDUMP_POST_SCRIPT` or timer for
|
||||
retention).
|
||||
4. Bootstrap backfill for existing nodes.
|
||||
5. `tests/lifecycle` — presence assertions (crash kernel reserved,
|
||||
rasdaemon active).
|
||||
|
||||
## Decisions needed before implementation
|
||||
|
||||
1. **Hang capture on or off?** Recommended ON (`hung_task_panic=1` +
|
||||
NMI watchdog): every hard lockup becomes a dump + self-reboot. OFF
|
||||
means dumps only on true panics; wedged nodes still need the button.
|
||||
2. **crashkernel=256M vs 320M** — 256M is the common default for
|
||||
16–64GB machines; 320M if we expect large io-heavy kernels.
|
||||
3. **Backfill now or new-installs-only?** Recommended: ship the backfill
|
||||
with the next release so the whole fleet gains capture on reboot.
|
||||
4. Phase-2 UI surfacing scope — confirm "later" so phase 1 stays small.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Peering & Federation Trust — naming and semantics
|
||||
|
||||
Status: TERMINOLOGY SET — records what the code does today (#134).
|
||||
Deferred: the "don't advertise my peers" opt-out (see §Open questions).
|
||||
|
||||
The code is the authority; this doc gives names to the four concepts that
|
||||
issue #134 showed get conflated in conversation. Where a name changed in
|
||||
user-facing discussion, the term below is the one to use everywhere
|
||||
(UI copy, docs, issues, reviews).
|
||||
|
||||
## The four concepts
|
||||
|
||||
| Term (use this) | What it is | Where it lives |
|
||||
|---|---|---|
|
||||
| **Trusted peer** | A node THIS operator invited and verified: bilateral DID challenge over an out-of-band invite code (`federation::sync`, ADR-007). The only level that grants full access. | `TrustLevel::Trusted`, set via `TrustSource::Invite` or `Manual` |
|
||||
| **Discovered peer** | A peer we learned about from a Trusted peer's advertised list — the transitive merge. Never better than **Observer**: `TRUST IS NOT TRANSITIVE` (sync.rs guard). | `TrustLevel::Observer`, `TrustSource::TransitiveMerge` |
|
||||
| **Routing hint** | What a Discovered peer actually contributes: an address that lets us route directly over FIPS without a second invite hop. Reachability, not trust. | Observer-level sync + FIPS endpoint records |
|
||||
| **Peer advertisement** | The act of a Trusted peer sharing its own peer list during sync. This is the *mechanism* #134 observed — a feature, not a leak. | sync.rs merge path |
|
||||
|
||||
## The two rules that make it sound
|
||||
|
||||
1. **Trust requires an operator decision, always traceable.** Every trust
|
||||
level carries a `TrustSource`. Only a minted invite (or an explicit
|
||||
operator change) can produce `Trusted`; uninvited joins and transitive
|
||||
merges are hard-capped at `Observer` — a peer can never expand our
|
||||
trusted set on its own authority.
|
||||
2. **Discovery is transitive; trust is not.** Seeing more nodes through a
|
||||
Trusted peer is expected and useful (routing). Granting those nodes
|
||||
anything is an operator action, never automatic.
|
||||
|
||||
## Why a Trusted peer advertising its list is by design
|
||||
|
||||
Without advertisement, every new node needs a direct invite from every node
|
||||
that wants to reach it — the invite graph becomes the routing bottleneck
|
||||
AdDR-007 set out to remove. With it, one invite makes a node *reachable* to
|
||||
the trusted set (routing hints), while *authorization* still requires each
|
||||
operator's own invite. Reachability ≠ access.
|
||||
|
||||
## Open questions (deferred, tracked in #134)
|
||||
|
||||
- **"Don't advertise my peers"** — an operator privacy toggle suppressing
|
||||
peer advertisement during sync. Small code change, real design questions:
|
||||
it hides peers who may WANT discovery, and it degrades the routing benefit
|
||||
for every node trusting you. Needs a product decision, not just code.
|
||||
- **Tier vocabulary in the UI** — whether to surface "Observer" as such or
|
||||
a friendlier term ("Connected"/"Visible") — part of the TODO.md peering
|
||||
trust-model item.
|
||||
@@ -0,0 +1,82 @@
|
||||
# System-Level OTA — host fixups
|
||||
|
||||
Status: Implemented (first payload shipped alongside this doc)
|
||||
Owner: `core/archipelago/src/host_fixups.rs`
|
||||
Related: docs/kdump-rasdaemon-design.md (first payload), CLAUDE.md invariants
|
||||
|
||||
## The problem
|
||||
|
||||
The binary OTA updates the node's own software, and the signed app catalog
|
||||
updates apps. But the **host OS** — Debian packages, kernel parameters,
|
||||
system services — previously moved only through ISO re-installs. A node
|
||||
deployed a year ago can be running today's node software on a host that
|
||||
never gained anything the image learned since. Issue #99 (missing polkit
|
||||
rule on old nodes) and the audio-stack heal were each hand-carved
|
||||
one-off bootstrap repairs; there was no general channel and no stated
|
||||
policy for touching the host from the node.
|
||||
|
||||
## The mechanism
|
||||
|
||||
`host_fixups::ensure_host_fixups()` — spawned from `main.rs` at startup
|
||||
alongside the other `ensure_*` heals, in the background, best-effort:
|
||||
|
||||
1. **Dev-box guard** — skip when `/home/archipelago/archy` is a symlink
|
||||
(contributor checkout) and when there's no dpkg (non-Debian host).
|
||||
2. **Packages** — install only what's missing, from a curated, in-code
|
||||
list (`HOST_PACKAGES`), `apt-get install` first, one `apt-get update`
|
||||
retry, both under timeout, never fatal (offline/locked-dpkg nodes
|
||||
converge on a later boot).
|
||||
3. **Configuration** — idempotent per-concern helpers writing root-owned
|
||||
config (via the existing `host_sudo` path): sysctl drop-ins, service
|
||||
defaults, GRUB cmdline, service enablement.
|
||||
4. **Reporting** — every step logs what it did; failures log warnings and
|
||||
move on. A host fixup must never be able to stop the node from starting.
|
||||
|
||||
### Why embedded-in-the-binary rather than fetched
|
||||
|
||||
Same reasoning as the tor-helper (`bootstrap.rs`): the signed binary OTA
|
||||
is the only authenticated delivery channel every node already trusts and
|
||||
pulls on schedule. Fixups compiled into the binary travel with a version,
|
||||
are reviewable in git, and can't be served to a subset of the fleet.
|
||||
|
||||
## Policy — what may travel this channel
|
||||
|
||||
| May | May not |
|
||||
|---|---|
|
||||
| Specific, pinned packages the node needs (kdump-tools, rasdaemon, …) | `dist-upgrade` or silent kernel/libc swaps — regular Debian upgrades stay with the operator |
|
||||
| Kernel *parameters* via GRUB/sysctl — with the next-reboot caveat logged loudly | Anything requiring a secret, or touching LUKS key material |
|
||||
| Service enablement + config the image also bakes in | Divergence: the ISO must converge to the SAME end state so fresh installs are a no-op |
|
||||
| Small, reviewable, per-concern Rust functions with tests | Shell-script-of-things payloads beyond a single concern |
|
||||
|
||||
The rule: **the ISO and the fixup must express the same intent twice,
|
||||
in reviewable places** — Dockerfile.rootfs/auto-install.sh for fresh
|
||||
installs, `host_fixups.rs` for the deployed fleet. A change that lands in
|
||||
one and not the other is a bug.
|
||||
|
||||
## Kernel cmdline caveat
|
||||
|
||||
`crashkernel=` (and any future `hugepages=`-style reservation) only takes
|
||||
effect at boot: the fixup writes `/etc/default/grub` + `update-grub` and
|
||||
logs `takes effect on the NEXT reboot`. Operators reboot nodes when
|
||||
applying releases; no special ceremony is required beyond that, but the
|
||||
lifecycle gate grades this state honestly (WARN for written-but-not-yet-
|
||||
rebooted, FAIL for never-written — see `tests/lifecycle/os-audit.sh`
|
||||
section D).
|
||||
|
||||
## Verification story
|
||||
|
||||
- Unit tests pin the policy constants and script shapes
|
||||
(`host_fixups` tests in `core/archipelago`).
|
||||
- `tests/lifecycle/os-audit.sh` section D asserts the end state on a real
|
||||
node (config present, crashkernel reserved or pending reboot, hang
|
||||
policy live, rasdaemon active).
|
||||
- The lifecycle gate runs on archi-dev-box per release; the QEMU ISO
|
||||
smoke covers fresh installs.
|
||||
|
||||
## Future payloads (candidates, not commitments)
|
||||
|
||||
- `unattended-upgrades` posture + a default-deny host nftables ruleset
|
||||
(the §F hardening-plan item — needs its own design first).
|
||||
- Host firewall rules for mesh/WG ports.
|
||||
- Chronic: anything the image learns post-deploy that old nodes must
|
||||
converge on (the polkit and audio precedents, formalized).
|
||||
@@ -567,6 +567,33 @@ RUN mkdir -p /etc/polkit-1/rules.d && \
|
||||
> /etc/polkit-1/rules.d/49-archipelago-networkmanager.rules && \
|
||||
chmod 644 /etc/polkit-1/rules.d/49-archipelago-networkmanager.rules
|
||||
|
||||
# kdump + rasdaemon (#144, docs/kdump-rasdaemon-design.md): crash dumps and
|
||||
# hardware-error capture on the host. Packages + config are baked in for fresh
|
||||
# installs; the binary's host_fixups module delivers the identical end state to
|
||||
# already-deployed nodes over OTA (idempotent no-op here once applied).
|
||||
RUN set -eu; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends kdump-tools kexec-tools rasdaemon; \
|
||||
apt-get clean; rm -rf /var/lib/apt/lists/*; \
|
||||
CONF=/etc/default/kdump-tools; \
|
||||
sed -i 's|^#\?USE_KDUMP=.*|USE_KDUMP="1"|' "$CONF"; \
|
||||
grep -q '^KDUMP_COREDIR=' "$CONF" \
|
||||
&& sed -i 's|^KDUMP_COREDIR=.*|KDUMP_COREDIR="/var/crash"|' "$CONF" \
|
||||
|| printf '\nKDUMP_COREDIR="/var/crash"\n' >> "$CONF"; \
|
||||
grep -q '^CORE_COLLECTOR=' "$CONF" \
|
||||
&& sed -i 's|^CORE_COLLECTOR=.*|CORE_COLLECTOR="makedumpfile -l --message-level 1 -d 31"|' "$CONF" \
|
||||
|| printf '\nCORE_COLLECTOR="makedumpfile -l --message-level 1 -d 31"\n' >> "$CONF"; \
|
||||
printf '%s\n' \
|
||||
'# Archipelago kdump policy (#144). A wedged kiosk is useless until someone' \
|
||||
'# power-cycles it — capture the evidence, then reboot by itself. Dumps land in' \
|
||||
'# /var/crash (see docs/kdump-rasdaemon-design.md); keep-2 pruning is done by' \
|
||||
'# the host fixup pass, not a timer.' \
|
||||
'kernel.panic = 10' \
|
||||
'kernel.panic_on_oops = 1' \
|
||||
'kernel.hung_task_panic = 1' \
|
||||
'kernel.hardlockup_panic = 1' \
|
||||
> /etc/sysctl.d/99-archipelago-kdump.conf
|
||||
|
||||
# Enable services
|
||||
RUN systemctl enable NetworkManager || true && \
|
||||
systemctl enable polkit || systemctl enable polkit.service || true && \
|
||||
@@ -580,7 +607,9 @@ RUN systemctl enable NetworkManager || true && \
|
||||
systemctl enable archipelago-update.timer || true && \
|
||||
systemctl enable archipelago-doctor.timer || true && \
|
||||
systemctl enable archipelago-tor-helper.path || true && \
|
||||
systemctl enable nostr-relay || true
|
||||
systemctl enable nostr-relay || true && \
|
||||
systemctl enable rasdaemon || true && \
|
||||
systemctl enable kdump-tools || true
|
||||
# archipelago-fips.service + archipelago-wg.service + archipelago-wg-address.service
|
||||
# stay installed and enabled. They all use `ConditionPathExists=` on their
|
||||
# respective seed-derived key files, so on a fresh pre-onboarding boot
|
||||
@@ -3715,8 +3744,15 @@ if [ -d "$BOOT_MEDIA/archipelago/plymouth-theme" ]; then
|
||||
ln -sf /usr/share/plymouth/themes/archipelago/archipelago.plymouth \
|
||||
/mnt/target/etc/alternatives/default.plymouth 2>/dev/null || true
|
||||
# Configure clean boot: splash, suppress kernel noise, hide cursor
|
||||
sed -i 's/GRUB_CMDLINE_LINUX_DEFAULT=".*"/GRUB_CMDLINE_LINUX_DEFAULT="quiet splash loglevel=0 rd.systemd.show_status=false vt.global_cursor_default=0 acpi=force"/' \
|
||||
sed -i 's/GRUB_CMDLINE_LINUX_DEFAULT=".*"/GRUB_CMDLINE_LINUX_DEFAULT="quiet splash loglevel=0 rd.systemd.show_status=false vt.global_cursor_default=0 acpi=force crashkernel=256M"/' \
|
||||
/mnt/target/etc/default/grub 2>/dev/null || true
|
||||
# kdump-tools ships a grub.d snippet that appends crashkernel=512M-:192M
|
||||
# after this line. The later value silently wins on amd64, so neutralize
|
||||
# the package default and keep Archipelago's explicit fixed reservation.
|
||||
if [ -f /mnt/target/etc/default/grub.d/kdump-tools.cfg ]; then
|
||||
printf '%s\n' '# Archipelago owns crashkernel sizing in /etc/default/grub.' \
|
||||
> /mnt/target/etc/default/grub.d/kdump-tools.cfg
|
||||
fi
|
||||
echo " Installed Archipelago Plymouth theme on target"
|
||||
fi
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user