Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b8535fca7 | ||
|
|
5441415a3d | ||
|
|
86426a0253 | ||
|
|
9d02517ca8 | ||
|
|
bd36b6fb3d | ||
|
|
8d6a2ba923 | ||
|
|
67e9b7d001 | ||
|
|
cb567056ff | ||
|
|
eb922f1d72 | ||
|
|
10b11d29ae | ||
|
|
89f1042b85 | ||
|
|
732ee5e714 | ||
|
|
37fde6cfa7 | ||
|
|
b19f191a92 | ||
|
|
db52c06a72 | ||
|
|
4b14b62e74 | ||
|
|
5da91e4099 | ||
|
|
62731cc729 | ||
|
|
5e17ace690 | ||
|
|
b010471a4a | ||
|
|
c4ede96517 | ||
|
|
be06e1a502 | ||
|
|
094f42312c | ||
|
|
da8c3ec193 | ||
|
|
4fdf8e8c58 | ||
|
|
61b5d93b11 | ||
|
|
be06b3ce2b | ||
|
|
f3d96ae2ee | ||
|
|
a4ae375617 | ||
|
|
0646bc4e85 | ||
|
|
0faaf4577f | ||
|
|
f9a1ef031c | ||
|
|
cf240df4b6 | ||
|
|
d8320896c4 | ||
|
|
b87f1f0612 | ||
|
|
1ca002661b | ||
|
|
0d0e2e243a | ||
|
|
9c49b502e3 | ||
|
|
d68a013e35 | ||
|
|
1464b1b24d | ||
|
|
82001403b4 |
@@ -1,5 +1,21 @@
|
||||
# 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.
|
||||
|
||||
- **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.
|
||||
|
||||
## v1.8.9-alpha (2026-09-01)
|
||||
|
||||
- **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.
|
||||
@@ -14,6 +30,8 @@
|
||||
|
||||
- **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.
|
||||
|
||||
- **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). 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.
|
||||
|
||||
## v1.8.8-alpha (2026-09-01)
|
||||
|
||||
- **SSH over the mesh is now a first-class setting.** Settings gains an "SSH over mesh" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit "I understand" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the `90-ssh.nft` drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, `fipssh <user>@<node-npub>` connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
app:
|
||||
id: cuprate-ui
|
||||
name: Cuprate UI
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: |
|
||||
Archipelago-native HTTP frontend for the Cuprate Monero node. Runs nginx
|
||||
inside a container, serves a static status dashboard, and proxies
|
||||
/cuprate-rpc/ to the cuprate restricted RPC on 127.0.0.1:18090 (the
|
||||
published host port for the container's 18089). No credentials are
|
||||
injected — the restricted RPC is Monero's own safe-for-public subset — so
|
||||
the nginx.conf is baked into the image and there is no rendered-config
|
||||
bind-mount like bitcoin-ui's.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/cuprate-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/cuprate-ui:local
|
||||
|
||||
dependencies:
|
||||
- app_id: cuprate
|
||||
|
||||
resources:
|
||||
memory_limit: 64Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 18091 directly on the host IP.
|
||||
# Declared so the APP GATE can see this port. Host networking means Podman
|
||||
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
|
||||
# is a statement of where the container's own nginx listens — 127.0.0.1 —
|
||||
# not a publish instruction. Without this declaration the gate would have no
|
||||
# idea the port existed: neither protected nor listed as unprotected.
|
||||
ports:
|
||||
- host: 18091
|
||||
container: 18091
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:18091
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/cuprate.svg
|
||||
category: money
|
||||
tier: optional
|
||||
author: Archipelago
|
||||
repo: https://github.com/Cuprate/cuprate
|
||||
+58
-21
@@ -36,17 +36,38 @@ app:
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
|
||||
# month; cuprated's pruning support is not confirmed stable yet (the
|
||||
# `pruning` crate exists in the workspace but nothing in this config
|
||||
# surface toggles it), so this sizes for a full unpruned chain plus
|
||||
# headroom rather than assuming pruning is available.
|
||||
- storage: 300Gi
|
||||
# Monero mainnet is ~250GiB unpruned as of 2026 and growing ~60GiB/year.
|
||||
# Verified against upstream main (binaries/cuprated/src/config.rs, 2026-09):
|
||||
# cuprated has NO on-disk pruning setting of any kind — the `pruning`
|
||||
# crate in its workspace is Monero's p2p *protocol* pruning, not a
|
||||
# smaller chain — so unlike bitcoin-knots this app CANNOT self-prune
|
||||
# when disk is scarce (see the DISK_GB branch in
|
||||
# apps/bitcoin-knots/manifest.yml). Left running on a too-small disk it
|
||||
# syncs until the filesystem fills and takes Archipelago down. The
|
||||
# disk-scarce equivalent is enforced in Rust instead: install, start,
|
||||
# restart and update refuse, and boot reconcile skips, on any node under
|
||||
# CUPRATE_MIN_DISK_GB (450GB — chain + headroom; refuses the 250GB VPS
|
||||
# class, allows 500GB-class disks). If upstream ever ships a prune flag,
|
||||
# replace that gate with the bitcoin-style entrypoint branch.
|
||||
#
|
||||
# 450Gi, not the chain size (~250GiB): every manifest-driven surface
|
||||
# (store size display, install pre-checks, docs) must show the number the
|
||||
# Rust gate actually enforces, or a user provisioned to the displayed
|
||||
# value gets refused at a different, unexplained one. Single source of
|
||||
# truth is crate::constants::CUPRATE_MIN_DISK_GB — keep in lockstep.
|
||||
- storage: 450Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 300Gi
|
||||
# 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
|
||||
# Matches the storage dependency above (= the enforced disk floor),
|
||||
# not the raw chain size — see the CUPRATE_MIN_DISK_GB note.
|
||||
disk_limit: 450Gi
|
||||
|
||||
security:
|
||||
# FROM scratch, no package manager/shell, ownership fixed at build time
|
||||
@@ -82,17 +103,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 +128,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 +165,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.9-alpha"
|
||||
version = "1.8.11-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.9-alpha"
|
||||
version = "1.8.11-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -42,6 +42,21 @@ fn json_i64(value: &serde_json::Value, key: &str) -> Option<i64> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Fee budget for a send, matching lncli's own default: the payment amount
|
||||
/// (100%). Zero-amount invoices take the payer-supplied amount; fixed invoices
|
||||
/// take the invoice's own amount. Falls back to a nominal 1,000 sats only when
|
||||
/// both are somehow absent — the limit must never be left at LND's zero
|
||||
/// default, which rejects every fee-carrying route as "no route".
|
||||
fn fee_limit_sats(amount_sats: Option<u64>, decoded_amt: i64) -> i64 {
|
||||
if let Some(amt) = amount_sats {
|
||||
return amt as i64;
|
||||
}
|
||||
if decoded_amt > 0 {
|
||||
return decoded_amt;
|
||||
}
|
||||
1_000
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Pay a Lightning invoice.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(
|
||||
@@ -107,6 +122,14 @@ impl RpcHandler {
|
||||
// enough, and it makes grpc-gateway's response a single JSON value.
|
||||
"no_inflight_updates": true,
|
||||
"timeout_seconds": 120,
|
||||
// Router.SendPaymentV2 treats an ABSENT fee limit as ZERO — every
|
||||
// real route carries a routing fee, so the pathfinder rejects
|
||||
// them all and the wallet gets "No route to the recipient" on
|
||||
// every send (fleet-wide, 2026-09-01: the v1.8.9 switch to the v2
|
||||
// route shipped without this, and a manual lncli test that set
|
||||
// --fee_limit masked it). lncli's own default is the payment
|
||||
// amount (100%), which is what we send here.
|
||||
"fee_limit_sat": fee_limit_sats(amount_sats, decoded_amt),
|
||||
});
|
||||
if let Some(amt) = amount_sats {
|
||||
pay_body["amt"] = serde_json::json!(amt.to_string());
|
||||
@@ -550,4 +573,15 @@ mod tests {
|
||||
"Insufficient channel balance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fee_limit_never_falls_back_to_zero() {
|
||||
// SendPaymentV2 defaults an ABSENT fee limit to zero — which rejects
|
||||
// every fee-carrying route as "no route". The budget must always be
|
||||
// positive: the payer-supplied amount for zero-amount invoices, the
|
||||
// invoice's own amount otherwise.
|
||||
assert_eq!(fee_limit_sats(Some(20_000), 0), 20_000);
|
||||
assert_eq!(fee_limit_sats(None, 20_000), 20_000);
|
||||
assert_eq!(fee_limit_sats(None, 0), 1_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -55,6 +55,7 @@ impl RpcHandler {
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
|
||||
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
|
||||
|
||||
// Reject if already in a transitional lifecycle (prevents double-click
|
||||
// queuing two installs on the same package).
|
||||
@@ -294,6 +295,12 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
// Update is stop → pull → remove → recreate, i.e. a fresh start by
|
||||
// another name: on a disk that shrank since install it would resume
|
||||
// cuprate's unprunable sync unchecked. Same gate as install and
|
||||
// start, run BEFORE the Updating flip so a refusal leaves the app
|
||||
// cleanly in its previous state.
|
||||
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
|
||||
|
||||
// Reject if already in a transitional lifecycle.
|
||||
{
|
||||
|
||||
@@ -670,6 +670,50 @@ async fn detect_disk_gb() -> u64 {
|
||||
.unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Smallest disk (GB, total) a cuprate node can live on. The value and its
|
||||
/// rationale live in ONE place — `crate::constants::CUPRATE_MIN_DISK_GB` —
|
||||
/// shared with the boot reconciler so install/start and boot can never
|
||||
/// disagree about where cuprate may run.
|
||||
use crate::constants::CUPRATE_MIN_DISK_GB;
|
||||
|
||||
/// The bitcoin apps pick `-prune` automatically when disk is scarce, because
|
||||
/// bitcoind supports pruning. Cuprate CANNOT: upstream has no pruning config
|
||||
/// at all (the `pruning` crate in its workspace is Monero's p2p *protocol*
|
||||
/// pruning, not on-disk pruning), so the disk-scarce equivalent is to refuse
|
||||
/// to run cuprate at all rather than let it sync until the filesystem fills —
|
||||
/// which took Archipelago itself down on nodes with too little disk.
|
||||
fn cuprate_insufficient_disk_message(disk_gb: u64) -> String {
|
||||
format!(
|
||||
"Cuprate needs a disk of at least {} GB and this node has {} GB. \
|
||||
A Monero node cannot run pruned — upstream cuprate has no pruning \
|
||||
support — so the chain (~250 GB and growing) would fill the disk and \
|
||||
take Archipelago down with it. Attach a larger disk (or move \
|
||||
/var/lib/archipelago to one) and try again. Bitcoin apps CAN run \
|
||||
pruned on smaller disks; Monero currently cannot.",
|
||||
CUPRATE_MIN_DISK_GB, disk_gb
|
||||
)
|
||||
}
|
||||
|
||||
/// Pure decision half of the cuprate disk gate — testable without df.
|
||||
pub(super) fn cuprate_disk_gate(disk_gb: u64) -> Option<String> {
|
||||
(disk_gb < CUPRATE_MIN_DISK_GB).then(|| cuprate_insufficient_disk_message(disk_gb))
|
||||
}
|
||||
|
||||
/// Install/start-time pre-check: refuse cuprate on disks too small to hold
|
||||
/// the Monero chain. Mirrors `check_bitcoin_pruning_compatibility`'s
|
||||
/// fail-open-on-unknown-disk behaviour (`detect_disk_gb` returns u64::MAX
|
||||
/// when df fails, so an unreadable disk never blocks an install).
|
||||
pub(super) async fn check_cuprate_disk_compatibility(package_id: &str) -> Result<()> {
|
||||
if package_id != "cuprate" {
|
||||
return Ok(());
|
||||
}
|
||||
let disk_gb = detect_disk_gb().await;
|
||||
if let Some(message) = cuprate_disk_gate(disk_gb) {
|
||||
anyhow::bail!(message);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Log informational messages about optional dependencies.
|
||||
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
|
||||
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
|
||||
@@ -873,9 +917,9 @@ pub(super) fn configure_fedimint_lnd(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
bitcoin_is_warming_up, dependency_list_declares_archival_bitcoin,
|
||||
bitcoin_is_warming_up, cuprate_disk_gate, dependency_list_declares_archival_bitcoin,
|
||||
manifest_declares_archival_bitcoin, order_present_containers, requires_unpruned_bitcoin,
|
||||
startup_order, BITCOIN_WARMUP_BUDGET,
|
||||
startup_order, BITCOIN_WARMUP_BUDGET, CUPRATE_MIN_DISK_GB,
|
||||
};
|
||||
use archipelago_container::Dependency;
|
||||
|
||||
@@ -1017,6 +1061,37 @@ mod tests {
|
||||
assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuprate_disk_gate_refuses_disks_too_small_for_the_monero_chain() {
|
||||
// 250 GB VPS class: the ~250 GiB chain does not fit, full stop.
|
||||
assert!(cuprate_disk_gate(0).is_some());
|
||||
assert!(cuprate_disk_gate(250).is_some());
|
||||
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB - 1).is_some());
|
||||
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB).is_none());
|
||||
assert!(cuprate_disk_gate(1000).is_none());
|
||||
// df failure reads as u64::MAX — an unreadable disk must not block.
|
||||
assert!(cuprate_disk_gate(u64::MAX).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuprate_disk_gate_message_names_the_fix_not_just_the_problem() {
|
||||
let msg = cuprate_disk_gate(250).expect("250 GB must be refused");
|
||||
assert!(msg.contains("cannot run pruned"), "{msg}");
|
||||
assert!(msg.contains("larger disk"), "{msg}");
|
||||
assert!(msg.contains("250 GB"), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cuprate_disk_gate_only_applies_to_cuprate() {
|
||||
// Every other package passes regardless of disk — including the
|
||||
// bitcoin apps, which self-prune via their manifest entrypoint.
|
||||
for package_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
|
||||
super::check_cuprate_disk_compatibility(package_id)
|
||||
.await
|
||||
.expect("non-cuprate installs must not be gated here");
|
||||
}
|
||||
}
|
||||
|
||||
mod dep_wait {
|
||||
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
@@ -3,10 +3,10 @@ use super::config::{
|
||||
is_readonly_compatible, is_valid_docker_image,
|
||||
};
|
||||
use super::dependencies::{
|
||||
check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers,
|
||||
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info,
|
||||
needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL,
|
||||
DEP_WAIT_MAX_ATTEMPTS,
|
||||
check_bitcoin_pruning_compatibility, check_cuprate_disk_compatibility, configure_fedimint_lnd,
|
||||
detect_existing_containers, detect_running_deps, detect_running_deps_from_package_data,
|
||||
log_optional_dep_info, needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps,
|
||||
DEP_WAIT_INTERVAL, DEP_WAIT_MAX_ATTEMPTS,
|
||||
};
|
||||
use super::progress::parse_pull_progress;
|
||||
use super::validation::validate_app_id;
|
||||
@@ -306,6 +306,7 @@ impl RpcHandler {
|
||||
// failing instantly.
|
||||
let deps = self.gate_install_deps(package_id).await?;
|
||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||
check_cuprate_disk_compatibility(package_id).await?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
// Materialise the RPC password file before any install path
|
||||
|
||||
@@ -60,6 +60,12 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
// A cuprate node that starts on a too-small disk fills it and takes
|
||||
// Archipelago down with it (no upstream pruning — see
|
||||
// dependencies::check_cuprate_disk_compatibility). Fail the start
|
||||
// before clearing user-stopped or flipping state, so the app stays
|
||||
// cleanly stopped and the error carries the actionable message.
|
||||
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
|
||||
|
||||
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
|
||||
vec![orchestrator_app_id(package_id).to_string()]
|
||||
@@ -251,6 +257,11 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
// Restart is stop + recreate, so on a disk that shrank below the cuprate
|
||||
// minimum after install it resumes the doomed unprunable sync just like
|
||||
// start would — same gate, same "fail before clearing user-stopped /
|
||||
// flipping state" contract (see handle_package_start).
|
||||
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
|
||||
|
||||
let single_orchestrator_app =
|
||||
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
|
||||
|
||||
@@ -9,3 +9,19 @@ pub const DWN_HEALTH_URL: &str = "http://127.0.0.1:3100/health";
|
||||
|
||||
/// Tor SOCKS5 proxy for outbound onion connections.
|
||||
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
|
||||
|
||||
/// Smallest disk (GB, total) a cuprate node may be installed, started,
|
||||
/// restarted, updated, or boot-reconciled onto. Cuprate has no on-disk
|
||||
/// pruning (verified against upstream `cuprated/src/config.rs` — the
|
||||
/// `pruning` crate is Monero's p2p protocol pruning), so unlike the bitcoin
|
||||
/// apps it cannot self-shrink on a scarce disk; below this line the ~250 GiB
|
||||
/// Monero chain simply does not fit and running it would fill the filesystem
|
||||
/// and take Archipelago down. 450 = chain + growth/headroom: allows
|
||||
/// 500 GB-class disks, refuses the 250 GB VPS class.
|
||||
///
|
||||
/// SINGLE SOURCE OF TRUTH — the RPC gates
|
||||
/// (`api::rpc::package::dependencies`) and the boot reconciler
|
||||
/// (`container::prod_orchestrator`) both read this; a drift between them
|
||||
/// would silently reopen the disk-fill failure the gate exists to close.
|
||||
/// Keep `apps/cuprate/manifest.yml` (storage dependency + comments) aligned.
|
||||
pub const CUPRATE_MIN_DISK_GB: u64 = 450;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! | lnd | archy-lnd-ui | wallet/channel UI |
|
||||
//! | electrumx | archy-electrs-ui | indexer status UI |
|
||||
//! | fedimint | archy-fedimint-ui | wait/proxy Guardian UI |
|
||||
//! | cuprate | archy-cuprate-ui | Monero node status UI |
|
||||
//!
|
||||
//! Lifecycle: `install` writes a Quadlet `.container` unit to
|
||||
//! `~/.config/containers/systemd/`, daemon-reloads, then starts the
|
||||
@@ -97,6 +98,7 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
||||
"lnd" => LND_UI,
|
||||
"electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI,
|
||||
"fedimint" | "fedimintd" => FEDIMINT_UI,
|
||||
"cuprate" => CUPRATE_UI,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
@@ -104,7 +106,8 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
||||
/// Every companion this build knows how to provision. Kept beside
|
||||
/// `companions_for` — a new companion must be added to both, or the reaper
|
||||
/// will not recognise it as one of ours and will leave it running forever.
|
||||
const ALL_COMPANIONS: &[&[CompanionSpec]] = &[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI];
|
||||
const ALL_COMPANIONS: &[&[CompanionSpec]] =
|
||||
&[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI, CUPRATE_UI];
|
||||
|
||||
const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-bitcoin-ui",
|
||||
@@ -172,6 +175,24 @@ const FEDIMINT_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
const CUPRATE_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-cuprate-ui",
|
||||
image_base: "cuprate-ui",
|
||||
build_dir_candidates: &[
|
||||
"/opt/archipelago/docker/cuprate-ui",
|
||||
"/home/archipelago/archy/docker/cuprate-ui",
|
||||
"/home/archipelago/Projects/archy/docker/cuprate-ui",
|
||||
],
|
||||
// No pre-start hook and no bind mounts: unlike bitcoin-ui there is no
|
||||
// secret to inject. Cuprate's restricted RPC (the only thing this UI
|
||||
// proxies) is unauthenticated by design — Monero's safe-for-public
|
||||
// subset — so the nginx.conf is baked into the image.
|
||||
pre_start: None,
|
||||
bind_mounts: &[],
|
||||
ports: &[],
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> {
|
||||
Box::pin(async {
|
||||
let paths = crate::container::bitcoin_ui::RenderPaths::default();
|
||||
@@ -869,6 +890,7 @@ mod tests {
|
||||
"mempool-electrs",
|
||||
"fedimint",
|
||||
"fedimintd",
|
||||
"cuprate",
|
||||
];
|
||||
let known: std::collections::HashSet<&str> = ALL_COMPANIONS
|
||||
.iter()
|
||||
@@ -893,6 +915,7 @@ mod tests {
|
||||
names(&orphan_companions(&[])),
|
||||
vec![
|
||||
"archy-bitcoin-ui",
|
||||
"archy-cuprate-ui",
|
||||
"archy-electrs-ui",
|
||||
"archy-fedimint-ui",
|
||||
"archy-lnd-ui"
|
||||
@@ -906,7 +929,10 @@ mod tests {
|
||||
// electrumx installed, fedimint and lnd not — yet all four companions
|
||||
// were running because the reconciler was fed the manifest list.
|
||||
let orphans = orphan_companions(&ids(&["bitcoin-knots", "electrumx"]));
|
||||
assert_eq!(names(&orphans), vec!["archy-fedimint-ui", "archy-lnd-ui"]);
|
||||
assert_eq!(
|
||||
names(&orphans),
|
||||
vec!["archy-cuprate-ui", "archy-fedimint-ui", "archy-lnd-ui"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -926,12 +952,18 @@ mod tests {
|
||||
#[test]
|
||||
fn apps_without_companions_orphan_everything_and_panic_nothing() {
|
||||
let orphans = orphan_companions(&ids(&["nextcloud", "not-a-real-app"]));
|
||||
assert_eq!(orphans.len(), 4);
|
||||
assert_eq!(orphans.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_backend_installed_leaves_no_orphans() {
|
||||
let orphans = orphan_companions(&ids(&["bitcoin-knots", "lnd", "electrumx", "fedimint"]));
|
||||
let orphans = orphan_companions(&ids(&[
|
||||
"bitcoin-knots",
|
||||
"lnd",
|
||||
"electrumx",
|
||||
"fedimint",
|
||||
"cuprate",
|
||||
]));
|
||||
assert!(
|
||||
names(&orphans).is_empty(),
|
||||
"unexpected orphans: {:?}",
|
||||
@@ -970,7 +1002,12 @@ mod tests {
|
||||
let due = due_after_grace(orphans, &names_seen, &mut since, start + ORPHAN_GRACE);
|
||||
assert_eq!(
|
||||
names(&due),
|
||||
vec!["archy-electrs-ui", "archy-fedimint-ui", "archy-lnd-ui"]
|
||||
vec![
|
||||
"archy-cuprate-ui",
|
||||
"archy-electrs-ui",
|
||||
"archy-fedimint-ui",
|
||||
"archy-lnd-ui"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1024,6 +1061,7 @@ mod tests {
|
||||
assert_eq!(companions_for("mempool-electrs").len(), 1);
|
||||
assert_eq!(companions_for("fedimint").len(), 1);
|
||||
assert_eq!(companions_for("fedimintd").len(), 1);
|
||||
assert_eq!(companions_for("cuprate").len(), 1);
|
||||
assert_eq!(companions_for("nextcloud").len(), 0);
|
||||
assert_eq!(companions_for("not-a-real-app").len(), 0);
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
||||
"bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"),
|
||||
"lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"),
|
||||
"electrs-ui" | "archy-electrs-ui" => Some("ELECTRS_UI_IMAGE"),
|
||||
"cuprate-ui" | "archy-cuprate-ui" => Some("CUPRATE_UI_IMAGE"),
|
||||
|
||||
// Mempool stack (primary = web)
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
|
||||
|
||||
@@ -131,6 +131,10 @@ const LND_STATE_DIRS: &[&str] = &[
|
||||
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
|
||||
const LND_CONTAINER: &str = "lnd";
|
||||
|
||||
/// Canonical on-host admin macaroon — same path the RPC layer reads.
|
||||
const LND_ADMIN_MACAROON: &str =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
/// Archipelago data dir (default; not overridden in prod). Holds the
|
||||
/// `user-stopped.json` that gates health-monitor auto-restart.
|
||||
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
||||
@@ -872,6 +876,188 @@ fn cert_sha256_thumbprint(pem: &str) -> Result<String> {
|
||||
Ok(hex::encode_upper(Sha256::digest(&der)))
|
||||
}
|
||||
|
||||
// ── Channel-peer watchdog ──────────────────────────────────────────────────
|
||||
|
||||
/// Every open channel's remote peer that is NOT currently connected.
|
||||
/// Pure over LND's REST JSON so the selection can be unit-tested.
|
||||
///
|
||||
/// `/v1/peers` uses `pub_key`; `/v1/channels` uses `remote_pubkey` — the
|
||||
/// asymmetry is LND's, not ours.
|
||||
fn select_reconnect_targets(
|
||||
channels: &serde_json::Value,
|
||||
peers: &serde_json::Value,
|
||||
) -> Vec<String> {
|
||||
let connected: std::collections::HashSet<&str> = peers
|
||||
.get("peers")
|
||||
.and_then(|p| p.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|p| p.get("pub_key").and_then(|v| v.as_str()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut targets: Vec<String> = channels
|
||||
.get("channels")
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|c| c.get("remote_pubkey").and_then(|v| v.as_str()))
|
||||
.filter(|pk| !connected.contains(pk))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
targets.sort();
|
||||
targets.dedup();
|
||||
targets
|
||||
}
|
||||
|
||||
/// Reconnect peers of open channels that LND has not re-established on its
|
||||
/// own. Returns the number of peers reconnected this pass.
|
||||
///
|
||||
/// LND normally reconnects channel peers after a restart — but not reliably:
|
||||
/// when the restart outages are long or repeated (an app update, a node
|
||||
/// reboot, reconciler churn), the peer link can stay down for hours while
|
||||
/// BOTH endpoints keep flagging the channel `disabled` in the routing
|
||||
/// graph. The node itself looks perfectly healthy and every payment in
|
||||
/// either direction fails "no route to the recipient" — observed live on
|
||||
/// framework-pt (2026-09-01): its only channel sat disabled on both policy
|
||||
/// sides for ~17h after the LND 0.21.2 update, while the wallet showed
|
||||
/// plenty of outbound. The channel graph is desired state; this keeps it.
|
||||
///
|
||||
/// Quietly returns Ok(0) when LND is not installed or its wallet is locked —
|
||||
/// that is every node without LND, on every pass.
|
||||
///
|
||||
/// `last_attempt` throttles retries per peer (`min_retry`) so an unreachable
|
||||
/// peer is not hammered every pass; the caller owns the map so the pass
|
||||
/// itself stays stateless and testable.
|
||||
pub(crate) async fn reconnect_disconnected_channel_peers(
|
||||
last_attempt: &mut std::collections::HashMap<String, std::time::Instant>,
|
||||
min_retry: std::time::Duration,
|
||||
) -> Result<usize> {
|
||||
let Ok(macaroon) = read_file_as_root(LND_ADMIN_MACAROON).await else {
|
||||
return Ok(0); // LND not installed (or not initialized yet)
|
||||
};
|
||||
let macaroon_hex = hex::encode(macaroon);
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client for the channel-peer watchdog")?;
|
||||
|
||||
let channels: serde_json::Value = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST: listing channels for the peer watchdog")?
|
||||
.json()
|
||||
.await
|
||||
.context("parsing LND channel list")?;
|
||||
// A locked wallet answers 503 with an error body — it parses as JSON
|
||||
// with no "channels" key, which selects nothing. That is a quiet pass.
|
||||
let peers: serde_json::Value = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST: listing peers for the peer watchdog")?
|
||||
.json()
|
||||
.await
|
||||
.context("parsing LND peer list")?;
|
||||
|
||||
let mut reconnected = 0usize;
|
||||
for pubkey in select_reconnect_targets(&channels, &peers) {
|
||||
if last_attempt
|
||||
.get(&pubkey)
|
||||
.is_some_and(|t| t.elapsed() < min_retry)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
last_attempt.insert(pubkey.clone(), std::time::Instant::now());
|
||||
|
||||
// Where does the peer live? Its advertised addresses in the public
|
||||
// graph. A peer with none (fully private) cannot be dialed from here
|
||||
// — LND itself may still find it; we only log the gap once per pass.
|
||||
// Unknown to the public graph (or the graph query failed) — nothing
|
||||
// to dial on.
|
||||
let Ok(node) = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/graph/node/{pubkey}"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(node) = node.json::<serde_json::Value>().await else {
|
||||
continue;
|
||||
};
|
||||
let addresses: Vec<String> = node
|
||||
.get("node")
|
||||
.and_then(|n| n.get("addresses"))
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|a| a.get("addr").and_then(|v| v.as_str()))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if addresses.is_empty() {
|
||||
tracing::warn!(
|
||||
peer = %pubkey,
|
||||
"LND channel peer is disconnected and advertises no address — cannot dial it; payments through this channel stay unroutable"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for addr in addresses {
|
||||
let Some((host, port)) = addr.rsplit_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let Ok(port) = port.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
let body = serde_json::json!({
|
||||
"perm": false,
|
||||
"timeout": "15s",
|
||||
"addr": { "pubkey": pubkey, "host": host, "port": port },
|
||||
});
|
||||
match client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
reconnected += 1;
|
||||
tracing::info!(
|
||||
peer = %pubkey,
|
||||
addr = %addr,
|
||||
"reconnected a disconnected channel peer (channel was unroutable)"
|
||||
);
|
||||
break;
|
||||
}
|
||||
Ok(resp) => {
|
||||
let msg = resp.text().await.unwrap_or_default();
|
||||
// Already connected between our list call and now — success.
|
||||
if msg.contains("already connected") {
|
||||
break;
|
||||
}
|
||||
tracing::debug!(peer = %pubkey, addr = %addr, %msg, "channel-peer connect attempt failed");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(peer = %pubkey, addr = %addr, error = %e, "channel-peer connect attempt failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(reconnected)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -985,4 +1171,35 @@ mod tests {
|
||||
let cands = unlock_password_candidates().await;
|
||||
assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_targets_pick_disconnected_channel_peers_only() {
|
||||
// Shape captured from a live node: /v1/channels uses remote_pubkey,
|
||||
// /v1/peers uses pub_key, and an offline channel's peer is simply
|
||||
// absent from the peer list — that absence is the whole signal.
|
||||
let channels = serde_json::json!({
|
||||
"channels": [
|
||||
{ "remote_pubkey": "AAA", "active": true },
|
||||
{ "remote_pubkey": "BBB", "active": false },
|
||||
{ "remote_pubkey": "AAA" }
|
||||
]
|
||||
});
|
||||
let peers = serde_json::json!({ "peers": [ { "pub_key": "AAA" } ] });
|
||||
|
||||
let targets = select_reconnect_targets(&channels, &peers);
|
||||
assert_eq!(targets, vec!["BBB".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_targets_empty_without_channels_or_peers() {
|
||||
// No LND wallet (503 error body), locked wallet, or an empty node:
|
||||
// selects nothing, quietly.
|
||||
let error_body = serde_json::json!({ "message": "locked" });
|
||||
assert!(select_reconnect_targets(&error_body, &serde_json::json!({})).is_empty());
|
||||
assert!(select_reconnect_targets(
|
||||
&serde_json::json!({ "channels": [] }),
|
||||
&serde_json::json!({ "peers": [] })
|
||||
)
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,17 @@ use crate::update::host_sudo;
|
||||
///
|
||||
/// Keep in sync with the running fixture on .116. Centralized as a constant
|
||||
/// so the rule is visible in one place and unit-testable.
|
||||
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"];
|
||||
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui", "cuprate-ui"];
|
||||
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
|
||||
// The cuprate disk floor is `crate::constants::CUPRATE_MIN_DISK_GB` — one
|
||||
// value shared with the install/start/restart/update RPC gates so boot
|
||||
// reconcile can never resume below the line they refuse at.
|
||||
|
||||
use crate::constants::CUPRATE_MIN_DISK_GB;
|
||||
|
||||
fn requires_cuprate_disk(app_id: &str, disk_gb: u64) -> bool {
|
||||
app_id == "cuprate" && disk_gb < CUPRATE_MIN_DISK_GB
|
||||
}
|
||||
|
||||
/// Apps expected to exist from first boot on every node — the ONLY apps the
|
||||
/// boot reconciler may install from nothing. Every other app needs
|
||||
@@ -1944,6 +1953,23 @@ impl ProdContainerOrchestrator {
|
||||
crate::crash_recovery::pending_boot_start_done(&container_name);
|
||||
continue;
|
||||
}
|
||||
// Same shape as the archival-bitcoin skip above: recorded BEFORE
|
||||
// ensure_running_with_mode, so the "absent" desired-state recovery
|
||||
// below can never fire on this reason and undo it.
|
||||
if mode == ReconcileMode::ExistingOnly && requires_cuprate_disk(&app_id, disk_gb) {
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
disk_gb,
|
||||
"cuprate needs a larger disk (no pruning support) — skipping start"
|
||||
);
|
||||
report.record(
|
||||
&app_id,
|
||||
ReconcileAction::Left("cuprate-insufficient-disk".into()),
|
||||
);
|
||||
crate::crash_recovery::pending_boot_start_done(&app_id);
|
||||
crate::crash_recovery::pending_boot_start_done(&container_name);
|
||||
continue;
|
||||
}
|
||||
match self.ensure_running_with_mode(&lm, mode).await {
|
||||
// Desired-state recovery: the app has no container and was left
|
||||
// "absent" by boot reconcile, BUT it was running at the last
|
||||
@@ -5312,6 +5338,27 @@ app:
|
||||
assert_eq!(compute_container_name(&m), "archy-electrs-ui");
|
||||
let m = pull_manifest("lnd-ui", "foo:1");
|
||||
assert_eq!(compute_container_name(&m), "archy-lnd-ui");
|
||||
let m = pull_manifest("cuprate-ui", "foo:1");
|
||||
assert_eq!(compute_container_name(&m), "archy-cuprate-ui");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuprate_disk_gate_blocks_only_cuprate_on_small_disks() {
|
||||
// 250 GB VPS class: the ~250 GiB Monero chain cannot fit and cuprate
|
||||
// has no pruning — boot reconcile must leave it down.
|
||||
assert!(requires_cuprate_disk("cuprate", 250));
|
||||
assert!(requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB - 1));
|
||||
assert!(!requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB));
|
||||
assert!(!requires_cuprate_disk("cuprate", 1000));
|
||||
// df failure in detect_disk_gb reads as 0 → fail closed at boot: a
|
||||
// doomed sync is worse than a node that stays down until it can
|
||||
// measure (same direction as the archival-bitcoin skip).
|
||||
assert!(requires_cuprate_disk("cuprate", 0));
|
||||
// Nothing else is gated here: bitcoin apps self-prune, everything
|
||||
// else is irrelevant to the Monero chain.
|
||||
for app_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
|
||||
assert!(!requires_cuprate_disk(app_id, 0), "{app_id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283, 2342, 3000, 3001, 3002, 3030, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087,
|
||||
8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
|
||||
18081, 18083, 23000, 32838, 50002,
|
||||
18081, 18083, 18091, 23000, 32838, 50002,
|
||||
];
|
||||
|
||||
@@ -53,8 +53,8 @@ fn container_tier(name: &str) -> StartupTier {
|
||||
| "indeedhub-api" => StartupTier::DependentService,
|
||||
|
||||
// Tier 4: Frontend/UI
|
||||
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "penpot-frontend"
|
||||
| "penpot-exporter" | "indeedhub" => StartupTier::Frontend,
|
||||
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "cuprate-ui"
|
||||
| "penpot-frontend" | "penpot-exporter" | "indeedhub" => StartupTier::Frontend,
|
||||
|
||||
// Tier 3: Application layer (everything else)
|
||||
_ => StartupTier::Application,
|
||||
|
||||
@@ -841,6 +841,37 @@ impl Server {
|
||||
});
|
||||
}
|
||||
|
||||
// LND channel-peer watchdog — every 2 minutes, reconnect the peers
|
||||
// of open channels that LND has not re-established on its own. LND's
|
||||
// reconnect logic gives up with a long backoff after repeated or
|
||||
// extended downtime (an app update, a reboot, reconciler churn), and
|
||||
// while the peer link is down BOTH endpoints keep the channel flagged
|
||||
// `disabled` in the routing graph — payments fail "no route" in both
|
||||
// directions while the node itself looks perfectly healthy. The
|
||||
// channel graph is desired state; this keeps it (framework-pt,
|
||||
// 2026-09-01: only channel unroutable ~17h after the 0.21.2 update).
|
||||
// No-ops quietly on nodes without LND. Per-peer retries are throttled
|
||||
// to 10 minutes so an unreachable peer is not hammered every pass.
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(120));
|
||||
let mut last_attempt: HashMap<String, Instant> = HashMap::new();
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match crate::container::lnd::reconnect_disconnected_channel_peers(
|
||||
&mut last_attempt,
|
||||
Duration::from_secs(600),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(0) => {}
|
||||
Ok(n) => info!(n, "LND channel-peer watchdog reconnected channel peers"),
|
||||
Err(e) => debug!("LND channel-peer watchdog (non-fatal): {}", e),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// FIPS seed-anchor apply loop — every 5 minutes we re-push the
|
||||
// configured seed anchors into the running fips daemon via
|
||||
// `fipsctl connect`. This keeps the mesh bootstrap resilient:
|
||||
|
||||
+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 '{}'. \
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Error</title>
|
||||
<style>
|
||||
html { color-scheme: light dark; }
|
||||
body { width: 35em; margin: 0 auto;
|
||||
font-family: Tahoma, Verdana, Arial, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>An error occurred.</h1>
|
||||
<p>Sorry, the page you are looking for is currently unavailable.<br/>
|
||||
Please try again later.</p>
|
||||
<p>If you are the system administrator of this resource then you should check
|
||||
the error log for details.</p>
|
||||
<p><em>Faithfully yours, nginx.</em></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
# Static site content.
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY 50x.html /usr/share/nginx/html/
|
||||
COPY assets/ /usr/share/nginx/html/assets/
|
||||
# Unlike bitcoin-ui, the nginx.conf is baked into the image, not
|
||||
# bind-mounted: there is no secret to render in. Cuprate's restricted RPC
|
||||
# (the only upstream this UI proxies) is unauthenticated by design —
|
||||
# Monero's safe-for-public subset — so there is nothing to substitute at
|
||||
# start time and no rotation to follow.
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
#
|
||||
# Run nginx as root to avoid chown failures in rootless Podman user
|
||||
# namespaces. The rest of the nginx image is unchanged.
|
||||
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
|
||||
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
|
||||
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
|
||||
/var/cache/nginx/scgi_temp
|
||||
EXPOSE 18091
|
||||
ENTRYPOINT []
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 869 KiB |
@@ -0,0 +1,407 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>Cuprate Node - Archipelago</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
|
||||
min-height: 100vh;
|
||||
background: #000;
|
||||
color: white;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.bg-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -10;
|
||||
background-image: url('assets/img/bg-network.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
opacity: .42;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.60);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-button:hover { color: white; background-color: rgba(0, 0, 0, 0.7); }
|
||||
|
||||
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem 3rem; }
|
||||
|
||||
/* Match the Bitcoin dashboard's app-header rhythm: identity on the
|
||||
left, compact live status cards on the right. */
|
||||
.app-header { display:flex; align-items:center; justify-content:space-between; gap:1.5rem; flex-wrap:wrap; }
|
||||
.app-header-id { display:flex; align-items:center; gap:1rem; min-width:0; flex:1 1 auto; }
|
||||
.app-header-actions { display:flex; align-items:center; justify-content:flex-end; gap:.75rem; flex:0 0 auto; flex-wrap:nowrap; margin-left:auto; }
|
||||
.info-card { display:flex; align-items:center; gap:.75rem; background:rgba(0,0,0,.6); backdrop-filter:blur(24px); border-radius:.75rem; padding:.65rem .85rem; box-shadow:inset 0 1px 0 rgba(255,255,255,.16); min-height:3.25rem; }
|
||||
.info-card .card-icon { width:1.25rem; height:1.25rem; color:rgba(255,255,255,.6); flex:0 0 auto; }
|
||||
.info-card-copy { display:flex; flex-direction:column; gap:.15rem; }
|
||||
.info-card-copy .sub { margin:0; font-size:.6875rem; }
|
||||
.info-card-copy strong { font-size:.8125rem; font-weight:600; color:rgba(255,255,255,.95); white-space:nowrap; }
|
||||
.node-mark { width:3.25rem; height:3.25rem; border-radius:.85rem; display:grid; place-items:center; background:#050505; border:1px solid rgba(255,255,255,.18); box-shadow:0 8px 24px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.2); }
|
||||
.tabbar { display:flex; gap:.35rem; padding:.35rem; margin:1.25rem 0; background:rgba(0,0,0,.62); border:1px solid rgba(255,255,255,.12); border-radius:.85rem; overflow-x:auto; }
|
||||
.tab-btn { flex:1 0 auto; border:0; border-radius:.6rem; padding:.65rem 1rem; background:transparent; color:rgba(255,255,255,.55); cursor:pointer; font-size:.8rem; font-weight:600; }
|
||||
.tab-btn:hover { color:#fff; background:rgba(255,255,255,.06); }
|
||||
.tab-btn.active { color:#fff; background:linear-gradient(135deg,rgba(247,147,26,.3),rgba(255,255,255,.08)); box-shadow:inset 0 1px 0 rgba(255,255,255,.15); }
|
||||
[data-panel].tab-hidden { display:none; }
|
||||
@media (max-width:700px) {
|
||||
.wrap { padding:1rem 1rem calc(6.75rem + env(safe-area-inset-bottom, 0px)); }
|
||||
.app-header { flex-direction:column; align-items:stretch; gap:1rem; }
|
||||
.app-header-id { flex-direction:column; justify-content:center; text-align:center; }
|
||||
.app-header-text { text-align:center; }
|
||||
.app-header-actions { flex-direction:column; align-items:stretch; justify-content:center; gap:.6rem; margin-left:0; }
|
||||
.app-header-actions > .info-card { width:100%; }
|
||||
.tabbar { position:fixed; left:.75rem; right:.75rem; bottom:calc(.5rem + env(safe-area-inset-bottom, 0px)); z-index:40; margin:0; padding:.35rem; border-radius:1rem; box-shadow:0 10px 30px rgba(0,0,0,.65); }
|
||||
.tab-btn { min-width:4.5rem; padding:.7rem .5rem; font-size:.7rem; }
|
||||
}
|
||||
@media (min-width:701px) {
|
||||
.app-header { flex-wrap:nowrap; }
|
||||
.app-header-text { min-width:0; }
|
||||
}
|
||||
|
||||
header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
||||
header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; }
|
||||
header h1 .accent { color: inherit; }
|
||||
.sub { color: rgba(255, 255, 255, 0.55); font-size: 0.85rem; margin-top: 0.2rem; }
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 0.45rem;
|
||||
padding: 0.35rem 0.8rem; border-radius: 999px;
|
||||
font-size: 0.78rem; font-weight: 600;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
|
||||
.dot.online { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.dot.syncing { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
|
||||
.dot.offline { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
|
||||
.pill.online .dot { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.pill.syncing .dot { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
|
||||
.pill.offline .dot { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; }
|
||||
|
||||
.card-title {
|
||||
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat { display: flex; justify-content: space-between; align-items: baseline; padding: 0.45rem 0; border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
|
||||
.stat:last-child { border-bottom: none; }
|
||||
.stat .label { color: rgba(255, 255, 255, 0.55); font-size: 0.82rem; }
|
||||
.stat .value { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.95rem; text-align: right; }
|
||||
.stat .value.warn { color: #f7931a; }
|
||||
.stat .value.err { color: #ef4444; }
|
||||
.stat .value.ok { color: #22c55e; }
|
||||
|
||||
.height-hero { display: flex; align-items: baseline; gap: 0.6rem; margin-bottom: 0.75rem; }
|
||||
.height-hero .big { font-size: 2.4rem; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
|
||||
.height-hero .of { color: rgba(255, 255, 255, 0.45); font-size: 1rem; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.progress { height: 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); overflow: hidden; margin: 0.5rem 0 0.35rem; }
|
||||
.progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, #f7931a, #ffc46b); transition: width 0.6s ease; }
|
||||
.progress-label { font-size: 0.75rem; color: rgba(255, 255, 255, 0.55); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.notice {
|
||||
margin-top: 1rem; padding: 0.9rem 1.1rem; border-radius: 0.75rem;
|
||||
background: rgba(247, 147, 26, 0.08);
|
||||
border: 1px solid rgba(247, 147, 26, 0.35);
|
||||
font-size: 0.82rem; line-height: 1.5; color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
.notice.error { background: rgba(239, 68, 68, 0.08); border-color: rgba(239, 68, 68, 0.4); }
|
||||
.notice b { color: white; }
|
||||
|
||||
.endpoint {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 0.5rem; padding: 0.55rem 0.75rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.82rem; overflow-x: auto; white-space: nowrap;
|
||||
}
|
||||
.hint { font-size: 0.75rem; color: rgba(255, 255, 255, 0.45); margin-top: 0.6rem; line-height: 1.5; }
|
||||
|
||||
footer { margin-top: 2rem; text-align: center; color: rgba(255, 255, 255, 0.35); font-size: 0.72rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-layer"></div>
|
||||
<div class="wrap">
|
||||
<header class="glass-card" style="padding:1.5rem;">
|
||||
<div class="app-header">
|
||||
<div class="app-header-id">
|
||||
<div class="node-mark"><img src="assets/img/app-icons/cuprate.svg" alt="Cuprate" style="width:2.35rem;height:2.35rem;object-fit:contain"></div>
|
||||
<div class="app-header-text">
|
||||
<h1><span class="accent">Cuprate</span> Monero Node</h1>
|
||||
<div class="sub">Rust implementation of the Monero protocol, on Archipelago</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-header-actions">
|
||||
<div class="info-card">
|
||||
<div class="relative"><span class="dot" id="headerStatusDot"></span><span class="absolute inset-0 dot animate-ping opacity-50"></span></div>
|
||||
<div class="info-card-copy"><div class="sub">Status</div><strong id="statusText">Connecting…</strong></div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
|
||||
<div class="info-card-copy"><div class="sub">Network</div><strong id="headerNetwork">—</strong></div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h16M4 17h16" /></svg>
|
||||
<div class="info-card-copy"><div class="sub">Height</div><strong id="headerHeight">—</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="offlineNotice" class="notice error" style="display:none;">
|
||||
<b>Cuprate is not reachable.</b> The node may be stopped, still installing, or syncing.
|
||||
Check the service status and try again shortly.
|
||||
</div>
|
||||
|
||||
<nav class="tabbar" role="tablist" aria-label="Cuprate dashboard sections">
|
||||
<button class="tab-btn active" data-tab="node" role="tab">Node</button>
|
||||
<button class="tab-btn" data-tab="insights" role="tab">Insights</button>
|
||||
<button class="tab-btn" data-tab="peers" role="tab">Peers</button>
|
||||
<button class="tab-btn" data-tab="connect" role="tab">Connect</button>
|
||||
</nav>
|
||||
|
||||
<div class="grid" style="margin-top:1rem;">
|
||||
<div class="glass-card" data-panel="node">
|
||||
<div class="card-title">Blockchain Sync</div>
|
||||
<div class="height-hero">
|
||||
<span class="big" id="height">—</span>
|
||||
<span class="of" id="targetOf">of —</span>
|
||||
</div>
|
||||
<div class="progress"><div class="progress-fill" id="syncBar"></div></div>
|
||||
<div class="progress-label" id="syncLabel">Waiting for node…</div>
|
||||
<div class="stat"><span class="label">Network</span><span class="value" id="nettype">—</span></div>
|
||||
<div class="stat"><span class="label">Uptime</span><span class="value" id="uptime">—</span></div>
|
||||
<div class="stat"><span class="label">Node time</span><span class="value" id="nodeTime">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card tab-hidden" data-panel="peers">
|
||||
<div class="card-title">Peers & Traffic</div>
|
||||
<div class="stat"><span class="label">Outgoing connections</span><span class="value" id="outConns">—</span></div>
|
||||
<div class="stat"><span class="label">Incoming connections</span><span class="value" id="inConns">—</span></div>
|
||||
<div class="stat"><span class="label">RPC connections</span><span class="value" id="rpcConns">—</span></div>
|
||||
<div class="stat"><span class="label">Known peers (white)</span><span class="value" id="whitePeers">—</span></div>
|
||||
<div class="stat"><span class="label">Known peers (gray)</span><span class="value" id="grayPeers">—</span></div>
|
||||
<div class="stat"><span class="label">Mempool transactions</span><span class="value" id="txPool">—</span></div>
|
||||
<div class="stat"><span class="label">Alt blocks</span><span class="value" id="altBlocks">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card tab-hidden" data-panel="insights">
|
||||
<div class="card-title">Chain & Disk</div>
|
||||
<div class="stat"><span class="label">Difficulty</span><span class="value" id="difficulty">—</span></div>
|
||||
<div class="stat"><span class="label">Chain size</span><span class="value" id="chainSize">—</span></div>
|
||||
<div class="stat"><span class="label">Free disk</span><span class="value" id="freeSpace">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card tab-hidden" data-panel="connect">
|
||||
<div class="card-title">Connect a Wallet</div>
|
||||
<div class="endpoint">
|
||||
<span id="walletEndpoint">—</span>
|
||||
<button class="glass-button" onclick="copyEndpoint(this)">Copy</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
Restricted RPC — Monero's own safe-for-public subset, what Feather,
|
||||
monero-wallet-rpc and the GUI use for a “remote node”. Full (unrestricted) RPC
|
||||
stays container-loopback only and is never published.
|
||||
</div>
|
||||
<div class="stat" style="margin-top:0.9rem;"><span class="label">P2P port</span><span class="value" id="p2pPort">18183</span></div>
|
||||
<div class="stat"><span class="label">Restricted RPC port</span><span class="value">18090</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Cuprate is work-in-progress software; it independently validates Monero consensus rules.
|
||||
Data served from this node's restricted RPC, refreshed every 15 seconds.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const RPC = 'cuprate-rpc/';
|
||||
const POLL_MS = 15000;
|
||||
|
||||
function tabular(n) { return Number(n || 0).toLocaleString('en-US'); }
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const n = Number(bytes);
|
||||
if (!Number.isFinite(n) || n <= 0) return '—';
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||
let v = n, i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
|
||||
return `${v >= 100 || i === 0 ? tabular(Math.round(v)) : v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatUptime(secs) {
|
||||
const s = Number(secs);
|
||||
if (!Number.isFinite(s) || s < 0) return '—';
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function setStat(id, value, cls) {
|
||||
const el = document.getElementById(id);
|
||||
el.textContent = (value === null || value === undefined || value === '') ? '—' : value;
|
||||
el.className = 'value' + (cls ? ' ' + cls : '');
|
||||
}
|
||||
|
||||
function setStatus(kind, text) {
|
||||
document.getElementById('statusText').textContent = text;
|
||||
const dot = document.getElementById('headerStatusDot');
|
||||
dot.className = 'dot ' + kind;
|
||||
document.getElementById('offlineNotice').style.display = (kind === 'offline') ? '' : 'none';
|
||||
}
|
||||
|
||||
async function callRpc(endpoint) {
|
||||
const response = await fetch(RPC + endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (data && data.error) throw new Error(data.error);
|
||||
return data;
|
||||
}
|
||||
|
||||
function render(info, heightFallback) {
|
||||
const height = info.height ?? heightFallback ?? 0;
|
||||
// Monero's get_info returns target_height == 0 when the node is
|
||||
// FULLY SYNCED — the field is the height being caught up to, not
|
||||
// the chain tip, so `??` cannot substitute for the 0 case (a
|
||||
// synced node would sit forever at "Syncing — 0.00%"). Treat
|
||||
// 0/absent as "target is our own height" — the same sentinel
|
||||
// core/archipelago/src/electrs_status.rs branches on.
|
||||
const rawTarget = info.target_height ?? info.target ?? 0;
|
||||
const target = rawTarget > 0 ? rawTarget : height;
|
||||
document.getElementById('height').textContent = tabular(height);
|
||||
document.getElementById('targetOf').textContent = `of ${tabular(target)}`;
|
||||
|
||||
const pct = target > 0 ? Math.min(100, (height / target) * 100) : 0;
|
||||
document.getElementById('syncBar').style.width = pct.toFixed(2) + '%';
|
||||
|
||||
const synced = target > 0 && height >= target;
|
||||
if (synced) {
|
||||
document.getElementById('syncLabel').textContent = 'Fully synced';
|
||||
setStatus('online', 'Synced');
|
||||
} else {
|
||||
const behind = Math.max(0, target - height);
|
||||
document.getElementById('syncLabel').textContent =
|
||||
`${pct.toFixed(2)}% — ${tabular(behind)} blocks behind`;
|
||||
setStatus('syncing', 'Syncing');
|
||||
}
|
||||
|
||||
const nettype = info.mainnet ? 'Mainnet'
|
||||
: info.testnet ? 'Testnet'
|
||||
: info.stagenet ? 'Stagenet'
|
||||
: (info.net || info.nettype || '—');
|
||||
setStat('nettype', nettype);
|
||||
document.getElementById('headerNetwork').textContent = nettype;
|
||||
document.getElementById('headerHeight').textContent = tabular(height);
|
||||
|
||||
const nowSecs = info.time ?? info.adjusted_time ?? Math.floor(Date.now() / 1000);
|
||||
const started = info.start_time ?? info.startup_time;
|
||||
setStat('uptime', started ? formatUptime(nowSecs - started) : '—');
|
||||
setStat('nodeTime', new Date(nowSecs * 1000).toLocaleString());
|
||||
|
||||
setStat('outConns', tabular(info.outgoing_connections_count));
|
||||
setStat('inConns', tabular(info.incoming_connections_count));
|
||||
setStat('rpcConns', tabular(info.rpc_connections_count));
|
||||
setStat('whitePeers', tabular(info.white_peerlist_size));
|
||||
setStat('grayPeers', tabular(info.grey_peerlist_size));
|
||||
setStat('txPool', tabular(info.tx_pool_size));
|
||||
const alt = Number(info.alt_blocks_count || 0);
|
||||
setStat('altBlocks', tabular(alt), alt > 0 ? 'warn' : undefined);
|
||||
|
||||
setStat('difficulty', tabular(info.difficulty));
|
||||
setStat('chainSize', formatBytes(info.blocks_size ?? info.block_sizes?.[0]));
|
||||
const free = info.free_space;
|
||||
const freeWarn = Number.isFinite(free) && free > 0 && free < 50 * 1024 * 1024 * 1024;
|
||||
setStat('freeSpace', formatBytes(free), freeWarn ? 'warn' : undefined);
|
||||
|
||||
if (!document.getElementById('walletEndpoint').textContent.includes(':')) {
|
||||
document.getElementById('walletEndpoint').textContent =
|
||||
`${window.location.hostname}:18090`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let height = null;
|
||||
try {
|
||||
const h = await callRpc('get_height');
|
||||
height = h.height;
|
||||
} catch { /* get_info below carries the real error */ }
|
||||
try {
|
||||
const info = await callRpc('get_info');
|
||||
render(info, height);
|
||||
} catch {
|
||||
setStatus('offline', 'Node offline');
|
||||
if (height !== null) document.getElementById('height').textContent = tabular(height);
|
||||
}
|
||||
}
|
||||
|
||||
function copyEndpoint(btn) {
|
||||
const text = document.getElementById('walletEndpoint').textContent;
|
||||
const done = () => { btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = 'Copy'; }, 1500); };
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(done).catch(() => { done(); });
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch { /* best effort */ }
|
||||
document.body.removeChild(ta);
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const tab = button.dataset.tab;
|
||||
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.toggle('active', b === button));
|
||||
document.querySelectorAll('[data-panel]').forEach((panel) => panel.classList.toggle('tab-hidden', panel.dataset.panel !== tab));
|
||||
});
|
||||
});
|
||||
|
||||
// Height alone answers even while get_info is warming up; if both
|
||||
// fail the offline card explains the disk gate as a likely cause.
|
||||
refresh();
|
||||
setInterval(refresh, POLL_MS);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,56 @@
|
||||
server {
|
||||
# Loopback ONLY — same rule as docker/bitcoin-ui and docker/electrs-ui.
|
||||
# This container is host-networked, so nginx binds the HOST's address
|
||||
# directly; a bare `listen` would expose the page on LAN, Tailscale and
|
||||
# the mesh with the app gate nowhere in front of it. Binding loopback lets
|
||||
# the daemon claim the external addresses and authenticate them;
|
||||
# see appgate::listener and apps/cuprate-ui/manifest.yml (auth: gated).
|
||||
listen 127.0.0.1:18091;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Session gate for the RPC proxy below. Internal: reachable only by
|
||||
# nginx's own auth_request subrequest, never by a client.
|
||||
location = /_session_check {
|
||||
internal;
|
||||
proxy_pass http://127.0.0.1:5678/auth/session-check;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
}
|
||||
|
||||
# Cuprate's restricted RPC (host-published on 127.0.0.1:18090, auth: open
|
||||
# — Monero's own safe-for-public subset, what remote-node wallets use).
|
||||
# It injects no credentials the caller lacks, but it is still session-
|
||||
# gated here so the whole companion behaves as one authenticated surface
|
||||
# (same defence-in-depth bitcoin-ui applies to its credential-injecting
|
||||
# proxy: loopback reaches it without the gate's challenge).
|
||||
location /cuprate-rpc/ {
|
||||
# Preflight carries no cookies by design — answer it before the gate,
|
||||
# otherwise the browser reports an opaque CORS failure instead of a 401.
|
||||
if ($request_method = OPTIONS) { return 204; }
|
||||
auth_request /_session_check;
|
||||
proxy_pass http://127.0.0.1:18090/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Access-Control-Allow-Origin $scheme://$http_host always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
add_header Vary "Origin" always;
|
||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||
}
|
||||
|
||||
# no-cache (revalidate), not no-store — same reasoning as docker/bitcoin-ui:
|
||||
# a rebuilt companion image must actually be seen by the browser, while the
|
||||
# ETag still saves the transfer when nothing changed.
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -52,13 +52,55 @@ built and verified to embed the alias fix. `cargo fmt` applied.
|
||||
| D1 shorty NPM crash-loop stopped cleanly (user-stopped marker; public hosts keep serving via host nginx mirror) | ✅ 12:52Z |
|
||||
| D2 shorty live nginx HSTS patch + reload | ✅ verified: :80 and :443 both answer `max-age=0` |
|
||||
| D3 Regenerate catalog (releases/app-catalog.json + store copies) | ✅ semantic diff = exactly the two NPM fixes |
|
||||
| D4 **User runs `scripts/sign-catalog.sh`** (signer built at /tmp/archy-sign-bin) | ⬜ waiting on mnemonic |
|
||||
| D5 Commit + push (origin + gitea-vps2 OTA mirror) | ✅ 6 commits pushed (signed catalog commits after D4) |
|
||||
| D6 Release v1.8.9-alpha: `scripts/create-release.sh 1.8.9-alpha` (mnemonic) → `scripts/publish-release-assets.sh 1.8.9-alpha gitea-vps2` | ⬜ waiting on mnemonic |
|
||||
| D7 OTA on shorty-s + framework-pt (Update button; framework-pt has no SSH from here) | ⬜ |
|
||||
| D8 shorty: clear the NPM user-stopped marker + Start (or it starts via the fixed catalog) | ⬜ |
|
||||
| D4 **User runs `scripts/sign-catalog.sh`** (signer built at /tmp/archy-sign-bin) | ✅ catalog signed + committed + pushed |
|
||||
| D5 Commit + push (origin + gitea-vps2 OTA mirror) | ✅ 9 commits pushed |
|
||||
| D6 Release v1.8.9-alpha: `scripts/create-release.sh 1.8.9-alpha` (mnemonic) → `scripts/publish-release-assets.sh 1.8.9-alpha gitea-vps2` | ✅ PUBLISHED (tag v1.8.9-alpha, releases/manifest.json live, backend+frontend assets verified by the script) |
|
||||
| D7 OTA on shorty-s + framework-pt (Update button; shorty is on 1.8.8-alpha, daily check — hit Update now) | ⬜ user action |
|
||||
| D8 shorty: clear the NPM user-stopped marker + Start (or it starts via the fixed catalog) | ✅ NPM LIVE-HEALED via the signed catalog: unit regenerated with both fixes, container up, admin UI HTTP 200 on :8081 (verified 15:42Z) |
|
||||
| D9 framework-pt: Start Mempool — its containers are confirmed stopped (port 4080 refuses; gate answers on 7778/8334/50002/18083 so those apps will embed over https immediately) | ⬜ |
|
||||
| D10 Post-deploy live checks: LND send+receive; mempool/IndeeHub/bitcoin-UI frames over https; NPM healthy + admin :8081; portainer token card on fresh DB; zero CORS errors | ⬜ |
|
||||
| D10 Post-deploy live checks: LND send+receive; mempool/IndeeHub/bitcoin-UI frames over https; NPM healthy + admin :8081 ✅; portainer token card on fresh DB; zero CORS errors | ⬜ after nodes update |
|
||||
|
||||
## E. Follow-ups discovered during the incident (ride the NEXT release, v1.8.10+)
|
||||
|
||||
- **LND channel-peer watchdog** (this release's headline platform fix): every
|
||||
2 minutes the daemon reconnects peers of open channels that LND has not
|
||||
re-established on its own (per-peer retry throttled to 10 minutes), using
|
||||
the peer's advertised addresses from the public graph. Kills the whole
|
||||
class this incident exposed — a channel unroutable ~17h after an LND update
|
||||
while both nodes looked healthy. Unit tests pin the selection logic over the
|
||||
live REST shapes.
|
||||
- **Funding-modal honesty fix** (1464b1b2): the
|
||||
Lightning "no channel" modal now states the node's real state — pending
|
||||
channel confirming / balance on the far side / payment couldn't route /
|
||||
genuinely no channels. Note the stale-direction defect it fixes: the
|
||||
payment-failure mapper never set the direction, so a SEND failure showed
|
||||
the RECEIVE-branch copy ("Receiving needs inbound liquidity…") — the exact
|
||||
modal users saw while their node had a healthy 583k-outbound channel.
|
||||
Both fixes have their v1.8.10 CHANGELOG + What's New entries staged so the
|
||||
next `create-release.sh 1.8.10-alpha` runs clean first time.
|
||||
- Nodes poll for OTA updates on `daily_check` — after publishing, tell the
|
||||
user to hit Update rather than wait for the next check.
|
||||
- `origin` remote had a stale pushurl with a dead token (pushes failed);
|
||||
fixed to the canonical repo URL, stale `~/.git-credentials` entry with an
|
||||
encoded port removed.
|
||||
|
||||
## F. Post-v1.8.9 verification on shorty-s (2026-09-01 evening)
|
||||
|
||||
- v1.8.9 applied; payment pipeline confirmed live: a 400,000 sat payment
|
||||
SUCCEEDED through the v2 router route; the 404s are gone.
|
||||
- App gate serves TLS on 4080/8334/18083/50002 (401 gate pages over https) —
|
||||
https app frames now answer. Mempool over https requires a hard refresh
|
||||
(PWA precaches the old bundle).
|
||||
- **"No route to the recipient" on sends is real**: the invoices being tested
|
||||
are from framework-pt, whose only channel (peer "Sandwich Farm",
|
||||
0224c955…) is flagged `disabled` on BOTH policy sides in the routing graph
|
||||
after today's node churn — the peer connection never re-established
|
||||
(LND's reconnect backoff can stretch to hours). A disabled edge is
|
||||
unroutable in both directions, so payments to/from framework-pt fail
|
||||
regardless of shorty's 583k outbound. Fix: `lncli connect` the peer, wait
|
||||
for the channel_update to re-enable the edge (~minutes), then re-test.
|
||||
- The 577k attempt earlier failed for a different, correct reason: it exceeded
|
||||
the channel's spendable balance (583,542 − 9,850 reserve ≈ 573k max).
|
||||
|
||||
framework-pt immediate workaround until its OTA lands: open the dashboard by
|
||||
IP (`http://192.168.x.x`) instead of `framework-pt.local`, and/or clear the
|
||||
|
||||
@@ -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.9-alpha",
|
||||
"version": "1.8.11-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.9-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.9-alpha",
|
||||
"version": "1.8.11-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -10,7 +10,32 @@
|
||||
z-index="z-[3600]"
|
||||
@close="onClose"
|
||||
>
|
||||
<p v-if="lightning.status.value === 'no-funds'" class="text-sm text-white/70 leading-relaxed">
|
||||
<p v-if="lightning.status.value === 'no-funds' && lightning.fundingReason.value === 'pending'" class="text-sm text-white/70 leading-relaxed">
|
||||
Your new channel is <span class="text-white/90">waiting for its on-chain confirmations</span> —
|
||||
that's why the network doesn't see it yet. It unlocks automatically once
|
||||
confirmed (usually within about half an hour); nothing is needed from
|
||||
you. This screen will work as soon as it lands.
|
||||
</p>
|
||||
<p v-else-if="lightning.status.value === 'no-funds' && lightning.fundingReason.value === 'far-side'" class="text-sm text-white/70 leading-relaxed">
|
||||
<template v-if="lightning.fundingDirection.value === 'receive'">
|
||||
You have channels, but <span class="text-white/90">all the balance is on your side</span> —
|
||||
you can send, but there's nothing to be paid into right now. Receive a
|
||||
payment by spending first, or open another channel to bring inbound
|
||||
liquidity in.
|
||||
</template>
|
||||
<template v-else>
|
||||
You have channels, but <span class="text-white/90">all the balance is on the far side</span> —
|
||||
you can receive, but there's nothing to send right now. Someone has to
|
||||
pay you first (or rebalance the channel), and sending unlocks on its own.
|
||||
</template>
|
||||
</p>
|
||||
<p v-else-if="lightning.status.value === 'no-funds' && lightning.fundingReason.value === 'failed-payment'" class="text-sm text-white/70 leading-relaxed">
|
||||
LND couldn't route this payment — most often there's
|
||||
<span class="text-white/90">not enough outbound for this amount</span>, or no
|
||||
route to the recipient at the fees offered. Smaller amounts sometimes
|
||||
get through; check the channels screen to see what's actually spendable.
|
||||
</p>
|
||||
<p v-else-if="lightning.status.value === 'no-funds'" class="text-sm text-white/70 leading-relaxed">
|
||||
Your Lightning node is running, but it has no payment channel yet.
|
||||
<template v-if="lightning.fundingDirection.value === 'receive'">
|
||||
Receiving needs <span class="text-white/90">inbound liquidity</span> — a
|
||||
@@ -101,14 +126,31 @@
|
||||
@click="openApps"
|
||||
>Open My Apps</button>
|
||||
<template v-else-if="lightning.status.value === 'no-funds'">
|
||||
<!-- A confirming channel needs no action at all — offering "open a
|
||||
channel" here would send the user to fix a problem they don't
|
||||
have (and possibly open a second one). -->
|
||||
<template v-if="lightning.fundingReason.value === 'pending'">
|
||||
<button
|
||||
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm"
|
||||
@click="onClose"
|
||||
>Got it — I'll wait</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm"
|
||||
@click="openSetupGuide"
|
||||
>Setup Guide</button>
|
||||
<button
|
||||
v-if="lightning.fundingReason.value !== 'failed-payment'"
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
|
||||
@click="openLightningSetup"
|
||||
>Open a channel</button>
|
||||
<button
|
||||
v-else
|
||||
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm"
|
||||
@click="onClose"
|
||||
>Close</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</BaseModal>
|
||||
@@ -155,7 +197,12 @@ const nodes: NodeChoice[] = [
|
||||
const router = useRouter()
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
if (lightningStatusIs('no-funds')) return 'You need a Lightning channel'
|
||||
if (lightningStatusIs('no-funds')) {
|
||||
if (lightning.fundingReason.value === 'pending') return 'Channel confirming…'
|
||||
if (lightning.fundingReason.value === 'far-side') return 'Balance is on the far side'
|
||||
if (lightning.fundingReason.value === 'failed-payment') return 'Payment couldn\u2019t route'
|
||||
return 'You need a Lightning channel'
|
||||
}
|
||||
if (lightningStatusIs('stopped')) return 'Lightning node not running'
|
||||
return 'Lightning node required'
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useLightningRequired } from '../useLightningRequired'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
// The gate reads install state off the app store's package list. Stub the
|
||||
// store rather than the RPC layer so the test pins the decision, not the
|
||||
@@ -14,6 +15,12 @@ vi.mock('@/stores/app', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useLightningRequired', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -73,4 +80,82 @@ describe('useLightningRequired', () => {
|
||||
packages.value = {}
|
||||
expect(useLightningRequired().lightningStatus()).toBe('absent')
|
||||
})
|
||||
|
||||
describe('requireLightningReady states the node\u2019s real funding state', () => {
|
||||
beforeEach(() => {
|
||||
packages.value = { lnd: { state: 'running' } }
|
||||
vi.mocked(rpcClient.call).mockReset()
|
||||
})
|
||||
|
||||
it('says the channel is confirming, not \u201cno channel\u201d, while pending', async () => {
|
||||
// The regression (framework-pt, 2026-09-01): a just-opened channel
|
||||
// sits in LND's pending list; the outbound sum is legitimately 0, but
|
||||
// the modal claimed the node had no channel at all.
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({
|
||||
total_inbound: 0,
|
||||
total_outbound: 0,
|
||||
channels: [{ status: 'pending_open', local_balance: 900000, remote_balance: 0 }],
|
||||
})
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(await lightning.requireLightningReady('send')).toBe(false)
|
||||
expect(lightning.show.value).toBe(true)
|
||||
expect(lightning.status.value).toBe('no-funds')
|
||||
expect(lightning.fundingReason.value).toBe('pending')
|
||||
})
|
||||
|
||||
it('says the balance is on the far side when channels exist but outbound is 0', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({
|
||||
total_inbound: 985000,
|
||||
total_outbound: 0,
|
||||
channels: [{ status: 'active', local_balance: 0, remote_balance: 985000 }],
|
||||
})
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(await lightning.requireLightningReady('send')).toBe(false)
|
||||
expect(lightning.fundingReason.value).toBe('far-side')
|
||||
// The same node CAN receive — the gate must pass for the other way.
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({
|
||||
total_inbound: 985000,
|
||||
total_outbound: 0,
|
||||
channels: [{ status: 'active', local_balance: 0, remote_balance: 985000 }],
|
||||
})
|
||||
expect(await lightning.requireLightningReady('receive')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the open-a-channel guidance only when there truly is no channel', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({
|
||||
total_inbound: 0,
|
||||
total_outbound: 0,
|
||||
channels: [],
|
||||
})
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(await lightning.requireLightningReady('send')).toBe(false)
|
||||
expect(lightning.fundingReason.value).toBe('none')
|
||||
})
|
||||
|
||||
it('fails OPEN on an RPC error \u2014 a transient blip must not block a working wallet', async () => {
|
||||
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Failed to fetch'))
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(await lightning.requireLightningReady('send')).toBe(true)
|
||||
expect(lightning.show.value).toBe(false)
|
||||
})
|
||||
|
||||
it('maps a routing/liquidity payment failure onto the modal without claiming \u201cno channel\u201d', () => {
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.handleLightningFailure(new Error('Payment failed: unable to find a path to destination'))).toBe(true)
|
||||
expect(lightning.status.value).toBe('no-funds')
|
||||
expect(lightning.fundingReason.value).toBe('failed-payment')
|
||||
})
|
||||
|
||||
it('leaves non-funding payment errors to the caller', () => {
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.handleLightningFailure(new Error('Payment failed: Not Found'))).toBe(false)
|
||||
expect(lightning.show.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,12 +34,27 @@ export const LIGHTNING_NODE_APP_IDS = ['lnd'] as const
|
||||
* `running` — good to go. */
|
||||
export type LightningStatus = 'absent' | 'stopped' | 'running' | 'no-funds'
|
||||
|
||||
/** WHY the funding modal opened — the old copy always said "you have no
|
||||
* channel yet", which was a lie three ways: a just-opened channel sits in
|
||||
* LND's pending list (invisible to the outbound sum) until it has ~3
|
||||
* confirmations, channels can exist with all their balance on the far
|
||||
* side, and a payment failure can look like a funding problem. The user
|
||||
* sees "no channel" while looking at a wallet full of pending liquidity
|
||||
* (framework-pt, 2026-09-01: "LND thinks I do not have a channel").
|
||||
* `none` — genuinely no channels, the open-one flow is right.
|
||||
* `pending` — channel(s) exist but are still confirming on-chain.
|
||||
* `far-side` — open channel(s), but the needed direction has zero balance.
|
||||
* `failed-payment` — LND refused a payment; looks like routing/liquidity. */
|
||||
export type FundingReason = 'none' | 'pending' | 'far-side' | 'failed-payment'
|
||||
|
||||
// Module-scope: one source of truth shared by every caller and the single
|
||||
// global modal mounted in App.vue.
|
||||
const show = ref(false)
|
||||
const status = ref<LightningStatus>('absent')
|
||||
/** Which direction raised the funding modal, so the copy can be specific. */
|
||||
const fundingDirection = ref<'send' | 'receive'>('receive')
|
||||
/** Why the funding modal opened, so the copy states the node's real state. */
|
||||
const fundingReason = ref<FundingReason>('none')
|
||||
|
||||
export function useLightningRequired() {
|
||||
// The store is resolved lazily, inside the functions that need it, rather
|
||||
@@ -86,8 +101,9 @@ export function useLightningRequired() {
|
||||
* rather than inventing a second one, and routes to the Lightning setup
|
||||
* goal where funding and channel-opening already live.
|
||||
*/
|
||||
function openLightningFunding() {
|
||||
function openLightningFunding(reason: FundingReason = 'none') {
|
||||
status.value = 'no-funds'
|
||||
fundingReason.value = reason
|
||||
show.value = true
|
||||
}
|
||||
|
||||
@@ -114,7 +130,9 @@ export function useLightningRequired() {
|
||||
'no path',
|
||||
].some((needle) => msg.includes(needle))
|
||||
if (!fundingRelated) return false
|
||||
openLightningFunding()
|
||||
// LND refused the payment itself — not necessarily "no channels", so
|
||||
// the modal must not claim it is. Most often this is routing/liquidity.
|
||||
openLightningFunding('failed-payment')
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -133,14 +151,28 @@ export function useLightningRequired() {
|
||||
async function requireLightningReady(direction: 'send' | 'receive'): Promise<boolean> {
|
||||
if (!requireLightningNode()) return false
|
||||
try {
|
||||
const res = await rpcClient.call<{ total_inbound?: number; total_outbound?: number }>({
|
||||
const res = await rpcClient.call<{
|
||||
total_inbound?: number
|
||||
total_outbound?: number
|
||||
channels?: { status?: string; local_balance?: number; remote_balance?: number }[]
|
||||
}>({
|
||||
method: 'lnd.listchannels',
|
||||
timeout: 15000,
|
||||
})
|
||||
const liquidity = direction === 'receive' ? res?.total_inbound ?? 0 : res?.total_outbound ?? 0
|
||||
if (liquidity > 0) return true
|
||||
fundingDirection.value = direction
|
||||
openLightningFunding()
|
||||
// Zero in the needed direction — say WHY, from the same response.
|
||||
// The channel list carries pending entries (status 'pending_open');
|
||||
// the totals deliberately exclude them (nothing is spendable through
|
||||
// an unconfirmed channel), so "0 outbound + pending channels" is the
|
||||
// just-opened-a-channel state, not "no channel".
|
||||
const channels = res?.channels ?? []
|
||||
const hasPending = channels.some(c => c.status === 'pending_open')
|
||||
const hasOpen = channels.some(
|
||||
c => c.status === 'active' || c.status === 'inactive' || (!c.status && (c.local_balance || c.remote_balance)),
|
||||
)
|
||||
openLightningFunding(hasPending ? 'pending' : hasOpen ? 'far-side' : 'none')
|
||||
return false
|
||||
} catch {
|
||||
return true
|
||||
@@ -150,6 +182,7 @@ export function useLightningRequired() {
|
||||
return {
|
||||
show,
|
||||
fundingDirection,
|
||||
fundingReason,
|
||||
status,
|
||||
lightningStatus,
|
||||
hasLightningNode,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
|
||||
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
|
||||
import { __setSignedCatalogForTests } from '../../discover/curatedApps'
|
||||
@@ -19,6 +19,8 @@ const SIGNED = {
|
||||
tailscale: { version: 'stable' },
|
||||
// auth:none ports are container-published too — https would fail.
|
||||
cuprate: { version: '0.1.0-preview', manifest: { app: { ports: [{ host: 18090, auth: 'none' }] } } },
|
||||
// Cuprate's companion dashboard owns the gated launch port.
|
||||
'cuprate-ui': { version: '1.0.0', manifest: { app: { ports: [{ host: 18091, auth: 'gated' }] } } },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -34,6 +36,9 @@ describe('appSessionConfig', () => {
|
||||
beforeEach(() => {
|
||||
__setSignedCatalogForTests(SIGNED as never)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
it('keeps manifest-owned new-tab apps marked on every viewport', () => {
|
||||
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
|
||||
expect(NEW_TAB_APPS.has('photoprism')).toBe(true)
|
||||
@@ -64,10 +69,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', () => {
|
||||
@@ -144,7 +149,18 @@ describe('appSessionConfig', () => {
|
||||
// A runtime port the gate does NOT front keeps plain http (https would
|
||||
// fail to connect outright).
|
||||
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083')
|
||||
// Cuprate's UI port is auth:none — plain HTTP stays plain.
|
||||
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090')
|
||||
})
|
||||
|
||||
it('launches cuprate on the companion UI, never the raw restricted RPC', () => {
|
||||
// resolveAppUrl has a DEV branch (vite path proxy) — assert prod launch.
|
||||
vi.stubEnv('DEV', false)
|
||||
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
|
||||
// cuprate publishes only raw JSON RPC (18090 restricted, 18183 p2p).
|
||||
// Launches must land on the companion dashboard (:18091, gate-fronted →
|
||||
// page scheme), even when the backend hands back a running-node runtime
|
||||
// URL pointing at the bare RPC.
|
||||
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('https://192.0.2.10:18091')
|
||||
expect(resolveAppUrl('cuprate-ui')).toBe('https://192.0.2.10:18091')
|
||||
expect(resolveAppUrl('archy-cuprate-ui')).toBe('https://192.0.2.10:18091')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'bitcoin-knots': 8334,
|
||||
'bitcoin-core': 8334,
|
||||
'bitcoin-ui': 8334,
|
||||
'cuprate': 18091,
|
||||
'cuprate-ui': 18091,
|
||||
'archy-cuprate-ui': 18091,
|
||||
'electrumx': 50002,
|
||||
'electrs': 50002,
|
||||
'archy-electrs-ui': 50002,
|
||||
@@ -111,6 +114,15 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
||||
return appOrigin(8334, id)
|
||||
}
|
||||
|
||||
// Cuprate UI is the same companion shape on :18091. The cuprate app itself
|
||||
// publishes only the restricted RPC (18090) — a raw JSON endpoint, not a
|
||||
// page — so cuprate launches must land on the companion, never on the
|
||||
// runtimeUrl a running cuprate reports.
|
||||
if (id === 'cuprate' || id === 'cuprate-ui' || id === 'archy-cuprate-ui') {
|
||||
if (import.meta.env.DEV) return '/app/cuprate-ui/'
|
||||
return appOrigin(18091, id)
|
||||
}
|
||||
|
||||
if (runtimeUrl && id !== 'netbird') {
|
||||
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
|
||||
// The backend reports runtime URLs as http:// because that is how the app
|
||||
|
||||
@@ -9,6 +9,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
"bitcoin-ui": 8334,
|
||||
"botfights": 9100,
|
||||
"btcpay-server": 23000,
|
||||
"cuprate-ui": 18091,
|
||||
"electrs-ui": 50002,
|
||||
"electrumx": 50002,
|
||||
"fedimint": 8175,
|
||||
@@ -54,6 +55,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||
"btcpay-server": "BTCPay Server",
|
||||
"core-lightning": "Core Lightning (CLN)",
|
||||
"cuprate": "Cuprate",
|
||||
"cuprate-ui": "Cuprate UI",
|
||||
"electrs-ui": "Electrs UI",
|
||||
"electrumx": "ElectrumX",
|
||||
"fedimint": "Fedimint Guardian",
|
||||
|
||||
@@ -38,6 +38,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
// Known app -> category mappings (matches App Store categorisation)
|
||||
export const APP_CATEGORY_MAP: Record<string, string> = {
|
||||
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
||||
'cuprate': 'money', 'cuprate-ui': 'money',
|
||||
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
|
||||
'fedimint': 'money', 'fedimint-gateway': 'money',
|
||||
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
||||
@@ -187,6 +188,9 @@ const APP_ICON_FALLBACKS: Record<string, string> = {
|
||||
'archy-bitcoin-ui': '/assets/img/app-icons/bitcoin-knots.webp',
|
||||
'archy-lnd-ui': '/assets/img/app-icons/lnd.png',
|
||||
'archy-electrs-ui': '/assets/img/app-icons/electrumx.png',
|
||||
// Cuprate's companion dashboard shares the node's icon.
|
||||
'cuprate-ui': '/assets/img/app-icons/cuprate.svg',
|
||||
'archy-cuprate-ui': '/assets/img/app-icons/cuprate.svg',
|
||||
// ElectrumX ships under a few historical ids (the backend was renamed
|
||||
// electrs → electrumx). Pin the whole family to the ElectrumX icon so My
|
||||
// Apps shows the right logo no matter which id the node has it installed
|
||||
|
||||
@@ -97,6 +97,8 @@ const CATALOG_APP_ID_ALIASES: Record<string, string> = {
|
||||
'archy-lnd-ui': 'lnd-ui',
|
||||
'bitcoin-knots': 'bitcoin-ui',
|
||||
'bitcoin-core': 'bitcoin-ui',
|
||||
'cuprate': 'cuprate-ui',
|
||||
'archy-cuprate-ui': 'cuprate-ui',
|
||||
'fedimintd': 'fedimint',
|
||||
'immich_server': 'immich',
|
||||
}
|
||||
|
||||
@@ -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,30 @@ 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">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.10-alpha</span>
|
||||
<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 — 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 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
@@ -369,11 +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>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 -->
|
||||
|
||||
@@ -162,6 +162,11 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/app/cuprate-ui': {
|
||||
target: process.env.BACKEND_URL || 'http://localhost:5959',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
// Demo mock app UIs (electrumx, lnd, fedimint) + generic notice page.
|
||||
'/app/electrumx': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
'/app/electrs': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
|
||||
+17
-18
@@ -1,30 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**SSH over the mesh is now a first-class setting.** Settings gains an \"SSH over mesh\" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit \"I understand\" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the `90-ssh.nft` drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, `fipssh <user>@<node-npub>` connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.",
|
||||
"**The App Store now lists apps — not parts of apps.** The signed catalog carries every manifest because the node's update layer needs their pins, and the store briefly listed them all: Mempool API, LND UI, Bitcoin UI, the Pine voice engines, the IndeeHub and Immich backends, the mesh router and friends. Components are hidden from the store listing (they still appear where they belong — the Services tab of My Apps, once installed), and four entries that never earned a tile are gone outright: MorphOS server (old), the Web5 DID wallet, Lightning Stack (an untracked upstream bundle — LND covers the need), and CryptPad (never tested).",
|
||||
"**App icons now persist everywhere, in the proper container style.** Two fixes: installed apps render the icon from their own manifest — Cuprate no longer falls back to the generic A-mark on its Services tile — and the store grids (the Discover page) apply the same icon container treatment (backdrop, border, shadow) as My Apps, the detail pages, and Home. Manifest-declared UI apps also classify correctly again: Alby Hub installs into My Apps with a working tile, not into Services, because a probe miss no longer buries an app the manifest itself says has a frontend.",
|
||||
"**Installing from the store keeps you on the store page.** The install progress lives on the tile itself and the app appears in My Apps when it lands — no more being yanked to My Apps mid-browse."
|
||||
"**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.8-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.8-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.8-alpha",
|
||||
"sha256": "96f39b8db6f08386200e1eab91c8444a7758526e6034100c8a33907ff9263530",
|
||||
"size_bytes": 64175864
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "ae569054edd6b2491beb101815f6809bc00c95a7dbe86bd084bcb9a7c36e1853",
|
||||
"size_bytes": 64179264
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.8-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.8-alpha/archipelago-frontend-1.8.8-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.8-alpha.tar.gz",
|
||||
"new_version": "1.8.8-alpha",
|
||||
"sha256": "7829b67edf8dec27997dd821650ed4d61aea721f802d46a8d47014f4b4246db1",
|
||||
"size_bytes": 97730549
|
||||
"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": "c839cbdcb356a503d87bc17f52b6e5f3a934ae1e72a891f2d21d85366f23debb224a2a40b9124bab50fe95444e01e27711690b1bc50062f40b7ed4f34e078d06",
|
||||
"release_date": "2026-09-07",
|
||||
"signature": "6449ce6ef35a4ef4fa6d0923bb58a2bff52ea5430d5532496e8f0af9ed52eaec293f19d7bec272dc9bc1af5fb2cdfa0e46068c827a9a4dd9cbef92d1c5845301",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.8-alpha"
|
||||
"version": "1.8.11-alpha"
|
||||
}
|
||||
|
||||
+3262
-3203
File diff suppressed because one or more lines are too long
+17
-18
@@ -1,30 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**SSH over the mesh is now a first-class setting.** Settings gains an \"SSH over mesh\" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit \"I understand\" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the `90-ssh.nft` drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, `fipssh <user>@<node-npub>` connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.",
|
||||
"**The App Store now lists apps — not parts of apps.** The signed catalog carries every manifest because the node's update layer needs their pins, and the store briefly listed them all: Mempool API, LND UI, Bitcoin UI, the Pine voice engines, the IndeeHub and Immich backends, the mesh router and friends. Components are hidden from the store listing (they still appear where they belong — the Services tab of My Apps, once installed), and four entries that never earned a tile are gone outright: MorphOS server (old), the Web5 DID wallet, Lightning Stack (an untracked upstream bundle — LND covers the need), and CryptPad (never tested).",
|
||||
"**App icons now persist everywhere, in the proper container style.** Two fixes: installed apps render the icon from their own manifest — Cuprate no longer falls back to the generic A-mark on its Services tile — and the store grids (the Discover page) apply the same icon container treatment (backdrop, border, shadow) as My Apps, the detail pages, and Home. Manifest-declared UI apps also classify correctly again: Alby Hub installs into My Apps with a working tile, not into Services, because a probe miss no longer buries an app the manifest itself says has a frontend.",
|
||||
"**Installing from the store keeps you on the store page.** The install progress lives on the tile itself and the app appears in My Apps when it lands — no more being yanked to My Apps mid-browse."
|
||||
"**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.8-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.8-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.8-alpha",
|
||||
"sha256": "96f39b8db6f08386200e1eab91c8444a7758526e6034100c8a33907ff9263530",
|
||||
"size_bytes": 64175864
|
||||
"new_version": "1.8.11-alpha",
|
||||
"sha256": "ae569054edd6b2491beb101815f6809bc00c95a7dbe86bd084bcb9a7c36e1853",
|
||||
"size_bytes": 64179264
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.8-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.8-alpha/archipelago-frontend-1.8.8-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.8-alpha.tar.gz",
|
||||
"new_version": "1.8.8-alpha",
|
||||
"sha256": "7829b67edf8dec27997dd821650ed4d61aea721f802d46a8d47014f4b4246db1",
|
||||
"size_bytes": 97730549
|
||||
"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": "c839cbdcb356a503d87bc17f52b6e5f3a934ae1e72a891f2d21d85366f23debb224a2a40b9124bab50fe95444e01e27711690b1bc50062f40b7ed4f34e078d06",
|
||||
"release_date": "2026-09-07",
|
||||
"signature": "6449ce6ef35a4ef4fa6d0923bb58a2bff52ea5430d5532496e8f0af9ed52eaec293f19d7bec272dc9bc1af5fb2cdfa0e46068c827a9a4dd9cbef92d1c5845301",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.8-alpha"
|
||||
"version": "1.8.11-alpha"
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.9-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.9-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.9-alpha",
|
||||
"sha256": "39795958963680f56763e3c05e3fe0cd589c30edd9a09416ad325bab4c862123",
|
||||
"size_bytes": 64139152
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-01",
|
||||
"signature": "d7d724b910e827651240bd9520102d66932b57a8a8d674ef645c45eb77f78c123fb45d294ec07f8bbfc3713ed9bd9f98096f59ff18cd6098df51aa473e771908",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.9-alpha"
|
||||
}
|
||||
@@ -19,6 +19,7 @@ INTERNAL_MANIFEST_IDS = {
|
||||
"archy-nbxplorer",
|
||||
"bitcoin-ui",
|
||||
"core-lightning",
|
||||
"cuprate-ui",
|
||||
"electrs-ui",
|
||||
"fips-ui",
|
||||
"lnd-ui",
|
||||
|
||||
+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
|
||||
@@ -64,6 +64,7 @@ SINGLE = {
|
||||
"bitcoin-ui": "BITCOIN_UI_IMAGE",
|
||||
"lnd-ui": "LND_UI_IMAGE",
|
||||
"electrs-ui": "ELECTRS_UI_IMAGE",
|
||||
"cuprate-ui": "CUPRATE_UI_IMAGE",
|
||||
"homeassistant": "HOMEASSISTANT_IMAGE",
|
||||
"grafana": "GRAFANA_IMAGE",
|
||||
"uptime-kuma": "UPTIME_KUMA_IMAGE",
|
||||
|
||||
@@ -127,6 +127,7 @@ IMMICH_SERVER_IMAGE="$ARCHY_REGISTRY/immich-server:release"
|
||||
BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.123-alpha"
|
||||
LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:1.7.123-alpha"
|
||||
ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:1.7.123-alpha"
|
||||
CUPRATE_UI_IMAGE="$ARCHY_REGISTRY/cuprate-ui:1.7.123-alpha"
|
||||
|
||||
# Base images
|
||||
NGINX_ALPINE_IMAGE="$ARCHY_REGISTRY/nginx:1.27.4-alpine"
|
||||
|
||||
@@ -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