Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6effc6b574 | ||
|
|
db52c06a72 | ||
|
|
4b14b62e74 | ||
|
|
5da91e4099 | ||
|
|
62731cc729 | ||
|
|
5e17ace690 | ||
|
|
b010471a4a | ||
|
|
c4ede96517 | ||
|
|
be06e1a502 | ||
|
|
094f42312c | ||
|
|
da8c3ec193 | ||
|
|
4fdf8e8c58 | ||
|
|
61b5d93b11 | ||
|
|
be06b3ce2b | ||
|
|
f3d96ae2ee | ||
|
|
a4ae375617 | ||
|
|
0646bc4e85 | ||
|
|
0faaf4577f | ||
|
|
f9a1ef031c | ||
|
|
cf240df4b6 | ||
|
|
d8320896c4 |
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.11-alpha (2026-09-07)
|
||||
|
||||
- **Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.
|
||||
|
||||
- **OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.
|
||||
|
||||
- **Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links.
|
||||
|
||||
## v1.8.10-alpha (2026-09-02)
|
||||
|
||||
- **Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered "No route to the recipient" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.
|
||||
|
||||
+35
-14
@@ -45,7 +45,12 @@ app:
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
memory_limit: 4Gi
|
||||
# Raised from 4Gi alongside target_max_memory below (see files[] comment)
|
||||
# — 2026-09-03 incident: a 4Gi/3GB-cache config starved
|
||||
# cuprated's DB cache into constant eviction/flush, driving 45% sustained
|
||||
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
|
||||
# headroom above the 8GiB cache for the process itself.
|
||||
memory_limit: 10Gi
|
||||
disk_limit: 300Gi
|
||||
|
||||
security:
|
||||
@@ -82,17 +87,21 @@ app:
|
||||
# bind without an explicit i_know_what_im_doing override.
|
||||
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
|
||||
# what wallets use when connecting to a "remote node". Disabled by
|
||||
# cuprated's own default; enabled via files[] below. A dashboard login
|
||||
# would break wallet clients connecting programmatically, same
|
||||
# reasoning as electrumx's port. The daemon still uses its canonical
|
||||
# container port 18089, but Penpot already owns host port 18089, so this
|
||||
# maps the public host port to the free 18090 instead.
|
||||
# cuprated's own default; enabled via files[] below. `open`, not `gated`:
|
||||
# the gate still takes the port over (loopback pin, external binds,
|
||||
# fronts the Tor onion) but skips the dashboard login challenge, same
|
||||
# reasoning as electrumx's port — wallet clients (Feather,
|
||||
# monero-wallet-rpc, GUI) speak plain HTTP JSON-RPC programmatically and
|
||||
# cannot complete a browser login or hold a session cookie. The daemon
|
||||
# still uses its canonical container port 18089, but Penpot already owns
|
||||
# host port 18089, so this maps the public host port to the free 18090
|
||||
# instead.
|
||||
- host: 18090
|
||||
container: 18089
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth: open
|
||||
auth_rationale: >-
|
||||
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.
|
||||
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot complete a browser login or hold a dashboard session cookie.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
@@ -103,11 +112,23 @@ app:
|
||||
# Settings that need to differ from cuprated's own documented defaults
|
||||
# (verified against `cuprated --generate-config` and `--dry-run` locally,
|
||||
# 2026-08-21):
|
||||
# - fast_sync: cuprated's own default is false, which performs full
|
||||
# cryptographic verification (ring signatures + RandomX PoW) on every
|
||||
# incoming block instead of trusting checkpointed history. Root-caused
|
||||
# 2026-09-03 as the dominant cause of a sustained 45% CPU node,
|
||||
# vs. 2.8% on a reference node with fast_sync = true — same chain height, same
|
||||
# block rate. Set explicitly rather than relying on the binary
|
||||
# default so fresh deploys don't silently regress into full-verify.
|
||||
# - target_max_memory: cuprated's own default auto-detects total *host*
|
||||
# RAM via sysinfo, which inside a memory-limited container would let
|
||||
# it size caches far past what resources.memory_limit above actually
|
||||
# grants — same class of problem bitcoin-knots' -dbcache sizing
|
||||
# comment addresses. Set explicitly, comfortably under the 4Gi limit.
|
||||
# comment addresses. Set explicitly, comfortably under the 10Gi limit.
|
||||
# Previously 3000000000 (~2.8GiB); that starved the DB cache and
|
||||
# forced constant eviction/flush (595GB/24h block I/O on a node just
|
||||
# appending ~2MB blocks every 2 minutes) — raised to 8GiB, matching
|
||||
# the healthy reference node, and
|
||||
# resources.memory_limit above raised in step to keep headroom above it.
|
||||
# - rpc.restricted.enable: cuprated ships this off by default; flip on
|
||||
# so the auth:none host port above actually serves something instead
|
||||
# of refusing every connection. port stays at its documented default
|
||||
@@ -128,21 +149,21 @@ app:
|
||||
# - tracing.stdout.level / tracing.file.{level,max_log_files}: an
|
||||
# operator reading Cuprated.toml on disk should be able to see and
|
||||
# tune the log level directly instead of the file silently omitting
|
||||
# the whole [tracing] table (verified live on amishparadise
|
||||
# the whole [tracing] table (verified live on the affected node
|
||||
# 2026-09-01: the deployed file had no [tracing] section at all, and
|
||||
# the level was only discoverable by running `cuprated
|
||||
# --generate-config` and diffing). file.level is set to "info", NOT
|
||||
# cuprated's own raw default of "debug" — matches the reference dev
|
||||
# config this app was built and tested against
|
||||
# (ssmithx@archy-dev-pa:/home/ssmithx/cuprate/Cuprated.toml,
|
||||
# verified 2026-09-01), which deliberately runs file logging quieter
|
||||
# config this app was built and tested against (verified 2026-09-01),
|
||||
# which deliberately runs file logging quieter
|
||||
# than the binary default. max_log_files similarly follows that
|
||||
# reference (14, not the binary default of 7).
|
||||
files:
|
||||
- path: /var/lib/archipelago/cuprate/Cuprated.toml
|
||||
content: |
|
||||
network = "Mainnet"
|
||||
target_max_memory = 3000000000
|
||||
fast_sync = true
|
||||
target_max_memory = 8589934592
|
||||
|
||||
[rpc.restricted]
|
||||
enable = true
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.10-alpha"
|
||||
version = "1.8.11-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.10-alpha"
|
||||
version = "1.8.11-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
@@ -90,8 +90,9 @@ rustls-pemfile = "1.0"
|
||||
webpki = { package = "rustls-webpki", version = "0.101" }
|
||||
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"] }
|
||||
# Nostr (node discovery + NIP-44 encrypted peer handshake).
|
||||
# nip06: NIP-06 key derivation for the Minibits @minibits.cash profile flow.
|
||||
nostr-sdk = { version = "0.44", features = ["nip04", "nip06", "nip44"] }
|
||||
|
||||
# Backup encryption (DID identity export) + TOTP 2FA encryption
|
||||
argon2 = "0.5.3"
|
||||
|
||||
@@ -269,6 +269,8 @@ impl RpcHandler {
|
||||
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
|
||||
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await,
|
||||
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
|
||||
"wallet.ecash-lnaddress" => self.handle_wallet_ecash_lnaddress().await,
|
||||
"wallet.ecash-lnaddress-claim" => self.handle_wallet_ecash_lnaddress_claim().await,
|
||||
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
|
||||
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
|
||||
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
|
||||
|
||||
@@ -135,7 +135,7 @@ impl RpcHandler {
|
||||
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
|
||||
// *package* name, never an on-disk filename.
|
||||
let tollgate_installed = router
|
||||
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
|
||||
.run("opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
|
||||
test -f /usr/bin/tollgate-wrt 2>/dev/null")
|
||||
.map(|(_, code)| code == 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -421,6 +421,25 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ecash-lnaddress` — the node's Minibits Lightning address
|
||||
/// (`<name>@minibits.cash`, LUD-16), derived from and authenticated by the
|
||||
/// ecash wallet's own seed. Registers the profile on first use; safe to call
|
||||
/// on every open of the Cashu receive screen (it is idempotent).
|
||||
pub(super) async fn handle_wallet_ecash_lnaddress(&self) -> Result<serde_json::Value> {
|
||||
crate::wallet::minibits::lnaddress(&self.config.data_dir).await
|
||||
}
|
||||
|
||||
/// `wallet.ecash-lnaddress-claim` — redeem any Lightning payments that
|
||||
/// arrived on the node's Minibits address as ecash. Returns the sats swept in
|
||||
/// (0 when nothing was waiting), so the UI can refresh its balance.
|
||||
pub(super) async fn handle_wallet_ecash_lnaddress_claim(&self) -> Result<serde_json::Value> {
|
||||
let outcome = crate::wallet::minibits::claim_and_redeem(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
"claimed_count": outcome.claimed_count,
|
||||
"received_sats": outcome.received_sats,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
|
||||
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
//! The Minibits `@minibits.cash` Lightning address (LUD-16) for the node's
|
||||
//! ecash wallet.
|
||||
//!
|
||||
//! ## Why this exists next to the mint client
|
||||
//!
|
||||
//! The ecash wallet already talks to `mint.minibits.cash` as a plain Cashu
|
||||
//! mint (`wallet::mint_client`): mint/melt quotes, swap, receive. That gives the
|
||||
//! node ecash *from* Minibits, but not a *name* at Minibits. A human-readable
|
||||
//! Lightning address like `braveharbor42@minibits.cash` is a separate service —
|
||||
//! the Minibits profile API at `api.minibits.cash/v3` — and it is what lets any
|
||||
//! Lightning wallet pay this node by typing an address, with the payment landing
|
||||
//! as ecash.
|
||||
//!
|
||||
//! ## Identity: the ecash wallet *is* the Minibits wallet
|
||||
//!
|
||||
//! Minibits ties an address to a wallet by `seedHash`, and authenticates the
|
||||
//! wallet with a NIP-06 Nostr keypair. Both come from the *existing* NUT-13
|
||||
//! ecash phrase (`wallet::nut13`), so there is no second secret to back up:
|
||||
//!
|
||||
//! - `seedHash = sha256(mnemonic.to_seed(""))` — the exact bytes the Minibits
|
||||
//! app hashes, so restoring the same phrase in the Minibits app recovers the
|
||||
//! same address (and vice-versa).
|
||||
//! - Nostr keys via NIP-06 at `m/44'/1237'/0'/0/0`. `nostr_sdk::Keys::from_mnemonic`
|
||||
//! uses that path with an empty BIP-39 passphrase — byte-for-byte the derivation
|
||||
//! the Minibits app (nostr-tools `accountFromSeedWords`) does, verified against
|
||||
//! the crate's own NIP-06 test vector.
|
||||
//!
|
||||
//! ## Flow (all verified against the live v3 API)
|
||||
//!
|
||||
//! 1. `POST /auth/challenge {pubkey}` → `{challenge, createdAt}`.
|
||||
//! 2. Sign a NIP-42 kind-22242 event (`relay` + `challenge` tags, server's
|
||||
//! `createdAt`) with the Nostr key.
|
||||
//! 3. `POST /auth/verify {pubkey, challenge, signature}` → JWT access token.
|
||||
//! 4. `POST /profile {walletId, seedHash}` → the assigned `lud16`/`nip05`.
|
||||
//! Idempotent per pubkey: re-registering returns the existing address.
|
||||
//! 5. `POST /claim {seedHash}` → NIP-04-encrypted Cashu tokens for Lightning
|
||||
//! payments sent to the address; decrypt with the Nostr key + the server's
|
||||
//! Nostr pubkey, then redeem through `ecash::receive_token`.
|
||||
//!
|
||||
//! Only runs on the mainnet ecash network — Minibits is a mainnet service, and a
|
||||
//! testnet node must not register a profile or hit the production API.
|
||||
|
||||
use super::ecash::{self, EcashNetwork};
|
||||
use super::nut13;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use base64::Engine;
|
||||
use nostr_sdk::nips::{nip04, nip06::FromMnemonic};
|
||||
use nostr_sdk::{EventBuilder, Kind, RelayUrl, Tag, TagKind, Timestamp, ToBech32};
|
||||
use rand::seq::SliceRandom;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Minibits profile/LNURL API. Confirmed live: `/v3/auth/challenge`,
|
||||
/// `/v3/profile`, `/v3/claim` (the older `/v2` host no longer serves profiles).
|
||||
const API_BASE: &str = "https://api.minibits.cash/v3";
|
||||
/// The relay named in the NIP-42 auth event. Matches the value the Minibits app
|
||||
/// sends and the relay the service publishes in its NIP-05 record.
|
||||
const RELAY_URL: &str = "wss://relay.minibits.cash";
|
||||
/// NIP-42 client authentication event kind.
|
||||
const AUTH_KIND: u16 = 22242;
|
||||
/// The Minibits service Nostr pubkey that NIP-04-encrypts claimed tokens. Used
|
||||
/// only as a fallback: the authoritative value is read from the address's own
|
||||
/// LUD-16 metadata (`nostrPubkey`) at claim time, so a Minibits key rotation
|
||||
/// does not strand claims.
|
||||
const FALLBACK_SERVER_NOSTR_PUBKEY: &str =
|
||||
"beeb48407a6f087ea8f76dc384a5d88c67ced9bd9fb0cdba90930210df3d92e7";
|
||||
/// Re-authenticate this long before the JWT actually expires, so a claim poll
|
||||
/// never races the expiry boundary.
|
||||
const TOKEN_EXPIRY_SKEP_SECS: i64 = 120;
|
||||
|
||||
const STATE_FILE: &str = "wallet/minibits.json";
|
||||
|
||||
/// Small word lists for the generated address name. Uniqueness comes from the
|
||||
/// numeric suffix plus the retry-on-collision below — the Minibits server rejects
|
||||
/// a name already taken by another wallet and we simply draw another, so these do
|
||||
/// not need to be exhaustive (the Minibits app ships lists hundreds long).
|
||||
const ADJECTIVES: &[&str] = &[
|
||||
"calm", "brave", "quiet", "solar", "rapid", "noble", "lunar", "vivid", "amber", "crisp",
|
||||
"eager", "fancy", "gentle", "happy", "jolly", "keen", "lucky", "mellow", "nimble", "proud",
|
||||
"quick", "rusty", "sunny", "tidy", "urban", "vital", "warm", "zesty", "bold", "clever",
|
||||
"daring", "epic", "fiery", "grand", "humble", "iron", "merry", "polar", "sleek", "wild",
|
||||
];
|
||||
const NOUNS: &[&str] = &[
|
||||
"harbor", "meadow", "canyon", "summit", "river", "forest", "island", "comet", "nebula",
|
||||
"orbit", "quartz", "maple", "willow", "falcon", "otter", "badger", "salmon", "crane",
|
||||
"ridge", "creek", "glade", "grove", "prairie", "delta", "cobalt", "onyx", "topaz", "ember",
|
||||
"anchor", "lantern", "beacon", "cabin", "drift", "signal", "thunder", "zephyr", "marble",
|
||||
"pebble", "sequoia", "tundra",
|
||||
];
|
||||
|
||||
/// Persistent state for the node's Minibits address.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MinibitsState {
|
||||
/// The chosen wallet name (the `name` in `name@minibits.cash`).
|
||||
pub wallet_id: String,
|
||||
/// The full LUD-16 Lightning address, e.g. `braveharbor42@minibits.cash`.
|
||||
pub lud16: String,
|
||||
/// NIP-05 address (Minibits sets this equal to `lud16`).
|
||||
pub nip05: String,
|
||||
/// This node's NIP-06 Nostr pubkey (hex) the profile is bound to.
|
||||
pub nostr_pubkey: String,
|
||||
/// `sha256(seed)` — the wallet identifier Minibits keys claims on.
|
||||
pub seed_hash: String,
|
||||
/// Cached JWT access token.
|
||||
#[serde(default)]
|
||||
pub access_token: String,
|
||||
/// Access-token expiry (unix seconds); 0 when unknown/expired.
|
||||
#[serde(default)]
|
||||
pub access_expires: i64,
|
||||
/// Server Nostr pubkey used to decrypt claims, discovered from LUD-16.
|
||||
#[serde(default)]
|
||||
pub server_nostur_pubkey: String,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// A fresh Nostr keypair + seedHash derived from the node's ecash phrase.
|
||||
struct MinibitsIdentity {
|
||||
keys: nostr_sdk::Keys,
|
||||
seed_hash: String,
|
||||
}
|
||||
|
||||
/// Derive the Minibits identity (NIP-06 Nostr keys + seedHash) from the node's
|
||||
/// ecash mnemonic. Both are deterministic, so the address and claims are
|
||||
/// recoverable from the same 24 words the ecash already lives on.
|
||||
fn derive_identity(phrase: &str, seed: &[u8; 64]) -> Result<MinibitsIdentity> {
|
||||
let keys = nostr_sdk::Keys::from_mnemonic(phrase, None::<&str>)
|
||||
.map_err(|e| anyhow!("NIP-06 derivation failed: {e}"))?;
|
||||
let seed_hash = hex::encode(Sha256::digest(seed));
|
||||
Ok(MinibitsIdentity { keys, seed_hash })
|
||||
}
|
||||
|
||||
/// A Minibits profile record — the fields we read off every profile response.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProfileRecord {
|
||||
#[serde(rename = "walletId")]
|
||||
wallet_id: String,
|
||||
#[serde(default)]
|
||||
nip05: String,
|
||||
#[serde(default)]
|
||||
lud16: Option<String>,
|
||||
#[serde(default)]
|
||||
pubkey: String,
|
||||
}
|
||||
|
||||
/// Turn a non-2xx Minibits response into a readable error, surfacing the
|
||||
/// server's `error.name`/`error.message` when present.
|
||||
fn minibits_error(status: reqwest::StatusCode, body: &str) -> anyhow::Error {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(body) {
|
||||
if let Some(err) = v.get("error") {
|
||||
let name = err.get("name").and_then(|n| n.as_str()).unwrap_or("ERROR");
|
||||
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("");
|
||||
return anyhow!("Minibits API error {status}: {name} {msg}");
|
||||
}
|
||||
}
|
||||
anyhow!(
|
||||
"Minibits API error {status}: {}",
|
||||
&body[..body.len().min(180)]
|
||||
)
|
||||
}
|
||||
|
||||
fn state_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
data_dir.join(STATE_FILE)
|
||||
}
|
||||
|
||||
async fn load_state(data_dir: &Path) -> Result<Option<MinibitsState>> {
|
||||
let path = state_path(data_dir);
|
||||
match fs::read_to_string(&path).await {
|
||||
Ok(s) => {
|
||||
let st: MinibitsState = serde_json::from_str(&s)
|
||||
.with_context(|| format!("Failed to parse {}", path.display()))?;
|
||||
if st.wallet_id.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(st))
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the state file 0600 — it holds a bearer JWT. Same sensitivity class as
|
||||
/// the ecash files it sits beside, so it gets the same owner-only mode.
|
||||
async fn save_state(data_dir: &Path, state: &MinibitsState) -> Result<()> {
|
||||
let path = state_path(data_dir);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.context("Failed to create the wallet directory")?;
|
||||
}
|
||||
let content = serde_json::to_string_pretty(state)
|
||||
.context("Failed to serialize the Minibits profile")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.with_context(|| format!("Failed to write {}", path.display()))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.await
|
||||
.with_context(|| format!("Failed to chmod 0600 {}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the `exp` claim (unix seconds) from a JWT without verifying it — the
|
||||
/// token comes straight from Minibits over TLS; we only use the expiry to decide
|
||||
/// when to refresh.
|
||||
fn jwt_expiry(token: &str) -> Option<i64> {
|
||||
let payload = token.split('.').nth(1)?;
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.ok()?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
v.get("exp")?.as_i64()
|
||||
}
|
||||
|
||||
/// Run the NIP-42 challenge/verify dance and return the access token plus its
|
||||
/// expiry. Idempotent and cheap enough to redo whenever the cached token lapses.
|
||||
async fn authenticate(
|
||||
client: &reqwest::Client,
|
||||
keys: &nostr_sdk::Keys,
|
||||
) -> Result<(String, i64)> {
|
||||
let ch: serde_json::Value = client
|
||||
.post(format!("{API_BASE}/auth/challenge"))
|
||||
.json(&serde_json::json!({ "pubkey": keys.public_key().to_hex() }))
|
||||
.send()
|
||||
.await
|
||||
.context("Minibits auth challenge request failed")?
|
||||
.error_for_status()
|
||||
.context("Minibits auth challenge rejected")?
|
||||
.json()
|
||||
.await
|
||||
.context("Minibits auth challenge was not JSON")?;
|
||||
|
||||
let challenge = ch["challenge"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("Minibits challenge response missing 'challenge'"))?
|
||||
.to_string();
|
||||
let created_at = ch["createdAt"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow!("Minibits challenge response missing 'createdAt'"))?;
|
||||
|
||||
// Sign a NIP-42 auth event, stamping the server's own createdAt so the
|
||||
// signature lines up with the challenge it was issued for.
|
||||
let unsigned = EventBuilder::new(Kind::from(AUTH_KIND), "")
|
||||
.tag(Tag::relay(
|
||||
RelayUrl::parse(RELAY_URL).context("Invalid Minibits relay URL")?,
|
||||
))
|
||||
.tag(Tag::custom(
|
||||
TagKind::custom("challenge"),
|
||||
vec![challenge.clone()],
|
||||
))
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.build(keys.public_key());
|
||||
let signed = unsigned
|
||||
.sign_with_keys(keys)
|
||||
.map_err(|e| anyhow!("Failed to sign the Minibits auth challenge: {e}"))?;
|
||||
|
||||
let tok: serde_json::Value = client
|
||||
.post(format!("{API_BASE}/auth/verify"))
|
||||
.json(&serde_json::json!({
|
||||
"pubkey": keys.public_key().to_hex(),
|
||||
"challenge": challenge,
|
||||
"signature": hex::encode(signed.sig.serialize()),
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.context("Minibits auth verify request failed")?
|
||||
.error_for_status()
|
||||
.context("Minibits auth verify rejected (bad challenge signature)")?
|
||||
.json()
|
||||
.await
|
||||
.context("Minibits auth verify was not JSON")?;
|
||||
|
||||
let access = tok["accessToken"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("Minibits verify response missing 'accessToken'"))?
|
||||
.to_string();
|
||||
let expires = jwt_expiry(&access).unwrap_or_else(|| {
|
||||
chrono::Utc::now().timestamp() + 3600 // conservative fallback
|
||||
});
|
||||
Ok((access, expires))
|
||||
}
|
||||
|
||||
/// True when the cached access token is missing or about to lapse.
|
||||
fn token_is_stale(state: &MinibitsState) -> bool {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
state.access_token.is_empty() || now + TOKEN_EXPIRY_SKEP_SECS >= state.access_expires
|
||||
}
|
||||
|
||||
/// Ensure we hold a valid access token, re-authenticating as needed and folding
|
||||
/// the fresh token back into `state` (which the caller persists).
|
||||
async fn ensure_token(
|
||||
client: &reqwest::Client,
|
||||
state: &mut MinibitsState,
|
||||
keys: &nostr_sdk::Keys,
|
||||
) -> Result<()> {
|
||||
if token_is_stale(state) {
|
||||
let (access, expires) = authenticate(client, keys).await?;
|
||||
state.access_token = access;
|
||||
state.access_expires = expires;
|
||||
debug!("Minibits: authenticated (token valid to {})", expires);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draw a fresh readable wallet name, Minibits-style: adjective + noun + number.
|
||||
fn generate_wallet_id() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
let adj = ADJECTIVES.choose(&mut rng).copied().unwrap_or("quiet");
|
||||
let noun = NOUNS.choose(&mut rng).copied().unwrap_or("harbor");
|
||||
let num = rand::Rng::gen_range(&mut rng, 1..=999);
|
||||
format!("{adj}{noun}{num}")
|
||||
}
|
||||
|
||||
/// Register the profile, returning the assigned address. Retries with a new name
|
||||
/// a handful of times if the generated name is already taken by another wallet.
|
||||
async fn register_profile(
|
||||
client: &reqwest::Client,
|
||||
access: &str,
|
||||
seed_hash: &str,
|
||||
) -> Result<ProfileRecord> {
|
||||
let mut last_err = None;
|
||||
for attempt in 0..6 {
|
||||
let wallet_id = generate_wallet_id();
|
||||
let resp = client
|
||||
.post(format!("{API_BASE}/profile"))
|
||||
.bearer_auth(access)
|
||||
.json(&serde_json::json!({ "walletId": wallet_id, "seedHash": seed_hash }))
|
||||
.send()
|
||||
.await
|
||||
.context("Minibits profile registration request failed")?;
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.context("Minibits profile response body read failed")?;
|
||||
if status.is_success() {
|
||||
let rec: ProfileRecord = serde_json::from_str(&body)
|
||||
.context("Minibits profile response was not the expected shape")?;
|
||||
return Ok(rec);
|
||||
}
|
||||
// Name collision → draw another. Anything else is fatal.
|
||||
let is_taken = body.contains("ALREADY_EXISTS") || body.contains("already");
|
||||
if is_taken {
|
||||
warn!("Minibits name '{wallet_id}' taken, retrying (attempt {attempt})");
|
||||
last_err = Some(minibits_error(status, &body));
|
||||
continue;
|
||||
}
|
||||
return Err(minibits_error(status, &body));
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow!("Could not register a free Minibits name")))
|
||||
}
|
||||
|
||||
/// Fetch the LUD-16 metadata for our own address and read the service's
|
||||
/// `nostrPubkey` — the key that NIP-04-encrypts claimed tokens.
|
||||
async fn discover_server_nostr_pubkey(
|
||||
client: &reqwest::Client,
|
||||
lud16: &str,
|
||||
) -> Result<String> {
|
||||
let (name, domain) = lud16
|
||||
.split_once('@')
|
||||
.ok_or_else(|| anyhow!("Malformed Minibits address '{lud16}'"))?;
|
||||
let url = format!("https://{domain}/.well-known/lnurlp/{name}");
|
||||
let md: serde_json::Value = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("Minibits LUD-16 metadata request failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Minibits LUD-16 metadata was not JSON")?;
|
||||
md.get("nostrPubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow!("Minibits LUD-16 metadata missing 'nostrPubkey'"))
|
||||
}
|
||||
|
||||
/// Load the ecash phrase, or establish it from the node master seed when this
|
||||
/// node has not materialised one yet — but never fail an address request just
|
||||
/// because a phrase is not on disk; report that clearly instead.
|
||||
async fn ecash_phrase(data_dir: &Path) -> Result<(String, [u8; 64])> {
|
||||
let seed = nut13::load_seed(data_dir)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("The ecash wallet has no seed yet — restore or reveal it first"))?;
|
||||
Ok((seed.phrase(), seed.seed_bytes()))
|
||||
}
|
||||
|
||||
/// Get (registering on first use) the node's Minibits Lightning address.
|
||||
///
|
||||
/// On mainnet this registers a profile with the Minibits server the first time
|
||||
/// and caches it in `wallet/minibits.json`; later calls return the cached address
|
||||
/// and refresh the access token as needed. Registration is idempotent per pubkey,
|
||||
/// so a node that restores the same ecash phrase recovers the same address.
|
||||
pub async fn lnaddress(data_dir: &Path) -> Result<serde_json::Value> {
|
||||
let network = ecash::load_network(data_dir).await;
|
||||
if network == EcashNetwork::Testnet {
|
||||
return Err(anyhow!(
|
||||
"Minibits Lightning addresses are mainnet-only — switch the ecash network to mainnet to set one up"
|
||||
));
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.build()
|
||||
.context("Failed to build the Minibits HTTP client")?;
|
||||
|
||||
let (phrase, seed) = ecash_phrase(data_dir).await?;
|
||||
let identity = derive_identity(&phrase, &seed)?;
|
||||
|
||||
let mut state = match load_state(data_dir).await? {
|
||||
Some(st) => st,
|
||||
None => {
|
||||
info!("Minibits: no profile yet, registering a new @minibits.cash address");
|
||||
let (access, expires) = authenticate(&client, &identity.keys).await?;
|
||||
let rec = register_profile(&client, &access, &identity.seed_hash).await?;
|
||||
MinibitsState {
|
||||
wallet_id: rec.wallet_id.clone(),
|
||||
lud16: rec
|
||||
.lud16
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}@minibits.cash", rec.wallet_id)),
|
||||
nip05: rec.nip05.clone(),
|
||||
nostr_pubkey: rec.pubkey.clone(),
|
||||
seed_hash: identity.seed_hash.clone(),
|
||||
access_token: access,
|
||||
access_expires: expires,
|
||||
server_nostur_pubkey: String::new(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ensure_token(&client, &mut state, &identity.keys).await?;
|
||||
save_state(data_dir, &state).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"address": state.lud16,
|
||||
"nip05": state.nip05,
|
||||
"wallet_id": state.wallet_id,
|
||||
"nostr_pubkey": state.nostr_pubkey,
|
||||
"npub": identity.keys.public_key().to_bech32().unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Outcome of a claim poll.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ClaimOutcome {
|
||||
pub claimed_count: usize,
|
||||
pub received_sats: u64,
|
||||
}
|
||||
|
||||
/// Poll Minibits for Lightning payments sent to the node's address and redeem
|
||||
/// each into the ecash wallet.
|
||||
///
|
||||
/// Each claim is a NUT-00 token NIP-04-encrypted by the Minibits service to this
|
||||
/// wallet's Nostr key; decrypting it needs the service pubkey (discovered from
|
||||
/// our LUD-16 metadata, falling back to the known constant). A token that fails
|
||||
/// to decrypt or redeem is logged and skipped rather than aborting the batch —
|
||||
/// but note a claim is consumed server-side the moment it is fetched, so any
|
||||
/// failure here is surfaced loudly since those coins cannot be re-fetched.
|
||||
pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
|
||||
let network = ecash::load_network(data_dir).await;
|
||||
if network == EcashNetwork::Testnet {
|
||||
return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 });
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build the Minibits HTTP client")?;
|
||||
|
||||
let (phrase, seed) = ecash_phrase(data_dir).await?;
|
||||
let identity = derive_identity(&phrase, &seed)?;
|
||||
|
||||
let mut state = match load_state(data_dir).await? {
|
||||
Some(st) => st,
|
||||
// Nothing is addressable until a profile exists; registering lazily here
|
||||
// means a payment could not have arrived, so claiming is a no-op.
|
||||
None => return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 }),
|
||||
};
|
||||
ensure_token(&client, &mut state, &identity.keys).await?;
|
||||
|
||||
// Discover (and cache) the service key that wraps claimed tokens.
|
||||
if state.server_nostur_pubkey.is_empty() {
|
||||
match discover_server_nostr_pubkey(&client, &state.lud16).await {
|
||||
Ok(pk) => state.server_nostur_pubkey = pk,
|
||||
Err(e) => {
|
||||
warn!("Minibits: could not read service Nostr pubkey ({e}); using fallback");
|
||||
state.server_nostur_pubkey = FALLBACK_SERVER_NOSTR_PUBKEY.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
save_state(data_dir, &state).await?;
|
||||
|
||||
let server_pk = nostr_sdk::PublicKey::from_hex(&state.server_nostur_pubkey)
|
||||
.context("Service Nostr pubkey was not valid hex")?;
|
||||
|
||||
let resp = client
|
||||
.post(format!("{API_BASE}/claim"))
|
||||
.bearer_auth(&state.access_token)
|
||||
.json(&serde_json::json!({ "seedHash": state.seed_hash }))
|
||||
.send()
|
||||
.await
|
||||
.context("Minibits claim request failed")?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.context("Minibits claim body read failed")?;
|
||||
if !status.is_success() {
|
||||
return Err(minibits_error(status, &body));
|
||||
}
|
||||
let claims: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&body).context("Minibits claim response was not a JSON array")?;
|
||||
if claims.is_empty() {
|
||||
return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 });
|
||||
}
|
||||
|
||||
let mut redeemed = 0usize;
|
||||
let mut sats = 0u64;
|
||||
for claim in &claims {
|
||||
let enc = match claim.get("token").and_then(|t| t.as_str()) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
warn!("Minibits claim had no 'token' field; skipping");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let decoded = match nip04::decrypt(identity.keys.secret_key(), &server_pk, enc) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
// Claim already consumed server-side — this is a real loss.
|
||||
warn!("Minibits claim could not be decrypted ({e}); coins may be unrecoverable");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match ecash::receive_token(data_dir, &decoded).await {
|
||||
Ok(got) => {
|
||||
redeemed += 1;
|
||||
sats += got;
|
||||
info!("Minibits: redeemed a claimed payment ({got} sats)");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Minibits claim decrypted but failed to redeem ({e}); coins may be unrecoverable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ClaimOutcome { claimed_count: redeemed, received_sats: sats })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn derived_nostr_key_matches_the_nip06_vector() {
|
||||
// The Minibits app derives its Nostr key at m/44'/1237'/0'/0/0 with an
|
||||
// empty BIP-39 passphrase (nostr-tools accountFromSeedWords). Lock to the
|
||||
// crate's own NIP-06 secret-key vector so a nostr-sdk bump cannot silently
|
||||
// move our derivation and orphan the registered address.
|
||||
let phrase = "leader monkey parrot ring guide accident before fence cannon height naive bean";
|
||||
let keys = nostr_sdk::Keys::from_mnemonic(phrase, None::<&str>).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(keys.secret_key().as_secret_bytes()),
|
||||
"7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_hash_is_sha256_of_the_bip39_seed() {
|
||||
// Minibits hashes the *seed*, not the phrase — a regression here would
|
||||
// make the node register a profile that the Minibits app cannot recover.
|
||||
let phrase = "leader monkey parrot ring guide accident before fence cannon height naive bean";
|
||||
let m: bip39::Mnemonic = phrase.parse().unwrap();
|
||||
let seed = m.to_seed("");
|
||||
let want = hex::encode(Sha256::digest(seed));
|
||||
let id = derive_identity(phrase, &seed).unwrap();
|
||||
assert_eq!(id.seed_hash, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_names_are_readable_and_bounded() {
|
||||
for _ in 0..200 {
|
||||
let n = generate_wallet_id();
|
||||
assert!(!n.is_empty());
|
||||
assert!(n.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
|
||||
// ends in at least one digit (the 1..=999 suffix)
|
||||
assert!(n.chars().last().map(|c| c.is_ascii_digit()).unwrap_or(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_token_when_missing_or_near_expiry() {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
assert!(token_is_stale(&MinibitsState::default()));
|
||||
assert!(token_is_stale(&MinibitsState {
|
||||
access_token: "x".into(),
|
||||
access_expires: now + 10, // inside the skew window
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(!token_is_stale(&MinibitsState {
|
||||
access_token: "x".into(),
|
||||
access_expires: now + 3600,
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
|
||||
/// Live end-to-end against the production Minibits API: register a throwaway
|
||||
/// profile with a random ecash phrase and claim (nothing pending → 0). Run
|
||||
/// with `cargo test -- --ignored --nocapture`. It creates one disposable
|
||||
/// profile on the public service and holds no funds.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn registers_and_claims_against_live_minibits() {
|
||||
let dir = std::env::temp_dir().join(format!("mbtest-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// A node has an ecash phrase before it has a Minibits profile; stand up
|
||||
// a fresh random one so registration derives a real identity.
|
||||
nut13::establish_independent(&dir).await.unwrap();
|
||||
|
||||
let info = lnaddress(&dir).await.expect("live registration failed");
|
||||
let addr = info["address"].as_str().unwrap().to_string();
|
||||
assert!(addr.ends_with("@minibits.cash"), "bad address {addr}");
|
||||
println!("registered live address: {addr}");
|
||||
|
||||
// A second call must return the same cached address, not register again.
|
||||
let again = lnaddress(&dir).await.unwrap();
|
||||
assert_eq!(again["address"].as_str().unwrap(), addr.as_str());
|
||||
|
||||
let out = claim_and_redeem(&dir).await.expect("live claim poll failed");
|
||||
println!("claim poll: {out:?}");
|
||||
assert_eq!(out.claimed_count, 0);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod bdhke;
|
||||
pub mod cashu;
|
||||
pub mod ecash;
|
||||
pub mod fedimint_client;
|
||||
pub mod minibits;
|
||||
pub mod mint_client;
|
||||
pub mod nut13;
|
||||
pub mod profits;
|
||||
|
||||
@@ -137,6 +137,18 @@ impl EcashSeed {
|
||||
self.mnemonic.words().map(|w| w.to_string()).collect()
|
||||
}
|
||||
|
||||
/// The phrase as a single string — the input to NUT-13 *and* to the NIP-06
|
||||
/// Nostr derivation the Minibits profile flow needs (`crate::wallet::minibits`).
|
||||
pub fn phrase(&self) -> String {
|
||||
self.mnemonic.to_string()
|
||||
}
|
||||
|
||||
/// The 64-byte BIP-39 seed. Same bytes Minibits hashes with SHA-256 to get
|
||||
/// its `seedHash`, so the two wallets agree on wallet identity.
|
||||
pub fn seed_bytes(&self) -> [u8; 64] {
|
||||
self.seed
|
||||
}
|
||||
|
||||
pub fn source(&self) -> SeedSource {
|
||||
self.source
|
||||
}
|
||||
|
||||
+19
-15
@@ -15,25 +15,32 @@ pub enum PkgManager {
|
||||
impl Router {
|
||||
/// Detect which package manager is available.
|
||||
///
|
||||
/// - If `/usr/bin/opkg` exists → `PkgManager::Opkg` (nothing to do).
|
||||
/// - If `/usr/bin/apk` exists → run `apk update` (switching repos to HTTP
|
||||
/// Looks up `opkg`/`apk` via the router's `$PATH` (`command -v`) rather
|
||||
/// than a hardcoded `/usr/bin/<tool>` — official OpenWrt images don't all
|
||||
/// symlink `/bin` into `/usr/bin` (e.g. the `glinet_gl-mt3000` 24.10.2
|
||||
/// build keeps them as separate real directories with `opkg` living in
|
||||
/// `/bin`), so a fixed absolute path silently misses a perfectly normal
|
||||
/// install and reports "no package management" (archy-x250-pa3, 2026-09-05).
|
||||
///
|
||||
/// - If `opkg` is on PATH → `PkgManager::Opkg` (nothing to do).
|
||||
/// - If `apk` is on PATH → run `apk update` (switching repos to HTTP
|
||||
/// first to work around missing CA bundle on fresh images), then try
|
||||
/// `apk add opkg`. If opkg is in the repos → `Opkg`. If not (OpenWrt
|
||||
/// 25.x) → `ApkNative`.
|
||||
/// - Neither found → error.
|
||||
pub fn opkg_check(&self) -> Result<PkgManager> {
|
||||
let (_, code) = self.run("test -x /usr/bin/opkg")?;
|
||||
let (_, code) = self.run("command -v opkg >/dev/null 2>&1")?;
|
||||
if code == 0 {
|
||||
return Ok(PkgManager::Opkg);
|
||||
}
|
||||
|
||||
let (_, apk_code) = self.run("test -x /usr/bin/apk")?;
|
||||
let (_, apk_code) = self.run("command -v apk >/dev/null 2>&1")?;
|
||||
if apk_code == 0 {
|
||||
info!("[{}] opkg not found — using apk (OpenWrt 25.x+)", self.host);
|
||||
// Fresh images ship without a CA bundle; switch repos to HTTP so
|
||||
// apk's wget can reach the package index without TLS verification.
|
||||
self.run_ok("sed -i 's|https://|http://|g' /etc/apk/repositories 2>/dev/null || true")?;
|
||||
let (update_out, update_code) = self.run("/usr/bin/apk update 2>&1")?;
|
||||
let (update_out, update_code) = self.run("apk update 2>&1")?;
|
||||
if update_code != 0 {
|
||||
anyhow::bail!(
|
||||
"apk update failed (exit {}) — router may have no internet access. \
|
||||
@@ -43,7 +50,7 @@ impl Router {
|
||||
);
|
||||
}
|
||||
// Try to install opkg (only available on some 25.x builds).
|
||||
let (add_out, add_code) = self.run("/usr/bin/apk add opkg 2>&1")?;
|
||||
let (add_out, add_code) = self.run("apk add opkg 2>&1")?;
|
||||
if add_code == 0 {
|
||||
return Ok(PkgManager::Opkg);
|
||||
}
|
||||
@@ -62,7 +69,7 @@ impl Router {
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"opkg not found at /usr/bin/opkg — this router's firmware may not \
|
||||
"Neither opkg nor apk found on this router's $PATH — its firmware may not \
|
||||
support package management (TollGate requires a standard OpenWrt build)"
|
||||
);
|
||||
}
|
||||
@@ -70,31 +77,28 @@ impl Router {
|
||||
/// `opkg update` — refresh package lists.
|
||||
pub fn opkg_update(&self) -> Result<()> {
|
||||
info!("[{}] opkg update", self.host);
|
||||
self.run_ok("/usr/bin/opkg update")?;
|
||||
self.run_ok("opkg update")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install a package, skipping if already installed.
|
||||
pub fn opkg_install(&self, package: &str) -> Result<()> {
|
||||
// Check if already installed to avoid unnecessary network traffic.
|
||||
let (_, code) = self.run(&format!(
|
||||
"/usr/bin/opkg list-installed | grep -q '^{} '",
|
||||
package
|
||||
))?;
|
||||
let (_, code) = self.run(&format!("opkg list-installed | grep -q '^{} '", package))?;
|
||||
if code == 0 {
|
||||
info!("[{}] {} already installed", self.host, package);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("[{}] opkg install {}", self.host, package);
|
||||
self.run_ok(&format!("/usr/bin/opkg install {}", package))?;
|
||||
self.run_ok(&format!("opkg install {}", package))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a package.
|
||||
pub fn opkg_remove(&self, package: &str) -> Result<()> {
|
||||
info!("[{}] opkg remove {}", self.host, package);
|
||||
self.run_ok(&format!("/usr/bin/opkg remove {}", package))?;
|
||||
self.run_ok(&format!("opkg remove {}", package))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -121,7 +125,7 @@ impl Router {
|
||||
}
|
||||
|
||||
info!("[{}] apk add {}", self.host, package);
|
||||
self.run_ok(&format!("/usr/bin/apk add {}", package))?;
|
||||
self.run_ok(&format!("apk add {}", package))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,53 @@ use crate::Router;
|
||||
/// The OpenWrt package name for the TollGate reference implementation.
|
||||
const TOLLGATE_PACKAGE: &str = "tollgate-module-basic-go";
|
||||
|
||||
/// Direct-download fallback URLs by opkg architecture string.
|
||||
/// Pinned upstream release. Was stuck on v0.2.0 (Oct 2025) until 2026-09-05 —
|
||||
/// nine releases behind. v0.5.0's changelog covers exactly the failure modes
|
||||
/// hit live against archy-x250-pa3: a mint with an empty/broken keyset used
|
||||
/// to crash-loop the daemon forever ("graceful degradation when Cashu mints
|
||||
/// fail" in v0.5.0), and the bundled captive-portal build had no CBOR support
|
||||
/// at all, so it could only decode legacy `cashuA` tokens — rejecting the
|
||||
/// `cashuB` (NUT-00 V4) tokens modern wallets like Minibits generate by
|
||||
/// default ("portal improvements" in v0.5.0 include a JS bundle update that
|
||||
/// should carry a current cashu-ts with V4 support). Bump this string to move
|
||||
/// both this crate's URLs and the version baked into the source comments.
|
||||
const TOLLGATE_VERSION: &str = "v0.5.0";
|
||||
|
||||
/// Direct-download fallback URLs by opkg architecture string, for the
|
||||
/// `.ipk` (ar-archive) package format.
|
||||
/// Used when the package is not in any configured feed.
|
||||
/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.2.0
|
||||
fn ipk_url(arch: &str) -> Option<&'static str> {
|
||||
match arch {
|
||||
"mips_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mips_24kc.ipk"),
|
||||
"mipsel_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mipsel_24kc.ipk"),
|
||||
"aarch64_cortex-a53" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a53.ipk"),
|
||||
"aarch64_cortex-a72" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a72.ipk"),
|
||||
"arm_cortex-a7" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/arm_cortex-a7.ipk"),
|
||||
_ => None,
|
||||
}
|
||||
/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.5.0
|
||||
fn ipk_url(arch: &str) -> Option<String> {
|
||||
let name = match arch {
|
||||
"mips_24kc" => "mips_24kc",
|
||||
"mipsel_24kc" => "mipsel_24kc",
|
||||
"aarch64_cortex-a53" => "aarch64_cortex-a53",
|
||||
"aarch64_cortex-a72" => "aarch64_cortex-a72",
|
||||
"arm_cortex-a7" => "arm_cortex-a7",
|
||||
"x86_64" => "x86_64",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/{TOLLGATE_VERSION}/tollgate-wrt_{TOLLGATE_VERSION}_{name}.ipk"
|
||||
))
|
||||
}
|
||||
|
||||
/// Direct-download URLs for the native Alpine-style `.apk` package format —
|
||||
/// only published for a subset of architectures as of v0.5.0. Where
|
||||
/// available this is strictly better than [`ipk_url`] on an apk-native
|
||||
/// (OpenWrt 25.x+) router: `apk add` installs it directly (dependency
|
||||
/// resolution, postinst, uci-defaults all handled by apk itself), instead of
|
||||
/// the manual `ar`/`tar` extraction dance `install_ipk` has to do to unpack
|
||||
/// an `.ipk` on a router with no `opkg`.
|
||||
fn apk_url(arch: &str) -> Option<String> {
|
||||
let name = match arch {
|
||||
"aarch64_cortex-a53" => "aarch64_cortex-a53",
|
||||
"x86_64" => "x86_64",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/{TOLLGATE_VERSION}/tollgate-wrt_{TOLLGATE_VERSION}_{name}.apk"
|
||||
))
|
||||
}
|
||||
|
||||
/// Install tollgate-module-basic-go via opkg (OpenWrt ≤24.x).
|
||||
@@ -34,8 +69,9 @@ pub fn install_tollgate(router: &Router) -> Result<()> {
|
||||
}
|
||||
|
||||
// Package not in any feed — download the .ipk directly.
|
||||
let arch = router
|
||||
.run_ok("/usr/bin/opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'")?;
|
||||
let arch = router.run_ok(
|
||||
"opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'",
|
||||
)?;
|
||||
let arch = arch.trim();
|
||||
|
||||
let url = ipk_url(arch).ok_or_else(|| {
|
||||
@@ -88,7 +124,7 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
|
||||
". /etc/openwrt_release 2>/dev/null \
|
||||
&& a=\"${DISTRIB_ARCH:-${OPENWRT_ARCH:-}}\" \
|
||||
&& [ -n \"$a\" ] && echo \"$a\" \
|
||||
|| /usr/bin/apk --print-arch 2>/dev/null \
|
||||
|| apk --print-arch 2>/dev/null \
|
||||
|| uname -m",
|
||||
)?;
|
||||
// Normalise: uname -m returns bare "mipsel"/"mips"; map to 24kc variant
|
||||
@@ -103,6 +139,38 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
|
||||
anyhow::bail!("Could not determine router architecture");
|
||||
}
|
||||
|
||||
// Prefer a native .apk when the release publishes one for this arch —
|
||||
// `apk add` handles the install itself (deps, postinst, uci-defaults),
|
||||
// skipping the manual ar/tar extraction the .ipk fallback below needs.
|
||||
if let Some(url) = apk_url(arch) {
|
||||
info!(
|
||||
"[{}] Downloading native TollGate .apk for {} from GitHub releases",
|
||||
router.host, arch
|
||||
);
|
||||
let (dl_out, dl_code) = router.run(&format!(
|
||||
"wget --no-check-certificate -O /tmp/tollgate.apk '{}' 2>&1",
|
||||
url
|
||||
))?;
|
||||
if dl_code != 0 {
|
||||
anyhow::bail!("TollGate .apk download failed: {}", dl_out.trim());
|
||||
}
|
||||
let (size_out, _) = router.run("wc -c < /tmp/tollgate.apk 2>/dev/null")?;
|
||||
let size: u64 = size_out.trim().parse().unwrap_or(0);
|
||||
if size < 50_000 {
|
||||
anyhow::bail!(
|
||||
"Downloaded TollGate .apk is only {}B — wget likely captured an error page. \
|
||||
Check router internet access and that the release URL is reachable.",
|
||||
size
|
||||
);
|
||||
}
|
||||
let (add_out, add_code) = router.run("apk add --allow-untrusted /tmp/tollgate.apk 2>&1")?;
|
||||
router.run_ok("rm -f /tmp/tollgate.apk")?;
|
||||
if add_code != 0 {
|
||||
anyhow::bail!("TollGate .apk install failed: {}", add_out.trim());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let url = ipk_url(arch).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No pre-built TollGate package for architecture '{}'. \
|
||||
|
||||
@@ -10,6 +10,7 @@ disagree, the code wins and the doc is a bug.
|
||||
- [Talking to your node](COMMANDS.md) — the conversational command surface
|
||||
- [Seed Verification](SEED-VERIFICATION.md) — independently verify your 24-word backup
|
||||
- [Troubleshooting](troubleshooting.md) — common problems and how to resolve them
|
||||
- [OpenWrt Gateway Setup](openwrt-gateway-setup.md) — pairing an OpenWrt router and provisioning TollGate pay-as-you-go WiFi
|
||||
- [Gamepad / Controller Navigation](GAMEPAD-NAV.md) — driving the UI from a controller
|
||||
- [Pine voice commands](pine-voice-commands.md) — the voice-satellite phrase surface
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
# OpenWrt Gateway Setup
|
||||
|
||||
How to connect an OpenWrt router to an Archipelago node and, optionally, turn
|
||||
it into a pay-as-you-go WiFi gateway with **TollGate**. Written for a node
|
||||
operator following the UI; a developer-facing RPC/architecture reference is
|
||||
at the bottom.
|
||||
|
||||
This feature manages a **separate physical (or virtual) router** running
|
||||
OpenWrt over SSH/UCI — it is not a containerized app. Archipelago itself does
|
||||
not flash or install OpenWrt; you bring a router that already runs it.
|
||||
|
||||
## What you get
|
||||
|
||||
- **Status dashboard**: hostname, uptime, firmware release, WiFi interfaces,
|
||||
WAN state — polled live from the router.
|
||||
- **WAN/WISP wizard**: point the router's radio at an upstream WiFi network
|
||||
(turns it into a wireless bridge/repeater) with DHCP + NAT configured for
|
||||
you.
|
||||
- **TollGate provisioning** (optional): installs the
|
||||
[TollGate](https://tollgate.me) captive-portal package
|
||||
(`tollgate-module-basic-go`) and stands up an `archipelago` SSID that
|
||||
sells timed internet access for sats, settled against this node's local
|
||||
Cashu mint.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **A router already flashed with OpenWrt.** Check the
|
||||
[OpenWrt Table of Hardware](https://openwrt.org/toh/start) for your model
|
||||
and follow OpenWrt's own install/flashing instructions — that part is
|
||||
outside Archipelago's scope. See below for a worked example (GL.iNet
|
||||
AX3000).
|
||||
2. **SSH reachable.** Fresh OpenWrt images enable `dropbear` (SSH) on LAN by
|
||||
default, listening as `root` with no password (or the password you set
|
||||
during OpenWrt's first-boot wizard at `192.168.1.1`). Archipelago
|
||||
connects with `ssh2` over a password (key-based auth is supported at the
|
||||
library level but the UI only offers password so far).
|
||||
3. **Same LAN as the Archipelago node**, at least for setup — plug the
|
||||
router's LAN port into the same switch/network segment the node is on.
|
||||
4. **For TollGate**: a running Cashu mint app (`nutshell`/`cashu-mint`) on
|
||||
this node — provisioning defaults `mint_url` to
|
||||
`http://<node-ip>:3338` and TollGate customers must be able to reach that
|
||||
URL from outside the node's loopback.
|
||||
|
||||
## Worked example: flashing a GL.iNet AX3000 to stock OpenWrt
|
||||
|
||||
GL.iNet's "AX3000" travel router is the **Beryl AX (GL-MT3000)** —
|
||||
MediaTek MT7981B (Cortex-A53), OpenWrt target `mediatek/filogic`. It ships
|
||||
running a GL.iNet fork of OpenWrt with its own web UI and LuCI already
|
||||
enabled, but the steps below replace that with stock/vanilla OpenWrt so it
|
||||
matches the prebuilt TollGate `.ipk` architectures exactly
|
||||
(`aarch64_cortex-a53`).
|
||||
|
||||
1. **Download the sysupgrade image** for the current stable release from
|
||||
`https://downloads.openwrt.org/releases/<version>/targets/mediatek/filogic/`
|
||||
— the file you want is
|
||||
`openwrt-<version>-mediatek-filogic-glinet_gl-mt3000-squashfs-sysupgrade.bin`.
|
||||
2. **Verify the checksum** against the `sha256sums` file in that same
|
||||
directory before flashing anything.
|
||||
3. **Flash from the GL.iNet UI**: on the router's default address
|
||||
(`192.168.8.1`), go to **More Settings → Upgrade → Local Upgrade**, or
|
||||
open **Advanced → LuCI** and use **System → Backup / Flash Firmware →
|
||||
Flash new firmware image**.
|
||||
4. Upload the `.bin` file. **Uncheck "Keep Settings"** — going from the
|
||||
GL.iNet fork to stock OpenWrt needs a clean reset, not a config carry-over.
|
||||
5. Confirm and wait ~3–5 minutes without power-cycling the router.
|
||||
6. **After it reboots** you're on stock OpenWrt: LAN at `192.168.1.1`, DHCP
|
||||
on, SSH (dropbear) open as `root` with **no password set yet** — set one
|
||||
via LuCI at `192.168.1.1` or `passwd` over SSH before doing anything else.
|
||||
From here, continue with the Prerequisites/Step 2 flow above to connect
|
||||
it to the Archipelago node.
|
||||
|
||||
> The Archipelago UI's Connect form (Step 2) authenticates *with* a
|
||||
> password — it has no flow for setting the initial one on a fresh,
|
||||
> passwordless router. You have to set it out-of-band first. If you're
|
||||
> working from the node's own local kiosk display rather than a normal
|
||||
> desktop browser, there's no visible tab bar/address bar to open a new
|
||||
> tab from — press **Ctrl+T** to open one anyway, navigate to
|
||||
> `192.168.1.1`, and use LuCI's first-boot prompt to set the root
|
||||
> password. Then switch back to the Archipelago tab and Connect with it.
|
||||
|
||||
**If the flash fails / the router doesn't come back**: filogic devices
|
||||
don't use a reset-button recovery. Instead, connect to the router's LAN
|
||||
port and, during boot, press a key within the first ~2 seconds to enter
|
||||
U-Boot; per the OpenWrt wiki, typing `gl` then `httpd` at the U-Boot prompt
|
||||
brings up a recovery web UI at `192.168.1.2` that accepts a firmware image.
|
||||
|
||||
## Step 1: Open the OpenWrt Gateway panel
|
||||
|
||||
1. In the Archipelago UI, go to **Server**.
|
||||
2. Under the network status list, click **OpenWrt Gateway**
|
||||
(`/dashboard/server/openwrt`).
|
||||
|
||||
If no router has been connected before, you'll land on the connect form.
|
||||
|
||||
## Step 2: Connect the router
|
||||
|
||||
You have two options:
|
||||
|
||||
- **Detect**: click **Detect** — this reads the node's own active wired
|
||||
Ethernet interface, derives its subnet, and probes every host on it for
|
||||
`TCP/22` + a valid `/etc/openwrt_release`. If it finds exactly one router
|
||||
it fills in the host automatically; if it finds several you pick from the
|
||||
list. A `/24` scan can take up to ~2 minutes (255 sequential probes at
|
||||
500 ms each on hosts that don't respond).
|
||||
- **Manual**: type the router's LAN IP (commonly `192.168.1.1` on a router
|
||||
freshly bridged in, or whatever address it has on your network) plus the
|
||||
SSH username (default `root`) and password.
|
||||
|
||||
Click **Connect**. On success the panel switches to the status dashboard and
|
||||
the connection (host + credentials) is persisted server-side — you won't
|
||||
need to re-enter them on future visits or from other views (e.g. the Home
|
||||
dashboard's network tile also polls this without prompting again).
|
||||
|
||||
> Credentials are stored in `router_config.json` under the node's data
|
||||
> directory alongside other node config. There's no separate secrets
|
||||
> vault entry for this yet — treat the router's SSH password like any other
|
||||
> node-local config.
|
||||
|
||||
## Step 3: (Optional) Configure WAN/WISP
|
||||
|
||||
Use this to make the OpenWrt router pull its internet connection from an
|
||||
upstream WiFi network instead of a wired uplink — useful for a
|
||||
battery/off-grid TollGate node or extending coverage from an existing
|
||||
network.
|
||||
|
||||
1. From the status dashboard, start the **WAN setup** wizard.
|
||||
2. **Scan** — the router's radio scans for visible networks (a few seconds
|
||||
of SSH round-trips).
|
||||
3. **Select network** — pick the upstream SSID from the list.
|
||||
4. **Password** — enter the upstream network's WiFi password (encryption
|
||||
defaults to `psk2`; leave blank only for open networks).
|
||||
5. **DHCP / NAT** — review the LAN DHCP pool (default `.100`–`.249`) and
|
||||
whether to enable NAT/masquerade on the WAN zone (leave this on unless
|
||||
you have a specific reason not to).
|
||||
6. **Connect** — this writes a `wwan` STA `wifi-iface` + `network` interface
|
||||
over UCI, enables the radio if it was disabled (OpenWrt ships with
|
||||
`radio0.disabled=1` on a fresh flash), and adds `wwan` to the WAN
|
||||
firewall zone.
|
||||
|
||||
The dashboard's WAN panel shows the resulting association state, assigned
|
||||
IP, and whether the router currently has internet reachability.
|
||||
|
||||
## Step 4: (Optional) Install TollGate
|
||||
|
||||
Once connected (and with a local Cashu mint app running), the dashboard
|
||||
shows a **TollGate: not installed** panel with a single **Install TollGate**
|
||||
button — there's no config form at this stage, it installs with defaults.
|
||||
The panel itself warns: *"Router needs internet access to install TollGate
|
||||
— configure WAN above first"* (Step 3), since the router has to reach the
|
||||
internet to download the package.
|
||||
|
||||
1. Click **Install TollGate**. The button relabels to *"Installing… this
|
||||
may take a few minutes"* while it works.
|
||||
2. Under the hood this installs `tollgate-module-basic-go` on the router
|
||||
(via `opkg` on OpenWrt ≤24.x, or a manual `.ipk` extract on 25.x images
|
||||
where `opkg` isn't available), writes `/etc/tollgate/config.json`, and
|
||||
creates the `archipelago` SSID — all with default pricing (10 sats per
|
||||
1-minute step, minimum 1 step, `mint_url` auto-filled to
|
||||
`http://<node-ip>:3338`, enabled).
|
||||
3. On success you'll see *"TollGate provisioned successfully"* and the
|
||||
panel switches to the installed view (Enabled/Disabled badge, current
|
||||
price/step/mint).
|
||||
|
||||
### Configuring price, step size, or mint (after install)
|
||||
|
||||
The installed-state panel has an **Edit** button — this is the only place
|
||||
you set price/step/mint, and it only appears once TollGate is already
|
||||
installed:
|
||||
|
||||
1. Click **Edit**.
|
||||
2. Set **Price** (sats), **Step size** (minutes — billed as `step_size_ms`
|
||||
under the hood), **Minimum steps** a customer must buy at once, **Mint
|
||||
URL** (leave as the auto-filled node URL unless pointing at an external
|
||||
mint), and the **Enable TollGate** toggle.
|
||||
3. Click **Save**. Changes are pushed to `/etc/tollgate/config.json` and the
|
||||
daemon is restarted to pick them up — it does not hot-reload.
|
||||
|
||||
Anyone who joins the `archipelago` SSID sees TollGate's captive portal and
|
||||
pays sats (via the configured Cashu mint) for timed access.
|
||||
|
||||
## Verifying a successful install
|
||||
|
||||
A clean install (flash → Connect → WAN/WISP → Install TollGate, all through
|
||||
the UI as above) ends in this state — worth checking if you want to confirm
|
||||
everything actually landed correctly rather than trusting the UI's success
|
||||
toast alone:
|
||||
|
||||
- `tollgate-wrt` is running (`/etc/init.d/tollgate-wrt status` → `running`).
|
||||
- nodogsplash's **rendered** config — not just the UCI source — has
|
||||
`GatewayInterface br-tollgate`. Check the actual file the daemon was
|
||||
started with (typically `/tmp/etc/nodogsplash_main.conf`), since that's
|
||||
what's actually enforced, not `uci show nodogsplash`. This matters because
|
||||
provisioning must stop nodogsplash and reconfigure it to gate the
|
||||
`br-tollgate` bridge *before* starting it — installing the package by hand
|
||||
(bypassing the UI/RPC flow) leaves nodogsplash on its default
|
||||
`br-lan`-gating behavior instead, which locks out the router's own
|
||||
admin/SSH access. If you ever see a router become unreachable right after
|
||||
a TollGate install, this is the first thing to check.
|
||||
- The router's own LAN (the interface you manage it over — SSH, ping) is
|
||||
still reachable and untouched by the portal.
|
||||
- TollGate's own log (`logread | grep tollgate-wrt`) shows successful mint
|
||||
probes for each configured mint.
|
||||
|
||||
A `dev build detected (branch=unknown), injecting test mint:
|
||||
https://nofee.testnut.cashu.space` line in that log means the installed
|
||||
build considers itself a dev build and silently adds a test mint alongside
|
||||
your configured one(s) — check the Edit panel's Mint URL afterward if you
|
||||
don't want that test mint accepted.
|
||||
|
||||
### A note on network topology during setup
|
||||
|
||||
If the Archipelago node reaches the router over the same wired interface the
|
||||
router uses as its LAN, expect the router to become the node's default
|
||||
route on that interface once it has its own working WAN/WISP uplink — this
|
||||
is normal and, once WAN is actually configured with internet access, works
|
||||
fine end-to-end (the node's traffic routes out through the router's
|
||||
uplink). It's only a problem *before* WAN is configured: a freshly flashed
|
||||
or freshly factory-reset router has no upstream internet yet, so if it wins
|
||||
the node's default-route race (lowest metric on its own interface) while
|
||||
still offline, it creates a dead-end route and the node loses its own
|
||||
connectivity (including anything tunneled, e.g. a VPN/mesh network the node
|
||||
relies on) until that route is removed or the router gets its uplink
|
||||
working. If you hit this, either wait until WAN/WISP is actually up before
|
||||
letting the router's interface win the route race, or temporarily lower the
|
||||
priority of that route until it is.
|
||||
|
||||
## Reconfiguring or moving to a different router
|
||||
|
||||
Use **Disconnect** on the status dashboard to return to the connect form —
|
||||
this only clears the panel's client-side state, it doesn't delete the
|
||||
persisted `router_config.json`, so reconnecting to the same router needs no
|
||||
re-entry. To point at a *different* router, disconnect and connect with a
|
||||
new host/credentials; the newly connected router becomes the persisted one.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"No router configured"**: nothing has been connected yet, or the saved
|
||||
config didn't include a host — go through Step 2 again.
|
||||
- **Connect hangs or times out**: the router isn't reachable on `TCP/22`
|
||||
from the node's network, or SSH auth failed. Confirm you can `ssh
|
||||
root@<router-ip>` manually from the node (or a machine on the same LAN)
|
||||
with the same credentials.
|
||||
- **Router "moved networks" / stale saved host**: SSH/status calls are
|
||||
bounded (5s TCP connect, 30s read/write) precisely so an unreachable
|
||||
saved router can't stall other RPCs — but the dashboard will show a
|
||||
connection error until you reconnect with the router's current address.
|
||||
- **TollGate provision fails with "No pre-built TollGate package for
|
||||
architecture..."**: your router's SoC isn't one of the prebuilt
|
||||
`.ipk` targets (`mips_24kc`, `mipsel_24kc`, `aarch64_cortex-a53`,
|
||||
`aarch64_cortex-a72`, `arm_cortex-a7`). You'll need a custom opkg feed or
|
||||
to build `tollgate-module-basic-go` from source for your architecture.
|
||||
- **TollGate download looks like it succeeded but provisioning still
|
||||
fails**: the node sanity-checks the downloaded `.ipk` is at least 50 KB —
|
||||
a smaller file usually means `wget` captured an HTML error page instead
|
||||
(no internet access from the router, or a bad release URL).
|
||||
- **Install fails right after a reboot or a fresh WAN setup** with `apk
|
||||
update failed ... router may have no internet access` even though WAN
|
||||
looks configured: this is usually just timing, not a real problem — the
|
||||
router's WiFi-uplink association (`wwan`/`hakodosh`-style STA interface)
|
||||
can take a few seconds longer to reconnect than the dashboard takes to
|
||||
let you click Install. Wait ~10–15 seconds after WAN shows `sta_state:
|
||||
up` and retry; it should succeed on the next attempt.
|
||||
- **Install fails with `opkg not found at /usr/bin/opkg` (or similar) even
|
||||
though the router clearly has `opkg`/`apk` installed**: fixed as of
|
||||
2026-09-05 — the backend used to hardcode `/usr/bin/opkg`/`/usr/bin/apk`,
|
||||
which some official OpenWrt builds don't symlink into `/bin`. If you're
|
||||
running an Archipelago build from before that fix, update first.
|
||||
|
||||
---
|
||||
|
||||
## Developer reference
|
||||
|
||||
Backend crate: `core/openwrt` (`archipelago-openwrt`) — SSH/UCI plumbing,
|
||||
WAN/WISP config, WiFi scanning, and TollGate install/config. See
|
||||
[`architecture.md`](architecture.md) for where it sits in the workspace.
|
||||
|
||||
RPC methods (`core/archipelago/src/api/rpc/openwrt.rs`, dispatched in
|
||||
`core/archipelago/src/api/rpc/dispatcher.rs`):
|
||||
|
||||
| Method | Purpose |
|
||||
|---|---|
|
||||
| `openwrt.scan` | Probe a subnet for OpenWrt routers (`subnet`, `prefix`, `ssh_user`, `ssh_password`) |
|
||||
| `openwrt.get-status` | Full status: release, WiFi interfaces, WAN, TollGate state. No params → uses saved `router_config.json`; params with `host` also persist the connection |
|
||||
| `openwrt.configure-wan` | Write WISP/WAN config (`ssid`, `password`, `encryption`, `dhcp_start`, `dhcp_limit`, `masq`) |
|
||||
| `openwrt.scan-wifi` | Radio scan for visible upstream networks |
|
||||
| `openwrt.provision-tollgate` | Install/reconfigure TollGate (`price_sats`, `step_size_ms`, `min_steps`, `mint_url`, `enabled`) |
|
||||
|
||||
Note: these are distinct from the unrelated `router.*` methods
|
||||
(`router.discover`, `router.configure`, `router.list-forwards`, ...), which
|
||||
handle UPnP/NAT-PMP port forwarding on the node's own upstream home router —
|
||||
not the OpenWrt gateway feature described here.
|
||||
|
||||
Frontend: `neode-ui/src/views/server/OpenWrtGateway.vue`, routed at
|
||||
`server/openwrt` (`neode-ui/src/router/index.ts`), linked from
|
||||
`neode-ui/src/views/Server.vue`.
|
||||
|
||||
Persisted connection state: `router_config.json` in the node's data
|
||||
directory (`core/archipelago/src/network/router.rs`:
|
||||
`load_router_config`/`save_router_config`).
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.10-alpha",
|
||||
"version": "1.8.11-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.10-alpha",
|
||||
"version": "1.8.11-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.10-alpha",
|
||||
"version": "1.8.11-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -106,6 +106,27 @@
|
||||
|
||||
<!-- Ecash -->
|
||||
<div v-if="receiveMethod === 'ecash'">
|
||||
<!-- Shareable @minibits.cash Lightning address (LUD-16): any Lightning
|
||||
wallet can pay this node by address, and the sats land as ecash.
|
||||
Fetched on tab open; claimed payments are polled in while open. -->
|
||||
<div v-if="lnAddress" class="mb-4 p-3 bg-white/5 rounded-lg text-center">
|
||||
<p class="text-white/60 text-sm mb-2">{{ t('receiveBitcoin.lnAddressTitle') }}</p>
|
||||
<canvas ref="lnAddressQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
|
||||
<p class="text-white/50 text-xs mb-1">{{ t('receiveBitcoin.lnAddressLabel') }}</p>
|
||||
<p class="text-base font-mono text-white/95 break-all mb-2">{{ lnAddress }}</p>
|
||||
<CopyButton :value="lnAddress" :label="t('common.copy')" />
|
||||
<p class="text-white/40 text-xs mt-3 leading-relaxed">{{ t('receiveBitcoin.lnAddressHint') }}</p>
|
||||
<p v-if="lnClaimedSats > 0" class="text-green-400 text-sm mt-2">
|
||||
{{ t('receiveBitcoin.lnAddressReceived', { amount: lnClaimedSats.toLocaleString() }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4">
|
||||
{{ t('receiveBitcoin.lnAddressLoading') }}
|
||||
</div>
|
||||
<div v-else-if="lnAddressError" class="mb-3 text-xs text-white/40">
|
||||
{{ t('receiveBitcoin.lnAddressUnavailable') }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
|
||||
<textarea v-model="ecashToken" rows="3" placeholder="cashuB… (Cashu) or Fedimint notes" class="w-full input-glass font-mono"></textarea>
|
||||
@@ -175,6 +196,11 @@ watch(() => props.show, (open) => {
|
||||
arkAddress.value = ''
|
||||
ecashToken.value = ''
|
||||
ecashResult.value = ''
|
||||
stopLnClaimPoll()
|
||||
lnAddress.value = ''
|
||||
lnAddressLoading.value = false
|
||||
lnAddressError.value = false
|
||||
lnClaimedSats.value = 0
|
||||
error.value = ''
|
||||
processing.value = false
|
||||
if (props.autoGenerate && receiveMethod.value === 'onchain') {
|
||||
@@ -193,9 +219,80 @@ const ecashResult = ref('')
|
||||
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const lnAddressQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const processing = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// ── Minibits Lightning address (ecash receive) ──────────────────────────────
|
||||
// The ecash tab doubles as "receive onto my @minibits.cash address": the node
|
||||
// derives/registers it from its own ecash seed (wallet.ecash-lnaddress) and
|
||||
// sweeps any Lightning payments that land there back into ecash while the tab is
|
||||
// open (wallet.ecash-lnaddress-claim). A registration failure is never fatal —
|
||||
// the paste-token path below always works.
|
||||
const lnAddress = ref('')
|
||||
const lnAddressLoading = ref(false)
|
||||
const lnAddressError = ref(false)
|
||||
const lnClaimedSats = ref(0)
|
||||
let lnClaimTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function loadLnAddress() {
|
||||
if (lnAddress.value || lnAddressLoading.value) return
|
||||
lnAddressLoading.value = true
|
||||
lnAddressError.value = false
|
||||
try {
|
||||
const res = await rpcClient.call<{ address?: string }>({ method: 'wallet.ecash-lnaddress' })
|
||||
lnAddress.value = res?.address || ''
|
||||
if (lnAddress.value) {
|
||||
await nextTick()
|
||||
renderQr(lnAddress.value, lnAddressQrCanvas.value)
|
||||
startLnClaimPoll()
|
||||
} else {
|
||||
lnAddressError.value = true
|
||||
}
|
||||
} catch {
|
||||
lnAddressError.value = true
|
||||
} finally {
|
||||
lnAddressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function stopLnClaimPoll() {
|
||||
if (lnClaimTimer) {
|
||||
clearInterval(lnClaimTimer)
|
||||
lnClaimTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startLnClaimPoll() {
|
||||
stopLnClaimPoll()
|
||||
lnClaimTimer = setInterval(() => void pollLnClaims(), 8000)
|
||||
}
|
||||
|
||||
async function pollLnClaims() {
|
||||
if (!props.show || !lnAddress.value) {
|
||||
stopLnClaimPoll()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await rpcClient.call<{ received_sats?: number }>({
|
||||
method: 'wallet.ecash-lnaddress-claim',
|
||||
})
|
||||
if (res?.received_sats && res.received_sats > 0) {
|
||||
lnClaimedSats.value += res.received_sats
|
||||
emit('received')
|
||||
}
|
||||
} catch {
|
||||
// Transient poll failure (offline, mint busy) — keep polling.
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(stopLnClaimPoll)
|
||||
|
||||
// Fetch the address the first time the operator opens the ecash tab.
|
||||
watch(receiveMethod, (m) => {
|
||||
if (m === 'ecash' && props.show) void loadLnAddress()
|
||||
})
|
||||
|
||||
// ── On-chain payment detection ────────────────────────────────────────────
|
||||
// The generated address is FRESH (lnd.newaddress), so any incoming wallet
|
||||
// transaction paying it is this receive — no baseline bookkeeping needed.
|
||||
@@ -309,12 +406,15 @@ async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix =
|
||||
|
||||
function close() {
|
||||
stopWatchingPayment()
|
||||
stopLnClaimPoll()
|
||||
paymentSeen.value = null
|
||||
invoiceResult.value = ''
|
||||
onchainAddress.value = ''
|
||||
arkAddress.value = ''
|
||||
ecashToken.value = ''
|
||||
ecashResult.value = ''
|
||||
lnAddress.value = ''
|
||||
lnClaimedSats.value = 0
|
||||
error.value = ''
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -775,6 +775,12 @@
|
||||
"paymentConfirmed": "Payment confirmed",
|
||||
"transactionId": "Transaction ID",
|
||||
"pasteEcashToken": "Paste ecash token",
|
||||
"lnAddressTitle": "Or share your Minibits Lightning address",
|
||||
"lnAddressHint": "Anyone can pay you sats with any Lightning wallet by sending to this address — the sats arrive as ecash. Keep this screen open to receive them.",
|
||||
"lnAddressLabel": "Your @minibits.cash address:",
|
||||
"lnAddressLoading": "Setting up your Lightning address…",
|
||||
"lnAddressUnavailable": "Lightning address unavailable — you can still paste a token below.",
|
||||
"lnAddressReceived": "Received {amount} sats to your Lightning address!",
|
||||
"processing": "Processing...",
|
||||
"generateAddress": "Generate Address",
|
||||
"createInvoice": "Create Invoice",
|
||||
|
||||
@@ -756,6 +756,12 @@
|
||||
"paymentConfirmed": "Pago confirmado",
|
||||
"transactionId": "ID de transacci\u00f3n",
|
||||
"pasteEcashToken": "Pegar token Ecash",
|
||||
"lnAddressTitle": "O comparte tu direcci\u00f3n Lightning de Minibits",
|
||||
"lnAddressHint": "Cualquier persona puede pagarte sats con cualquier billetera Lightning enviando a esta direcci\u00f3n \u2014 los sats llegan como ecash. Mant\u00e9n esta pantalla abierta para recibirlos.",
|
||||
"lnAddressLabel": "Su direcci\u00f3n @minibits.cash:",
|
||||
"lnAddressLoading": "Configurando su direcci\u00f3n Lightning\u2026",
|
||||
"lnAddressUnavailable": "Direcci\u00f3n Lightning no disponible \u2014 a\u00fan puede pegar un token abajo.",
|
||||
"lnAddressReceived": "\u00a1Recibi\u00f3 {amount} sats en su direcci\u00f3n Lightning!",
|
||||
"processing": "Procesando...",
|
||||
"generateAddress": "Generar direcci\u00f3n",
|
||||
"createInvoice": "Crear factura",
|
||||
|
||||
@@ -64,10 +64,10 @@ describe('appSessionConfig', () => {
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// did-wallet's manifest publishes host port 8088 (apps/did-wallet/
|
||||
// searxng's manifest publishes host port 8888 (apps/searxng/
|
||||
// manifest.yml) — assert against the manifest-generated value, which is
|
||||
// exactly what this test exists to protect.
|
||||
expect(resolveAppUrl('did-wallet')).toBe('http://192.0.2.10:8088')
|
||||
expect(resolveAppUrl('searxng')).toBe('http://192.0.2.10:8888')
|
||||
})
|
||||
|
||||
it('does not treat service-only tcp ports as web launch surfaces', () => {
|
||||
|
||||
@@ -115,6 +115,24 @@ const showConnectForm = ref(false)
|
||||
const connecting = ref(false)
|
||||
const connectedParams = ref<Record<string, string> | null>(null)
|
||||
|
||||
// Every action below (install/edit TollGate, WiFi scan, WAN configure) needs
|
||||
// host/ssh_user/ssh_password to reach the router. `connectedParams` only gets
|
||||
// set when the Connect form was actually submitted this session (WR-03 above)
|
||||
// — on a normal page load the router reconnects via the server-persisted
|
||||
// config instead, so `sshPassword`/`sshUser`/`host` (the Connect form's own
|
||||
// local refs) sit at their untouched defaults ('', 'root', ''). Falling back
|
||||
// to those refs here used to send an explicit-but-empty ssh_password, which
|
||||
// the backend treats as "the caller provided this" and never falls back to
|
||||
// the real saved password — a real router password then fails auth on every
|
||||
// action even though the status poll (which sends no params at all) keeps
|
||||
// working fine (archy-x250-pa3, 2026-09-05: dropbear logged one bad-password
|
||||
// attempt at the exact moment "Install TollGate" was clicked). Omitting the
|
||||
// fields entirely when there's no explicit connectedParams lets the backend's
|
||||
// own saved-config fallback do the right thing, same as the status poll.
|
||||
function authParams(): Record<string, string> {
|
||||
return connectedParams.value ?? {}
|
||||
}
|
||||
|
||||
const detecting = ref(false)
|
||||
const detectError = ref('')
|
||||
const detectedCandidates = ref<string[]>([])
|
||||
@@ -271,11 +289,7 @@ async function provisionTollgate() {
|
||||
provisionError.value = ''
|
||||
provisionSuccess.value = false
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
}
|
||||
const params: Record<string, unknown> = { ...authParams() }
|
||||
await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 })
|
||||
provisionSuccess.value = true
|
||||
await load(connectedParams.value ?? undefined)
|
||||
@@ -302,9 +316,7 @@ async function saveTollgateConfig() {
|
||||
updateTollgateError.value = ''
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
...authParams(),
|
||||
price_sats: editPriceSats.value,
|
||||
step_size_ms: editStepSizeMin.value * 60_000,
|
||||
min_steps: editMinSteps.value,
|
||||
@@ -336,11 +348,7 @@ async function scanWifi() {
|
||||
wanStep.value = 'scanning'
|
||||
wanError.value = ''
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
}
|
||||
const params: Record<string, unknown> = { ...authParams() }
|
||||
const result = await rpcClient.call<{ networks: ScannedNetwork[] }>({
|
||||
method: 'openwrt.scan-wifi',
|
||||
params,
|
||||
@@ -367,9 +375,7 @@ async function configureWan() {
|
||||
wanError.value = ''
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
...authParams(),
|
||||
ssid: selectedNetwork.value.ssid,
|
||||
password: wanPassword.value,
|
||||
encryption: selectedNetwork.value.encryption,
|
||||
|
||||
@@ -362,6 +362,18 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.8.11-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.11-alpha</span>
|
||||
<span class="text-xs text-white/40">September 7, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>Cuprate now syncs without burning a core for days.</strong> The app's shipped config now enables Cuprate's checkpoint-backed fast_sync path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.</p>
|
||||
<p><strong>OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.</strong> The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds opkg/apk through the router's actual PATH instead of assuming /usr/bin, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to v0.5.0 with a native .apk install path where upstream provides one.</p>
|
||||
<p><strong>Release publishing now checks the public Gitea download links before a manifest goes live.</strong> The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea ROOT_URL or proxy setting cannot publish working files behind broken public HTTPS download links.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.10-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
@@ -369,9 +381,9 @@ init()
|
||||
<span class="text-xs text-white/40">September 2, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>Lightning sends work again.</strong> v1.8.9's move to LND 0.21's supported payment route shipped without a fee budget, and the API treats a missing one as zero allowed fees — so every wallet send failed "No route to the recipient" all day, on perfectly healthy channels. Payments now carry a proper fee budget and a test keeps it from ever regressing.</p>
|
||||
<p><strong>A channel that drops its peer link now heals itself — on every node.</strong> Restarting LND (an app update, a reboot, container churn) can leave a channel's peer connection down for hours while both endpoints keep the channel flagged disabled in the routing graph: the node looks perfectly healthy, the wallet shows balance, and every payment in either direction fails "no route to the recipient". The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't. Nodes without LND are untouched; an unreachable peer is retried gently.</p>
|
||||
<p><strong>The Lightning wallet says what's actually wrong, instead of "you have no channel".</strong> Trying to send while a channel you just opened was still confirming — or when all its balance sits on the far side — produced a modal claiming you had no channel at all, and payment routing failures even showed the receiving copy. The gate now reads your real channel list: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", and only a genuinely channel-less node is sent to open one.</p>
|
||||
<p><strong>Lightning sends work again — v1.8.9's payment switch lost the fee budget.</strong> Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as <strong>zero allowed fees</strong>: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered "No route to the recipient" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (fee_limit=0 mSAT on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.</p>
|
||||
<p><strong>A channel that drops its peer link now heals itself — on every node.</strong> Restarting LND (an app update, a reboot, container churn) can leave a channel's peer connection down for hours while both endpoints keep the channel flagged disabled in the routing graph: the node looks perfectly healthy, the wallet shows balance, and every payment in either direction fails "no route to the recipient". Observed live: a node's only channel sat unroutable for ~17 hours after the LND 0.21.2 update, with no sign of it in any dashboard. The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't, using the peer's advertised addresses. Nodes without LND are untouched; an unreachable peer is retried gently, not hammered.</p>
|
||||
<p><strong>The Lightning wallet states the node's real funding state instead of "you have no channel."</strong> Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had NO channel at all (the outbound sum is legitimately zero in both states), pointed the user at opening a second channel, and — for payment routing failures — even showed the <em>receiving</em> copy. The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of claiming channel problems, and only a genuinely channel-less node keeps the open-one guidance.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.9-alpha -->
|
||||
@@ -381,12 +393,13 @@ init()
|
||||
<span class="text-xs text-white/40">September 1, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>Lightning sends work again after the LND 0.21.2 update.</strong> LND 0.21 removed the payment route the node's backend used — every send answered "Not Found". Payments now go through LND's supported v2 router route, slow multi-hop payments are still tracked to completion (never falsely declared failed), failures explain themselves in plain language, and a new test speaks the payment route directly at release-gate time so an image/backend mismatch like this can never ship silently again.</p>
|
||||
<p><strong>HTTP and HTTPS both work, and no longer break each other.</strong> The HTTPS listener used to pin a year-long browser policy (HSTS); once your browser had visited HTTPS, it silently rewrote the HTTP dashboard's calls to HTTPS — cross-origin, so everything showed "Failed to fetch"/CORS errors while the node was healthy. The pin is gone, the HTTPS listener now actively clears the stale policy browsers already cached (visit HTTPS once after this update to clear yours), and plain-HTTP access — which is deliberate on nodes whose self-signed certificate you haven't installed — keeps working exactly as before.</p>
|
||||
<p><strong>Apps open over HTTPS again, including Mempool, Bitcoin and IndeeHub.</strong> The launcher looked each app's port policy up in the signed catalog under the name you click, but the catalog lists that port under the app that owns it — so Mempool "did not connect", Bitcoin opened a plain-http tab, and Nostr sign-in on IndeeHub silently did nothing over HTTPS. Launches now follow the alias to the owning manifest, the catalog is loaded before the first app you open (not just in the App Store), and the Nostr bridge replies to the app frame's real origin instead of a stale recorded address.</p>
|
||||
<p><strong>Nginx Proxy Manager starts again.</strong> Its manifest was missing two things its image requires — the LetsEncrypt folder mount and the permission to bind low ports — leaving it in an endless restart loop on nodes that had it installed. Both are declared now; your existing certificates are untouched, and the fix arrives via the signed catalog without waiting for this release.</p>
|
||||
<p><strong>Portainer's first-run token is on the app page, not buried in "server logs".</strong> New Portainer versions hand the first admin a one-time setup token that was only printed in the container logs — on this box, that token now appears with your app's other credentials, with a copy button, and disappears once setup is done.</p>
|
||||
<p><strong>The Lightning wallet says what's actually wrong, instead of "you have no channel".</strong> Trying to send while a channel you just opened was still confirming — or when all its balance sits on the far side — produced a modal claiming you had no channel at all. The gate now looks at your real channel list: a confirming channel gets "it unlocks automatically once confirmed, nothing needed from you", a far-side balance gets "you can receive but there's nothing to send right now", and only a genuinely channel-less node is sent to open one.</p>
|
||||
<p><strong>Lightning sends work again after the LND 0.21.2 update.</strong> LND 0.21 removed the old synchronous payment route the node's backend paid through (/v1/channels/transactions) — every Lightning send answered the literal "Not Found" and the wallet showed "Payment failed: Not Found". The backend now pays through the supported Router.SendPaymentV2 route, keeps the same settle-then-report behaviour (a slow multi-hop payment is still tracked to completion, never falsely declared failed), and translates LND's failure reasons into plain advice. A new gate test speaks the payment route directly against the running LND, so an image/backend skew like this can never ship silently again.</p>
|
||||
<p><strong>The node no longer pins HSTS — HTTP access is a supported mode, and it stays working.</strong> The HTTPS listener used to send Strict-Transport-Security: max-age=31536000; includeSubDomains; browsers that visited HTTPS once cached that and then silently upgraded the still-open HTTP dashboard's calls to HTTPS, which is a scheme change — cross-origin — so every request died as "CORS blocked / Failed to fetch" while the node was perfectly healthy. The HTTPS listener now actively clears the cached policy (max-age=0) and port 80 sends no HSTS at all, which is deliberate: the node's certificate is optional and self-signed, and devices that haven't installed the CA must keep plain-HTTP access (that's what Settings → Node certificate is for). If your browser already cached the old policy, visiting the dashboard over HTTPS once after this update clears it; a gate test now refuses any config that reintroduces the pin.</p>
|
||||
<p><strong>App frames open over HTTPS again — including the ones that "did not connect."</strong> The launcher asked the signed catalog for each app's port policy under the name you click ("Mempool Web", "Bitcoin Knots"), but the catalog declares those ports under the manifest that owns them (the Mempool web container, Bitcoin UI). The lookup missed, the launcher handed the iframe an http:// address, and the browser blocked it as mixed content — the app tile went blank or spun forever. Port resolution now follows launch aliases (mempool-web, bitcoin-knots/bitcoin-core, lnd, electrs and friends), falls back to a port-wide catalog scan when the id is unknown, and the catalog is warmed as soon as the dashboard loads rather than only in the App Store, so the very first app you open already knows which ports serve TLS.</p>
|
||||
<p><strong>Signing in to IndeeHub with Nostr works over HTTPS.</strong> The NIP-07 bridge compared the app frame's origin for exact equality with the recorded http:// app URL — a frame the browser upgraded to HTTPS (or any scheme change) was silently ignored, and replies addressed to the stale origin were refused outright, so Nostr sign-in quietly did nothing. The bridge now matches host and port (scheme intentionally ignored) and always replies to the frame's real origin.</p>
|
||||
<p><strong>Nginx Proxy Manager starts again.</strong> Converting it to a platform manifest dropped two things its image needs: the /etc/letsencrypt mount its boot script hard-requires, and the NET_BIND_SERVICE capability its internal nginx needs to bind ports 80/443/81 under the orchestrator's --cap-drop=ALL. The result was an endless start/die loop (a node watched it restart 3,176 times). Both are declared in its manifest now, its certs live on unchanged under the same persistent app directory, and the signed catalog carries the fix so installed nodes heal on the next update.</p>
|
||||
<p><strong>Portainer's first-run token is in the app page, not buried in "server logs."</strong> New Portainer versions mint a one-time setup token on a fresh install and print it only to the container logs — on an appliance that meant telling the user to go read a server log to get into their own app. The token now appears in the same launch interstitial as app login credentials (with a copy button), only while first-run setup is actually pending; once the admin account exists the card disappears on its own.</p>
|
||||
<p><strong>The Lightning wallet states the node's real funding state instead of "you have no channel."</strong> Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had no channel at all (the outbound sum is legitimately zero in both states). The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of pointing at channel setup, and only a genuinely channel-less node is sent to open one.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.8-alpha -->
|
||||
|
||||
+17
-20
@@ -1,32 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Lightning sends work again after the LND 0.21.2 update.** LND 0.21 removed the old synchronous payment route the node's backend paid through (`/v1/channels/transactions`) — every Lightning send answered the literal \"Not Found\" and the wallet showed \"Payment failed: Not Found\". The backend now pays through the supported Router.SendPaymentV2 route, keeps the same settle-then-report behaviour (a slow multi-hop payment is still tracked to completion, never falsely declared failed), and translates LND's failure reasons into plain advice. A new gate test speaks the payment route directly against the running LND, so an image/backend skew like this can never ship silently again.",
|
||||
"**The node no longer pins HSTS — HTTP access is a supported mode, and it stays working.** The HTTPS listener used to send `Strict-Transport-Security: max-age=31536000; includeSubDomains`; browsers that visited HTTPS once cached that and then silently upgraded the still-open HTTP dashboard's calls to HTTPS, which is a scheme change — cross-origin — so every request died as \"CORS blocked / Failed to fetch\" while the node was perfectly healthy. The HTTPS listener now actively clears the cached policy (`max-age=0`) and port 80 sends no HSTS at all, which is deliberate: the node's certificate is optional and self-signed, and devices that haven't installed the CA must keep plain-HTTP access (that's what Settings → Node certificate is for). If your browser already cached the old policy, visiting the dashboard over HTTPS once after this update clears it; a gate test now refuses any config that reintroduces the pin.",
|
||||
"**App frames open over HTTPS again — including the ones that \"did not connect.\"** The launcher asked the signed catalog for each app's port policy under the name you click (\"Mempool Web\", \"Bitcoin Knots\"), but the catalog declares those ports under the manifest that owns them (the Mempool web container, Bitcoin UI). The lookup missed, the launcher handed the iframe an `http://` address, and the browser blocked it as mixed content — the app tile went blank or spun forever. Port resolution now follows launch aliases (mempool-web, bitcoin-knots/bitcoin-core, lnd, electrs and friends), falls back to a port-wide catalog scan when the id is unknown, and the catalog is warmed as soon as the dashboard loads rather than only in the App Store, so the very first app you open already knows which ports serve TLS.",
|
||||
"**Signing in to IndeeHub with Nostr works over HTTPS.** The NIP-07 bridge compared the app frame's origin for exact equality with the recorded `http://` app URL — a frame the browser upgraded to HTTPS (or any scheme change) was silently ignored, and replies addressed to the stale origin were refused outright, so Nostr sign-in quietly did nothing. The bridge now matches host and port (scheme intentionally ignored) and always replies to the frame's real origin.",
|
||||
"**Nginx Proxy Manager starts again.** Converting it to a platform manifest dropped two things its image needs: the `/etc/letsencrypt` mount its boot script hard-requires, and the `NET_BIND_SERVICE` capability its internal nginx needs to bind ports 80/443/81 under the orchestrator's `--cap-drop=ALL`. The result was an endless start/die loop (a node watched it restart 3,176 times). Both are declared in its manifest now, its certs live on unchanged under the same persistent app directory, and the signed catalog carries the fix so installed nodes heal on the next update.",
|
||||
"**Portainer's first-run token is in the app page, not buried in \"server logs.\"** New Portainer versions mint a one-time setup token on a fresh install and print it only to the container logs — on an appliance that meant telling the user to go read a server log to get into their own app. The token now appears in the same launch interstitial as app login credentials (with a copy button), only while first-run setup is actually pending; once the admin account exists the card disappears on its own."
|
||||
"**Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.",
|
||||
"**OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.",
|
||||
"**Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.9-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.9-alpha/archipelago",
|
||||
"current_version": "1.8.11-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.9-alpha",
|
||||
"sha256": "39795958963680f56763e3c05e3fe0cd589c30edd9a09416ad325bab4c862123",
|
||||
"size_bytes": 64139152
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "ae569054edd6b2491beb101815f6809bc00c95a7dbe86bd084bcb9a7c36e1853",
|
||||
"size_bytes": 64179264
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.9-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.9-alpha/archipelago-frontend-1.8.9-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.9-alpha.tar.gz",
|
||||
"new_version": "1.8.9-alpha",
|
||||
"sha256": "624dd10dfea09809be1fdddc7eac804e1fde66ff3d552cb90d56d9ac550ed944",
|
||||
"size_bytes": 97734650
|
||||
"current_version": "1.8.11-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago-frontend-1.8.11-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.11-alpha.tar.gz",
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "192fd0470b6ccf66e78c80b4a4c3af5468882b85d81959362a3bd11f88b9d71d",
|
||||
"size_bytes": 97741740
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-01",
|
||||
"signature": "d7d724b910e827651240bd9520102d66932b57a8a8d674ef645c45eb77f78c123fb45d294ec07f8bbfc3713ed9bd9f98096f59ff18cd6098df51aa473e771908",
|
||||
"release_date": "2026-09-07",
|
||||
"signature": "6449ce6ef35a4ef4fa6d0923bb58a2bff52ea5430d5532496e8f0af9ed52eaec293f19d7bec272dc9bc1af5fb2cdfa0e46068c827a9a4dd9cbef92d1c5845301",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.9-alpha"
|
||||
"version": "1.8.11-alpha"
|
||||
}
|
||||
|
||||
@@ -1319,7 +1319,7 @@
|
||||
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
|
||||
"files": [
|
||||
{
|
||||
"content": "network = \"Mainnet\"\ntarget_max_memory = 3000000000\n\n[rpc.restricted]\nenable = true\n\n[tracing.stdout]\nlevel = \"info\"\n\n[tracing.file]\nlevel = \"info\"\nmax_log_files = 14\n",
|
||||
"content": "network = \"Mainnet\"\nfast_sync = true\ntarget_max_memory = 8589934592\n\n[rpc.restricted]\nenable = true\n\n[tracing.stdout]\nlevel = \"info\"\n\n[tracing.file]\nlevel = \"info\"\nmax_log_files = 14\n",
|
||||
"overwrite": false,
|
||||
"path": "/var/lib/archipelago/cuprate/Cuprated.toml"
|
||||
}
|
||||
@@ -1350,8 +1350,8 @@
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.",
|
||||
"auth": "open",
|
||||
"auth_rationale": "Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot complete a browser login or hold a dashboard session cookie.",
|
||||
"container": 18089,
|
||||
"host": 18090,
|
||||
"protocol": "tcp"
|
||||
@@ -1360,7 +1360,7 @@
|
||||
"resources": {
|
||||
"cpu_limit": 0,
|
||||
"disk_limit": "300Gi",
|
||||
"memory_limit": "4Gi"
|
||||
"memory_limit": "10Gi"
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [],
|
||||
@@ -5429,7 +5429,7 @@
|
||||
}
|
||||
},
|
||||
"schema": 1,
|
||||
"signature": "f982faeb9823062d9d39f6e4b38a171b4442cad0f35e74792ea161b5d77246ab9128044acbdc390ec23f921363af2d13bbba66c558b188d14d06a3f9a7f42406",
|
||||
"signature": "3e87496a7197177ea295eba416cd1ed9a2c41ddca3328a160b1db2c65d39ce813c2b1e63df1313680a8e48e0113e2bbe6118df01df4a777bd33b189e7ef69206",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"updated": "2026-09-01"
|
||||
"updated": "2026-09-03"
|
||||
}
|
||||
|
||||
+17
-20
@@ -1,32 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Lightning sends work again after the LND 0.21.2 update.** LND 0.21 removed the old synchronous payment route the node's backend paid through (`/v1/channels/transactions`) — every Lightning send answered the literal \"Not Found\" and the wallet showed \"Payment failed: Not Found\". The backend now pays through the supported Router.SendPaymentV2 route, keeps the same settle-then-report behaviour (a slow multi-hop payment is still tracked to completion, never falsely declared failed), and translates LND's failure reasons into plain advice. A new gate test speaks the payment route directly against the running LND, so an image/backend skew like this can never ship silently again.",
|
||||
"**The node no longer pins HSTS — HTTP access is a supported mode, and it stays working.** The HTTPS listener used to send `Strict-Transport-Security: max-age=31536000; includeSubDomains`; browsers that visited HTTPS once cached that and then silently upgraded the still-open HTTP dashboard's calls to HTTPS, which is a scheme change — cross-origin — so every request died as \"CORS blocked / Failed to fetch\" while the node was perfectly healthy. The HTTPS listener now actively clears the cached policy (`max-age=0`) and port 80 sends no HSTS at all, which is deliberate: the node's certificate is optional and self-signed, and devices that haven't installed the CA must keep plain-HTTP access (that's what Settings → Node certificate is for). If your browser already cached the old policy, visiting the dashboard over HTTPS once after this update clears it; a gate test now refuses any config that reintroduces the pin.",
|
||||
"**App frames open over HTTPS again — including the ones that \"did not connect.\"** The launcher asked the signed catalog for each app's port policy under the name you click (\"Mempool Web\", \"Bitcoin Knots\"), but the catalog declares those ports under the manifest that owns them (the Mempool web container, Bitcoin UI). The lookup missed, the launcher handed the iframe an `http://` address, and the browser blocked it as mixed content — the app tile went blank or spun forever. Port resolution now follows launch aliases (mempool-web, bitcoin-knots/bitcoin-core, lnd, electrs and friends), falls back to a port-wide catalog scan when the id is unknown, and the catalog is warmed as soon as the dashboard loads rather than only in the App Store, so the very first app you open already knows which ports serve TLS.",
|
||||
"**Signing in to IndeeHub with Nostr works over HTTPS.** The NIP-07 bridge compared the app frame's origin for exact equality with the recorded `http://` app URL — a frame the browser upgraded to HTTPS (or any scheme change) was silently ignored, and replies addressed to the stale origin were refused outright, so Nostr sign-in quietly did nothing. The bridge now matches host and port (scheme intentionally ignored) and always replies to the frame's real origin.",
|
||||
"**Nginx Proxy Manager starts again.** Converting it to a platform manifest dropped two things its image needs: the `/etc/letsencrypt` mount its boot script hard-requires, and the `NET_BIND_SERVICE` capability its internal nginx needs to bind ports 80/443/81 under the orchestrator's `--cap-drop=ALL`. The result was an endless start/die loop (a node watched it restart 3,176 times). Both are declared in its manifest now, its certs live on unchanged under the same persistent app directory, and the signed catalog carries the fix so installed nodes heal on the next update.",
|
||||
"**Portainer's first-run token is in the app page, not buried in \"server logs.\"** New Portainer versions mint a one-time setup token on a fresh install and print it only to the container logs — on an appliance that meant telling the user to go read a server log to get into their own app. The token now appears in the same launch interstitial as app login credentials (with a copy button), only while first-run setup is actually pending; once the admin account exists the card disappears on its own."
|
||||
"**Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.",
|
||||
"**OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.",
|
||||
"**Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.9-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.9-alpha/archipelago",
|
||||
"current_version": "1.8.11-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.9-alpha",
|
||||
"sha256": "39795958963680f56763e3c05e3fe0cd589c30edd9a09416ad325bab4c862123",
|
||||
"size_bytes": 64139152
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "ae569054edd6b2491beb101815f6809bc00c95a7dbe86bd084bcb9a7c36e1853",
|
||||
"size_bytes": 64179264
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.9-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.9-alpha/archipelago-frontend-1.8.9-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.9-alpha.tar.gz",
|
||||
"new_version": "1.8.9-alpha",
|
||||
"sha256": "624dd10dfea09809be1fdddc7eac804e1fde66ff3d552cb90d56d9ac550ed944",
|
||||
"size_bytes": 97734650
|
||||
"current_version": "1.8.11-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago-frontend-1.8.11-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.11-alpha.tar.gz",
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "192fd0470b6ccf66e78c80b4a4c3af5468882b85d81959362a3bd11f88b9d71d",
|
||||
"size_bytes": 97741740
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-01",
|
||||
"signature": "d7d724b910e827651240bd9520102d66932b57a8a8d674ef645c45eb77f78c123fb45d294ec07f8bbfc3713ed9bd9f98096f59ff18cd6098df51aa473e771908",
|
||||
"release_date": "2026-09-07",
|
||||
"signature": "6449ce6ef35a4ef4fa6d0923bb58a2bff52ea5430d5532496e8f0af9ed52eaec293f19d7bec272dc9bc1af5fb2cdfa0e46068c827a9a4dd9cbef92d1c5845301",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.9-alpha"
|
||||
"version": "1.8.11-alpha"
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered \"No route to the recipient\" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.",
|
||||
"**A channel that drops its peer link now heals itself — on every node.** Restarting LND (an app update, a reboot, container churn) can leave a channel's peer connection down for hours while both endpoints keep the channel flagged disabled in the routing graph: the node looks perfectly healthy, the wallet shows balance, and every payment in either direction fails \"no route to the recipient\". Observed live: a node's only channel sat unroutable for ~17 hours after the LND 0.21.2 update, with no sign of it in any dashboard. The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't, using the peer's advertised addresses. Nodes without LND are untouched; an unreachable peer is retried gently, not hammered.",
|
||||
"**The Lightning wallet states the node's real funding state instead of \"you have no channel.\"** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had NO channel at all (the outbound sum is legitimately zero in both states), pointed the user at opening a second channel, and — for payment routing failures — even showed the *receiving* copy. The funding gate now reads the channel list it already fetched: a confirming channel gets \"it unlocks automatically once confirmed, nothing is needed from you\", a far-side balance gets \"you can receive, but there's nothing to send right now\", a routing/liquidity payment failure says so instead of claiming channel problems, and only a genuinely channel-less node keeps the open-one guidance."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.10-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.10-alpha",
|
||||
"sha256": "6c8bd41fed44cd999cb360c00e1b66a2d19d19812cc2b0c8a1677eec2a9579e6",
|
||||
"size_bytes": 64178056
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.10-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago-frontend-1.8.10-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.10-alpha.tar.gz",
|
||||
"new_version": "1.8.10-alpha",
|
||||
"sha256": "6b25de8a8e1a4f7fe51594f9bbbe21f5820f417af47a8b309c2dbf8f8723b719",
|
||||
"size_bytes": 97736297
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-01",
|
||||
"signature": "b69926bcb1851ff7d6a5b24519cd4a8015aab4ed4b588ee989d8ce6e3beaeb2cc0eb38078f522ded0d389fe53b7dbcdbf3f40c534b4bfafa5cf4a2ab2c59e40f",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.10-alpha"
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-gitea-release-download-links.sh - verify Gitea's public release page
|
||||
# points users at the canonical HTTPS download URLs, not an internal ROOT_URL.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-gitea-release-download-links.sh VERSION ASSET_NAME...
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-}"
|
||||
if [ -z "$VERSION" ] || [ "$#" -lt 2 ]; then
|
||||
echo "usage: $0 VERSION ASSET_NAME..." >&2
|
||||
exit 2
|
||||
fi
|
||||
shift
|
||||
|
||||
PUBLIC_BASE="${ARCHY_RELEASE_PUBLIC_BASE:-https://source.archipelago-foundation.org/lfg2025/archy}"
|
||||
page_url="$PUBLIC_BASE/releases/tag/v$VERSION"
|
||||
|
||||
command -v curl >/dev/null 2>&1 || { echo "ERROR: curl required" >&2; exit 2; }
|
||||
command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 required" >&2; exit 2; }
|
||||
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
curl -fsSL "$page_url" -o "$tmp"
|
||||
|
||||
python3 - "$tmp" "$PUBLIC_BASE" "$VERSION" "$page_url" "$@" <<'PY'
|
||||
from html.parser import HTMLParser
|
||||
from urllib.parse import quote
|
||||
import sys
|
||||
|
||||
html_path, public_base, version, page_url, *assets = sys.argv[1:]
|
||||
with open(html_path, encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
|
||||
class LinkParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.hrefs = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag.lower() != "a":
|
||||
return
|
||||
attrs = dict(attrs)
|
||||
href = attrs.get("href")
|
||||
if href:
|
||||
self.hrefs.append(href)
|
||||
|
||||
parser = LinkParser()
|
||||
parser.feed(html)
|
||||
hrefs = set(parser.hrefs)
|
||||
|
||||
bad_internal = sorted(
|
||||
h for h in hrefs
|
||||
if "/releases/download/" in h and h.startswith(("http://", "https://"))
|
||||
and not h.startswith(public_base + "/releases/download/")
|
||||
)
|
||||
|
||||
failures = []
|
||||
for asset in assets:
|
||||
expected = f"{public_base}/releases/download/v{quote(version)}/{quote(asset)}"
|
||||
if expected not in hrefs:
|
||||
matches = sorted(h for h in hrefs if h.endswith("/" + quote(asset)))
|
||||
if matches:
|
||||
failures.append(f"{asset}: expected {expected}, found {matches[0]}")
|
||||
else:
|
||||
failures.append(f"{asset}: expected {expected}, but no matching release-page link was found")
|
||||
|
||||
if bad_internal:
|
||||
failures.append("release page contains non-canonical download href(s):")
|
||||
failures.extend(f" {h}" for h in bad_internal[:10])
|
||||
|
||||
if failures:
|
||||
print(f"FAIL: public release page has broken download links: {page_url}", file=sys.stderr)
|
||||
for failure in failures:
|
||||
print(f" {failure}", file=sys.stderr)
|
||||
print(
|
||||
"Fix the Gitea public URL/proxy configuration so release links are generated "
|
||||
"from the canonical HTTPS origin, then re-run the publish check.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"OK: public release page download links use {public_base}")
|
||||
PY
|
||||
@@ -145,6 +145,11 @@ echo "Verifying public download URLs (full GET + size + sha256)..."
|
||||
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$MANIFEST" \
|
||||
|| fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run."
|
||||
|
||||
"$PROJECT_ROOT/scripts/check-gitea-release-download-links.sh" "$VERSION" \
|
||||
"archipelago" \
|
||||
"archipelago-frontend-${VERSION}.tar.gz" \
|
||||
|| fail "release page download links are not public HTTPS URLs — fix Gitea ROOT_URL/proxy configuration before publishing."
|
||||
|
||||
# Assets are proven fetchable — only now may the manifest become live. First
|
||||
# incorporate concurrent work, then promote in a dedicated commit. Until the
|
||||
# final push succeeds the remote still serves the previous manifest.
|
||||
@@ -261,4 +266,10 @@ for b in bad:
|
||||
sys.exit(1 if bad else 0)
|
||||
PY
|
||||
|
||||
"$PROJECT_ROOT/scripts/check-gitea-release-download-links.sh" "$VERSION" \
|
||||
"$ISO_NAME" \
|
||||
"$ISO_NAME.sha256" \
|
||||
"$ISO_NAME.sha256.json" \
|
||||
|| fail "ISO is uploaded but the release page links are not public HTTPS URLs — fix Gitea ROOT_URL/proxy configuration."
|
||||
|
||||
echo "ISO for v${VERSION} published and verified on $REMOTE."
|
||||
|
||||
Reference in New Issue
Block a user