Compare commits
130
Commits
@@ -35,6 +35,13 @@ class InputWebSocket(
|
||||
/** Player ID for arcade mode (0 = broadcast, 1 = P1, 2 = P2) */
|
||||
var playerId: Int = 0
|
||||
|
||||
/**
|
||||
* Invoked when the kiosk asks us to open a URL in the phone's default
|
||||
* browser ({"t":"o","url":"…"}). "Open in external browser" apps can't be
|
||||
* usefully opened on the kiosk, so the kiosk forwards them here.
|
||||
*/
|
||||
var onExternalOpen: ((String) -> Unit)? = null
|
||||
|
||||
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||
val state: StateFlow<ConnectionState> = _state
|
||||
|
||||
@@ -127,6 +134,20 @@ class InputWebSocket(
|
||||
reconnectAttempt = 0
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
// The only inbound message we act on is an external-open request
|
||||
// forwarded from the kiosk: {"t":"o","url":"https://…"}.
|
||||
try {
|
||||
val obj = org.json.JSONObject(text)
|
||||
if (obj.optString("t") == "o") {
|
||||
val url = obj.optString("url")
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
onExternalOpen?.invoke(url)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
_state.value = ConnectionState.ERROR
|
||||
scheduleReconnect()
|
||||
|
||||
@@ -63,6 +63,21 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
|
||||
val ws = remember { InputWebSocket(scope) }
|
||||
|
||||
// When the kiosk forwards an "open in external browser" app, launch it in
|
||||
// the phone's default browser.
|
||||
DisposableEffect(ws) {
|
||||
ws.onExternalOpen = { url ->
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
).apply { addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) }
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
onDispose { ws.onExternalOpen = null }
|
||||
}
|
||||
|
||||
fun togglePlayer() {
|
||||
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
|
||||
ws.playerId = playerId
|
||||
|
||||
@@ -1,5 +1,87 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.99-alpha (2026-06-17)
|
||||
|
||||
- Your node can now hold Fedimint ecash as well as Cashu. Wallet Settings now has tabbed sections for each: keep your list of trusted Cashu mints, or paste a Fedimint invite code to join a federation, and the home wallet card shows both your Cashu and Fedimint balances side by side. A new "Fedimint Client" app in the catalog powers the federation side.
|
||||
- You can now buy files shared by another node, right from their cloud. When you open a peer's paid file you get a simple "Buy this file" picker with several ways to pay — instantly from this node's ecash balance, from your node's own Lightning wallet, on-chain from your node, or by scanning a Lightning QR code with any outside wallet. Once payment settles, the file downloads automatically.
|
||||
- Your node can now act as an AI assistant on the off-grid mesh radio network. If your node has a local AI model available (via Ollama), other people on the mesh can ask it a question by starting their message with "!ai" and get an answer back over the radio — handy where there's no internet. A new Mesh assistant panel lets you turn this on or off and shows whether a local AI model was detected.
|
||||
- You can now view your node's 24-word recovery phrase whenever you need it. Settings has a new "Recovery phrase" option that, after you confirm your password (and 2FA code if you use one), reveals the words behind a tap-to-show blur with a copy button — so you can write them down and store them safely offline.
|
||||
- Setting up a brand-new node is smoother and less alarming. If the node is still starting up while you generate or confirm your recovery phrase, it now quietly waits and retries instead of flashing a scary error, and offers a clear "Try again" button only when something genuinely goes wrong. The final setup screen also shows a gentle "securing your private connection…" status that turns to "ready" on its own, so you can tell the encrypted transport is coming up rather than stuck.
|
||||
- The NetBird VPN app now actually logs in. It was failing to reach its sign-in screen because the dashboard needs a secure (HTTPS) connection that wasn't being provided; the node now serves it over HTTPS and opens it in a browser tab, so the login flow completes.
|
||||
- When you use your phone to remote-control a node's attached screen, two-finger scrolling now works inside apps and panels, not just the main page. And tapping an app that's meant to open in an external browser now hands the link to your phone to open there, instead of trying to open it on the (often unattended) attached display.
|
||||
- You can now choose whether your node shares Bitcoin block headers over the mesh. The Mesh Bitcoin panel has new switches to announce headers to peers and to accept headers from them, and your choices are remembered.
|
||||
- Version numbers now display cleanly everywhere. In a few places the interface was showing a doubled "v" (like "vv1.7.98"); it now always shows a single, tidy version label.
|
||||
- The "Back" buttons throughout the cloud and other detail screens now look and behave consistently on both desktop and mobile, including when browsing another node's files.
|
||||
- For advanced testing, Settings now includes an optional "update & app source" choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode that pulls updates and app content from other nodes first, falling back to the origin automatically. The trusted origin remains the default.
|
||||
|
||||
## v1.7.98-alpha (2026-06-16)
|
||||
|
||||
- Apps that crash now recover on their own. Multi-part apps like Immich and IndeedHub could have one of their pieces stop and stay stopped until the whole node was rebooted; the node now checks every couple of minutes and restarts any crashed piece automatically (while still leaving apps you deliberately stopped alone).
|
||||
- The on-screen kiosk display can no longer slow the whole node down. On machines without a graphics chip the kiosk browser could spin a CPU core at full tilt, starving everything else (including the wallet, which then timed out); it's now capped and uses lighter rendering on those machines.
|
||||
- If an update download fails, you're taken back to the Download button to retry, instead of being stranded on an Install button for an update that didn't actually finish downloading.
|
||||
- Your node's identity is clearer and always visible: Settings now shows your Node DID on every node (it previously only appeared if your browser had cached it) plus your node's npub, both with copy buttons. There's also a terminal tool to cryptographically prove all your node's keys come from your one seed phrase.
|
||||
- The "all nodes over Tor" group chat sends quickly now — the "sending" spinner clears as soon as the reachable nodes have the message, instead of hanging on a slow or offline node.
|
||||
- Message notifications now have a close button and open the relevant chat when tapped.
|
||||
- The encrypted mesh transport (FIPS) turns itself on automatically after setup — no button to press — and connects to peers more reliably (it retries and keeps connections warm), so node-to-node features use the fast path more often instead of falling back to Tor.
|
||||
- Your chat history with other nodes is saved reliably and now encrypted on disk, so it survives restarts and updates and can't be read from a stolen drive (only clearing chat removes it).
|
||||
- Peer media shows a "connecting" loader before a video or audio file plays, and audio errors are accurate instead of blaming File Browser.
|
||||
- The Fedimint app now displays with its proper styling, and the Connected Nodes screen stays compact — it shows a few nodes and scrolls, you can tap a node to jump to it in Federation, or tap Message to open its chat.
|
||||
- App updates can now arrive on their own without waiting for a full system release, so individual apps can be improved and shipped faster.
|
||||
|
||||
## v1.7.97-alpha (2026-06-16)
|
||||
|
||||
- The Bitcoin sync status on the home screen no longer disappears for a moment when it refreshes. If the node was briefly busy, the panel used to vanish and pop back; it now stays put and simply shows "Updating…" until the next reading arrives, while a genuinely stopped node still correctly shows as not running.
|
||||
- Bitcoin sync progress on the home screen now updates more promptly, so the percentage and block height keep pace with the node instead of lagging behind.
|
||||
- The Lightning wallet "connect your wallet" screen loads its details and QR code again across all nodes, instead of failing to fetch them.
|
||||
- Your list of trusted nodes is now clean: the same node no longer appears several times under different names, and removed nodes stay removed. In chat, a node that previously showed up as two separate contacts now appears just once.
|
||||
- Browsing another node's cloud is smoother: music and video files from a peer now preview and play properly (including seeking partway through), and the connection now shows a small badge telling you whether it's using the fast encrypted mesh or the slower Tor network.
|
||||
- Opening "My Folders" in the cloud now shows a clear, friendly message when the file app isn't running, instead of a confusing error.
|
||||
- The Electrum server app opens on its own once it's ready, instead of sometimes leaving a loading spinner stuck on top of the screen.
|
||||
- The Fedimint app now displays with its proper styling and icons, instead of appearing unstyled with a missing image.
|
||||
- The Mempool app now connects to your Bitcoin node whether the node is Bitcoin Core or Bitcoin Knots, instead of only working with one of them.
|
||||
- Nodes start up cleanly after a reboot. On some boots the node's main service was trying to start before its data drive had finished mounting, so it failed and retried about twenty times over roughly five minutes — showing a wall of "Failed to start" messages — before finally coming up. It now waits for the data drive to be ready first, so it starts on the first try.
|
||||
- The background images throughout the interface now load faster — they've been made significantly smaller with no loss of quality.
|
||||
|
||||
## v1.7.96-alpha (2026-06-15)
|
||||
|
||||
- The screen attached to your node now shows the normal Archipelago interface and your dashboard after you sign in, instead of a separate, stripped-down grid of app icons that could appear in its place. That extra screen has been removed so the attached display matches what you see everywhere else.
|
||||
- On a brand-new node, the attached screen now walks through the same welcome and setup steps you'd see on a phone or laptop, and shows the normal sign-in screen once the node is set up — so the on-device display always matches the rest of the interface.
|
||||
- When adding a FIPS network anchor, you can now choose whether it connects over TCP (for a public anchor reached across the internet) or UDP (for one on your local network), instead of it always assuming the local-network option.
|
||||
- Behind the scenes, a new automated two-node test now exercises real node-to-node features — browsing another node's shared files and handling a removed node — against live nodes before each release, so node-to-node problems are caught earlier.
|
||||
|
||||
## v1.7.95-alpha (2026-06-15)
|
||||
|
||||
- Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic "Operation failed" message because the request for their file list wasn't permitted over the mesh and came back as "not found" — and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up.
|
||||
- Nodes you remove from your federation now stay removed. Previously a deleted node could quietly come back the next time you synced with another node that still listed it. Removed nodes are now remembered as removed and won't reappear on their own — only if you add them back yourself.
|
||||
- The app credentials pop-up now appears as a normal centred box with a dimmed background over the whole screen, instead of stretching to fill the entire screen.
|
||||
|
||||
## v1.7.94-alpha (2026-06-15)
|
||||
|
||||
- Your node now joins the private encrypted mesh network on its own. A wrong built-in setting meant nodes were quietly never reaching the shared mesh meeting point, so everything between nodes fell back to the slower Tor network. Every node now connects to the mesh automatically on startup, so node-to-node features like file sharing use the faster encrypted mesh first and only fall back to Tor when a peer is genuinely offline. (Confirmed live: a node with its mesh setting wiped re-connected to the mesh by itself within a second of starting.)
|
||||
- You can now bring the mesh networking software up to the latest stable version straight from the node, with one action — it fetches the new version, checks it's genuine before installing, and restarts the mesh on its own. (Confirmed live end to end: a node on an older build was upgraded to the current stable release and rejoined the mesh automatically.)
|
||||
- The Lightning wallet screen connects again on nodes where it was showing a "failed to fetch" error instead of your balance and channels. The wallet app and the node now talk to each other correctly, and the connection quietly repairs itself if its details drift after a restart.
|
||||
|
||||
## v1.7.93-alpha (2026-06-14)
|
||||
|
||||
- Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so "generate a receive address" kept failing with a "wallet is locked" message that nothing could clear. The node now detects this and repairs itself automatically.
|
||||
- Each node now secures its Lightning wallet with its own unique, randomly generated password instead of a shared built-in one, and remembers it safely so the wallet unlocks on its own after every restart or update — no more getting stuck locked.
|
||||
- If a wallet is found locked with an unrecoverable password, the node rebuilds it cleanly so Bitcoin and Lightning start working again. (On these early-access nodes the wallet holds no funds, so nothing is lost — a wallet locked with an unknown password was already inaccessible.)
|
||||
- The self-repair was validated end to end on live nodes: a stuck, locked wallet was detected, rebuilt, and came back unlocked on its own, and stayed unlocked across restarts.
|
||||
|
||||
## v1.7.92-alpha (2026-06-14)
|
||||
|
||||
- The Electrum server app no longer flashes a "can't connect, try again" error over its loading screen while it's still catching up. If ElectrumX is building its index or waiting on the Bitcoin node, you now just see the sync progress, and the app opens on its own once it's ready.
|
||||
- Behind the scenes, the reboot-survival test now confirms the whole system is genuinely healthy after a restart — every app reachable, updates not stuck, core services answering — instead of only checking that containers came back, so update-related problems are caught before shipping.
|
||||
- Settings → What's New now lists the notes for every recent release again. The screen had quietly fallen several versions behind, so the last eight releases of changes weren't showing up there — they're all back now, and a release check keeps it from drifting again.
|
||||
|
||||
## v1.7.91-alpha (2026-06-14)
|
||||
|
||||
- Apps you've installed now reliably show their "Open" button again. Some apps — including Jellyfin, BTCPay Server, Fedimint, Gitea and Portainer — were running fine but their launch link sometimes went missing, so there was no way to open them from the home screen. They now open correctly.
|
||||
- Receiving Bitcoin is more dependable: if the wallet's internal connection details drift after a restart, it now repairs them on its own, and any error it does hit is reported clearly instead of as a generic failure or a misleading "wallet locked" message.
|
||||
- Installing Bitcoin now sets itself up correctly without manual help — a security credential that could previously be missing and stop Bitcoin from starting is created automatically before it launches.
|
||||
- The Electrum server app is back on the home screen and can be launched again.
|
||||
- Behind the scenes, the release now runs an expanded automated test suite before shipping, so these kinds of issues are caught earlier.
|
||||
|
||||
## v1.7.90-alpha (2026-06-13)
|
||||
|
||||
- Generating a Bitcoin receive address works again — the wallet now requests the correct address type, fixing the "400 Bad Request" error when creating an address.
|
||||
|
||||
@@ -290,6 +290,18 @@
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
|
||||
"repoUrl": "https://github.com/fedimint/fedimint"
|
||||
},
|
||||
{
|
||||
"id": "fedimint-clientd",
|
||||
"title": "Fedimint Client",
|
||||
"version": "0.8.0",
|
||||
"description": "Fedimint ecash client daemon (fmcd). Lets your node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.0",
|
||||
"repoUrl": "https://github.com/minmoto/fmcd"
|
||||
},
|
||||
{
|
||||
"id": "fedimint-gateway",
|
||||
"title": "Fedimint Gateway",
|
||||
|
||||
@@ -51,6 +51,20 @@ app:
|
||||
- CACHE_MB=1024
|
||||
- MAX_SEND=10000000
|
||||
|
||||
# The ElectrumX dashboard tile is served by the host-networked companion UI
|
||||
# (archy-electrs-ui) on port 50002, NOT by this container. Declaring it here
|
||||
# lets the catalog generator emit electrumx -> 50002 into GENERATED_APP_PORTS
|
||||
# so the tile resolves a launch URL without relying on the hand-maintained
|
||||
# override in appSessionConfig.ts (which the generator can clobber). The
|
||||
# backend only validates this block — it does not proxy/health-check it.
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: ElectrumX server status and connection details
|
||||
type: ui
|
||||
port: 50002
|
||||
protocol: http
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:50001
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
app:
|
||||
id: fedimint-clientd
|
||||
name: Fedimint Client
|
||||
version: 0.8.0
|
||||
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:
|
||||
# fmcd built from source (github.com/minmoto/fmcd v0.8.0, fedimint-client
|
||||
# 0.8.2 — iroh-capable). No usable upstream image exists, so we build + push
|
||||
# this to the node registry. Pin the tag to match the REST shapes coded in
|
||||
# core/archipelago/src/wallet/fedimint_client.rs (validated against 0.8.2).
|
||||
image: 146.59.87.168:3000/lfg2025/fmcd:0.8.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# No entrypoint override: the image's resilient `fmcd-run` launcher loops
|
||||
# fmcd and retries on join failure (fmcd needs >=1 federation to boot), so an
|
||||
# unreachable default never crash-loops. All config comes from FMCD_* env
|
||||
# below. Nodes can join more federations via wallet.fedimint-join.
|
||||
secret_env:
|
||||
- key: FMCD_PASSWORD
|
||||
secret_file: fmcd-password
|
||||
data_uid: "1000:1000"
|
||||
|
||||
# NOTE: this is a CLIENT, not the guardian — it does not require the local
|
||||
# `fedimint` app. It joins external federations (default below), so it can be
|
||||
# bundled standalone on every node.
|
||||
dependencies:
|
||||
- storage: 2Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 2Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
|
||||
# relays to reach iroh-transport federations. Lock down once the default
|
||||
# federation's reachability model is finalized.
|
||||
network_policy: open
|
||||
|
||||
ports:
|
||||
# fmcd REST bound to 8080 in-container; 8080 collides with LND REST on the
|
||||
# host, so map to 8178. The Rust bridge targets http://127.0.0.1:8178.
|
||||
- host: 8178
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
# Same dir the first-boot bundled path uses + where the wallet bridge reads
|
||||
# the password (/var/lib/archipelago/fmcd/password) — keep install paths aligned.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/fmcd
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- FMCD_ADDR=0.0.0.0:8080
|
||||
- FMCD_MODE=rest
|
||||
- FMCD_DATA_DIR=/data
|
||||
# Default federation joined out-of-the-box (guardian on .116, iroh
|
||||
# transport; validated to join with fmcd 0.8.2). iroh does NAT traversal so
|
||||
# it's reachable fleet-wide. Keep in sync with DEFAULT_FEDERATION_INVITE in
|
||||
# core/.../wallet/fedimint_client.rs. CAVEAT: iroh is experimental — validate
|
||||
# join reliability from a real second node before relying on auto-bundle.
|
||||
- FMCD_INVITE_CODE=fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8080
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,42 @@
|
||||
app:
|
||||
id: fips-ui
|
||||
name: FIPS Mesh
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
|
||||
inside a container with host networking, serves a static dashboard on
|
||||
:8336, and reverse-proxies /rpc/v1 to the archipelago backend on
|
||||
127.0.0.1:5678. All FIPS controls (status, seed anchors, reconnect,
|
||||
restart, and stable-channel daemon updates) go through the existing
|
||||
fips.* RPC methods, authenticated by the browser's own archipelago
|
||||
session — there is no separate secret to manage.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/fips-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/fips-ui:local
|
||||
|
||||
resources:
|
||||
memory_limit: 128Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 8336 directly on the host IP and
|
||||
# proxies to 127.0.0.1:5678 (the archipelago RPC). `ports:` is
|
||||
# intentionally empty because host networking bypasses port mapping.
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8336
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -8,6 +8,12 @@ app:
|
||||
image: git.tx1138.com/lfg2025/mempool-backend:v3.0.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
|
||||
# Core — resolved at apply time from host facts (B12). Hardcoding either
|
||||
# breaks mempool's RPC connection on the other.
|
||||
derived_env:
|
||||
- key: CORE_RPC_HOST
|
||||
template: "{{BITCOIN_HOST}}"
|
||||
secret_env:
|
||||
- key: CORE_RPC_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
@@ -47,7 +53,6 @@ app:
|
||||
- ELECTRUM_HOST=electrumx
|
||||
- ELECTRUM_PORT=50001
|
||||
- ELECTRUM_TLS_ENABLED=false
|
||||
- CORE_RPC_HOST=bitcoin-knots
|
||||
- CORE_RPC_PORT=8332
|
||||
- CORE_RPC_USERNAME=archipelago
|
||||
- DATABASE_ENABLED=true
|
||||
|
||||
Generated
+3077
-91
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.90-alpha"
|
||||
version = "1.7.99-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
@@ -9,6 +9,16 @@ authors = ["Archipelago Team"]
|
||||
name = "archipelago"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# DHT Phase 2: iroh-blobs peer swarm engine. OFF by default — it pulls a heavy
|
||||
# QUIC dependency tree, so it ships behind a flag for PoC/measurement on a
|
||||
# scratch node before any fleet rollout. With the flag off, swarm::providers()
|
||||
# is empty and every fetch goes straight to the origin HTTP path (today's
|
||||
# behaviour). Attach the optional iroh / iroh-blobs deps to this feature when
|
||||
# wiring the IrohProvider.
|
||||
iroh-swarm = ["dep:iroh", "dep:iroh-blobs"]
|
||||
|
||||
[dependencies]
|
||||
# Core dependencies
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -42,6 +52,7 @@ archipelago-performance = { path = "../performance" }
|
||||
# Authentication
|
||||
bcrypt = "0.15"
|
||||
sha2 = "0.10.9"
|
||||
blake3 = "1"
|
||||
hmac = "0.12.1"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
regex = "1.10"
|
||||
@@ -64,7 +75,7 @@ serde_yaml = "0.9"
|
||||
|
||||
# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging)
|
||||
# Uses rustls-tls for cross-compilation (no OpenSSL dependency)
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls"] }
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
|
||||
|
||||
# Nostr (node discovery + NIP-44 encrypted peer handshake)
|
||||
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
|
||||
@@ -106,6 +117,12 @@ sd-notify = "0.4"
|
||||
# Trait objects for async methods (container orchestrator trait, Step 4)
|
||||
async-trait = "0.1"
|
||||
|
||||
# DHT Phase 2: iroh-blobs peer swarm engine. OPTIONAL — only pulled in by the
|
||||
# `iroh-swarm` feature (off by default). Heavy QUIC dep tree; kept behind the
|
||||
# flag so the default fleet build is unaffected until the PoC is measured.
|
||||
iroh = { version = "1", optional = true }
|
||||
iroh-blobs = { version = "0.103", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3.10"
|
||||
|
||||
@@ -66,6 +66,21 @@ impl ApiHandler {
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Extract a paid-entitlement gate token from X-Invoice-Hash (Lightning)
|
||||
// or X-Onchain-Address (on-chain) — both authorize the download if this
|
||||
// node issued+settled them, and both resolve against the same shared
|
||||
// entitlement store keyed by the token string (#46).
|
||||
let invoice_hash = headers
|
||||
.get("x-invoice-hash")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("x-onchain-address")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
// Extract federation peer DID from X-Federation-DID header
|
||||
let peer_did = headers
|
||||
.get("x-federation-did")
|
||||
@@ -82,6 +97,7 @@ impl ApiHandler {
|
||||
&config.data_dir,
|
||||
content_id,
|
||||
payment_token.as_deref(),
|
||||
invoice_hash.as_deref(),
|
||||
peer_did.as_deref(),
|
||||
range,
|
||||
)
|
||||
@@ -140,6 +156,255 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Seller side (#46): mint a Lightning invoice for a paid catalog item so a
|
||||
/// buyer can pay from any external wallet. Path: GET /content/{id}/invoice.
|
||||
/// Records a pending entitlement keyed by the invoice's payment hash.
|
||||
pub(super) async fn handle_content_invoice(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let content_id = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|s| s.strip_suffix("/invoice"))
|
||||
.unwrap_or("");
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid content ID"),
|
||||
));
|
||||
}
|
||||
|
||||
let catalog = content_server::load_catalog(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let item = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
))
|
||||
}
|
||||
};
|
||||
let price_sats = match &item.access {
|
||||
content_server::AccessControl::Paid { price_sats } => *price_sats,
|
||||
_ => {
|
||||
// Not a paid item — no invoice to issue.
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let memo = format!("Archipelago peer file {content_id}");
|
||||
match self
|
||||
.rpc_handler
|
||||
.create_invoice(price_sats as i64, &memo)
|
||||
.await
|
||||
{
|
||||
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
|
||||
crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await;
|
||||
let body = serde_json::json!({
|
||||
"bolt11": bolt11,
|
||||
"payment_hash": payment_hash,
|
||||
"price_sats": price_sats,
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
Ok(_) => Ok(build_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Invoice missing payment hash"}"#),
|
||||
)),
|
||||
Err(e) => {
|
||||
let body = serde_json::json!({
|
||||
"error": format!("Could not create invoice: {e}")
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seller side (#46): report whether a previously-issued invoice has settled.
|
||||
/// Path: GET /content/{id}/invoice-status/{payment_hash}. On settlement the
|
||||
/// entitlement is marked paid so the buyer can then download the file.
|
||||
pub(super) async fn handle_content_invoice_status(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let rest = path.strip_prefix("/content/").unwrap_or("");
|
||||
let (content_id, payment_hash) = match rest.split_once("/invoice-status/") {
|
||||
Some((id, hash)) => (id, hash),
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
))
|
||||
}
|
||||
};
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) || payment_hash.is_empty() {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
));
|
||||
}
|
||||
|
||||
// The hash must be one we issued for exactly this content item.
|
||||
match crate::content_invoice::lookup(payment_hash).await {
|
||||
Some((cid, _)) if cid == content_id => {}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Already paid? Otherwise ask our LND and persist the result.
|
||||
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
|
||||
if !paid {
|
||||
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
|
||||
crate::content_invoice::mark_paid(payment_hash).await;
|
||||
paid = true;
|
||||
}
|
||||
}
|
||||
|
||||
let body = serde_json::json!({ "paid": paid });
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item
|
||||
/// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a
|
||||
/// pending entitlement keyed by the address; price doubles as expected amount.
|
||||
pub(super) async fn handle_content_onchain(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let content_id = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|s| s.strip_suffix("/onchain"))
|
||||
.unwrap_or("");
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid content ID"),
|
||||
));
|
||||
}
|
||||
let catalog = content_server::load_catalog(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||
Some(i) => match &i.access {
|
||||
content_server::AccessControl::Paid { price_sats } => *price_sats,
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
|
||||
))
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
match self.rpc_handler.new_onchain_address().await {
|
||||
Ok(address) if !address.is_empty() => {
|
||||
crate::content_invoice::record_pending(&address, content_id, price_sats).await;
|
||||
let body = serde_json::json!({
|
||||
"address": address,
|
||||
"amount_sats": price_sats,
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
let body = serde_json::json!({
|
||||
"error": "Could not generate an on-chain address (is the wallet ready?)"
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seller side (#46): report whether an on-chain payment to a previously-
|
||||
/// issued address has arrived (>= price, >= 1 conf). Path:
|
||||
/// GET /content/{id}/onchain-status/{address}. Marks the entitlement paid.
|
||||
pub(super) async fn handle_content_onchain_status(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let rest = path.strip_prefix("/content/").unwrap_or("");
|
||||
let (content_id, address) = match rest.split_once("/onchain-status/") {
|
||||
Some((id, addr)) => (id, addr),
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
))
|
||||
}
|
||||
};
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) || address.is_empty() {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
));
|
||||
}
|
||||
// The address must be one we issued for exactly this content item.
|
||||
let price = match crate::content_invoice::lookup(address).await {
|
||||
Some((cid, price)) if cid == content_id => price,
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Unknown address"}"#),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let mut paid = crate::content_invoice::is_paid_for(address, content_id).await;
|
||||
if !paid {
|
||||
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
|
||||
crate::content_invoice::mark_paid(address).await;
|
||||
paid = true;
|
||||
}
|
||||
}
|
||||
let body = serde_json::json!({ "paid": paid });
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Serve a degraded preview of paid content (blurred image or first 2% of video).
|
||||
pub(super) async fn handle_content_preview(
|
||||
path: &str,
|
||||
@@ -190,6 +455,14 @@ impl ApiHandler {
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
Ok(content_server::PreviewResult::PreviewUnavailable) => Ok(Response::builder()
|
||||
.status(StatusCode::UNSUPPORTED_MEDIA_TYPE)
|
||||
.header("Content-Type", "text/plain")
|
||||
.header("X-Content-Preview", "unavailable")
|
||||
.body(hyper::Body::from(
|
||||
"Preview unavailable for this media (needs re-encoding)",
|
||||
))
|
||||
.unwrap()),
|
||||
Ok(content_server::PreviewResult::NotFound) | Err(_) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
|
||||
@@ -44,6 +44,11 @@ pub struct ApiHandler {
|
||||
session_store: SessionStore,
|
||||
/// Broadcast channel for relaying companion app input to remote browsers.
|
||||
input_relay_tx: broadcast::Sender<String>,
|
||||
/// Reverse broadcast channel: the kiosk browser publishes "open this URL
|
||||
/// externally" requests here, and the companion (phone) socket forwards them
|
||||
/// to the phone's default browser. Lets "open in external browser" apps —
|
||||
/// which the kiosk can't usefully open itself — launch on the controller.
|
||||
external_open_tx: broadcast::Sender<String>,
|
||||
/// Content-addressed blob store for attachments shared over mesh/federation.
|
||||
blob_store: Arc<BlobStore>,
|
||||
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
|
||||
@@ -71,6 +76,7 @@ impl ApiHandler {
|
||||
.await?,
|
||||
);
|
||||
let (input_relay_tx, _) = broadcast::channel(64);
|
||||
let (external_open_tx, _) = broadcast::channel(16);
|
||||
|
||||
// Derive a blob-store capability key from the node's Ed25519 signing
|
||||
// key. SHA-256 domain-separated so rotating the identity rotates
|
||||
@@ -100,6 +106,7 @@ impl ApiHandler {
|
||||
metrics_store,
|
||||
session_store,
|
||||
input_relay_tx,
|
||||
external_open_tx,
|
||||
blob_store,
|
||||
self_pubkey_hex,
|
||||
})
|
||||
@@ -202,6 +209,27 @@ impl ApiHandler {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A 401 that still carries CORS headers, for endpoints fetched
|
||||
/// cross-origin by same-node app UIs (e.g. the LND wallet UI on its own
|
||||
/// port). Without the ACAO header the browser surfaces an opaque CORS
|
||||
/// error instead of the 401, so the app can't tell it just needs auth.
|
||||
/// `origin` is the already-validated reflect value from `app_cors_origin`
|
||||
/// (empty string when the origin isn't allowed → no CORS header added).
|
||||
fn unauthorized_cors(origin: &str) -> Response<hyper::Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Vary", "Origin");
|
||||
if !origin.is_empty() {
|
||||
builder = builder
|
||||
.header("Access-Control-Allow-Origin", origin)
|
||||
.header("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
builder.body(hyper::Body::from(body_bytes)).unwrap()
|
||||
}
|
||||
|
||||
/// Allowed CORS origins derived from the config host IP.
|
||||
fn allowed_origins(&self) -> Vec<String> {
|
||||
let mut origins = vec![
|
||||
@@ -256,6 +284,45 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// CORS origin to echo for same-node app → backend calls (e.g. the LND
|
||||
/// wallet UI, served on its own APP_PORTS port). Such apps share the node's
|
||||
/// host but use a different port, so the strict allowlist (`host_ip`, no
|
||||
/// port) rejects them and the browser gets no `Access-Control-Allow-Origin`
|
||||
/// header ("blocked by CORS policy"). Reflect the Origin when its host
|
||||
/// matches the request's own `Host` header — i.e. the app lives on the same
|
||||
/// address the node is being reached by, which transparently covers the LAN
|
||||
/// IP, the Tailscale IP, localhost, and the `.onion` address without needing
|
||||
/// to enumerate them. Auth is still enforced by the session cookie; this
|
||||
/// only authorizes the browser to *read* the reply. Returns "" (no echoed
|
||||
/// origin) when there is no match.
|
||||
fn app_cors_origin(&self, headers: &hyper::HeaderMap) -> String {
|
||||
if let Some(origin) = self.validate_origin(headers) {
|
||||
return origin;
|
||||
}
|
||||
let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) else {
|
||||
return String::new();
|
||||
};
|
||||
// host portion (no scheme, no port) of an `scheme://host[:port]` value
|
||||
let host_of = |s: &str| -> Option<String> {
|
||||
let after_scheme = s.split_once("://").map(|(_, r)| r).unwrap_or(s);
|
||||
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
|
||||
let host = host_port
|
||||
.rsplit_once(':')
|
||||
.map(|(h, _)| h)
|
||||
.unwrap_or(host_port);
|
||||
(!host.is_empty()).then(|| host.to_string())
|
||||
};
|
||||
let origin_host = host_of(origin);
|
||||
let req_host = headers
|
||||
.get(hyper::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(host_of);
|
||||
match (origin_host, req_host) {
|
||||
(Some(o), Some(r)) if o == r => origin.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_request(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
@@ -265,9 +332,10 @@ impl ApiHandler {
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.header("Vary", "Origin");
|
||||
if let Some(origin) = self.validate_origin(req.headers()) {
|
||||
let preflight_origin = self.app_cors_origin(req.headers());
|
||||
if !preflight_origin.is_empty() {
|
||||
builder = builder
|
||||
.header("Access-Control-Allow-Origin", &origin)
|
||||
.header("Access-Control-Allow-Origin", &preflight_origin)
|
||||
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
|
||||
.header("Access-Control-Allow-Credentials", "true");
|
||||
@@ -295,7 +363,12 @@ impl ApiHandler {
|
||||
tracing::warn!("401 WebSocket /ws/remote-input — session invalid or missing");
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
return Self::handle_remote_input(req, self.input_relay_tx.clone()).await;
|
||||
return Self::handle_remote_input(
|
||||
req,
|
||||
self.input_relay_tx.clone(),
|
||||
self.external_open_tx.subscribe(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Remote relay WebSocket — browser receives companion input events
|
||||
@@ -304,7 +377,12 @@ impl ApiHandler {
|
||||
tracing::warn!("401 WebSocket /ws/remote-relay — session invalid or missing");
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
return Self::handle_remote_relay(req, self.input_relay_tx.subscribe()).await;
|
||||
return Self::handle_remote_relay(
|
||||
req,
|
||||
self.input_relay_tx.subscribe(),
|
||||
self.external_open_tx.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Convert body to bytes for non-WS routes
|
||||
@@ -419,6 +497,22 @@ impl ApiHandler {
|
||||
Self::handle_content_preview(p, &self.config).await
|
||||
}
|
||||
|
||||
// Lightning-invoice peer-file sale (#46): mint invoice / poll settlement
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/invoice") => {
|
||||
self.handle_content_invoice(p).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => {
|
||||
self.handle_content_invoice_status(p).await
|
||||
}
|
||||
|
||||
// On-chain peer-file sale (#46): issue address / poll for payment
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.contains("/onchain-status/") => {
|
||||
self.handle_content_onchain_status(p).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/onchain") => {
|
||||
self.handle_content_onchain(p).await
|
||||
}
|
||||
|
||||
// Content serving — peers access shared content over Tor (no session auth)
|
||||
(Method::GET, p) if p.starts_with("/content/") => {
|
||||
Self::handle_content_request(p, &headers, &self.config).await
|
||||
@@ -448,7 +542,8 @@ impl ApiHandler {
|
||||
// No backend auth check here because the LND UI iframe fetches this
|
||||
// endpoint and the session cookie flow is validated at the nginx layer.
|
||||
(Method::GET, "/lnd-connect-info") => {
|
||||
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
|
||||
let origin = self.app_cors_origin(&headers);
|
||||
Self::handle_lnd_connect_info(self.rpc_handler.clone(), &origin).await
|
||||
}
|
||||
|
||||
// Container logs — requires session
|
||||
@@ -460,13 +555,26 @@ impl ApiHandler {
|
||||
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
// LND proxy — requires session
|
||||
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
||||
// Peer content streaming proxy — Range-streams a peer's media file
|
||||
// so <video>/<audio> can seek/play (B3). Same-origin, session-gated.
|
||||
(Method::GET, p) if p.starts_with("/api/peer-content/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
let origin = self.validate_origin(&headers).unwrap_or_default();
|
||||
Self::handle_lnd_proxy(path, &origin).await
|
||||
self.handle_peer_content_stream(p, &headers).await
|
||||
}
|
||||
|
||||
// LND proxy — requires session. The LND wallet UI calls this
|
||||
// cross-origin from its own app port, so even the 401 must carry
|
||||
// CORS headers; otherwise the browser reports a bare CORS failure
|
||||
// ("No 'Access-Control-Allow-Origin' header") instead of a
|
||||
// readable 401 the UI can act on.
|
||||
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
||||
let origin = self.app_cors_origin(&headers);
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized_cors(&origin));
|
||||
}
|
||||
Self::handle_lnd_proxy(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
// DWN health — unauthenticated
|
||||
|
||||
@@ -19,6 +19,8 @@ impl ApiHandler {
|
||||
signature: Option<String>,
|
||||
#[serde(default)]
|
||||
encrypted: bool,
|
||||
#[serde(default)]
|
||||
msg_id: Option<String>,
|
||||
}
|
||||
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
|
||||
from_pubkey: None,
|
||||
@@ -26,6 +28,7 @@ impl ApiHandler {
|
||||
message: None,
|
||||
signature: None,
|
||||
encrypted: false,
|
||||
msg_id: None,
|
||||
});
|
||||
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref())
|
||||
{
|
||||
@@ -152,7 +155,13 @@ impl ApiHandler {
|
||||
let clean_from = sanitize_html(from);
|
||||
let clean_msg = sanitize_html(&plaintext);
|
||||
let clean_name = incoming.from_name.as_deref().map(sanitize_html);
|
||||
node_msg::store_received(&clean_from, &clean_msg, clean_name.as_deref()).await;
|
||||
node_msg::store_received(
|
||||
&clean_from,
|
||||
&clean_msg,
|
||||
clean_name.as_deref(),
|
||||
incoming.msg_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
|
||||
@@ -99,33 +99,61 @@ impl ApiHandler {
|
||||
|
||||
pub(super) async fn handle_lnd_connect_info(
|
||||
rpc: std::sync::Arc<super::super::rpc::RpcHandler>,
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// The LND wallet UI is served on its own APP_PORTS origin and fetches
|
||||
// this cross-origin, so it needs the CORS headers echoed back.
|
||||
let cors = |builder: hyper::http::response::Builder| {
|
||||
builder
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
};
|
||||
match rpc.handle_lnd_connect_info().await {
|
||||
Ok(val) => {
|
||||
let body = serde_json::to_vec(&val).unwrap_or_default();
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(body),
|
||||
))
|
||||
Ok(cors(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json"),
|
||||
)
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||
}
|
||||
Err(e) => Ok(Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(
|
||||
serde_json::json!({"error": e.to_string()}).to_string(),
|
||||
))
|
||||
.unwrap()),
|
||||
Err(e) => Ok(cors(
|
||||
Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json"),
|
||||
)
|
||||
.body(hyper::Body::from(
|
||||
serde_json::json!({"error": e.to_string()}).to_string(),
|
||||
))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_proxy(
|
||||
rpc: Arc<RpcHandler>,
|
||||
path: &str,
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||
match reqwest::get(&url).await {
|
||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||
// A bare reqwest::get() uses the default client, which rejects the
|
||||
// self-signed cert (TLS verify error -> 502 "failing to fetch") and
|
||||
// sends no macaroon. Use the shared authenticated client instead — the
|
||||
// same one lnd.getinfo and the wallet RPCs use.
|
||||
let request = match rpc.lnd_client().await {
|
||||
Ok((client, macaroon_hex)) => client
|
||||
.get(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.map_err(anyhow::Error::from),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match request {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let headers = resp.headers().clone();
|
||||
@@ -157,4 +185,84 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Range-streaming proxy for a peer's content file (B3). The browser's
|
||||
/// `<video>`/`<audio>` element makes Range requests; we forward the Range
|
||||
/// header to the peer's `/content/<id>` (which already returns 206 Partial
|
||||
/// Content) and pass the bytes + Content-Range/Content-Type straight back.
|
||||
/// This replaces the old path of downloading the whole file as base64 into
|
||||
/// a non-seekable Blob URL, which broke playback/seeking for video and
|
||||
/// large audio. Same-origin + session-authenticated (checked by caller).
|
||||
/// Path: `/api/peer-content/<onion>/<content_id>`.
|
||||
pub(super) async fn handle_peer_content_stream(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let bad = |msg: &str| {
|
||||
Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::json!({ "error": msg }).to_string()),
|
||||
))
|
||||
};
|
||||
let rest = path.strip_prefix("/api/peer-content/").unwrap_or("");
|
||||
let (onion, content_id) = match rest.split_once('/') {
|
||||
Some((o, c)) if !o.is_empty() && !c.is_empty() => (o, c),
|
||||
_ => return bad("expected /api/peer-content/<onion>/<content_id>"),
|
||||
};
|
||||
// Validate to prevent SSRF / path traversal.
|
||||
let onion_norm = onion.trim_end_matches(".onion");
|
||||
let onion_ok = onion_norm.len() == 56
|
||||
&& onion_norm
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
|
||||
let id_ok = !content_id.contains("..")
|
||||
&& content_id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'));
|
||||
if !onion_ok || !id_ok {
|
||||
return bad("invalid onion or content id");
|
||||
}
|
||||
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let peer_path = format!("/content/{}", content_id);
|
||||
// Generous overall timeout: this endpoint serves both seek/Range
|
||||
// playback (small, finishes fast) and full-file downloads of large
|
||||
// media (#38). 60s was too tight for a multi-hundred-MB transfer over
|
||||
// Tor and aborted the download mid-stream.
|
||||
let mut req = crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &peer_path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(900));
|
||||
if let Some(r) = headers.get("range").and_then(|v| v.to_str().ok()) {
|
||||
req = req.header("Range", r.to_string());
|
||||
}
|
||||
match req.send_get().await {
|
||||
Ok((resp, _transport)) => {
|
||||
let status = resp.status().as_u16();
|
||||
let rh = resp.headers().clone();
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header("Accept-Ranges", "bytes");
|
||||
for h in ["content-type", "content-range", "content-length"] {
|
||||
if let Some(v) = rh.get(h).and_then(|v| v.to_str().ok()) {
|
||||
builder = builder.header(h, v);
|
||||
}
|
||||
}
|
||||
// Stream the peer's body straight through instead of buffering
|
||||
// the whole file into memory (#38). For a 178MB download the old
|
||||
// `resp.bytes().await` allocated the entire file on the node
|
||||
// before sending a byte; `wrap_stream` forwards chunks as they
|
||||
// arrive, with constant memory.
|
||||
Ok(builder
|
||||
.body(hyper::Body::wrap_stream(resp.bytes_stream()))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::empty())))
|
||||
}
|
||||
Err(e) => Ok(build_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::json!({ "error": e.to_string() }).to_string()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,7 @@ impl ApiHandler {
|
||||
pub(super) async fn handle_remote_input(
|
||||
req: Request<hyper::Body>,
|
||||
relay_tx: broadcast::Sender<String>,
|
||||
mut external_open_rx: broadcast::Receiver<String>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// Extract optional player ID from query string: /ws/remote-input?p=1
|
||||
let player_id: Option<u8> = req
|
||||
@@ -266,6 +267,19 @@ impl ApiHandler {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Forward kiosk "open this URL externally" requests down to
|
||||
// the companion so the link opens in the phone's browser.
|
||||
ext = external_open_rx.recv() => {
|
||||
match ext {
|
||||
Ok(text) => {
|
||||
if tx.send(Message::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {}
|
||||
Err(broadcast::error::RecvError::Closed) => {}
|
||||
}
|
||||
}
|
||||
msg = rx.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
|
||||
@@ -11,9 +11,16 @@ use super::ApiHandler;
|
||||
impl ApiHandler {
|
||||
/// WebSocket endpoint for browser clients to receive relayed companion input.
|
||||
/// The browser's remote-relay.ts dispatches these as DOM keyboard/mouse events.
|
||||
///
|
||||
/// The kiosk also uses this socket in the *reverse* direction: when an "open
|
||||
/// in external browser" app is launched, the kiosk can't usefully open it
|
||||
/// itself, so it sends `{"t":"o","url":"https://…"}` here. We validate the
|
||||
/// URL and publish it on `external_open_tx`, which the companion (phone)
|
||||
/// socket forwards so the link opens in the phone's default browser.
|
||||
pub(super) async fn handle_remote_relay(
|
||||
req: Request<hyper::Body>,
|
||||
mut relay_rx: broadcast::Receiver<String>,
|
||||
external_open_tx: broadcast::Sender<String>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||
@@ -63,10 +70,20 @@ impl ApiHandler {
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
// Handle client-side messages (pong, close)
|
||||
// Handle client-side messages (pong, close, open-url requests)
|
||||
client_msg = rx.next() => {
|
||||
match client_msg {
|
||||
Some(Ok(Message::Pong(_))) | Some(Ok(Message::Ping(_))) => {}
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
// The only kiosk→server message we accept is an
|
||||
// external-open request: {"t":"o","url":"https://…"}.
|
||||
if let Some(url) = parse_open_url(&text) {
|
||||
debug!("Relaying external-open to companion: {}", url);
|
||||
let _ = external_open_tx.send(
|
||||
format!(r#"{{"t":"o","url":{}}}"#, json_string(&url))
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
@@ -81,3 +98,29 @@ impl ApiHandler {
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a kiosk `{"t":"o","url":"…"}` external-open request, returning the URL
|
||||
/// only if it's a well-formed http(s) URL. Anything else (other message tags,
|
||||
/// non-http schemes like `javascript:`/`file:`, malformed JSON) is rejected so a
|
||||
/// compromised kiosk page can't push arbitrary URIs to the phone.
|
||||
fn parse_open_url(text: &str) -> Option<String> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
if v.get("t").and_then(|t| t.as_str()) != Some("o") {
|
||||
return None;
|
||||
}
|
||||
let url = v.get("url").and_then(|u| u.as_str())?.trim();
|
||||
if url.len() > 2048 {
|
||||
return None;
|
||||
}
|
||||
let lower = url.to_ascii_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
Some(url.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a string as a JSON string literal (with surrounding quotes).
|
||||
fn json_string(s: &str) -> String {
|
||||
serde_json::Value::String(s.to_string()).to_string()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
mod handler;
|
||||
mod rpc;
|
||||
pub(crate) mod rpc;
|
||||
|
||||
pub use handler::ApiHandler;
|
||||
|
||||
@@ -33,6 +33,19 @@ impl RpcHandler {
|
||||
|
||||
tracing::info!("[onboarding] login successful");
|
||||
|
||||
// Best-effort: heal a LOCKED LND wallet created with an unknown/legacy
|
||||
// password by rotating it onto the per-node secret, using the password
|
||||
// the user just authenticated with as a candidate. Non-blocking so login
|
||||
// is never slowed or broken when LND isn't installed / already unlocked.
|
||||
let candidate = password.to_string();
|
||||
tokio::spawn(async move {
|
||||
match crate::container::lnd::migrate_locked_wallet(&[candidate]).await {
|
||||
Ok(true) => tracing::info!("[login] LND wallet healed / auto-unlocked"),
|
||||
Ok(false) => {} // not locked, or seed-recovery required
|
||||
Err(e) => tracing::debug!("[login] LND wallet migration skipped: {e}"),
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure NostrVPN config exists — covers the case where onboardingComplete
|
||||
// was never called (e.g., user took the "already set up" shortcut).
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
|
||||
@@ -107,7 +107,7 @@ struct TrustedRelayPeer {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TxRelayCredentials {
|
||||
pub(crate) struct TxRelayCredentials {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
@@ -648,7 +648,13 @@ async fn txrelay_credentials_available(data_dir: &Path) -> bool {
|
||||
&& fs::metadata(&client_env_path).await.is_ok()
|
||||
}
|
||||
|
||||
async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
|
||||
/// Idempotently ensure the tx-relay credential trio exists in the secrets dir:
|
||||
/// the random password, its derived `rpcauth` line, and the client env file.
|
||||
/// Bitcoin backend manifests reference `bitcoin-rpc-txrelay-rpcauth` as a
|
||||
/// required `secret_env`, so this must run before bitcoind starts — otherwise
|
||||
/// secret resolution hard-fails and the whole Bitcoin stack cascades (the .198
|
||||
/// failure). Safe to call repeatedly; it only writes what's missing or stale.
|
||||
pub(crate) async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
|
||||
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
|
||||
let password = match read_trimmed(&password_path).await {
|
||||
Some(value) => value,
|
||||
|
||||
@@ -234,7 +234,7 @@ impl RpcHandler {
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
let (response, _transport) =
|
||||
let (response, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
@@ -242,6 +242,15 @@ impl RpcHandler {
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Record which transport actually reached the peer (B14) so the UI
|
||||
// reflects FIPS vs Tor truthfully instead of always showing Tor/none.
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||
@@ -294,13 +303,21 @@ impl RpcHandler {
|
||||
fips_npub.is_some()
|
||||
);
|
||||
|
||||
let (response, _transport) =
|
||||
let (response, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
@@ -309,11 +326,20 @@ impl RpcHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response
|
||||
let mut body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse peer catalog")?;
|
||||
|
||||
// Surface the transport that actually reached the peer so the cloud
|
||||
// browse UI can show a FIPS/Tor pill instead of always assuming Tor (B21).
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.insert(
|
||||
"transport".to_string(),
|
||||
serde_json::Value::String(transport.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
@@ -353,25 +379,49 @@ impl RpcHandler {
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
let (response, _transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Surface a real reason instead of the generic sanitized error (#30):
|
||||
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
||||
// here means the peer is genuinely unreachable on both transports.
|
||||
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
||||
fips_npub.as_deref(),
|
||||
onion,
|
||||
&path,
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str)
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
||||
}));
|
||||
}
|
||||
};
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
// Payment was rejected — token is spent but content not received
|
||||
return Err(anyhow::anyhow!(
|
||||
"Payment rejected by peer — token may have been insufficient or invalid"
|
||||
));
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Payment rejected by peer — the token may have been insufficient or invalid."
|
||||
}));
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({}).", response.status())
|
||||
}));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
@@ -389,6 +439,381 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
|
||||
/// paid item so the buyer can pay from any external wallet. Returns the
|
||||
/// bolt11 invoice + payment hash to render as a QR and poll for settlement.
|
||||
pub(super) async fn handle_content_request_invoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||
let content_id = params
|
||||
.get("content_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}/invoice", content_id);
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("request-invoice dial failed for {}: {:#}", onion, e);
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline."
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Seller could not create an invoice ({}).", response.status())
|
||||
}));
|
||||
}
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse invoice response")?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Buyer side (#46): poll the selling node for invoice settlement.
|
||||
pub(super) async fn handle_content_invoice_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||
let content_id = params
|
||||
.get("content_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||
let payment_hash = params
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing payment_hash"))?;
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
// Payment hash is hex from the seller; keep it strictly hex so it's safe
|
||||
// to interpolate into the request path.
|
||||
if payment_hash.is_empty()
|
||||
|| payment_hash.len() > 128
|
||||
|| !payment_hash.chars().all(|c| c.is_ascii_hexdigit())
|
||||
{
|
||||
return Err(anyhow::anyhow!("Invalid payment_hash"));
|
||||
}
|
||||
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let path = format!("/content/{}/invoice-status/{}", content_id, payment_hash);
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
// Treat an unreachable peer as "not yet paid" so the UI keeps polling.
|
||||
return Ok(serde_json::json!({ "paid": false, "unreachable": true }));
|
||||
}
|
||||
};
|
||||
if !response.status().is_success() {
|
||||
return Ok(serde_json::json!({ "paid": false }));
|
||||
}
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse invoice-status response")?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Buyer side (#46): download a paid item after the invoice settled, passing
|
||||
/// the payment hash so the seller's content gate releases the file.
|
||||
pub(super) async fn handle_content_download_peer_invoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||
let content_id = params
|
||||
.get("content_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||
let payment_hash = params
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing payment_hash"))?;
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
if payment_hash.is_empty() || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid payment_hash"));
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
||||
fips_npub.as_deref(),
|
||||
onion,
|
||||
&path,
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Invoice-Hash", payment_hash.to_string())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("invoice download dial failed for {}: {:#}", onion, e);
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
||||
}));
|
||||
}
|
||||
};
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Seller has not registered this payment yet — wait for settlement and retry."
|
||||
}));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({}).", response.status())
|
||||
}));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
|
||||
pub(super) async fn handle_content_request_onchain(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||
let content_id = params
|
||||
.get("content_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}/onchain", content_id);
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("request-onchain dial failed for {}: {:#}", onion, e);
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline."
|
||||
}));
|
||||
}
|
||||
};
|
||||
if !response.status().is_success() {
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Seller could not provide an address ({}).", response.status())
|
||||
}));
|
||||
}
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse onchain response")?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Buyer side (#46): poll the selling node for on-chain payment detection.
|
||||
pub(super) async fn handle_content_onchain_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||
let content_id = params
|
||||
.get("content_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||
let address = params
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing address"))?;
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
// Bitcoin addresses are alphanumeric; keep strictly so for safe path use.
|
||||
if address.is_empty()
|
||||
|| address.len() > 100
|
||||
|| !address.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
{
|
||||
return Err(anyhow::anyhow!("Invalid address"));
|
||||
}
|
||||
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let path = format!("/content/{}/onchain-status/{}", content_id, address);
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(serde_json::json!({ "paid": false, "unreachable": true })),
|
||||
};
|
||||
if !response.status().is_success() {
|
||||
return Ok(serde_json::json!({ "paid": false }));
|
||||
}
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse onchain-status response")?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Buyer side (#46): download a paid item after the on-chain payment was
|
||||
/// detected, passing the address so the seller's content gate releases it.
|
||||
pub(super) async fn handle_content_download_peer_onchain(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let onion = params
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||
let content_id = params
|
||||
.get("content_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||
let address = params
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing address"))?;
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
if address.is_empty() || !address.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err(anyhow::anyhow!("Invalid address"));
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
||||
fips_npub.as_deref(),
|
||||
onion,
|
||||
&path,
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Onchain-Address", address.to_string())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("onchain download dial failed for {}: {:#}", onion, e);
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
||||
}));
|
||||
}
|
||||
};
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Seller has not registered this payment yet — wait for confirmation and retry."
|
||||
}));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({}).", response.status())
|
||||
}));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fetch a preview of paid content from a peer (no payment required).
|
||||
pub(super) async fn handle_content_preview_peer(
|
||||
&self,
|
||||
@@ -418,13 +843,21 @@ impl RpcHandler {
|
||||
fips_npub.is_some()
|
||||
);
|
||||
|
||||
let (response, _transport) =
|
||||
let (response, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer for preview")?;
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
|
||||
@@ -33,6 +33,7 @@ impl RpcHandler {
|
||||
"seed.restore" => self.handle_seed_restore(params).await,
|
||||
"seed.save-encrypted" => self.handle_seed_save_encrypted(params).await,
|
||||
"seed.status" => self.handle_seed_status().await,
|
||||
"seed.reveal" => self.handle_seed_reveal(params).await,
|
||||
|
||||
// Container orchestration (for Archipelago-managed containers)
|
||||
"container-install" => self.handle_container_install(params).await,
|
||||
@@ -55,6 +56,7 @@ impl RpcHandler {
|
||||
"package.restart" => self.handle_package_restart(params).await,
|
||||
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
|
||||
"package.update" => self.clone().spawn_package_update(params).await,
|
||||
"package.check-updates" => self.handle_package_check_updates(params).await,
|
||||
"package.credentials" => self.handle_package_credentials(params).await,
|
||||
"app.filebrowser-token" => self.handle_filebrowser_token().await,
|
||||
|
||||
@@ -236,6 +238,11 @@ impl RpcHandler {
|
||||
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
|
||||
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
|
||||
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
|
||||
// Fedimint ecash (via fedimint-clientd sidecar)
|
||||
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
|
||||
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
|
||||
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
|
||||
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
|
||||
|
||||
// Container registries
|
||||
"registry.list" => self.handle_registry_list().await,
|
||||
@@ -249,6 +256,7 @@ impl RpcHandler {
|
||||
"streaming.configure-service" => self.handle_streaming_configure_service(params).await,
|
||||
"streaming.toggle-service" => self.handle_streaming_toggle_service(params).await,
|
||||
"streaming.pay" => self.handle_streaming_pay(params).await,
|
||||
"streaming.prepare-payment" => self.handle_streaming_prepare_payment(params).await,
|
||||
"streaming.discover" => self.handle_streaming_discover().await,
|
||||
"streaming.usage" => self.handle_streaming_usage(params).await,
|
||||
"streaming.session" => self.handle_streaming_session(params).await,
|
||||
@@ -268,6 +276,16 @@ impl RpcHandler {
|
||||
"content.browse-peer" => self.handle_content_browse_peer(params).await,
|
||||
"content.download-peer" => self.handle_content_download_peer(params).await,
|
||||
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
|
||||
"content.request-invoice" => self.handle_content_request_invoice(params).await,
|
||||
"content.invoice-status" => self.handle_content_invoice_status(params).await,
|
||||
"content.download-peer-invoice" => {
|
||||
self.handle_content_download_peer_invoice(params).await
|
||||
}
|
||||
"content.request-onchain" => self.handle_content_request_onchain(params).await,
|
||||
"content.onchain-status" => self.handle_content_onchain_status(params).await,
|
||||
"content.download-peer-onchain" => {
|
||||
self.handle_content_download_peer_onchain(params).await
|
||||
}
|
||||
"content.preview-peer" => self.handle_content_preview_peer(params).await,
|
||||
|
||||
// DWN (Decentralized Web Node)
|
||||
@@ -379,6 +397,11 @@ impl RpcHandler {
|
||||
"mesh.deadman-status" => self.handle_mesh_deadman_status().await,
|
||||
"mesh.deadman-configure" => self.handle_mesh_deadman_configure(params).await,
|
||||
"mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().await,
|
||||
"mesh.assistant-status" => self.handle_mesh_assistant_status().await,
|
||||
"mesh.assistant-configure" => self.handle_mesh_assistant_configure(params).await,
|
||||
"mesh.schedule-message" => self.handle_mesh_schedule_message(params).await,
|
||||
"mesh.list-scheduled" => self.handle_mesh_list_scheduled().await,
|
||||
"mesh.cancel-scheduled" => self.handle_mesh_cancel_scheduled(params).await,
|
||||
"mesh.test-send" => self.handle_mesh_test_send(params).await,
|
||||
|
||||
// Transport layer (unified routing)
|
||||
@@ -470,6 +493,11 @@ impl RpcHandler {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_test_mirror(&p).await
|
||||
}
|
||||
"update.get-source" => self.handle_update_get_source().await,
|
||||
"update.set-source" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_update_set_source(&p).await
|
||||
}
|
||||
"update.apply" => self.handle_update_apply().await,
|
||||
"update.git-apply" => self.handle_update_git_apply().await,
|
||||
"update.rollback" => self.handle_update_rollback().await,
|
||||
|
||||
@@ -30,6 +30,25 @@ impl RpcHandler {
|
||||
mesh::upsert_federation_peer(&svc.shared_state(), pubkey_hex, did, name).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-seed every federation node from disk into the mesh peer table so the
|
||||
/// chat list reflects what the latest federation sync learned — display
|
||||
/// names (landed in `nodes.json` by `update_node_state` when a peer
|
||||
/// announces its name) and transitively-discovered peers (merged by
|
||||
/// `merge_transitive_peers`) — WITHOUT waiting for a mesh restart.
|
||||
///
|
||||
/// Without this, a peer accepted via invite (seeded with `name = None`)
|
||||
/// stays "Archipelago <pubkey8>" in chat until the next restart even after
|
||||
/// sync has learned its real name, and transitive peers never appear as
|
||||
/// chat contacts at all. `seed_federation_peers_into_mesh` is idempotent
|
||||
/// and dedups by onion, so calling it after each sync is safe.
|
||||
/// Best-effort: silently no-ops when mesh is off.
|
||||
pub(crate) async fn refresh_federation_mesh_peers(&self) {
|
||||
let svc = self.mesh_service.read().await;
|
||||
if let Some(svc) = svc.as_ref() {
|
||||
mesh::seed_federation_peers_into_mesh(&svc.shared_state(), &self.config.data_dir).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -243,9 +262,31 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'did' parameter"))?;
|
||||
validate_did(did)?;
|
||||
|
||||
// Capture the node's pubkey before removal so we can also purge its
|
||||
// synthetic mesh contact/thread (#2) — remove_node only touches
|
||||
// nodes.json, which would otherwise leave a stale chat contact behind.
|
||||
let removed_pubkey = federation::load_nodes(&self.config.data_dir)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|nodes| nodes.into_iter().find(|n| n.did == did).map(|n| n.pubkey));
|
||||
|
||||
let nodes = federation::remove_node(&self.config.data_dir, did).await?;
|
||||
info!(did = %did, "Removed node from federation");
|
||||
|
||||
if let Some(pubkey) = removed_pubkey.filter(|p| !p.is_empty()) {
|
||||
let svc = self.mesh_service.read().await;
|
||||
if let Some(svc) = svc.as_ref() {
|
||||
let contact_id = mesh::federation_peer_contact_id(&pubkey);
|
||||
mesh::purge_federation_peer(
|
||||
&svc.shared_state(),
|
||||
contact_id,
|
||||
&pubkey,
|
||||
&self.config.data_dir,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"removed": true,
|
||||
"nodes_remaining": nodes.len(),
|
||||
@@ -341,6 +382,10 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Push any names/roster the sync just learned into the live mesh peer
|
||||
// table so the chat list updates without a restart (#42).
|
||||
self.refresh_federation_mesh_peers().await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"synced": synced,
|
||||
"failed": failed,
|
||||
@@ -533,6 +578,19 @@ impl RpcHandler {
|
||||
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
|
||||
}
|
||||
|
||||
// Respect operator removal: a peer the operator deleted must not
|
||||
// silently re-join via a stale invite. The tombstone is only cleared
|
||||
// by an explicit local action (manually adding the node or accepting
|
||||
// an incoming invite) — not by a remote-triggered join.
|
||||
if federation::load_removed_dids(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.contains(did)
|
||||
{
|
||||
info!(peer_did = %did, "Ignoring peer-joined for a removed (tombstoned) DID");
|
||||
return Ok(serde_json::json!({ "accepted": false, "removed": true }));
|
||||
}
|
||||
|
||||
let node = FederatedNode {
|
||||
did: did.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Fedimint ecash RPCs — bridge to the `fedimint-clientd` sidecar.
|
||||
//!
|
||||
//! Companion to the Cashu wallet RPCs in [`super::wallet`]. Joining/holding
|
||||
//! Fedimint ecash is delegated to the clientd container via
|
||||
//! [`crate::wallet::fedimint_client::FedimintClient`]; here we expose the
|
||||
//! node's JSON-RPC surface and keep a local registry of joined federations so
|
||||
//! the list survives clientd being temporarily unreachable.
|
||||
//!
|
||||
//! See `docs/dual-ecash-design.md`.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::fedimint_client::{self, FedimintClient, JoinedFederation};
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// `wallet.fedimint-list` — joined federations with live balances.
|
||||
pub(super) async fn handle_wallet_fedimint_list(&self) -> Result<serde_json::Value> {
|
||||
// Best-effort: make sure the default federation is joined/tracked.
|
||||
let _ = fedimint_client::ensure_default_federation(&self.config.data_dir).await;
|
||||
|
||||
let reg = fedimint_client::load_registry(&self.config.data_dir).await?;
|
||||
|
||||
// Live balances are best-effort: if clientd is down we still return the
|
||||
// tracked federations (with 0 balance) rather than failing the call.
|
||||
let info = match FedimintClient::from_node(&self.config.data_dir).await {
|
||||
Ok(client) => client.info().await.ok(),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
let federations: Vec<serde_json::Value> = reg
|
||||
.federations
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let balance_sats = info
|
||||
.as_ref()
|
||||
.and_then(|i| i.get(&f.federation_id))
|
||||
.and_then(|e| {
|
||||
e.get("totalAmountMsat")
|
||||
.or_else(|| e.get("totalMsat"))
|
||||
.and_then(|v| v.as_u64())
|
||||
})
|
||||
.map(|msat| msat / 1000)
|
||||
.unwrap_or(0);
|
||||
serde_json::json!({
|
||||
"federation_id": f.federation_id,
|
||||
"name": f.name,
|
||||
"balance_sats": balance_sats,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "federations": federations }))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-join` — join a federation by invite code.
|
||||
pub(super) async fn handle_wallet_fedimint_join(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let invite_code = params
|
||||
.get("invite_code")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing invite_code"))?;
|
||||
|
||||
let client = FedimintClient::from_node(&self.config.data_dir).await?;
|
||||
let federation_id = client.join(invite_code).await?;
|
||||
|
||||
// Try to label it from the federation meta (best-effort).
|
||||
let name = client.info().await.ok().and_then(|i| {
|
||||
i.get(&federation_id)
|
||||
.and_then(|e| e.get("meta"))
|
||||
.and_then(|m| {
|
||||
m.get("federation_name")
|
||||
.or_else(|| m.get("federation_expiry_timestamp"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let mut reg = fedimint_client::load_registry(&self.config.data_dir).await?;
|
||||
if !reg
|
||||
.federations
|
||||
.iter()
|
||||
.any(|f| f.federation_id == federation_id)
|
||||
{
|
||||
reg.federations.push(JoinedFederation {
|
||||
federation_id: federation_id.clone(),
|
||||
name,
|
||||
});
|
||||
fedimint_client::save_registry(&self.config.data_dir, ®).await?;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "federation_id": federation_id }))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-leave` — stop tracking a federation locally.
|
||||
pub(super) async fn handle_wallet_fedimint_leave(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let federation_id = params
|
||||
.get("federation_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing federation_id"))?;
|
||||
|
||||
let mut reg = fedimint_client::load_registry(&self.config.data_dir).await?;
|
||||
let before = reg.federations.len();
|
||||
reg.federations.retain(|f| f.federation_id != federation_id);
|
||||
let removed = reg.federations.len() != before;
|
||||
if removed {
|
||||
fedimint_client::save_registry(&self.config.data_dir, ®).await?;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "removed": removed }))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-balance` — total sats across all joined federations.
|
||||
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
|
||||
// Soft-fail to zero when clientd isn't installed/running, so the unified
|
||||
// wallet balance still renders from the Cashu side.
|
||||
let balance_sats = match FedimintClient::from_node(&self.config.data_dir).await {
|
||||
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
};
|
||||
Ok(serde_json::json!({ "balance_sats": balance_sats }))
|
||||
}
|
||||
}
|
||||
@@ -115,10 +115,12 @@ impl RpcHandler {
|
||||
} else if !after.key_present {
|
||||
"no_seed_key"
|
||||
} else if after.authenticated_peer_count == 0 {
|
||||
// Daemon is up with a key but hasn't authenticated any
|
||||
// peers — almost always outbound UDP/8668 dropped by the
|
||||
// local firewall/router, or the anchor itself being down.
|
||||
"no_outbound_udp_or_anchor_down"
|
||||
// Daemon is up with a key but hasn't authenticated any peers —
|
||||
// almost always the outbound connection to the anchor being
|
||||
// dropped by the local firewall/router, or the anchor itself
|
||||
// being down. The public anchor is reached over TCP/8443 (not
|
||||
// UDP/8668 — that endpoint is dead).
|
||||
"no_outbound_or_anchor_down"
|
||||
} else {
|
||||
"peers_but_no_anchor"
|
||||
};
|
||||
@@ -126,8 +128,8 @@ impl RpcHandler {
|
||||
"connected" => "An anchor is reachable.",
|
||||
"daemon_down" => "The FIPS daemon didn't come back up — check the FIPS service on this host.",
|
||||
"no_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step.",
|
||||
"no_outbound_udp_or_anchor_down" =>
|
||||
"Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or every configured anchor could be down. Add a reachable peer in Seed Anchors.",
|
||||
"no_outbound_or_anchor_down" =>
|
||||
"Daemon is running but no peers handshook. Your router or ISP may be blocking the outbound connection to the mesh anchor (TCP port 8443), or every configured anchor is down. The public anchor is added automatically — if it still won't connect, add another reachable peer in Seed Anchors.",
|
||||
"peers_but_no_anchor" =>
|
||||
"Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors.",
|
||||
_ => "",
|
||||
|
||||
@@ -14,10 +14,39 @@ impl RpcHandler {
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let (identities, default_id) = manager.list().await?;
|
||||
|
||||
// #49: The canonical node Nostr key is the node-level HKDF key
|
||||
// (`derive_node_nostr_key`) that Settings and Nostr discovery both use
|
||||
// via `node.nostr-pubkey`. The mirrored "Node" identity stores
|
||||
// nostr=None, and seed identities use a different BIP-32 NIP-06 key, so
|
||||
// the "Node" entry in Web5 > Identities disagreed with Settings. Resolve
|
||||
// the node-level key once and override it onto whichever identity record
|
||||
// is the node's own (its ed25519 matches `server_info.pubkey`), so both
|
||||
// surfaces always show the same npub. Display-only — no key is rewritten.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_nostr_hex = crate::nostr_discovery::get_nostr_pubkey(&identity_dir)
|
||||
.await
|
||||
.ok();
|
||||
let node_nostr_npub = node_nostr_hex.as_ref().and_then(|h| {
|
||||
nostr_sdk::PublicKey::from_hex(h)
|
||||
.ok()
|
||||
.and_then(|pk| pk.to_bech32().ok())
|
||||
});
|
||||
let (snapshot, _) = self.state_manager.get_snapshot().await;
|
||||
let node_pubkey_hex = snapshot.server_info.pubkey.clone();
|
||||
|
||||
let items: Vec<serde_json::Value> = identities
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let is_default = default_id.as_deref() == Some(&id.id);
|
||||
let is_node = !node_pubkey_hex.is_empty() && id.pubkey_hex == node_pubkey_hex;
|
||||
let (nostr_pubkey, nostr_npub) = if is_node {
|
||||
(
|
||||
node_nostr_hex.clone().or(id.nostr_pubkey),
|
||||
node_nostr_npub.clone().or(id.nostr_npub),
|
||||
)
|
||||
} else {
|
||||
(id.nostr_pubkey, id.nostr_npub)
|
||||
};
|
||||
serde_json::json!({
|
||||
"id": id.id,
|
||||
"name": id.name,
|
||||
@@ -26,8 +55,8 @@ impl RpcHandler {
|
||||
"did": id.did,
|
||||
"created_at": id.created_at,
|
||||
"is_default": is_default,
|
||||
"nostr_pubkey": id.nostr_pubkey,
|
||||
"nostr_npub": id.nostr_npub,
|
||||
"nostr_pubkey": nostr_pubkey,
|
||||
"nostr_npub": nostr_npub,
|
||||
"profile": id.profile,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,9 +9,15 @@ use super::LND_REST_BASE_URL;
|
||||
impl RpcHandler {
|
||||
/// Generate a new on-chain Bitcoin address.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let (client, macaroon_hex) = self.lnd_client().await.map_err(|e| {
|
||||
tracing::warn!(error = %format!("{e:#}"), "LND newaddress: client/macaroon unavailable");
|
||||
receive_error(
|
||||
RECEIVE_WALLET_UNINITIALIZED,
|
||||
"The Lightning wallet isn't set up on this node yet. Finish wallet setup, then try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let resp = client
|
||||
let resp = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
|
||||
// LND's REST gateway parses `type` as the AddressType enum by its
|
||||
// proto name (or integer), NOT the lncli aliases. "p2wkh" is not a
|
||||
@@ -21,13 +27,26 @@ impl RpcHandler {
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
// The .116 case: LND container is up but its REST endpoint isn't
|
||||
// reachable on the expected port (e.g. published-port drift), so
|
||||
// the connection is refused. This is NOT a locked wallet — emit a
|
||||
// distinct code so the UI stops mislabelling it.
|
||||
tracing::warn!(error = %format!("{e:#}"), "LND newaddress: REST connection failed");
|
||||
return Err(receive_error(
|
||||
RECEIVE_REST_UNREACHABLE,
|
||||
"The Lightning wallet service isn't reachable yet. It may be starting up or recovering — please try again in a moment.",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let raw_body = resp
|
||||
.text()
|
||||
.await
|
||||
.context("LND address response could not be read")?;
|
||||
.context("Bitcoin address response could not be read")?;
|
||||
let body: serde_json::Value = serde_json::from_str(&raw_body).unwrap_or_else(|_| {
|
||||
serde_json::json!({
|
||||
"raw": raw_body,
|
||||
@@ -36,11 +55,9 @@ impl RpcHandler {
|
||||
|
||||
if !status.is_success() {
|
||||
let message = lnd_error_message(&body);
|
||||
anyhow::bail!(
|
||||
"Bitcoin address generation failed ({}): {}",
|
||||
status,
|
||||
message
|
||||
);
|
||||
let code = classify_lnd_address_error(&message);
|
||||
tracing::warn!(%status, lnd_message = %message, code, "LND newaddress returned an error");
|
||||
return Err(receive_error(code, default_receive_detail(code)));
|
||||
}
|
||||
|
||||
if let Some(error) = body
|
||||
@@ -48,14 +65,21 @@ impl RpcHandler {
|
||||
.or_else(|| body.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
anyhow::bail!("Bitcoin address generation failed: {}", error);
|
||||
let code = classify_lnd_address_error(error);
|
||||
tracing::warn!(lnd_message = %error, code, "LND newaddress returned an error body");
|
||||
return Err(receive_error(code, default_receive_detail(code)));
|
||||
}
|
||||
|
||||
let address = body
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|addr| !addr.trim().is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Bitcoin address generation failed: LND did not return a Bitcoin address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync."))?
|
||||
.ok_or_else(|| {
|
||||
receive_error(
|
||||
RECEIVE_WALLET_UNINITIALIZED,
|
||||
"The wallet didn't return an address yet. It may still be unlocking or waiting for Bitcoin to sync — please try again shortly.",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
Ok(serde_json::json!({ "address": address }))
|
||||
@@ -127,6 +151,179 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Create a Lightning invoice.
|
||||
/// Create a Lightning invoice and return `(bolt11, payment_hash_hex)`.
|
||||
///
|
||||
/// Shared helper used by both the `lnd.createinvoice` RPC and the seller-side
|
||||
/// peer-file invoice flow (#46). LND returns `r_hash` as base64; we re-encode
|
||||
/// it as hex so it can be used as a stable lookup key and passed in URLs.
|
||||
pub(crate) async fn create_invoice(
|
||||
&self,
|
||||
amount_sats: i64,
|
||||
memo: &str,
|
||||
) -> Result<(String, String)> {
|
||||
if amount_sats < 0 {
|
||||
return Err(anyhow::anyhow!("Amount must be non-negative"));
|
||||
}
|
||||
if memo.len() > 639 {
|
||||
return Err(anyhow::anyhow!("Memo too long (max 639 bytes)"));
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let invoice_body = serde_json::json!({
|
||||
"value": amount_sats.to_string(),
|
||||
"memo": memo,
|
||||
});
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&invoice_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create invoice")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse invoice response")?;
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to create invoice: {}", msg));
|
||||
}
|
||||
|
||||
let payment_request = body
|
||||
.get("payment_request")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// r_hash is base64 in LND's REST response — convert to hex.
|
||||
use base64::Engine;
|
||||
let payment_hash_hex = body
|
||||
.get("r_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||
.map(hex::encode)
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok((payment_request, payment_hash_hex))
|
||||
}
|
||||
|
||||
/// Look up an invoice by hex payment hash; true if it has settled.
|
||||
pub(crate) async fn invoice_is_settled(&self, payment_hash_hex: &str) -> Result<bool> {
|
||||
if payment_hash_hex.is_empty() || hex::decode(payment_hash_hex).is_err() {
|
||||
return Err(anyhow::anyhow!("Invalid payment hash"));
|
||||
}
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
// LND REST: GET /v1/invoice/{r_hash_str} where r_hash_str is hex.
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{payment_hash_hex}"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to look up invoice")?;
|
||||
if !resp.status().is_success() {
|
||||
return Ok(false);
|
||||
}
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse invoice lookup response")?;
|
||||
let settled = body
|
||||
.get("settled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
|| body.get("state").and_then(|v| v.as_str()) == Some("SETTLED");
|
||||
Ok(settled)
|
||||
}
|
||||
|
||||
/// Generate a fresh on-chain receive address (seller side, #46).
|
||||
pub(crate) async fn new_onchain_address(&self) -> Result<String> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to get new address")?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow::anyhow!("LND newaddress failed: {}", resp.status()));
|
||||
}
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse newaddress response")?;
|
||||
body.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("LND newaddress returned no address"))
|
||||
}
|
||||
|
||||
/// True if an on-chain payment of >= `min_sats` to `address` has been seen
|
||||
/// with at least one confirmation (seller side, #46). Conservative on
|
||||
/// purpose: requires a confirmation + exact-address + sufficient-amount so a
|
||||
/// file sale is never released on an unconfirmed (reorg-able) tx.
|
||||
pub(crate) async fn onchain_received(&self, address: &str, min_sats: u64) -> Result<bool> {
|
||||
if address.is_empty() {
|
||||
return Err(anyhow::anyhow!("Empty address"));
|
||||
}
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to list transactions")?;
|
||||
if !resp.status().is_success() {
|
||||
return Ok(false);
|
||||
}
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse transactions response")?;
|
||||
let i64_field = |tx: &serde_json::Value, k: &str| -> i64 {
|
||||
tx.get(k)
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.or_else(|| tx.get(k).and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let txs = body
|
||||
.get("transactions")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
for tx in &txs {
|
||||
if i64_field(tx, "num_confirmations") < 1 {
|
||||
continue;
|
||||
}
|
||||
if i64_field(tx, "amount") < min_sats as i64 {
|
||||
continue;
|
||||
}
|
||||
let pays_addr = tx
|
||||
.get("dest_addresses")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().any(|a| a.as_str() == Some(address)))
|
||||
.unwrap_or(false)
|
||||
|| tx
|
||||
.get("output_details")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.any(|o| o.get("address").and_then(|a| a.as_str()) == Some(address))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if pays_addr {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_createinvoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -528,8 +725,15 @@ impl RpcHandler {
|
||||
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(entropy);
|
||||
entropy.zeroize();
|
||||
|
||||
// Use the per-node secret as the LND wallet password (NOT the
|
||||
// caller-supplied one) so the unattended boot path can auto-unlock this
|
||||
// wallet. The wallet stays recoverable from the Archipelago seed via the
|
||||
// derived entropy above. This unifies both init paths on one password
|
||||
// source — the divergence here is what left wallets locked fleet-wide.
|
||||
let _ = wallet_password; // accepted for API compat; superseded by the per-node secret
|
||||
let node_wallet_pw = crate::container::lnd::ensure_wallet_password().await?;
|
||||
let wallet_password_b64 =
|
||||
base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(node_wallet_pw.as_bytes());
|
||||
|
||||
// Call LND REST API to initialize wallet with derived entropy.
|
||||
// LND must be running but NOT yet initialized (no existing wallet).
|
||||
@@ -583,9 +787,75 @@ fn lnd_error_message(body: &serde_json::Value) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// Stable, machine-readable reason codes for receive-address failures. They are
|
||||
// embedded in the error message as a `[CODE]` token so the frontend
|
||||
// (neode-ui/src/utils/bitcoinReceive.ts) can show an accurate explanation
|
||||
// instead of guessing wallet state by substring-matching — which is what made
|
||||
// .228 report "wallet is locked" when LND's REST was merely unreachable.
|
||||
//
|
||||
// Every receive error string starts with "Bitcoin address" so it survives the
|
||||
// RPC error sanitizer (api/rpc/middleware.rs) unchanged rather than being
|
||||
// flattened to the generic "Operation failed" message (the .116 symptom).
|
||||
pub(crate) const RECEIVE_REST_UNREACHABLE: &str = "LND_REST_UNREACHABLE";
|
||||
pub(crate) const RECEIVE_WALLET_LOCKED: &str = "LND_WALLET_LOCKED";
|
||||
pub(crate) const RECEIVE_WALLET_UNINITIALIZED: &str = "LND_WALLET_UNINITIALIZED";
|
||||
pub(crate) const RECEIVE_SYNCING: &str = "LND_SYNCING";
|
||||
pub(crate) const RECEIVE_LND_ERROR: &str = "LND_ERROR";
|
||||
|
||||
/// Build a receive-address error carrying a reason code the UI can map.
|
||||
fn receive_error(code: &str, detail: &str) -> anyhow::Error {
|
||||
anyhow::anyhow!("Bitcoin address unavailable [{code}]: {detail}")
|
||||
}
|
||||
|
||||
/// A sensible default human message per code (used for non-UI callers and logs;
|
||||
/// the frontend renders its own copy from the code).
|
||||
fn default_receive_detail(code: &str) -> &'static str {
|
||||
match code {
|
||||
RECEIVE_REST_UNREACHABLE => {
|
||||
"The Lightning wallet service isn't reachable yet. It may be starting up or recovering — please try again in a moment."
|
||||
}
|
||||
RECEIVE_WALLET_LOCKED => {
|
||||
"The Lightning wallet is locked. Unlock it (or finish wallet setup), then try again."
|
||||
}
|
||||
RECEIVE_WALLET_UNINITIALIZED => {
|
||||
"The Lightning wallet isn't set up yet. Finish wallet setup, then try again."
|
||||
}
|
||||
RECEIVE_SYNCING => {
|
||||
"The wallet is still syncing with the Bitcoin network. Please try again once it has caught up."
|
||||
}
|
||||
_ => "Couldn't generate a Bitcoin address right now. Please try again shortly.",
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a non-2xx LND error body/message into a reason code. The wording of
|
||||
/// LND's REST errors is stable enough to bucket: a locked wallet, an
|
||||
/// uninitialized wallet, a syncing chain, or some other failure.
|
||||
fn classify_lnd_address_error(message: &str) -> &'static str {
|
||||
let m = message.to_lowercase();
|
||||
if m.contains("locked") || m.contains("unlock") {
|
||||
RECEIVE_WALLET_LOCKED
|
||||
} else if m.contains("synchroniz")
|
||||
|| m.contains("syncing")
|
||||
|| m.contains("not yet ready")
|
||||
|| m.contains("in the process of starting")
|
||||
{
|
||||
RECEIVE_SYNCING
|
||||
} else if m.contains("wallet not found")
|
||||
|| m.contains("not exist")
|
||||
|| m.contains("uninitialized")
|
||||
|| m.contains("not initialized")
|
||||
|| m.contains("create a wallet")
|
||||
|| m.contains("no wallet")
|
||||
{
|
||||
RECEIVE_WALLET_UNINITIALIZED
|
||||
} else {
|
||||
RECEIVE_LND_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::lnd_error_message;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lnd_error_message_prefers_message_field() {
|
||||
@@ -604,4 +874,46 @@ mod tests {
|
||||
"unknown LND error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_locked_wallet() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("wallet locked, please unlock"),
|
||||
RECEIVE_WALLET_LOCKED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_uninitialized_wallet() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("wallet not found, create a wallet first"),
|
||||
RECEIVE_WALLET_UNINITIALIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_syncing() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("server is still in the process of starting"),
|
||||
RECEIVE_SYNCING
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_unknown_is_generic_error() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("some other failure"),
|
||||
RECEIVE_LND_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_error_starts_with_sanitizer_safe_prefix_and_embeds_code() {
|
||||
// Must start with "Bitcoin address" (survives the RPC error sanitizer)
|
||||
// and carry the [CODE] token the frontend parses.
|
||||
let err = receive_error(RECEIVE_REST_UNREACHABLE, "unreachable");
|
||||
let s = format!("{err}");
|
||||
assert!(s.starts_with("Bitcoin address"), "got: {s}");
|
||||
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Mesh-AI assistant RPCs (issue #50): read/update the local assistant config
|
||||
//! and report whether a local Ollama is available (for the install deep-link).
|
||||
|
||||
use super::super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Default model when the node hasn't picked one (kept in sync with the mesh
|
||||
/// assistant handler's `DEFAULT_MODEL`).
|
||||
const DEFAULT_MODEL: &str = "qwen2.5-coder";
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.assistant-status — current settings + local Ollama availability.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_assistant_status(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let cfg = {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
svc.assistant_config().await
|
||||
};
|
||||
|
||||
let (ollama_detected, models) = detect_ollama().await;
|
||||
let claude_available =
|
||||
tokio::fs::metadata(self.config.data_dir.join("secrets/claude-api-key"))
|
||||
.await
|
||||
.is_ok();
|
||||
Ok(serde_json::json!({
|
||||
"enabled": cfg.enabled,
|
||||
"model": cfg.model,
|
||||
"trusted_only": cfg.trusted_only,
|
||||
"backend": cfg.backend,
|
||||
"default_model": DEFAULT_MODEL,
|
||||
"ollama_detected": ollama_detected,
|
||||
"claude_available": claude_available,
|
||||
"models": models,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.assistant-configure — update assistant settings live.
|
||||
/// Params: `enabled?: bool`, `trusted_only?: bool`,
|
||||
/// `model?: string|null` (string sets, null clears to default, absent leaves).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_assistant_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let enabled = params.get("enabled").and_then(|v| v.as_bool());
|
||||
let trusted_only = params.get("trusted_only").and_then(|v| v.as_bool());
|
||||
let backend = params
|
||||
.get("backend")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
// model: key present + string => set; present + null => clear; absent => leave
|
||||
let model = if let Some(v) = params.get("model") {
|
||||
Some(v.as_str().map(|s| s.to_string()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
svc.configure_assistant(enabled, model, trusted_only, backend)
|
||||
.await?;
|
||||
let cfg = svc.assistant_config().await;
|
||||
Ok(serde_json::json!({
|
||||
"enabled": cfg.enabled,
|
||||
"model": cfg.model,
|
||||
"trusted_only": cfg.trusted_only,
|
||||
"backend": cfg.backend,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.schedule-message — queue a message to send at a future time.
|
||||
/// Params: `body: string`, `fire_at: i64` (unix secs), and one of
|
||||
/// `contact_id: u32` (DM) or `channel: u8` (broadcast).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_schedule_message(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let p = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let body = p
|
||||
.get("body")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("body is required"))?
|
||||
.to_string();
|
||||
let fire_at = p
|
||||
.get("fire_at")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("fire_at (unix seconds) is required"))?;
|
||||
let contact_id = p
|
||||
.get("contact_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
let channel = p.get("channel").and_then(|v| v.as_u64()).map(|v| v as u8);
|
||||
if contact_id.is_none() && channel.is_none() {
|
||||
anyhow::bail!("either contact_id or channel is required");
|
||||
}
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let msg = svc
|
||||
.scheduler
|
||||
.add(contact_id, channel, body, fire_at)
|
||||
.await?;
|
||||
Ok(serde_json::to_value(msg)?)
|
||||
}
|
||||
|
||||
/// mesh.list-scheduled — list queued messages (sorted by fire time).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_list_scheduled(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let messages = svc.scheduler.list().await;
|
||||
Ok(serde_json::json!({ "messages": messages }))
|
||||
}
|
||||
|
||||
/// mesh.cancel-scheduled — remove a queued message by id.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_cancel_scheduled(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("id"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("id is required"))?;
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let cancelled = svc.scheduler.cancel(id).await?;
|
||||
Ok(serde_json::json!({ "cancelled": cancelled }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the local Ollama HTTP API; return (detected, model_names).
|
||||
async fn detect_ollama() -> (bool, Vec<String>) {
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return (false, Vec::new()),
|
||||
};
|
||||
match client.get("http://localhost:11434/api/tags").send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let json: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let models = json
|
||||
.get("models")
|
||||
.and_then(|m| m.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|m| {
|
||||
m.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(true, models)
|
||||
}
|
||||
_ => (false, Vec::new()),
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,18 @@ impl RpcHandler {
|
||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||
config.advert_name = Some(name.to_string());
|
||||
}
|
||||
if let Some(announce) = params
|
||||
.get("announce_block_headers")
|
||||
.and_then(|v| v.as_bool())
|
||||
{
|
||||
config.announce_block_headers = announce;
|
||||
}
|
||||
if let Some(receive) = params
|
||||
.get("receive_block_headers")
|
||||
.and_then(|v| v.as_bool())
|
||||
{
|
||||
config.receive_block_headers = receive;
|
||||
}
|
||||
|
||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||
|
||||
@@ -124,6 +136,8 @@ impl RpcHandler {
|
||||
"configured": true,
|
||||
"enabled": config.enabled,
|
||||
"device_path": config.device_path,
|
||||
"announce_block_headers": config.announce_block_headers,
|
||||
"receive_block_headers": config.receive_block_headers,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod assistant;
|
||||
mod bitcoin_ops;
|
||||
mod messaging;
|
||||
mod safety;
|
||||
|
||||
@@ -5,26 +5,39 @@ use anyhow::Result;
|
||||
impl RpcHandler {
|
||||
/// mesh.status — Get mesh radio status, device info, and peer count.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
|
||||
// Block-header send/receive prefs live in MeshConfig; surface them in
|
||||
// status so the UI toggles (issue #28) can show the persisted state.
|
||||
let config = mesh::load_config(&self.config.data_dir).await?;
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let mut value = if let Some(svc) = service.as_ref() {
|
||||
let status = svc.status().await;
|
||||
Ok(serde_json::to_value(status)?)
|
||||
serde_json::to_value(status)?
|
||||
} else {
|
||||
// No service running — return basic config + device detection
|
||||
let config = mesh::load_config(&self.config.data_dir).await?;
|
||||
let devices = mesh::detect_devices().await;
|
||||
Ok(serde_json::json!({
|
||||
serde_json::json!({
|
||||
"enabled": config.enabled,
|
||||
"device_connected": false,
|
||||
"device_type": "unknown",
|
||||
"device_path": config.device_path,
|
||||
"channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()),
|
||||
"channel_name": config.channel_name.clone().unwrap_or_else(|| "archipelago".to_string()),
|
||||
"detected_devices": devices,
|
||||
"peer_count": 0,
|
||||
"messages_sent": 0,
|
||||
"messages_received": 0,
|
||||
}))
|
||||
})
|
||||
};
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert(
|
||||
"announce_block_headers".into(),
|
||||
config.announce_block_headers.into(),
|
||||
);
|
||||
obj.insert(
|
||||
"receive_block_headers".into(),
|
||||
config.receive_block_headers.into(),
|
||||
);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// mesh.peers — List discovered mesh peers.
|
||||
|
||||
@@ -1184,6 +1184,12 @@ impl RpcHandler {
|
||||
entry.pinned = p;
|
||||
}
|
||||
let saved = entry.clone();
|
||||
let snapshot = contacts.clone();
|
||||
drop(contacts);
|
||||
// Persist (encrypted, atomic) so the customisation survives restarts.
|
||||
if let Err(e) = crate::mesh::save_mesh_contacts(&self.config.data_dir, &snapshot).await {
|
||||
tracing::warn!("failed to persist mesh contacts: {e}");
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"saved": true,
|
||||
"pubkey": pubkey,
|
||||
@@ -1215,6 +1221,11 @@ impl RpcHandler {
|
||||
let mut contacts = state.contacts.write().await;
|
||||
let entry = contacts.entry(pubkey.clone()).or_default();
|
||||
entry.blocked = blocked;
|
||||
let snapshot = contacts.clone();
|
||||
drop(contacts);
|
||||
if let Err(e) = crate::mesh::save_mesh_contacts(&self.config.data_dir, &snapshot).await {
|
||||
tracing::warn!("failed to persist mesh contacts: {e}");
|
||||
}
|
||||
Ok(serde_json::json!({ "pubkey": pubkey, "blocked": blocked }))
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ mod credentials;
|
||||
mod dispatcher;
|
||||
mod dwn;
|
||||
mod federation;
|
||||
mod fedimint;
|
||||
mod fips;
|
||||
mod handshake;
|
||||
mod identity;
|
||||
|
||||
@@ -32,6 +32,8 @@ fn is_platform_managed_app(app_id: &str) -> bool {
|
||||
| "fedimint-gateway"
|
||||
| "indeedhub"
|
||||
| "immich"
|
||||
| "fips"
|
||||
| "fips-ui"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -750,27 +752,33 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool-api" => (
|
||||
vec!["8999:8999".to_string()],
|
||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||
vec![
|
||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||
"ELECTRUM_HOST=electrumx".to_string(),
|
||||
"ELECTRUM_PORT=50001".to_string(),
|
||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||
"CORE_RPC_HOST=bitcoin-knots".to_string(),
|
||||
"CORE_RPC_PORT=8332".to_string(),
|
||||
"CORE_RPC_USERNAME=archipelago".to_string(),
|
||||
format!("CORE_RPC_PASSWORD={}", rpc_pass),
|
||||
"DATABASE_ENABLED=true".to_string(),
|
||||
"DATABASE_HOST=archy-mempool-db".to_string(),
|
||||
"DATABASE_DATABASE=mempool".to_string(),
|
||||
"DATABASE_USERNAME=mempool".to_string(),
|
||||
format!("DATABASE_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool-api" => {
|
||||
// CORE_RPC_HOST must resolve to the actual Bitcoin node container —
|
||||
// bitcoin-knots OR bitcoin-core — else mempool-api can't reach RPC
|
||||
// on a Core node (B12). Falls back to bitcoin-knots if undetected.
|
||||
let bitcoin_rpc_host = super::dependencies::detect_bitcoin_rpc_host().await;
|
||||
(
|
||||
vec!["8999:8999".to_string()],
|
||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||
vec![
|
||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||
"ELECTRUM_HOST=electrumx".to_string(),
|
||||
"ELECTRUM_PORT=50001".to_string(),
|
||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||
format!("CORE_RPC_HOST={}", bitcoin_rpc_host),
|
||||
"CORE_RPC_PORT=8332".to_string(),
|
||||
"CORE_RPC_USERNAME=archipelago".to_string(),
|
||||
format!("CORE_RPC_PASSWORD={}", rpc_pass),
|
||||
"DATABASE_ENABLED=true".to_string(),
|
||||
"DATABASE_HOST=archy-mempool-db".to_string(),
|
||||
"DATABASE_DATABASE=mempool".to_string(),
|
||||
"DATABASE_USERNAME=mempool".to_string(),
|
||||
format!("DATABASE_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
"electrumx" | "mempool-electrs" | "electrs" => {
|
||||
(
|
||||
vec!["50001:50001".to_string()],
|
||||
|
||||
@@ -84,6 +84,78 @@ pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect the container name of the running Bitcoin node so dependent stacks
|
||||
/// (mempool) can point CORE_RPC_HOST at the right host. Bitcoin Knots and Bitcoin
|
||||
/// Core are both reachable on archy-net by their container name — only the name
|
||||
/// differs (`bitcoin-knots` vs `bitcoin-core`), so hardcoding one breaks the
|
||||
/// other. Returns the first running BITCOIN_NAMES match; falls back to the
|
||||
/// default `bitcoin-knots` if none is detected (callers gate on has_bitcoin).
|
||||
pub(super) async fn detect_bitcoin_rpc_host() -> String {
|
||||
let out = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
if let Ok(Ok(o)) = out {
|
||||
if o.status.success() {
|
||||
let running = String::from_utf8_lossy(&o.stdout);
|
||||
if let Some(name) = pick_bitcoin_host(&running) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
"bitcoin-knots".to_string()
|
||||
}
|
||||
|
||||
/// Pure host-selection step of [`detect_bitcoin_rpc_host`], split out so it can
|
||||
/// be unit-tested without a podman runtime. Returns the first `podman ps` line
|
||||
/// whose trimmed name is one of [`BITCOIN_NAMES`]. (The Quadlet orchestrator
|
||||
/// mirrors this in `prod_orchestrator::bitcoin_host`.)
|
||||
fn pick_bitcoin_host(podman_names: &str) -> Option<String> {
|
||||
podman_names
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|name| BITCOIN_NAMES.contains(name))
|
||||
.map(|name| name.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bitcoin_host_tests {
|
||||
use super::pick_bitcoin_host;
|
||||
|
||||
#[test]
|
||||
fn picks_knots() {
|
||||
let ps = "electrumx\nbitcoin-knots\narchy-mempool-db\n";
|
||||
assert_eq!(pick_bitcoin_host(ps).as_deref(), Some("bitcoin-knots"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_core() {
|
||||
let ps = "lnd\nbitcoin-core\nelectrumx\n";
|
||||
assert_eq!(pick_bitcoin_host(ps).as_deref(), Some("bitcoin-core"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_plain_bitcoin() {
|
||||
assert_eq!(pick_bitcoin_host("bitcoin\n").as_deref(), Some("bitcoin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_when_no_bitcoin_node() {
|
||||
let ps = "electrumx\nlnd\narchy-mempool-db\n";
|
||||
assert_eq!(pick_bitcoin_host(ps), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_substring_matches() {
|
||||
// A companion UI container must NOT be mistaken for the node itself.
|
||||
let ps = "archy-bitcoin-ui\nbitcoin-knots-foo\n";
|
||||
assert_eq!(pick_bitcoin_host(ps), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that required dependency services are running before installing an app.
|
||||
/// Returns an error with a user-friendly message if dependencies are missing.
|
||||
pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result<()> {
|
||||
|
||||
@@ -434,6 +434,13 @@ async fn wait_for_stack_containers(
|
||||
containers: &[&str],
|
||||
timeout_secs: u64,
|
||||
) -> Result<()> {
|
||||
// A container can exit on its first start because a dependency (db, redis,
|
||||
// the bitcoin node) was not quite ready — a transient crash, not a broken
|
||||
// install. Restart each exited container a bounded number of times before
|
||||
// declaring the install failed (#25). The runtime supervisor keeps it alive
|
||||
// afterwards, but we want a healthy state by the time install returns.
|
||||
const MAX_RESTARTS: u32 = 3;
|
||||
let mut restarts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
let mut pending = Vec::new();
|
||||
@@ -449,20 +456,41 @@ async fn wait_for_stack_containers(
|
||||
match state.as_str() {
|
||||
"running" => {}
|
||||
"exited" | "dead" => {
|
||||
let logs = stack_container_logs(container, 40).await;
|
||||
install_log(&format!(
|
||||
"INSTALL CRASH: {} - container {} exited. Logs:\n{}",
|
||||
stack_name,
|
||||
container,
|
||||
logs.chars().take(1000).collect::<String>()
|
||||
))
|
||||
.await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} container {} exited after install. Logs: {}",
|
||||
stack_name,
|
||||
container,
|
||||
logs.chars().take(500).collect::<String>()
|
||||
));
|
||||
let attempts = restarts.entry(container.to_string()).or_insert(0);
|
||||
if *attempts < MAX_RESTARTS {
|
||||
*attempts += 1;
|
||||
install_log(&format!(
|
||||
"INSTALL RESTART: {} - container {} exited, restart attempt {}/{}",
|
||||
stack_name, container, *attempts, MAX_RESTARTS
|
||||
))
|
||||
.await;
|
||||
let _ = podman_stack_output(
|
||||
&["start", container],
|
||||
PODMAN_STACK_PROBE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
pending.push(format!(
|
||||
"{}=restarting({}/{})",
|
||||
container, *attempts, MAX_RESTARTS
|
||||
));
|
||||
} else {
|
||||
let logs = stack_container_logs(container, 40).await;
|
||||
install_log(&format!(
|
||||
"INSTALL CRASH: {} - container {} exited after {} restarts. Logs:\n{}",
|
||||
stack_name,
|
||||
container,
|
||||
MAX_RESTARTS,
|
||||
logs.chars().take(1000).collect::<String>()
|
||||
))
|
||||
.await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} container {} exited after install ({} restarts). Logs: {}",
|
||||
stack_name,
|
||||
container,
|
||||
MAX_RESTARTS,
|
||||
logs.chars().take(500).collect::<String>()
|
||||
));
|
||||
}
|
||||
}
|
||||
other => pending.push(format!("{}={}", container, other)),
|
||||
}
|
||||
@@ -1152,6 +1180,9 @@ impl RpcHandler {
|
||||
let deps = super::dependencies::detect_running_deps().await?;
|
||||
super::dependencies::check_install_deps("mempool", &deps)?;
|
||||
let (_, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
// CORE_RPC_HOST must match the actual Bitcoin node container name —
|
||||
// bitcoin-knots OR bitcoin-core — else mempool-api can't reach RPC (B12).
|
||||
let bitcoin_rpc_host = super::dependencies::detect_bitcoin_rpc_host().await;
|
||||
|
||||
install_log("INSTALL START: mempool (stack: mariadb + mempool-api + mempool-web)").await;
|
||||
|
||||
@@ -1275,7 +1306,7 @@ impl RpcHandler {
|
||||
"-e",
|
||||
"ELECTRUM_TLS_ENABLED=false",
|
||||
"-e",
|
||||
"CORE_RPC_HOST=bitcoin-knots",
|
||||
&format!("CORE_RPC_HOST={}", bitcoin_rpc_host),
|
||||
"-e",
|
||||
"CORE_RPC_PORT=8332",
|
||||
"-e",
|
||||
@@ -1776,14 +1807,22 @@ impl RpcHandler {
|
||||
let host_ip = detect_netbird_public_host_ip()
|
||||
.await
|
||||
.unwrap_or_else(|| self.config.host_ip.clone());
|
||||
write_netbird_config_files(&host_ip).await?;
|
||||
|
||||
// Create the network FIRST so we can read back the gateway it was
|
||||
// assigned — that gateway is Podman's aardvark DNS, which the proxy's
|
||||
// nginx needs as an explicit `resolver` to re-resolve container names
|
||||
// (issue #15: without it nginx caches a container IP and 502s forever
|
||||
// once that IP changes on restart/reboot).
|
||||
let _ = podman_stack_status(
|
||||
&["network", "create", "netbird-net"],
|
||||
PODMAN_STACK_PROBE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
|
||||
let resolver_ip = netbird_net_resolver_ip().await;
|
||||
write_netbird_config_files(&host_ip, &self.config.host_ip, &resolver_ip).await?;
|
||||
ensure_netbird_tls_cert(&host_ip).await?;
|
||||
|
||||
let mut server_cmd = tokio::process::Command::new("podman");
|
||||
server_cmd.args([
|
||||
"run",
|
||||
@@ -1821,6 +1860,10 @@ impl RpcHandler {
|
||||
"netbird-dashboard",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
// Explicit alias so the proxy can always resolve `netbird-dashboard`
|
||||
// via Podman DNS — don't rely on implicit container-name aliasing.
|
||||
"--network-alias",
|
||||
"netbird-dashboard",
|
||||
"--restart=unless-stopped",
|
||||
"--env-file",
|
||||
"/var/lib/archipelago/netbird/dashboard.env",
|
||||
@@ -1837,10 +1880,16 @@ impl RpcHandler {
|
||||
"--network",
|
||||
"netbird-net",
|
||||
"--restart=unless-stopped",
|
||||
// 8087 publishes the TLS listener — netbird's dashboard requires a
|
||||
// secure context (window.crypto.subtle / OIDC PKCE), issue #15.
|
||||
"-p",
|
||||
"8087:80",
|
||||
"8087:443",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/nginx.conf:/etc/nginx/conf.d/default.conf:ro",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/tls.crt:/etc/nginx/tls.crt:ro",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/tls.key:/etc/nginx/tls.key:ro",
|
||||
NETBIRD_PROXY_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("netbird", "create unified proxy", &mut proxy_cmd).await?;
|
||||
@@ -1885,9 +1934,104 @@ async fn read_or_generate_b64_secret(name: &str) -> String {
|
||||
secret
|
||||
}
|
||||
|
||||
async fn write_netbird_config_files(host_ip: &str) -> Result<()> {
|
||||
let public_origin = format!("http://{}:8087", host_ip);
|
||||
/// Read the gateway of the `netbird-net` bridge. Podman runs its aardvark DNS
|
||||
/// resolver on this address, so nginx can use it as an explicit `resolver` to
|
||||
/// re-resolve container names at request time. Falls back to Podman's usual
|
||||
/// first-pool gateway if the inspect fails (best effort — config is rewritten
|
||||
/// on every (re)install).
|
||||
async fn netbird_net_resolver_ip() -> String {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"network",
|
||||
"inspect",
|
||||
"netbird-net",
|
||||
"--format",
|
||||
"{{range .Subnets}}{{.Gateway}}{{end}}",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(o) = out {
|
||||
let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if !gw.is_empty() && gw.parse::<std::net::IpAddr>().is_ok() {
|
||||
return gw;
|
||||
}
|
||||
}
|
||||
"10.89.0.1".to_string()
|
||||
}
|
||||
|
||||
/// Generate a self-signed TLS cert for the netbird proxy if absent. The
|
||||
/// dashboard needs a secure context (window.crypto.subtle / OIDC PKCE), so the
|
||||
/// proxy serves HTTPS; a self-signed cert is sufficient (the user accepts it
|
||||
/// once when opening netbird in a tab). SAN covers the LAN IP plus
|
||||
/// localhost/127.0.0.1 so it's valid however the box is reached locally.
|
||||
async fn ensure_netbird_tls_cert(host_ip: &str) -> Result<()> {
|
||||
let dir = "/var/lib/archipelago/netbird";
|
||||
let crt = format!("{dir}/tls.crt");
|
||||
let key = format!("{dir}/tls.key");
|
||||
if tokio::fs::metadata(&crt).await.is_ok() && tokio::fs::metadata(&key).await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = tokio::fs::create_dir_all(dir).await;
|
||||
let san = format!("subjectAltName=IP:{host_ip},IP:127.0.0.1,DNS:localhost");
|
||||
let status = tokio::process::Command::new("openssl")
|
||||
.args([
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
&key,
|
||||
"-out",
|
||||
&crt,
|
||||
"-days",
|
||||
"3650",
|
||||
"-subj",
|
||||
&format!("/CN={host_ip}"),
|
||||
"-addext",
|
||||
&san,
|
||||
])
|
||||
.status()
|
||||
.await
|
||||
.context("failed to run openssl for netbird TLS cert")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("openssl failed to generate netbird TLS cert");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_netbird_config_files(host_ip: &str, lan_ip: &str, resolver_ip: &str) -> Result<()> {
|
||||
// netbird's dashboard uses window.crypto.subtle (OIDC PKCE), which browsers
|
||||
// only expose in a SECURE context — so the proxy serves HTTPS and every
|
||||
// origin here is https (issue #15: over plain http the dashboard threw
|
||||
// "window.crypto.subtle is unavailable" and never reached login).
|
||||
let public_origin = format!("https://{}:8087", host_ip);
|
||||
let server_origin = format!("http://{}:8086", host_ip);
|
||||
// A single box is reached via several addresses. Allow the OIDC login flow
|
||||
// to redirect back to whichever origin the user actually used, otherwise
|
||||
// post-login lands on the wrong host and the dashboard shows
|
||||
// "Unauthenticated" (issue #15). The browser-side CORS is handled in the
|
||||
// nginx proxy; this covers the redirect-URI allow-list.
|
||||
let lan_origin = format!("https://{}:8087", lan_ip);
|
||||
let mut redirect_origins = vec![public_origin.clone()];
|
||||
if lan_origin != public_origin {
|
||||
redirect_origins.push(lan_origin);
|
||||
}
|
||||
let dashboard_redirect_uris = redirect_origins
|
||||
.iter()
|
||||
.flat_map(|o| {
|
||||
[
|
||||
format!(" - \"{o}/nb-auth\""),
|
||||
format!(" - \"{o}/nb-silent-auth\""),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let dashboard_logout_uris = redirect_origins
|
||||
.iter()
|
||||
.map(|o| format!(" - \"{o}/\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let relay_secret = read_or_generate_b64_secret("netbird-relay-auth-secret").await;
|
||||
let encryption_key = read_or_generate_b64_secret("netbird-store-encryption-key").await;
|
||||
let config = format!(
|
||||
@@ -1907,10 +2051,9 @@ async fn write_netbird_config_files(host_ip: &str) -> Result<()> {
|
||||
localAuthDisabled: false
|
||||
signKeyRefreshEnabled: false
|
||||
dashboardRedirectURIs:
|
||||
- "{public_origin}/nb-auth"
|
||||
- "{public_origin}/nb-silent-auth"
|
||||
{dashboard_redirect_uris}
|
||||
dashboardPostLogoutRedirectURIs:
|
||||
- "{public_origin}/"
|
||||
{dashboard_logout_uris}
|
||||
cliRedirectURIs:
|
||||
- "http://localhost:53000/"
|
||||
store:
|
||||
@@ -1944,12 +2087,23 @@ LETSENCRYPT_DOMAIN=none
|
||||
|
||||
let nginx_conf = format!(
|
||||
r#"server {{
|
||||
listen 80;
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
|
||||
# Route browser API/auth through the host-published server port. Rootless
|
||||
# Podman can give netbird-server a new container IP on restart while nginx
|
||||
# keeps an old resolved address, which breaks login with 502s.
|
||||
# netbird's dashboard needs a secure context (window.crypto.subtle for OIDC
|
||||
# PKCE), so the proxy terminates TLS with a self-signed cert (issue #15).
|
||||
ssl_certificate /etc/nginx/tls.crt;
|
||||
ssl_certificate_key /etc/nginx/tls.key;
|
||||
|
||||
# Rootless Podman can hand a container a new IP across restarts/reboots.
|
||||
# nginx resolves a literal upstream name ONCE at startup and caches it, so
|
||||
# after the IP moves every request 502s with "host unreachable" (issue #15,
|
||||
# observed live on .198: nginx pinned to a dead netbird-dashboard IP). Fix:
|
||||
# point `resolver` at the netbird-net gateway (Podman's aardvark DNS) and
|
||||
# use VARIABLE upstreams, which forces nginx to re-resolve the container
|
||||
# names at request time. Everything is reached container-to-container by
|
||||
# name so nothing depends on host-published ports either.
|
||||
resolver {resolver_ip} valid=10s ipv6=off;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -1958,24 +2112,60 @@ LETSENCRYPT_DOMAIN=none
|
||||
proxy_http_version 1.1;
|
||||
|
||||
location ~ ^/(relay|ws-proxy/) {{
|
||||
proxy_pass http://host.containers.internal:8086;
|
||||
set $nb_server netbird-server;
|
||||
proxy_pass http://$nb_server:80;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 1d;
|
||||
}}
|
||||
|
||||
location ~ ^/(api|oauth2)(/|$) {{
|
||||
proxy_pass http://host.containers.internal:8086;
|
||||
# The dashboard is a SPA whose API/OIDC base URL is baked at build time
|
||||
# to one host:port. A single box is reached via several addresses (LAN
|
||||
# IP, Tailscale 100.x, hostname), so those fetches are cross-origin and
|
||||
# the browser blocks them with no Access-Control-Allow-Origin (issue
|
||||
# #15, observed live on .198). Reflect the caller's Origin so the
|
||||
# self-hosted management/OIDC API is reachable from any of them, and
|
||||
# answer the CORS preflight here.
|
||||
if ($request_method = OPTIONS) {{
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
add_header Access-Control-Max-Age 86400 always;
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}}
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
set $nb_server netbird-server;
|
||||
proxy_pass http://$nb_server:80;
|
||||
}}
|
||||
|
||||
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ {{
|
||||
grpc_pass grpc://netbird-server:80;
|
||||
set $nb_server netbird-server;
|
||||
grpc_pass grpc://$nb_server:80;
|
||||
grpc_read_timeout 1d;
|
||||
grpc_send_timeout 1d;
|
||||
}}
|
||||
|
||||
# OIDC callback routes are client-side SPA routes with NO prebuilt page in
|
||||
# the dashboard bundle, so proxying them straight through 404s — which
|
||||
# crashes the dashboard's auth init and shows "Unauthenticated" with dead
|
||||
# buttons (issue #15, confirmed live on .198: /nb-auth + /nb-silent-auth
|
||||
# returned 404). Serve the dashboard's index.html at these paths (URL
|
||||
# unchanged) so react-oidc boots and completes the login / silent-SSO.
|
||||
location ~ ^/(nb-auth|nb-silent-auth) {{
|
||||
set $nb_dashboard netbird-dashboard;
|
||||
rewrite ^.*$ /index.html break;
|
||||
proxy_pass http://$nb_dashboard:80;
|
||||
}}
|
||||
|
||||
location / {{
|
||||
proxy_pass http://netbird-dashboard:80;
|
||||
set $nb_dashboard netbird-dashboard;
|
||||
proxy_pass http://$nb_dashboard:80;
|
||||
}}
|
||||
}}
|
||||
|
||||
@@ -1996,10 +2186,32 @@ async fn detect_netbird_public_host_ip() -> Option<String> {
|
||||
.await
|
||||
.ok()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout
|
||||
let ips: Vec<&str> = stdout
|
||||
.split_whitespace()
|
||||
.find(|ip| ip.starts_with("100.") && ip.contains('.'))
|
||||
.map(str::to_string)
|
||||
.filter(|s| s.contains('.'))
|
||||
.collect();
|
||||
|
||||
// Prefer the LAN address as the canonical origin — that's what users browse
|
||||
// to on the local network. Baking the Tailscale 100.x address here broke
|
||||
// LAN access with cross-origin/redirect mismatches (issue #15). Tailscale
|
||||
// (100.64.0.0/10 CGNAT) is only a fallback for nodes with no LAN IP.
|
||||
let is_private_lan = |ip: &str| {
|
||||
ip.starts_with("192.168.")
|
||||
|| ip.starts_with("10.")
|
||||
|| (ip.starts_with("172.")
|
||||
&& ip
|
||||
.split('.')
|
||||
.nth(1)
|
||||
.and_then(|o| o.parse::<u8>().ok())
|
||||
.map(|o| (16..=31).contains(&o))
|
||||
.unwrap_or(false))
|
||||
};
|
||||
if let Some(lan) = ips.iter().find(|ip| is_private_lan(ip)) {
|
||||
return Some(lan.to_string());
|
||||
}
|
||||
ips.iter()
|
||||
.find(|ip| ip.starts_with("100."))
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -32,8 +32,11 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
// Verify an update is actually available
|
||||
let pinned = image_versions::pinned_image_for_app(package_id)
|
||||
// Verify an update is actually available. Prefer the remote app catalog
|
||||
// (decoupled from the binary OTA), falling back to the image-versions.sh
|
||||
// pin when the catalog is absent or doesn't cover this app.
|
||||
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
|
||||
.or_else(|| image_versions::pinned_image_for_app(package_id))
|
||||
.ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?;
|
||||
|
||||
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||
@@ -149,6 +152,28 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Manual "check for updates": refresh the remote app catalog now. The
|
||||
/// package scanner recomputes each app's `available-update` from the fresh
|
||||
/// catalog on its next cycle and pushes it to the UI. Best-effort — a fetch
|
||||
/// failure leaves the cached catalog in place and reports `refreshed: false`.
|
||||
pub(in crate::api::rpc) async fn handle_package_check_updates(
|
||||
&self,
|
||||
_params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
match crate::container::app_catalog::refresh_catalog(&self.config.data_dir).await {
|
||||
Ok(count) => Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"refreshed": true,
|
||||
"catalog_apps": count,
|
||||
})),
|
||||
Err(e) => Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"refreshed": false,
|
||||
"error": e.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Core update execution: stop → pull → remove → recreate → verify.
|
||||
async fn execute_update(
|
||||
&self,
|
||||
@@ -385,13 +410,24 @@ impl RpcHandler {
|
||||
package_id: &str,
|
||||
pinned_primary: &str,
|
||||
) -> Vec<(String, String)> {
|
||||
let stack_images = image_versions::pinned_images_for_stack(package_id);
|
||||
let mut stack_images = image_versions::pinned_images_for_stack(package_id);
|
||||
if stack_images.is_empty() {
|
||||
// Single container app
|
||||
vec![(package_id.to_string(), pinned_primary.to_string())]
|
||||
} else {
|
||||
stack_images
|
||||
// Single container app — pinned_primary already prefers the catalog.
|
||||
return vec![(package_id.to_string(), pinned_primary.to_string())];
|
||||
}
|
||||
// Stack app: override per-container images with the catalog where it
|
||||
// provides them; components the catalog omits keep the image-versions.sh
|
||||
// pin. This lets a single component (e.g. the IndeeHub frontend) be
|
||||
// bumped without touching the rest of the stack.
|
||||
let catalog_images = crate::container::app_catalog::catalog_stack_images(package_id);
|
||||
if !catalog_images.is_empty() {
|
||||
for (name, image) in stack_images.iter_mut() {
|
||||
if let Some(catalog_image) = catalog_images.get(name) {
|
||||
*image = catalog_image.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
stack_images
|
||||
}
|
||||
|
||||
/// Rollback: restart old containers if they still exist.
|
||||
|
||||
@@ -60,6 +60,30 @@ impl RpcHandler {
|
||||
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
|
||||
/// Returns the words for the user to write down.
|
||||
pub(in crate::api::rpc) async fn handle_seed_generate(&self) -> Result<serde_json::Value> {
|
||||
// Serialize concurrent / retried generate calls. The web client aborts
|
||||
// at 15s and retries internally (up to 3x), and the onboarding view
|
||||
// re-fires every 4s while the server is still booting on slow first-boot
|
||||
// hardware. Without this guard each hit would mint a brand-new seed and
|
||||
// overwrite the node keys mid-flight, leaving the words shown to the user
|
||||
// out of sync with what `seed.verify` expects — the classic "error at the
|
||||
// DID-creation screen". Holding the lock across the whole op fully
|
||||
// serializes them.
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
|
||||
// Idempotent fast-path: a fresh pending mnemonic already exists, so the
|
||||
// node keys are already on disk. Return the SAME words rather than
|
||||
// regenerating, so every retry yields a consistent result.
|
||||
if let Some(existing) = state.as_ref() {
|
||||
if existing.created_at.elapsed() < MNEMONIC_TTL {
|
||||
let words: Vec<String> = existing
|
||||
.words
|
||||
.split_whitespace()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
return Ok(serde_json::json!({ "words": words }));
|
||||
}
|
||||
}
|
||||
|
||||
let (mnemonic, seed) = crate::seed::MasterSeed::generate()?;
|
||||
|
||||
// Derive and write node Ed25519 key.
|
||||
@@ -89,16 +113,14 @@ impl RpcHandler {
|
||||
// the onboarding RPC returns immediately.
|
||||
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
||||
|
||||
let words: Vec<&str> = mnemonic.words().collect();
|
||||
let words: Vec<String> = mnemonic.words().map(str::to_string).collect();
|
||||
|
||||
// Hold mnemonic in memory for the verify step.
|
||||
{
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
*state = Some(OnboardingMnemonicState {
|
||||
words: mnemonic.to_string(),
|
||||
created_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
// Hold mnemonic in memory for the verify step. We already own the lock
|
||||
// guard (`state`) from the top of the function, so just write through it.
|
||||
*state = Some(OnboardingMnemonicState {
|
||||
words: mnemonic.to_string(),
|
||||
created_at: std::time::Instant::now(),
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"words": words,
|
||||
@@ -149,11 +171,13 @@ impl RpcHandler {
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
|
||||
|
||||
// Clear mnemonic from memory now that it's verified.
|
||||
{
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
*state = None;
|
||||
}
|
||||
// Intentionally DO NOT clear the mnemonic here. The web client aborts
|
||||
// slow requests at 15s and retries internally; if we wiped it on the
|
||||
// first (successful) verify, a retried request would fail with
|
||||
// "No pending seed generation or session expired" even though the user
|
||||
// did everything right. The mnemonic is bounded by MNEMONIC_TTL (10 min)
|
||||
// and is overwritten on the next generate, so leaving it makes verify
|
||||
// idempotent without meaningfully widening the in-memory window.
|
||||
|
||||
// Save the encrypted seed for convenience backup.
|
||||
// Use empty passphrase placeholder — the real encrypted save happens via seed.save-encrypted.
|
||||
@@ -290,4 +314,101 @@ impl RpcHandler {
|
||||
"next_index": next_index,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reveal the node's 24-word recovery phrase after onboarding. Heavily
|
||||
/// gated, because this is the keys to the whole node:
|
||||
/// - requires a full authenticated session (enforced upstream: this
|
||||
/// method is NOT in the public auth whitelist),
|
||||
/// - re-verifies the login password,
|
||||
/// - requires a valid TOTP code when 2FA is enabled (replay-protected),
|
||||
/// - decrypts `identity/master_seed.enc` with the backup passphrase
|
||||
/// (defaults to the login password when the user used the same value).
|
||||
/// The words are returned to the caller only and never logged.
|
||||
pub(in crate::api::rpc) async fn handle_seed_reveal(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let mut password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if password.is_empty() {
|
||||
anyhow::bail!("Password is required to reveal the recovery phrase");
|
||||
}
|
||||
|
||||
// Nothing to reveal if this node never stored an encrypted seed.
|
||||
if !crate::seed::seed_exists(&self.config.data_dir) {
|
||||
anyhow::bail!(
|
||||
"This node has no encrypted seed backup, so the recovery phrase \
|
||||
cannot be shown. It was only displayed once during setup."
|
||||
);
|
||||
}
|
||||
|
||||
// 1) Re-authenticate with the login password.
|
||||
if !self.auth_manager.verify_password(&password).await? {
|
||||
password.zeroize();
|
||||
anyhow::bail!("Incorrect password");
|
||||
}
|
||||
|
||||
// 2) Require a valid 2FA code when TOTP is enabled (replay-protected).
|
||||
if self.auth_manager.is_totp_enabled().await.unwrap_or(false) {
|
||||
let code = params
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if code.is_empty() {
|
||||
password.zeroize();
|
||||
anyhow::bail!("A 2FA code is required to reveal the recovery phrase");
|
||||
}
|
||||
let totp_data = self
|
||||
.auth_manager
|
||||
.get_totp_data()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("2FA is enabled but no TOTP data found"))?;
|
||||
let secret = crate::totp::decrypt_secret(&totp_data, &password)
|
||||
.context("Could not unlock 2FA with this password")?;
|
||||
match crate::totp::verify_code(&secret, &code, &totp_data.used_steps)? {
|
||||
Some(step) => {
|
||||
// Record the used step for replay protection, pruning old ones.
|
||||
let mut data = totp_data;
|
||||
data.used_steps.push(step);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let cutoff = (now / 30) - 10; // ~5 minutes
|
||||
data.used_steps.retain(|s| *s > cutoff);
|
||||
let _ = self.auth_manager.update_totp(data).await;
|
||||
}
|
||||
None => {
|
||||
password.zeroize();
|
||||
anyhow::bail!("Invalid 2FA code");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Decrypt the stored seed. The backup passphrase may differ from the
|
||||
// login password, so accept an explicit one and fall back to the
|
||||
// password when the user used the same value for both.
|
||||
let passphrase = params
|
||||
.get("passphrase")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let secret_phrase = passphrase.unwrap_or_else(|| password.clone());
|
||||
let reveal = crate::seed::load_seed_encrypted(&self.config.data_dir, &secret_phrase).await;
|
||||
password.zeroize();
|
||||
let mnemonic = reveal.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Could not decrypt the saved seed. If you set a separate backup \
|
||||
passphrase during setup, enter that passphrase."
|
||||
)
|
||||
})?;
|
||||
|
||||
let words: Vec<String> = mnemonic.words().map(|w| w.to_string()).collect();
|
||||
let word_count = words.len();
|
||||
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +205,64 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a payment token for a remote seeder (payer side, cross-mint aware).
|
||||
///
|
||||
/// Given the seeder's advertised `accepted_mints` and `price_sats`, builds a
|
||||
/// `cashuA` token denominated in one of those mints — paying directly if we
|
||||
/// already hold the right mint, else auto-swapping into a trusted accepted
|
||||
/// mint (within `max_fee_sats`). If the price is over `budget_sats`, the
|
||||
/// wallet can't cover it, or the swap is too costly, returns `declined` so
|
||||
/// the caller falls back to the free origin (origin always wins).
|
||||
pub(super) async fn handle_streaming_prepare_payment(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let accepted_mints: Vec<String> = params
|
||||
.get("accepted_mints")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let price_sats = params
|
||||
.get("price_sats")
|
||||
.or_else(|| params.get("amount_sats"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing price_sats"))?;
|
||||
// Default budget = the asked price (willing to pay exactly what's quoted).
|
||||
let budget_sats = params
|
||||
.get("budget_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(price_sats);
|
||||
let max_fee_sats = params
|
||||
.get("max_fee_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let policy = crate::swarm::payment::PaymentPolicy::with_budget(budget_sats, max_fee_sats);
|
||||
match crate::swarm::payment::auto_pay_token(
|
||||
&self.config.data_dir,
|
||||
&policy,
|
||||
&accepted_mints,
|
||||
price_sats,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(token) => Ok(serde_json::json!({
|
||||
"status": "ready",
|
||||
"token": token,
|
||||
"paid_sats": price_sats,
|
||||
})),
|
||||
None => Ok(serde_json::json!({
|
||||
"status": "declined",
|
||||
"message": "payment declined (over budget, unpayable, or swap too costly) — use free origin",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover available streaming services (pricing info).
|
||||
/// This is the unauthenticated discovery endpoint.
|
||||
pub(super) async fn handle_streaming_discover(&self) -> Result<serde_json::Value> {
|
||||
|
||||
@@ -253,6 +253,54 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "mirrors": list }))
|
||||
}
|
||||
|
||||
/// Report the node's swarm prefs (fetch source + whether it provides to the
|
||||
/// swarm) plus swarm capability, so the UI can show whether DHT mode is
|
||||
/// actually usable on this build.
|
||||
pub(super) async fn handle_update_get_source(&self) -> Result<serde_json::Value> {
|
||||
let source = update::load_update_source(&self.config.data_dir).await;
|
||||
let provide_dht = update::load_provide_dht(&self.config.data_dir).await;
|
||||
let source_str = match source {
|
||||
update::UpdateSource::Origin => "origin",
|
||||
update::UpdateSource::Swarm => "swarm",
|
||||
};
|
||||
Ok(serde_json::json!({
|
||||
"source": source_str,
|
||||
// Whether this node seeds/serves blobs to peers (default true).
|
||||
"provide_dht": provide_dht,
|
||||
// Compiled with the iroh swarm engine? If false, "swarm" mode has no
|
||||
// peers and silently behaves like origin.
|
||||
"swarm_available": cfg!(feature = "iroh-swarm"),
|
||||
// Runtime swarm-assist gate from config (ARCHIPELAGO_SWARM_ENABLED).
|
||||
"swarm_enabled": self.config.swarm_enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update the node's swarm prefs. Params (both optional, at least one):
|
||||
/// `{ source?: "origin" | "swarm", provide?: bool }`.
|
||||
pub(super) async fn handle_update_set_source(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut touched = false;
|
||||
if let Some(s) = params.get("source").and_then(|v| v.as_str()) {
|
||||
let source = match s {
|
||||
"origin" => update::UpdateSource::Origin,
|
||||
"swarm" => update::UpdateSource::Swarm,
|
||||
_ => anyhow::bail!("source must be \"origin\" or \"swarm\""),
|
||||
};
|
||||
update::save_update_source(&self.config.data_dir, source).await?;
|
||||
touched = true;
|
||||
}
|
||||
if let Some(provide) = params.get("provide").and_then(|v| v.as_bool()) {
|
||||
update::save_provide_dht(&self.config.data_dir, provide).await?;
|
||||
touched = true;
|
||||
}
|
||||
if !touched {
|
||||
anyhow::bail!("expected \"source\" and/or \"provide\"");
|
||||
}
|
||||
self.handle_update_get_source().await
|
||||
}
|
||||
|
||||
/// Add a mirror to the end of the list. Params: `{ url, label? }`.
|
||||
/// Duplicates (same URL) are replaced rather than added twice.
|
||||
pub(super) async fn handle_update_add_mirror(
|
||||
|
||||
@@ -25,6 +25,12 @@ pub const MAX_BLOB_SIZE: u64 = 64 * 1024 * 1024;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlobMeta {
|
||||
pub cid: String,
|
||||
/// DHT Phase 1: BLAKE3 hash of the content (iroh-native swarm address).
|
||||
/// The on-disk path stays SHA-256-keyed (`cid`) for back-compat; this
|
||||
/// advertises the hash a peer swarm can fetch/range-verify by. Absent in
|
||||
/// legacy metadata written before Phase 1.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub blake3: Option<String>,
|
||||
pub size: u64,
|
||||
pub mime: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -88,6 +94,7 @@ impl BlobStore {
|
||||
let cid = hex::encode(hasher.finalize());
|
||||
let meta = BlobMeta {
|
||||
cid: cid.clone(),
|
||||
blake3: Some(crate::content_hash::blake3_hex(bytes)),
|
||||
size: bytes.len() as u64,
|
||||
mime: mime.to_string(),
|
||||
filename,
|
||||
|
||||
@@ -30,8 +30,22 @@ const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.s
|
||||
const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service";
|
||||
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
|
||||
|
||||
// Kiosk hardening (#36): keep the deployed unit + launcher in sync with the
|
||||
// repo so the CPU/memory cap and the GPU-vs-headless flag selection reach
|
||||
// already-installed nodes via OTA, not just fresh ISOs.
|
||||
const KIOSK_SERVICE: &str = include_str!("../../../image-recipe/configs/archipelago-kiosk.service");
|
||||
const KIOSK_LAUNCHER: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-kiosk-launcher.sh");
|
||||
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
||||
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
|
||||
|
||||
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
|
||||
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
|
||||
/// Per-app proxy snippet included by the HTTPS (:443) server block. Carries its
|
||||
/// own `/app/fedimint/` location, so it needs the same B13 asset-rewrite heal as
|
||||
/// the main conf — browsers reach fedimint over HTTPS via this snippet. Absent on
|
||||
/// HTTP-only nodes, in which case the bootstrap loop skips it.
|
||||
const NGINX_HTTPS_SNIPPET_PATH: &str = "/etc/nginx/snippets/archipelago-https-app-proxies.conf";
|
||||
const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
|
||||
|
||||
/// Inserted into every server block of the nginx config that lacks the
|
||||
@@ -41,6 +55,41 @@ const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backe
|
||||
|
||||
const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n proxy_pass http://127.0.0.1:5678/bitcoin-status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Inserted into every server block that lacks the `/proxy/lnd/` proxy. Nodes
|
||||
/// flashed before 2026-04-10 shipped an nginx config without this block, so the
|
||||
/// browser's wallet fetches to `/proxy/lnd/*` fell through to the SPA
|
||||
/// index.html and got HTML back instead of JSON ("failing to fetch"). Kept in
|
||||
/// sync with the canonical block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles auth + CORS\n location /proxy/lnd/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Inserted into every server block lacking the peer-content streaming proxy.
|
||||
/// Without it, the browser's `<video>`/`<audio>` Range requests to
|
||||
/// `/api/peer-content/*` fall through to the SPA index.html (HTML, no Range)
|
||||
/// and peer media won't play (B3). Forwards Cookie (session auth) + Range and
|
||||
/// disables buffering so streaming works. Kept in sync with the canonical
|
||||
/// block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file.\n # Long read timeout: this path also serves full-file downloads of large\n # media (#38), which can take minutes over Tor; 120s aborted them.\n location /api/peer-content/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 900s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// B13 — Fedimint UI asset rewrite. Pre-fix nodes proxy /app/fedimint/ with only
|
||||
/// the nostr-provider injection (`sub_filter_once on`), so the UI's root-rooted
|
||||
/// CSS/JS asset URLs (href="/…", url("/…")) miss the proxy and load the SPA shell
|
||||
/// → unstyled UI. We swap that single sub_filter for the full rewrite set that
|
||||
/// reroots every asset URL under /app/fedimint/. NEW matches the canonical block
|
||||
/// in image-recipe/configs/nginx-archipelago.conf byte-for-byte so self-healed
|
||||
/// nodes converge to the same config fresh ISOs ship with.
|
||||
const NGINX_FEDIMINT_OLD: &str = " sub_filter_once on;\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';\n }\n location /app/fedimint-gateway/ {";
|
||||
const NGINX_FEDIMINT_NEW: &str = " sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';\n }\n location /app/fedimint-gateway/ {";
|
||||
|
||||
/// B13 Style B — the HTTPS app-proxy snippet's fedimint block has NO sub_filter
|
||||
/// at all (older than the main conf's), and the directive that follows it varies
|
||||
/// per node (fedimint-gateway vs tailscale), so a full-block match is unreliable.
|
||||
/// Instead we anchor on the unique :8175 proxy_pass (fedimint is the only block
|
||||
/// proxying there) and insert the reroot set right after it — directive order
|
||||
/// inside a location block is irrelevant to nginx. Idempotent via the same
|
||||
/// `href="/app/fedimint/` marker the main-conf heal leaves behind.
|
||||
const NGINX_FEDIMINT_SNIPPET_ANCHOR: &str = "proxy_pass http://127.0.0.1:8175/;";
|
||||
const NGINX_FEDIMINT_SNIPPET_INSERT: &str = "proxy_pass http://127.0.0.1:8175/;\n proxy_set_header Accept-Encoding \"\";\n sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';";
|
||||
|
||||
/// Entry point called from main startup. Never returns an error to the caller —
|
||||
/// failing to bootstrap host artifacts must not prevent the backend from serving.
|
||||
pub async fn ensure_doctor_installed() {
|
||||
@@ -476,6 +525,92 @@ async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
const ARCHIPELAGO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago.service";
|
||||
const MOUNT_REQUIRE_LINE: &str = "RequiresMountsFor=/var/lib/archipelago";
|
||||
|
||||
/// B17 self-heal: ensure the installed archipelago.service waits for the data
|
||||
/// volume to mount before it starts. On production nodes `/var/lib/archipelago`
|
||||
/// (the app data dir AND podman's graphroot) is a separate device-mapper volume;
|
||||
/// without a mount dependency the service can start before `var-lib-archipelago.mount`,
|
||||
/// write to the bare mountpoint on rootfs, fail every podman call, exit, and be
|
||||
/// restarted every 5s until the volume mounts (~5 min of "[FAILED] Failed to start"
|
||||
/// on cold boots). Fresh ISOs already ship the directive; this heals already-deployed
|
||||
/// nodes. The change is boot-ordering only — it takes effect on the NEXT reboot, so we
|
||||
/// never restart the running service here. Idempotent; no-op if the unit is absent
|
||||
/// (dev runs) or already patched. Harmless when the data dir is on rootfs (systemd maps
|
||||
/// the requirement to the always-mounted root).
|
||||
pub async fn ensure_archipelago_mount_ordering() {
|
||||
let current = match fs::read_to_string(ARCHIPELAGO_SERVICE_PATH).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"mount-ordering self-heal: {} not readable ({}) — skipping",
|
||||
ARCHIPELAGO_SERVICE_PATH,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if current.contains(MOUNT_REQUIRE_LINE) {
|
||||
return; // already healed
|
||||
}
|
||||
// Insert the directive into the [Unit] section, immediately before [Service].
|
||||
let Some(idx) = current.find("\n[Service]") else {
|
||||
tracing::warn!(
|
||||
"mount-ordering self-heal: no [Service] section in {} — skipping",
|
||||
ARCHIPELAGO_SERVICE_PATH
|
||||
);
|
||||
return;
|
||||
};
|
||||
let mut patched = String::with_capacity(current.len() + MOUNT_REQUIRE_LINE.len() + 96);
|
||||
patched.push_str(¤t[..idx]);
|
||||
patched.push_str("\n# B17: start only after the data volume (+ podman graphroot) is mounted\n");
|
||||
patched.push_str(MOUNT_REQUIRE_LINE);
|
||||
patched.push_str(¤t[idx..]);
|
||||
match write_root_if_needed(ARCHIPELAGO_SERVICE_PATH, &patched).await {
|
||||
Ok(true) => {
|
||||
info!(
|
||||
"B17: added '{}' to archipelago.service (effective next reboot)",
|
||||
MOUNT_REQUIRE_LINE
|
||||
);
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
tracing::warn!("B17 self-heal: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!("B17 mount-ordering self-heal failed: {:#}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// #36 self-heal: keep the kiosk unit + launcher current on already-deployed
|
||||
/// nodes so the CPU/memory cap (a runaway chromium was saturating the node and
|
||||
/// starving the backend) and the GPU-vs-headless flag selection arrive via OTA.
|
||||
/// No-op on nodes without the kiosk installed; only restarts the kiosk if it's
|
||||
/// actually running (so it never re-enables an operator-disabled kiosk).
|
||||
pub async fn ensure_kiosk_hardened() {
|
||||
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
|
||||
return; // kiosk not installed on this node
|
||||
}
|
||||
let svc_changed = write_root_if_needed(KIOSK_SERVICE_PATH, KIOSK_SERVICE)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let launcher_changed = write_root_if_needed(KIOSK_LAUNCHER_PATH, KIOSK_LAUNCHER)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if launcher_changed {
|
||||
let _ = host_sudo(&["chmod", "+x", KIOSK_LAUNCHER_PATH]).await;
|
||||
}
|
||||
if svc_changed || launcher_changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
warn!("kiosk hardening: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
// try-restart only restarts a currently-active unit — leaves a stopped/
|
||||
// disabled kiosk alone.
|
||||
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-kiosk.service"]).await;
|
||||
info!("kiosk: applied resource cap + GPU-flag hardening (#36)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
|
||||
/// configs shipped individual per-endpoint `location` blocks, so missing
|
||||
/// endpoints silently fell through to the SPA `index.html` and the frontend
|
||||
@@ -496,7 +631,11 @@ async fn run_nginx() -> Result<bool> {
|
||||
|
||||
let mut changed = false;
|
||||
let mut patched_paths = Vec::<PathBuf>::new();
|
||||
for path in [NGINX_CONF_PATH, NGINX_ENABLED_CONF_PATH] {
|
||||
for path in [
|
||||
NGINX_CONF_PATH,
|
||||
NGINX_ENABLED_CONF_PATH,
|
||||
NGINX_HTTPS_SNIPPET_PATH,
|
||||
] {
|
||||
let candidate = Path::new(path);
|
||||
if !candidate.exists() {
|
||||
debug!("{} missing — skipping nginx bootstrap", path);
|
||||
@@ -514,18 +653,100 @@ async fn run_nginx() -> Result<bool> {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Reflective CORS add_headers that older configs placed inside the
|
||||
/// `/lnd-connect-info` location. The backend now sets a validated
|
||||
/// `Access-Control-Allow-Origin` for that endpoint (api/handler/proxy.rs), so
|
||||
/// leaving these in nginx emits a DUPLICATE header ("contains multiple values
|
||||
/// … but only one is allowed") and the LND wallet UI's cross-origin fetch is
|
||||
/// rejected. Stripped during nginx bootstrap so the backend solely owns CORS.
|
||||
const NGINX_LND_DUP_CORS: &str = " add_header Access-Control-Allow-Origin $http_origin always;\n add_header Access-Control-Allow-Credentials \"true\" always;\n";
|
||||
|
||||
async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
let content = fs::read_to_string(path)
|
||||
.await
|
||||
.with_context(|| format!("read {}", path))?;
|
||||
let missing_app_catalog = !content.contains("location /api/app-catalog");
|
||||
let missing_bitcoin_status = !content.contains("location /bitcoin-status");
|
||||
if !missing_app_catalog && !missing_bitcoin_status {
|
||||
// Each "missing" flag is gated on the splice anchor actually being present,
|
||||
// so an included snippet that legitimately has none of these endpoints (the
|
||||
// HTTPS app-proxy snippet) neither tries to patch them nor logs warn-skips on
|
||||
// every boot — it falls through to the fedimint heal alone.
|
||||
let has_lnd_anchor = content.contains(" location /lnd-connect-info {")
|
||||
|| content.contains(" location /electrs-status {");
|
||||
let missing_app_catalog = content
|
||||
.contains(" # DWN endpoints — peer access over Tor (no auth)")
|
||||
&& !content.contains("location /api/app-catalog");
|
||||
let missing_bitcoin_status = content.contains(" location /electrs-status {")
|
||||
&& !content.contains("location /bitcoin-status");
|
||||
let missing_lnd_proxy = has_lnd_anchor && !content.contains("location /proxy/lnd/");
|
||||
let missing_peer_content = has_lnd_anchor && !content.contains("location /api/peer-content");
|
||||
let has_lnd_dup_cors = content.contains(NGINX_LND_DUP_CORS);
|
||||
// B13: fedimint block present but lacking the asset-rewrite sub_filters.
|
||||
let needs_fedimint_css = content.contains("location /app/fedimint/")
|
||||
&& !content.contains("'href=\"/' 'href=\"/app/fedimint/'");
|
||||
if !missing_app_catalog
|
||||
&& !missing_bitcoin_status
|
||||
&& !missing_lnd_proxy
|
||||
&& !missing_peer_content
|
||||
&& !has_lnd_dup_cors
|
||||
&& !needs_fedimint_css
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut patched = content.clone();
|
||||
|
||||
if has_lnd_dup_cors {
|
||||
// Drop the redundant nginx-side CORS headers so the backend's single
|
||||
// validated Access-Control-Allow-Origin is the only one returned.
|
||||
patched = patched.replace(NGINX_LND_DUP_CORS, "");
|
||||
}
|
||||
|
||||
if needs_fedimint_css {
|
||||
// Style A (main conf): the block already injects nostr-provider, so swap
|
||||
// its single-sub_filter tail for the full asset-rewrite set. No-op if the
|
||||
// node's fedimint block doesn't match OLD.
|
||||
patched = patched.replace(NGINX_FEDIMINT_OLD, NGINX_FEDIMINT_NEW);
|
||||
// Style B (HTTPS app-proxy snippet): the block has no sub_filter to swap,
|
||||
// so insert the reroot set after the unique :8175 proxy_pass. Guarded on
|
||||
// the marker so it can never double-apply after Style A already healed.
|
||||
if !patched.contains("'href=\"/' 'href=\"/app/fedimint/'") {
|
||||
patched = patched.replace(NGINX_FEDIMINT_SNIPPET_ANCHOR, NGINX_FEDIMINT_SNIPPET_INSERT);
|
||||
}
|
||||
}
|
||||
|
||||
if missing_lnd_proxy {
|
||||
// Prefer the `/lnd-connect-info` anchor (present since 2026-03-17); fall
|
||||
// back to `/electrs-status` (since 2026-03-08) for even older configs.
|
||||
// Both appear once per archipelago server block, so the block is added
|
||||
// to every server block that proxies to the backend.
|
||||
let anchor = if patched.contains(" location /lnd-connect-info {") {
|
||||
" location /lnd-connect-info {"
|
||||
} else {
|
||||
" location /electrs-status {"
|
||||
};
|
||||
if !patched.contains(anchor) {
|
||||
warn!("nginx conf missing lnd-connect-info/electrs-status anchor — skipping /proxy/lnd patch");
|
||||
} else {
|
||||
let replacement = format!("{}{}", NGINX_LND_PROXY_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
}
|
||||
}
|
||||
|
||||
if missing_peer_content {
|
||||
// Same anchoring as the LND proxy: prepend the block to every server
|
||||
// block so /api/peer-content/* reaches the backend instead of the SPA.
|
||||
let anchor = if patched.contains(" location /lnd-connect-info {") {
|
||||
" location /lnd-connect-info {"
|
||||
} else {
|
||||
" location /electrs-status {"
|
||||
};
|
||||
if patched.contains(anchor) {
|
||||
let replacement = format!("{}{}", NGINX_PEER_CONTENT_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
} else {
|
||||
warn!("nginx conf missing anchor — skipping /api/peer-content patch");
|
||||
}
|
||||
}
|
||||
|
||||
if missing_bitcoin_status {
|
||||
let anchor = " location /electrs-status {";
|
||||
if !patched.contains(anchor) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Release-root signing ceremony — the publisher-side counterpart to
|
||||
//! `trust::anchor`. Run as a subcommand of the same binary so it reuses the
|
||||
//! exact key derivation (`seed::derive_release_root_ed25519`) and canonical
|
||||
//! signing (`trust::signed_doc::sign_detached`) the fleet verifies against.
|
||||
//!
|
||||
//! Usage (the mnemonic is read from the `RELEASE_MASTER_MNEMONIC` env var or
|
||||
//! stdin — never an argv so it stays out of shell history / `ps`):
|
||||
//!
|
||||
//! ```text
|
||||
//! archipelago ceremony gen
|
||||
//! Generate a fresh 24-word release master mnemonic and print it plus the
|
||||
//! derived release-root pubkey + did. Back the mnemonic up OFFLINE.
|
||||
//!
|
||||
//! RELEASE_MASTER_MNEMONIC="word1 …" archipelago ceremony pubkey
|
||||
//! Print the release-root pubkey hex (for ARCHY_RELEASE_ROOT_PUBKEY /
|
||||
//! trust::anchor::RELEASE_ROOT_PUBKEY_HEX) and the signer did:key.
|
||||
//!
|
||||
//! RELEASE_MASTER_MNEMONIC="word1 …" archipelago ceremony sign <file.json>
|
||||
//! Sign a JSON document (e.g. releases/app-catalog.json) in place: insert
|
||||
//! `signature` + `signed_by` over the canonical form, matching exactly
|
||||
//! what `trust::verify_detached` recomputes on every node.
|
||||
//! ```
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
use crate::seed::{self, MasterSeed};
|
||||
use crate::trust::{did, signed_doc};
|
||||
|
||||
const ENV_MNEMONIC: &str = "RELEASE_MASTER_MNEMONIC";
|
||||
|
||||
/// True if argv selects the ceremony subcommand. Checked before any server init.
|
||||
pub fn is_ceremony_invocation() -> bool {
|
||||
std::env::args().nth(1).as_deref() == Some("ceremony")
|
||||
}
|
||||
|
||||
/// Entry point for `archipelago ceremony …`. Returns Ok(()) on success; the
|
||||
/// caller (main) should exit without starting the server.
|
||||
pub fn run() -> Result<()> {
|
||||
let sub = std::env::args().nth(2).unwrap_or_default();
|
||||
match sub.as_str() {
|
||||
"gen" => cmd_gen(),
|
||||
"pubkey" => cmd_pubkey(),
|
||||
"sign" => {
|
||||
let file = std::env::args()
|
||||
.nth(3)
|
||||
.context("usage: archipelago ceremony sign <file.json>")?;
|
||||
cmd_sign(&file)
|
||||
}
|
||||
other => {
|
||||
bail!(
|
||||
"unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file>",
|
||||
other
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_gen() -> Result<()> {
|
||||
let (mnemonic, seed) = MasterSeed::generate().context("generate mnemonic")?;
|
||||
let key = seed::derive_release_root_ed25519(&seed).context("derive release-root")?;
|
||||
eprintln!("⚠ Back this mnemonic up OFFLINE. It is the ONLY way to re-derive");
|
||||
eprintln!(" the release-root signing key. Anyone with it can sign for the fleet.\n");
|
||||
println!("RELEASE_MASTER_MNEMONIC=\"{}\"", mnemonic);
|
||||
print_key(&key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_pubkey() -> Result<()> {
|
||||
let key = load_release_root_key()?;
|
||||
print_key(&key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_sign(path: &str) -> Result<()> {
|
||||
let key = load_release_root_key()?;
|
||||
|
||||
let body = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
|
||||
let mut value: serde_json::Value =
|
||||
serde_json::from_str(&body).with_context(|| format!("parse {path} as JSON"))?;
|
||||
{
|
||||
let obj = value
|
||||
.as_object_mut()
|
||||
.context("document root must be a JSON object")?;
|
||||
// Re-sign cleanly: drop any prior signature so the preimage matches.
|
||||
obj.remove("signature");
|
||||
obj.remove("signed_by");
|
||||
}
|
||||
|
||||
let (signature, signed_by) =
|
||||
signed_doc::sign_detached(&key, &value).context("sign document")?;
|
||||
|
||||
let obj = value.as_object_mut().expect("checked above");
|
||||
obj.insert("signature".into(), serde_json::Value::String(signature));
|
||||
obj.insert(
|
||||
"signed_by".into(),
|
||||
serde_json::Value::String(signed_by.clone()),
|
||||
);
|
||||
|
||||
let pretty = serde_json::to_string_pretty(&value).context("serialize signed document")?;
|
||||
let tmp = format!("{path}.tmp");
|
||||
std::fs::write(&tmp, format!("{pretty}\n")).with_context(|| format!("write {tmp}"))?;
|
||||
std::fs::rename(&tmp, path).with_context(|| format!("rename {tmp} -> {path}"))?;
|
||||
|
||||
eprintln!("✓ signed {path}");
|
||||
eprintln!(" signed_by: {signed_by}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Derive the release-root signing key from the mnemonic in env/stdin.
|
||||
fn load_release_root_key() -> Result<SigningKey> {
|
||||
let phrase = read_mnemonic()?;
|
||||
let (_mnemonic, seed) = MasterSeed::from_mnemonic_words(phrase.trim())
|
||||
.context("invalid release master mnemonic")?;
|
||||
seed::derive_release_root_ed25519(&seed).context("derive release-root")
|
||||
}
|
||||
|
||||
/// Read the mnemonic from `RELEASE_MASTER_MNEMONIC` or, if unset, stdin.
|
||||
fn read_mnemonic() -> Result<String> {
|
||||
if let Ok(v) = std::env::var(ENV_MNEMONIC) {
|
||||
if !v.trim().is_empty() {
|
||||
return Ok(v);
|
||||
}
|
||||
}
|
||||
use std::io::Read;
|
||||
eprintln!("Paste the release master mnemonic, then Ctrl-D:");
|
||||
let mut buf = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut buf)
|
||||
.context("read mnemonic from stdin")?;
|
||||
if buf.trim().is_empty() {
|
||||
bail!("no mnemonic provided (set {ENV_MNEMONIC} or pipe it on stdin)");
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn print_key(key: &SigningKey) {
|
||||
let vk = key.verifying_key();
|
||||
println!("RELEASE_ROOT_PUBKEY_HEX={}", hex::encode(vk.to_bytes()));
|
||||
println!("signed_by_did={}", did::did_key_for_ed25519(&vk));
|
||||
}
|
||||
@@ -70,6 +70,13 @@ pub struct Config {
|
||||
/// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`.
|
||||
#[serde(default)]
|
||||
pub use_quadlet_backends: bool,
|
||||
/// DHT swarm-assist (Phase 3): when true AND the binary was built with the
|
||||
/// `iroh-swarm` feature, stand up an iroh-blobs provider that fetches release
|
||||
/// blobs peer-to-peer (origin always wins) and seeds them via signed Nostr
|
||||
/// adverts. Off by default; with the feature absent this is inert. Reuses
|
||||
/// `nostr_relays` + `nostr_tor_proxy` for discovery transport.
|
||||
#[serde(default)]
|
||||
pub swarm_enabled: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -182,6 +189,12 @@ impl Config {
|
||||
config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) };
|
||||
}
|
||||
|
||||
// DHT swarm-assist (Phase 3). Opt-in: only takes effect when the binary
|
||||
// was also built with the `iroh-swarm` feature; otherwise inert.
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_SWARM_ENABLED") {
|
||||
config.swarm_enabled = parse_truthy_env(&v);
|
||||
}
|
||||
|
||||
// Phase 3.2 of v1.7.52. Truthy values (1, true, yes, on — case-insensitive)
|
||||
// route backend installs through the Quadlet path without requiring a
|
||||
// config.json edit + archipelago.service restart (which would trigger
|
||||
@@ -241,6 +254,7 @@ impl Default for Config {
|
||||
],
|
||||
nostr_tor_proxy: Some("127.0.0.1:9050".into()),
|
||||
use_quadlet_backends: false,
|
||||
swarm_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Remote app version catalog — DECOUPLES per-app updates from the binary OTA.
|
||||
//!
|
||||
//! Background: `image_versions.rs` reads the pinned image tags from
|
||||
//! `image-versions.sh`, which is deployed *with the archipelago binary*. That
|
||||
//! coupled every app update to a full node release. This module adds a remote
|
||||
//! catalog (`app-catalog.json`) fetched over HTTP from the same origin as the
|
||||
//! OTA manifest, refreshed periodically and on demand. Bumping an app's version
|
||||
//! is then a JSON edit + push — no binary release.
|
||||
//!
|
||||
//! Resolution order (origin-always-wins, matching the DHT design's posture):
|
||||
//! 1. Remote catalog (this module) — the live source of "available update".
|
||||
//! 2. `image-versions.sh` pin — offline/baseline fallback when the catalog is
|
||||
//! missing or doesn't cover the app.
|
||||
//!
|
||||
//! ## Forward-compatibility with the DHT distribution plan
|
||||
//! (`docs/dht-distribution-design.md`)
|
||||
//! This catalog IS the "discovery / authenticity" layer of that plan. The schema
|
||||
//! is deliberately extensible so the later phases bolt on WITHOUT a breaking
|
||||
//! change:
|
||||
//! - `signature` / `signed_by` (top level) — Phase 0 seed-derived release-root
|
||||
//! signature over the canonical JSON. Absent today; verified when present.
|
||||
//! - per-image `digest` / `size` — BLAKE3/SHA-256 content address + length, so
|
||||
//! the iroh swarm can fetch images by hash with the registry as origin.
|
||||
//! Unknown fields are ignored (no `deny_unknown_fields`), so adding fields on the
|
||||
//! publisher side never breaks older nodes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::SystemTime;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Filename for both the published catalog and the on-node cache.
|
||||
pub const APP_CATALOG_FILE: &str = "app-catalog.json";
|
||||
|
||||
/// Cache of the parsed catalog, invalidated when the cache file mtime changes.
|
||||
static CACHE: Mutex<Option<CacheEntry>> = Mutex::new(None);
|
||||
|
||||
struct CacheEntry {
|
||||
mtime: SystemTime,
|
||||
catalog: AppCatalog,
|
||||
}
|
||||
|
||||
/// Top-level catalog document.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AppCatalog {
|
||||
/// Schema version. 1 = current. Bump only on incompatible changes.
|
||||
#[serde(default)]
|
||||
pub schema: u32,
|
||||
/// Publish date (RFC 3339 or YYYY-MM-DD). Informational.
|
||||
#[serde(default)]
|
||||
pub updated: String,
|
||||
/// app_id -> entry.
|
||||
#[serde(default)]
|
||||
pub apps: HashMap<String, AppCatalogEntry>,
|
||||
/// DHT-plan forward-compat: detached signature over the canonical JSON,
|
||||
/// produced by the seed-derived release-root key. Absent today.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
/// DHT-plan forward-compat: publisher identity (did:key / npub).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signed_by: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-app catalog entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AppCatalogEntry {
|
||||
/// User-facing version string (drives the "Update available" badge text).
|
||||
pub version: String,
|
||||
/// Primary single-container image reference (`registry/repo:tag`). For stack
|
||||
/// apps this is the primary container's image (the one whose version the
|
||||
/// badge tracks — e.g. the IndeeHub frontend).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
/// Stack apps only: container_name -> image reference. Components omitted here
|
||||
/// fall back to the `image-versions.sh` pin during an update.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub images: Option<HashMap<String, String>>,
|
||||
/// DHT-plan forward-compat: content address of the primary image (unused now).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub digest: Option<String>,
|
||||
/// DHT-plan forward-compat: size in bytes of the primary image (unused now).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
/// Optional human-readable changelog lines for this version.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub changelog: Vec<String>,
|
||||
}
|
||||
|
||||
/// Read-side cache file search order. Mirrors `image_versions.rs`: the running
|
||||
/// daemon's data dir first (via env for dev), then the canonical runtime path.
|
||||
fn cache_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
if let Ok(dir) = std::env::var("ARCHIPELAGO_DATA_DIR") {
|
||||
paths.push(Path::new(&dir).join(APP_CATALOG_FILE));
|
||||
}
|
||||
paths.push(Path::new("/var/lib/archipelago").join(APP_CATALOG_FILE));
|
||||
paths
|
||||
}
|
||||
|
||||
fn find_cache_file() -> Option<(PathBuf, SystemTime)> {
|
||||
for p in cache_paths() {
|
||||
if let Ok(meta) = p.metadata() {
|
||||
if let Ok(mtime) = meta.modified() {
|
||||
return Some((p, mtime));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Load and cache the on-node catalog. Returns an empty catalog when absent —
|
||||
/// callers then fall back to `image-versions.sh`.
|
||||
fn load_catalog() -> AppCatalog {
|
||||
let (path, mtime) = match find_cache_file() {
|
||||
Some(v) => v,
|
||||
None => return AppCatalog::default(),
|
||||
};
|
||||
|
||||
{
|
||||
let cache = CACHE.lock().unwrap();
|
||||
if let Some(ref entry) = *cache {
|
||||
if entry.mtime == mtime {
|
||||
return entry.catalog.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
debug!("app-catalog: failed to read {}: {}", path.display(), e);
|
||||
return AppCatalog::default();
|
||||
}
|
||||
};
|
||||
let catalog: AppCatalog = match serde_json::from_str(&content) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("app-catalog: invalid JSON at {}: {}", path.display(), e);
|
||||
return AppCatalog::default();
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut cache = CACHE.lock().unwrap();
|
||||
*cache = Some(CacheEntry {
|
||||
mtime,
|
||||
catalog: catalog.clone(),
|
||||
});
|
||||
}
|
||||
catalog
|
||||
}
|
||||
|
||||
fn entry_for(app_id: &str) -> Option<AppCatalogEntry> {
|
||||
load_catalog().apps.get(app_id).cloned()
|
||||
}
|
||||
|
||||
/// Primary image for an app per the remote catalog, if covered.
|
||||
pub fn catalog_primary_image(app_id: &str) -> Option<String> {
|
||||
entry_for(app_id).and_then(|e| e.image)
|
||||
}
|
||||
|
||||
/// Per-container stack image overrides from the catalog (container_name -> image).
|
||||
pub fn catalog_stack_images(app_id: &str) -> HashMap<String, String> {
|
||||
entry_for(app_id).and_then(|e| e.images).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Image override for the orchestrator's install/upgrade path. Returns the
|
||||
/// catalog's primary image for `app_id` ONLY when it refers to the same
|
||||
/// repository as the manifest's current image — a guard so a catalog typo can
|
||||
/// never redirect an app to an unrelated image. `None` means "use the manifest
|
||||
/// image as-is" (catalog absent, app uncovered, or repo mismatch).
|
||||
pub fn catalog_image_override(app_id: &str, manifest_image: &str) -> Option<String> {
|
||||
let candidate = catalog_primary_image(app_id)?;
|
||||
let same_repo = crate::container::image_versions::image_without_registry_or_tag(&candidate)
|
||||
== crate::container::image_versions::image_without_registry_or_tag(manifest_image);
|
||||
if same_repo {
|
||||
Some(candidate)
|
||||
} else {
|
||||
warn!(
|
||||
"app-catalog: ignoring image for {} — repo mismatch (catalog={}, manifest={})",
|
||||
app_id, candidate, manifest_image
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoupled "available update" check for ALL apps.
|
||||
///
|
||||
/// Prefers the remote catalog; when the catalog covers the app, its verdict is
|
||||
/// authoritative (so we never advertise a stale `image-versions.sh` pin over a
|
||||
/// newer catalog, nor vice-versa). Falls back to the deployed pin only when the
|
||||
/// catalog is missing or doesn't cover the app.
|
||||
pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<String> {
|
||||
if let Some(catalog_image) = catalog_primary_image(app_id) {
|
||||
// Catalog covers this app with a concrete image -> authoritative.
|
||||
return crate::container::image_versions::available_update_for_images(
|
||||
&catalog_image,
|
||||
running_image,
|
||||
);
|
||||
}
|
||||
// Not covered by the catalog -> baseline pin from image-versions.sh.
|
||||
crate::container::image_versions::available_update_for_app(app_id, running_image)
|
||||
}
|
||||
|
||||
/// Derive candidate catalog URLs from the OTA mirror list by swapping the
|
||||
/// manifest filename for the catalog filename. Falls back to the default
|
||||
/// manifest origin when no mirrors are configured.
|
||||
fn catalog_urls_from_mirrors(mirrors: &[crate::update::UpdateMirror]) -> Vec<String> {
|
||||
let mut urls: Vec<String> = mirrors
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
// mirror.url ends with ".../releases/manifest.json"
|
||||
if m.url.ends_with("manifest.json") {
|
||||
Some(m.url.replace("manifest.json", APP_CATALOG_FILE))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
urls.dedup();
|
||||
urls
|
||||
}
|
||||
|
||||
/// Fetch the catalog from the first reachable mirror and atomically write it to
|
||||
/// `<data_dir>/app-catalog.json`. Returns the number of apps in the catalog on
|
||||
/// success. Best-effort: a fetch failure leaves the existing cache untouched
|
||||
/// (origin-always-wins; updates simply aren't refreshed this cycle).
|
||||
pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<usize> {
|
||||
let mirrors = crate::update::load_mirrors(data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let urls = catalog_urls_from_mirrors(&mirrors);
|
||||
if urls.is_empty() {
|
||||
debug!("app-catalog: no mirror-derived URLs to fetch from");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.build()?;
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for url in &urls {
|
||||
match fetch_one(&client, url).await {
|
||||
Ok(catalog) => {
|
||||
let count = catalog.apps.len();
|
||||
write_cache(data_dir, &catalog)?;
|
||||
// Invalidate the in-process cache so the next read re-parses.
|
||||
*CACHE.lock().unwrap() = None;
|
||||
info!("app-catalog: refreshed from {} ({} apps)", url, count);
|
||||
return Ok(count);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("app-catalog: fetch {} failed: {}", url, e);
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable")))
|
||||
}
|
||||
|
||||
async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<AppCatalog> {
|
||||
let resp = client.get(url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("HTTP {}", resp.status());
|
||||
}
|
||||
let body = resp.text().await?;
|
||||
let catalog: AppCatalog = serde_json::from_str(&body)?;
|
||||
|
||||
// DHT Phase 0 authenticity: verify the release-root signature when present.
|
||||
// We verify against the raw JSON (the exact bytes the publisher signed),
|
||||
// not a re-serialization of the typed struct, so unknown forward-compat
|
||||
// fields stay part of the signed preimage. Unsigned catalogs are still
|
||||
// accepted during the migration window — same trust level as today's
|
||||
// manifest — but a *present* signature that fails is a hard reject so a
|
||||
// tampering mirror cannot pass off altered bytes.
|
||||
let raw: serde_json::Value = serde_json::from_str(&body)?;
|
||||
match crate::trust::verify_detached(&raw)? {
|
||||
crate::trust::SignatureStatus::Unsigned => {
|
||||
debug!("app-catalog: unsigned (accepted during migration window)");
|
||||
}
|
||||
crate::trust::SignatureStatus::Verified {
|
||||
signer_did,
|
||||
anchored,
|
||||
} => {
|
||||
if anchored {
|
||||
info!(
|
||||
"app-catalog: release-root signature verified ({})",
|
||||
signer_did
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"app-catalog: signature self-consistent but release-root anchor \
|
||||
not pinned ({}); cannot confirm signer identity",
|
||||
signer_did
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
fn write_cache(data_dir: &Path, catalog: &AppCatalog) -> anyhow::Result<()> {
|
||||
let dest = data_dir.join(APP_CATALOG_FILE);
|
||||
let tmp = data_dir.join(format!("{}.tmp", APP_CATALOG_FILE));
|
||||
let json = serde_json::to_string_pretty(catalog)?;
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_and_ignores_unknown_fields() {
|
||||
let json = r#"{
|
||||
"schema": 1,
|
||||
"updated": "2026-06-16",
|
||||
"future_field": "ignored",
|
||||
"signature": "sig123",
|
||||
"signed_by": "did:key:zABC",
|
||||
"apps": {
|
||||
"indeedhub": {
|
||||
"version": "1.0.1",
|
||||
"image": "146.59.87.168:3000/lfg2025/indeedhub:1.0.1",
|
||||
"digest": "blake3:deadbeef",
|
||||
"size": 12345,
|
||||
"another_future_field": true
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let cat: AppCatalog = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(cat.schema, 1);
|
||||
assert_eq!(cat.signature.as_deref(), Some("sig123"));
|
||||
let e = cat.apps.get("indeedhub").unwrap();
|
||||
assert_eq!(e.version, "1.0.1");
|
||||
assert_eq!(
|
||||
e.image.as_deref(),
|
||||
Some("146.59.87.168:3000/lfg2025/indeedhub:1.0.1")
|
||||
);
|
||||
assert_eq!(e.digest.as_deref(), Some("blake3:deadbeef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_catalog_when_absent_is_default() {
|
||||
let cat = AppCatalog::default();
|
||||
assert!(cat.apps.is_empty());
|
||||
assert!(cat.signature.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_url_derived_from_mirror() {
|
||||
let mirrors = vec![crate::update::UpdateMirror {
|
||||
url: "http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||
.to_string(),
|
||||
label: "Server 1".to_string(),
|
||||
}];
|
||||
let urls = catalog_urls_from_mirrors(&mirrors);
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec![
|
||||
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json"
|
||||
.to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -172,8 +172,10 @@ impl DockerPackageScanner {
|
||||
// Extract actual version from container image tag
|
||||
let running_version = image_versions::extract_version_from_image(&container.image);
|
||||
|
||||
// Decoupled from the binary OTA: prefer the remote app catalog,
|
||||
// falling back to the image-versions.sh pin when uncovered/offline.
|
||||
let available_update =
|
||||
image_versions::available_update_for_app(&app_id, &container.image);
|
||||
crate::container::app_catalog::available_update_for_app(&app_id, &container.image);
|
||||
|
||||
let package = PackageDataEntry {
|
||||
state: package_state.clone(),
|
||||
@@ -699,7 +701,7 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
|
||||
if !requires_reachable_launch(app_id) {
|
||||
return Some(url);
|
||||
}
|
||||
let Some(port) = url.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()) else {
|
||||
let Some(port) = launch_url_port(&url) else {
|
||||
return None;
|
||||
};
|
||||
if launch_port_reachable(port).await {
|
||||
@@ -710,6 +712,23 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the TCP port from a launch URL's authority.
|
||||
///
|
||||
/// The candidate URL can carry a path when it comes from a manifest
|
||||
/// `interfaces.main` declaration (e.g. `http://localhost:8096/`). A naive
|
||||
/// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently
|
||||
/// drops a reachable launch URL. Reading digits after the final colon mirrors
|
||||
/// `port_from_url` in the RPC layer and tolerates a trailing path.
|
||||
fn launch_url_port(url: &str) -> Option<u16> {
|
||||
let after_colon = url.rsplit_once(':')?.1;
|
||||
after_colon
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
}
|
||||
|
||||
async fn launch_port_reachable(port: u16) -> bool {
|
||||
matches!(
|
||||
tokio::time::timeout(
|
||||
@@ -788,3 +807,26 @@ fn package_state_str(state: &PackageState) -> &str {
|
||||
PackageState::Updating => "updating",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod launch_url_port_tests {
|
||||
use super::launch_url_port;
|
||||
|
||||
#[test]
|
||||
fn parses_port_with_trailing_path() {
|
||||
// Regression: manifest interfaces.main yields a path-suffixed URL.
|
||||
// The old rsplit(':') parse produced "8096/" and dropped the URL.
|
||||
assert_eq!(launch_url_port("http://localhost:8096/"), Some(8096));
|
||||
assert_eq!(launch_url_port("http://localhost:8175/admin"), Some(8175));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bare_authority_port() {
|
||||
assert_eq!(launch_url_port("http://localhost:8083"), Some(8083));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_url_without_port() {
|
||||
assert_eq!(launch_url_port("http://localhost/"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
|
||||
available_update_for_images(&pinned, running_image)
|
||||
}
|
||||
|
||||
fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
let pinned_version = extract_version_from_image(&pinned);
|
||||
if is_floating_tag(&pinned_version) {
|
||||
return None;
|
||||
@@ -255,7 +255,7 @@ fn is_floating_tag(tag: &str) -> bool {
|
||||
matches!(tag, "latest" | "stable" | "release" | "main")
|
||||
}
|
||||
|
||||
fn image_without_registry_or_tag(image: &str) -> &str {
|
||||
pub fn image_without_registry_or_tag(image: &str) -> &str {
|
||||
let without_tag = strip_tag(image);
|
||||
match without_tag.split_once('/') {
|
||||
Some((first, rest))
|
||||
|
||||
@@ -11,7 +11,16 @@ use crate::update::host_sudo;
|
||||
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
|
||||
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf";
|
||||
const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
|
||||
pub const WALLET_PASSWORD: &str = "hellohello";
|
||||
|
||||
/// Per-node LND wallet password file (random, 0600). Replaces the old
|
||||
/// fleet-wide hardcoded constant: each node's wallet password is now unique,
|
||||
/// high-entropy, and recorded here so the unattended boot path can auto-unlock.
|
||||
const WALLET_PASSWORD_SECRET: &str = "/var/lib/archipelago/secrets/lnd-wallet-password";
|
||||
|
||||
/// Legacy fleet-wide wallet password (builds that hardcoded it). Kept ONLY as an
|
||||
/// unlock fallback so wallets created by those builds still open; new wallets
|
||||
/// never use it, and the login-path migration rotates away from it.
|
||||
const LEGACY_WALLET_PASSWORD: &str = "hellohello";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsurePaths {
|
||||
@@ -79,15 +88,125 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
match unlock_existing_wallet().await? {
|
||||
true => {
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
false => {
|
||||
// Every candidate password was actively rejected: this wallet was
|
||||
// created with a password this node no longer has, so it can never
|
||||
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
|
||||
// locked with an unknown password is already inaccessible, so wipe +
|
||||
// recreate it on the per-node secret to self-heal at boot.
|
||||
recreate_wallet_destructively().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
|
||||
/// LND data subdirectories holding wallet + channel + graph state. Removing them
|
||||
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
|
||||
/// so deletion is destructive — only done once the wallet is already unrecoverable.
|
||||
const LND_STATE_DIRS: &[&str] = &[
|
||||
"/var/lib/archipelago/lnd/data/chain",
|
||||
"/var/lib/archipelago/lnd/data/graph",
|
||||
];
|
||||
|
||||
/// Podman container name for the core LND app (see `compute_container_name`:
|
||||
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
|
||||
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
|
||||
const LND_CONTAINER: &str = "lnd";
|
||||
|
||||
/// Archipelago data dir (default; not overridden in prod). Holds the
|
||||
/// `user-stopped.json` that gates health-monitor auto-restart.
|
||||
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
||||
|
||||
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
|
||||
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
|
||||
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
|
||||
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
|
||||
/// candidate password can open the existing wallet.
|
||||
async fn recreate_wallet_destructively() -> Result<()> {
|
||||
tracing::warn!(
|
||||
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
|
||||
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
|
||||
);
|
||||
|
||||
// The health monitor restarts any container it sees stopped; mark LND
|
||||
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
|
||||
// Always cleared below so LND auto-recovers normally afterwards.
|
||||
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
|
||||
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
let result = wipe_and_reinit_wallet().await;
|
||||
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn wipe_and_reinit_wallet() -> Result<()> {
|
||||
podman_user_scoped(&["stop", LND_CONTAINER])
|
||||
.await
|
||||
.context("stopping lnd before wallet wipe")?;
|
||||
|
||||
for dir in LND_STATE_DIRS {
|
||||
let status = host_sudo(&["rm", "-rf", dir])
|
||||
.await
|
||||
.with_context(|| format!("removing {dir}"))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("removing {dir} exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
podman_user_scoped(&["start", LND_CONTAINER])
|
||||
.await
|
||||
.context("restarting lnd after wallet wipe")?;
|
||||
|
||||
wait_for_wallet_state("NON_EXISTING").await?;
|
||||
init_wallet_via_rest().await
|
||||
}
|
||||
|
||||
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
|
||||
/// how the orchestrator/health-monitor manage rootless containers (keeps the
|
||||
/// container out of the archipelago service's cgroup).
|
||||
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
|
||||
let out = tokio::process::Command::new("systemd-run")
|
||||
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
|
||||
async fn wait_for_wallet_state(target: &str) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
for _ in 0..120 {
|
||||
if wallet_state(&client).await.as_deref() == Some(target) {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND did not reach state {target} after wallet wipe")
|
||||
}
|
||||
|
||||
async fn file_exists_as_root(path: &str) -> bool {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return true;
|
||||
@@ -121,11 +240,96 @@ async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn unlock_existing_wallet() -> Result<()> {
|
||||
/// Read the per-node wallet password from the secrets file, if present.
|
||||
/// Never generates one — absence means "fall back to legacy / not set yet".
|
||||
async fn read_wallet_password() -> Option<String> {
|
||||
let bytes = fs::read(WALLET_PASSWORD_SECRET).await.ok()?;
|
||||
let pw = String::from_utf8_lossy(&bytes).trim().to_string();
|
||||
(!pw.is_empty()).then_some(pw)
|
||||
}
|
||||
|
||||
/// Return the per-node wallet password, generating and persisting a fresh
|
||||
/// 256-bit one (base64, 0600) if none exists. Use ONLY when creating a NEW
|
||||
/// wallet — calling it merely to unlock an existing wallet would record a
|
||||
/// password that doesn't match it.
|
||||
pub(crate) async fn ensure_wallet_password() -> Result<String> {
|
||||
if let Some(pw) = read_wallet_password().await {
|
||||
return Ok(pw);
|
||||
}
|
||||
use rand::RngCore;
|
||||
let mut raw = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut raw);
|
||||
let pw = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
|
||||
let path = std::path::Path::new(WALLET_PASSWORD_SECRET);
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
fs::write(path, &pw)
|
||||
.await
|
||||
.with_context(|| format!("writing {WALLET_PASSWORD_SECRET}"))?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await;
|
||||
Ok(pw)
|
||||
}
|
||||
|
||||
/// Candidate passwords to try when unlocking an EXISTING wallet, in order: the
|
||||
/// per-node secret (current scheme) first, then the legacy constant so wallets
|
||||
/// created by older builds still open.
|
||||
async fn unlock_password_candidates() -> Vec<String> {
|
||||
let mut v = Vec::new();
|
||||
if let Some(pw) = read_wallet_password().await {
|
||||
v.push(pw);
|
||||
}
|
||||
v.push(LEGACY_WALLET_PASSWORD.to_string());
|
||||
v
|
||||
}
|
||||
|
||||
/// Outcome of a single unlock attempt — lets the caller fail fast on a wrong
|
||||
/// password (no point retrying) vs keep waiting for LND to come up.
|
||||
enum UnlockAttempt {
|
||||
Unlocked,
|
||||
WrongPassword,
|
||||
NotReady,
|
||||
}
|
||||
|
||||
/// One unlock POST, no internal retry. Distinguishes "invalid passphrase"
|
||||
/// (WrongPassword — try the next candidate, don't retry) from transient
|
||||
/// not-ready / connection errors (NotReady — worth retrying).
|
||||
async fn try_unlock_once(client: &reqwest::Client, password: &str) -> UnlockAttempt {
|
||||
let body = serde_json::json!({
|
||||
"wallet_password": base64::engine::general_purpose::STANDARD.encode(password)
|
||||
});
|
||||
match client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/unlockwallet"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() || text.contains("already unlocked") {
|
||||
UnlockAttempt::Unlocked
|
||||
} else if text.contains("invalid passphrase") {
|
||||
UnlockAttempt::WrongPassword
|
||||
} else {
|
||||
UnlockAttempt::NotReady
|
||||
}
|
||||
}
|
||||
Err(_) => UnlockAttempt::NotReady,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlock an existing wallet. Ok(true) = unlocked; Ok(false) = every candidate
|
||||
/// password was actively rejected (unrecoverable — caller should recreate);
|
||||
/// Err = transient (LND not ready / timeout — caller should retry, NOT wipe).
|
||||
async fn unlock_existing_wallet() -> Result<bool> {
|
||||
unlock_existing_wallet_via_rest().await
|
||||
}
|
||||
|
||||
async fn unlock_existing_wallet_via_rest() -> Result<()> {
|
||||
async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
@@ -133,57 +337,130 @@ async fn unlock_existing_wallet_via_rest() -> Result<()> {
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
|
||||
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
|
||||
match post_lnd_unlocker_json::<serde_json::Value>(
|
||||
&client,
|
||||
"/v1/unlockwallet",
|
||||
serde_json::json!({ "wallet_password": wallet_password }),
|
||||
)
|
||||
.await
|
||||
.context("unlocking existing LND wallet")?
|
||||
{
|
||||
UnlockerResponse::Value(_) | UnlockerResponse::WalletAlreadyExists => Ok(()),
|
||||
let candidates = unlock_password_candidates().await;
|
||||
// Retry only while LND's unlocker isn't ready yet. If every candidate is
|
||||
// *actively rejected* (invalid passphrase), retrying can't help — fail fast
|
||||
// with a clear message instead of hanging the boot path for 60s+ (the wallet
|
||||
// was created with a password this node doesn't have → migration/recovery).
|
||||
for _ in 0..60 {
|
||||
let mut all_rejected = true;
|
||||
for pw in &candidates {
|
||||
match try_unlock_once(&client, pw).await {
|
||||
UnlockAttempt::Unlocked => return Ok(true),
|
||||
UnlockAttempt::WrongPassword => {}
|
||||
UnlockAttempt::NotReady => all_rejected = false,
|
||||
}
|
||||
}
|
||||
if all_rejected {
|
||||
tracing::warn!(
|
||||
"[lnd] none of the {} candidate password(s) unlock the wallet — it was created \
|
||||
with a password this node does not have",
|
||||
candidates.len()
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND wallet unlock timed out waiting for the unlocker to become ready")
|
||||
}
|
||||
|
||||
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
||||
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
||||
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/state"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let v: serde_json::Value = resp.json().await.ok()?;
|
||||
v.get("state")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// ChangePassword via WalletUnlocker (wallet must be LOCKED). Both passwords are
|
||||
/// base64-encoded. Ok(true) = current accepted and rotated; Ok(false) = current
|
||||
/// rejected (wrong password — try the next candidate); Err = transport/other.
|
||||
async fn change_wallet_password(
|
||||
client: &reqwest::Client,
|
||||
current: &str,
|
||||
new: &str,
|
||||
) -> Result<bool> {
|
||||
let body = serde_json::json!({
|
||||
"current_password": base64::engine::general_purpose::STANDARD.encode(current),
|
||||
"new_password": base64::engine::general_purpose::STANDARD.encode(new),
|
||||
});
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/changepassword"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("calling LND changepassword")?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() {
|
||||
Ok(true)
|
||||
} else if text.contains("invalid passphrase") {
|
||||
Ok(false)
|
||||
} else {
|
||||
anyhow::bail!("LND changepassword returned {status}: {text}")
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn unlock_existing_wallet_via_lncli() -> Result<()> {
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["exec", "-i", "lnd", "lncli", "unlock", "--stdin"]);
|
||||
cmd.stdin(std::process::Stdio::piped());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
/// Best-effort migration of a LOCKED wallet onto the per-node secret. Called at
|
||||
/// login, when the onboarding password is available as a candidate. If the
|
||||
/// per-node secret already opens the wallet, just unlock. Otherwise try each
|
||||
/// candidate as the CURRENT password and ChangePassword it to a fresh per-node
|
||||
/// secret so all future boots auto-unlock. Ok(true) = healed/unlocked;
|
||||
/// Ok(false) = not locked, or no candidate worked (seed-recovery required).
|
||||
pub(crate) async fn migrate_locked_wallet(candidates: &[String]) -> Result<bool> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
|
||||
let mut child = cmd.spawn().context("spawning lncli wallet unlock")?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
stdin
|
||||
.write_all(format!("{}\n", WALLET_PASSWORD).as_bytes())
|
||||
.await
|
||||
.context("writing lncli password")?;
|
||||
}
|
||||
let out = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.context("waiting for lncli")?;
|
||||
if out.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let msg = format!("{stderr}{stdout}");
|
||||
if msg.contains("wallet already unlocked") || msg.contains("already unlocked") {
|
||||
return Ok(());
|
||||
}
|
||||
last_err = Some(msg);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
// Only act on a wallet that is actually LOCKED.
|
||||
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
|
||||
return Ok(false);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"lncli wallet unlock failed: {}",
|
||||
last_err.unwrap_or_else(|| "unknown error".to_string())
|
||||
)
|
||||
|
||||
// If the per-node secret already opens it, nothing to rotate — just unlock.
|
||||
if let Some(secret) = read_wallet_password().await {
|
||||
if matches!(
|
||||
try_unlock_once(&client, &secret).await,
|
||||
UnlockAttempt::Unlocked
|
||||
) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
// The wallet's new password becomes the per-node secret (generate if absent).
|
||||
let new_secret = ensure_wallet_password().await?;
|
||||
|
||||
// ChangePassword requires LOCKED; bail out if a prior step already unlocked.
|
||||
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
for cand in candidates {
|
||||
if cand.is_empty() || *cand == new_secret {
|
||||
continue;
|
||||
}
|
||||
match change_wallet_password(&client, cand, &new_secret).await {
|
||||
Ok(true) => {
|
||||
tracing::info!("[lnd-migrate] rotated locked wallet onto the per-node secret");
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false) => continue, // wrong current password — try next candidate
|
||||
Err(e) => tracing::debug!("[lnd-migrate] changepassword error: {e}"),
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
"[lnd-migrate] no candidate password opened the wallet — seed-recovery required"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -225,7 +502,8 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
anyhow::bail!("LND genseed returned no seed words");
|
||||
}
|
||||
|
||||
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
|
||||
let wallet_password =
|
||||
base64::engine::general_purpose::STANDARD.encode(ensure_wallet_password().await?);
|
||||
let req = InitWalletRequest {
|
||||
wallet_password,
|
||||
cipher_seed_mnemonic: seed.cipher_seed_mnemonic,
|
||||
@@ -239,7 +517,9 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
.context("initializing LND wallet")?
|
||||
{
|
||||
UnlockerResponse::Value(_) => {}
|
||||
UnlockerResponse::WalletAlreadyExists => unlock_existing_wallet().await?,
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -450,7 +730,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_password_is_valid_for_lncli() {
|
||||
assert!(WALLET_PASSWORD.len() > 8);
|
||||
fn legacy_wallet_password_is_valid_for_lncli() {
|
||||
// Legacy fallback must still be a valid lncli passphrase (>8 chars).
|
||||
assert!(LEGACY_WALLET_PASSWORD.len() > 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlock_candidates_always_include_legacy_fallback() {
|
||||
// With no per-node secret on disk in the test env, candidates fall back
|
||||
// to the legacy constant so old wallets still open.
|
||||
let cands = unlock_password_candidates().await;
|
||||
assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod app_catalog;
|
||||
pub mod bitcoin_ui;
|
||||
pub mod boot_reconciler;
|
||||
pub mod companion;
|
||||
|
||||
@@ -176,6 +176,70 @@ pub fn compute_container_name(manifest: &AppManifest) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fingerprint a local build context so a changed source tree (e.g. a rebuilt
|
||||
/// `neode-ui` dist copied into `docker/<ui>/`) forces an image rebuild even
|
||||
/// when the image tag already exists (#34). Walks the context directory and
|
||||
/// hashes each file's relative path, length, and mtime.
|
||||
///
|
||||
/// Metadata-only by design: it's cheap enough to recompute on every reconcile,
|
||||
/// and podman's own COPY-layer cache still skips the actual layer work when the
|
||||
/// file *content* is unchanged, so a spurious mtime bump costs almost nothing.
|
||||
/// Returns `None` if the context can't be read (caller falls back to building).
|
||||
fn fingerprint_build_context(context: &Path) -> Option<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut entries: Vec<(String, u64, i128)> = Vec::new();
|
||||
let mut stack = vec![context.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let rd = std::fs::read_dir(&dir).ok()?;
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
let rel = path
|
||||
.strip_prefix(context)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let mtime = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_nanos() as i128)
|
||||
.unwrap_or(0);
|
||||
entries.push((rel, meta.len(), mtime));
|
||||
}
|
||||
}
|
||||
// Sort so the hash is independent of directory-walk order.
|
||||
entries.sort();
|
||||
let mut hasher = Sha256::new();
|
||||
for (rel, len, mtime) in &entries {
|
||||
hasher.update(rel.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(len.to_le_bytes());
|
||||
hasher.update(mtime.to_le_bytes());
|
||||
}
|
||||
Some(hex::encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
/// Path of the stamp file recording the build-context fingerprint that produced
|
||||
/// the currently-built image for `tag`. Keyed by a filesystem-safe form of the
|
||||
/// tag so distinct UI images don't collide.
|
||||
fn build_fingerprint_stamp_path(data_dir: &Path, tag: &str) -> PathBuf {
|
||||
let safe: String = tag
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
||||
.collect();
|
||||
data_dir
|
||||
.join(".image-build")
|
||||
.join(format!("{safe}.fingerprint"))
|
||||
}
|
||||
|
||||
async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||
let uid = uid_gid
|
||||
.split_once(':')
|
||||
@@ -219,6 +283,120 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||
))
|
||||
}
|
||||
|
||||
/// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING
|
||||
/// container.
|
||||
///
|
||||
/// For each writable bind mount, write-probe as the container's own process
|
||||
/// user; if it can't write, `chown -R` from INSIDE the container (`podman exec`
|
||||
/// as root) to that service uid:gid. Because the chown runs in the container's
|
||||
/// user namespace, podman translates it to the correct host owner regardless of
|
||||
/// the rootless idmap — so there is NO host-side UID guessing, and it works for
|
||||
/// compose stacks (no manifest / `data_uid` needed) exactly as for registry apps.
|
||||
/// This is the durable replacement for the per-app hardcoded host chowns.
|
||||
///
|
||||
/// Drift-checked via the write-probe, so it only `chown`s when the volume is
|
||||
/// actually unwritable — cheap enough to call on every reconcile. Best-effort:
|
||||
/// returns true if it repaired something; never fails reconcile (a degraded app
|
||||
/// must not block the loop). See the immich EACCES crash-loop (.198, 2026-06-17).
|
||||
async fn ensure_running_container_ownership(name: &str) -> bool {
|
||||
async fn podman_stdout(args: &[&str]) -> Option<String> {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
// The uid:gid the container's main process actually runs as.
|
||||
let uid = match podman_stdout(&["exec", name, "id", "-u"]).await {
|
||||
Some(u) if !u.is_empty() => u,
|
||||
_ => return false, // can't exec (no shell / not running) — nothing to do
|
||||
};
|
||||
let gid = podman_stdout(&["exec", name, "id", "-g"])
|
||||
.await
|
||||
.filter(|g| !g.is_empty())
|
||||
.unwrap_or_else(|| uid.clone());
|
||||
|
||||
// Writable bind-mount destinations only.
|
||||
let dests = match podman_stdout(&[
|
||||
"inspect",
|
||||
name,
|
||||
"--format",
|
||||
"{{range .Mounts}}{{if eq .Type \"bind\"}}{{if .RW}}{{.Destination}}\n{{end}}{{end}}{{end}}",
|
||||
])
|
||||
.await
|
||||
{
|
||||
Some(d) => d,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let mut repaired = false;
|
||||
for dest in dests.lines().map(str::trim).filter(|d| !d.is_empty()) {
|
||||
// Never touch system / socket bind mounts.
|
||||
if dest == "/"
|
||||
|| dest.starts_with("/proc")
|
||||
|| dest.starts_with("/sys")
|
||||
|| dest.starts_with("/dev")
|
||||
|| dest.starts_with("/run")
|
||||
|| dest.starts_with("/etc")
|
||||
|| dest.ends_with(".sock")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drift check: can the service user write here already?
|
||||
let probe = format!(
|
||||
"t=\"{dest}/.archy-wtest.$$\"; touch \"$t\" 2>/dev/null && rm -f \"$t\" 2>/dev/null"
|
||||
);
|
||||
let writable = tokio::process::Command::new("podman")
|
||||
.args(["exec", name, "sh", "-c", &probe])
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if writable {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Repair inside the container's userns — podman maps to the right host uid.
|
||||
let chown = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"-u",
|
||||
"0",
|
||||
name,
|
||||
"chown",
|
||||
"-R",
|
||||
&format!("{uid}:{gid}"),
|
||||
dest,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
match chown {
|
||||
Ok(o) if o.status.success() => {
|
||||
repaired = true;
|
||||
tracing::warn!(
|
||||
container = %name, dest, uid = %uid,
|
||||
"repaired unwritable volume ownership (in-container chown)"
|
||||
);
|
||||
}
|
||||
Ok(o) => tracing::warn!(
|
||||
container = %name, dest,
|
||||
"volume ownership repair failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!(container = %name, dest, "volume ownership repair errored: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
repaired
|
||||
}
|
||||
|
||||
async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
@@ -297,6 +475,53 @@ async fn wait_for_manifest_host_ports(manifest: &AppManifest, timeout_secs: u64)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pure published-port drift check. `port_bindings_json` is the JSON that
|
||||
/// `podman inspect --format '{{json .HostConfig.PortBindings}}'` emits, e.g.
|
||||
/// `{"8080/tcp":[{"HostIp":"","HostPort":"18080"}]}`. Returns true only when a
|
||||
/// manifest container-port is positively published to a *different* host port
|
||||
/// than the manifest now asks for. Absence of a binding is deliberately NOT
|
||||
/// treated as drift here — that case is handled by the host-port repair/restart
|
||||
/// path and by host-networked apps that publish nothing — so we never trigger a
|
||||
/// destructive recreate on a false positive.
|
||||
fn host_port_bindings_drifted(
|
||||
port_bindings_json: &str,
|
||||
manifest_ports: &[archipelago_container::manifest::PortMapping],
|
||||
) -> bool {
|
||||
let parsed: serde_json::Value = match serde_json::from_str(port_bindings_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let Some(map) = parsed.as_object() else {
|
||||
return false;
|
||||
};
|
||||
for port in manifest_ports {
|
||||
let proto = if port.protocol.is_empty() {
|
||||
"tcp"
|
||||
} else {
|
||||
port.protocol.as_str()
|
||||
};
|
||||
let key = format!("{}/{}", port.container, proto);
|
||||
let Some(bindings) = map.get(&key).and_then(|b| b.as_array()) else {
|
||||
// Container-port not currently published — not our case.
|
||||
continue;
|
||||
};
|
||||
if bindings.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let expected = port.host.to_string();
|
||||
let matches_expected = bindings.iter().any(|b| {
|
||||
b.get("HostPort")
|
||||
.and_then(|h| h.as_str())
|
||||
.map(|h| h == expected)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if !matches_expected {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn ensure_user_podman_socket() -> Result<()> {
|
||||
let socket_path = "/run/user/1000/podman/podman.sock";
|
||||
if podman_socket_accepts_connections(socket_path).await {
|
||||
@@ -725,6 +950,8 @@ pub struct ProdContainerOrchestrator {
|
||||
use_quadlet_backends: bool,
|
||||
#[cfg(test)]
|
||||
test_disk_gb: Option<u64>,
|
||||
#[cfg(test)]
|
||||
test_bitcoin_host: Option<String>,
|
||||
}
|
||||
|
||||
struct FileSecretsProvider {
|
||||
@@ -734,8 +961,19 @@ struct FileSecretsProvider {
|
||||
impl SecretsProvider for FileSecretsProvider {
|
||||
fn read(&self, name: &str) -> std::result::Result<String, ManifestError> {
|
||||
let path = self.root.join(name);
|
||||
let data = std::fs::read_to_string(&path).map_err(ManifestError::Io)?;
|
||||
Ok(data.trim().to_string())
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(data) => Ok(data.trim().to_string()),
|
||||
// Name the missing secret explicitly so the failure is actionable
|
||||
// instead of a bare "IO error: No such file or directory" that hides
|
||||
// which secret (and which app) is blocked.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
Err(ManifestError::Invalid(format!(
|
||||
"required secret '{name}' is missing (expected at {}); the app cannot start until it is generated",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
Err(e) => Err(ManifestError::Io(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,6 +1012,8 @@ impl ProdContainerOrchestrator {
|
||||
use_quadlet_backends: config.use_quadlet_backends,
|
||||
#[cfg(test)]
|
||||
test_disk_gb: None,
|
||||
#[cfg(test)]
|
||||
test_bitcoin_host: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -792,6 +1032,7 @@ impl ProdContainerOrchestrator {
|
||||
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
|
||||
use_quadlet_backends: false,
|
||||
test_disk_gb: None,
|
||||
test_bitcoin_host: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,6 +1271,30 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App-agnostic volume-ownership self-heal. Sweep EVERY running container
|
||||
// (registry/manifest apps AND legacy compose stacks like immich) and
|
||||
// repair any that can't write their bind mounts — the durable, app-
|
||||
// agnostic replacement for per-app hardcoded host chowns. Drift-checked,
|
||||
// so steady state is just cheap in-container write-probes; only a broken
|
||||
// volume is chowned (in-userns, mapping-proof) and its container
|
||||
// restarted to recover. Fixes the class of EACCES crash-loops fleet-wide
|
||||
// and self-heals existing nodes after OTA. (immich .198, 2026-06-17.)
|
||||
if let Ok(containers) = self.runtime.list_containers().await {
|
||||
for c in containers
|
||||
.iter()
|
||||
.filter(|c| matches!(c.state, ContainerState::Running))
|
||||
{
|
||||
if ensure_running_container_ownership(&c.name).await {
|
||||
tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover");
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", &c.name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
@@ -1054,6 +1319,34 @@ impl ProdContainerOrchestrator {
|
||||
let lock = self.app_lock(&app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
self.ensure_app_secrets(&app_id).await?;
|
||||
|
||||
// Don't fight the Bitcoin-implementation switch: bitcoin-core and
|
||||
// bitcoin-knots share port 8332, so if the *other* variant is already
|
||||
// running the inactive one can never start — the reconciler would just
|
||||
// churn "address already in use" and report a reconcile failure. Skip
|
||||
// it, mirroring the health monitor's same skip. (#47)
|
||||
if let Some(conflict) = match app_id.strip_prefix("archy-").unwrap_or(app_id.as_str()) {
|
||||
"bitcoin-core" => Some("bitcoin-knots"),
|
||||
"bitcoin-knots" | "bitcoin" => Some("bitcoin-core"),
|
||||
_ => None,
|
||||
} {
|
||||
if let Ok(list) = self.runtime.list_containers().await {
|
||||
let other_running = list.iter().any(|c| {
|
||||
c.name.strip_prefix("archy-").unwrap_or(c.name.as_str()) == conflict
|
||||
&& matches!(c.state, ContainerState::Running)
|
||||
});
|
||||
if other_running {
|
||||
tracing::debug!(
|
||||
app_id = %app_id,
|
||||
conflict,
|
||||
"skipping reconcile — the other Bitcoin implementation is running"
|
||||
);
|
||||
return Ok(ReconcileAction::NoOp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut resolved_manifest = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved_manifest)?;
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
@@ -1094,6 +1387,22 @@ impl ProdContainerOrchestrator {
|
||||
self.run_post_start_hooks(&app_id).await?;
|
||||
return Ok(ReconcileAction::Started);
|
||||
}
|
||||
// Published-port drift means the container exists but maps
|
||||
// its ports to the wrong host ports (e.g. lnd REST stuck on
|
||||
// host 8080 while the manifest/clients expect 18080, as seen
|
||||
// on .116). The container is already non-functional, so
|
||||
// recreate it even for restart-sensitive apps during boot —
|
||||
// leaving it "untouched" would perpetuate the breakage.
|
||||
if self
|
||||
.container_ports_drifted(&name, &resolved_manifest)
|
||||
.await
|
||||
{
|
||||
tracing::info!(app_id = %app_id, container = %name, "container published-port drift detected — recreating");
|
||||
let _ = self.runtime.stop_container(&name).await;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(lm).await?;
|
||||
return Ok(ReconcileAction::Installed);
|
||||
}
|
||||
if self.container_env_drifted(&name, &resolved_manifest).await {
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
&& is_restart_sensitive_app(&app_id)
|
||||
@@ -1154,8 +1463,12 @@ impl ProdContainerOrchestrator {
|
||||
// reading it. A Rewritten outcome is fine here — we're
|
||||
// about to start from a stopped state anyway.
|
||||
self.prepare_for_start(&resolved_manifest).await?;
|
||||
if self.container_env_drifted(&name, &resolved_manifest).await {
|
||||
tracing::info!(app_id = %app_id, container = %name, "stopped container env drift detected — recreating");
|
||||
if self.container_env_drifted(&name, &resolved_manifest).await
|
||||
|| self
|
||||
.container_ports_drifted(&name, &resolved_manifest)
|
||||
.await
|
||||
{
|
||||
tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating");
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(lm).await?;
|
||||
return Ok(ReconcileAction::Installed);
|
||||
@@ -1297,10 +1610,33 @@ impl ProdContainerOrchestrator {
|
||||
|
||||
/// Build-or-pull, create, start. Assumes the per-app mutex is already held.
|
||||
async fn install_fresh(&self, lm: &LoadedManifest) -> Result<()> {
|
||||
self.ensure_app_secrets(&lm.manifest.app.id).await?;
|
||||
let mut resolved_manifest = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved_manifest)?;
|
||||
|
||||
let resolved = lm.manifest.app.container.resolve().ok_or_else(|| {
|
||||
// Decouple the app image from the shipped manifest: prefer the remote
|
||||
// app catalog when it covers this app with a same-repo image. This makes
|
||||
// both the pull below and create_container() below use the catalog tag,
|
||||
// so an app update no longer requires a binary/runtime release. Falls
|
||||
// back to the manifest image when the catalog is absent/uncovered.
|
||||
if let Some(current) = resolved_manifest.app.container.image.clone() {
|
||||
if let Some(catalog_image) = crate::container::app_catalog::catalog_image_override(
|
||||
&resolved_manifest.app.id,
|
||||
¤t,
|
||||
) {
|
||||
if catalog_image != current {
|
||||
tracing::info!(
|
||||
app_id = %resolved_manifest.app.id,
|
||||
from = %current,
|
||||
to = %catalog_image,
|
||||
"app-catalog: overriding manifest image"
|
||||
);
|
||||
resolved_manifest.app.container.image = Some(catalog_image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = resolved_manifest.app.container.resolve().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"manifest for {} has invalid container source (neither image nor build)",
|
||||
lm.manifest.app.id
|
||||
@@ -1328,7 +1664,7 @@ impl ProdContainerOrchestrator {
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
}
|
||||
let already = match self.runtime.image_exists(&bcfg.tag).await {
|
||||
let exists = match self.runtime.image_exists(&bcfg.tag).await {
|
||||
Ok(exists) => exists,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
@@ -1339,11 +1675,51 @@ impl ProdContainerOrchestrator {
|
||||
false
|
||||
}
|
||||
};
|
||||
if !already {
|
||||
// Presence alone isn't enough: the local UI images (bitcoin-ui,
|
||||
// lnd-ui, electrs-ui) COPY a built `neode-ui` dist, so a UI
|
||||
// update changes the source but leaves the old tag in place.
|
||||
// Rebuild whenever the build context's fingerprint differs from
|
||||
// the one that produced the existing image (#34). podman's
|
||||
// COPY-layer cache keeps the rebuild cheap when content is
|
||||
// actually unchanged.
|
||||
let fingerprint = fingerprint_build_context(Path::new(&bcfg.context));
|
||||
let stamp_path = build_fingerprint_stamp_path(&self.data_dir, &bcfg.tag);
|
||||
let stale = match &fingerprint {
|
||||
Some(current) => match tokio::fs::read_to_string(&stamp_path).await {
|
||||
Ok(prev) => prev.trim() != current,
|
||||
// No stamp recorded → treat as stale so we rebuild and
|
||||
// capture the fingerprint going forward.
|
||||
Err(_) => true,
|
||||
},
|
||||
// Couldn't fingerprint the context — don't skip on staleness.
|
||||
None => true,
|
||||
};
|
||||
if !exists || stale {
|
||||
if exists && stale {
|
||||
tracing::info!(
|
||||
image = %bcfg.tag,
|
||||
context = %bcfg.context,
|
||||
"build context changed since last build; rebuilding image"
|
||||
);
|
||||
}
|
||||
self.runtime
|
||||
.build_image(&bcfg)
|
||||
.await
|
||||
.with_context(|| format!("build_image {}", bcfg.tag))?;
|
||||
// Record the fingerprint that this image was built from so
|
||||
// the next reconcile skips the build until the source moves.
|
||||
if let Some(current) = &fingerprint {
|
||||
if let Some(parent) = stamp_path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
if let Err(err) = tokio::fs::write(&stamp_path, current).await {
|
||||
tracing::warn!(
|
||||
image = %bcfg.tag,
|
||||
error = %err,
|
||||
"failed to write build fingerprint stamp"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2234,9 +2610,45 @@ impl ProdContainerOrchestrator {
|
||||
host_ip,
|
||||
host_mdns,
|
||||
disk_gb,
|
||||
// Cheap default; resolve_dynamic_env fills the real node name on
|
||||
// demand (it costs a podman call) only for manifests that use
|
||||
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
|
||||
bitcoin_host: "bitcoin-knots".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Container name of the running Bitcoin node (`bitcoin-knots` or
|
||||
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
|
||||
/// Synchronous `podman ps` to match the surrounding host-fact detection;
|
||||
/// defaults to `bitcoin-knots` when none is running (B12).
|
||||
fn bitcoin_host(&self) -> String {
|
||||
#[cfg(test)]
|
||||
if let Some(host) = &self.test_bitcoin_host {
|
||||
return host.clone();
|
||||
}
|
||||
// Mirrors api::rpc::package::dependencies (the legacy install path);
|
||||
// both Bitcoin node variants are reachable on archy-net by name.
|
||||
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||
let names = Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
.unwrap_or_default();
|
||||
names
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|name| BITCOIN_NAMES.contains(name))
|
||||
.map(|name| name.to_string())
|
||||
.unwrap_or_else(|| "bitcoin-knots".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_bitcoin_host_for_test(&mut self, host: &str) {
|
||||
self.test_bitcoin_host = Some(host.to_string());
|
||||
}
|
||||
|
||||
fn detect_host_ip() -> Option<String> {
|
||||
let output = Command::new("hostname").arg("-I").output().ok()?;
|
||||
if !output.status.success() {
|
||||
@@ -2300,8 +2712,38 @@ impl ProdContainerOrchestrator {
|
||||
Self::detect_disk_gb()
|
||||
}
|
||||
|
||||
/// Ensure app-specific secrets exist *before* env resolution. The Bitcoin
|
||||
/// backends reference `bitcoin-rpc-txrelay-rpcauth` as a required
|
||||
/// `secret_env`; it is normally created by the tx-relay flow, so nodes that
|
||||
/// never used tx-relay lack it and `resolve_secret_env` hard-fails — taking
|
||||
/// bitcoind (and everything that depends on it) down. Generating it here,
|
||||
/// idempotently, lets reconcile/install self-heal that state (the .198 case)
|
||||
/// instead of cascading. Must be called before every `resolve_dynamic_env`.
|
||||
async fn ensure_app_secrets(&self, app_id: &str) -> Result<()> {
|
||||
if cfg!(test) {
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(app_id, "bitcoin-knots" | "bitcoin-core" | "bitcoin") {
|
||||
crate::api::rpc::bitcoin_relay::ensure_txrelay_credentials(&self.data_dir)
|
||||
.await
|
||||
.context("ensuring bitcoin tx-relay credentials")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
|
||||
let facts = self.detect_host_facts();
|
||||
let mut facts = self.detect_host_facts();
|
||||
// Only pay the podman cost to detect Knots-vs-Core when this manifest
|
||||
// actually templates the Bitcoin node into its env (mempool — B12).
|
||||
if manifest
|
||||
.app
|
||||
.container
|
||||
.derived_env
|
||||
.iter()
|
||||
.any(|e| e.template.contains("{{BITCOIN_HOST}}"))
|
||||
{
|
||||
facts.bitcoin_host = self.bitcoin_host();
|
||||
}
|
||||
let mut env = manifest.app.environment.clone();
|
||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||
|
||||
@@ -2443,6 +2885,42 @@ impl ProdContainerOrchestrator {
|
||||
false
|
||||
}
|
||||
|
||||
/// True when a *running* container publishes a manifest container-port to a
|
||||
/// different host port than the manifest now asks for (published-port
|
||||
/// drift). This catches the class of failure seen on .116, where `lnd` was
|
||||
/// created mapping host 8080 -> container 8080 but the current manifest maps
|
||||
/// host 18080 -> container 8080, so every in-process REST client (which
|
||||
/// connects to the manifest port) gets connection-refused forever while the
|
||||
/// container looks "Up". `container_env_drifted` never inspects ports, and
|
||||
/// `wait_for_manifest_host_ports` only restarts the stale container (which
|
||||
/// republishes the wrong mapping), so without this check the drift is
|
||||
/// self-perpetuating.
|
||||
async fn container_ports_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
|
||||
if cfg!(test) {
|
||||
return false;
|
||||
}
|
||||
if manifest.app.ports.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let inspect = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"inspect",
|
||||
name,
|
||||
"--format",
|
||||
"{{json .HostConfig.PortBindings}}",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let Ok(output) = inspect else {
|
||||
return false;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return false;
|
||||
}
|
||||
let bindings = String::from_utf8_lossy(&output.stdout);
|
||||
host_port_bindings_drifted(&bindings, &manifest.app.ports)
|
||||
}
|
||||
|
||||
async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> {
|
||||
let Some(uid_gid) = manifest.app.container.data_uid.as_ref() else {
|
||||
return Ok(());
|
||||
@@ -2752,6 +3230,7 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
let lm = self.loaded(app_id).await?;
|
||||
let lock = self.app_lock(app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
self.ensure_app_secrets(app_id).await?;
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
let mut resolved_manifest = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved_manifest)?;
|
||||
@@ -2913,6 +3392,61 @@ mod tests {
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
fn port(host: u16, container: u16) -> archipelago_container::manifest::PortMapping {
|
||||
archipelago_container::manifest::PortMapping {
|
||||
host,
|
||||
container,
|
||||
protocol: "tcp".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_drift_detected_when_host_port_differs() {
|
||||
// The .116 case: container publishes container-port 8080 on host 8080,
|
||||
// but the manifest now asks for host 18080.
|
||||
let bindings = r#"{"8080/tcp":[{"HostIp":"","HostPort":"8080"}]}"#;
|
||||
assert!(host_port_bindings_drifted(bindings, &[port(18080, 8080)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_drift_when_host_port_matches() {
|
||||
let bindings = r#"{"8080/tcp":[{"HostIp":"0.0.0.0","HostPort":"18080"}]}"#;
|
||||
assert!(!host_port_bindings_drifted(bindings, &[port(18080, 8080)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_drift_when_binding_absent() {
|
||||
// Absence is handled elsewhere (host-port repair / host-networked apps);
|
||||
// never treat it as drift to avoid a destructive recreate on a false
|
||||
// positive.
|
||||
assert!(!host_port_bindings_drifted("{}", &[port(18080, 8080)]));
|
||||
assert!(!host_port_bindings_drifted("null", &[port(18080, 8080)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_drift_on_unparseable_bindings() {
|
||||
assert!(!host_port_bindings_drifted(
|
||||
"not json",
|
||||
&[port(18080, 8080)]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_secret_error_names_the_secret() {
|
||||
use archipelago_container::manifest::SecretsProvider;
|
||||
let provider = FileSecretsProvider {
|
||||
root: PathBuf::from("/nonexistent-secrets-dir-xyz"),
|
||||
};
|
||||
let err = provider
|
||||
.read("bitcoin-rpc-txrelay-rpcauth")
|
||||
.expect_err("missing secret must error");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("bitcoin-rpc-txrelay-rpcauth"),
|
||||
"error should name the missing secret, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Instrumented in-memory runtime. Every call is recorded so tests can assert
|
||||
/// the exact sequence of side effects.
|
||||
#[derive(Default)]
|
||||
@@ -3298,6 +3832,35 @@ app:
|
||||
assert!(!calls.iter().any(|c| c.starts_with("build_image:")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mempool_core_rpc_host_follows_bitcoin_node() {
|
||||
// B12: mempool's CORE_RPC_HOST must resolve to whichever Bitcoin node
|
||||
// container is running (Knots OR Core), not a hardcoded value.
|
||||
let yaml = "app:\n id: mempool-api\n name: mempool-api\n version: 1.0.0\n container:\n image: x:1\n derived_env:\n - key: CORE_RPC_HOST\n template: \"{{BITCOIN_HOST}}\"\n";
|
||||
|
||||
for (node, expected) in [
|
||||
("bitcoin-core", "bitcoin-core"),
|
||||
("bitcoin-knots", "bitcoin-knots"),
|
||||
] {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
orch.set_bitcoin_host_for_test(node);
|
||||
|
||||
let mut manifest = AppManifest::parse(yaml).unwrap();
|
||||
orch.resolve_dynamic_env(&mut manifest).unwrap();
|
||||
|
||||
assert!(
|
||||
manifest
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|e| e == &format!("CORE_RPC_HOST={expected}")),
|
||||
"node={node}: expected CORE_RPC_HOST={expected}, got {:?}",
|
||||
manifest.app.environment
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_fresh_build_when_image_absent() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
@@ -4062,4 +4625,37 @@ app:
|
||||
let calls = rt.calls();
|
||||
assert!(calls.iter().any(|c| c == "create_container:lnd:offset=0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_build_context_detects_source_changes() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let ctx = tmp.path();
|
||||
std::fs::write(ctx.join("Dockerfile"), "FROM nginx\n").unwrap();
|
||||
std::fs::create_dir_all(ctx.join("assets")).unwrap();
|
||||
std::fs::write(ctx.join("assets/app.js"), b"v1").unwrap();
|
||||
|
||||
let a = fingerprint_build_context(ctx).expect("fingerprint");
|
||||
// Recomputing over the same tree is stable.
|
||||
let b = fingerprint_build_context(ctx).expect("fingerprint");
|
||||
assert_eq!(a, b, "fingerprint must be stable for an unchanged tree");
|
||||
|
||||
// Changing a COPYed source file (different length) changes the fingerprint.
|
||||
std::fs::write(ctx.join("assets/app.js"), b"v2-longer").unwrap();
|
||||
let c = fingerprint_build_context(ctx).expect("fingerprint");
|
||||
assert_ne!(a, c, "changed source file must change the fingerprint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_fingerprint_stamp_path_sanitizes_tag() {
|
||||
let p = build_fingerprint_stamp_path(
|
||||
Path::new("/var/lib/archipelago"),
|
||||
"localhost/bitcoin-ui:local",
|
||||
);
|
||||
assert_eq!(
|
||||
p,
|
||||
PathBuf::from(
|
||||
"/var/lib/archipelago/.image-build/localhost_bitcoin_ui_local.fingerprint"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Content hashing for the DHT distribution plan's *integrity & addressing*
|
||||
//! tier (`docs/dht-distribution-design.md` §4).
|
||||
//!
|
||||
//! SHA-256 is the incumbent: it keys `blobs.rs` and verifies OTA components
|
||||
//! today. BLAKE3 is introduced **alongside** it because iroh-blobs addresses
|
||||
//! and *range-verifies* content by BLAKE3 — essential for resumable downloads
|
||||
//! and HLS streaming. During the migration window both may be present; SHA-256
|
||||
//! stays mandatory and BLAKE3 is verified when supplied.
|
||||
//!
|
||||
//! Digests are written multihash-style as `"<alg>:<hex>"`, e.g.
|
||||
//! `"blake3:ab12…"` / `"sha256:cd34…"`, matching the app-catalog `digest` field.
|
||||
//! Both algorithms emit 32-byte (64-hex-char) digests.
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const DIGEST_LEN: usize = 32;
|
||||
|
||||
/// Supported content-hash algorithms.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HashAlg {
|
||||
Sha256,
|
||||
Blake3,
|
||||
}
|
||||
|
||||
impl HashAlg {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
HashAlg::Sha256 => "sha256",
|
||||
HashAlg::Blake3 => "blake3",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hex-encoded SHA-256 of `bytes`.
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
hex::encode(Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
/// Hex-encoded BLAKE3 of `bytes`.
|
||||
pub fn blake3_hex(bytes: &[u8]) -> String {
|
||||
blake3::hash(bytes).to_hex().to_string()
|
||||
}
|
||||
|
||||
/// A parsed `"<alg>:<hex>"` content digest.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContentDigest {
|
||||
pub alg: HashAlg,
|
||||
/// Lowercase hex, validated to the algorithm's length.
|
||||
pub hex: String,
|
||||
}
|
||||
|
||||
impl ContentDigest {
|
||||
/// Parse a multihash-style `"<alg>:<hex>"` string.
|
||||
pub fn parse(s: &str) -> Result<Self> {
|
||||
let (alg_part, hex_part) = s
|
||||
.split_once(':')
|
||||
.ok_or_else(|| anyhow!("digest must be '<alg>:<hex>', got: {}", s))?;
|
||||
let alg = match alg_part {
|
||||
"sha256" => HashAlg::Sha256,
|
||||
"blake3" => HashAlg::Blake3,
|
||||
other => bail!("unsupported hash algorithm: {}", other),
|
||||
};
|
||||
let raw = hex::decode(hex_part).context("digest hex is invalid")?;
|
||||
if raw.len() != DIGEST_LEN {
|
||||
bail!(
|
||||
"{} digest must be {} bytes, got {}",
|
||||
alg.as_str(),
|
||||
DIGEST_LEN,
|
||||
raw.len()
|
||||
);
|
||||
}
|
||||
Ok(Self {
|
||||
alg,
|
||||
hex: hex_part.to_ascii_lowercase(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute the digest of `bytes` under this digest's algorithm.
|
||||
pub fn compute_hex(&self, bytes: &[u8]) -> String {
|
||||
match self.alg {
|
||||
HashAlg::Sha256 => sha256_hex(bytes),
|
||||
HashAlg::Blake3 => blake3_hex(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify `bytes` hash to this digest. Errors (does not panic) on mismatch.
|
||||
pub fn verify(&self, bytes: &[u8]) -> Result<()> {
|
||||
let actual = self.compute_hex(bytes);
|
||||
if actual.eq_ignore_ascii_case(&self.hex) {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!(
|
||||
"{} mismatch: expected {}, got {}",
|
||||
self.alg.as_str(),
|
||||
self.hex,
|
||||
actual
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ContentDigest {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}:{}", self.alg.as_str(), self.hex)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn digest_lengths_are_32_bytes() {
|
||||
assert_eq!(sha256_hex(b"hi").len(), 64);
|
||||
assert_eq!(blake3_hex(b"hi").len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blake3_known_answer() {
|
||||
// BLAKE3 of the empty input — RFC/reference vector.
|
||||
assert_eq!(
|
||||
blake3_hex(b""),
|
||||
"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_roundtrip() {
|
||||
let d = ContentDigest::parse(&format!("blake3:{}", blake3_hex(b"x"))).unwrap();
|
||||
assert_eq!(d.alg, HashAlg::Blake3);
|
||||
assert_eq!(d.to_string(), format!("blake3:{}", blake3_hex(b"x")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_accepts_and_rejects() {
|
||||
let d = ContentDigest::parse(&format!("sha256:{}", sha256_hex(b"payload"))).unwrap();
|
||||
assert!(d.verify(b"payload").is_ok());
|
||||
assert!(d.verify(b"tampered").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_bad_input() {
|
||||
assert!(ContentDigest::parse("nocolon").is_err());
|
||||
assert!(ContentDigest::parse("md5:abcd").is_err());
|
||||
assert!(ContentDigest::parse("blake3:nothex").is_err());
|
||||
assert!(ContentDigest::parse("blake3:ab").is_err()); // too short
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Seller-side pending entitlements for Lightning-invoice peer-file sales (#46).
|
||||
//!
|
||||
//! When a buyer asks to pay for a paid catalog item with an external wallet (as
|
||||
//! opposed to the local-ecash fast path), the *selling* node mints a Lightning
|
||||
//! invoice on its own LND and records a pending entitlement here, keyed by the
|
||||
//! invoice's payment hash. The buyer pays the invoice from any wallet and polls
|
||||
//! for settlement; once the seller's LND confirms the invoice is settled we mark
|
||||
//! the entitlement paid, and the content gate (`content_server::serve_content`)
|
||||
//! then releases the file to anyone presenting that payment hash.
|
||||
//!
|
||||
//! State is in-memory and bounded by a TTL. If the seller restarts before the
|
||||
//! buyer pays, the buyer simply requests a fresh invoice — no value is lost
|
||||
//! because an unpaid invoice represents no money.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// How long a pending/paid entitlement is retained. Generous enough for a human
|
||||
/// to pay an invoice and download, short enough to keep the map small.
|
||||
const ENTITLEMENT_TTL: Duration = Duration::from_secs(3600); // 1 hour
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Entitlement {
|
||||
content_id: String,
|
||||
price_sats: u64,
|
||||
paid: bool,
|
||||
created_at: Instant,
|
||||
}
|
||||
|
||||
static ENTITLEMENTS: LazyLock<Mutex<HashMap<String, Entitlement>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Drop expired entries. Caller must hold the lock.
|
||||
fn prune(map: &mut HashMap<String, Entitlement>) {
|
||||
map.retain(|_, e| e.created_at.elapsed() < ENTITLEMENT_TTL);
|
||||
}
|
||||
|
||||
/// Record a freshly-minted invoice as a pending (unpaid) entitlement.
|
||||
pub async fn record_pending(payment_hash: &str, content_id: &str, price_sats: u64) {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.insert(
|
||||
payment_hash.to_string(),
|
||||
Entitlement {
|
||||
content_id: content_id.to_string(),
|
||||
price_sats,
|
||||
paid: false,
|
||||
created_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Mark the entitlement for `payment_hash` paid. No-op if unknown/expired.
|
||||
pub async fn mark_paid(payment_hash: &str) {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
if let Some(e) = map.get_mut(payment_hash) {
|
||||
e.paid = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The content_id + price an entitlement was issued for, if still live.
|
||||
pub async fn lookup(payment_hash: &str) -> Option<(String, u64)> {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.get(payment_hash)
|
||||
.map(|e| (e.content_id.clone(), e.price_sats))
|
||||
}
|
||||
|
||||
/// True if `payment_hash` is a paid entitlement for exactly `content_id`.
|
||||
/// This is the gate the content server consults to release a file.
|
||||
pub async fn is_paid_for(payment_hash: &str, content_id: &str) -> bool {
|
||||
let mut map = ENTITLEMENTS.lock().await;
|
||||
prune(&mut map);
|
||||
map.get(payment_hash)
|
||||
.map(|e| e.paid && e.content_id == content_id)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -198,6 +198,7 @@ pub async fn serve_content(
|
||||
data_dir: &Path,
|
||||
id: &str,
|
||||
payment_token: Option<&str>,
|
||||
invoice_hash: Option<&str>,
|
||||
peer_did: Option<&str>,
|
||||
range: Option<ByteRange>,
|
||||
) -> Result<ServeResult> {
|
||||
@@ -236,12 +237,24 @@ pub async fn serve_content(
|
||||
// Check access control
|
||||
match &item.access {
|
||||
AccessControl::Paid { price_sats } => {
|
||||
// Verify payment token
|
||||
// Two ways to satisfy payment:
|
||||
// (a) a valid ecash token (the local-wallet fast path), or
|
||||
// (b) a Lightning-invoice payment hash this node issued and has
|
||||
// since confirmed settled (the "pay from any wallet" path, #46).
|
||||
let mut authorized = false;
|
||||
if let Some(token) = payment_token {
|
||||
if !verify_payment_token(data_dir, token, *price_sats).await {
|
||||
return Ok(ServeResult::PaymentRequired(*price_sats));
|
||||
if verify_payment_token(data_dir, token, *price_sats).await {
|
||||
authorized = true;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if !authorized {
|
||||
if let Some(hash) = invoice_hash {
|
||||
if crate::content_invoice::is_paid_for(hash, id).await {
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !authorized {
|
||||
return Ok(ServeResult::PaymentRequired(*price_sats));
|
||||
}
|
||||
}
|
||||
@@ -317,10 +330,63 @@ pub enum PreviewResult {
|
||||
BlurPreview(Vec<u8>, String),
|
||||
/// Truncated preview for paid video (first ~2% of bytes).
|
||||
TruncatedPreview(Vec<u8>, String, u64),
|
||||
/// A preview can't be produced for this media without re-encoding (e.g. a
|
||||
/// non-faststart MP4 whose moov atom is at the end, so a byte prefix won't
|
||||
/// play). The UI shows its "preview unavailable" overlay instead of a
|
||||
/// broken player. (#35)
|
||||
PreviewUnavailable,
|
||||
/// Content not found.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Scan an MP4's top-level boxes and report whether `moov` appears before
|
||||
/// `mdat` ("faststart"). Returns `Some(true)` if faststart (a byte prefix is
|
||||
/// playable), `Some(false)` if the media data precedes the index (a prefix
|
||||
/// will NOT play), or `None` if neither box is found / the file isn't parseable
|
||||
/// as ISO-BMFF (caller falls back to the legacy prefix behavior).
|
||||
async fn mp4_is_faststart(path: &std::path::Path) -> Option<bool> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
|
||||
let mut f = tokio::fs::File::open(path).await.ok()?;
|
||||
let file_len = f.metadata().await.ok()?.len();
|
||||
let mut pos: u64 = 0;
|
||||
// Bound the walk so a malformed file can't spin forever.
|
||||
for _ in 0..1024 {
|
||||
if pos.saturating_add(8) > file_len {
|
||||
return None;
|
||||
}
|
||||
f.seek(SeekFrom::Start(pos)).await.ok()?;
|
||||
let mut hdr = [0u8; 8];
|
||||
if f.read_exact(&mut hdr).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
let mut size = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]) as u64;
|
||||
let btype = &hdr[4..8];
|
||||
let mut header_len = 8u64;
|
||||
if size == 1 {
|
||||
// 64-bit extended size.
|
||||
let mut ext = [0u8; 8];
|
||||
if f.read_exact(&mut ext).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
size = u64::from_be_bytes(ext);
|
||||
header_len = 16;
|
||||
} else if size == 0 {
|
||||
// Box runs to EOF — it's the last one.
|
||||
size = file_len.saturating_sub(pos);
|
||||
}
|
||||
match btype {
|
||||
b"moov" => return Some(true), // index before media → faststart
|
||||
b"mdat" => return Some(false), // media before index → not faststart
|
||||
_ => {}
|
||||
}
|
||||
if size < header_len {
|
||||
return None; // malformed
|
||||
}
|
||||
pos = pos.checked_add(size)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Serve a preview of content by ID. For paid content, returns degraded previews:
|
||||
/// - Images: full file with X-Content-Preview: blur (frontend applies CSS blur)
|
||||
/// - Videos: first 2% of file bytes (minimum 512KB for codec headers)
|
||||
@@ -358,6 +424,26 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
|
||||
);
|
||||
Ok(PreviewResult::BlurPreview(bytes, item.mime_type.clone()))
|
||||
} else if mime.starts_with("video/") || mime.starts_with("audio/") {
|
||||
// A byte-prefix preview only plays if the container's index is at
|
||||
// the front. For MP4/MOV that means the `moov` atom must precede
|
||||
// `mdat` (faststart). Non-faststart files have moov at the end, so
|
||||
// a 10% prefix is an unplayable truncated MP4 (#35) — report it as
|
||||
// unavailable rather than streaming bytes that hang the player.
|
||||
let is_isobmff = mime == "video/mp4"
|
||||
|| mime == "video/quicktime"
|
||||
|| matches!(
|
||||
file_path.extension().and_then(|e| e.to_str()),
|
||||
Some("mp4") | Some("m4v") | Some("mov") | Some("m4a")
|
||||
);
|
||||
if is_isobmff && mp4_is_faststart(&file_path).await == Some(false) {
|
||||
debug!(
|
||||
"Paid {} '{}' is a non-faststart MP4 (moov after mdat) — no playable prefix preview",
|
||||
if mime.starts_with("video/") { "video" } else { "audio" },
|
||||
id
|
||||
);
|
||||
return Ok(PreviewResult::PreviewUnavailable);
|
||||
}
|
||||
|
||||
// Serve first 10% of video/audio, minimum 512KB for codec headers
|
||||
let metadata = fs::metadata(&file_path)
|
||||
.await
|
||||
@@ -431,3 +517,41 @@ async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod faststart_tests {
|
||||
use super::*;
|
||||
|
||||
fn box_hdr(size: u32, typ: &[u8; 4]) -> Vec<u8> {
|
||||
let mut v = size.to_be_bytes().to_vec();
|
||||
v.extend_from_slice(typ);
|
||||
v
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detects_faststart_moov_before_mdat() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("fast.mp4");
|
||||
let mut data = Vec::new();
|
||||
data.extend(box_hdr(16, b"ftyp"));
|
||||
data.extend([0u8; 8]);
|
||||
data.extend(box_hdr(8, b"moov"));
|
||||
data.extend(box_hdr(8, b"mdat"));
|
||||
tokio::fs::write(&p, &data).await.unwrap();
|
||||
assert_eq!(mp4_is_faststart(&p).await, Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detects_non_faststart_mdat_before_moov() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("slow.mp4");
|
||||
let mut data = Vec::new();
|
||||
data.extend(box_hdr(16, b"ftyp"));
|
||||
data.extend([0u8; 8]);
|
||||
data.extend(box_hdr(16, b"mdat"));
|
||||
data.extend([0u8; 8]);
|
||||
data.extend(box_hdr(8, b"moov"));
|
||||
tokio::fs::write(&p, &data).await.unwrap();
|
||||
assert_eq!(mp4_is_faststart(&p).await, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ mod types;
|
||||
pub use invites::{accept_invite, create_invite};
|
||||
#[allow(unused_imports)]
|
||||
pub use storage::{
|
||||
add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes,
|
||||
set_trust_level, update_node,
|
||||
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
|
||||
remove_node, save_nodes, set_trust_level, update_node,
|
||||
};
|
||||
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
|
||||
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
|
||||
|
||||
@@ -117,9 +117,12 @@ fn expire_stale(requests: &mut Vec<PendingPeerRequest>) {
|
||||
/// or `None` if the request was deduplicated or rate-limited.
|
||||
///
|
||||
/// Dedup rule: if the same (from_nostr_pubkey, from_did) already has a
|
||||
/// `Pending` entry, do not insert a second one — the user will see the
|
||||
/// existing row and act on that. Otherwise count `Pending` entries per
|
||||
/// pubkey and reject anything beyond `MAX_PENDING_PER_PUBKEY`.
|
||||
/// `Pending` OR `Approved` entry, do not insert a second one. Including
|
||||
/// `Approved` is what stops an already-approved peer from re-spawning a fresh
|
||||
/// pending row every time their request re-syncs (the reported "approve, Poll
|
||||
/// Now, see approved + a new pending" loop). `Rejected` is intentionally NOT
|
||||
/// matched so a previously-rejected peer can still ask again later. Otherwise
|
||||
/// count `Pending` entries per pubkey and reject beyond `MAX_PENDING_PER_PUBKEY`.
|
||||
pub async fn insert_inbound(
|
||||
data_dir: &Path,
|
||||
from_nostr_pubkey: String,
|
||||
@@ -131,13 +134,13 @@ pub async fn insert_inbound(
|
||||
let mut requests = load_pending(data_dir).await?;
|
||||
expire_stale(&mut requests);
|
||||
|
||||
let already_pending = requests.iter().any(|r| {
|
||||
let already_handled = requests.iter().any(|r| {
|
||||
r.from_nostr_pubkey == from_nostr_pubkey
|
||||
&& r.from_did == from_did
|
||||
&& matches!(r.state, PendingState::Pending)
|
||||
&& matches!(r.state, PendingState::Pending | PendingState::Approved)
|
||||
&& !r.outbound
|
||||
});
|
||||
if already_pending {
|
||||
if already_handled {
|
||||
save_pending(data_dir, &requests).await?;
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -271,6 +274,54 @@ mod tests {
|
||||
assert!(r2.is_none(), "duplicate Pending request should be ignored");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_approved_request_does_not_respawn_pending() {
|
||||
// Regression for the "approve → Poll Now → approved + a fresh pending"
|
||||
// loop: once a request is Approved, a re-synced inbound for the same
|
||||
// peer must NOT create a new Pending row.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let r1 = insert_inbound(
|
||||
dir.path(),
|
||||
"npk1".into(),
|
||||
"npub1".into(),
|
||||
"did:key:zABC".into(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("first insert stored");
|
||||
|
||||
set_state(dir.path(), &r1.id, PendingState::Approved)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let r2 = insert_inbound(
|
||||
dir.path(),
|
||||
"npk1".into(),
|
||||
"npub1".into(),
|
||||
"did:key:zABC".into(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
r2.is_none(),
|
||||
"an already-approved peer must not re-spawn a pending request"
|
||||
);
|
||||
|
||||
let pending = load_pending(dir.path()).await.unwrap();
|
||||
assert_eq!(
|
||||
pending
|
||||
.iter()
|
||||
.filter(|r| matches!(r.state, PendingState::Pending))
|
||||
.count(),
|
||||
0,
|
||||
"no Pending rows should remain after approval + re-sync"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rate_limit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -10,6 +10,9 @@ use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLeve
|
||||
pub(crate) const FEDERATION_DIR: &str = "federation";
|
||||
pub(crate) const NODES_FILE: &str = "nodes.json";
|
||||
pub(crate) const INVITES_FILE: &str = "invites.json";
|
||||
/// Tombstones: DIDs the operator explicitly removed. Kept so transitive
|
||||
/// federation discovery can't silently re-add a peer they deleted.
|
||||
pub(crate) const REMOVED_FILE: &str = "removed-nodes.json";
|
||||
|
||||
/// Top-level file structures.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
@@ -17,6 +20,17 @@ pub(crate) struct NodesFile {
|
||||
pub(crate) nodes: Vec<FederatedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct RemovedFile {
|
||||
pub(crate) removed: Vec<RemovedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct RemovedNode {
|
||||
pub(crate) did: String,
|
||||
pub(crate) removed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct InvitesFile {
|
||||
pub(crate) outgoing: Vec<FederationInvite>,
|
||||
@@ -44,7 +58,43 @@ pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
|
||||
.await
|
||||
.context("Failed to read federation nodes")?;
|
||||
let file: NodesFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file.nodes)
|
||||
Ok(dedup_nodes_by_onion(file.nodes))
|
||||
}
|
||||
|
||||
/// Collapse entries that share an onion. An onion is a node's stable, unique
|
||||
/// network identity, so two entries with the same onion are the SAME physical
|
||||
/// node lingering under two dids (e.g. after a did/key change). Returning both
|
||||
/// duplicates the node in the trusted-node list (B1) and the chat list (B2).
|
||||
/// Keep the first occurrence and merge any missing fips_npub/name/last_state
|
||||
/// from the duplicates into it, then drop them. Non-destructive to disk; the
|
||||
/// deduped list persists the next time nodes are saved (add/sync).
|
||||
fn dedup_nodes_by_onion(nodes: Vec<FederatedNode>) -> Vec<FederatedNode> {
|
||||
use std::collections::HashMap;
|
||||
let mut by_onion: HashMap<String, usize> = HashMap::new();
|
||||
let mut out: Vec<FederatedNode> = Vec::with_capacity(nodes.len());
|
||||
for node in nodes {
|
||||
let key = node.onion.trim_end_matches(".onion").to_string();
|
||||
if key.is_empty() {
|
||||
out.push(node);
|
||||
continue;
|
||||
}
|
||||
if let Some(&idx) = by_onion.get(&key) {
|
||||
let kept = &mut out[idx];
|
||||
if kept.fips_npub.is_none() {
|
||||
kept.fips_npub = node.fips_npub;
|
||||
}
|
||||
if kept.name.is_none() {
|
||||
kept.name = node.name;
|
||||
}
|
||||
if kept.last_state.is_none() {
|
||||
kept.last_state = node.last_state;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
by_onion.insert(key, out.len());
|
||||
out.push(node);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Look up a federated peer's FIPS npub given their onion address.
|
||||
@@ -114,6 +164,9 @@ pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<Federa
|
||||
if exists {
|
||||
anyhow::bail!("Node with DID {} is already federated", node.did);
|
||||
}
|
||||
// Explicitly (re-)adding a node clears any prior tombstone so the
|
||||
// operator can intentionally bring back a previously removed peer.
|
||||
let _ = untombstone_did(data_dir, &node.did).await;
|
||||
nodes.push(node);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
@@ -127,9 +180,70 @@ pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode
|
||||
anyhow::bail!("No federated node with DID {}", did);
|
||||
}
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
// Tombstone the DID so transitive federation discovery (a still-federated
|
||||
// peer advertising this DID as one of *its* trusted peers) can't silently
|
||||
// re-add it. Best-effort: a failed tombstone write must not fail the
|
||||
// remove the operator asked for.
|
||||
let _ = tombstone_did(data_dir, did).await;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Load the set of tombstoned (operator-removed) DIDs.
|
||||
pub async fn load_removed_dids(data_dir: &Path) -> Result<std::collections::HashSet<String>> {
|
||||
let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(std::collections::HashSet::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read removed nodes")?;
|
||||
let file: RemovedFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file.removed.into_iter().map(|r| r.did).collect())
|
||||
}
|
||||
|
||||
/// Record a DID as removed. Idempotent.
|
||||
pub async fn tombstone_did(data_dir: &Path, did: &str) -> Result<()> {
|
||||
let dir = ensure_dir(data_dir).await?;
|
||||
let path = dir.join(REMOVED_FILE);
|
||||
let mut file: RemovedFile = if path.exists() {
|
||||
serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
RemovedFile::default()
|
||||
};
|
||||
if !file.removed.iter().any(|r| r.did == did) {
|
||||
file.removed.push(RemovedNode {
|
||||
did: did.to_string(),
|
||||
removed_at: chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write removed nodes")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear a DID's tombstone (operator explicitly re-added it).
|
||||
pub async fn untombstone_did(data_dir: &Path, did: &str) -> Result<()> {
|
||||
let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file: RemovedFile =
|
||||
serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
let before = file.removed.len();
|
||||
file.removed.retain(|r| r.did != did);
|
||||
if file.removed.len() != before {
|
||||
let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write removed nodes")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_trust_level(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
@@ -236,6 +350,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dedup_nodes_by_onion_collapses_same_onion() {
|
||||
// Two entries share an onion (same physical node under two dids) — must
|
||||
// collapse to one, keeping the first did and merging fips_npub/name (B1/B2).
|
||||
let mut dup = make_node("did:key:zDUP", "shared.onion");
|
||||
dup.fips_npub = Some("npub1merged".to_string());
|
||||
dup.name = Some("Sapien".to_string());
|
||||
let nodes = vec![
|
||||
make_node("did:key:zKEEP", "shared.onion"),
|
||||
dup,
|
||||
make_node("did:key:zOTHER", "other.onion"),
|
||||
];
|
||||
let out = dedup_nodes_by_onion(nodes);
|
||||
assert_eq!(out.len(), 2, "two distinct onions remain");
|
||||
let kept = out.iter().find(|n| n.onion == "shared.onion").unwrap();
|
||||
assert_eq!(kept.did, "did:key:zKEEP", "keeps first did for the onion");
|
||||
assert_eq!(
|
||||
kept.fips_npub.as_deref(),
|
||||
Some("npub1merged"),
|
||||
"merges fips_npub from the dropped duplicate"
|
||||
);
|
||||
assert_eq!(
|
||||
kept.name.as_deref(),
|
||||
Some("Sapien"),
|
||||
"merges name from the dup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dedup_onion_suffix_insensitive() {
|
||||
// The ".onion" suffix must not affect the match.
|
||||
let nodes = vec![
|
||||
make_node("did:key:z1", "abc"),
|
||||
make_node("did:key:z2", "abc.onion"),
|
||||
];
|
||||
assert_eq!(dedup_nodes_by_onion(nodes).len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_nodes_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -287,6 +439,36 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_tombstones_and_readd_clears_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
// No tombstones yet.
|
||||
assert!(load_removed_dids(dir.path()).await.unwrap().is_empty());
|
||||
|
||||
// Removing tombstones the DID so transitive discovery won't re-add it.
|
||||
remove_node(dir.path(), "did:key:z1").await.unwrap();
|
||||
let removed = load_removed_dids(dir.path()).await.unwrap();
|
||||
assert!(
|
||||
removed.contains("did:key:z1"),
|
||||
"removed DID must be tombstoned"
|
||||
);
|
||||
|
||||
// Explicitly re-adding clears the tombstone (intentional re-federate).
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!load_removed_dids(dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("did:key:z1"),
|
||||
"explicit re-add must clear the tombstone"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -118,6 +118,12 @@ async fn merge_transitive_peers(
|
||||
return Ok(());
|
||||
}
|
||||
let mut nodes = super::storage::load_nodes(data_dir).await?;
|
||||
// Tombstoned DIDs: peers the operator explicitly removed. Never re-add
|
||||
// them via transitive discovery, or deleted (e.g. stale test) nodes
|
||||
// reappear on the next sync with any peer that still lists them.
|
||||
let removed = super::storage::load_removed_dids(data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut added = 0u32;
|
||||
let mut refreshed = 0u32;
|
||||
|
||||
@@ -127,6 +133,10 @@ async fn merge_transitive_peers(
|
||||
if hint.did == source_did || hint.did == local_did {
|
||||
continue;
|
||||
}
|
||||
// Skip anything the operator deliberately removed.
|
||||
if removed.contains(&hint.did) {
|
||||
continue;
|
||||
}
|
||||
if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) {
|
||||
// Already known — just refresh fips_npub if we didn't have one.
|
||||
if existing.fips_npub.is_none() && hint.fips_npub.is_some() {
|
||||
@@ -135,6 +145,27 @@ async fn merge_transitive_peers(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Same physical node advertised under a DIFFERENT did? Match on the
|
||||
// onion (its stable network identity). Without this, a node that
|
||||
// appears under two dids (e.g. after a key/did change) gets added
|
||||
// twice — showing up duplicated in the trusted-node list (B1) and as
|
||||
// two separate mesh chat contacts (B2). Merge into the existing entry.
|
||||
let hint_onion = hint.onion.trim_end_matches(".onion");
|
||||
if !hint_onion.is_empty() {
|
||||
if let Some(existing) = nodes
|
||||
.iter_mut()
|
||||
.find(|n| n.onion.trim_end_matches(".onion") == hint_onion)
|
||||
{
|
||||
if existing.fips_npub.is_none() && hint.fips_npub.is_some() {
|
||||
existing.fips_npub = hint.fips_npub.clone();
|
||||
}
|
||||
if existing.name.is_none() && hint.name.is_some() {
|
||||
existing.name = hint.name.clone();
|
||||
}
|
||||
refreshed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
nodes.push(FederatedNode {
|
||||
did: hint.did.clone(),
|
||||
pubkey: hint.pubkey.clone(),
|
||||
|
||||
@@ -28,12 +28,38 @@ use tokio::process::Command;
|
||||
/// On-disk filename under `data_dir/`.
|
||||
const SEED_ANCHORS_FILE: &str = "seed-anchors.json";
|
||||
|
||||
/// Public anchor (`fips.v0l.io`) carried as a default seed for fresh
|
||||
/// installs — the one the upstream daemon dials anyway. Operators can
|
||||
/// remove it from the UI once their own cluster has independent anchors.
|
||||
/// Public anchor (`fips.v0l.io`) carried as a default seed for every
|
||||
/// node — it bootstraps DHT routing so a fresh node isn't isolated.
|
||||
/// Operators can remove it from the UI once their own cluster has
|
||||
/// independent anchors (removal persists, see `load`/`remove`).
|
||||
///
|
||||
/// IMPORTANT transport details, learned the hard way (see git history /
|
||||
/// the 2026-06-15 debugging on .116):
|
||||
/// - The anchor answers ONLY on **TCP port 8443**. UDP 8668 is dead
|
||||
/// (host pings on both IP families but never completes a UDP FIPS
|
||||
/// handshake). `fips/config.rs` always knew this; the old default
|
||||
/// here (`fips.v0l.io:8668`/udp) silently never connected fleet-wide.
|
||||
/// - We use the **IPv4 literal** rather than the `fips.v0l.io` hostname
|
||||
/// on purpose: the hostname resolves IPv6-first, but the daemon binds
|
||||
/// its transports IPv4-only (`0.0.0.0:8443`), so a v6 target makes the
|
||||
/// daemon fail to send the handshake with `EAFNOSUPPORT (os error 97)`.
|
||||
/// An IPv4 literal sidesteps the resolver entirely.
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
|
||||
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "fips.v0l.io:8668";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp";
|
||||
|
||||
/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried
|
||||
/// implicitly by `load()` on nodes that have never edited their anchor
|
||||
/// list, so every node dials it without operator action.
|
||||
pub fn default_public_anchor() -> SeedAnchor {
|
||||
SeedAnchor {
|
||||
npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(),
|
||||
address: DEFAULT_PUBLIC_ANCHOR_ADDR.to_string(),
|
||||
transport: DEFAULT_PUBLIC_ANCHOR_TRANSPORT.to_string(),
|
||||
label: "Public anchor (fips.v0l.io)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One seed-anchor entry. `address` must be directly dialable (IP or
|
||||
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
|
||||
@@ -60,12 +86,15 @@ fn anchors_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(SEED_ANCHORS_FILE)
|
||||
}
|
||||
|
||||
/// Load the seed-anchor list. Returns an empty list if the file
|
||||
/// doesn't exist yet — a first-boot node with no operator config.
|
||||
/// Load the seed-anchor list. A node that has never edited its anchor
|
||||
/// list (no file yet) gets the default public anchor so it can bootstrap
|
||||
/// the mesh out of the box. Once the operator edits anchors — including
|
||||
/// removing the default — a file exists and is authoritative, so removal
|
||||
/// persists and we never silently re-add it.
|
||||
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
||||
let path = anchors_path(data_dir);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
return Ok(vec![default_public_anchor()]);
|
||||
}
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
@@ -121,11 +150,27 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
|
||||
/// `fipsctl connect` is idempotent-ish: calling it for an already-
|
||||
/// connected peer is a no-op at the protocol layer, so re-applying on
|
||||
/// a timer is safe. Returns a list of per-anchor results for logging.
|
||||
///
|
||||
/// Invoked through `sudo -n`: the upstream daemon's control socket
|
||||
/// (`/run/fips/control.sock`) is owned `root:fips` 0660, and the
|
||||
/// archipelago service user is not in the `fips` group, so a bare
|
||||
/// `fipsctl connect` fails with EACCES. This matches the privileged
|
||||
/// `sudo -n fipsctl show peers` call in `service::peer_connectivity_summary`.
|
||||
/// Without it, seed anchors persist to disk but never actually dial,
|
||||
/// leaving `anchor_connected=false` and every peer dial falling back to
|
||||
/// a slow Tor timeout.
|
||||
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
let mut results = Vec::with_capacity(anchors.len());
|
||||
for anchor in anchors {
|
||||
let out = Command::new("fipsctl")
|
||||
.args(["connect", &anchor.npub, &anchor.address, &anchor.transport])
|
||||
let out = Command::new("sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"fipsctl",
|
||||
"connect",
|
||||
&anchor.npub,
|
||||
&anchor.address,
|
||||
&anchor.transport,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let result = match out {
|
||||
@@ -138,7 +183,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!(
|
||||
"fipsctl exited {}: {}",
|
||||
"sudo fipsctl connect exited {}: {}",
|
||||
o.status,
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
@@ -146,7 +191,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
Err(e) => ApplyResult {
|
||||
npub: anchor.npub.clone(),
|
||||
ok: false,
|
||||
message: format!("fipsctl launch failed: {}", e),
|
||||
message: format!("sudo fipsctl launch failed: {}", e),
|
||||
},
|
||||
};
|
||||
if result.ok {
|
||||
@@ -185,10 +230,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_missing_returns_empty() {
|
||||
async fn load_missing_seeds_default_public_anchor() {
|
||||
// A node that has never edited its anchor list should still get
|
||||
// the public anchor so it can bootstrap the mesh out of the box.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty());
|
||||
assert_eq!(got, vec![default_public_anchor()]);
|
||||
// ...and the default must be the TCP/8443 form, not the dead udp:8668.
|
||||
assert_eq!(got[0].transport, "tcp");
|
||||
assert!(got[0].address.ends_with(":8443"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_default_persists_as_empty() {
|
||||
// Once the operator removes the default, a file exists and is
|
||||
// authoritative — we must not silently re-seed it on next load.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(list.is_empty());
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty(), "default must stay removed once edited");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -34,6 +34,17 @@ use tokio::net::UdpSocket;
|
||||
/// path filter can restrict the exposed surface.
|
||||
pub const PEER_PORT: u16 = 5679;
|
||||
|
||||
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
|
||||
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
|
||||
/// doesn't expose that path (e.g. a peer on an older build with a stricter
|
||||
/// `is_peer_allowed_path`), and `5xx` is a server-side error — both are
|
||||
/// worth retrying over Tor, which reaches a different (less-filtered) route.
|
||||
/// Success, redirects, and other 4xx (auth / bad request) are authoritative
|
||||
/// and are returned as-is so we neither mask real errors nor double latency.
|
||||
fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
|
||||
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
|
||||
}
|
||||
|
||||
/// DNS suffix appended to a peer's bech32 npub.
|
||||
pub const FIPS_DNS_SUFFIX: &str = "fips";
|
||||
|
||||
@@ -82,17 +93,71 @@ pub async fn peer_base_url(npub: &str) -> Result<String> {
|
||||
Ok(format!("http://[{}]:{}", ip, PEER_PORT))
|
||||
}
|
||||
|
||||
/// Build an HTTP client tuned for FIPS peer-to-peer dialing. No proxy,
|
||||
/// short timeout — fall back to Tor on failure.
|
||||
/// Build an HTTP client tuned for FIPS peer-to-peer dialing. No proxy.
|
||||
/// `connect_timeout` is generous enough to let NAT hole-punching complete on
|
||||
/// the first dial (FIPS is UDP hole-punched; the path often isn't established
|
||||
/// until the first packets flow), so a reachable-but-cold peer isn't abandoned
|
||||
/// to Tor prematurely. Reliability over latency — FIPS is the preferred path.
|
||||
pub fn client() -> reqwest::Client {
|
||||
client_with_timeout(Duration::from_secs(20))
|
||||
}
|
||||
|
||||
/// FIPS client with a caller-chosen overall request timeout. The static 20s
|
||||
/// `client()` budget is fine for catalog browses and short calls, but a large
|
||||
/// content download (#38) needs the per-request timeout the caller asked for —
|
||||
/// otherwise a 178MB transfer is aborted at 20s and the whole download fails
|
||||
/// before the Tor fallback ever gets a chance. The generous `connect_timeout`
|
||||
/// is preserved so a cold hole-punched path still gets time to establish.
|
||||
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.timeout(timeout)
|
||||
.connect_timeout(Duration::from_secs(8))
|
||||
.user_agent("archipelago-fips/1")
|
||||
.build()
|
||||
.expect("static reqwest client config")
|
||||
}
|
||||
|
||||
/// Send a FIPS request with ONE retry on a connect/timeout error.
|
||||
///
|
||||
/// The first dial to a peer typically triggers NAT hole-punching and can time
|
||||
/// out before the overlay path is established; a quick retry then lands on the
|
||||
/// now-warm path. Without this, a single cold-path failure drops the call to
|
||||
/// Tor even though the peer is FIPS-reachable — the main reason FIPS "isn't
|
||||
/// robust". Only connect/timeout errors are retried (a real HTTP response,
|
||||
/// including 4xx/5xx, is returned as-is for the caller to interpret).
|
||||
async fn send_with_retry(rb: reqwest::RequestBuilder) -> Result<reqwest::Response, reqwest::Error> {
|
||||
let retry = rb.try_clone();
|
||||
match rb.send().await {
|
||||
Ok(resp) => Ok(resp),
|
||||
Err(e) if (e.is_connect() || e.is_timeout()) && retry.is_some() => {
|
||||
// Brief pause so the hole-punch packets from the first attempt can
|
||||
// traverse before we re-dial onto the warmed path.
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
retry.expect("retry builder present").send().await
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Proactively warm the hole-punched FIPS path to a peer: resolve its overlay
|
||||
/// address and open a short connection to its peer listener. Hole-punched
|
||||
/// paths and NAT mappings go cold after ~30-60s of no traffic, after which the
|
||||
/// next real dial pays the full re-punch cost and often falls back to Tor.
|
||||
/// Keeping the path warm is what makes FIPS the transport that actually gets
|
||||
/// used. Best-effort: any error (peer offline, UDP blocked) is ignored — the
|
||||
/// connection attempt itself is what re-punches and refreshes the path.
|
||||
pub async fn warm_path(npub: &str) {
|
||||
if !is_service_active().await {
|
||||
return;
|
||||
}
|
||||
let Ok(base) = peer_base_url(npub).await else {
|
||||
return;
|
||||
};
|
||||
let c = client();
|
||||
// The response status is irrelevant; establishing the connection warms it.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(8), c.get(&base).send()).await;
|
||||
}
|
||||
|
||||
// ── DNS wire-format helpers ─────────────────────────────────────────────
|
||||
|
||||
fn encode_query(id: u16, npub: &str) -> Result<Vec<u8>> {
|
||||
@@ -294,13 +359,22 @@ impl<'a> PeerRequest<'a> {
|
||||
let pref = self.preference().await;
|
||||
// FIPS-only or Auto: try FIPS first.
|
||||
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
||||
if let Some(resp) = self.try_fips_post_json(body).await? {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
match self.try_fips_post_json(body).await? {
|
||||
Some(resp) => {
|
||||
// Use the FIPS reply unless it's one a Tor retry could
|
||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||
// fall back. FIPS-only never falls back.
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let resp = self.send_tor_post_json(body).await?;
|
||||
@@ -312,13 +386,19 @@ impl<'a> PeerRequest<'a> {
|
||||
use crate::settings::transport::TransportPref;
|
||||
let pref = self.preference().await;
|
||||
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
||||
if let Some(resp) = self.try_fips_get().await? {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
match self.try_fips_get().await? {
|
||||
Some(resp) => {
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if pref == TransportPref::Fips {
|
||||
anyhow::bail!(
|
||||
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let resp = self.send_tor_get().await?;
|
||||
@@ -343,15 +423,19 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client();
|
||||
let c = client_with_timeout(self.timeout);
|
||||
let mut rb = c.post(&url).json(body);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match rb.send().await {
|
||||
match send_with_retry(rb).await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
tracing::debug!("FIPS POST {} failed: {}, falling back to Tor", url, e);
|
||||
tracing::debug!(
|
||||
"FIPS POST {} failed after retry: {}, falling back to Tor",
|
||||
url,
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -372,15 +456,19 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client();
|
||||
let c = client_with_timeout(self.timeout);
|
||||
let mut rb = c.get(&url);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match rb.send().await {
|
||||
match send_with_retry(rb).await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
tracing::debug!("FIPS GET {} failed: {}, falling back to Tor", url, e);
|
||||
tracing::debug!(
|
||||
"FIPS GET {} failed after retry: {}, falling back to Tor",
|
||||
url,
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,63 @@ pub mod service;
|
||||
pub mod update;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Auto-activate FIPS with no user interaction. Once seed onboarding has
|
||||
/// materialised the fips key, install the daemon config + start the service if
|
||||
/// it isn't already up. Idempotent and best-effort: FIPS is the preferred
|
||||
/// transport and should come up on its own — the UI "Activate" button is now a
|
||||
/// manual fallback, not a requirement. No-op pre-onboarding (no key yet) or
|
||||
/// when the service is already active.
|
||||
pub async fn ensure_activated(data_dir: &std::path::Path) {
|
||||
let identity_dir = identity_dir_from(data_dir);
|
||||
if !identity_dir.join("fips_key").exists() {
|
||||
return; // pre-onboarding: nothing to activate yet
|
||||
}
|
||||
if dial::is_service_active().await {
|
||||
return; // already up
|
||||
}
|
||||
tracing::info!("FIPS inactive — auto-activating (no user interaction needed)");
|
||||
if let Err(e) = config::install(&identity_dir).await {
|
||||
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = service::activate(SERVICE_UNIT).await {
|
||||
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
|
||||
return;
|
||||
}
|
||||
tracing::info!("FIPS auto-activated");
|
||||
}
|
||||
|
||||
/// Spawn the FIPS supervisor: every 45s it (1) auto-activates FIPS if onboarding
|
||||
/// is done but the service is down — so it comes up with zero user interaction,
|
||||
/// and (2) keeps hole-punched paths to known federation peers warm, so on-demand
|
||||
/// dials land on FIPS instead of falling back to Tor. Warms peers concurrently
|
||||
/// so one slow/offline peer doesn't delay the rest.
|
||||
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(45));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
// Bring FIPS up on its own once onboarding has materialised the key.
|
||||
ensure_activated(&data_dir).await;
|
||||
if !dial::is_service_active().await {
|
||||
continue;
|
||||
}
|
||||
let nodes = crate::federation::load_nodes(&data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut handles = Vec::new();
|
||||
for node in nodes {
|
||||
if let Some(npub) = node.fips_npub.clone() {
|
||||
handles.push(tokio::spawn(async move { dial::warm_path(&npub).await }));
|
||||
}
|
||||
}
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Systemd unit name supervised by archipelago.
|
||||
|
||||
@@ -1,156 +1,401 @@
|
||||
//! User-triggered FIPS upgrade from the upstream default branch.
|
||||
//! User-triggered FIPS upgrade from upstream GitHub releases.
|
||||
//!
|
||||
//! Flow (no auto-update, no background polling — user clicks a button):
|
||||
//! 1. Query GitHub for the upstream repo's default branch, then the
|
||||
//! latest commit on it. (jmcorgan/fips default is `master`, not
|
||||
//! `main` — we resolve it dynamically so a future rename Just Works.)
|
||||
//! 2. Compare with the installed daemon version reported by
|
||||
//! `fipsctl --version`. If identical, report "up to date".
|
||||
//! 3. Fetch the built .deb artefact for that commit + its SHA256.
|
||||
//! 4. SHA256-verify the download.
|
||||
//! 5. `sudo dpkg -i` the .deb, `sudo systemctl restart` the service.
|
||||
//! 1. Query GitHub for the latest *stable* release of `jmcorgan/fips`
|
||||
//! (`/releases/latest` returns the newest non-prerelease, non-draft
|
||||
//! tag, so release candidates like `v0.4.0-rc1` are skipped).
|
||||
//! 2. Compare its tag (e.g. `v0.3.0`) with the installed daemon version
|
||||
//! reported by `fipsctl --version`. A dev/pre-release build of the
|
||||
//! same number (`0.3.0-dev`) counts as older than the released tag.
|
||||
//! 3. Pick the Debian package asset matching the host architecture
|
||||
//! (`fips_<ver>_amd64.deb` / `_arm64.deb`) plus `checksums-linux.txt`.
|
||||
//! 4. Download both, SHA256-verify the .deb against the checksums file.
|
||||
//! 5. `sudo dpkg -i` the verified .deb, then restart the active fips unit.
|
||||
//!
|
||||
//! The artefact URL / SHA256 source is not yet fixed — upstream doesn't
|
||||
//! publish stable release assets for per-commit builds. This module
|
||||
//! currently implements steps 1–2 (the "is there anything newer?" query)
|
||||
//! and stubs out 3–5 so the RPC/UI can wire through. The apply path
|
||||
//! returns a clear "not yet available" error until the artefact source
|
||||
//! is decided.
|
||||
//! Upstream began publishing tagged releases with `.deb` artefacts and
|
||||
//! `checksums-linux.txt` (verified present as of v0.1.0 → v0.4.0-rc1), so
|
||||
//! the apply path is fully wired against those assets.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{service, UPSTREAM_REPO};
|
||||
|
||||
const GITHUB_API: &str = "https://api.github.com";
|
||||
const USER_AGENT: &str = "archipelago-fips-updater";
|
||||
|
||||
/// Result of `check_update()` — what the dashboard renders.
|
||||
/// Result of `check()` — what the dashboard renders.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateCheck {
|
||||
/// Currently installed daemon version (from `fipsctl --version`).
|
||||
pub current: Option<String>,
|
||||
/// Short SHA of the latest commit on upstream `main`.
|
||||
pub latest_commit: String,
|
||||
/// True when the installed version string does not mention the latest SHA.
|
||||
/// Tag of the latest stable upstream release, e.g. `v0.3.0`.
|
||||
pub latest_version: String,
|
||||
/// True when the installed version is older than `latest_version`.
|
||||
pub update_available: bool,
|
||||
/// Release channel this check tracked. Currently always "stable".
|
||||
pub channel: String,
|
||||
/// Browser download URL of the architecture-matched .deb for the
|
||||
/// latest release, when one exists (informational; apply() re-resolves).
|
||||
pub asset_url: Option<String>,
|
||||
/// Human-readable note for the UI.
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// Query GitHub for the latest commit on the upstream default branch and
|
||||
/// compare to the installed version. Never errors on "no package installed"
|
||||
/// — that is itself a valid state where an update is available.
|
||||
/// One GitHub release as we consume it.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Release {
|
||||
tag_name: String,
|
||||
#[serde(default)]
|
||||
prerelease: bool,
|
||||
#[serde(default)]
|
||||
draft: bool,
|
||||
#[serde(default)]
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
fn http_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Build HTTP client")
|
||||
}
|
||||
|
||||
/// Debian architecture string for the host (`amd64` / `arm64`). Returns
|
||||
/// the raw `std::env::consts::ARCH` for anything we don't map, so the
|
||||
/// asset lookup simply finds nothing and surfaces a clear error.
|
||||
fn deb_arch() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Query GitHub for the latest stable release and compare to the installed
|
||||
/// version. Never errors on "no package installed" — that is itself a valid
|
||||
/// state where an update is available.
|
||||
pub async fn check() -> Result<UpdateCheck> {
|
||||
let current = service::daemon_version().await.ok();
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.context("Build HTTP client")?;
|
||||
let branch = fetch_default_branch(&client).await?;
|
||||
let latest = fetch_head_sha(&client, &branch).await?;
|
||||
let short = latest.chars().take(7).collect::<String>();
|
||||
let client = http_client()?;
|
||||
let release = fetch_latest_stable(&client).await?;
|
||||
|
||||
let update_available = match ¤t {
|
||||
Some(v) => !v.contains(&short),
|
||||
Some(v) => version_is_older(v, &release.tag_name),
|
||||
None => true,
|
||||
};
|
||||
|
||||
let asset_url = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| is_deb_for_arch(&a.name))
|
||||
.map(|a| a.browser_download_url.clone());
|
||||
|
||||
let notes = if update_available {
|
||||
format!(
|
||||
"Upstream {} is at {}; installed: {}",
|
||||
branch,
|
||||
short,
|
||||
"Update available: {} (installed: {})",
|
||||
release.tag_name,
|
||||
current.as_deref().unwrap_or("not installed")
|
||||
)
|
||||
} else {
|
||||
format!("Up to date ({} @ {})", branch, short)
|
||||
format!("Up to date ({})", release.tag_name)
|
||||
};
|
||||
|
||||
Ok(UpdateCheck {
|
||||
current,
|
||||
latest_commit: short,
|
||||
latest_version: release.tag_name,
|
||||
update_available,
|
||||
channel: "stable".to_string(),
|
||||
asset_url,
|
||||
notes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the update. Stubbed pending a stable artefact source for
|
||||
/// per-commit builds of the `fips` debian package. When this is wired
|
||||
/// up it must: download → SHA256-verify → `sudo dpkg -i` → restart.
|
||||
/// Download, verify, and install the latest stable FIPS release, then
|
||||
/// restart the daemon. Steps: resolve release → match .deb for this arch
|
||||
/// → download .deb + checksums → SHA256-verify → `sudo dpkg -i` → restart.
|
||||
pub async fn apply() -> Result<()> {
|
||||
anyhow::bail!(
|
||||
"FIPS auto-apply not yet wired — upstream does not publish stable \
|
||||
per-commit .deb artefacts for main. Upgrade manually for now: \
|
||||
`git pull && cargo deb && sudo dpkg -i target/debian/fips_*.deb`."
|
||||
)
|
||||
}
|
||||
let client = http_client()?;
|
||||
let release = fetch_latest_stable(&client).await?;
|
||||
|
||||
async fn fetch_default_branch(client: &reqwest::Client) -> Result<String> {
|
||||
let url = format!("{}/repos/{}", GITHUB_API, UPSTREAM_REPO);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
let deb = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| is_deb_for_arch(&a.name))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"release {} has no .deb for architecture {}",
|
||||
release.tag_name,
|
||||
deb_arch()
|
||||
)
|
||||
})?;
|
||||
let checksums = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == "checksums-linux.txt")
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("release {} has no checksums-linux.txt", release.tag_name)
|
||||
})?;
|
||||
|
||||
// Download the .deb (bytes) and the checksums (text).
|
||||
let deb_bytes = client
|
||||
.get(&deb.browser_download_url)
|
||||
.send()
|
||||
.await
|
||||
.context("GitHub repo API")?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("GitHub repo API returned {}", resp.status());
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.context("Parse repo JSON")?;
|
||||
body.get("default_branch")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitHub repo response missing default_branch"))
|
||||
}
|
||||
|
||||
async fn fetch_head_sha(client: &reqwest::Client, branch: &str) -> Result<String> {
|
||||
let url = format!("{}/repos/{}/commits/{}", GITHUB_API, UPSTREAM_REPO, branch);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.context("download .deb")?
|
||||
.error_for_status()
|
||||
.context(".deb download HTTP error")?
|
||||
.bytes()
|
||||
.await
|
||||
.context("read .deb body")?;
|
||||
let checksums_text = client
|
||||
.get(&checksums.browser_download_url)
|
||||
.send()
|
||||
.await
|
||||
.context("GitHub commits API")?;
|
||||
if !resp.status().is_success() {
|
||||
.context("download checksums")?
|
||||
.error_for_status()
|
||||
.context("checksums download HTTP error")?
|
||||
.text()
|
||||
.await
|
||||
.context("read checksums body")?;
|
||||
|
||||
// Verify SHA256 against the checksums manifest (sha256sum format:
|
||||
// "<hex>␠␠<filename>"). The filename column may include a leading
|
||||
// "*" (binary mode) or a path prefix, so match on the basename.
|
||||
let expected = checksums_text
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.split_whitespace();
|
||||
let hash = parts.next()?;
|
||||
let name = parts.next()?.trim_start_matches('*');
|
||||
let base = name.rsplit('/').next().unwrap_or(name);
|
||||
(base == deb.name).then(|| hash.to_lowercase())
|
||||
})
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("checksums-linux.txt has no entry for {}", deb.name))?;
|
||||
|
||||
let actual = {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&deb_bytes);
|
||||
hex::encode(hasher.finalize())
|
||||
};
|
||||
if actual != expected {
|
||||
anyhow::bail!(
|
||||
"GitHub commits API returned {} for branch {}",
|
||||
resp.status(),
|
||||
branch
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
deb.name,
|
||||
expected,
|
||||
actual
|
||||
);
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.context("Parse commits JSON")?;
|
||||
body.get("sha")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field"))
|
||||
|
||||
// Stage the verified .deb in /tmp (shared with the host — the
|
||||
// service runs with PrivateTmp=no) and install it.
|
||||
let dest = std::env::temp_dir().join(&deb.name);
|
||||
tokio::fs::write(&dest, &deb_bytes)
|
||||
.await
|
||||
.with_context(|| format!("write {}", dest.display()))?;
|
||||
|
||||
// Run dpkg via `systemd-run` rather than `sudo dpkg` directly. The
|
||||
// archipelago service runs under `ProtectSystem=strict`, so `/usr`
|
||||
// and `/var/lib/dpkg` are read-only *inside the service's mount
|
||||
// namespace* — and a `sudo` child inherits that namespace, so a
|
||||
// bare `sudo dpkg -i` fails with "Read-only file system" on the
|
||||
// dpkg database. `systemd-run` asks PID 1 to launch the command in
|
||||
// a fresh transient scope outside our sandbox, where the real
|
||||
// (writable) host filesystem is visible. `--wait` blocks until it
|
||||
// finishes and propagates the exit status; `--pipe` forwards
|
||||
// dpkg's output; `--collect` reaps the unit even on failure.
|
||||
//
|
||||
// dpkg flags, both load-bearing for this package specifically:
|
||||
// --force-confold: the fips package ships conffiles under
|
||||
// /etc/fips that archipelago rewrites at install time, so dpkg
|
||||
// hits an interactive "keep/replace?" conffile prompt. With our
|
||||
// closed stdin that aborts the configure step ("EOF on stdin at
|
||||
// conffile prompt") and leaves the package half-unpacked
|
||||
// (status `iU`), which `fips.status` then reports as
|
||||
// `installed:false`. confold = keep our managed config, no prompt.
|
||||
// --force-downgrade: ISO/dev nodes carry `0.3.0-dev-1`, which dpkg
|
||||
// orders as NEWER than the stable tag `0.3.0` (a trailing
|
||||
// `-dev` sorts above the bare release). Moving a dev build onto
|
||||
// the stable line is therefore a dpkg "downgrade"; without this
|
||||
// flag dpkg warns and exits non-zero. Our own version_is_older()
|
||||
// gate already decided this is the wanted direction.
|
||||
// DEBIAN_FRONTEND=noninteractive belt-and-suspenders against any
|
||||
// other maintainer-script prompt.
|
||||
let dpkg = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"systemd-run",
|
||||
"--collect",
|
||||
"--wait",
|
||||
"--quiet",
|
||||
"--pipe",
|
||||
"--",
|
||||
"env",
|
||||
"DEBIAN_FRONTEND=noninteractive",
|
||||
"dpkg",
|
||||
"--force-confold",
|
||||
"--force-downgrade",
|
||||
"-i",
|
||||
])
|
||||
.arg(&dest)
|
||||
.output()
|
||||
.await
|
||||
.context("sudo systemd-run dpkg -i failed to launch")?;
|
||||
// Best-effort cleanup regardless of dpkg result.
|
||||
let _ = tokio::fs::remove_file(&dest).await;
|
||||
if !dpkg.status.success() {
|
||||
anyhow::bail!(
|
||||
"dpkg -i {} exited {}: {}",
|
||||
deb.name,
|
||||
dpkg.status,
|
||||
String::from_utf8_lossy(&dpkg.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
// Restart whichever fips unit is supervising the daemon so the new
|
||||
// binary takes over.
|
||||
let unit = service::active_unit().await;
|
||||
service::restart(unit)
|
||||
.await
|
||||
.with_context(|| format!("restart {} after install", unit))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `/releases/latest` returns the most recent non-prerelease, non-draft
|
||||
/// release. We still re-check the flags defensively in case the endpoint
|
||||
/// or repo settings change.
|
||||
async fn fetch_latest_stable(client: &reqwest::Client) -> Result<Release> {
|
||||
let url = format!("{}/repos/{}/releases/latest", GITHUB_API, UPSTREAM_REPO);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
.context("GitHub releases/latest API")?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("GitHub releases/latest API returned {}", resp.status());
|
||||
}
|
||||
let release: Release = resp.json().await.context("Parse release JSON")?;
|
||||
if release.draft || release.prerelease {
|
||||
anyhow::bail!(
|
||||
"releases/latest returned a {} release ({})",
|
||||
if release.draft { "draft" } else { "prerelease" },
|
||||
release.tag_name
|
||||
);
|
||||
}
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
fn is_deb_for_arch(name: &str) -> bool {
|
||||
name.starts_with("fips_") && name.ends_with(&format!("_{}.deb", deb_arch()))
|
||||
}
|
||||
|
||||
/// Parse the leading `MAJOR.MINOR.PATCH` triple from a version string,
|
||||
/// plus whether a pre-release suffix (`-dev`, `-rc1`, …) follows it.
|
||||
fn parse_version(s: &str) -> Option<((u64, u64, u64), bool)> {
|
||||
// Take the first whitespace token, drop a leading 'v'.
|
||||
let tok = s.split_whitespace().next().unwrap_or(s);
|
||||
let tok = tok.strip_prefix('v').unwrap_or(tok);
|
||||
// Split off any pre-release / build suffix.
|
||||
let (core, rest) = match tok.find(|c: char| c == '-' || c == '+') {
|
||||
Some(i) => (&tok[..i], &tok[i..]),
|
||||
None => (tok, ""),
|
||||
};
|
||||
let mut it = core.split('.');
|
||||
let major = it.next()?.parse::<u64>().ok()?;
|
||||
let minor = it.next().unwrap_or("0").parse::<u64>().ok()?;
|
||||
let patch = it.next().unwrap_or("0").parse::<u64>().ok()?;
|
||||
let has_prerelease = rest.starts_with('-');
|
||||
Some(((major, minor, patch), has_prerelease))
|
||||
}
|
||||
|
||||
/// True when `installed` is strictly older than release tag `latest`.
|
||||
/// Same numeric triple but `installed` carries a pre-release suffix while
|
||||
/// `latest` doesn't ⇒ installed is older (e.g. `0.3.0-dev` < `v0.3.0`).
|
||||
/// If either side can't be parsed, fall back to "differs ⇒ update".
|
||||
fn version_is_older(installed: &str, latest: &str) -> bool {
|
||||
match (parse_version(installed), parse_version(latest)) {
|
||||
(Some((ic, ipre)), Some((lc, lpre))) => {
|
||||
if ic != lc {
|
||||
ic < lc
|
||||
} else {
|
||||
// Equal cores: a pre-release is older than the final release.
|
||||
ipre && !lpre
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unparseable: be conservative — offer the update unless the
|
||||
// installed string already mentions the latest tag.
|
||||
!installed.contains(latest.trim_start_matches('v'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_returns_clear_stub_error() {
|
||||
let err = apply().await.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("not yet wired"),
|
||||
"apply() should return an explicit not-yet-wired error, got: {}",
|
||||
err
|
||||
);
|
||||
#[test]
|
||||
fn test_deb_arch_maps_known() {
|
||||
// On the host running tests this is whatever the test arch is;
|
||||
// just assert it returns a non-empty, lowercase token.
|
||||
let a = deb_arch();
|
||||
assert!(!a.is_empty());
|
||||
assert_eq!(a, a.to_lowercase());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_older() {
|
||||
assert!(version_is_older("0.3.0-dev (rev abc123)", "v0.3.0"));
|
||||
assert!(version_is_older("0.2.1", "v0.3.0"));
|
||||
assert!(version_is_older("0.3.0-rc1", "v0.3.0"));
|
||||
assert!(!version_is_older("0.3.0", "v0.3.0"));
|
||||
assert!(!version_is_older("0.4.0", "v0.3.0"));
|
||||
assert!(!version_is_older("0.3.1", "v0.3.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version() {
|
||||
assert_eq!(parse_version("v0.3.0"), Some(((0, 3, 0), false)));
|
||||
assert_eq!(parse_version("0.3.0-dev (rev x)"), Some(((0, 3, 0), true)));
|
||||
assert_eq!(parse_version("0.4.0-rc1"), Some(((0, 4, 0), true)));
|
||||
assert_eq!(parse_version("1.2"), Some(((1, 2, 0), false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_deb_for_arch() {
|
||||
let arch = deb_arch();
|
||||
assert!(is_deb_for_arch(&format!("fips_0.3.0_{}.deb", arch)));
|
||||
assert!(!is_deb_for_arch("fips_0.3.0_someotherarch.deb"));
|
||||
assert!(!is_deb_for_arch("checksums-linux.txt"));
|
||||
assert!(!is_deb_for_arch(&format!(
|
||||
"fips-0.3.0-linux-{}.tar.gz",
|
||||
arch
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_check_serialises() {
|
||||
let uc = UpdateCheck {
|
||||
current: Some("0.2.0-abc1234".to_string()),
|
||||
latest_commit: "def5678".to_string(),
|
||||
current: Some("0.3.0-dev".to_string()),
|
||||
latest_version: "v0.3.0".to_string(),
|
||||
update_available: true,
|
||||
channel: "stable".to_string(),
|
||||
asset_url: Some("https://example/fips_0.3.0_amd64.deb".to_string()),
|
||||
notes: "test".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&uc).unwrap();
|
||||
assert!(json.contains("latest_commit"));
|
||||
assert!(json.contains("latest_version"));
|
||||
assert!(json.contains("update_available"));
|
||||
assert!(json.contains("stable"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,8 +76,12 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
|
||||
"fedimint" => &["bitcoin"],
|
||||
"fedimint-gateway" => &["bitcoin", "fedimint"],
|
||||
|
||||
// IndeedHub stack
|
||||
"indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis"],
|
||||
// IndeedHub stack. The API needs MinIO (object storage) up before it
|
||||
// can serve — without listing it the health monitor would restart the
|
||||
// API while MinIO was still coming up, which is the "needs 1-2 restarts
|
||||
// to recover" symptom (#41). MinIO has no deps of its own, so the
|
||||
// monitor restarts it independently first; no deadlock.
|
||||
"indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis", "indeedhub-minio"],
|
||||
"indeedhub" => &["indeedhub-api"],
|
||||
"indeedhub-relay" => &["indeedhub-postgres"],
|
||||
"indeedhub-ffmpeg" => &["indeedhub-api"],
|
||||
|
||||
@@ -33,9 +33,12 @@ mod bitcoin_rpc;
|
||||
mod bitcoin_status;
|
||||
mod blobs;
|
||||
mod bootstrap;
|
||||
mod ceremony;
|
||||
mod config;
|
||||
mod constants;
|
||||
mod container;
|
||||
mod content_hash;
|
||||
mod content_invoice;
|
||||
mod content_server;
|
||||
mod crash_recovery;
|
||||
mod credentials;
|
||||
@@ -64,9 +67,12 @@ mod server;
|
||||
mod session;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod storage_crypto;
|
||||
mod streaming;
|
||||
mod swarm;
|
||||
mod totp;
|
||||
mod transport;
|
||||
mod trust;
|
||||
mod update;
|
||||
mod vpn;
|
||||
mod wallet;
|
||||
@@ -81,6 +87,13 @@ use server::Server;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Release-root signing ceremony: a publisher-side subcommand of the same
|
||||
// binary. Handle it before any server/tracing init so its stdout stays
|
||||
// clean (machine-readable KEY=VALUE lines) and it never touches node state.
|
||||
if ceremony::is_ceremony_invocation() {
|
||||
return ceremony::run();
|
||||
}
|
||||
|
||||
let startup_start = std::time::Instant::now();
|
||||
crash_recovery::init_start_time();
|
||||
|
||||
@@ -271,6 +284,15 @@ async fn main() -> Result<()> {
|
||||
// delays server readiness; best-effort, warnings only.
|
||||
tokio::spawn(bootstrap::ensure_doctor_installed());
|
||||
|
||||
// B17: heal already-deployed nodes whose archipelago.service lacks a mount
|
||||
// dependency on the data volume, so cold boots stop flapping. Boot-ordering
|
||||
// only — effective next reboot; never restarts the running service.
|
||||
tokio::spawn(bootstrap::ensure_archipelago_mount_ordering());
|
||||
|
||||
// #36: keep the kiosk unit + launcher hardened (CPU/mem cap + GPU-vs-headless
|
||||
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
|
||||
tokio::spawn(bootstrap::ensure_kiosk_hardened());
|
||||
|
||||
// Spawn periodic container snapshot (for crash recovery)
|
||||
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
|
||||
|
||||
@@ -291,6 +313,31 @@ async fn main() -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Periodically restart crashed multi-container stack members (immich,
|
||||
// indeedhub, …) at RUNTIME, not just at boot. The health monitor skips them
|
||||
// as "orphans" because the sub-container app_ids (e.g. immich_server) aren't
|
||||
// in package_data, so without this a crashed immich_server / indeedhub-api
|
||||
// never comes back until the next reboot (#16/#17). Reuses the boot
|
||||
// recovery, which cheaply skips already-running containers and respects the
|
||||
// user-stopped list, so this only acts on genuinely-down stack members.
|
||||
{
|
||||
let data_dir = config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(120));
|
||||
tick.tick().await; // consume the immediate tick; boot recovery covers t0
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let report = crash_recovery::start_stopped_stack_containers(&data_dir).await;
|
||||
if report.recovered > 0 {
|
||||
info!(
|
||||
"🔄 Stack supervisor: restarted {} crashed stack member(s) (failed: {:?})",
|
||||
report.recovered, report.failed
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn disk space monitor (warns at 85%, auto-cleans at 90%)
|
||||
disk_monitor::spawn_disk_monitor(config.data_dir.clone());
|
||||
|
||||
@@ -306,6 +353,11 @@ async fn main() -> Result<()> {
|
||||
electrs_status::spawn_status_cache();
|
||||
bitcoin_status::spawn_status_cache();
|
||||
|
||||
// FIPS supervisor: auto-activate FIPS after onboarding (no Activate button
|
||||
// needed) and keep hole-punched paths to federation peers warm so peer dials
|
||||
// land on FIPS (the preferred transport) instead of falling back to Tor.
|
||||
fips::spawn_fips_supervisor(config.data_dir.clone());
|
||||
|
||||
let startup_ms = startup_start.elapsed().as_millis();
|
||||
info!(
|
||||
"Server listening on http://{} (startup: {}ms)",
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Mesh-AI assistant (issue #50) — answers `AssistQuery` messages with this
|
||||
//! node's local LLM and sends the reply back over the mesh.
|
||||
//!
|
||||
//! This is the Rust-native lift of Meshroller's "LLM bridge": a trusted peer
|
||||
//! asks a question over meshcore, an internet/compute-bearing node runs it
|
||||
//! through a local model (Ollama) and streams the answer back in capped,
|
||||
//! ordered chunks. Airtime is scarce, so the reply is length-capped and each
|
||||
//! asker is limited to one in-flight query.
|
||||
|
||||
use super::super::message_types::{self, AssistResponsePayload, MeshMessageType};
|
||||
use super::bitcoin::send_to_peer;
|
||||
use super::{MeshCommand, MeshState};
|
||||
use crate::federation::TrustLevel;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Local Ollama generate endpoint (same host the Ollama app binds).
|
||||
const OLLAMA_URL: &str = "http://localhost:11434/api/generate";
|
||||
/// Default model when the node hasn't configured one (matches Meshroller).
|
||||
const DEFAULT_MODEL: &str = "qwen2.5-coder";
|
||||
/// Anthropic Messages API (called with the shared proxy token).
|
||||
const CLAUDE_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
/// Default Claude model — Haiku 4.5: fast + cheap, ideal for short mesh answers.
|
||||
const CLAUDE_DEFAULT_MODEL: &str = "claude-haiku-4-5-20251001";
|
||||
/// Max time to wait on the model before giving up.
|
||||
const OLLAMA_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// Hard cap on answer length sent over the radio — keeps airtime sane.
|
||||
const MAX_REPLY_CHARS: usize = 480;
|
||||
/// Characters of answer text per `AssistResponse` chunk.
|
||||
const CHUNK_CHARS: usize = 160;
|
||||
/// Tighter cap for plain-text channel replies (bare `!ai` clients) — these
|
||||
/// aren't reassembled by an archipelago UI, so keep them to a couple frames.
|
||||
const CHANNEL_REPLY_CHARS: usize = 200;
|
||||
|
||||
/// Where an answer should go.
|
||||
pub(super) enum AssistReply {
|
||||
/// Typed `AssistResponse` chunks addressed to one peer — the archipelago
|
||||
/// UI path (rich, reassembled, correlated by `req_id`).
|
||||
Typed { contact_id: u32 },
|
||||
/// Plain-text broadcast on a mesh channel — the bare `!ai` path, so any
|
||||
/// client (including non-archipelago meshcore/Meshtastic nodes) sees it.
|
||||
ChannelText { channel: u8 },
|
||||
}
|
||||
|
||||
/// Entry point: gate the query, run the model, send the answer back via the
|
||||
/// requested reply path. Spawned off the radio loop so it never blocks.
|
||||
pub(super) async fn run_assist(
|
||||
prompt: String,
|
||||
model_override: Option<String>,
|
||||
req_id: u64,
|
||||
asker_contact_id: u32,
|
||||
sender_name: String,
|
||||
reply: AssistReply,
|
||||
state: Arc<MeshState>,
|
||||
) {
|
||||
let asker = asker_contact_id;
|
||||
|
||||
// Trust + block gate.
|
||||
if !is_sender_allowed(&state, asker).await {
|
||||
warn!(
|
||||
from = asker,
|
||||
name = %sender_name,
|
||||
"AssistQuery denied — sender not permitted by assistant policy"
|
||||
);
|
||||
// Silent on the wire (no airtime spent on denials); surface to the UI.
|
||||
let _ = state
|
||||
.event_tx
|
||||
.send(super::super::types::MeshEvent::AssistResponseReady {
|
||||
req_id,
|
||||
to_contact_id: asker,
|
||||
error: Some("denied".to_string()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// One in-flight query per asker.
|
||||
{
|
||||
let mut inflight = state.assist_inflight.write().await;
|
||||
if !inflight.insert(asker) {
|
||||
warn!(
|
||||
from = asker,
|
||||
"AssistQuery dropped — asker already has one in flight"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = state
|
||||
.event_tx
|
||||
.send(super::super::types::MeshEvent::AssistQueryReceived {
|
||||
from_contact_id: asker,
|
||||
prompt: prompt.clone(),
|
||||
});
|
||||
|
||||
let (backend, configured_model) = {
|
||||
let a = state.assistant.read().await;
|
||||
(a.backend.clone(), a.model.clone())
|
||||
};
|
||||
let is_claude = backend == "claude";
|
||||
let default_model = if is_claude {
|
||||
CLAUDE_DEFAULT_MODEL
|
||||
} else {
|
||||
DEFAULT_MODEL
|
||||
};
|
||||
let model = model_override
|
||||
.or(configured_model)
|
||||
.unwrap_or_else(|| default_model.to_string());
|
||||
|
||||
info!(from = asker, req_id, backend = %backend, model = %model, "Answering AI query over mesh");
|
||||
|
||||
let result = if is_claude {
|
||||
call_claude(&state.data_dir, &model, &prompt).await
|
||||
} else {
|
||||
call_ollama(&model, &prompt).await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(answer) => {
|
||||
send_reply(&state, &reply, req_id, &answer).await;
|
||||
let _ = state
|
||||
.event_tx
|
||||
.send(super::super::types::MeshEvent::AssistResponseReady {
|
||||
req_id,
|
||||
to_contact_id: asker,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(req_id, "AI query failed: {}", e);
|
||||
send_failure(&state, &reply, req_id, "AI unavailable").await;
|
||||
let _ = state
|
||||
.event_tx
|
||||
.send(super::super::types::MeshEvent::AssistResponseReady {
|
||||
req_id,
|
||||
to_contact_id: asker,
|
||||
error: Some(e.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.assist_inflight.write().await.remove(&asker);
|
||||
}
|
||||
|
||||
/// Whether `sender_contact_id` may invoke the assistant under the node's policy.
|
||||
/// Always denies user-blocked contacts. With `trusted_only`, requires a
|
||||
/// federation-Trusted match on the peer's pubkey or DID.
|
||||
async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bool {
|
||||
let (pubkey_hex, did) = {
|
||||
let peers = state.peers.read().await;
|
||||
match peers.get(&sender_contact_id) {
|
||||
Some(p) => (p.pubkey_hex.clone(), p.did.clone()),
|
||||
None => (None, None),
|
||||
}
|
||||
};
|
||||
|
||||
// Never answer a user-blocked contact, regardless of policy.
|
||||
if let Some(ref pk) = pubkey_hex {
|
||||
if state
|
||||
.contacts
|
||||
.read()
|
||||
.await
|
||||
.get(pk)
|
||||
.map(|c| c.blocked)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if !state.assistant.read().await.trusted_only {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Trusted-only: match against the federation trust list.
|
||||
let nodes = crate::federation::load_nodes(&state.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
nodes.iter().any(|n| {
|
||||
n.trust_level == TrustLevel::Trusted
|
||||
&& (Some(&n.pubkey) == pubkey_hex.as_ref() || Some(&n.did) == did.as_ref())
|
||||
})
|
||||
}
|
||||
|
||||
/// Cap the answer to `MAX_REPLY_CHARS`, appending a marker when truncated.
|
||||
/// Returns (text_to_send, was_truncated).
|
||||
fn cap_reply(answer: &str) -> (String, bool) {
|
||||
let trimmed = answer.trim();
|
||||
if trimmed.chars().count() <= MAX_REPLY_CHARS {
|
||||
return (trimmed.to_string(), false);
|
||||
}
|
||||
let capped: String = trimmed.chars().take(MAX_REPLY_CHARS).collect();
|
||||
(format!("{capped}…(truncated)"), true)
|
||||
}
|
||||
|
||||
/// Send a successful answer via the requested reply path.
|
||||
async fn send_reply(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, answer: &str) {
|
||||
match reply {
|
||||
AssistReply::Typed { contact_id } => {
|
||||
let (text, _) = cap_reply(answer);
|
||||
send_typed_chunks(state, *contact_id, req_id, &text).await;
|
||||
}
|
||||
AssistReply::ChannelText { channel } => {
|
||||
let text = cap_channel(answer);
|
||||
send_channel_text(state, *channel, &text).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a failure notice via the requested reply path.
|
||||
async fn send_failure(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, msg: &str) {
|
||||
match reply {
|
||||
AssistReply::Typed { contact_id } => {
|
||||
let payload = AssistResponsePayload {
|
||||
req_id,
|
||||
text: String::new(),
|
||||
seq: 0,
|
||||
done: true,
|
||||
error: Some(msg.to_string()),
|
||||
};
|
||||
send_typed_response(state, *contact_id, &payload).await;
|
||||
}
|
||||
AssistReply::ChannelText { channel } => {
|
||||
send_channel_text(state, *channel, &format!("AI: {msg}")).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split the answer into ordered `AssistResponse` chunks and send each back to
|
||||
/// the asker on the encrypted, peer-addressed path (archipelago UI path).
|
||||
async fn send_typed_chunks(state: &Arc<MeshState>, dest_contact_id: u32, req_id: u64, text: &str) {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let chunks: Vec<String> = if chars.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
chars
|
||||
.chunks(CHUNK_CHARS)
|
||||
.map(|c| c.iter().collect())
|
||||
.collect()
|
||||
};
|
||||
let last = chunks.len().saturating_sub(1);
|
||||
for (i, chunk) in chunks.into_iter().enumerate() {
|
||||
let payload = AssistResponsePayload {
|
||||
req_id,
|
||||
text: chunk,
|
||||
seq: i as u16,
|
||||
done: i == last,
|
||||
error: None,
|
||||
};
|
||||
send_typed_response(state, dest_contact_id, &payload).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode an `AssistResponse` payload and send it to a peer.
|
||||
async fn send_typed_response(
|
||||
state: &Arc<MeshState>,
|
||||
dest_contact_id: u32,
|
||||
payload: &AssistResponsePayload,
|
||||
) {
|
||||
let bytes = match message_types::encode_payload(payload) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("Failed to encode AssistResponse: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let envelope = message_types::TypedEnvelope::new(MeshMessageType::AssistResponse, bytes);
|
||||
match envelope.to_wire() {
|
||||
Ok(wire) => send_to_peer(state, dest_contact_id, wire).await,
|
||||
Err(e) => warn!("Failed to encode AssistResponse envelope: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcast a plain-text answer on a channel for bare `!ai` clients.
|
||||
async fn send_channel_text(state: &Arc<MeshState>, channel: u8, text: &str) {
|
||||
let _ = state
|
||||
.send_cmd(MeshCommand::BroadcastChannel {
|
||||
channel,
|
||||
payload: text.as_bytes().to_vec(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Cap a plain-text channel reply to a couple of frames.
|
||||
fn cap_channel(answer: &str) -> String {
|
||||
let trimmed = answer.trim();
|
||||
if trimmed.chars().count() <= CHANNEL_REPLY_CHARS {
|
||||
return format!("AI: {trimmed}");
|
||||
}
|
||||
let capped: String = trimmed.chars().take(CHANNEL_REPLY_CHARS).collect();
|
||||
format!("AI: {capped}…")
|
||||
}
|
||||
|
||||
/// Call the local Ollama model and return the generated text.
|
||||
async fn call_ollama(model: &str, prompt: &str) -> anyhow::Result<String> {
|
||||
let client = reqwest::Client::builder().timeout(OLLAMA_TIMEOUT).build()?;
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": false,
|
||||
});
|
||||
let resp = client.post(OLLAMA_URL).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Ollama returned HTTP {}", resp.status());
|
||||
}
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
let text = json
|
||||
.get("response")
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if text.trim().is_empty() {
|
||||
anyhow::bail!("Ollama returned an empty response");
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Call Claude via the Anthropic Messages API using the node's shared proxy
|
||||
/// token at `secrets/claude-api-key`. Keeps answers short for radio airtime.
|
||||
async fn call_claude(data_dir: &Path, model: &str, prompt: &str) -> anyhow::Result<String> {
|
||||
let key = tokio::fs::read_to_string(data_dir.join("secrets/claude-api-key"))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Claude API key not configured on this node"))?;
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
anyhow::bail!("Claude API key is empty");
|
||||
}
|
||||
let client = reqwest::Client::builder().timeout(OLLAMA_TIMEOUT).build()?;
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"max_tokens": 512,
|
||||
"system": "You answer questions over a low-bandwidth radio mesh. Reply in at most two short sentences. No markdown, no preamble.",
|
||||
"messages": [{ "role": "user", "content": prompt }],
|
||||
});
|
||||
let resp = client
|
||||
.post(CLAUDE_URL)
|
||||
.header("x-api-key", key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let txt = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"Claude API HTTP {}: {}",
|
||||
status,
|
||||
txt.chars().take(180).collect::<String>()
|
||||
);
|
||||
}
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
// `content` is an array of blocks; take the first text block.
|
||||
let text = json
|
||||
.get("content")
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter()
|
||||
.find_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
})
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if text.trim().is_empty() {
|
||||
anyhow::bail!("Claude returned an empty response");
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
@@ -445,7 +445,9 @@ async fn encrypt_for_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: &
|
||||
/// Send raw wire bytes to a specific peer by contact_id.
|
||||
/// Encrypts directed messages via ratchet or shared secret when available.
|
||||
/// Falls back to channel 0 broadcast (plaintext) if peer's pubkey is unknown.
|
||||
async fn send_to_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: Vec<u8>) {
|
||||
/// `pub(super)` so sibling handlers (e.g. the AI assistant) can reply on the
|
||||
/// same encrypted, peer-addressed path the relay handlers use.
|
||||
pub(super) async fn send_to_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: Vec<u8>) {
|
||||
let peers = state.peers.read().await;
|
||||
if let Some(peer) = peers.get(&contact_id) {
|
||||
if let Some(ref pk) = peer.pubkey_hex {
|
||||
|
||||
@@ -352,6 +352,45 @@ pub(super) async fn store_plain_message(
|
||||
state.store_message(msg.clone()).await;
|
||||
state.status.write().await.messages_received += 1;
|
||||
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
||||
|
||||
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` on the
|
||||
// channel is answered by this node's local model when the assistant is on.
|
||||
// Reply goes back as plain channel text so bare (non-archipelago) clients
|
||||
// see it. The trust/rate gate lives in run_assist.
|
||||
if state.assistant.read().await.enabled {
|
||||
if let Some(prompt) = strip_ai_trigger(text) {
|
||||
if !prompt.is_empty() {
|
||||
let req_id = state.next_id().await;
|
||||
let prompt = prompt.to_string();
|
||||
let name = peer_name.to_string();
|
||||
let st = Arc::clone(state);
|
||||
tokio::spawn(async move {
|
||||
super::assist::run_assist(
|
||||
prompt,
|
||||
None,
|
||||
req_id,
|
||||
contact_id,
|
||||
name,
|
||||
super::assist::AssistReply::ChannelText { channel: 0 },
|
||||
st,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognise a `!ai`/`!ask ` command prefix (case-insensitive) and return the
|
||||
/// trimmed question after it, or `None` if the text isn't an AI command.
|
||||
fn strip_ai_trigger(text: &str) -> Option<&str> {
|
||||
let t = text.trim_start();
|
||||
for p in ["!ai ", "!ask "] {
|
||||
if t.len() >= p.len() && t[..p.len()].eq_ignore_ascii_case(p) {
|
||||
return Some(t[p.len()..].trim());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Handle a received identity broadcast from a peer.
|
||||
|
||||
@@ -681,6 +681,80 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
.await;
|
||||
}
|
||||
|
||||
Some(MeshMessageType::AssistQuery) => {
|
||||
match message_types::decode_payload::<message_types::AssistQueryPayload>(&envelope.v) {
|
||||
Ok(query) => {
|
||||
if !state.assistant.read().await.enabled {
|
||||
debug!(
|
||||
from = sender_contact_id,
|
||||
"AssistQuery ignored — assistant disabled on this node"
|
||||
);
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
from = sender_contact_id,
|
||||
req_id = query.req_id,
|
||||
"AI query received over mesh"
|
||||
);
|
||||
let json = payload_to_json(&query);
|
||||
store_typed_message(
|
||||
state,
|
||||
sender_contact_id,
|
||||
sender_name,
|
||||
&query.prompt,
|
||||
"assist_query",
|
||||
json,
|
||||
Some(envelope.seq),
|
||||
)
|
||||
.await;
|
||||
// Run the model + reply off the radio loop. Typed query →
|
||||
// typed chunked reply back to the asking peer.
|
||||
let assist_state = Arc::clone(state);
|
||||
let name = sender_name.to_string();
|
||||
tokio::spawn(async move {
|
||||
super::assist::run_assist(
|
||||
query.prompt,
|
||||
query.model,
|
||||
query.req_id,
|
||||
sender_contact_id,
|
||||
name,
|
||||
super::assist::AssistReply::Typed {
|
||||
contact_id: sender_contact_id,
|
||||
},
|
||||
assist_state,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
Err(e) => warn!("Failed to decode AssistQuery payload: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Some(MeshMessageType::AssistResponse) => {
|
||||
match message_types::decode_payload::<message_types::AssistResponsePayload>(&envelope.v)
|
||||
{
|
||||
Ok(resp) => {
|
||||
let display = resp
|
||||
.error
|
||||
.clone()
|
||||
.map(|e| format!("AI error: {e}"))
|
||||
.unwrap_or_else(|| resp.text.clone());
|
||||
let json = payload_to_json(&resp);
|
||||
store_typed_message(
|
||||
state,
|
||||
sender_contact_id,
|
||||
sender_name,
|
||||
&display,
|
||||
"assist_response",
|
||||
json,
|
||||
Some(envelope.seq),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => warn!("Failed to decode AssistResponse payload: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
debug!(
|
||||
msg_type = ?msg_type,
|
||||
@@ -697,6 +771,10 @@ async fn dispatch_block_header(
|
||||
sender_name: &str,
|
||||
state: &Arc<MeshState>,
|
||||
) {
|
||||
// Respect the receive toggle (issue #28): nodes can opt out of inbound headers.
|
||||
if !state.receive_block_headers {
|
||||
return;
|
||||
}
|
||||
// Compact binary format: height(8) + hash(32) + timestamp(4)
|
||||
match super::super::bitcoin_relay::decode_compact_block_header(&envelope.v) {
|
||||
Ok((height, hash_hex, timestamp)) => {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
use super::super::message_types::TypedEnvelope;
|
||||
use super::super::protocol;
|
||||
use super::decode::{
|
||||
is_mc_chunk_frame, resolve_peer, store_plain_message, try_base64_typed, try_chunk_reassemble,
|
||||
try_decrypt_base64, try_decrypt_ratchet_base64,
|
||||
handle_identity_received, is_mc_chunk_frame, resolve_peer, store_plain_message,
|
||||
try_base64_typed, try_chunk_reassemble, try_decrypt_base64, try_decrypt_ratchet_base64,
|
||||
};
|
||||
use super::dispatch::handle_typed_message;
|
||||
use super::MeshState;
|
||||
@@ -18,7 +18,6 @@ pub(super) async fn handle_frame(
|
||||
state: &Arc<MeshState>,
|
||||
our_x25519_secret: &[u8; 32],
|
||||
) -> bool {
|
||||
let _ = our_x25519_secret; // reserved for future per-frame decryption
|
||||
match frame.code {
|
||||
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
|
||||
info!(
|
||||
@@ -109,7 +108,8 @@ pub(super) async fn handle_frame(
|
||||
match protocol::parse_channel_msg_v3_raw(&frame.data) {
|
||||
Ok((channel_idx, payload)) => {
|
||||
if !payload.is_empty() {
|
||||
handle_channel_payload(state, channel_idx, &payload).await;
|
||||
handle_channel_payload(state, channel_idx, &payload, our_x25519_secret)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to parse v3 channel message: {}", e),
|
||||
@@ -121,7 +121,8 @@ pub(super) async fn handle_frame(
|
||||
match protocol::parse_channel_msg_v1_raw(&frame.data) {
|
||||
Ok((channel_idx, payload)) => {
|
||||
if !payload.is_empty() {
|
||||
handle_channel_payload(state, channel_idx, &payload).await;
|
||||
handle_channel_payload(state, channel_idx, &payload, our_x25519_secret)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to parse channel message: {}", e),
|
||||
@@ -146,7 +147,12 @@ pub(super) async fn handle_frame(
|
||||
/// local mesh peer pubkeys (or we can't tell), the inner payload is
|
||||
/// dispatched through the direct-message path so it lands in the right
|
||||
/// chat. Otherwise it's handled as a normal channel text/typed message.
|
||||
async fn handle_channel_payload(state: &Arc<MeshState>, channel_idx: u8, payload: &[u8]) {
|
||||
async fn handle_channel_payload(
|
||||
state: &Arc<MeshState>,
|
||||
channel_idx: u8,
|
||||
payload: &[u8],
|
||||
our_x25519_secret: &[u8; 32],
|
||||
) {
|
||||
// DM-via-channel wrapper (text form): the channel text carries an
|
||||
// ASCII "@DM:<base64>" token somewhere in the body. We locate the
|
||||
// marker anywhere in the payload (the firmware auto-prepends the
|
||||
@@ -326,6 +332,34 @@ async fn handle_channel_payload(state: &Arc<MeshState>, channel_idx: u8, payload
|
||||
return;
|
||||
}
|
||||
|
||||
// Archipelago identity broadcast (`ARCHY:`): upsert the sender's real
|
||||
// archipelago identity (DID + ed25519 + x25519) so trust-gating and
|
||||
// encrypted DMs work over BOTH meshcore and Meshtastic — the latter
|
||||
// otherwise only exposes synthetic node keys. Keyed by the archipelago
|
||||
// pubkey (federation_peer_contact_id) so it MERGES with the federation-
|
||||
// seeded peer instead of creating a duplicate chat thread. Not stored as
|
||||
// a chat message.
|
||||
if let Ok(text) = std::str::from_utf8(payload) {
|
||||
if let Some((did, ed_hex, x_hex)) = super::super::protocol::parse_identity_broadcast(text) {
|
||||
// Ignore our own identity echoed back by the radio/channel.
|
||||
if ed_hex.eq_ignore_ascii_case(&state.our_ed_pubkey_hex) {
|
||||
return;
|
||||
}
|
||||
let contact_id = super::super::federation_peer_contact_id(&ed_hex);
|
||||
handle_identity_received(
|
||||
contact_id,
|
||||
0,
|
||||
&did,
|
||||
&ed_hex,
|
||||
&x_hex,
|
||||
state,
|
||||
our_x25519_secret,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular channel broadcast (not DM-wrapped)
|
||||
let chan_contact_id = u32::MAX - (channel_idx as u32);
|
||||
let chan_name = format!("Channel {}", channel_idx);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! - Reconnects on device disconnect
|
||||
//! - Manages peer cache and message store
|
||||
|
||||
mod assist;
|
||||
mod bitcoin;
|
||||
mod decode;
|
||||
pub(crate) mod dispatch;
|
||||
@@ -102,6 +103,8 @@ pub struct MeshState {
|
||||
pub session_manager: Arc<super::session::SessionManager>,
|
||||
/// Whether to encrypt directed relay messages (config toggle for rollback).
|
||||
pub encrypt_relay: bool,
|
||||
/// Whether to accept inbound Bitcoin block headers from peers (issue #28).
|
||||
pub receive_block_headers: bool,
|
||||
/// Last-seen presence heartbeats per peer pubkey hex: (status, last_active_epoch, received_at).
|
||||
pub presence: RwLock<HashMap<String, (String, u32, u64)>>,
|
||||
/// Contacts store — alias/notes/pinned/blocked per peer pubkey hex.
|
||||
@@ -121,6 +124,30 @@ pub struct MeshState {
|
||||
/// persistent contact table from regenerating rows the user just
|
||||
/// wiped. Persisted to `mesh-ignored-radio-contacts.json`.
|
||||
pub radio_contact_blocklist: RwLock<HashSet<String>>,
|
||||
/// Mesh-AI assistant settings (issue #50): whether this node answers
|
||||
/// AssistQuery messages with its local LLM, and who may ask. Live-updatable
|
||||
/// so the UI toggle applies without restarting the listener.
|
||||
pub assistant: RwLock<AssistantConfig>,
|
||||
/// Data dir — lets dispatch handlers reach disk-backed stores (e.g. the
|
||||
/// federation trust list used to gate AI queries) without threading a path
|
||||
/// through every call.
|
||||
pub data_dir: std::path::PathBuf,
|
||||
/// Contact-ids with an AI query currently being answered. Caps each asker to
|
||||
/// one in-flight query so a peer can't flood the node's compute / airtime.
|
||||
pub assist_inflight: RwLock<HashSet<u32>>,
|
||||
}
|
||||
|
||||
/// Mesh-AI assistant configuration, snapshotted from `MeshConfig` at startup.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AssistantConfig {
|
||||
/// Answer AssistQuery messages with the local LLM.
|
||||
pub enabled: bool,
|
||||
/// Model to use; None → the backend's built-in default.
|
||||
pub model: Option<String>,
|
||||
/// Restrict asking to federation-Trusted peers (vs. anyone on the mesh).
|
||||
pub trusted_only: bool,
|
||||
/// AI backend: "claude" (shared proxy token) or "ollama" (local model).
|
||||
pub backend: String,
|
||||
}
|
||||
|
||||
/// Contact metadata kept alongside MeshState.peers. Pinned contacts sort to
|
||||
@@ -151,8 +178,11 @@ impl MeshState {
|
||||
relay_tracker: Option<Arc<super::bitcoin_relay::RelayTracker>>,
|
||||
stego_mode: super::steganography::SteganographyMode,
|
||||
encrypt_relay: bool,
|
||||
receive_block_headers: bool,
|
||||
session_manager: Arc<super::session::SessionManager>,
|
||||
our_ed_pubkey_hex: String,
|
||||
assistant: AssistantConfig,
|
||||
data_dir: std::path::PathBuf,
|
||||
) -> (
|
||||
Arc<Self>,
|
||||
broadcast::Receiver<MeshEvent>,
|
||||
@@ -187,11 +217,15 @@ impl MeshState {
|
||||
chunk_buffer: RwLock::new(HashMap::new()),
|
||||
session_manager,
|
||||
encrypt_relay,
|
||||
receive_block_headers,
|
||||
presence: RwLock::new(HashMap::new()),
|
||||
contacts: RwLock::new(HashMap::new()),
|
||||
our_ed_pubkey_hex,
|
||||
blob_store: RwLock::new(None),
|
||||
radio_contact_blocklist: RwLock::new(HashSet::new()),
|
||||
assistant: RwLock::new(assistant),
|
||||
data_dir,
|
||||
assist_inflight: RwLock::new(HashSet::new()),
|
||||
});
|
||||
(state, rx, cmd_rx)
|
||||
}
|
||||
|
||||
@@ -363,9 +363,9 @@ pub(super) async fn run_mesh_session(
|
||||
state: &Arc<MeshState>,
|
||||
preferred_path: Option<&str>,
|
||||
our_did: &str,
|
||||
_our_ed_pubkey_hex: &str,
|
||||
our_ed_pubkey_hex: &str,
|
||||
our_x25519_secret: &[u8; 32],
|
||||
_our_x25519_pubkey_hex: &str,
|
||||
our_x25519_pubkey_hex: &str,
|
||||
server_name: Option<&str>,
|
||||
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
||||
@@ -424,6 +424,22 @@ pub(super) async fn run_mesh_session(
|
||||
warn!("Failed to send initial advert: {}", e);
|
||||
}
|
||||
|
||||
// Archipelago identity advert (`ARCHY:2:{ed}:{x25519}`): broadcast as channel
|
||||
// text so peers can bind our radio presence to our DID + keys. The firmware
|
||||
// advert alone carries the meshcore key (and nothing on Meshtastic), so this
|
||||
// is what makes trust-gating + encrypted DMs work across BOTH transports.
|
||||
let identity_advert = super::super::protocol::encode_identity_broadcast(
|
||||
our_did,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
);
|
||||
if let Err(e) = device
|
||||
.send_channel_text(0, identity_advert.as_bytes())
|
||||
.await
|
||||
{
|
||||
warn!("Failed to broadcast archipelago identity: {}", e);
|
||||
}
|
||||
|
||||
// Fetch existing contacts from the device
|
||||
refresh_contacts(&mut device, state).await;
|
||||
|
||||
@@ -491,6 +507,11 @@ pub(super) async fn run_mesh_session(
|
||||
} else {
|
||||
consecutive_write_failures = 0;
|
||||
}
|
||||
// Re-broadcast archipelago identity so peers that joined since
|
||||
// startup (or missed it) can bind our DID/keys.
|
||||
if let Err(e) = device.send_channel_text(0, identity_advert.as_bytes()).await {
|
||||
warn!("Failed to re-broadcast archipelago identity: {}", e);
|
||||
}
|
||||
refresh_contacts(&mut device, state).await;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,12 @@ pub enum MeshMessageType {
|
||||
/// MCIIXXTT framing) and the peer has no Tor path. Recipient writes the
|
||||
/// bytes to its local BlobStore on reassembly.
|
||||
ContentInline = 23,
|
||||
/// "Ask the node's AI" — a prompt to be answered by the receiving node's
|
||||
/// local LLM (issue #50). Gated by the assistant config + trust policy.
|
||||
AssistQuery = 24,
|
||||
/// Reply to an AssistQuery — a chunk of the LLM's answer, addressed back to
|
||||
/// the asker by `req_id`. Long answers span multiple chunks (`seq`/`done`).
|
||||
AssistResponse = 25,
|
||||
}
|
||||
|
||||
impl MeshMessageType {
|
||||
@@ -99,6 +105,8 @@ impl MeshMessageType {
|
||||
21 => Some(Self::ChannelInvite),
|
||||
22 => Some(Self::ContactCard),
|
||||
23 => Some(Self::ContentInline),
|
||||
24 => Some(Self::AssistQuery),
|
||||
25 => Some(Self::AssistResponse),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -132,6 +140,8 @@ impl MeshMessageType {
|
||||
"channel_invite" => Some(Self::ChannelInvite),
|
||||
"contact_card" => Some(Self::ContactCard),
|
||||
"content_inline" => Some(Self::ContentInline),
|
||||
"assist_query" => Some(Self::AssistQuery),
|
||||
"assist_response" => Some(Self::AssistResponse),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -162,6 +172,8 @@ impl MeshMessageType {
|
||||
Self::ChannelInvite => "channel_invite",
|
||||
Self::ContactCard => "contact_card",
|
||||
Self::ContentInline => "content_inline",
|
||||
Self::AssistQuery => "assist_query",
|
||||
Self::AssistResponse => "assist_response",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,6 +419,37 @@ pub struct LightningRelayPayload {
|
||||
pub request_id: u64,
|
||||
}
|
||||
|
||||
/// "Ask the node's AI" request (issue #50). Sent to a peer running a local
|
||||
/// LLM; answered with one or more `AssistResponsePayload` chunks.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AssistQueryPayload {
|
||||
/// Asker-chosen id correlating the query with its response chunks.
|
||||
pub req_id: u64,
|
||||
/// The natural-language prompt.
|
||||
pub prompt: String,
|
||||
/// Optional model override; falls back to the responder's configured model.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// One chunk of an AI answer, addressed back to the asker by `req_id`.
|
||||
/// Airtime is scarce, so long answers are capped and split into ordered chunks.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AssistResponsePayload {
|
||||
pub req_id: u64,
|
||||
/// This chunk's text.
|
||||
pub text: String,
|
||||
/// 0-based chunk index.
|
||||
#[serde(default)]
|
||||
pub seq: u16,
|
||||
/// True on the final chunk.
|
||||
#[serde(default)]
|
||||
pub done: bool,
|
||||
/// Set instead of `text` when the query failed (model unreachable, denied…).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Lightning relay response (proof of payment).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LightningRelayResponsePayload {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod message_types;
|
||||
pub mod outbox;
|
||||
pub mod protocol;
|
||||
pub mod ratchet;
|
||||
pub mod scheduler;
|
||||
pub mod serial;
|
||||
pub mod session;
|
||||
pub mod steganography;
|
||||
@@ -37,6 +38,7 @@ use tracing::{error, info, warn};
|
||||
|
||||
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
||||
const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.json";
|
||||
const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
|
||||
|
||||
/// Derive a stable synthetic `contact_id` for a federation peer from its
|
||||
/// archipelago ed25519 pubkey. Mesh LoRa contacts use meshcore firmware's
|
||||
@@ -87,6 +89,38 @@ pub(crate) async fn upsert_federation_peer(
|
||||
contact_id
|
||||
}
|
||||
|
||||
/// Purge a federation peer from all live mesh state and persisted contacts so
|
||||
/// removing a node (federation.remove-node) also clears its chat contact,
|
||||
/// thread, and any per-contact customisation — otherwise a stale/renamed node
|
||||
/// (e.g. an old "Arch HP" entry) lingers in the chat list even after it's gone
|
||||
/// from `nodes.json` (#2). Keyed by the synthetic `contact_id` for the peer
|
||||
/// table/messages and by `pubkey_hex` for the pubkey-keyed contacts/presence
|
||||
/// stores.
|
||||
pub(crate) async fn purge_federation_peer(
|
||||
state: &Arc<listener::MeshState>,
|
||||
contact_id: u32,
|
||||
pubkey_hex: &str,
|
||||
data_dir: &Path,
|
||||
) {
|
||||
state.peers.write().await.remove(&contact_id);
|
||||
state.shared_secrets.write().await.remove(&contact_id);
|
||||
state
|
||||
.messages
|
||||
.write()
|
||||
.await
|
||||
.retain(|m| m.peer_contact_id != contact_id);
|
||||
state.presence.write().await.remove(pubkey_hex);
|
||||
let mut contacts = state.contacts.write().await;
|
||||
if contacts.remove(pubkey_hex).is_some() {
|
||||
let snapshot = contacts.clone();
|
||||
drop(contacts);
|
||||
if let Err(e) = save_mesh_contacts(data_dir, &snapshot).await {
|
||||
warn!("Failed to persist mesh contacts after purge: {}", e);
|
||||
}
|
||||
}
|
||||
state.update_peer_count().await;
|
||||
}
|
||||
|
||||
/// Load federation nodes from disk and upsert each as a synthetic mesh peer.
|
||||
/// Called at MeshService startup so the chat list already contains every
|
||||
/// known federation node — users can share files to them without first
|
||||
@@ -99,7 +133,17 @@ pub(crate) async fn seed_federation_peers_into_mesh(
|
||||
Ok(n) => n,
|
||||
Err(_) => return,
|
||||
};
|
||||
// Skip nodes whose onion we've already seeded: the same physical node can
|
||||
// linger in the federation list under two dids (see B1/B2). Seeding both
|
||||
// would create two chat contacts for one node — one by name+logo and one
|
||||
// by raw did. One onion → one mesh contact.
|
||||
let mut seen_onions = std::collections::HashSet::new();
|
||||
for node in nodes {
|
||||
let onion_key = node.onion.trim_end_matches(".onion").to_string();
|
||||
if !onion_key.is_empty() && !seen_onions.insert(onion_key) {
|
||||
tracing::debug!(did = %node.did, onion = %node.onion, "skipping duplicate federation node (onion already seeded)");
|
||||
continue;
|
||||
}
|
||||
upsert_federation_peer(state, &node.pubkey, &node.did, node.name.as_deref()).await;
|
||||
}
|
||||
}
|
||||
@@ -126,6 +170,10 @@ pub struct MeshConfig {
|
||||
/// Announce new Bitcoin block headers over mesh (internet-connected nodes only).
|
||||
#[serde(default)]
|
||||
pub announce_block_headers: bool,
|
||||
/// Accept Bitcoin block headers received over mesh from peers. On by default;
|
||||
/// turn off to ignore inbound headers (the receive half of issue #28).
|
||||
#[serde(default = "default_true")]
|
||||
pub receive_block_headers: bool,
|
||||
/// Steganographic encoding mode for mesh messages (Normal = disabled).
|
||||
#[serde(default)]
|
||||
pub steganography_mode: steganography::SteganographyMode,
|
||||
@@ -133,6 +181,26 @@ pub struct MeshConfig {
|
||||
/// Set to false to disable encryption for debugging or rollback.
|
||||
#[serde(default = "default_true")]
|
||||
pub encrypt_relay_messages: bool,
|
||||
/// Answer AI queries (AssistQuery) from peers using this node's local LLM
|
||||
/// (issue #50). Off by default — the node only becomes a mesh AI on opt-in.
|
||||
#[serde(default)]
|
||||
pub assistant_enabled: bool,
|
||||
/// Ollama model used to answer AI queries. None → the built-in default.
|
||||
#[serde(default)]
|
||||
pub assistant_model: Option<String>,
|
||||
/// When true (default), only federation-Trusted peers may ask; when false,
|
||||
/// any peer on the mesh may ask (spends this node's compute + airtime).
|
||||
#[serde(default = "default_true")]
|
||||
pub assistant_trusted_only: bool,
|
||||
/// Which AI backend answers queries: "claude" (the shared Claude proxy
|
||||
/// token at secrets/claude-api-key — default for now, works without a
|
||||
/// local GPU) or "ollama" (a local model on this node).
|
||||
#[serde(default = "default_assistant_backend")]
|
||||
pub assistant_backend: String,
|
||||
}
|
||||
|
||||
fn default_assistant_backend() -> String {
|
||||
"claude".to_string()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -149,8 +217,13 @@ impl Default for MeshConfig {
|
||||
advert_name: None,
|
||||
mesh_only_mode: None,
|
||||
announce_block_headers: false,
|
||||
receive_block_headers: true,
|
||||
steganography_mode: steganography::SteganographyMode::Normal,
|
||||
encrypt_relay_messages: true,
|
||||
assistant_enabled: false,
|
||||
assistant_model: None,
|
||||
assistant_trusted_only: true,
|
||||
assistant_backend: default_assistant_backend(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,6 +273,66 @@ pub async fn save_ignored_radio_contacts(data_dir: &Path, pubkeys: &[String]) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load persisted mesh contact customisations (alias / notes / pinned / blocked),
|
||||
/// decrypting at rest with the node key and migrating any legacy plaintext file.
|
||||
/// Returns an empty map on any error so a read failure never loses live state.
|
||||
pub async fn load_mesh_contacts(
|
||||
data_dir: &Path,
|
||||
) -> std::collections::HashMap<String, listener::ContactEntry> {
|
||||
let path = data_dir.join(MESH_CONTACTS_FILE);
|
||||
let Ok(raw) = fs::read(&path).await else {
|
||||
return std::collections::HashMap::new();
|
||||
};
|
||||
let bytes = if crate::storage_crypto::is_plaintext_json(&raw) {
|
||||
raw
|
||||
} else {
|
||||
match crate::storage_crypto::derive_key(
|
||||
data_dir,
|
||||
crate::storage_crypto::DOMAIN_MESH_CONTACTS,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(k) => match crate::storage_crypto::open(&raw, &k) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("mesh contacts: decrypt failed ({e}); keeping in-memory state");
|
||||
return std::collections::HashMap::new();
|
||||
}
|
||||
},
|
||||
Err(_) => return std::collections::HashMap::new(),
|
||||
}
|
||||
};
|
||||
serde_json::from_slice(&bytes).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Persist mesh contact customisations, encrypted at rest with the node key and
|
||||
/// written atomically (temp + rename) so a crash mid-write can't corrupt them.
|
||||
pub async fn save_mesh_contacts(
|
||||
data_dir: &Path,
|
||||
contacts: &std::collections::HashMap<String, listener::ContactEntry>,
|
||||
) -> Result<()> {
|
||||
fs::create_dir_all(data_dir).await.ok();
|
||||
let content = serde_json::to_vec(contacts).context("Failed to serialize mesh contacts")?;
|
||||
let bytes = match crate::storage_crypto::derive_key(
|
||||
data_dir,
|
||||
crate::storage_crypto::DOMAIN_MESH_CONTACTS,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(k) => crate::storage_crypto::seal(&content, &k).unwrap_or(content),
|
||||
Err(_) => content, // no key yet (pre-onboarding) → plaintext rather than no-write
|
||||
};
|
||||
let path = data_dir.join(MESH_CONTACTS_FILE);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, &bytes)
|
||||
.await
|
||||
.context("Failed to write mesh contacts tmp")?;
|
||||
fs::rename(&tmp, &path)
|
||||
.await
|
||||
.context("Failed to rename mesh contacts")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Detect serial devices that could be mesh radios.
|
||||
/// Checks both Meshcore (via probe) and legacy Meshtastic paths.
|
||||
pub async fn detect_devices() -> Vec<String> {
|
||||
@@ -219,6 +352,7 @@ pub struct MeshService {
|
||||
deadman_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
block_announcer_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
presence_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
scheduler_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
cmd_rx: Option<tokio::sync::mpsc::Receiver<listener::MeshCommand>>,
|
||||
// Crypto identity for this node
|
||||
our_did: String,
|
||||
@@ -232,6 +366,8 @@ pub struct MeshService {
|
||||
pub block_header_cache: Arc<BlockHeaderCache>,
|
||||
pub relay_tracker: Arc<RelayTracker>,
|
||||
pub dead_man_switch: Arc<DeadManSwitch>,
|
||||
/// Scheduled / queued outbound mesh messages (issue #50, phase 1.7).
|
||||
pub scheduler: Arc<scheduler::MeshScheduler>,
|
||||
}
|
||||
|
||||
impl MeshService {
|
||||
@@ -257,8 +393,16 @@ impl MeshService {
|
||||
Some(Arc::clone(&relay_tracker)),
|
||||
config.steganography_mode,
|
||||
config.encrypt_relay_messages,
|
||||
config.receive_block_headers,
|
||||
Arc::clone(&session_manager),
|
||||
ed_pubkey_hex.to_string(),
|
||||
listener::AssistantConfig {
|
||||
enabled: config.assistant_enabled,
|
||||
model: config.assistant_model.clone(),
|
||||
trusted_only: config.assistant_trusted_only,
|
||||
backend: config.assistant_backend.clone(),
|
||||
},
|
||||
data_dir.to_path_buf(),
|
||||
);
|
||||
|
||||
// Derive X25519 keys from Ed25519 identity
|
||||
@@ -294,6 +438,18 @@ impl MeshService {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore persisted contact customisations (alias/notes/pinned/blocked),
|
||||
// decrypted with the node key, so they survive restarts.
|
||||
{
|
||||
let saved = load_mesh_contacts(data_dir).await;
|
||||
if !saved.is_empty() {
|
||||
let mut contacts = state.contacts.write().await;
|
||||
for (pk, entry) in saved {
|
||||
contacts.insert(pk, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
state,
|
||||
config,
|
||||
@@ -303,6 +459,7 @@ impl MeshService {
|
||||
deadman_handle: None,
|
||||
block_announcer_handle: None,
|
||||
presence_handle: None,
|
||||
scheduler_handle: None,
|
||||
cmd_rx: Some(cmd_rx),
|
||||
our_did: did.to_string(),
|
||||
our_ed_pubkey_hex: ed_pubkey_hex.to_string(),
|
||||
@@ -313,6 +470,7 @@ impl MeshService {
|
||||
block_header_cache,
|
||||
relay_tracker,
|
||||
dead_man_switch,
|
||||
scheduler: Arc::new(scheduler::MeshScheduler::load(data_dir).await),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -390,6 +548,20 @@ impl MeshService {
|
||||
});
|
||||
self.deadman_handle = Some(dms_handle);
|
||||
|
||||
// Scheduled-message task (issue #50, phase 1.7): fires queued messages
|
||||
// when due, retrying peer DMs until the peer is back in range.
|
||||
let sched = Arc::clone(&self.scheduler);
|
||||
let sched_state = Arc::clone(&self.state);
|
||||
let sched_shutdown = self
|
||||
.shutdown_tx
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Shutdown channel not initialized"))?
|
||||
.subscribe();
|
||||
let sched_handle = tokio::spawn(async move {
|
||||
scheduler::run_scheduler(sched, sched_state, sched_shutdown).await;
|
||||
});
|
||||
self.scheduler_handle = Some(sched_handle);
|
||||
|
||||
// Spawn block header announcer (internet-connected nodes only)
|
||||
if self.config.announce_block_headers {
|
||||
let bha_state = Arc::clone(&self.state);
|
||||
@@ -540,6 +712,10 @@ impl MeshService {
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
if let Some(handle) = self.scheduler_handle.take() {
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
if let Some(handle) = self.block_announcer_handle.take() {
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
@@ -742,15 +918,33 @@ impl MeshService {
|
||||
let is_federation_synthetic = contact_id & 0x8000_0000 != 0;
|
||||
let exceeds_lora = wire.len() > protocol::MAX_MESSAGE_LEN;
|
||||
if is_federation_synthetic || exceeds_lora {
|
||||
let (peer_pubkey, peer_did) = {
|
||||
// Resolve the peer's pubkey/did. Prefer the live mesh peer table,
|
||||
// but fall back to federation storage for federation-synthetic ids
|
||||
// that were never seeded into `state.peers` — e.g. a radio-less
|
||||
// node where the mesh device table is empty. Without this fallback
|
||||
// chatting a federation contact bails "Unknown federation peer"
|
||||
// even though we know its onion from nodes.json.
|
||||
let from_table = {
|
||||
let peers = self.state.peers.read().await;
|
||||
match peers.get(&contact_id) {
|
||||
Some(p) => (p.pubkey_hex.clone(), p.did.clone()),
|
||||
None if is_federation_synthetic => {
|
||||
anyhow::bail!("Unknown federation peer {}", contact_id);
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.map(|p| (p.pubkey_hex.clone(), p.did.clone()))
|
||||
};
|
||||
let (peer_pubkey, peer_did) = match from_table {
|
||||
Some(v) => v,
|
||||
None if is_federation_synthetic => {
|
||||
let nodes = crate::federation::load_nodes(&self.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
match nodes
|
||||
.iter()
|
||||
.find(|n| federation_peer_contact_id(&n.pubkey) == contact_id)
|
||||
{
|
||||
Some(n) => (Some(n.pubkey.clone()), Some(n.did.clone())),
|
||||
None => anyhow::bail!("Unknown federation peer {}", contact_id),
|
||||
}
|
||||
None => (None, None),
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
let nodes = crate::federation::load_nodes(&self.data_dir)
|
||||
.await
|
||||
@@ -1193,6 +1387,51 @@ impl MeshService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current mesh-AI assistant settings (issue #50).
|
||||
pub async fn assistant_config(&self) -> listener::AssistantConfig {
|
||||
self.state.assistant.read().await.clone()
|
||||
}
|
||||
|
||||
/// Update the mesh-AI assistant settings live (no listener restart) and
|
||||
/// persist them to the mesh config. `model: Some(None)` clears the override
|
||||
/// (falls back to the built-in default); `None` leaves a field unchanged.
|
||||
pub async fn configure_assistant(
|
||||
&self,
|
||||
enabled: Option<bool>,
|
||||
model: Option<Option<String>>,
|
||||
trusted_only: Option<bool>,
|
||||
backend: Option<String>,
|
||||
) -> Result<()> {
|
||||
{
|
||||
let mut a = self.state.assistant.write().await;
|
||||
if let Some(e) = enabled {
|
||||
a.enabled = e;
|
||||
}
|
||||
if let Some(m) = model {
|
||||
a.model = m;
|
||||
}
|
||||
if let Some(t) = trusted_only {
|
||||
a.trusted_only = t;
|
||||
}
|
||||
if let Some(b) = backend {
|
||||
a.backend = b;
|
||||
}
|
||||
}
|
||||
// Persist by updating the on-disk config (the in-memory `self.config`
|
||||
// snapshot stays as-is; the live `state.assistant` is the runtime
|
||||
// source of truth and is re-seeded from disk on the next start).
|
||||
let mut cfg = load_config(&self.data_dir).await.unwrap_or_default();
|
||||
{
|
||||
let a = self.state.assistant.read().await;
|
||||
cfg.assistant_enabled = a.enabled;
|
||||
cfg.assistant_model = a.model.clone();
|
||||
cfg.assistant_trusted_only = a.trusted_only;
|
||||
cfg.assistant_backend = a.backend.clone();
|
||||
}
|
||||
save_config(&self.data_dir, &cfg).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update mesh configuration.
|
||||
pub async fn configure(&mut self, config: MeshConfig) -> Result<()> {
|
||||
save_config(&self.data_dir, &config).await?;
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Scheduled / queued mesh messages (issue #50, phase 1.7).
|
||||
//!
|
||||
//! A small persisted queue of messages to send at a future time. A background
|
||||
//! task fires due messages via the listener. A message addressed to a peer that
|
||||
//! isn't currently in the contact table stays queued and retries on later ticks
|
||||
//! — i.e. it sends itself when the peer comes back in range.
|
||||
|
||||
use super::listener::{MeshCommand, MeshState};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use tokio::sync::{watch, RwLock};
|
||||
use tracing::warn;
|
||||
|
||||
const SCHEDULER_FILE: &str = "mesh-scheduled.json";
|
||||
/// Wake interval for firing due messages.
|
||||
const TICK_SECS: u64 = 10;
|
||||
/// Drop a still-undeliverable message after this many attempts (~1h at 10s).
|
||||
const MAX_ATTEMPTS: u32 = 360;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScheduledMessage {
|
||||
pub id: u64,
|
||||
/// Direct-message target (peer contact_id), or None for a channel broadcast.
|
||||
#[serde(default)]
|
||||
pub contact_id: Option<u32>,
|
||||
/// Channel to broadcast on, or None for a direct message.
|
||||
#[serde(default)]
|
||||
pub channel: Option<u8>,
|
||||
pub body: String,
|
||||
/// Unix seconds when the message becomes due.
|
||||
pub fire_at: i64,
|
||||
#[serde(default)]
|
||||
pub attempts: u32,
|
||||
}
|
||||
|
||||
pub struct MeshScheduler {
|
||||
path: PathBuf,
|
||||
queue: RwLock<Vec<ScheduledMessage>>,
|
||||
next_id: RwLock<u64>,
|
||||
}
|
||||
|
||||
impl MeshScheduler {
|
||||
pub async fn load(data_dir: &Path) -> Self {
|
||||
let path = data_dir.join(SCHEDULER_FILE);
|
||||
let queue: Vec<ScheduledMessage> = match fs::read_to_string(&path).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let next = queue.iter().map(|m| m.id).max().unwrap_or(0) + 1;
|
||||
Self {
|
||||
path,
|
||||
queue: RwLock::new(queue),
|
||||
next_id: RwLock::new(next),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save(&self) -> Result<()> {
|
||||
let json = {
|
||||
let q = self.queue.read().await;
|
||||
serde_json::to_string_pretty(&*q).context("serialize scheduled queue")?
|
||||
};
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent).await.ok();
|
||||
}
|
||||
fs::write(&self.path, json)
|
||||
.await
|
||||
.context("write scheduled queue")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add(
|
||||
&self,
|
||||
contact_id: Option<u32>,
|
||||
channel: Option<u8>,
|
||||
body: String,
|
||||
fire_at: i64,
|
||||
) -> Result<ScheduledMessage> {
|
||||
let id = {
|
||||
let mut n = self.next_id.write().await;
|
||||
let id = *n;
|
||||
*n += 1;
|
||||
id
|
||||
};
|
||||
let msg = ScheduledMessage {
|
||||
id,
|
||||
contact_id,
|
||||
channel,
|
||||
body,
|
||||
fire_at,
|
||||
attempts: 0,
|
||||
};
|
||||
self.queue.write().await.push(msg.clone());
|
||||
self.save().await?;
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Vec<ScheduledMessage> {
|
||||
let mut v = self.queue.read().await.clone();
|
||||
v.sort_by_key(|m| m.fire_at);
|
||||
v
|
||||
}
|
||||
|
||||
pub async fn cancel(&self, id: u64) -> Result<bool> {
|
||||
let removed = {
|
||||
let mut q = self.queue.write().await;
|
||||
let before = q.len();
|
||||
q.retain(|m| m.id != id);
|
||||
q.len() != before
|
||||
};
|
||||
if removed {
|
||||
self.save().await?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Background loop: every `TICK_SECS`, fire any due messages.
|
||||
pub async fn run_scheduler(
|
||||
scheduler: Arc<MeshScheduler>,
|
||||
state: Arc<MeshState>,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(TICK_SECS));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => fire_due(&scheduler, &state).await,
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() { return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fire_due(scheduler: &Arc<MeshScheduler>, state: &Arc<MeshState>) {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let due: Vec<ScheduledMessage> = scheduler
|
||||
.queue
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|m| m.fire_at <= now)
|
||||
.cloned()
|
||||
.collect();
|
||||
if due.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut delivered: Vec<u64> = Vec::new();
|
||||
let mut failed: Vec<u64> = Vec::new();
|
||||
for msg in &due {
|
||||
if try_send(state, msg).await {
|
||||
delivered.push(msg.id);
|
||||
} else {
|
||||
failed.push(msg.id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut to_remove = delivered;
|
||||
{
|
||||
let mut q = scheduler.queue.write().await;
|
||||
for m in q.iter_mut() {
|
||||
if failed.contains(&m.id) {
|
||||
m.attempts += 1;
|
||||
if m.attempts >= MAX_ATTEMPTS {
|
||||
warn!(
|
||||
id = m.id,
|
||||
attempts = m.attempts,
|
||||
"Dropping undeliverable scheduled message"
|
||||
);
|
||||
to_remove.push(m.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
q.retain(|m| !to_remove.contains(&m.id));
|
||||
}
|
||||
let _ = scheduler.save().await;
|
||||
}
|
||||
|
||||
/// Hand a due message to the radio. Returns true if it was sent (or should be
|
||||
/// dropped); false to keep it queued for a later retry (peer not in range yet).
|
||||
async fn try_send(state: &Arc<MeshState>, msg: &ScheduledMessage) -> bool {
|
||||
let payload = msg.body.clone().into_bytes();
|
||||
if let Some(channel) = msg.channel {
|
||||
return state
|
||||
.send_cmd(MeshCommand::BroadcastChannel { channel, payload })
|
||||
.await
|
||||
.is_ok();
|
||||
}
|
||||
if let Some(contact_id) = msg.contact_id {
|
||||
let pubkey = {
|
||||
let peers = state.peers.read().await;
|
||||
peers.get(&contact_id).and_then(|p| p.pubkey_hex.clone())
|
||||
};
|
||||
if let Some(pk) = pubkey {
|
||||
if let Ok(bytes) = hex::decode(&pk) {
|
||||
if bytes.len() >= 6 {
|
||||
let mut dest = [0u8; 6];
|
||||
dest.copy_from_slice(&bytes[..6]);
|
||||
return state
|
||||
.send_cmd(MeshCommand::SendText {
|
||||
dest_pubkey_prefix: dest,
|
||||
payload,
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Peer unknown / not in range yet — keep queued, retry next tick.
|
||||
return false;
|
||||
}
|
||||
warn!("Scheduled message has neither channel nor contact_id — dropping");
|
||||
true
|
||||
}
|
||||
@@ -161,4 +161,15 @@ pub enum MeshEvent {
|
||||
payment_hash: Option<String>,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// An AI query arrived from a peer and was accepted for answering (#50).
|
||||
AssistQueryReceived {
|
||||
from_contact_id: u32,
|
||||
prompt: String,
|
||||
},
|
||||
/// A local-AI answer finished sending back to the asker (or failed) (#50).
|
||||
AssistResponseReady {
|
||||
req_id: u64,
|
||||
to_contact_id: u32,
|
||||
error: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ pub struct IncomingMessage {
|
||||
/// Sender's node name (for display in group chat).
|
||||
#[serde(default)]
|
||||
pub from_name: Option<String>,
|
||||
/// Sender-assigned unique id for the message. Used to dedup reliably even
|
||||
/// when a slow-Tor retry/redelivery arrives outside the time window (#31).
|
||||
#[serde(default)]
|
||||
pub msg_id: Option<String>,
|
||||
pub message: String,
|
||||
pub timestamp: String,
|
||||
/// "sent" or "received"
|
||||
@@ -43,18 +47,68 @@ fn data_path() -> &'static Mutex<Option<PathBuf>> {
|
||||
PATH.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// At-rest encryption key for messages.json, derived from the node identity in
|
||||
/// `init()`. `None` only if the node key is unreadable (pre-onboarding) — in
|
||||
/// which case we persist plaintext rather than lose messages.
|
||||
fn enc_key() -> &'static Mutex<Option<[u8; 32]>> {
|
||||
static KEY: OnceLock<Mutex<Option<[u8; 32]>>> = OnceLock::new();
|
||||
KEY.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// Initialize message store — load from disk. Call once at startup.
|
||||
pub async fn init(data_dir: &Path) {
|
||||
let path = data_dir.join("messages.json");
|
||||
*data_path().lock().unwrap_or_else(|e| e.into_inner()) = Some(path.clone());
|
||||
|
||||
if let Ok(content) = tokio::fs::read_to_string(&path).await {
|
||||
if let Ok(loaded) = serde_json::from_str::<MessageStore>(&content) {
|
||||
// Derive + cache the at-rest encryption key (bound to this node's identity).
|
||||
match crate::storage_crypto::derive_key(data_dir, crate::storage_crypto::DOMAIN_MESSAGES).await
|
||||
{
|
||||
Ok(k) => *enc_key().lock().unwrap_or_else(|e| e.into_inner()) = Some(k),
|
||||
Err(e) => tracing::warn!(
|
||||
"message store: encryption key unavailable ({e}); will persist plaintext"
|
||||
),
|
||||
}
|
||||
|
||||
let Ok(raw) = tokio::fs::read(&path).await else {
|
||||
return; // no file yet (new node)
|
||||
};
|
||||
// Decrypt the on-disk blob, transparently migrating a legacy plaintext file.
|
||||
let mut was_plaintext = false;
|
||||
let bytes = if crate::storage_crypto::is_plaintext_json(&raw) {
|
||||
was_plaintext = true;
|
||||
Some(raw)
|
||||
} else {
|
||||
let key = *enc_key().lock().unwrap_or_else(|e| e.into_inner());
|
||||
match key {
|
||||
Some(k) => match crate::storage_crypto::open(&raw, &k) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"message store: decrypt failed ({e}); NOT overwriting on-disk data"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
if let Some(bytes) = bytes {
|
||||
if let Ok(loaded) = serde_json::from_slice::<MessageStore>(&bytes) {
|
||||
let mut guard = store().lock().unwrap_or_else(|e| e.into_inner());
|
||||
*guard = loaded;
|
||||
tracing::info!("Loaded {} messages from disk", guard.messages.len());
|
||||
}
|
||||
}
|
||||
// Eagerly re-write a legacy plaintext file as encrypted on first boot.
|
||||
if was_plaintext
|
||||
&& enc_key()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_some()
|
||||
{
|
||||
persist();
|
||||
tracing::info!("message store: migrated plaintext messages.json to encrypted at rest");
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist current messages to disk.
|
||||
@@ -63,31 +117,62 @@ pub async fn init(data_dir: &Path) {
|
||||
fn persist() {
|
||||
let guard = store().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let path_guard = data_path().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = *enc_key().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(ref path) = *path_guard {
|
||||
if let Ok(content) = serde_json::to_string(&*guard) {
|
||||
if let Ok(content) = serde_json::to_vec(&*guard) {
|
||||
let path = path.clone();
|
||||
drop(path_guard);
|
||||
drop(guard);
|
||||
tokio::task::spawn(async move {
|
||||
let _ = tokio::fs::write(&path, content).await;
|
||||
// Encrypt at rest when the node key is available; fall back to
|
||||
// plaintext rather than drop the write if it somehow isn't.
|
||||
let bytes = match key {
|
||||
Some(k) => crate::storage_crypto::seal(&content, &k).unwrap_or(content),
|
||||
None => content,
|
||||
};
|
||||
// Atomic write: stage to a temp file then rename, so a crash or
|
||||
// reboot mid-write can never truncate/corrupt the real history
|
||||
// (rename is atomic on the same filesystem).
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if tokio::fs::write(&tmp, &bytes).await.is_ok() {
|
||||
let _ = tokio::fs::rename(&tmp, &path).await;
|
||||
} else {
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a received message (called from HTTP handler).
|
||||
pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&str>) {
|
||||
pub fn store_received_sync(
|
||||
from_pubkey: &str,
|
||||
message: &str,
|
||||
from_name: Option<&str>,
|
||||
msg_id: Option<&str>,
|
||||
) {
|
||||
let ts = chrono::Utc::now().to_rfc3339();
|
||||
let mut guard = store().lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
// Deduplication: skip if same pubkey + message within last 30 seconds
|
||||
let dominated = guard.messages.iter().rev().take(20).any(|m| {
|
||||
m.from_pubkey == from_pubkey
|
||||
&& m.message == message
|
||||
&& m.direction == "received"
|
||||
&& within_seconds(&m.timestamp, &ts, 30)
|
||||
});
|
||||
if dominated {
|
||||
// Deduplication. When the sender supplied a unique id, dedup on
|
||||
// (from_pubkey, msg_id) across all retained history — this is robust even
|
||||
// when a slow-Tor redelivery arrives well outside any time window (#31).
|
||||
// Older senders send no id; fall back to the legacy same-pubkey+message
|
||||
// within-30s heuristic.
|
||||
let duplicate = if let Some(id) = msg_id {
|
||||
guard
|
||||
.messages
|
||||
.iter()
|
||||
.any(|m| m.from_pubkey == from_pubkey && m.msg_id.as_deref() == Some(id))
|
||||
} else {
|
||||
guard.messages.iter().rev().take(20).any(|m| {
|
||||
m.from_pubkey == from_pubkey
|
||||
&& m.message == message
|
||||
&& m.direction == "received"
|
||||
&& within_seconds(&m.timestamp, &ts, 30)
|
||||
})
|
||||
};
|
||||
if duplicate {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,6 +180,7 @@ pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&
|
||||
from_pubkey: from_pubkey.to_string(),
|
||||
from_onion: None,
|
||||
from_name: from_name.map(|s| s.to_string()),
|
||||
msg_id: msg_id.map(|s| s.to_string()),
|
||||
message: message.to_string(),
|
||||
timestamp: ts,
|
||||
direction: "received".to_string(),
|
||||
@@ -104,8 +190,13 @@ pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&
|
||||
persist();
|
||||
}
|
||||
|
||||
pub async fn store_received(from_pubkey: &str, message: &str, from_name: Option<&str>) {
|
||||
store_received_sync(from_pubkey, message, from_name);
|
||||
pub async fn store_received(
|
||||
from_pubkey: &str,
|
||||
message: &str,
|
||||
from_name: Option<&str>,
|
||||
msg_id: Option<&str>,
|
||||
) {
|
||||
store_received_sync(from_pubkey, message, from_name, msg_id);
|
||||
}
|
||||
|
||||
/// Store a sent message (for display in Archipelago channel).
|
||||
@@ -115,6 +206,7 @@ pub fn store_sent(message: &str) {
|
||||
from_pubkey: "me".to_string(),
|
||||
from_onion: None,
|
||||
from_name: None,
|
||||
msg_id: None,
|
||||
message: message.to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
direction: "sent".to_string(),
|
||||
@@ -270,6 +362,9 @@ pub async fn send_to_peer(
|
||||
"message": payload_message,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
"encrypted": encrypted,
|
||||
// Unique per-message id so receivers can dedup reliably even across
|
||||
// slow-Tor retries/redeliveries (#31). Old receivers ignore it.
|
||||
"msg_id": uuid::Uuid::new_v4().to_string(),
|
||||
});
|
||||
if let Some(name) = from_name {
|
||||
body["from_name"] = serde_json::Value::String(name.to_string());
|
||||
|
||||
@@ -27,7 +27,7 @@ const D_TAG: &str = "archipelago-node";
|
||||
const LEGACY_RELAYS: &[&str] = &["wss://relay.damus.io", "wss://relay.nostr.info"];
|
||||
|
||||
/// Load or create Nostr keys (secp256k1) for node discovery.
|
||||
async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result<Keys> {
|
||||
pub(crate) async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result<Keys> {
|
||||
let secret_path = identity_dir.join(NOSTR_SECRET_FILE);
|
||||
let pub_path = identity_dir.join(NOSTR_PUB_FILE);
|
||||
|
||||
@@ -78,7 +78,7 @@ async fn load_nostr_keys_if_exists(identity_dir: &Path) -> Result<Option<Keys>>
|
||||
/// Publish a replaceable event with empty content to overwrite/revoke previously published data.
|
||||
/// Uses NIP-33: same kind + d-tag + author = latest replaces. Sends to LEGACY_RELAYS only.
|
||||
/// Requires tor_proxy to avoid leaking IP to relay operators.
|
||||
fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result<Client> {
|
||||
pub(crate) fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result<Client> {
|
||||
let client = if let Some(proxy_str) = tor_proxy {
|
||||
let addr = parse_proxy_addr(proxy_str)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid Nostr Tor proxy: {}", proxy_str))?;
|
||||
|
||||
@@ -18,6 +18,7 @@ const RESERVED_PORTS: &[u16] = &[
|
||||
4080, 8999, 50001, // Mempool stack
|
||||
23000, // BTCPay
|
||||
8173, 8174, 8175, // Fedimint
|
||||
8178, // Fedimint client daemon (fedimint-clientd REST)
|
||||
8123, // Home Assistant
|
||||
3000, // Grafana
|
||||
11434, // Ollama
|
||||
@@ -28,6 +29,7 @@ const RESERVED_PORTS: &[u16] = &[
|
||||
8888, // SearXNG
|
||||
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
|
||||
8443, // FIPS TCP fallback
|
||||
8336, // FIPS UI (fips-ui)
|
||||
];
|
||||
|
||||
/// Start of range for allocating web app ports when preferred is taken.
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
//! ├── HKDF(seed, "archipelago/node/ed25519/v1") → Node Ed25519 → did:key
|
||||
//! ├── HKDF(seed, "archipelago/nostr-node/secp256k1/v1") → Node Nostr key
|
||||
//! ├── HKDF(seed, "archipelago/fips/secp256k1/v1") → FIPS mesh transport key
|
||||
//! ├── HKDF(seed, "archipelago/release/root/ed25519/v1") → Release-root signing key
|
||||
//! │ (publisher-only; nodes pin the PUBLIC key — see trust::anchor)
|
||||
//! ├── HKDF(seed, "archipelago/identity/{i}/ed25519/v1") → Identity i Ed25519
|
||||
//! ├── BIP-32 m/44'/1237'/0'/0/{i} → Identity i Nostr (NIP-06)
|
||||
//! ├── BIP-32 m/84'/0'/0' → Bitcoin Core wallet
|
||||
@@ -34,6 +36,7 @@ const NODE_ED25519_INFO: &[u8] = b"archipelago/node/ed25519/v1";
|
||||
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";
|
||||
|
||||
// ─── MasterSeed ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -88,6 +91,21 @@ pub fn derive_node_ed25519(seed: &MasterSeed) -> Result<SigningKey> {
|
||||
Ok(SigningKey::from_bytes(&derived))
|
||||
}
|
||||
|
||||
/// Derive the fleet **release-root** Ed25519 signing key.
|
||||
///
|
||||
/// This is a *publisher-side* derivation: only the holder of the release master
|
||||
/// seed runs it (e.g. in the signing ceremony). Fleet nodes never derive this —
|
||||
/// they pin the corresponding PUBLIC key as a trust anchor (see
|
||||
/// `crate::trust::anchor`) and use it to verify signed manifests/catalogs.
|
||||
///
|
||||
/// Keeping it seed-derived means the signing key is reproducible from a
|
||||
/// backed-up mnemonic (disaster recovery) rather than a loose key file, and it
|
||||
/// is domain-separated from every node/identity key by its HKDF info string.
|
||||
pub fn derive_release_root_ed25519(seed: &MasterSeed) -> Result<SigningKey> {
|
||||
let derived = hkdf_derive_32(seed.as_bytes(), RELEASE_ROOT_ED25519_INFO)?;
|
||||
Ok(SigningKey::from_bytes(&derived))
|
||||
}
|
||||
|
||||
/// Derive an identity's Ed25519 signing key by index.
|
||||
pub fn derive_identity_ed25519(seed: &MasterSeed, index: u32) -> Result<SigningKey> {
|
||||
let info = format!("archipelago/identity/{}/ed25519/v1", index);
|
||||
@@ -543,4 +561,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_key_known_answer_vs_python_verifier() {
|
||||
// Cross-checks scripts/verify-seed-derivation.py: same mnemonic must
|
||||
// produce the same node_key bytes in Rust and in the Python verifier.
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let key = derive_node_ed25519(&seed).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(key.to_bytes()),
|
||||
"3b4f4a1450450260ae360adb9c33ea5eb86356fa14454ca0067dd4b51ea8be87"
|
||||
);
|
||||
let nostr = derive_node_nostr_key(&seed).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(nostr.secret_key().to_secret_bytes()),
|
||||
"3a94fb32efab2a5025401d53fd7d82b41323a5c06ad14ce528ebe3a813d88831"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_root_deterministic_and_domain_separated() {
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let a = derive_release_root_ed25519(&seed).unwrap();
|
||||
let b = derive_release_root_ed25519(&seed).unwrap();
|
||||
assert_eq!(
|
||||
a.verifying_key().as_bytes(),
|
||||
b.verifying_key().as_bytes(),
|
||||
"Same mnemonic must produce the same release-root key"
|
||||
);
|
||||
// Must NOT collide with the node key — different HKDF domain.
|
||||
let node = derive_node_ed25519(&seed).unwrap();
|
||||
assert_ne!(
|
||||
a.verifying_key().as_bytes(),
|
||||
node.verifying_key().as_bytes(),
|
||||
"Release-root key must be domain-separated from the node key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_root_known_answer() {
|
||||
// KAT pins the derivation so the signing ceremony, the pinned anchor,
|
||||
// and any external verifier agree on the bytes for a given mnemonic.
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let key = derive_release_root_ed25519(&seed).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(key.to_bytes()),
|
||||
"613ab879e5fbd4fcded32bc7ffad662fff1ce0f744c69baa63e7416ffabe7b71",
|
||||
"release-root private key KAT"
|
||||
);
|
||||
assert_eq!(
|
||||
hex::encode(key.verifying_key().to_bytes()),
|
||||
"995eaf9188617f0ecbcff9cd44d57adb9aa7dd5f34db2733e97f3e317fb0aba2",
|
||||
"release-root public key KAT"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,31 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
// DHT swarm-assist (Phase 3): build the iroh provider once at startup so
|
||||
// release downloads can fetch from peers (origin always wins) and seed
|
||||
// what they hold. Inert unless built with `iroh-swarm` AND swarm_enabled.
|
||||
if let Err(e) = crate::swarm::init(
|
||||
&config.data_dir,
|
||||
&config.nostr_relays,
|
||||
config.nostr_tor_proxy.as_deref(),
|
||||
config.swarm_enabled,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Swarm init (non-fatal, falling back to origin-only): {}", e);
|
||||
}
|
||||
|
||||
// Resume any cross-mint ecash swap interrupted by a previous crash
|
||||
// (paid the source mint but never claimed the target tokens). Best-effort.
|
||||
match crate::wallet::ecash::resume_pending_swaps(&config.data_dir).await {
|
||||
Ok(0) => {}
|
||||
Ok(reclaimed) => tracing::info!(
|
||||
"Resumed interrupted cross-mint swaps: reclaimed {} sats",
|
||||
reclaimed
|
||||
),
|
||||
Err(e) => tracing::debug!("resume_pending_swaps (non-fatal): {}", e),
|
||||
}
|
||||
|
||||
// Revoke any previously published Nostr data (runs before publish so revocation is not overwritten)
|
||||
let identity_dir = config.data_dir.join("identity");
|
||||
let tor_proxy_revoke = config.nostr_tor_proxy.clone();
|
||||
@@ -241,6 +266,39 @@ impl Server {
|
||||
warn!("Mesh service start failed (non-fatal): {}", e);
|
||||
} else {
|
||||
info!("📡 Mesh networking started");
|
||||
|
||||
// Push mesh peer changes to open WebSockets instantly
|
||||
// instead of the UI polling every 5s (#48): subscribe to
|
||||
// mesh events and nudge the data-model revision (debounced)
|
||||
// so /ws/db clients refetch peers on discovery/update.
|
||||
let mut rx = mesh_service.state().event_tx.subscribe();
|
||||
let sm = state_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
use tokio::time::{Duration, Instant};
|
||||
let mut last: Option<Instant> = None;
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(crate::mesh::MeshEvent::PeerDiscovered(_))
|
||||
| Ok(crate::mesh::MeshEvent::PeerUpdated(_)) => {
|
||||
// Debounce advert storms to ~2 Hz.
|
||||
if last
|
||||
.map(|t| t.elapsed() < Duration::from_millis(500))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
last = Some(Instant::now());
|
||||
let (data, _) = sm.get_snapshot().await;
|
||||
sm.update_data(data).await;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(
|
||||
_,
|
||||
)) => continue,
|
||||
Err(_) => break, // sender dropped → mesh stopped
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
api_handler
|
||||
@@ -508,6 +566,7 @@ impl Server {
|
||||
{
|
||||
let data_dir = config.data_dir.clone();
|
||||
let state = state_manager.clone();
|
||||
let rpc = api_handler.rpc_handler().clone();
|
||||
tokio::spawn(async move {
|
||||
// First run 60s after boot to let onboarding settle.
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
@@ -558,6 +617,10 @@ impl Server {
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
// After syncing every peer, push the names/roster just
|
||||
// learned (into nodes.json) into the live mesh peer table
|
||||
// so chat contacts refresh without a restart (#42).
|
||||
rpc.refresh_federation_mesh_peers().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -769,6 +832,13 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|
||||
| "/archipelago/mesh-typed"
|
||||
| "/dwn"
|
||||
| "/transport/inbox"
|
||||
// Content *catalog* — the peer-browse entry point. This is the
|
||||
// exact path `/content` (no trailing slash); the prefix match
|
||||
// below only covers `/content/<id>` item fetches, so without
|
||||
// this the catalog 404s over the mesh and `content.browse-peer`
|
||||
// fails with "Peer returned error: 404 Not Found" (and never
|
||||
// falls back to Tor, since a 404 is a successful HTTP exchange).
|
||||
| "/content"
|
||||
)
|
||||
// Prefix-matched content endpoints (peer file browse + fetch)
|
||||
|| path.starts_with("/content/")
|
||||
@@ -1248,6 +1318,7 @@ fn ensure_main_lan_address(pkg: &mut crate::data_model::PackageDataEntry, port:
|
||||
fn fallback_package_port(app_id: &str) -> Option<u16> {
|
||||
match app_id {
|
||||
"fedimint" | "fedimintd" => Some(8175),
|
||||
"fedimint-clientd" => Some(8178),
|
||||
"filebrowser" => Some(8083),
|
||||
"indeedhub" => Some(7778),
|
||||
"nginx-proxy-manager" => Some(8081),
|
||||
@@ -1378,6 +1449,25 @@ mod merge_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_path_filter_allows_content_catalog_and_items() {
|
||||
// Regression: the content *catalog* is exactly "/content" (no trailing
|
||||
// slash). It must be reachable over the peer (FIPS) listener, else
|
||||
// `content.browse-peer` 404s over the mesh. Item fetches are
|
||||
// "/content/<id>".
|
||||
assert!(is_peer_allowed_path("/content"), "catalog must be allowed");
|
||||
assert!(
|
||||
is_peer_allowed_path("/content/abc123"),
|
||||
"items must be allowed"
|
||||
);
|
||||
assert!(is_peer_allowed_path("/rpc/v1"));
|
||||
assert!(is_peer_allowed_path("/health"));
|
||||
// Not on the allow-list → rejected (no broad surface over the mesh).
|
||||
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
|
||||
assert!(!is_peer_allowed_path("/"));
|
||||
assert!(!is_peer_allowed_path("/rpc/v2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_transitional_state_on_merge() {
|
||||
// existing: user initiated a stop, spawn_transitional set Stopping.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! At-rest encryption for local state stores (chat messages, mesh contacts).
|
||||
//!
|
||||
//! Best-practice envelope, matching `credentials::store`:
|
||||
//! - **Key**: SHA-256(domain-separator ‖ node identity key). The node key is
|
||||
//! seed-derived and never leaves the device, so each store is bound to this
|
||||
//! node's identity — a stolen disk image is unreadable without it, and the
|
||||
//! per-domain separator means one store's key can't open another.
|
||||
//! - **Cipher**: ChaCha20-Poly1305 AEAD with a fresh random 96-bit nonce per
|
||||
//! write (`nonce ‖ ciphertext` on disk). The Poly1305 tag makes it
|
||||
//! tamper-evident — any on-disk modification fails to open.
|
||||
//! - **Migration**: legacy plaintext JSON is detected and read transparently,
|
||||
//! then re-written encrypted on the next save. No data is stranded.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
|
||||
/// Domain separators — one per store so keys never overlap.
|
||||
pub const DOMAIN_MESSAGES: &[u8] = b"archipelago-message-store-v1";
|
||||
pub const DOMAIN_MESH_CONTACTS: &[u8] = b"archipelago-mesh-contacts-v1";
|
||||
|
||||
/// Derive a 32-byte key bound to this node's identity for a given store domain.
|
||||
pub async fn derive_key(data_dir: &Path, domain: &[u8]) -> Result<[u8; 32]> {
|
||||
let node_key_path = data_dir.join("identity").join("node_key");
|
||||
let key_bytes = tokio::fs::read(&node_key_path)
|
||||
.await
|
||||
.context("reading node key for at-rest encryption")?;
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(domain);
|
||||
hasher.update(&key_bytes);
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hasher.finalize());
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext`, returning `nonce ‖ ciphertext`.
|
||||
pub fn seal(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("cipher init: {e}"))?;
|
||||
let ct = cipher
|
||||
.encrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes),
|
||||
plaintext,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("encryption failed: {e}"))?;
|
||||
let mut out = Vec::with_capacity(12 + ct.len());
|
||||
out.extend_from_slice(&nonce_bytes);
|
||||
out.extend_from_slice(&ct);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decrypt `nonce ‖ ciphertext`.
|
||||
pub fn open(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
if data.len() < 12 {
|
||||
anyhow::bail!("ciphertext too short");
|
||||
}
|
||||
let (nonce, ct) = data.split_at(12);
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("cipher init: {e}"))?;
|
||||
cipher
|
||||
.decrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
||||
ct,
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("decryption failed — key mismatch or corruption"))
|
||||
}
|
||||
|
||||
/// Heuristic: does this look like legacy plaintext JSON (starts with `{`/`[`)?
|
||||
/// Encrypted blobs start with a random nonce byte, so a `{`/`[` first byte is a
|
||||
/// reliable migration signal.
|
||||
pub fn is_plaintext_json(raw: &[u8]) -> bool {
|
||||
matches!(raw.first(), Some(b'{') | Some(b'['))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn seal_open_round_trips() {
|
||||
let key = [7u8; 32];
|
||||
let msg = br#"{"messages":[{"m":"hi"}]}"#;
|
||||
let sealed = seal(msg, &key).unwrap();
|
||||
// Encrypted output must NOT be readable plaintext.
|
||||
assert!(!is_plaintext_json(&sealed));
|
||||
assert_ne!(&sealed[12..], &msg[..]);
|
||||
assert_eq!(open(&sealed, &key).unwrap(), msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_fails_on_wrong_key_or_tamper() {
|
||||
let sealed = seal(b"secret", &[1u8; 32]).unwrap();
|
||||
assert!(open(&sealed, &[2u8; 32]).is_err());
|
||||
let mut tampered = sealed.clone();
|
||||
*tampered.last_mut().unwrap() ^= 0x01;
|
||||
assert!(open(&tampered, &[1u8; 32]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_plaintext_vs_ciphertext() {
|
||||
assert!(is_plaintext_json(b"{\"a\":1}"));
|
||||
assert!(is_plaintext_json(b"[]"));
|
||||
assert!(!is_plaintext_json(&seal(b"x", &[3u8; 32]).unwrap()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! iroh-blobs swarm provider — the DHT Phase 2 engine, gated behind the
|
||||
//! `iroh-swarm` feature (heavy QUIC dep tree, off by default).
|
||||
//!
|
||||
//! Stands up a real iroh node: binds a QUIC [`Endpoint`], opens a persistent
|
||||
//! blob [`FsStore`] under `data_dir/iroh-blobs`, and serves blobs over the
|
||||
//! iroh-blobs protocol — so a node that *fetches* content also *seeds* it
|
||||
//! afterwards. Content is addressed by BLAKE3 ([`Hash`]) and range-verified by
|
||||
//! iroh on arrival.
|
||||
//!
|
||||
//! This provider is an optimization beneath the origin HTTP path: the [`super`]
|
||||
//! swarm seam falls back to origin whenever [`try_fetch`](IrohProvider::try_fetch)
|
||||
//! returns `Ok(false)` (no known seeds) or `Err` (transient swarm failure).
|
||||
//!
|
||||
//! ## Discovery boundary (Phase 3)
|
||||
//! Downloading needs the [`EndpointId`]s of peers that hold the hash. That
|
||||
//! discovery — design Phase 3, *signed Nostr advertisement events* mapping
|
||||
//! `{content-hash → provider endpoint}` — is injected via [`ProviderDiscovery`].
|
||||
//! Until it is wired, discovery yields nothing and every fetch defers to origin,
|
||||
//! so enabling the feature is safe (never worse than today).
|
||||
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use iroh::{endpoint::presets, protocol::Router, Endpoint, EndpointId};
|
||||
use iroh_blobs::{store::fs::FsStore, BlobsProtocol, Hash};
|
||||
|
||||
use super::payment::PaymentPolicy;
|
||||
use super::BlobProvider;
|
||||
use crate::content_hash::{ContentDigest, HashAlg};
|
||||
|
||||
/// Resolves which peers are believed to hold a given content hash.
|
||||
///
|
||||
/// Phase 3 (signed Nostr advertisement events) provides the production impl
|
||||
/// [`NostrSeedDiscovery`]; `None` discovery means "origin-only" — a safe
|
||||
/// default. The query is async (it hits relays), so the trait is async.
|
||||
#[async_trait]
|
||||
pub trait ProviderDiscovery: Send + Sync {
|
||||
/// Candidate seed endpoints for `hash` (may be empty).
|
||||
async fn providers_for(&self, hash: &Hash) -> Vec<EndpointId>;
|
||||
}
|
||||
|
||||
/// Production [`ProviderDiscovery`]: reads signed seed advertisements from Nostr
|
||||
/// relays and parses the advertised endpoint-id strings into [`EndpointId`]s.
|
||||
///
|
||||
/// Unparseable ids are skipped (an advert from an incompatible/garbage peer must
|
||||
/// not abort discovery). Reuses the node's existing relay list + Tor proxy.
|
||||
pub struct NostrSeedDiscovery {
|
||||
relays: Vec<String>,
|
||||
tor_proxy: Option<String>,
|
||||
}
|
||||
|
||||
impl NostrSeedDiscovery {
|
||||
pub fn new(relays: Vec<String>, tor_proxy: Option<String>) -> Self {
|
||||
Self { relays, tor_proxy }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderDiscovery for NostrSeedDiscovery {
|
||||
async fn providers_for(&self, hash: &Hash) -> Vec<EndpointId> {
|
||||
let hex = hash.to_hex();
|
||||
let ids = super::seed_advert::fetch_seed_endpoint_ids(
|
||||
&self.relays,
|
||||
self.tor_proxy.as_deref(),
|
||||
&hex,
|
||||
)
|
||||
.await;
|
||||
ids.into_iter()
|
||||
.filter_map(|s| match EndpointId::from_str(&s) {
|
||||
Ok(id) => Some(id),
|
||||
Err(e) => {
|
||||
tracing::debug!("swarm: skipping unparseable seed endpoint id {s}: {e}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches content-addressed blobs from the iroh swarm, and seeds what it has.
|
||||
#[allow(dead_code)] // constructed once Phase 3 discovery is wired into providers()
|
||||
pub struct IrohProvider {
|
||||
endpoint: Endpoint,
|
||||
store: FsStore,
|
||||
/// Kept alive so the node keeps accepting blob-protocol connections (seeds).
|
||||
_router: Router,
|
||||
discovery: Option<Arc<dyn ProviderDiscovery>>,
|
||||
/// Where pricing/session/wallet state lives — for paid-fetch negotiation.
|
||||
data_dir: std::path::PathBuf,
|
||||
/// Willingness to pay swarm peers when fetching. Defaults to
|
||||
/// [`PaymentPolicy::free`]: never pay (releases/catalog stay free), so a
|
||||
/// seeder that prices a blob is skipped → origin. A future film fetch can
|
||||
/// pass a real budget.
|
||||
pay_policy: PaymentPolicy,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl IrohProvider {
|
||||
/// Bind an iroh endpoint, open the persistent blob store at
|
||||
/// `data_dir/iroh-blobs`, and start serving blobs (seed capability).
|
||||
pub async fn new(
|
||||
data_dir: &Path,
|
||||
discovery: Option<Arc<dyn ProviderDiscovery>>,
|
||||
) -> Result<Self> {
|
||||
let root = data_dir.join("iroh-blobs");
|
||||
tokio::fs::create_dir_all(&root).await.ok();
|
||||
|
||||
let store = FsStore::load(&root)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("open iroh blob store: {e}"))?;
|
||||
|
||||
let endpoint = Endpoint::bind(presets::N0)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("bind iroh endpoint: {e}"))?;
|
||||
|
||||
// Serve blobs: a node that fetches a blob can then seed it to others.
|
||||
// The event sender gates each request through the ecash `streaming` layer
|
||||
// — free by default, paid only if the operator priced `content-download`
|
||||
// (Networking Profits → Settings). It also hard-disables peer writes.
|
||||
let event_sender =
|
||||
super::paid::gated_event_sender(data_dir.to_path_buf(), (*store).clone());
|
||||
let blobs = BlobsProtocol::new(&store, Some(event_sender));
|
||||
// Shape-A paid negotiation rides a second ALPN on the same endpoint so a
|
||||
// downloader can pay (open a session) before the blob-GET above serves it.
|
||||
let paid =
|
||||
super::paid_alpn::PaidBlobsProtocol::new(data_dir.to_path_buf(), (*store).clone());
|
||||
let router = Router::builder(endpoint.clone())
|
||||
.accept(iroh_blobs::ALPN, blobs)
|
||||
.accept(super::paid_alpn::PAID_ALPN, paid)
|
||||
.spawn();
|
||||
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
store,
|
||||
_router: router,
|
||||
discovery,
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
pay_policy: PaymentPolicy::free(),
|
||||
})
|
||||
}
|
||||
|
||||
/// This node's iroh endpoint id — what Phase 3 advertises as a seed address.
|
||||
pub fn endpoint_id(&self) -> EndpointId {
|
||||
self.endpoint.id()
|
||||
}
|
||||
|
||||
/// Import a held PUBLIC blob into the seed store and advertise it on Nostr so
|
||||
/// other nodes can fetch it from us. Call this only for releases/catalog
|
||||
/// content (the design's privacy scope) — never private user blobs.
|
||||
///
|
||||
/// Importing makes us an actual seed: a node that downloaded a release from
|
||||
/// the HTTP origin can now serve it to peers over iroh-blobs. The advert maps
|
||||
/// `blake3_hex → this endpoint id`. Defensive check: the bytes we import must
|
||||
/// hash to what we advertise, so a path/hash mismatch can never publish a lie.
|
||||
pub async fn seed_and_advertise(
|
||||
&self,
|
||||
path: &Path,
|
||||
blake3_hex: &str,
|
||||
identity_dir: &Path,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let expected = {
|
||||
let raw = hex::decode(blake3_hex).map_err(|e| anyhow::anyhow!("blake3 hex: {e}"))?;
|
||||
let arr: [u8; 32] = raw
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("blake3 digest must be 32 bytes"))?;
|
||||
Hash::from_bytes(arr)
|
||||
};
|
||||
let info = self
|
||||
.store
|
||||
.blobs()
|
||||
.add_path(path)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("import blob into seed store: {e}"))?;
|
||||
if info.hash != expected {
|
||||
anyhow::bail!(
|
||||
"imported blob hash {} != advertised {}",
|
||||
info.hash.to_hex(),
|
||||
blake3_hex
|
||||
);
|
||||
}
|
||||
super::seed_advert::publish_seed_advert(
|
||||
identity_dir,
|
||||
relays,
|
||||
tor_proxy,
|
||||
blake3_hex,
|
||||
&self.endpoint_id().to_string(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlobProvider for IrohProvider {
|
||||
fn name(&self) -> &str {
|
||||
"iroh"
|
||||
}
|
||||
|
||||
async fn try_fetch(&self, digest: &ContentDigest, dest: &Path) -> Result<bool> {
|
||||
// iroh addresses content by BLAKE3. A sha256-only digest isn't fetchable
|
||||
// from the swarm — defer to origin.
|
||||
if digest.alg != HashAlg::Blake3 {
|
||||
return Ok(false);
|
||||
}
|
||||
let raw = hex::decode(&digest.hex).map_err(|e| anyhow::anyhow!("digest hex: {e}"))?;
|
||||
let arr: [u8; 32] = raw
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("blake3 digest must be 32 bytes"))?;
|
||||
let hash = Hash::from_bytes(arr);
|
||||
|
||||
// Who has it? Without discovery (Phase 3) this is empty → origin wins.
|
||||
let providers = match &self.discovery {
|
||||
Some(d) => d.providers_for(&hash).await,
|
||||
None => Vec::new(),
|
||||
};
|
||||
if providers.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Shape-A: negotiate paid access with each candidate. Best-effort and
|
||||
// additive — a peer is dropped only if it explicitly requires a payment
|
||||
// we won't make under `pay_policy` (free by default → priced seeders are
|
||||
// skipped). Connect/protocol failures keep the peer; the blob-GET gate is
|
||||
// the real enforcement and a refused GET still falls back to origin.
|
||||
let mut allowed = Vec::with_capacity(providers.len());
|
||||
for peer in providers {
|
||||
if super::paid_alpn::negotiate_access(
|
||||
&self.endpoint,
|
||||
&self.data_dir,
|
||||
peer,
|
||||
&digest.hex,
|
||||
&self.pay_policy,
|
||||
)
|
||||
.await
|
||||
{
|
||||
allowed.push(peer);
|
||||
}
|
||||
}
|
||||
if allowed.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Fetch (range-verified by iroh) then export the verified blob to the
|
||||
// staging path the caller expects. The seam re-verifies the digest.
|
||||
let downloader = self.store.downloader(&self.endpoint);
|
||||
downloader
|
||||
.download(hash, allowed)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("iroh swarm download: {e}"))?;
|
||||
self.store
|
||||
.blobs()
|
||||
.export(hash, dest)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("export blob to staging: {e}"))?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Swarm-assist content fetch — the *transport & swarm* tier of the DHT
|
||||
//! distribution plan (`docs/dht-distribution-design.md` §4).
|
||||
//!
|
||||
//! ## Guiding principle: swarm-assist, origin ALWAYS wins
|
||||
//! The peer swarm is an optimization layered *above* a proven HTTP path, never
|
||||
//! in place of it. A node asks each available [`BlobProvider`] (e.g. an
|
||||
//! iroh-blobs swarm) for content by its [`ContentDigest`]; the first peer that
|
||||
//! serves bytes which **verify** against the digest wins. If no provider has it
|
||||
//! — or the swarm is disabled, or every peer is offline — we fall back to the
|
||||
//! origin HTTP download, which is the guaranteed source of truth. Worst case is
|
||||
//! exactly today's behaviour.
|
||||
//!
|
||||
//! Peer-sourced bytes are UNTRUSTED, so this module verifies them against the
|
||||
//! content digest before accepting. Origin bytes run through the caller's
|
||||
//! existing verification (e.g. the SHA-256 gate in `update.rs`).
|
||||
//!
|
||||
//! The actual iroh-blobs provider is gated behind the `iroh-swarm` feature
|
||||
//! (heavy QUIC dep tree); with the feature off, [`providers`] is empty and
|
||||
//! every fetch goes straight to origin — byte-for-byte today's path.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::content_hash::ContentDigest;
|
||||
|
||||
pub mod payment;
|
||||
pub mod seed_advert;
|
||||
|
||||
#[cfg(feature = "iroh-swarm")]
|
||||
pub mod iroh_provider;
|
||||
|
||||
#[cfg(feature = "iroh-swarm")]
|
||||
pub mod paid;
|
||||
|
||||
#[cfg(feature = "iroh-swarm")]
|
||||
pub mod paid_alpn;
|
||||
|
||||
/// Which source ultimately served the content.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FetchSource {
|
||||
/// A peer in the swarm served (and the bytes verified).
|
||||
Swarm,
|
||||
/// The origin HTTP fallback served.
|
||||
Origin,
|
||||
}
|
||||
|
||||
/// A source that may be able to serve content addressed by its digest.
|
||||
#[async_trait]
|
||||
pub trait BlobProvider: Send + Sync {
|
||||
/// Short name for logging (e.g. "iroh").
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Try to fetch the content for `digest` into `dest`.
|
||||
///
|
||||
/// * `Ok(true)` — bytes written to `dest` (caller verifies the digest).
|
||||
/// * `Ok(false)` — this provider does not have the content; try the next.
|
||||
/// * `Err(_)` — a transient failure; try the next provider.
|
||||
async fn try_fetch(&self, digest: &ContentDigest, dest: &Path) -> Result<bool>;
|
||||
}
|
||||
|
||||
/// Process-wide swarm runtime, built once at startup by [`init`]. Holding the
|
||||
/// providers here (rather than rebuilding per download) keeps the iroh endpoint
|
||||
/// + blob store + protocol router alive for the life of the process, so a node
|
||||
/// keeps *seeding* between downloads. Empty/inert unless the `iroh-swarm`
|
||||
/// feature is built AND `swarm_enabled` is set.
|
||||
struct SwarmRuntime {
|
||||
providers: Vec<Arc<dyn BlobProvider>>,
|
||||
/// Context for announcing held public blobs; `None` when seeding is off.
|
||||
#[cfg(feature = "iroh-swarm")]
|
||||
announce: Option<AnnounceCtx>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh-swarm")]
|
||||
struct AnnounceCtx {
|
||||
iroh: Arc<iroh_provider::IrohProvider>,
|
||||
relays: Vec<String>,
|
||||
tor_proxy: Option<String>,
|
||||
identity_dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
static RUNTIME: OnceLock<SwarmRuntime> = OnceLock::new();
|
||||
|
||||
/// Build the swarm runtime once, at startup. Idempotent: a second call is a
|
||||
/// no-op (the first registration wins). Safe to call unconditionally — when the
|
||||
/// `iroh-swarm` feature is absent, or `enabled` is false, it registers an empty
|
||||
/// runtime so every fetch goes straight to origin (today's path).
|
||||
///
|
||||
/// `relays` / `tor_proxy` come from the node's Nostr config and double as the
|
||||
/// seed-advert transport; `data_dir` hosts the persistent iroh blob store under
|
||||
/// `data_dir/iroh-blobs` and the node identity under `data_dir/identity`.
|
||||
pub async fn init(
|
||||
data_dir: &Path,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
enabled: bool,
|
||||
) -> Result<()> {
|
||||
if RUNTIME.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "iroh-swarm"))]
|
||||
{
|
||||
let _ = (data_dir, relays, tor_proxy);
|
||||
if enabled {
|
||||
warn!("swarm: swarm_enabled set but binary built without the `iroh-swarm` feature — staying origin-only");
|
||||
}
|
||||
let _ = RUNTIME.set(SwarmRuntime {
|
||||
providers: Vec::new(),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh-swarm")]
|
||||
{
|
||||
if !enabled {
|
||||
info!("swarm: disabled (swarm_enabled=false) — origin-only");
|
||||
let _ = RUNTIME.set(SwarmRuntime {
|
||||
providers: Vec::new(),
|
||||
announce: None,
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let discovery: Arc<dyn iroh_provider::ProviderDiscovery> = Arc::new(
|
||||
iroh_provider::NostrSeedDiscovery::new(relays.to_vec(), tor_proxy.map(str::to_string)),
|
||||
);
|
||||
let provider = Arc::new(iroh_provider::IrohProvider::new(data_dir, Some(discovery)).await?);
|
||||
info!(
|
||||
"swarm: iroh provider active (endpoint {}) — swarm-assist enabled, origin always wins",
|
||||
provider.endpoint_id()
|
||||
);
|
||||
let providers: Vec<Arc<dyn BlobProvider>> = vec![provider.clone()];
|
||||
let _ = RUNTIME.set(SwarmRuntime {
|
||||
providers,
|
||||
announce: Some(AnnounceCtx {
|
||||
iroh: provider,
|
||||
relays: relays.to_vec(),
|
||||
tor_proxy: tor_proxy.map(str::to_string),
|
||||
identity_dir: data_dir.join("identity"),
|
||||
}),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The ordered list of swarm providers to consult before the origin.
|
||||
///
|
||||
/// Empty until [`init`] registers a provider (needs the `iroh-swarm` feature +
|
||||
/// `swarm_enabled`). While empty, [`fetch_content_addressed`] goes straight to
|
||||
/// origin — byte-for-byte today's path.
|
||||
pub fn providers() -> Vec<Arc<dyn BlobProvider>> {
|
||||
RUNTIME
|
||||
.get()
|
||||
.map(|r| r.providers.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Announce that this node now holds a PUBLIC release/catalog blob (addressed by
|
||||
/// `blake3_hex`, bytes at `path`) so peers can fetch it from us: import it into
|
||||
/// the seed store and publish a signed Nostr advert. Best-effort and inert
|
||||
/// unless the iroh provider is active — a failure never affects the install.
|
||||
///
|
||||
/// **Scope:** call only for releases/catalog content, never private user blobs.
|
||||
pub async fn announce_held_blob(_blake3_hex: &str, _path: &Path) {
|
||||
#[cfg(feature = "iroh-swarm")]
|
||||
{
|
||||
let Some(rt) = RUNTIME.get() else { return };
|
||||
let Some(ctx) = rt.announce.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ctx
|
||||
.iroh
|
||||
.seed_and_advertise(
|
||||
_path,
|
||||
_blake3_hex,
|
||||
&ctx.identity_dir,
|
||||
&ctx.relays,
|
||||
ctx.tor_proxy.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("swarm: failed to announce held blob {_blake3_hex}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch content-addressed bytes: swarm-assist, origin always wins.
|
||||
///
|
||||
/// Tries each provider in order; the first to write bytes that VERIFY against
|
||||
/// `digest` wins and returns [`FetchSource::Swarm`]. If none succeed, runs
|
||||
/// `origin` (the guaranteed HTTP fallback) and returns [`FetchSource::Origin`].
|
||||
/// A node that obtained bytes from the swarm has, by definition, a verified
|
||||
/// copy it can itself seed afterwards.
|
||||
pub async fn fetch_content_addressed<F, Fut>(
|
||||
digest: &ContentDigest,
|
||||
providers: &[Arc<dyn BlobProvider>],
|
||||
dest: &Path,
|
||||
origin: F,
|
||||
) -> Result<FetchSource>
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
for provider in providers {
|
||||
match provider.try_fetch(digest, dest).await {
|
||||
Ok(true) => match verify_dest(digest, dest).await {
|
||||
Ok(()) => {
|
||||
info!("swarm: {} served {} (verified)", provider.name(), digest);
|
||||
return Ok(FetchSource::Swarm);
|
||||
}
|
||||
Err(e) => {
|
||||
// A peer served bytes that don't match the digest — could be
|
||||
// corruption or a malicious seed. Discard and try the next
|
||||
// source; never let unverified peer bytes through.
|
||||
warn!(
|
||||
"swarm: {} served bytes failing verification for {}: {} — discarding",
|
||||
provider.name(),
|
||||
digest,
|
||||
e
|
||||
);
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
}
|
||||
},
|
||||
Ok(false) => debug!("swarm: {} does not have {}", provider.name(), digest),
|
||||
Err(e) => debug!("swarm: {} failed for {}: {}", provider.name(), digest, e),
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"swarm: no provider served {} — falling back to origin",
|
||||
digest
|
||||
);
|
||||
origin().await?;
|
||||
Ok(FetchSource::Origin)
|
||||
}
|
||||
|
||||
/// Read `dest` and verify it hashes to `digest`.
|
||||
async fn verify_dest(digest: &ContentDigest, dest: &Path) -> Result<()> {
|
||||
let bytes = tokio::fs::read(dest).await?;
|
||||
digest.verify(&bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
fn digest_of(bytes: &[u8]) -> ContentDigest {
|
||||
ContentDigest::parse(&format!(
|
||||
"blake3:{}",
|
||||
crate::content_hash::blake3_hex(bytes)
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Provider that writes a fixed payload (which may or may not match).
|
||||
struct FixedProvider {
|
||||
name: &'static str,
|
||||
payload: Option<Vec<u8>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl BlobProvider for FixedProvider {
|
||||
fn name(&self) -> &str {
|
||||
self.name
|
||||
}
|
||||
async fn try_fetch(&self, _d: &ContentDigest, dest: &Path) -> Result<bool> {
|
||||
match &self.payload {
|
||||
Some(p) => {
|
||||
tokio::fs::write(dest, p).await?;
|
||||
Ok(true)
|
||||
}
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn arc(p: FixedProvider) -> Arc<dyn BlobProvider> {
|
||||
Arc::new(p)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn swarm_hit_verifies_and_skips_origin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dest = dir.path().join("out");
|
||||
let content = b"hello swarm".to_vec();
|
||||
let digest = digest_of(&content);
|
||||
let providers = vec![arc(FixedProvider {
|
||||
name: "good",
|
||||
payload: Some(content.clone()),
|
||||
})];
|
||||
let origin_ran = AtomicBool::new(false);
|
||||
let src = fetch_content_addressed(&digest, &providers, &dest, || async {
|
||||
origin_ran.store(true, Ordering::SeqCst);
|
||||
tokio::fs::write(&dest, b"from-origin").await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(src, FetchSource::Swarm);
|
||||
assert!(
|
||||
!origin_ran.load(Ordering::SeqCst),
|
||||
"origin must not run on swarm hit"
|
||||
);
|
||||
assert_eq!(tokio::fs::read(&dest).await.unwrap(), content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_swarm_bytes_are_discarded_and_origin_wins() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dest = dir.path().join("out");
|
||||
let content = b"the real bytes".to_vec();
|
||||
let digest = digest_of(&content);
|
||||
// Provider claims a hit but serves tampered bytes.
|
||||
let providers = vec![arc(FixedProvider {
|
||||
name: "evil",
|
||||
payload: Some(b"TAMPERED".to_vec()),
|
||||
})];
|
||||
let src = fetch_content_addressed(&digest, &providers, &dest, || async {
|
||||
tokio::fs::write(&dest, &content).await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
src,
|
||||
FetchSource::Origin,
|
||||
"tampered swarm bytes must not be accepted"
|
||||
);
|
||||
assert_eq!(tokio::fs::read(&dest).await.unwrap(), content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_providers_goes_straight_to_origin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dest = dir.path().join("out");
|
||||
let content = b"x".to_vec();
|
||||
let digest = digest_of(&content);
|
||||
let providers: Vec<Arc<dyn BlobProvider>> = vec![];
|
||||
let src = fetch_content_addressed(&digest, &providers, &dest, || async {
|
||||
tokio::fs::write(&dest, &content).await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(src, FetchSource::Origin);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_through_providers_in_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dest = dir.path().join("out");
|
||||
let content = b"second wins".to_vec();
|
||||
let digest = digest_of(&content);
|
||||
let providers = vec![
|
||||
arc(FixedProvider {
|
||||
name: "miss",
|
||||
payload: None,
|
||||
}),
|
||||
arc(FixedProvider {
|
||||
name: "hit",
|
||||
payload: Some(content.clone()),
|
||||
}),
|
||||
];
|
||||
let src = fetch_content_addressed(&digest, &providers, &dest, || async {
|
||||
tokio::fs::write(&dest, b"origin").await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(src, FetchSource::Swarm);
|
||||
assert_eq!(tokio::fs::read(&dest).await.unwrap(), content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Paid swarm serving — gate the iroh-blobs provider through the ecash
|
||||
//! `streaming` payment layer (DHT distribution plan, Phase 4 step F).
|
||||
//!
|
||||
//! ## Free by default
|
||||
//! Serving is **free unless the node operator turns it on** in
|
||||
//! *Networking Profits → Settings* (which enables the `content-download`
|
||||
//! streaming service). With that service disabled — the shipped default —
|
||||
//! [`is_authorized`] returns `true` for everyone and behaviour is byte-for-byte
|
||||
//! the old open seeder. When it is enabled, a peer must hold an active paid
|
||||
//! session (opened out-of-band via the `streaming.pay` RPC with a Cashu token)
|
||||
//! before the swarm will serve them; otherwise the request is refused and they
|
||||
//! fall back to the HTTP origin.
|
||||
//!
|
||||
//! ## How it hooks in
|
||||
//! iroh-blobs 0.103 lets a provider authorize each request: we pass an
|
||||
//! [`EventSender`] (built here) to `BlobsProtocol::new`, set the [`EventMask`]
|
||||
//! to intercept connections + GET requests, and answer each one with
|
||||
//! `Ok(())` (serve) or `Err(AbortReason::Permission)` (refuse). Peer-initiated
|
||||
//! writes (`push`) are hard-disabled so a peer can never mutate our store.
|
||||
//!
|
||||
//! Scope note: today every swarm blob is a public release/app component, so the
|
||||
//! gate only ever charges if the operator explicitly priced `content-download`.
|
||||
//! When IndeeHub films land on the same blob layer (Phase 4), they reuse this
|
||||
//! exact path.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use iroh::EndpointId;
|
||||
use iroh_blobs::api::blobs::BlobStatus;
|
||||
use iroh_blobs::api::Store;
|
||||
use iroh_blobs::provider::events::{
|
||||
AbortReason, ConnectMode, EventMask, EventResult, EventSender, ObserveMode, ProviderMessage,
|
||||
RequestMode, ThrottleMode,
|
||||
};
|
||||
use iroh_blobs::Hash;
|
||||
|
||||
use crate::streaming::gate::{self, GateResult};
|
||||
|
||||
/// The streaming pricing service that meters swarm blob serving. Enabling it in
|
||||
/// the Settings UI is what flips swarm serving from free to paid.
|
||||
const SERVICE_ID: &str = "content-download";
|
||||
|
||||
/// Build the gated [`EventSender`] for `BlobsProtocol` and spawn the task that
|
||||
/// authorizes each blob GET through the ecash gate.
|
||||
///
|
||||
/// `data_dir` locates the pricing/session state; `store` is cloned in to look up
|
||||
/// blob sizes for metering. The spawned task lives as long as the provider keeps
|
||||
/// the returned sender alive (i.e. the life of the node).
|
||||
pub fn gated_event_sender(data_dir: PathBuf, store: Store) -> EventSender {
|
||||
// Intercept connections + read requests so we can allow/deny per peer & hash.
|
||||
// `push` (peer writes into our store) is hard-disabled. `throttle`/`observe`
|
||||
// stay off — we meter coarsely at request time, not per 16 KiB chunk.
|
||||
let mask = EventMask {
|
||||
connected: ConnectMode::Intercept,
|
||||
get: RequestMode::Intercept,
|
||||
get_many: RequestMode::Intercept,
|
||||
push: RequestMode::Disabled,
|
||||
observe: ObserveMode::None,
|
||||
throttle: ThrottleMode::None,
|
||||
};
|
||||
let (sender, mut rx) = EventSender::channel(64, mask);
|
||||
tokio::spawn(async move {
|
||||
// connection_id → remote endpoint id, learned at ClientConnected and used
|
||||
// to key the paying peer's streaming session on each request.
|
||||
let mut peers: HashMap<u64, Option<EndpointId>> = HashMap::new();
|
||||
while let Some(msg) = rx.recv().await {
|
||||
match msg {
|
||||
ProviderMessage::ClientConnected(m) => {
|
||||
peers.insert(m.inner.connection_id, m.inner.endpoint_id);
|
||||
// Accept the connection; gating happens per request.
|
||||
let _ = m.tx.send(Ok(())).await;
|
||||
}
|
||||
ProviderMessage::ConnectionClosed(m) => {
|
||||
peers.remove(&m.inner.connection_id);
|
||||
}
|
||||
ProviderMessage::GetRequestReceived(m) => {
|
||||
let peer = peers.get(&m.inner.connection_id).copied().flatten();
|
||||
let hash = m.inner.request.hash;
|
||||
let verdict = authorize(&data_dir, &store, peer, &hash).await;
|
||||
let _ = m.tx.send(verdict).await;
|
||||
}
|
||||
ProviderMessage::GetManyRequestReceived(m) => {
|
||||
let peer = peers.get(&m.inner.connection_id).copied().flatten();
|
||||
// A get-many is all-or-nothing here: authorize on the first hash.
|
||||
let verdict = match m.inner.request.hashes.first().copied() {
|
||||
Some(h) => authorize(&data_dir, &store, peer, &h).await,
|
||||
None => Ok(()),
|
||||
};
|
||||
let _ = m.tx.send(verdict).await;
|
||||
}
|
||||
ProviderMessage::PushRequestReceived(m) => {
|
||||
// Disabled in the mask; refuse defensively if one ever arrives.
|
||||
let _ = m.tx.send(Err(AbortReason::Permission)).await;
|
||||
}
|
||||
// Notify-only variants, observe and throttle: nothing to gate.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
sender
|
||||
}
|
||||
|
||||
/// Authorize one blob GET, returning the iroh [`EventResult`]
|
||||
/// (`Ok(())` = serve, `Err(Permission)` = refuse).
|
||||
async fn authorize(
|
||||
data_dir: &Path,
|
||||
store: &Store,
|
||||
peer: Option<EndpointId>,
|
||||
hash: &Hash,
|
||||
) -> EventResult {
|
||||
// Cost = full blob size (coarse, request-time metering). If we don't hold the
|
||||
// complete blob there's nothing to meter — let iroh serve what it can.
|
||||
let size = match store.blobs().status(*hash).await {
|
||||
Ok(BlobStatus::Complete { size }) => size,
|
||||
_ => 0,
|
||||
};
|
||||
let peer_id = peer
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_else(|| "anonymous".to_string());
|
||||
if is_authorized(data_dir, &peer_id, size).await {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AbortReason::Permission)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure allow/deny decision (no iroh types) — unit-testable without a live node.
|
||||
async fn is_authorized(data_dir: &Path, peer_id: &str, size: u64) -> bool {
|
||||
match gate::check_gate(data_dir, peer_id, SERVICE_ID, None, size).await {
|
||||
// Service disabled (the default) → free for everyone. Or the peer holds an
|
||||
// active paid session with remaining allotment.
|
||||
Ok(GateResult::ServiceUnavailable)
|
||||
| Ok(GateResult::Allowed { .. })
|
||||
| Ok(GateResult::PaidAndAllowed { .. }) => true,
|
||||
// Metered + no/exhausted session: the peer must pay out-of-band first
|
||||
// (streaming.pay) before the swarm serves them — they fall back to origin.
|
||||
Ok(_) => false,
|
||||
// Never let a payment-layer fault break content distribution: fail OPEN
|
||||
// (serve free) and log. Availability beats revenue when something breaks.
|
||||
Err(e) => {
|
||||
tracing::warn!("paid-gate: check errored ({e}); serving free");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::streaming::pricing::{self, Metric, PricingConfig, ServicePricing};
|
||||
|
||||
fn content_download(enabled: bool) -> PricingConfig {
|
||||
PricingConfig {
|
||||
services: vec![ServicePricing {
|
||||
service_id: SERVICE_ID.to_string(),
|
||||
name: "Content Downloads".to_string(),
|
||||
metric: Metric::Bytes,
|
||||
step_size: 1_048_576,
|
||||
price_per_step: 1,
|
||||
min_steps: 0,
|
||||
enabled,
|
||||
description: String::new(),
|
||||
accepted_mints: vec![],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn free_when_service_disabled_by_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// No pricing file → defaults → content-download disabled → free for all.
|
||||
assert!(is_authorized(dir.path(), "peer-a", 1_000_000).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn free_when_service_explicitly_disabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
pricing::save_pricing(dir.path(), &content_download(false))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(is_authorized(dir.path(), "peer-a", 1_048_576).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn denied_when_metered_and_peer_has_not_paid() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
pricing::save_pricing(dir.path(), &content_download(true))
|
||||
.await
|
||||
.unwrap();
|
||||
// Enabled service + no session/token → the swarm refuses; peer uses origin.
|
||||
assert!(!is_authorized(dir.path(), "peer-b", 1_048_576).await);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//! Shape-A paid-blobs negotiation ALPN (`archy/paid-blobs/1`) — the on-wire
|
||||
//! exchange that lets a downloader pay a seeder *before* fetching a gated blob
|
||||
//! (DHT distribution plan §1, "shape A"). Gated behind `iroh-swarm`.
|
||||
//!
|
||||
//! ## Why a side ALPN
|
||||
//! iroh-blobs carries the raw bytes; this tiny request/grant protocol rides a
|
||||
//! second ALPN on the *same* endpoint so a downloader can discover the price and
|
||||
//! deliver an ecash token first. The token opens a metered `streaming` session
|
||||
//! keyed by the downloader's endpoint id — exactly the session the blob-GET gate
|
||||
//! ([`super::paid`]) already checks. Same endpoint → same session → the GET is
|
||||
//! then served.
|
||||
//!
|
||||
//! ```text
|
||||
//! B ──(archy/paid-blobs/1)──▶ A PaidRequest { want: H, token: None }
|
||||
//! B ◀─────────────────────── A PaymentRequired { price, accepted_mints }
|
||||
//! B: auto_pay_token(...) ── builds a cashuA token (cross-mint aware)
|
||||
//! B ──(archy/paid-blobs/1)──▶ A PaidRequest { want: H, token: Some(t) }
|
||||
//! B ◀─────────────────────── A Granted (session now exists on A)
|
||||
//! B ──(iroh-blobs ALPN)─────▶ A GET H → served (gate sees the session)
|
||||
//! ```
|
||||
//!
|
||||
//! ## North star: origin always wins, releases stay free
|
||||
//! Negotiation is **best-effort and additive**. A peer that doesn't speak this
|
||||
//! ALPN, or any connect/protocol error, is treated as "proceed" — the blob-GET
|
||||
//! gate is the real enforcement, and a denied GET just falls back to origin.
|
||||
//! With the default [`PaymentPolicy::free`] a downloader never sends a token, so
|
||||
//! a seeder that prices a blob is simply skipped → origin. Only films (a future
|
||||
//! caller with a real budget) will actually pay.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use iroh::endpoint::Connection;
|
||||
use iroh::protocol::{AcceptError, ProtocolHandler};
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId};
|
||||
use iroh_blobs::api::blobs::BlobStatus;
|
||||
use iroh_blobs::api::Store;
|
||||
use iroh_blobs::Hash;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::payment::PaymentPolicy;
|
||||
use crate::streaming::gate::{self, GateResult};
|
||||
|
||||
/// ALPN for the paid-blobs negotiation protocol.
|
||||
pub const PAID_ALPN: &[u8] = b"archy/paid-blobs/1";
|
||||
|
||||
/// The streaming service that meters swarm blob serving (same id as [`super::paid`]).
|
||||
const SERVICE_ID: &str = "content-download";
|
||||
|
||||
/// Cap on a single negotiation message (JSON). Requests/responses are tiny.
|
||||
const MAX_MSG: usize = 64 * 1024;
|
||||
|
||||
/// A downloader's ask for one content-addressed blob, optionally with payment.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct PaidRequest {
|
||||
/// BLAKE3 hex of the wanted blob.
|
||||
want: String,
|
||||
/// A `cashuA` token, present on the paying retry.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
/// The seeder's verdict.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
enum PaidResponse {
|
||||
/// Fetch away — free, or a paid session is now active for this peer.
|
||||
Granted,
|
||||
/// Payment needed before serving. The downloader may pay and retry.
|
||||
PaymentRequired {
|
||||
price_sats: u64,
|
||||
accepted_mints: Vec<String>,
|
||||
},
|
||||
/// Refused (bad request, insufficient/failed payment).
|
||||
Denied { reason: String },
|
||||
}
|
||||
|
||||
// ── Serve side ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Accept-side handler for [`PAID_ALPN`]. Registered on the provider's `Router`
|
||||
/// alongside the iroh-blobs protocol.
|
||||
#[derive(Clone)]
|
||||
pub struct PaidBlobsProtocol {
|
||||
data_dir: PathBuf,
|
||||
store: Store,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PaidBlobsProtocol {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PaidBlobsProtocol").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PaidBlobsProtocol {
|
||||
pub fn new(data_dir: PathBuf, store: Store) -> Self {
|
||||
Self { data_dir, store }
|
||||
}
|
||||
|
||||
/// Decide the verdict for a request from `peer`. Mirrors [`super::paid`]'s
|
||||
/// policy: free when the service is disabled (default) or the peer holds an
|
||||
/// active session; payment-required when metered and unpaid; fail-OPEN
|
||||
/// (Granted) on an internal gate error so a fault never blocks distribution.
|
||||
async fn decide(&self, peer: &str, req: &PaidRequest) -> PaidResponse {
|
||||
let size = self.blob_size(&req.want).await;
|
||||
match gate::check_gate(&self.data_dir, peer, SERVICE_ID, req.token.as_deref(), size).await {
|
||||
Ok(GateResult::ServiceUnavailable)
|
||||
| Ok(GateResult::Allowed { .. })
|
||||
| Ok(GateResult::PaidAndAllowed { .. }) => PaidResponse::Granted,
|
||||
Ok(GateResult::PaymentRequired {
|
||||
minimum_sats,
|
||||
pricing,
|
||||
..
|
||||
}) => PaidResponse::PaymentRequired {
|
||||
price_sats: minimum_sats,
|
||||
accepted_mints: pricing.accepted_mints,
|
||||
},
|
||||
Ok(GateResult::InsufficientPayment {
|
||||
provided_sats,
|
||||
minimum_sats,
|
||||
}) => PaidResponse::Denied {
|
||||
reason: format!("insufficient payment: {provided_sats} < {minimum_sats} sats"),
|
||||
},
|
||||
Ok(GateResult::PaymentFailed { reason }) => PaidResponse::Denied { reason },
|
||||
// Availability beats revenue: a gate fault serves free, matching the
|
||||
// blob-GET gate's fail-open behaviour.
|
||||
Err(e) => {
|
||||
tracing::warn!("paid-alpn: gate errored ({e}); granting free");
|
||||
PaidResponse::Granted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full size of a held blob (for metering); 0 if we don't hold it complete.
|
||||
async fn blob_size(&self, blake3_hex: &str) -> u64 {
|
||||
let Ok(raw) = hex::decode(blake3_hex) else {
|
||||
return 0;
|
||||
};
|
||||
let Ok(arr) = <[u8; 32]>::try_from(raw.as_slice()) else {
|
||||
return 0;
|
||||
};
|
||||
match self.store.blobs().status(Hash::from_bytes(arr)).await {
|
||||
Ok(BlobStatus::Complete { size }) => size,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolHandler for PaidBlobsProtocol {
|
||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||
let peer = connection.remote_id().to_string();
|
||||
// One bi-stream per request (a paying downloader opens a second one).
|
||||
loop {
|
||||
let (mut send, mut recv) = match connection.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
// Connection closed by the peer — normal end of negotiation.
|
||||
Err(_) => break,
|
||||
};
|
||||
let buf = recv
|
||||
.read_to_end(MAX_MSG)
|
||||
.await
|
||||
.map_err(AcceptError::from_err)?;
|
||||
let response = match serde_json::from_slice::<PaidRequest>(&buf) {
|
||||
Ok(req) => self.decide(&peer, &req).await,
|
||||
Err(e) => PaidResponse::Denied {
|
||||
reason: format!("bad request: {e}"),
|
||||
},
|
||||
};
|
||||
let bytes = serde_json::to_vec(&response).map_err(AcceptError::from_err)?;
|
||||
send.write_all(&bytes)
|
||||
.await
|
||||
.map_err(AcceptError::from_err)?;
|
||||
send.finish().map_err(AcceptError::from_err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetch side ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Negotiate access to `blake3_hex` from `peer` before fetching. Returns whether
|
||||
/// the caller should proceed to download from this peer.
|
||||
///
|
||||
/// Best-effort: any connect/protocol failure returns `true` (proceed — the
|
||||
/// blob-GET gate is the real enforcement, and a denied GET falls back to origin).
|
||||
/// Returns `false` only when the seeder explicitly requires a payment we won't or
|
||||
/// can't make under `policy`.
|
||||
pub async fn negotiate_access(
|
||||
endpoint: &Endpoint,
|
||||
data_dir: &Path,
|
||||
peer: EndpointId,
|
||||
blake3_hex: &str,
|
||||
policy: &PaymentPolicy,
|
||||
) -> bool {
|
||||
match negotiate_inner(endpoint, data_dir, peer, blake3_hex, policy).await {
|
||||
Ok(proceed) => proceed,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"paid-alpn: negotiation with {peer} failed ({e}) — proceeding (gate decides)"
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn negotiate_inner(
|
||||
endpoint: &Endpoint,
|
||||
data_dir: &Path,
|
||||
peer: EndpointId,
|
||||
blake3_hex: &str,
|
||||
policy: &PaymentPolicy,
|
||||
) -> Result<bool> {
|
||||
let conn = endpoint.connect(EndpointAddr::new(peer), PAID_ALPN).await?;
|
||||
|
||||
// First ask with no token.
|
||||
let resp = exchange(
|
||||
&conn,
|
||||
&PaidRequest {
|
||||
want: blake3_hex.to_string(),
|
||||
token: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
match resp {
|
||||
PaidResponse::Granted => Ok(true),
|
||||
PaidResponse::Denied { .. } => Ok(false),
|
||||
PaidResponse::PaymentRequired {
|
||||
price_sats,
|
||||
accepted_mints,
|
||||
} => {
|
||||
// Build a token within budget (cross-mint aware); None ⇒ use origin.
|
||||
match super::payment::auto_pay_token(data_dir, policy, &accepted_mints, price_sats)
|
||||
.await?
|
||||
{
|
||||
None => Ok(false),
|
||||
Some(token) => {
|
||||
let resp2 = exchange(
|
||||
&conn,
|
||||
&PaidRequest {
|
||||
want: blake3_hex.to_string(),
|
||||
token: Some(token),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(matches!(resp2, PaidResponse::Granted))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One request/response round trip on a fresh bi-stream.
|
||||
async fn exchange(conn: &Connection, req: &PaidRequest) -> Result<PaidResponse> {
|
||||
let (mut send, mut recv) = conn.open_bi().await?;
|
||||
send.write_all(&serde_json::to_vec(req)?).await?;
|
||||
send.finish()?;
|
||||
let buf = recv.read_to_end(MAX_MSG).await?;
|
||||
Ok(serde_json::from_slice(&buf)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_round_trips_and_omits_absent_token() {
|
||||
let req = PaidRequest {
|
||||
want: "abcd".into(),
|
||||
token: None,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(
|
||||
!json.contains("token"),
|
||||
"absent token must be omitted: {json}"
|
||||
);
|
||||
let back: PaidRequest = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.want, "abcd");
|
||||
assert!(back.token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_with_token_round_trips() {
|
||||
let req = PaidRequest {
|
||||
want: "ff".into(),
|
||||
token: Some("cashuAbc".into()),
|
||||
};
|
||||
let back: PaidRequest =
|
||||
serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
|
||||
assert_eq!(back.token.as_deref(), Some("cashuAbc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_tagged_serialization() {
|
||||
let granted = serde_json::to_string(&PaidResponse::Granted).unwrap();
|
||||
assert_eq!(granted, r#"{"status":"granted"}"#);
|
||||
|
||||
let pr = serde_json::to_string(&PaidResponse::PaymentRequired {
|
||||
price_sats: 7,
|
||||
accepted_mints: vec!["https://m".into()],
|
||||
})
|
||||
.unwrap();
|
||||
let back: PaidResponse = serde_json::from_str(&pr).unwrap();
|
||||
match back {
|
||||
PaidResponse::PaymentRequired {
|
||||
price_sats,
|
||||
accepted_mints,
|
||||
} => {
|
||||
assert_eq!(price_sats, 7);
|
||||
assert_eq!(accepted_mints, vec!["https://m".to_string()]);
|
||||
}
|
||||
other => panic!("expected PaymentRequired, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Fetch-side auto-pay — the *downloader's* decision layer for paid swarm
|
||||
//! content (plan §1 "fetch side" + §2a cross-mint).
|
||||
//!
|
||||
//! When a swarm seeder gates a blob behind payment (its `PaymentRequired`
|
||||
//! advertises a price and a set of `accepted_mints`), a downloading node uses
|
||||
//! this layer to decide whether to pay and, if so, to build a `cashuA` token
|
||||
//! denominated in one of the seeder's accepted mints — auto-swapping across
|
||||
//! mints when needed (see [`crate::wallet::ecash::build_payment_token`]).
|
||||
//!
|
||||
//! ## North star: origin always wins
|
||||
//! Paying is strictly an optimization. If the price is over budget, the wallet
|
||||
//! can't cover it, no trusted mint is reachable, or a swap would cost too much,
|
||||
//! this layer returns `None` and the caller falls back to the free HTTP origin —
|
||||
//! exactly today's path. A wallet/mint problem must never block content.
|
||||
//!
|
||||
//! ## Scope / what's NOT here
|
||||
//! This builds the *token*; it does not yet carry it to the seeder. The on-wire
|
||||
//! exchange (a downloader presenting the token to a paid seeder, then streaming
|
||||
//! the blob) is the in-band paid-blobs ALPN — "shape (A)" in the design doc —
|
||||
//! which is deferred. Today's seeder side (`swarm::paid`) only allow/deny-gates
|
||||
//! iroh-blobs requests; once shape (A) lands, the provider's fetch path calls
|
||||
//! [`auto_pay_token`] on a `PaymentRequired` and retries with the token.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::wallet::ecash;
|
||||
|
||||
/// A downloader's willingness to pay swarm peers for a single fetch.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PaymentPolicy {
|
||||
/// Maximum total sats to spend for this content. `0` disables paying
|
||||
/// entirely (origin-only) — the safe default.
|
||||
pub budget_sats: u64,
|
||||
/// Maximum cross-mint swap fee tolerated when we must swap into the
|
||||
/// seeder's mint. Ignored when we already hold the right mint.
|
||||
pub max_fee_sats: u64,
|
||||
}
|
||||
|
||||
impl PaymentPolicy {
|
||||
/// The default: never pay, always use the free origin. The production caller
|
||||
/// is the deferred in-band paid-blobs ALPN (shape A); used by tests today.
|
||||
#[allow(dead_code)]
|
||||
pub fn free() -> Self {
|
||||
Self {
|
||||
budget_sats: 0,
|
||||
max_fee_sats: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A budget-capped policy.
|
||||
pub fn with_budget(budget_sats: u64, max_fee_sats: u64) -> Self {
|
||||
Self {
|
||||
budget_sats,
|
||||
max_fee_sats,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a seeder's `price_sats` is worth paying under this policy. A zero
|
||||
/// price is treated as "not a real paid request" (use origin / free path).
|
||||
pub fn affords(&self, price_sats: u64) -> bool {
|
||||
price_sats > 0 && price_sats <= self.budget_sats
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide whether to pay a seeder `price_sats`, and if so build a `cashuA` token
|
||||
/// denominated in one of its `accepted_mints` (auto-swapping if needed).
|
||||
///
|
||||
/// * `Ok(Some(token))` — pay the seeder with this token.
|
||||
/// * `Ok(None)` — decline (over budget, unpayable, or swap too costly);
|
||||
/// the caller should fall back to the free origin.
|
||||
///
|
||||
/// Never returns `Err` for a wallet/mint problem: those degrade to `Ok(None)`
|
||||
/// so a payment failure can never block content.
|
||||
pub async fn auto_pay_token(
|
||||
data_dir: &Path,
|
||||
policy: &PaymentPolicy,
|
||||
accepted_mints: &[String],
|
||||
price_sats: u64,
|
||||
) -> Result<Option<String>> {
|
||||
if !policy.affords(price_sats) {
|
||||
debug!(
|
||||
"auto-pay: price {} sats over budget {} (or zero) — using origin",
|
||||
price_sats, policy.budget_sats
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match ecash::build_payment_token(data_dir, accepted_mints, price_sats, policy.max_fee_sats)
|
||||
.await
|
||||
{
|
||||
Ok(token) => Ok(Some(token)),
|
||||
Err(e) => {
|
||||
// Unpayable within balance/trust/fee — not an error, just decline.
|
||||
debug!("auto-pay: declined ({}) — falling back to origin", e);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn free_policy_never_affords() {
|
||||
let p = PaymentPolicy::free();
|
||||
assert!(!p.affords(1));
|
||||
assert!(!p.affords(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_policy_affordability() {
|
||||
let p = PaymentPolicy::with_budget(100, 5);
|
||||
assert!(p.affords(100)); // exactly at budget
|
||||
assert!(p.affords(1));
|
||||
assert!(!p.affords(101)); // over budget
|
||||
assert!(!p.affords(0)); // zero price is never a real paid request
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn over_budget_declines_without_touching_wallet() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Price exceeds budget → None, and no wallet/mint interaction occurs.
|
||||
let out = auto_pay_token(
|
||||
tmp.path(),
|
||||
&PaymentPolicy::with_budget(50, 5),
|
||||
&["https://seeder.example.com".into()],
|
||||
100,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_budget_is_origin_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let out = auto_pay_token(
|
||||
tmp.path(),
|
||||
&PaymentPolicy::free(),
|
||||
&["https://seeder.example.com".into()],
|
||||
10,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unpayable_within_budget_declines_gracefully() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Within budget, but empty wallet + untrusted seeder mint → build fails;
|
||||
// auto_pay degrades to None (origin) rather than erroring.
|
||||
let out = auto_pay_token(
|
||||
tmp.path(),
|
||||
&PaymentPolicy::with_budget(1000, 10),
|
||||
&["https://untrusted.example.com".into()],
|
||||
100,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Phase 3 discovery — signed Nostr "seed advertisement" events.
|
||||
//!
|
||||
//! A node that holds a PUBLIC release / app-image blob (addressed by BLAKE3)
|
||||
//! announces "I can seed hash H from iroh endpoint E" as a signed, NIP-33
|
||||
//! addressable Nostr event. **Scope is releases/catalog content ONLY** — never
|
||||
//! private user blobs (decided 2026-06-16): smallest privacy surface, covers
|
||||
//! the OTA + app-install use-cases. Discovery queries these events to find
|
||||
//! swarm seeds for a hash; the iroh provider then dials those endpoints.
|
||||
//!
|
||||
//! Event shape (NIP-33 addressable, kind [`ARCHIPELAGO_SEED_KIND`]):
|
||||
//! - `d` tag = blake3 hex of the content → one current advert per (author, hash)
|
||||
//! - content = `{"v":1,"endpoint_id":"<iroh endpoint id>"}`
|
||||
//! - author pubkey = the node's seed-derived Nostr identity (signs the event)
|
||||
//!
|
||||
//! Endpoint ids stay opaque strings here so this protocol layer builds/parses/
|
||||
//! publishes/queries WITHOUT the heavy iroh dep; only the `iroh-swarm`
|
||||
//! discovery glue parses the string into an `iroh::EndpointId`.
|
||||
|
||||
// The publish/query path that calls these lives behind `iroh-swarm` (it needs
|
||||
// the node's iroh EndpointId), so in the default build they're exercised only
|
||||
// by unit tests — allow them to stand without a production caller.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr_sdk::{Event, EventBuilder, Filter, Keys, Kind, Tag};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How long to wait for relay connects / event fetches. Matches the rest of the
|
||||
/// Nostr discovery path so the swarm never stalls the download longer than node
|
||||
/// discovery already might.
|
||||
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const RELAY_FETCH_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// NIP-33 addressable kind for Archipelago seed advertisements.
|
||||
/// Distinct from the node-discovery app-data kind (30078).
|
||||
pub const ARCHIPELAGO_SEED_KIND: u16 = 30081;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct AdvertContent {
|
||||
v: u8,
|
||||
endpoint_id: String,
|
||||
}
|
||||
|
||||
/// Build the (unsigned) advertisement event for `blake3_hex` served from
|
||||
/// `endpoint_id`. Sign with the node's Nostr key (`.sign_with_keys()` /
|
||||
/// `.sign()`) or publish via `client.send_event_builder()`.
|
||||
pub fn advertisement_builder(blake3_hex: &str, endpoint_id: &str) -> EventBuilder {
|
||||
let content = serde_json::to_string(&AdvertContent {
|
||||
v: 1,
|
||||
endpoint_id: endpoint_id.to_string(),
|
||||
})
|
||||
.expect("serialize advert content");
|
||||
EventBuilder::new(Kind::Custom(ARCHIPELAGO_SEED_KIND), content)
|
||||
.tag(Tag::identifier(blake3_hex.to_string()))
|
||||
}
|
||||
|
||||
/// Filter matching all current seed advertisements for `blake3_hex` (one per
|
||||
/// advertising node; NIP-33 latest-replaces per author).
|
||||
pub fn advertisement_filter(blake3_hex: &str) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Custom(ARCHIPELAGO_SEED_KIND))
|
||||
.identifier(blake3_hex.to_string())
|
||||
}
|
||||
|
||||
/// Extract the advertised endpoint id from an event, or `None` if it is the
|
||||
/// wrong kind or malformed.
|
||||
pub fn parse_endpoint_id(event: &Event) -> Option<String> {
|
||||
if event.kind != Kind::Custom(ARCHIPELAGO_SEED_KIND) {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str::<AdvertContent>(&event.content)
|
||||
.ok()
|
||||
.map(|c| c.endpoint_id)
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Collect the unique advertised endpoint ids across a set of events, skipping
|
||||
/// malformed ones. Order-preserving, de-duplicated.
|
||||
pub fn endpoint_ids_from_events<'a>(events: impl IntoIterator<Item = &'a Event>) -> Vec<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for ev in events {
|
||||
if let Some(id) = parse_endpoint_id(ev) {
|
||||
if seen.insert(id.clone()) {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Query `relays` for the current seed advertisements for `blake3_hex` and
|
||||
/// return the de-duplicated endpoint-id strings (opaque here; the `iroh-swarm`
|
||||
/// glue parses them into `iroh::EndpointId`).
|
||||
///
|
||||
/// Best-effort by design: an empty relay list, a connect timeout, or a fetch
|
||||
/// failure all yield an empty list — never an error. The swarm seam treats "no
|
||||
/// providers" as "use origin", so discovery problems can only ever degrade to
|
||||
/// today's HTTP path, never block it.
|
||||
pub async fn fetch_seed_endpoint_ids(
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
blake3_hex: &str,
|
||||
) -> Vec<String> {
|
||||
if relays.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Query anonymously — discovery reads public adverts and must not link the
|
||||
// query back to this node's seed identity.
|
||||
let anon = Keys::generate();
|
||||
let client = match crate::nostr_discovery::build_nostr_client(anon, tor_proxy) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("seed-advert: build relay client failed: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
}
|
||||
if tokio::time::timeout(RELAY_CONNECT_TIMEOUT, client.connect())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("seed-advert: relay connect timed out, continuing anyway");
|
||||
}
|
||||
let events = client
|
||||
.fetch_events(advertisement_filter(blake3_hex), RELAY_FETCH_TIMEOUT)
|
||||
.await
|
||||
.map(|e| e.to_vec())
|
||||
.unwrap_or_default();
|
||||
client.disconnect().await;
|
||||
endpoint_ids_from_events(events.iter())
|
||||
}
|
||||
|
||||
/// Publish a signed advertisement — "this node can seed `blake3_hex` from
|
||||
/// `endpoint_id`" — to `relays`, signed with the node's seed-derived Nostr key.
|
||||
///
|
||||
/// **Caller must restrict this to PUBLIC releases/catalog blobs** (the design's
|
||||
/// privacy scope, decided 2026-06-16) — never private user content. Best-effort:
|
||||
/// relay failures are logged, not fatal, since seeding is an optimization.
|
||||
pub async fn publish_seed_advert(
|
||||
identity_dir: &Path,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
blake3_hex: &str,
|
||||
endpoint_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
if relays.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let keys = crate::nostr_discovery::load_or_create_nostr_keys(identity_dir).await?;
|
||||
let client = crate::nostr_discovery::build_nostr_client(keys, tor_proxy)?;
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
}
|
||||
if tokio::time::timeout(RELAY_CONNECT_TIMEOUT, client.connect())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("seed-advert: publish relay connect timed out, continuing anyway");
|
||||
}
|
||||
let _ = client
|
||||
.send_event_builder(advertisement_builder(blake3_hex, endpoint_id))
|
||||
.await;
|
||||
client.disconnect().await;
|
||||
tracing::info!("seed-advert: announced {blake3_hex} seedable from {endpoint_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_sign_parse_roundtrip() {
|
||||
let keys = Keys::generate();
|
||||
let hash = "a".repeat(64);
|
||||
let endpoint = "node-example-endpoint-id";
|
||||
let event = advertisement_builder(&hash, endpoint)
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert_eq!(event.kind, Kind::Custom(ARCHIPELAGO_SEED_KIND));
|
||||
assert_eq!(parse_endpoint_id(&event).as_deref(), Some(endpoint));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_targets_the_hash_dtag_and_kind() {
|
||||
let hash = "b".repeat(64);
|
||||
let json = serde_json::to_string(&advertisement_filter(&hash)).unwrap();
|
||||
assert!(json.contains(&hash), "filter must target the hash d-tag");
|
||||
assert!(
|
||||
json.contains("30081"),
|
||||
"filter must constrain the seed kind"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_wrong_kind_and_empty_endpoint() {
|
||||
let keys = Keys::generate();
|
||||
let wrong_kind = EventBuilder::new(Kind::Custom(1), "{}")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert_eq!(parse_endpoint_id(&wrong_kind), None);
|
||||
let empty_endpoint = advertisement_builder(&"c".repeat(64), "")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert_eq!(parse_endpoint_id(&empty_endpoint), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedups_endpoint_ids_across_events() {
|
||||
let a = Keys::generate();
|
||||
let b = Keys::generate();
|
||||
let hash = "d".repeat(64);
|
||||
let e1 = advertisement_builder(&hash, "endpoint-A")
|
||||
.sign_with_keys(&a)
|
||||
.unwrap();
|
||||
let e2 = advertisement_builder(&hash, "endpoint-A")
|
||||
.sign_with_keys(&b)
|
||||
.unwrap();
|
||||
let e3 = advertisement_builder(&hash, "endpoint-B")
|
||||
.sign_with_keys(&b)
|
||||
.unwrap();
|
||||
let ids = endpoint_ids_from_events([&e1, &e2, &e3]);
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["endpoint-A".to_string(), "endpoint-B".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! The fleet's pinned **release-root** trust anchor.
|
||||
//!
|
||||
//! Every node ships the release-root *public* key. Signed manifests and the app
|
||||
//! catalog must be signed by the corresponding private key (derived once, in
|
||||
//! the signing ceremony, via `seed::derive_release_root_ed25519`). Pinning the
|
||||
//! key in the binary is what makes a swapped-in mirror key detectable.
|
||||
//!
|
||||
//! Until the ceremony runs against the real release master seed, the pinned
|
||||
//! constant is `None`. While `None`, signature verification still runs and
|
||||
//! still rejects tampered documents, but it cannot enforce signer *identity*
|
||||
//! (see `signed_doc::SignatureStatus::anchored`). Set
|
||||
//! `ARCHY_RELEASE_ROOT_PUBKEY` (64-char hex) to pin a key at runtime for
|
||||
//! staging/test fleets before the constant is baked in.
|
||||
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
|
||||
/// Hex of the pinned Ed25519 release-root public key (32 bytes / 64 hex chars).
|
||||
///
|
||||
/// TODO(dht Phase 0): bake the real value here after the signing ceremony.
|
||||
/// Generate it with: `scripts/release-root-ceremony.sh pubkey`.
|
||||
pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> = None;
|
||||
|
||||
const ENV_OVERRIDE: &str = "ARCHY_RELEASE_ROOT_PUBKEY";
|
||||
|
||||
/// Resolve the pinned release-root public key, if any.
|
||||
///
|
||||
/// Runtime env override wins over the baked-in constant so a test fleet can pin
|
||||
/// a ceremony key without a rebuild. Malformed values are ignored (treated as
|
||||
/// "not pinned") rather than crashing the node.
|
||||
pub fn release_root_pubkey() -> Option<VerifyingKey> {
|
||||
if let Ok(hex_str) = std::env::var(ENV_OVERRIDE) {
|
||||
if let Some(key) = parse_pubkey_hex(hex_str.trim()) {
|
||||
return Some(key);
|
||||
}
|
||||
tracing::warn!(
|
||||
"{} is set but not a valid 32-byte hex Ed25519 key; ignoring",
|
||||
ENV_OVERRIDE
|
||||
);
|
||||
}
|
||||
RELEASE_ROOT_PUBKEY_HEX.and_then(parse_pubkey_hex)
|
||||
}
|
||||
|
||||
fn parse_pubkey_hex(s: &str) -> Option<VerifyingKey> {
|
||||
let bytes = hex::decode(s).ok()?;
|
||||
let arr: [u8; 32] = bytes.as_slice().try_into().ok()?;
|
||||
VerifyingKey::from_bytes(&arr).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unset_constant_is_none() {
|
||||
// Default build ships no pinned anchor yet.
|
||||
assert!(RELEASE_ROOT_PUBKEY_HEX.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_valid_hex() {
|
||||
let key = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]).verifying_key();
|
||||
let parsed = parse_pubkey_hex(&hex::encode(key.to_bytes())).unwrap();
|
||||
assert_eq!(parsed.as_bytes(), key.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_hex() {
|
||||
assert!(parse_pubkey_hex("nothex").is_none());
|
||||
assert!(parse_pubkey_hex("abcd").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Canonical JSON for signing — a pragmatic subset of RFC 8785 (JCS).
|
||||
//!
|
||||
//! Signatures are computed over a *byte-exact* serialization so that a verifier
|
||||
//! reproduces the same preimage the signer hashed. We guarantee:
|
||||
//!
|
||||
//! * object keys recursively sorted (lexicographic by Rust `str` ordering,
|
||||
//! i.e. Unicode scalar value — matches JCS for the ASCII keys we use),
|
||||
//! * no insignificant whitespace,
|
||||
//! * arrays preserved in order.
|
||||
//!
|
||||
//! We do NOT implement JCS number canonicalization (ECMAScript shortest-form).
|
||||
//! Archipelago manifests/catalogs carry only integers, strings, bools, arrays
|
||||
//! and objects, for which `serde_json`'s output is already unambiguous. If a
|
||||
//! float ever enters a signed document this must be hardened (or rejected).
|
||||
//! `contains_float()` lets callers enforce that invariant.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Serialize `value` to canonical JSON bytes (sorted keys, compact).
|
||||
///
|
||||
/// Rebuilds every object through a `BTreeMap` so the result is independent of
|
||||
/// the `serde_json/preserve_order` feature being toggled on anywhere in the
|
||||
/// dependency graph.
|
||||
pub fn to_canonical_bytes(value: &Value) -> Vec<u8> {
|
||||
let canonical = canonicalize(value);
|
||||
// serde_json never fails to serialize a Value it produced.
|
||||
serde_json::to_vec(&canonical).expect("canonical JSON serialization")
|
||||
}
|
||||
|
||||
/// Reject documents that contain a float anywhere — they are not safely
|
||||
/// canonicalizable under this implementation.
|
||||
pub fn contains_float(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Number(n) => n.as_i64().is_none() && n.as_u64().is_none(),
|
||||
Value::Array(items) => items.iter().any(contains_float),
|
||||
Value::Object(map) => map.values().any(contains_float),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
// BTreeMap gives deterministic key ordering on serialize.
|
||||
let sorted: std::collections::BTreeMap<String, Value> = map
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), canonicalize(v)))
|
||||
.collect();
|
||||
serde_json::to_value(sorted).expect("canonical object")
|
||||
}
|
||||
Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn key_order_does_not_change_bytes() {
|
||||
let a = json!({"b": 1, "a": 2, "c": {"z": 1, "y": 2}});
|
||||
let b = json!({"c": {"y": 2, "z": 1}, "a": 2, "b": 1});
|
||||
assert_eq!(to_canonical_bytes(&a), to_canonical_bytes(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_sorted_and_compact() {
|
||||
let v = json!({"b": 1, "a": [3, 2, 1]});
|
||||
assert_eq!(to_canonical_bytes(&v), br#"{"a":[3,2,1],"b":1}"#.to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_order_is_preserved() {
|
||||
let a = json!([1, 2, 3]);
|
||||
let b = json!([3, 2, 1]);
|
||||
assert_ne!(to_canonical_bytes(&a), to_canonical_bytes(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_floats() {
|
||||
assert!(contains_float(&json!({"x": 1.5})));
|
||||
assert!(contains_float(&json!([1, 2, 0.1])));
|
||||
assert!(!contains_float(&json!({"x": 12345, "y": "s", "z": [1, 2]})));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! `did:key` <-> Ed25519 public key, mirroring the encoding already used by
|
||||
//! `identity_manager` so release-root DIDs are interchangeable with node DIDs.
|
||||
//!
|
||||
//! Format: `did:key:z<base58btc(0xed01 || 32-byte-pubkey)>`
|
||||
//! (`0xed01` is the multicodec varint prefix for an Ed25519 public key.)
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
|
||||
const ED25519_MULTICODEC: [u8; 2] = [0xed, 0x01];
|
||||
|
||||
/// Encode an Ed25519 public key as a `did:key` string.
|
||||
pub fn did_key_for_ed25519(key: &VerifyingKey) -> String {
|
||||
let mut bytes = Vec::with_capacity(34);
|
||||
bytes.extend_from_slice(&ED25519_MULTICODEC);
|
||||
bytes.extend_from_slice(key.as_bytes());
|
||||
format!("did:key:z{}", bs58::encode(bytes).into_string())
|
||||
}
|
||||
|
||||
/// Decode a `did:key` string into an Ed25519 verifying key.
|
||||
pub fn ed25519_pubkey_from_did_key(did: &str) -> Result<VerifyingKey> {
|
||||
let z_part = did
|
||||
.strip_prefix("did:key:z")
|
||||
.ok_or_else(|| anyhow!("invalid did:key format: {}", did))?;
|
||||
let decoded = bs58::decode(z_part)
|
||||
.into_vec()
|
||||
.context("invalid base58 in did:key")?;
|
||||
if decoded.len() != 34 || decoded[0..2] != ED25519_MULTICODEC {
|
||||
return Err(anyhow!("not an Ed25519 did:key (bad multicodec prefix)"));
|
||||
}
|
||||
let arr: [u8; 32] = decoded[2..].try_into().expect("length checked above");
|
||||
VerifyingKey::from_bytes(&arr).context("invalid Ed25519 public key in did:key")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let key = SigningKey::from_bytes(&[3u8; 32]).verifying_key();
|
||||
let did = did_key_for_ed25519(&key);
|
||||
assert!(did.starts_with("did:key:z6Mk"), "got {}", did);
|
||||
let back = ed25519_pubkey_from_did_key(&did).unwrap();
|
||||
assert_eq!(key.as_bytes(), back.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_ed25519() {
|
||||
assert!(ed25519_pubkey_from_did_key("did:key:zQ3shazz").is_err());
|
||||
assert!(ed25519_pubkey_from_did_key("not-a-did").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Authenticity layer for the DHT distribution plan (Phase 0).
|
||||
//!
|
||||
//! Content addressing (SHA-256 today, BLAKE3 later) proves downloaded bytes are
|
||||
//! *intact*. It does not prove they were *authorized*. This module adds the
|
||||
//! missing half: detached Ed25519 signatures over canonical JSON, verified
|
||||
//! against a pinned **release-root** trust anchor.
|
||||
//!
|
||||
//! Layout:
|
||||
//! * [`anchor`] — the pinned release-root public key (+ env override).
|
||||
//! * [`canonical`] — deterministic JSON serialization for signing.
|
||||
//! * [`did`] — `did:key` <-> Ed25519 public key.
|
||||
//! * [`signed_doc`]— detached sign/verify over a signed document.
|
||||
//!
|
||||
//! The release-root *private* key is publisher-only and derived in the signing
|
||||
//! ceremony via [`crate::seed::derive_release_root_ed25519`]; fleet nodes only
|
||||
//! ever hold the public key.
|
||||
|
||||
pub mod anchor;
|
||||
pub mod canonical;
|
||||
pub mod did;
|
||||
pub mod signed_doc;
|
||||
|
||||
pub use signed_doc::{verify_detached, SignatureStatus};
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Detached Ed25519 signatures over canonical JSON documents.
|
||||
//!
|
||||
//! A *signed document* is any JSON object carrying two reserved top-level
|
||||
//! fields:
|
||||
//!
|
||||
//! * `signed_by` — the signer's `did:key` (Ed25519), e.g. the release-root.
|
||||
//! * `signature` — hex-encoded Ed25519 signature over the canonical JSON of
|
||||
//! the document with **both** reserved fields removed.
|
||||
//!
|
||||
//! Removing the fields before canonicalizing makes the signature *detached*:
|
||||
//! the signer signs the payload, then attaches the proof, without a
|
||||
//! chicken-and-egg dependency on the signature's own bytes.
|
||||
//!
|
||||
//! Authenticity ≠ integrity. Content addressing (SHA-256/BLAKE3 in the
|
||||
//! manifest) proves the bytes are intact; this signature proves *we authorized
|
||||
//! them*. The DHT plan requires both.
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::anchor;
|
||||
use super::canonical;
|
||||
use super::did;
|
||||
|
||||
pub const SIGNATURE_FIELD: &str = "signature";
|
||||
pub const SIGNED_BY_FIELD: &str = "signed_by";
|
||||
|
||||
/// Outcome of inspecting a document for a detached signature.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SignatureStatus {
|
||||
/// No `signature` field present. Caller decides whether to accept
|
||||
/// (during the migration window we still accept unsigned documents).
|
||||
Unsigned,
|
||||
/// Signature verified. `anchored` is true when `signed_by` matched the
|
||||
/// pinned release-root anchor (full authenticity); false means the
|
||||
/// signature is internally consistent but the signer key is not yet
|
||||
/// pinned, so it only proves the document wasn't tampered relative to its
|
||||
/// own claimed key.
|
||||
Verified { signer_did: String, anchored: bool },
|
||||
}
|
||||
|
||||
/// Verify a document's detached signature *if present*.
|
||||
///
|
||||
/// Returns `Ok(Unsigned)` when there is no signature. Returns `Ok(Verified)`
|
||||
/// when a present signature checks out. Returns `Err` when a signature is
|
||||
/// present but malformed, fails verification, or names a signer that
|
||||
/// contradicts the pinned anchor — callers MUST reject the document on `Err`.
|
||||
pub fn verify_detached(doc: &Value) -> Result<SignatureStatus> {
|
||||
let obj = doc
|
||||
.as_object()
|
||||
.ok_or_else(|| anyhow!("signed document must be a JSON object"))?;
|
||||
|
||||
let signature_hex = match obj.get(SIGNATURE_FIELD) {
|
||||
None | Some(Value::Null) => return Ok(SignatureStatus::Unsigned),
|
||||
Some(Value::String(s)) => s,
|
||||
Some(_) => bail!("`{}` must be a string", SIGNATURE_FIELD),
|
||||
};
|
||||
|
||||
let signed_by = obj
|
||||
.get(SIGNED_BY_FIELD)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"signed document has `{}` but no `{}`",
|
||||
SIGNATURE_FIELD,
|
||||
SIGNED_BY_FIELD
|
||||
)
|
||||
})?;
|
||||
|
||||
let signer = did::ed25519_pubkey_from_did_key(signed_by)
|
||||
.with_context(|| format!("invalid `{}` did:key", SIGNED_BY_FIELD))?;
|
||||
|
||||
// If the fleet has a pinned release-root, the signer MUST be it. This is
|
||||
// what stops a mirror from swapping in its own keypair and re-signing.
|
||||
let anchored = match anchor::release_root_pubkey() {
|
||||
Some(pinned) => {
|
||||
if pinned != signer {
|
||||
bail!("signed_by does not match the pinned release-root anchor");
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
|
||||
let signature = parse_signature_hex(signature_hex)?;
|
||||
let preimage = signing_preimage(obj)?;
|
||||
signer
|
||||
.verify_strict(&preimage, &signature)
|
||||
.map_err(|_| anyhow!("release-root signature verification failed"))?;
|
||||
|
||||
Ok(SignatureStatus::Verified {
|
||||
signer_did: signed_by.to_string(),
|
||||
anchored,
|
||||
})
|
||||
}
|
||||
|
||||
/// Produce a detached signature for `payload` (the document WITHOUT the
|
||||
/// reserved fields). Used by the signing ceremony and round-trip tests.
|
||||
/// Returns `(signature_hex, signed_by_did)`.
|
||||
pub fn sign_detached(key: &SigningKey, payload: &Value) -> Result<(String, String)> {
|
||||
let obj = payload
|
||||
.as_object()
|
||||
.ok_or_else(|| anyhow!("payload must be a JSON object"))?;
|
||||
if obj.contains_key(SIGNATURE_FIELD) || obj.contains_key(SIGNED_BY_FIELD) {
|
||||
bail!("payload must not already contain reserved signature fields");
|
||||
}
|
||||
let preimage = signing_preimage(obj)?;
|
||||
let signature = key.sign(&preimage);
|
||||
let did = did::did_key_for_ed25519(&key.verifying_key());
|
||||
Ok((hex::encode(signature.to_bytes()), did))
|
||||
}
|
||||
|
||||
/// Canonical bytes the signature covers: the object minus the reserved fields.
|
||||
fn signing_preimage(obj: &serde_json::Map<String, Value>) -> Result<Vec<u8>> {
|
||||
let mut payload = obj.clone();
|
||||
payload.remove(SIGNATURE_FIELD);
|
||||
payload.remove(SIGNED_BY_FIELD);
|
||||
let value = Value::Object(payload);
|
||||
if canonical::contains_float(&value) {
|
||||
bail!("signed documents must not contain floating-point numbers");
|
||||
}
|
||||
Ok(canonical::to_canonical_bytes(&value))
|
||||
}
|
||||
|
||||
fn parse_signature_hex(s: &str) -> Result<Signature> {
|
||||
let bytes = hex::decode(s).context("signature is not valid hex")?;
|
||||
let arr: [u8; 64] = bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("signature must be 64 bytes, got {}", bytes.len()))?;
|
||||
Ok(Signature::from_bytes(&arr))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn test_key() -> SigningKey {
|
||||
SigningKey::from_bytes(&[7u8; 32])
|
||||
}
|
||||
|
||||
fn sign_into(key: &SigningKey, mut doc: Value) -> Value {
|
||||
let (sig, did) = sign_detached(key, &doc).unwrap();
|
||||
let obj = doc.as_object_mut().unwrap();
|
||||
obj.insert(SIGNED_BY_FIELD.into(), json!(did));
|
||||
obj.insert(SIGNATURE_FIELD.into(), json!(sig));
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsigned_document_reports_unsigned() {
|
||||
let doc = json!({"schema": 1, "apps": {}});
|
||||
assert_eq!(verify_detached(&doc).unwrap(), SignatureStatus::Unsigned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_verifies() {
|
||||
let signed = sign_into(&test_key(), json!({"schema": 1, "n": 42}));
|
||||
match verify_detached(&signed).unwrap() {
|
||||
// No anchor pinned in the default test build → anchored == false.
|
||||
SignatureStatus::Verified { anchored, .. } => assert!(!anchored),
|
||||
other => panic!("expected Verified, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_survives_key_reordering() {
|
||||
// Re-emitting the document with shuffled keys must not break the sig.
|
||||
let signed = sign_into(&test_key(), json!({"b": 2, "a": 1}));
|
||||
let reparsed: Value =
|
||||
serde_json::from_str(&serde_json::to_string(&signed).unwrap()).unwrap();
|
||||
assert!(matches!(
|
||||
verify_detached(&reparsed).unwrap(),
|
||||
SignatureStatus::Verified { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_payload_is_rejected() {
|
||||
let mut signed = sign_into(&test_key(), json!({"schema": 1, "n": 42}));
|
||||
signed
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("n".into(), json!(43));
|
||||
assert!(verify_detached(&signed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_signed_by_is_rejected() {
|
||||
let doc = json!({"schema": 1, "signature": "00"});
|
||||
assert!(verify_detached(&doc).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_payload_cannot_be_signed() {
|
||||
assert!(sign_detached(&test_key(), &json!({"x": 1.5})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,106 @@ pub async fn save_mirrors(data_dir: &Path, mirrors: &[UpdateMirror]) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Update/app fetch source (origin vs DHT swarm) ──────────────────────────
|
||||
//
|
||||
// User-selectable per node, persisted in `data_dir/update-source.json`. This is
|
||||
// the live-testing switch: keep `Origin` (default) to pull releases/app blobs
|
||||
// purely over HTTP from the configured mirrors — the known-good path — or flip
|
||||
// to `Swarm` on a test node to exercise the DHT (iroh swarm-assist), knowing the
|
||||
// origin still always wins as fallback. Independent of the compile-time
|
||||
// `iroh-swarm` feature and the `swarm_enabled` config: if the swarm engine isn't
|
||||
// present, `Swarm` simply has no peers to consult and behaves like `Origin`.
|
||||
|
||||
const UPDATE_SOURCE_FILE: &str = "update-source.json";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UpdateSource {
|
||||
/// HTTP origin/mirrors only. The safe default and the universal fallback.
|
||||
#[default]
|
||||
Origin,
|
||||
/// Try DHT swarm peers first for content-addressed blobs, origin always wins.
|
||||
Swarm,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Node-level swarm preferences, persisted together in `update-source.json`.
|
||||
/// Two independent switches:
|
||||
/// - `source`: where THIS node fetches (origin vs swarm). Default origin.
|
||||
/// - `provide_dht`: whether this node SEEDS/serves blobs to peers. Default on
|
||||
/// (opt-out) so the swarm has providers; nodes that don't want to serve can
|
||||
/// turn it off without affecting how they fetch.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
struct SwarmPrefs {
|
||||
#[serde(default)]
|
||||
source: UpdateSource,
|
||||
#[serde(default = "default_true")]
|
||||
provide_dht: bool,
|
||||
}
|
||||
|
||||
impl Default for SwarmPrefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
source: UpdateSource::default(),
|
||||
provide_dht: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_source_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
data_dir.join(UPDATE_SOURCE_FILE)
|
||||
}
|
||||
|
||||
async fn load_swarm_prefs(data_dir: &Path) -> SwarmPrefs {
|
||||
match fs::read_to_string(update_source_path(data_dir)).await {
|
||||
Ok(s) => serde_json::from_str::<SwarmPrefs>(&s).unwrap_or_default(),
|
||||
Err(_) => SwarmPrefs::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_swarm_prefs(data_dir: &Path, prefs: &SwarmPrefs) -> Result<()> {
|
||||
fs::create_dir_all(data_dir)
|
||||
.await
|
||||
.with_context(|| format!("mkdir {}", data_dir.display()))?;
|
||||
let path = update_source_path(data_dir);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let json = serde_json::to_vec_pretty(prefs).context("serialize swarm prefs")?;
|
||||
fs::write(&tmp, json)
|
||||
.await
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &path)
|
||||
.await
|
||||
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load the node's selected fetch source. Missing/corrupt file → `Origin`.
|
||||
pub async fn load_update_source(data_dir: &Path) -> UpdateSource {
|
||||
load_swarm_prefs(data_dir).await.source
|
||||
}
|
||||
|
||||
/// Persist the node's selected fetch source (preserving `provide_dht`).
|
||||
pub async fn save_update_source(data_dir: &Path, source: UpdateSource) -> Result<()> {
|
||||
let mut prefs = load_swarm_prefs(data_dir).await;
|
||||
prefs.source = source;
|
||||
save_swarm_prefs(data_dir, &prefs).await
|
||||
}
|
||||
|
||||
/// Whether this node seeds/serves blobs to peers. Default true (opt-out).
|
||||
pub async fn load_provide_dht(data_dir: &Path) -> bool {
|
||||
load_swarm_prefs(data_dir).await.provide_dht
|
||||
}
|
||||
|
||||
/// Persist whether this node provides to the swarm (preserving `source`).
|
||||
pub async fn save_provide_dht(data_dir: &Path, provide: bool) -> Result<()> {
|
||||
let mut prefs = load_swarm_prefs(data_dir).await;
|
||||
prefs.provide_dht = provide;
|
||||
save_swarm_prefs(data_dir, &prefs).await
|
||||
}
|
||||
|
||||
/// Parse a manifest URL and return its `scheme://host[:port]` prefix.
|
||||
/// Used by `rewrite_manifest_origins` so a manifest fetched from a
|
||||
/// mirror points component downloads back at the same mirror rather
|
||||
@@ -263,6 +363,11 @@ pub struct ComponentUpdate {
|
||||
pub download_url: String,
|
||||
pub sha256: String,
|
||||
pub size_bytes: u64,
|
||||
/// DHT Phase 1: BLAKE3 content address (bare hex or `"blake3:<hex>"`), the
|
||||
/// iroh-native, range-verifiable hash. Optional during the migration
|
||||
/// window — when present it is verified ALONGSIDE the mandatory SHA-256.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub blake3: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -538,12 +643,19 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Marker written only after EVERY component has downloaded and verified.
|
||||
/// Distinguishes a complete, install-ready staging from the partial files a
|
||||
/// resumable-but-failed download leaves behind.
|
||||
const STAGED_COMPLETE_MARKER: &str = ".download-complete";
|
||||
|
||||
async fn has_staged_update(data_dir: &Path) -> bool {
|
||||
let staging_dir = data_dir.join("update-staging");
|
||||
let Ok(mut entries) = fs::read_dir(&staging_dir).await else {
|
||||
return false;
|
||||
};
|
||||
matches!(entries.next_entry().await, Ok(Some(_)))
|
||||
// A *complete* staged update carries the marker. A partial/failed download
|
||||
// leaves component files (kept for resume) but no marker, so it reads as
|
||||
// "not staged" — the state self-heal then clears update_in_progress and the
|
||||
// UI returns to Download instead of stranding the user on Install.
|
||||
fs::metadata(data_dir.join("update-staging").join(STAGED_COMPLETE_MARKER))
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {
|
||||
@@ -783,6 +895,23 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
|
||||
DOWNLOAD_PROGRESS_AT.store(now_ms(), Ordering::Relaxed);
|
||||
|
||||
// Consult swarm peers only when the node has opted into DHT mode. In Origin
|
||||
// mode (default) this stays empty so every component goes straight to the
|
||||
// HTTP origin — instant, no-rebuild fallback while live-testing the swarm.
|
||||
let update_source = load_update_source(data_dir).await;
|
||||
let provide_dht = load_provide_dht(data_dir).await;
|
||||
let swarm_providers = if update_source == UpdateSource::Swarm {
|
||||
crate::swarm::providers()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if update_source == UpdateSource::Swarm {
|
||||
info!(
|
||||
providers = swarm_providers.len(),
|
||||
"Update source = DHT swarm (origin still wins as fallback)"
|
||||
);
|
||||
}
|
||||
|
||||
for component in &manifest.components {
|
||||
if is_canceled() {
|
||||
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
|
||||
@@ -791,7 +920,57 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
}
|
||||
info!(name = %component.name, url = %component.download_url, "Downloading component");
|
||||
let dest = staging_dir.join(&component.name);
|
||||
download_component_resumable(&client, component, &dest, downloaded).await?;
|
||||
|
||||
// DHT Phase 2: when the manifest pins a BLAKE3 digest, route the fetch
|
||||
// through the swarm seam (swarm-assist, origin always wins). With no
|
||||
// providers registered (iroh-swarm feature off) this is identical to
|
||||
// calling the resumable HTTP origin directly — same bytes, now
|
||||
// content-addressed. A swarm hit is BLAKE3-verified inside the seam;
|
||||
// we still enforce the mandatory SHA-256 gate on peer bytes here and
|
||||
// re-fetch from origin if a (consistency-broken) peer slips through.
|
||||
let digest = component.blake3.as_deref().and_then(|b| {
|
||||
let s = b.trim();
|
||||
let normalized = if s.contains(':') {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("blake3:{s}")
|
||||
};
|
||||
crate::content_hash::ContentDigest::parse(&normalized).ok()
|
||||
});
|
||||
if let Some(digest) = digest {
|
||||
let client_ref = &client;
|
||||
let dest_ref = &dest;
|
||||
let source = crate::swarm::fetch_content_addressed(
|
||||
&digest,
|
||||
&swarm_providers,
|
||||
&dest,
|
||||
move || async move {
|
||||
download_component_resumable(client_ref, component, dest_ref, downloaded).await
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if source == crate::swarm::FetchSource::Swarm {
|
||||
let bytes = tokio::fs::read(&dest).await?;
|
||||
if crate::content_hash::sha256_hex(&bytes) != component.sha256 {
|
||||
warn!(
|
||||
name = %component.name,
|
||||
"swarm bytes passed BLAKE3 but failed the SHA-256 manifest gate — re-fetching from origin"
|
||||
);
|
||||
let _ = tokio::fs::remove_file(&dest).await;
|
||||
download_component_resumable(&client, component, &dest, downloaded).await?;
|
||||
}
|
||||
}
|
||||
// This is a PUBLIC release blob and it just passed both the BLAKE3 and
|
||||
// SHA-256 gates — announce that we can now seed it to peers. Gated on
|
||||
// the node's "provide to swarm" preference (default on); best-effort,
|
||||
// inert unless the iroh swarm is active, and never blocks the install.
|
||||
// Independent of fetch source: an origin-fetching node can still seed.
|
||||
if provide_dht {
|
||||
crate::swarm::announce_held_blob(&digest.hex, &dest).await;
|
||||
}
|
||||
} else {
|
||||
download_component_resumable(&client, component, &dest, downloaded).await?;
|
||||
}
|
||||
downloaded += component.size_bytes;
|
||||
DOWNLOAD_BYTES.store(downloaded, Ordering::Relaxed);
|
||||
info!(
|
||||
@@ -801,7 +980,10 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
);
|
||||
}
|
||||
|
||||
// Mark update as downloaded
|
||||
// Mark update as downloaded. Write the completion marker FIRST so a crash
|
||||
// between the two can't leave update_in_progress=true without the marker
|
||||
// (which the self-heal would then clear, harmlessly forcing a re-download).
|
||||
let _ = fs::write(staging_dir.join(STAGED_COMPLETE_MARKER), b"1").await;
|
||||
let mut state = load_state(data_dir).await?;
|
||||
state.update_in_progress = true;
|
||||
save_state(data_dir, &state).await?;
|
||||
@@ -983,6 +1165,25 @@ async fn download_component_resumable(
|
||||
.context("read staging file for hash check")?;
|
||||
let hash = hex::encode(Sha256::digest(&bytes));
|
||||
if hash == component.sha256 {
|
||||
// DHT Phase 1: if the manifest also pins a BLAKE3 digest, it must
|
||||
// match too. SHA-256 stays the mandatory gate during migration;
|
||||
// BLAKE3 is the hash the iroh swarm will fetch/verify by, so a
|
||||
// present-but-wrong BLAKE3 means the bytes aren't swarm-consistent
|
||||
// — treat it like a SHA mismatch and re-download.
|
||||
if let Some(b3) = component.blake3.as_deref() {
|
||||
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
|
||||
let actual = crate::content_hash::blake3_hex(&bytes);
|
||||
if !actual.eq_ignore_ascii_case(expected) {
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"BLAKE3 mismatch for {}: expected {}, got {}",
|
||||
component.name,
|
||||
expected,
|
||||
actual
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// SHA mismatch — the file on disk is garbage. Nuke it and
|
||||
@@ -1507,9 +1708,26 @@ pub async fn run_update_scheduler(data_dir: std::path::PathBuf) {
|
||||
// Check every hour; act based on schedule setting
|
||||
let mut tick = interval(Duration::from_secs(3600));
|
||||
|
||||
// Refresh the app catalog once at startup so per-app "update available"
|
||||
// badges appear without waiting for the first hourly tick.
|
||||
if let Err(e) = crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
debug!(
|
||||
"Update scheduler: initial app-catalog refresh failed: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
loop {
|
||||
tick.tick().await;
|
||||
|
||||
// App-catalog refresh is INDEPENDENT of the OTA schedule below: it only
|
||||
// populates per-app update availability (the "Update" button still has
|
||||
// to be clicked — nothing auto-applies). Best-effort; on failure the
|
||||
// previously cached catalog stays in place (origin-always-wins).
|
||||
if let Err(e) = crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
debug!("Update scheduler: app-catalog refresh failed: {}", e);
|
||||
}
|
||||
|
||||
let state = match load_state(&data_dir).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
@@ -1648,6 +1866,7 @@ mod tests {
|
||||
download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago".into(),
|
||||
sha256: "x".into(),
|
||||
size_bytes: 1,
|
||||
blake3: None,
|
||||
},
|
||||
ComponentUpdate {
|
||||
name: "frontend".into(),
|
||||
@@ -1656,6 +1875,7 @@ mod tests {
|
||||
download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/frontend.tar.gz".into(),
|
||||
sha256: "y".into(),
|
||||
size_bytes: 2,
|
||||
blake3: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1855,6 +2075,13 @@ mod tests {
|
||||
tokio::fs::write(staging.join("archipelago"), b"staged")
|
||||
.await
|
||||
.unwrap();
|
||||
// A *complete* staged update carries the .download-complete marker;
|
||||
// without it has_staged_update() reads the staging as partial and the
|
||||
// load_state self-heal clears update_in_progress (see #26). This test
|
||||
// simulates a complete staging, so write the marker.
|
||||
tokio::fs::write(staging.join(STAGED_COMPLETE_MARKER), b"1")
|
||||
.await
|
||||
.unwrap();
|
||||
let state = UpdateState {
|
||||
current_version: "1.0.0".to_string(),
|
||||
last_check: Some("2025-06-15T12:00:00Z".to_string()),
|
||||
@@ -1869,6 +2096,7 @@ mod tests {
|
||||
download_url: "https://example.com/binary".to_string(),
|
||||
sha256: "abc123".to_string(),
|
||||
size_bytes: 5000,
|
||||
blake3: None,
|
||||
}],
|
||||
}),
|
||||
update_in_progress: true,
|
||||
|
||||
@@ -10,7 +10,7 @@ use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::{debug, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const WALLET_FILE: &str = "wallet/ecash.json";
|
||||
const MINTS_FILE: &str = "wallet/accepted_mints.json";
|
||||
@@ -106,6 +106,18 @@ impl WalletState {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Spendable (unspent, unreserved) balance grouped by mint URL.
|
||||
pub fn spendable_by_mint(&self) -> Vec<(String, u64)> {
|
||||
use std::collections::BTreeMap;
|
||||
let mut by_mint: BTreeMap<String, u64> = BTreeMap::new();
|
||||
for p in &self.proofs {
|
||||
if !p.spent && !p.reserved {
|
||||
*by_mint.entry(p.mint_url.clone()).or_default() += p.proof.amount;
|
||||
}
|
||||
}
|
||||
by_mint.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Select unspent proofs that cover at least `amount` sats from a specific mint.
|
||||
/// Returns selected proofs and any overpayment amount.
|
||||
pub fn select_proofs(&self, mint_url: &str, amount: u64) -> Option<(Vec<usize>, u64)> {
|
||||
@@ -352,10 +364,232 @@ pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Resul
|
||||
Ok(quote.amount)
|
||||
}
|
||||
|
||||
/// Create a cashuA token string to send to a peer.
|
||||
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
|
||||
// ── Cross-mint settlement (plan §2a / phasing F2) ──────────────────────────
|
||||
//
|
||||
// The wallet data model is already multi-mint (proofs, balances and selection
|
||||
// are all keyed by mint URL). What was hardcoded to the home mint is the
|
||||
// convenience layer. These `*_at` helpers parameterize that layer by target
|
||||
// mint, and `swap_between_mints` moves value across mints over Lightning so a
|
||||
// node holding tokens on mint A can pay a seeder that only accepts mint B.
|
||||
|
||||
/// How long to wait for a target mint's Lightning invoice to settle before
|
||||
/// claiming the freshly-minted tokens.
|
||||
const SWAP_CLAIM_TIMEOUT_SECS: u64 = 60;
|
||||
/// Poll interval while waiting for the invoice to settle.
|
||||
const SWAP_CLAIM_POLL_SECS: u64 = 2;
|
||||
|
||||
/// Whether we trust a mint enough to swap value *into* it (or accept its tokens).
|
||||
///
|
||||
/// The local Fedimint (home mint) is always trusted; any other mint must be on
|
||||
/// the configured accepted-mints allow-list. Comparison ignores a trailing
|
||||
/// slash so advertised URLs match stored ones. See plan §2a "Mint trust list".
|
||||
pub async fn is_mint_trusted(data_dir: &Path, mint_url: &str) -> Result<bool> {
|
||||
let norm = |s: &str| s.trim_end_matches('/').to_string();
|
||||
let target = norm(mint_url);
|
||||
if target == norm(&default_mint_url()) {
|
||||
return Ok(true);
|
||||
}
|
||||
let accepted = load_accepted_mints(data_dir).await?;
|
||||
Ok(accepted.mints.iter().any(|m| norm(m) == target))
|
||||
}
|
||||
|
||||
/// All-in cost of a swap, relative to the amount actually delivered.
|
||||
///
|
||||
/// `total_paid` is what the source mint charges (melt amount + LN fee reserve);
|
||||
/// `amount_delivered` is what lands on the target mint. The difference is the
|
||||
/// fee the user pays to move value across mints.
|
||||
fn swap_fee(total_paid: u64, amount_delivered: u64) -> u64 {
|
||||
total_paid.saturating_sub(amount_delivered)
|
||||
}
|
||||
|
||||
/// Move `amount_sats` of value from mint `from_mint` to mint `to_mint` over
|
||||
/// Lightning, returning the amount claimed on the target mint.
|
||||
///
|
||||
/// Flow (Cashu/Fedimint settle over BOLT11):
|
||||
/// 1. `mint_quote` on the target → a Lightning invoice to pay.
|
||||
/// 2. `melt_quote` on the source → the cost in source tokens (amount + fee).
|
||||
/// 3. Fee-cap check: refuse if the all-in fee exceeds `max_fee_sats`.
|
||||
/// 4. Select source proofs and `melt` them to pay the target's invoice.
|
||||
/// 5. Once the invoice settles, `mint` (claim) the tokens on the target.
|
||||
///
|
||||
/// Crash-safety: the source spend is persisted *before* the claim, so a crash
|
||||
/// between paying and claiming never double-spends — at worst the target tokens
|
||||
/// are left unclaimed (reconcilable from the mint quote id). Idempotent resume
|
||||
/// is phasing step 3 (deferred).
|
||||
pub async fn swap_between_mints(
|
||||
data_dir: &Path,
|
||||
from_mint: &str,
|
||||
to_mint: &str,
|
||||
amount_sats: u64,
|
||||
max_fee_sats: u64,
|
||||
) -> Result<u64> {
|
||||
if amount_sats == 0 {
|
||||
anyhow::bail!("swap amount must be greater than zero");
|
||||
}
|
||||
let norm = |s: &str| s.trim_end_matches('/').to_string();
|
||||
if norm(from_mint) == norm(to_mint) {
|
||||
anyhow::bail!("swap source and target mints are identical");
|
||||
}
|
||||
if !is_mint_trusted(data_dir, to_mint).await? {
|
||||
anyhow::bail!(
|
||||
"target mint '{}' is not in the trusted/accepted mint list",
|
||||
to_mint
|
||||
);
|
||||
}
|
||||
|
||||
let from = MintClient::new(from_mint)?;
|
||||
let to = MintClient::new(to_mint)?;
|
||||
|
||||
// 1. Mint quote on the target → invoice to pay.
|
||||
let mint_quote = to
|
||||
.mint_quote(amount_sats)
|
||||
.await
|
||||
.with_context(|| format!("requesting mint quote at target mint {}", to_mint))?;
|
||||
|
||||
// 2. Melt quote on the source for that invoice → cost in source tokens.
|
||||
let melt_quote = from
|
||||
.melt_quote(&mint_quote.request)
|
||||
.await
|
||||
.with_context(|| format!("requesting melt quote at source mint {}", from_mint))?;
|
||||
let total_needed = melt_quote.amount + melt_quote.fee_reserve;
|
||||
let fee = swap_fee(total_needed, amount_sats);
|
||||
|
||||
// 3. Fee-cap check — caller falls back to free origin if too expensive.
|
||||
if fee > max_fee_sats {
|
||||
anyhow::bail!(
|
||||
"swap fee {} sats exceeds cap {} sats (need {} on {} to deliver {} on {})",
|
||||
fee,
|
||||
max_fee_sats,
|
||||
total_needed,
|
||||
from_mint,
|
||||
amount_sats,
|
||||
to_mint
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Select source proofs and melt them to pay the target invoice.
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mint_url = wallet.mint_url.clone();
|
||||
let (indices, _overpayment) =
|
||||
wallet
|
||||
.select_proofs(from_mint, total_needed)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"insufficient balance on {}: need {} sats, have {} sats",
|
||||
from_mint,
|
||||
total_needed,
|
||||
wallet.balance_for_mint(from_mint)
|
||||
)
|
||||
})?;
|
||||
let proofs: Vec<Proof> = indices
|
||||
.iter()
|
||||
.map(|&i| wallet.proofs[i].proof.clone())
|
||||
.collect();
|
||||
|
||||
if let Err(e) = from.melt_tokens(&melt_quote.quote, &proofs).await {
|
||||
// The pay leg never completed — record the route failure so future
|
||||
// payments can prefer a route with a track record.
|
||||
record_swap_failure(data_dir, from_mint, to_mint).await;
|
||||
return Err(e).with_context(|| {
|
||||
format!(
|
||||
"melting source proofs at {} to pay target invoice",
|
||||
from_mint
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Persist the spend BEFORE claiming so a crash can't double-spend, and
|
||||
// journal the in-flight swap so the claim can be resumed after a crash.
|
||||
wallet.mark_spent(&indices);
|
||||
wallet.record_tx(
|
||||
TransactionType::Melt,
|
||||
total_needed,
|
||||
&format!(
|
||||
"Cross-mint swap {}→{}: paid {} sats (fee {})",
|
||||
from_mint, to_mint, total_needed, fee
|
||||
),
|
||||
from_mint,
|
||||
to_mint,
|
||||
);
|
||||
save_wallet(data_dir, &wallet).await?;
|
||||
add_pending_swap(
|
||||
data_dir,
|
||||
PendingSwap {
|
||||
from_mint: from_mint.to_string(),
|
||||
to_mint: to_mint.to_string(),
|
||||
amount_sats,
|
||||
melt_quote_id: melt_quote.quote.clone(),
|
||||
mint_quote_id: mint_quote.quote.clone(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 5. Wait for the invoice to settle, then claim the minted tokens.
|
||||
wait_for_mint_quote_paid(&to, &mint_quote.quote).await?;
|
||||
let result = to
|
||||
.mint_tokens(&mint_quote.quote, amount_sats)
|
||||
.await
|
||||
.with_context(|| format!("claiming minted tokens at target mint {}", to_mint))?;
|
||||
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
|
||||
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
wallet.add_proofs(to_mint, result.proofs);
|
||||
wallet.record_tx(
|
||||
TransactionType::Mint,
|
||||
minted,
|
||||
&format!(
|
||||
"Cross-mint swap {}→{}: claimed {} sats",
|
||||
from_mint, to_mint, minted
|
||||
),
|
||||
to_mint,
|
||||
from_mint,
|
||||
);
|
||||
save_wallet(data_dir, &wallet).await?;
|
||||
|
||||
// Swap fully settled — clear the journal entry and credit the route.
|
||||
remove_pending_swap(data_dir, &mint_quote.quote).await?;
|
||||
record_swap_success(data_dir, from_mint, to_mint).await;
|
||||
|
||||
debug!(
|
||||
"Cross-mint swap complete: {} → {} delivered {} sats (fee {})",
|
||||
from_mint, to_mint, minted, fee
|
||||
);
|
||||
Ok(minted)
|
||||
}
|
||||
|
||||
/// Poll a mint quote until its Lightning invoice is paid (state `PAID`/`ISSUED`),
|
||||
/// or time out. The melt above pays the invoice; the target mint sees it settle
|
||||
/// shortly after.
|
||||
async fn wait_for_mint_quote_paid(client: &MintClient, quote_id: &str) -> Result<()> {
|
||||
let deadline = SWAP_CLAIM_TIMEOUT_SECS / SWAP_CLAIM_POLL_SECS.max(1);
|
||||
for _ in 0..deadline.max(1) {
|
||||
let status = client.mint_quote_status(quote_id).await?;
|
||||
match status.state.as_str() {
|
||||
"PAID" | "ISSUED" => return Ok(()),
|
||||
_ => tokio::time::sleep(std::time::Duration::from_secs(SWAP_CLAIM_POLL_SECS)).await,
|
||||
}
|
||||
}
|
||||
anyhow::bail!(
|
||||
"target mint invoice for quote {} did not settle within {}s",
|
||||
quote_id,
|
||||
SWAP_CLAIM_TIMEOUT_SECS
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a cashuA 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.
|
||||
///
|
||||
/// 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
|
||||
/// only ever receives its own mint's proofs (see plan §2a, payer-side swap).
|
||||
pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result<String> {
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mint_url = mint_url.to_string();
|
||||
|
||||
// Select proofs covering the amount
|
||||
let (indices, overpayment) = wallet
|
||||
@@ -422,6 +656,336 @@ pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
|
||||
Ok(token_str)
|
||||
}
|
||||
|
||||
// ── Payer-side payment builder (plan §2a step 2) ───────────────────────────
|
||||
//
|
||||
// Given a seeder's advertised `accepted_mints`, pick the cheapest way to pay:
|
||||
// spend tokens we already hold on an accepted mint (no fee), else swap value
|
||||
// into a *trusted* accepted mint and pay from there. If neither is possible
|
||||
// within budget, decline so the caller falls back to free origin.
|
||||
|
||||
/// How a payment of a given amount can be satisfied across our mints.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PaymentPlan {
|
||||
/// We already hold enough on this accepted mint — pay directly, no swap fee.
|
||||
Direct { mint_url: String },
|
||||
/// Swap value from `from_mint` into the trusted accepted `to_mint`, then pay.
|
||||
Swap { from_mint: String, to_mint: String },
|
||||
/// No single mint can cover the amount (caller should use free origin).
|
||||
Insufficient,
|
||||
}
|
||||
|
||||
/// Decide how to pay `amount` sats to a seeder, given what we hold and which
|
||||
/// mints the seeder accepts.
|
||||
///
|
||||
/// - `holdings`: our spendable `(mint_url, balance)` pairs (verbatim URLs).
|
||||
/// - `accepted`: the seeder's `(mint_url, trusted)` pairs, where `trusted`
|
||||
/// means the mint is on our swap-into allow-list (`is_mint_trusted`).
|
||||
/// - Direct beats Swap (no fee). A Direct target needs no trust (we already
|
||||
/// hold those tokens); a Swap target must be trusted. The home mint is
|
||||
/// preferred as a tie-break for both legs (lowest friction).
|
||||
///
|
||||
/// Pure and synchronous so it can be unit-tested without a live mint. It does
|
||||
/// not know swap fees; `swap_between_mints` enforces the fee cap and bails (→
|
||||
/// origin fallback) if the chosen source can't cover amount + fee.
|
||||
fn plan_payment(
|
||||
holdings: &[(String, u64)],
|
||||
accepted: &[(String, bool)],
|
||||
amount: u64,
|
||||
) -> PaymentPlan {
|
||||
let norm = |s: &str| s.trim_end_matches('/').to_string();
|
||||
let home = norm(&default_mint_url());
|
||||
let held = |mint: &str| -> u64 {
|
||||
holdings
|
||||
.iter()
|
||||
.filter(|(m, _)| norm(m) == norm(mint))
|
||||
.map(|(_, b)| *b)
|
||||
.sum()
|
||||
};
|
||||
|
||||
// 1. Direct: any accepted mint we already hold enough on. Prefer home.
|
||||
let mut direct: Vec<&(String, bool)> =
|
||||
accepted.iter().filter(|(m, _)| held(m) >= amount).collect();
|
||||
direct.sort_by_key(|(m, _)| norm(m) != home); // home (false) sorts first
|
||||
if let Some((mint, _)) = direct.first() {
|
||||
return PaymentPlan::Direct {
|
||||
mint_url: mint.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Swap: a trusted accepted target + a source we hold that covers `amount`.
|
||||
let mut targets: Vec<&(String, bool)> =
|
||||
accepted.iter().filter(|(_, trusted)| *trusted).collect();
|
||||
targets.sort_by_key(|(m, _)| norm(m) != home);
|
||||
if let Some((to_mint, _)) = targets.first() {
|
||||
// Largest source we hold that isn't the target itself.
|
||||
let from = holdings
|
||||
.iter()
|
||||
.filter(|(m, b)| norm(m) != norm(to_mint) && *b >= amount)
|
||||
.max_by_key(|(_, b)| *b);
|
||||
if let Some((from_mint, _)) = from {
|
||||
return PaymentPlan::Swap {
|
||||
from_mint: from_mint.clone(),
|
||||
to_mint: to_mint.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
PaymentPlan::Insufficient
|
||||
}
|
||||
|
||||
/// Build a cashuA 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.
|
||||
///
|
||||
/// Errors (caller should fall back to free origin) when no accepted mint is
|
||||
/// reachable within balance, no trusted swap target exists, or the swap exceeds
|
||||
/// the fee cap.
|
||||
pub async fn build_payment_token(
|
||||
data_dir: &Path,
|
||||
accepted_mints: &[String],
|
||||
amount_sats: u64,
|
||||
max_fee_sats: u64,
|
||||
) -> Result<String> {
|
||||
if amount_sats == 0 {
|
||||
anyhow::bail!("payment amount must be greater than zero");
|
||||
}
|
||||
if accepted_mints.is_empty() {
|
||||
anyhow::bail!("seeder advertised no accepted mints");
|
||||
}
|
||||
|
||||
// Annotate each accepted mint with whether we trust swapping into it.
|
||||
let mut accepted: Vec<(String, bool)> = Vec::with_capacity(accepted_mints.len());
|
||||
for m in accepted_mints {
|
||||
let trusted = is_mint_trusted(data_dir, m).await?;
|
||||
accepted.push((m.clone(), trusted));
|
||||
}
|
||||
|
||||
// Prefer swap targets with a liquidity track record. plan_payment's stable
|
||||
// sort keeps the home mint first; within the rest, this orders by how
|
||||
// reliably we've reached each target before (best routes first).
|
||||
let liq = load_swap_liquidity(data_dir).await;
|
||||
accepted.sort_by_key(|(m, _)| std::cmp::Reverse(target_liquidity_score(&liq, m)));
|
||||
|
||||
let holdings = load_wallet(data_dir).await?.spendable_by_mint();
|
||||
|
||||
match plan_payment(&holdings, &accepted, amount_sats) {
|
||||
PaymentPlan::Direct { mint_url } => {
|
||||
debug!(
|
||||
"Payment plan: direct from {} for {} sats",
|
||||
mint_url, amount_sats
|
||||
);
|
||||
send_token_at(data_dir, &mint_url, amount_sats).await
|
||||
}
|
||||
PaymentPlan::Swap { from_mint, to_mint } => {
|
||||
debug!(
|
||||
"Payment plan: swap {}→{} then pay {} sats (fee cap {})",
|
||||
from_mint, to_mint, amount_sats, max_fee_sats
|
||||
);
|
||||
swap_between_mints(data_dir, &from_mint, &to_mint, amount_sats, max_fee_sats).await?;
|
||||
send_token_at(data_dir, &to_mint, amount_sats).await
|
||||
}
|
||||
PaymentPlan::Insufficient => anyhow::bail!(
|
||||
"cannot pay {} sats: no accepted mint covers it within balance/trust",
|
||||
amount_sats
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ── F2 step 3 — hardening: idempotent swap resume + liquidity cache ─────────
|
||||
|
||||
const PENDING_SWAPS_FILE: &str = "wallet/pending_swaps.json";
|
||||
const SWAP_LIQUIDITY_FILE: &str = "wallet/swap_liquidity.json";
|
||||
|
||||
/// An in-flight cross-mint swap, journaled the moment the source proofs are
|
||||
/// melted (paid) so a crash before the target claim can be resumed instead of
|
||||
/// silently losing the value. Removed once the target tokens are claimed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingSwap {
|
||||
pub from_mint: String,
|
||||
pub to_mint: String,
|
||||
pub amount_sats: u64,
|
||||
/// Source-mint melt quote id (the leg already paid).
|
||||
pub melt_quote_id: String,
|
||||
/// Target-mint mint quote id (the leg to claim).
|
||||
pub mint_quote_id: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
async fn load_pending_swaps(data_dir: &Path) -> Result<Vec<PendingSwap>> {
|
||||
let path = data_dir.join(PENDING_SWAPS_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path).await?;
|
||||
Ok(serde_json::from_str(&content).unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn save_pending_swaps(data_dir: &Path, swaps: &[PendingSwap]) -> Result<()> {
|
||||
let dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&dir).await?;
|
||||
let path = data_dir.join(PENDING_SWAPS_FILE);
|
||||
fs::write(&path, serde_json::to_string_pretty(swaps)?).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_pending_swap(data_dir: &Path, swap: PendingSwap) -> Result<()> {
|
||||
let mut all = load_pending_swaps(data_dir).await?;
|
||||
all.push(swap);
|
||||
save_pending_swaps(data_dir, &all).await
|
||||
}
|
||||
|
||||
async fn remove_pending_swap(data_dir: &Path, mint_quote_id: &str) -> Result<()> {
|
||||
let mut all = load_pending_swaps(data_dir).await?;
|
||||
all.retain(|s| s.mint_quote_id != mint_quote_id);
|
||||
save_pending_swaps(data_dir, &all).await
|
||||
}
|
||||
|
||||
/// Resume any swaps that were interrupted between paying the source mint and
|
||||
/// claiming the target tokens. For each pending swap, ask the target mint about
|
||||
/// the mint quote:
|
||||
/// - `PAID` → claim now (the value was paid but never claimed). Reclaimed.
|
||||
/// - `ISSUED` → already claimed on a prior run; just drop the journal entry.
|
||||
/// - else → leave it (the invoice hasn't settled yet; retry next time).
|
||||
///
|
||||
/// Returns the total sats reclaimed. Safe to call repeatedly (idempotent): a
|
||||
/// quote is only minted once, and `ISSUED` quotes are never re-claimed.
|
||||
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) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"resume_pending_swaps: bad target mint {}: {}",
|
||||
swap.to_mint, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let status = match to.mint_quote_status(&swap.mint_quote_id).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"resume_pending_swaps: status check failed for {}: {} — leaving pending",
|
||||
swap.mint_quote_id, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match status.state.as_str() {
|
||||
"PAID" => match to.mint_tokens(&swap.mint_quote_id, swap.amount_sats).await {
|
||||
Ok(result) => {
|
||||
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
wallet.add_proofs(&swap.to_mint, result.proofs);
|
||||
wallet.record_tx(
|
||||
TransactionType::Mint,
|
||||
minted,
|
||||
&format!(
|
||||
"Resumed cross-mint swap {}→{}: claimed {} sats",
|
||||
swap.from_mint, swap.to_mint, minted
|
||||
),
|
||||
&swap.to_mint,
|
||||
&swap.from_mint,
|
||||
);
|
||||
save_wallet(data_dir, &wallet).await?;
|
||||
remove_pending_swap(data_dir, &swap.mint_quote_id).await?;
|
||||
record_swap_success(data_dir, &swap.from_mint, &swap.to_mint).await;
|
||||
reclaimed += minted;
|
||||
info!(
|
||||
"Resumed interrupted swap {}→{}: reclaimed {} sats",
|
||||
swap.from_mint, swap.to_mint, minted
|
||||
);
|
||||
}
|
||||
Err(e) => warn!(
|
||||
"resume_pending_swaps: claim failed for {}: {}",
|
||||
swap.mint_quote_id, e
|
||||
),
|
||||
},
|
||||
"ISSUED" => {
|
||||
// Already claimed on a previous run — drop the journal entry.
|
||||
remove_pending_swap(data_dir, &swap.mint_quote_id).await?;
|
||||
}
|
||||
other => debug!(
|
||||
"resume_pending_swaps: quote {} state {} — leaving pending",
|
||||
swap.mint_quote_id, other
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(reclaimed)
|
||||
}
|
||||
|
||||
/// Success/failure counts for a single (from → to) swap route.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
struct RouteStat {
|
||||
successes: u64,
|
||||
failures: u64,
|
||||
}
|
||||
|
||||
/// Per-mint-pair liquidity cache: which swap routes have actually worked, so the
|
||||
/// payer can prefer routes with a track record over ones that keep failing.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct SwapLiquidity {
|
||||
/// Keyed by `"<from>|<to>"` (normalized URLs).
|
||||
routes: std::collections::BTreeMap<String, RouteStat>,
|
||||
}
|
||||
|
||||
fn route_key(from_mint: &str, to_mint: &str) -> String {
|
||||
format!(
|
||||
"{}|{}",
|
||||
from_mint.trim_end_matches('/'),
|
||||
to_mint.trim_end_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
async fn load_swap_liquidity(data_dir: &Path) -> SwapLiquidity {
|
||||
let path = data_dir.join(SWAP_LIQUIDITY_FILE);
|
||||
match fs::read_to_string(&path).await {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||
Err(_) => SwapLiquidity::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_swap_liquidity(data_dir: &Path, liq: &SwapLiquidity) {
|
||||
let dir = data_dir.join("wallet");
|
||||
let _ = fs::create_dir_all(&dir).await;
|
||||
if let Ok(content) = serde_json::to_string_pretty(liq) {
|
||||
let _ = fs::write(data_dir.join(SWAP_LIQUIDITY_FILE), content).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that a swap route succeeded (best-effort; never fails the caller).
|
||||
async fn record_swap_success(data_dir: &Path, from_mint: &str, to_mint: &str) {
|
||||
let mut liq = load_swap_liquidity(data_dir).await;
|
||||
liq.routes
|
||||
.entry(route_key(from_mint, to_mint))
|
||||
.or_default()
|
||||
.successes += 1;
|
||||
save_swap_liquidity(data_dir, &liq).await;
|
||||
}
|
||||
|
||||
/// Record that a swap route failed (best-effort; never fails the caller).
|
||||
async fn record_swap_failure(data_dir: &Path, from_mint: &str, to_mint: &str) {
|
||||
let mut liq = load_swap_liquidity(data_dir).await;
|
||||
liq.routes
|
||||
.entry(route_key(from_mint, to_mint))
|
||||
.or_default()
|
||||
.failures += 1;
|
||||
save_swap_liquidity(data_dir, &liq).await;
|
||||
}
|
||||
|
||||
/// Liquidity score for reaching `to_mint` from any source: net successes across
|
||||
/// all routes ending at this target. Higher = a more reliable destination.
|
||||
fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 {
|
||||
let suffix = format!("|{}", to_mint.trim_end_matches('/'));
|
||||
liq.routes
|
||||
.iter()
|
||||
.filter(|(k, _)| k.ends_with(&suffix))
|
||||
.map(|(_, s)| s.successes as i64 - s.failures as i64)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Receive a cashuA 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
|
||||
@@ -936,4 +1500,323 @@ mod tests {
|
||||
fn test_default_mint_url() {
|
||||
assert_eq!(default_mint_url(), "http://127.0.0.1:8175");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_swap_fee() {
|
||||
assert_eq!(swap_fee(105, 100), 5);
|
||||
// Defensive: never underflow if mint quotes oddly.
|
||||
assert_eq!(swap_fee(100, 100), 0);
|
||||
assert_eq!(swap_fee(90, 100), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_is_mint_trusted_home_always() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Home mint is trusted even with no accepted-mints file.
|
||||
assert!(is_mint_trusted(tmp.path(), &default_mint_url())
|
||||
.await
|
||||
.unwrap());
|
||||
// Trailing slash on the home URL still matches.
|
||||
assert!(is_mint_trusted(tmp.path(), "http://127.0.0.1:8175/")
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_is_mint_trusted_respects_accepted_list() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
save_accepted_mints(
|
||||
tmp.path(),
|
||||
&AcceptedMints {
|
||||
mints: vec![default_mint_url(), "https://mint.example.com".into()],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(is_mint_trusted(tmp.path(), "https://mint.example.com")
|
||||
.await
|
||||
.unwrap());
|
||||
// Normalized comparison ignores a trailing slash.
|
||||
assert!(is_mint_trusted(tmp.path(), "https://mint.example.com/")
|
||||
.await
|
||||
.unwrap());
|
||||
// A mint not on the list is not trusted.
|
||||
assert!(!is_mint_trusted(tmp.path(), "https://evil.example.com")
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_swap_between_mints_rejects_identical() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let err = swap_between_mints(
|
||||
tmp.path(),
|
||||
&default_mint_url(),
|
||||
"http://127.0.0.1:8175/",
|
||||
100,
|
||||
10,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("identical"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_swap_between_mints_rejects_untrusted_target() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let err = swap_between_mints(
|
||||
tmp.path(),
|
||||
&default_mint_url(),
|
||||
"https://untrusted.example.com",
|
||||
100,
|
||||
10,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("trusted"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_swap_between_mints_rejects_zero_amount() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let err = swap_between_mints(
|
||||
tmp.path(),
|
||||
&default_mint_url(),
|
||||
"https://mint.example.com",
|
||||
0,
|
||||
10,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("greater than zero"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spendable_by_mint_groups_and_excludes() {
|
||||
let mut wallet = WalletState::default();
|
||||
wallet.add_proofs(
|
||||
"http://mint-a",
|
||||
vec![
|
||||
Proof {
|
||||
amount: 10,
|
||||
id: "k".into(),
|
||||
secret: "s1".into(),
|
||||
c: "c".into(),
|
||||
},
|
||||
Proof {
|
||||
amount: 5,
|
||||
id: "k".into(),
|
||||
secret: "s2".into(),
|
||||
c: "c".into(),
|
||||
},
|
||||
],
|
||||
);
|
||||
wallet.add_proofs(
|
||||
"http://mint-b",
|
||||
vec![Proof {
|
||||
amount: 7,
|
||||
id: "k".into(),
|
||||
secret: "s3".into(),
|
||||
c: "c".into(),
|
||||
}],
|
||||
);
|
||||
wallet.proofs[1].spent = true; // exclude the 5 on mint-a
|
||||
let by_mint = wallet.spendable_by_mint();
|
||||
assert_eq!(
|
||||
by_mint,
|
||||
vec![
|
||||
("http://mint-a".to_string(), 10),
|
||||
("http://mint-b".to_string(), 7)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_payment_direct_prefers_home() {
|
||||
let home = default_mint_url();
|
||||
let holdings = vec![(home.clone(), 100), ("https://other".into(), 100)];
|
||||
// Both accepted; home should win the tie-break.
|
||||
let accepted = vec![("https://other".into(), true), (home.clone(), true)];
|
||||
assert_eq!(
|
||||
plan_payment(&holdings, &accepted, 50),
|
||||
PaymentPlan::Direct { mint_url: home }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_payment_direct_only_accepted_mint() {
|
||||
let holdings = vec![("https://a".into(), 100), ("https://b".into(), 100)];
|
||||
// We hold both, but the seeder only accepts b.
|
||||
let accepted = vec![("https://b".into(), true)];
|
||||
assert_eq!(
|
||||
plan_payment(&holdings, &accepted, 50),
|
||||
PaymentPlan::Direct {
|
||||
mint_url: "https://b".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_payment_swaps_into_trusted_target() {
|
||||
// We hold value on A; seeder accepts only B (trusted) which we don't hold.
|
||||
let holdings = vec![("https://a".into(), 100)];
|
||||
let accepted = vec![("https://b".into(), true)];
|
||||
assert_eq!(
|
||||
plan_payment(&holdings, &accepted, 50),
|
||||
PaymentPlan::Swap {
|
||||
from_mint: "https://a".into(),
|
||||
to_mint: "https://b".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_payment_refuses_untrusted_swap_target() {
|
||||
// Seeder accepts only B, but B is not trusted → no swap, insufficient.
|
||||
let holdings = vec![("https://a".into(), 100)];
|
||||
let accepted = vec![("https://b".into(), false)];
|
||||
assert_eq!(
|
||||
plan_payment(&holdings, &accepted, 50),
|
||||
PaymentPlan::Insufficient
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_payment_insufficient_when_no_single_source_covers() {
|
||||
// Total 60 across two mints, but neither alone covers 50+ for a swap and
|
||||
// we hold neither accepted mint directly.
|
||||
let holdings = vec![("https://a".into(), 30), ("https://c".into(), 30)];
|
||||
let accepted = vec![("https://b".into(), true)];
|
||||
assert_eq!(
|
||||
plan_payment(&holdings, &accepted, 50),
|
||||
PaymentPlan::Insufficient
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_payment_direct_beats_swap() {
|
||||
// We hold the accepted mint directly AND could swap — Direct must win.
|
||||
let home = default_mint_url();
|
||||
let holdings = vec![("https://b".into(), 100), (home.clone(), 100)];
|
||||
let accepted = vec![("https://b".into(), true)];
|
||||
assert_eq!(
|
||||
plan_payment(&holdings, &accepted, 50),
|
||||
PaymentPlan::Direct {
|
||||
mint_url: "https://b".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_build_payment_token_rejects_empty_mints() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let err = build_payment_token(tmp.path(), &[], 100, 10)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("no accepted mints"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_build_payment_token_insufficient_falls_through() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Empty wallet, untrusted seeder mint → cannot pay (caller uses origin).
|
||||
let err = build_payment_token(tmp.path(), &["https://seeder.example.com".into()], 100, 10)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("cannot pay"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_key_normalizes_trailing_slash() {
|
||||
assert_eq!(route_key("https://a/", "https://b/"), "https://a|https://b");
|
||||
assert_eq!(route_key("https://a", "https://b"), "https://a|https://b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pending_swaps_roundtrip_and_remove() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
assert!(load_pending_swaps(tmp.path()).await.unwrap().is_empty());
|
||||
|
||||
add_pending_swap(
|
||||
tmp.path(),
|
||||
PendingSwap {
|
||||
from_mint: "https://a".into(),
|
||||
to_mint: "https://b".into(),
|
||||
amount_sats: 100,
|
||||
melt_quote_id: "melt-1".into(),
|
||||
mint_quote_id: "mint-1".into(),
|
||||
created_at: "2026-06-17T00:00:00Z".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let loaded = load_pending_swaps(tmp.path()).await.unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].mint_quote_id, "mint-1");
|
||||
|
||||
remove_pending_swap(tmp.path(), "mint-1").await.unwrap();
|
||||
assert!(load_pending_swaps(tmp.path()).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resume_pending_swaps_empty_is_noop() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
assert_eq!(resume_pending_swaps(tmp.path()).await.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_liquidity_cache_records_and_scores() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Two routes into B succeed; one into C fails.
|
||||
record_swap_success(tmp.path(), "https://a", "https://b").await;
|
||||
record_swap_success(tmp.path(), "https://x", "https://b").await;
|
||||
record_swap_failure(tmp.path(), "https://a", "https://c").await;
|
||||
|
||||
let liq = load_swap_liquidity(tmp.path()).await;
|
||||
// B reached successfully from two sources → net +2; trailing slash tolerant.
|
||||
assert_eq!(target_liquidity_score(&liq, "https://b/"), 2);
|
||||
// C only failed → net -1.
|
||||
assert_eq!(target_liquidity_score(&liq, "https://c"), -1);
|
||||
// Unknown target → neutral 0.
|
||||
assert_eq!(target_liquidity_score(&liq, "https://unknown"), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_build_payment_token_prefers_liquid_target() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Trust two non-home mints; hold value on a source mint for both.
|
||||
save_accepted_mints(
|
||||
tmp.path(),
|
||||
&AcceptedMints {
|
||||
mints: vec![
|
||||
default_mint_url(),
|
||||
"https://liquid".into(),
|
||||
"https://dry".into(),
|
||||
],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Give "https://liquid" a track record so it should be preferred.
|
||||
record_swap_success(tmp.path(), "https://src", "https://liquid").await;
|
||||
|
||||
// Seeder accepts both non-home mints; we only hold "https://src".
|
||||
let accepted = vec![
|
||||
("https://dry".into(), true),
|
||||
("https://liquid".into(), true),
|
||||
];
|
||||
let holdings = vec![("https://src".to_string(), 1000u64)];
|
||||
|
||||
// Mirror build_payment_token's ordering step, then plan.
|
||||
let liq = load_swap_liquidity(tmp.path()).await;
|
||||
let mut ordered = accepted.clone();
|
||||
ordered.sort_by_key(|(m, _): &(String, bool)| {
|
||||
std::cmp::Reverse(target_liquidity_score(&liq, m))
|
||||
});
|
||||
match plan_payment(&holdings, &ordered, 100) {
|
||||
PaymentPlan::Swap { to_mint, .. } => assert_eq!(to_mint, "https://liquid"),
|
||||
other => panic!("expected swap into liquid target, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Thin HTTP bridge to the `fedimint-clientd` sidecar container.
|
||||
//!
|
||||
//! Keeps the heavy, fast-moving Fedimint client SDK OUT of this binary: the
|
||||
//! `fedimint-clientd` daemon (in `apps/fedimint-clientd`) holds the federation
|
||||
//! clients and ecash notes; we just speak its REST API (`/v2/*`, Bearer auth),
|
||||
//! mirroring how [`super::mint_client::MintClient`] speaks the Cashu NUT API.
|
||||
//!
|
||||
//! See `docs/dual-ecash-design.md`. Endpoint/JSON shapes target fedimint-clientd
|
||||
//! v0.3.x and must be pinned to the vendored image tag.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::debug;
|
||||
|
||||
const CLIENTD_TIMEOUT_SECS: u64 = 15;
|
||||
const CLIENTD_HEAVY_TIMEOUT_SECS: u64 = 60;
|
||||
|
||||
/// Default host port the `fedimint-clientd` container is mapped to (its own
|
||||
/// default 8080 collides with LND REST, so the manifest maps it to 8178).
|
||||
const DEFAULT_CLIENTD_URL: &str = "http://127.0.0.1:8178";
|
||||
|
||||
/// Federation joined out-of-the-box on every node. The fmcd container also
|
||||
/// auto-joins this at boot (`FMCD_INVITE_CODE` in the manifest); keep in sync.
|
||||
///
|
||||
/// The preferred default federation (guardian on .116, iroh transport).
|
||||
/// Validated: fmcd 0.8.2 joins it (federation_id 2debd071…73b76884). iroh does
|
||||
/// NAT traversal, so it's reachable fleet-wide — the right fleet default.
|
||||
/// CAVEAT: iroh is experimental and the connection can be flaky (esp. NAT
|
||||
/// hairpin when fmcd runs on .116 itself reaching .116's own WAN IP); validate
|
||||
/// reliability from a separate node. ensure_default_federation is best-effort.
|
||||
/// See docs/dual-ecash-design.md.
|
||||
pub const DEFAULT_FEDERATION_INVITE: &str = "fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp";
|
||||
|
||||
/// One joined federation, persisted locally so the list survives clientd being
|
||||
/// temporarily down. Balances are always read live from clientd.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JoinedFederation {
|
||||
pub federation_id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct FederationRegistry {
|
||||
pub federations: Vec<JoinedFederation>,
|
||||
}
|
||||
|
||||
const REGISTRY_FILE: &str = "wallet/fedimint_federations.json";
|
||||
|
||||
pub async fn load_registry(data_dir: &Path) -> Result<FederationRegistry> {
|
||||
let path = data_dir.join(REGISTRY_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(FederationRegistry::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read fedimint federation registry")?;
|
||||
Ok(serde_json::from_str(&content).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn save_registry(data_dir: &Path, reg: &FederationRegistry) -> Result<()> {
|
||||
let dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create wallet dir")?;
|
||||
let content = serde_json::to_string_pretty(reg).context("Failed to serialize registry")?;
|
||||
fs::write(data_dir.join(REGISTRY_FILE), content)
|
||||
.await
|
||||
.context("Failed to write fedimint federation registry")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Idempotently ensure the node has joined the default federation and that it
|
||||
/// is tracked in the local registry. Best-effort: silently no-ops if clientd
|
||||
/// isn't installed/running yet. Joining is idempotent on the clientd side.
|
||||
pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
|
||||
let client = match FedimintClient::from_node(data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(()), // clientd not configured yet
|
||||
};
|
||||
let federation_id = match client.join(DEFAULT_FEDERATION_INVITE).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
debug!("default federation autojoin skipped: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let mut reg = load_registry(data_dir).await?;
|
||||
if !reg
|
||||
.federations
|
||||
.iter()
|
||||
.any(|f| f.federation_id == federation_id)
|
||||
{
|
||||
reg.federations.push(JoinedFederation {
|
||||
federation_id,
|
||||
name: None,
|
||||
});
|
||||
save_registry(data_dir, ®).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// HTTP client for a `fedimint-clientd` instance.
|
||||
pub struct FedimintClient {
|
||||
base_url: String,
|
||||
password: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl FedimintClient {
|
||||
pub fn new(base_url: &str, password: &str) -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(CLIENTD_HEAVY_TIMEOUT_SECS))
|
||||
.build()
|
||||
.context("Failed to build HTTP client for fedimint-clientd")?;
|
||||
Ok(Self::with_client(base_url, password, client))
|
||||
}
|
||||
|
||||
pub fn with_client(base_url: &str, password: &str, client: reqwest::Client) -> Self {
|
||||
Self {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
password: password.to_string(),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve URL + password from env / node secret, with sane defaults.
|
||||
/// URL: `FEDIMINT_CLIENTD_URL` else the default mapped port.
|
||||
/// Password: `FEDIMINT_CLIENTD_PASSWORD` else `<data_dir>/fedimint-clientd/password`.
|
||||
pub async fn from_node(data_dir: &Path) -> Result<Self> {
|
||||
let base_url =
|
||||
std::env::var("FMCD_URL").unwrap_or_else(|_| DEFAULT_CLIENTD_URL.to_string());
|
||||
let password = match std::env::var("FMCD_PASSWORD") {
|
||||
Ok(p) if !p.is_empty() => p,
|
||||
_ => {
|
||||
let secret = data_dir.join("fmcd").join("password");
|
||||
fs::read_to_string(&secret)
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.context(
|
||||
"Fedimint client not configured (no FMCD_PASSWORD and no \
|
||||
fmcd/password secret). Install the Fedimint client app.",
|
||||
)?
|
||||
}
|
||||
};
|
||||
Self::new(&base_url, &password)
|
||||
}
|
||||
|
||||
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
// fmcd uses HTTP Basic auth with a fixed username `fmcd`.
|
||||
req.basic_auth("fmcd", Some(&self.password))
|
||||
}
|
||||
|
||||
async fn post(&self, path: &str, body: serde_json::Value) -> Result<serde_json::Value> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let resp = self
|
||||
.auth(self.client.post(&url))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("fedimint-clientd POST {path} failed (is it running?)"))?;
|
||||
Self::parse(resp, path).await
|
||||
}
|
||||
|
||||
async fn get(&self, path: &str) -> Result<serde_json::Value> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let resp = self
|
||||
.auth(self.client.get(&url))
|
||||
.timeout(std::time::Duration::from_secs(CLIENTD_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("fedimint-clientd GET {path} failed (is it running?)"))?;
|
||||
Self::parse(resp, path).await
|
||||
}
|
||||
|
||||
async fn parse(resp: reqwest::Response, path: &str) -> Result<serde_json::Value> {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("fedimint-clientd {path} returned {status}: {text}");
|
||||
}
|
||||
if text.is_empty() {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
serde_json::from_str(&text)
|
||||
.with_context(|| format!("fedimint-clientd {path} returned non-JSON: {text}"))
|
||||
}
|
||||
|
||||
/// `GET /v2/admin/info` — per-federation holdings keyed by federationId.
|
||||
pub async fn info(&self) -> Result<serde_json::Value> {
|
||||
self.get("/v2/admin/info").await
|
||||
}
|
||||
|
||||
/// `POST /v2/admin/join` — join a federation by invite code; returns its federationId.
|
||||
pub async fn join(&self, invite_code: &str) -> Result<String> {
|
||||
let res = self
|
||||
.post(
|
||||
"/v2/admin/join",
|
||||
serde_json::json!({ "inviteCode": invite_code, "useManualSecret": false }),
|
||||
)
|
||||
.await?;
|
||||
let id = res
|
||||
.get("thisFederationId")
|
||||
.or_else(|| res.get("federationId"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
match id {
|
||||
Some(id) => {
|
||||
debug!("joined fedimint federation {id}");
|
||||
Ok(id)
|
||||
}
|
||||
// Older/newer clientd may return the full info map; fall back to info().
|
||||
None => self.latest_federation_id().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total balance across all joined federations, in sats.
|
||||
pub async fn total_balance_sats(&self) -> Result<u64> {
|
||||
let info = self.info().await?;
|
||||
Ok(sum_msat(&info) / 1000)
|
||||
}
|
||||
|
||||
/// Balance of one federation in sats (0 if unknown).
|
||||
pub async fn federation_balance_sats(&self, federation_id: &str) -> Result<u64> {
|
||||
let info = self.info().await?;
|
||||
let msat = info
|
||||
.get(federation_id)
|
||||
.and_then(federation_msat)
|
||||
.unwrap_or(0);
|
||||
Ok(msat / 1000)
|
||||
}
|
||||
|
||||
/// `POST /v2/mint/spend` — prepare notes to send (ecash), in msat. Returns serialized notes.
|
||||
pub async fn spend(&self, federation_id: &str, amount_sats: u64) -> Result<String> {
|
||||
let res = self
|
||||
.post(
|
||||
"/v2/mint/spend",
|
||||
serde_json::json!({
|
||||
"federationId": federation_id,
|
||||
"amountMsat": amount_sats * 1000,
|
||||
"allowOverpay": true,
|
||||
"timeout": 3600,
|
||||
"includeInvite": false,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
res.get("notes")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("fedimint spend: no notes in response"))
|
||||
}
|
||||
|
||||
/// `POST /v2/mint/reissue` — redeem received notes; returns reissued sats.
|
||||
pub async fn reissue(&self, federation_id: &str, notes: &str) -> Result<u64> {
|
||||
let res = self
|
||||
.post(
|
||||
"/v2/mint/reissue",
|
||||
serde_json::json!({ "federationId": federation_id, "notes": notes }),
|
||||
)
|
||||
.await?;
|
||||
let msat = res
|
||||
.get("amountMsat")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("fedimint reissue: no amountMsat in response"))?;
|
||||
Ok(msat / 1000)
|
||||
}
|
||||
|
||||
async fn latest_federation_id(&self) -> Result<String> {
|
||||
let info = self.info().await?;
|
||||
info.as_object()
|
||||
.and_then(|m| m.keys().next_back().cloned())
|
||||
.ok_or_else(|| anyhow::anyhow!("joined federation but clientd reported none"))
|
||||
}
|
||||
}
|
||||
|
||||
fn federation_msat(entry: &serde_json::Value) -> Option<u64> {
|
||||
entry
|
||||
.get("totalAmountMsat")
|
||||
.or_else(|| entry.get("totalMsat"))
|
||||
.and_then(|v| v.as_u64())
|
||||
}
|
||||
|
||||
fn sum_msat(info: &serde_json::Value) -> u64 {
|
||||
info.as_object()
|
||||
.map(|m| m.values().filter_map(federation_msat).sum())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -4,5 +4,6 @@
|
||||
pub mod bdhke;
|
||||
pub mod cashu;
|
||||
pub mod ecash;
|
||||
pub mod fedimint_client;
|
||||
pub mod mint_client;
|
||||
pub mod profits;
|
||||
|
||||
@@ -858,6 +858,11 @@ pub struct HostFacts {
|
||||
/// `/` if the data partition is not yet mounted). Drives the
|
||||
/// prune-vs-full-node decision in bitcoin-knots custom_args.
|
||||
pub disk_gb: u64,
|
||||
/// Container name of the running Bitcoin node — `bitcoin-knots` or
|
||||
/// `bitcoin-core` — so dependents (mempool's CORE_RPC_HOST) reach the
|
||||
/// right host. Both are reachable on archy-net by their container name;
|
||||
/// only the name differs. Falls back to `bitcoin-knots` when undetected.
|
||||
pub bitcoin_host: String,
|
||||
}
|
||||
|
||||
impl HostFacts {
|
||||
@@ -868,13 +873,14 @@ impl HostFacts {
|
||||
host_ip: "192.168.1.116".to_string(),
|
||||
host_mdns: "archi-thinkpad.local".to_string(),
|
||||
disk_gb: 2000,
|
||||
bitcoin_host: "bitcoin-knots".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Supported placeholder names in `DerivedEnv::template`. Keep in sync
|
||||
/// with `HostFacts`. Centralized so validation and rendering agree.
|
||||
const DERIVED_PLACEHOLDERS: &[&str] = &["HOST_IP", "HOST_MDNS", "DISK_GB"];
|
||||
const DERIVED_PLACEHOLDERS: &[&str] = &["HOST_IP", "HOST_MDNS", "DISK_GB", "BITCOIN_HOST"];
|
||||
|
||||
fn validate_derived_template(key: &str, template: &str) -> Result<(), ManifestError> {
|
||||
// Walk `{{NAME}}` occurrences and ensure each NAME is recognized.
|
||||
@@ -957,7 +963,8 @@ impl ContainerConfig {
|
||||
.template
|
||||
.replace("{{HOST_IP}}", &facts.host_ip)
|
||||
.replace("{{HOST_MDNS}}", &facts.host_mdns)
|
||||
.replace("{{DISK_GB}}", &facts.disk_gb.to_string());
|
||||
.replace("{{DISK_GB}}", &facts.disk_gb.to_string())
|
||||
.replace("{{BITCOIN_HOST}}", &facts.bitcoin_host);
|
||||
format!("{}={}", e.key, value)
|
||||
})
|
||||
.collect()
|
||||
@@ -1463,6 +1470,10 @@ app:
|
||||
key: "INFO".to_string(),
|
||||
template: "{{HOST_IP}}-{{DISK_GB}}".to_string(),
|
||||
},
|
||||
DerivedEnv {
|
||||
key: "CORE_RPC_HOST".to_string(),
|
||||
template: "{{BITCOIN_HOST}}".to_string(),
|
||||
},
|
||||
],
|
||||
secret_env: vec![],
|
||||
data_uid: None,
|
||||
@@ -1471,11 +1482,13 @@ app:
|
||||
host_ip: "192.168.1.116".to_string(),
|
||||
host_mdns: "archi-thinkpad.local".to_string(),
|
||||
disk_gb: 2000,
|
||||
bitcoin_host: "bitcoin-core".to_string(),
|
||||
};
|
||||
|
||||
let out = c.resolve_derived_env(&facts);
|
||||
assert_eq!(out[0], "FM_API_URL=ws://archi-thinkpad.local:8174");
|
||||
assert_eq!(out[1], "INFO=192.168.1.116-2000");
|
||||
assert_eq!(out[2], "CORE_RPC_HOST=bitcoin-core");
|
||||
}
|
||||
|
||||
struct MapSecretsProvider {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 987 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 976 KiB After Width: | Height: | Size: 869 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 976 KiB After Width: | Height: | Size: 869 KiB |
@@ -5,10 +5,24 @@ server {
|
||||
proxy_intercept_errors on;
|
||||
error_page 500 502 503 504 = @wait_page;
|
||||
|
||||
# Serve our own wait-page/icon assets locally first, but fall back to the
|
||||
# real fedimint guardian (:8177) for ITS bundled /assets/*.css|js. Without
|
||||
# the fallback, the guardian UI's stylesheets resolve to this local root,
|
||||
# 404, and the app renders unstyled (B13 fixed the local icon; this fixes
|
||||
# the guardian UI's own CSS).
|
||||
location /assets/ {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "public, max-age=3600" always;
|
||||
try_files $uri =404;
|
||||
try_files $uri @guardian_assets;
|
||||
}
|
||||
|
||||
location @guardian_assets {
|
||||
proxy_pass http://127.0.0.1:8177;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
# Static site content.
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
#
|
||||
# FIPS UI talks only to the archipelago RPC on 127.0.0.1:5678, using the
|
||||
# browser's own archipelago session — there is NO per-node secret to
|
||||
# substitute, so (unlike bitcoin-ui) the nginx config is baked straight
|
||||
# into the image rather than bind-mounted/rendered at container-create.
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
#
|
||||
# Run nginx as root to avoid chown failures in rootless Podman user
|
||||
# namespaces. The rest of the nginx image is unchanged.
|
||||
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
|
||||
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
|
||||
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
|
||||
/var/cache/nginx/scgi_temp
|
||||
EXPOSE 8336
|
||||
ENTRYPOINT []
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user