Compare commits

...
Author SHA1 Message Date
ssmithxandClaude Sonnet 5 5f83fd1fee feat(openwrt): make TollGate payout Lightning address configurable
Archipelago never touched /etc/tollgate/identities.json — the "owner"
payout identity was whatever the router's TollGate install happened to
default to. Confirmed live against archy-x250-pa3: an unmodified upstream
placeholder (tollgate@minibits.cash), meaning 79% of every customer payment
would auto-payout to an address the operator never chose and doesn't
control.

Adds TollGateConfig.payout_address (opt-in — None leaves the router
untouched), config::apply_payout_identity() to merge it into the "owner"
entry of identities.json without disturbing the merchant keypair or the
other profit-share identities, an RPC param on openwrt.provision-tollgate,
and a status field + reconfigure-form input in the OpenWrt Gateway panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-07 20:23:19 +00:00
ssmithxandClaude Sonnet 5 2b2a14c569 docs(tollgate-sweep): document two live-confirmed drain-CLI bugs
sweep_once() has never actually swept anything: `tollgate wallet drain
cashu` (no flags) blocks on an interactive y/N confirmation that Router::run
can never answer over a non-PTY SSH exec (empty stdin -> EOF -> defaults to
N -> "Operation cancelled." with exit code 0), so the drain_code != 0 check
can't catch it and every tick silently no-ops.

The obvious fix isn't safe either: `--json` skips the prompt, but confirmed
live against archy-x250-pa3 that on a wallet.db with a stale duplicate
per-mint entry (trailing-slash leftover from before the mint_url fix), it
completes a real swap against the good entry, then aborts on the second
(empty, stale) entry and reports "success": false without ever printing or
persisting the resulting token anywhere. 50 sats went from spendable balance
to gone in that one call. Documented so nobody "fixes" this by wiring in
--json before upstream fixes the partial-failure data loss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-07 17:32:26 +00:00
ssmithxandClaude Sonnet 5 f355ae753b fix(openwrt): close TollGate free-access gap and mint URL mismatch
Two bugs found live against archy-x250-pa3: TollGate-3458 (the upstream
tollgate-module-basic-go installer's own default AP, rebranded from
OpenWrt's factory default wireless.default_radioN sections) was left
bound to `network=lan` — wide open, unmetered, and sharing the router's
admin LAN — because install_ipk() runs the upstream package's own
uci-defaults scripts but nothing reconciled the AP they create with the
separate `tollgate` network/bridge/firewall this project's own
provision_ssid() sets up for the "archipelago" SSID. Fixed by folding any
default_radioN section left on `lan` onto the `tollgate` network right
after it's created.

Separately, a caller-supplied mint_url with a trailing slash
(https://mint.minibits.cash/Bitcoin/) got written byte-for-byte into
accepted_mints[0].url, which tollgate-wrt string-compares exactly against
a token's embedded (slash-less) mint URL — rejecting every otherwise-valid
token as an "untrusted mint". Fixed by trimming trailing slashes before
the value is used anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-07 14:37:12 +00:00
archipelago db52c06a72 chore(catalog): sign Cuprate registry update 2026-09-07 05:12:05 -04:00
archipelago 4b14b62e74 chore: publish release v1.8.11-alpha
Demo images / Build & push demo images (push) Successful in 3m40s
2026-09-07 04:35:48 -04:00
archipelago 5da91e4099 chore: prepare release v1.8.11-alpha 2026-09-07 04:32:02 -04:00
archipelago 62731cc729 test(ui): use shipped app for generated launch port check 2026-09-07 03:30:29 -04:00
archipelago 5e17ace690 style(openwrt): format TollGate installer 2026-09-07 03:26:31 -04:00
archipelago b010471a4a chore(release): prep v1.8.11 notes and link checks 2026-09-07 03:26:01 -04:00
ai c4ede96517 Merge PR #154: docs(openwrt): OpenWrt Gateway setup guide + live-tested fixes
Demo images / Build & push demo images (push) Successful in 3m52s
2026-09-07 07:24:33 +00:00
ai be06e1a502 Merge PR #153: fix(cuprate): enable fast_sync and raise DB cache 2026-09-07 07:24:25 +00:00
ssmithxandClaude Sonnet 5 094f42312c docs(openwrt): document the confirmed working end-to-end install flow
Adds a verification checklist (service running, nodogsplash bound to
br-tollgate not br-lan via the rendered config not just UCI, LAN/SSH
untouched, mint probes succeeding) plus notes on the dev-build test-mint
injection and the default-route race between a router's LAN interface
and the node's other uplinks before the router's own WAN/WISP is live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-07 03:06:58 +00:00
ssmithxandClaude Sonnet 5 da8c3ec193 docs(openwrt): note the Ctrl+T/LuCI workaround for setting the initial root password
Archipelago's Connect form only authenticates with an existing password;
it has no flow for setting one on a fresh, passwordless router. On the
node's kiosk display there's no visible tab bar, so Ctrl+T to open a new
tab to LuCI is the way to set it before Connect will work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-07 02:53:21 +00:00
ssmithxandClaude Sonnet 5 4fdf8e8c58 fix(openwrt): bump pinned TollGate release v0.2.0 -> v0.5.0
The install code was hardcoded to the Oct 2025 v0.2.0 release —
nine releases behind. Its changelog covers exactly the failures hit
live against archy-x250-pa3: a mint with an empty/broken keyset
crash-looped tollgate-wrt forever (v0.5.0 adds "graceful degradation
when Cashu mints fail"), and the bundled captive-portal JS had zero
CBOR support, hard-rejecting the cashuB (NUT-00 V4) tokens modern
wallets like Minibits generate by default.

Also: v0.5.0 publishes native .apk packages for aarch64_cortex-a53
and x86_64. install_tollgate_apk_native now prefers those directly
(apk add handles deps/postinst/uci-defaults itself) instead of always
falling back to the manual ar/tar .ipk extraction dance, which only
exists because earlier releases had no native apk build at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 17:28:20 +00:00
ssmithxandClaude Sonnet 5 61b5d93b11 docs(openwrt): document the transient post-reboot apk-update failure
Observed live on archy-x250-pa3: right after WAN reconnects (fresh
boot or WAN reconfigure), the first Install attempt can fail with
"apk update failed ... router may have no internet access" purely
because the WiFi-uplink STA association hasn't finished yet — it's
not a real error, just retry a few seconds later. Also cross-referenced
the now-fixed /usr/bin/opkg hardcoding bug for anyone hitting it on an
older build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 16:36:53 +00:00
ssmithxandClaude Sonnet 5 be06b3ce2b fix(ui): stop sending an empty ssh_password over the saved router connection
provisionTollgate/saveTollgateConfig/scanWifi/configureWan all fell
back to the Connect form's local refs (host/sshUser/sshPassword) when
connectedParams was null. Those refs only get populated if the form
was actually submitted this session — on a normal page load the
router reconnects via the server-persisted config instead, leaving
sshPassword at its default ''. Sending that as an explicit
(empty-but-present) ssh_password overrides the backend's saved-config
fallback, so every action auths with a blank password instead of the
real saved one.

Added authParams(): omit host/ssh_user/ssh_password entirely unless
connectedParams is actually set, same as the status poll already does.
Caught live: dropbear on archy-x250-pa3's router logged a single bad
password attempt at the exact moment "Install TollGate" was clicked,
sandwiched between periodic status-poll connections succeeding with
the real saved password.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 15:14:07 +00:00
ssmithxandClaude Sonnet 5 f3d96ae2ee fix(openwrt): resolve opkg/apk via $PATH, not a hardcoded /usr/bin path
opkg_check() and every opkg/apk invocation hardcoded /usr/bin/opkg and
/usr/bin/apk. Official OpenWrt images don't all symlink /bin into
/usr/bin — the glinet_gl-mt3000 24.10.2 build keeps them as separate
real directories with opkg living in /bin — so the check silently
missed a perfectly normal install and TollGate provisioning failed
with "this router's firmware may not support package management".

Switched every call to resolve through the router's own $PATH
(command -v / bare opkg / apk) instead. Reproduced and fixed live
against archy-x250-pa3, 2026-09-05.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 15:14:00 +00:00
ssmithxandClaude Sonnet 5 a4ae375617 docs(openwrt): fix TollGate step — install is separate from configure
Step 4 described a single "Provision TollGate" action that prompts for
price/step/mint upfront. The real UI (OpenWrtGateway.vue) doesn't work
that way: "Install TollGate" is a one-click action with no config form
that installs with defaults, and price/step/mint/enabled are only
editable afterward via a separate "Edit" panel. Caught while walking
through a live install.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:32:39 +00:00
ssmithxandClaude Sonnet 5 0646bc4e85 docs(openwrt): add GL.iNet AX3000 → stock OpenWrt flashing steps
Worked example for the Beryl AX (GL-MT3000, mediatek/filogic) verified
against the OpenWrt wiki and firmware selector: exact sysupgrade image
filename, GL.iNet UI / LuCI flash path, post-flash SSH state, and the
U-Boot recovery procedure if the flash goes sideways.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:26:21 +00:00
ssmithxandClaude Sonnet 5 0faaf4577f docs: add OpenWrt Gateway setup guide
Walks a node operator through pairing an OpenWrt router over SSH,
running the WAN/WISP wizard, and provisioning TollGate pay-as-you-go
WiFi — plus an RPC/architecture reference for developers. Distills
the openwrt crate, RPC handlers, and Vue panel into user-facing steps
that didn't exist anywhere in docs/ before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:07:05 +00:00
22 changed files with 4059 additions and 3352 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # 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) ## 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. - **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.
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.8.10-alpha" version = "1.8.11-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.8.10-alpha" version = "1.8.11-alpha"
edition = "2021" edition = "2021"
license.workspace = true license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
+28 -2
View File
@@ -135,7 +135,7 @@ impl RpcHandler {
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk // not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
// *package* name, never an on-disk filename. // *package* name, never an on-disk filename.
let tollgate_installed = router 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") test -f /usr/bin/tollgate-wrt 2>/dev/null")
.map(|(_, code)| code == 0) .map(|(_, code)| code == 0)
.unwrap_or(false); .unwrap_or(false);
@@ -150,6 +150,7 @@ impl RpcHandler {
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1), "min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(), "currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(), "mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
"payout_address":router.uci_get("tollgate.main.payout_address").unwrap_or_default(),
}) })
} else { } else {
serde_json::json!({ "installed": false }) serde_json::json!({ "installed": false })
@@ -199,10 +200,15 @@ impl RpcHandler {
/// ///
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "", /// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
/// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1, /// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1,
/// "mint_url": "<optional override>" }` /// "mint_url": "<optional override>",
/// "payout_address": "<optional Lightning address>" }`
/// ///
/// `mint_url` defaults to `http://<this node's IP>:3338` — the local Cashu /// `mint_url` defaults to `http://<this node's IP>:3338` — the local Cashu
/// mint that must be running as an Archy app before calling this endpoint. /// mint that must be running as an Archy app before calling this endpoint.
///
/// `payout_address` sets the "owner" identity's Lightning address for
/// TollGate's own built-in payout (see `config::apply_payout_identity`).
/// Omitted or blank leaves whatever's already on the router untouched.
pub(super) async fn handle_openwrt_provision_tollgate( pub(super) async fn handle_openwrt_provision_tollgate(
&self, &self,
params: Option<serde_json::Value>, params: Option<serde_json::Value>,
@@ -240,12 +246,31 @@ impl RpcHandler {
.unwrap_or_default(); .unwrap_or_default();
let default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT); let default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT);
// Trim trailing slash(es): tollgate-wrt matches a token's embedded
// mint URL against this value with an exact string compare, and
// Cashu wallets (Minibits included) encode mint URLs without a
// trailing slash. A stray slash here means every otherwise-valid
// token gets rejected as "untrusted mint" — confirmed live against
// archy-x250-pa3 2026-09-07 with a manually-entered
// "https://mint.minibits.cash/Bitcoin/".
let mint_url = p let mint_url = p
.get("mint_url") .get("mint_url")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(&default_mint_url) .unwrap_or(&default_mint_url)
.trim_end_matches('/')
.to_string(); .to_string();
// `None` (not sent, or sent blank) leaves whatever's already on the
// router untouched — see apply_payout_identity's doc comment for why
// that matters (an upstream-default placeholder otherwise survives
// forever, since nothing else ever writes this field).
let payout_address = p
.get("payout_address")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let config = TollGateConfig { let config = TollGateConfig {
ssid: "archipelago".to_string(), ssid: "archipelago".to_string(),
mint_url, mint_url,
@@ -256,6 +281,7 @@ impl RpcHandler {
.unwrap_or(60_000), .unwrap_or(60_000),
min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32, min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true), enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
payout_address,
}; };
// Blocking SSH session, and provision runs `opkg install` over it — // Blocking SSH session, and provision runs `opkg install` over it —
+40
View File
@@ -22,6 +22,46 @@ use crate::wallet::ecash;
/// ///
/// Returns the total sats swept in (0 if there was nothing to do, including /// Returns the total sats swept in (0 if there was nothing to do, including
/// when no router is configured or it doesn't have TollGate installed). /// when no router is configured or it doesn't have TollGate installed).
///
/// # KNOWN BROKEN as of 2026-09-07 — do not "fix" by adding `--json` without
/// reading the rest of this comment first.
///
/// Confirmed live against archy-x250-pa3, two stacked bugs in the upstream
/// `tollgate` CLI, not in this function:
///
/// 1. **This call never actually drains anything.** `tollgate wallet drain
/// cashu` (no flags — what this function runs) prints an interactive
/// `Are you sure? (y/N)` confirmation and reads stdin for the answer.
/// `Router::run` executes over SSH with no PTY and empty stdin, so it
/// always reads EOF, defaults to "N", and prints "Operation cancelled." —
/// **with exit code 0**. The `drain_code != 0` check below can never catch
/// this, so every single tick silently falls through to "no `Token:`
/// lines found" → `Ok(0)`. No error, no log line (even at `warn!`), just
/// quiet total inaction, forever. This has presumably never swept a
/// single sat on any node.
///
/// 2. **The obvious fix is worse.** `tollgate --json wallet drain cashu`
/// *does* skip the confirmation prompt — but confirmed live: when the
/// wallet's internal per-mint registry holds more than one entry for what
/// is really the same mint (here: `https://mint.minibits.cash/Bitcoin` vs.
/// a stale `.../Bitcoin/` — leftover from before the trailing-slash
/// `mint_url` fix elsewhere in this codebase; `wallet.db` still had a
/// proof/registry entry keyed under the old slashed URL even after
/// `config.json` was corrected), the CLI appears to complete a real swap
/// against the *good* entry — spending and irreversibly consuming the
/// original proofs, per how Cashu swaps work — then hits the second,
/// empty, stale-keyed entry, reports the whole command as
/// `"success": false`, and **never prints or persists the resulting
/// token anywhere** (checked every location its own "will be saved to a
/// file" warning implies: `/etc/tollgate/ecash/`, `/root`, `/tmp`,
/// nothing). Balance went from 50 sats to 0 across that one call. The
/// funds are gone — there is no undo once a swap is submitted to the
/// mint.
///
/// Do not wire `--json` into this function until upstream fixes partial
/// per-mint failure handling in `drain cashu` to preserve/return whatever it
/// already successfully drained. Until then, the current silent-no-op
/// behavior, while useless, is at least safe.
pub async fn sweep_once(data_dir: &Path) -> Result<u64> { pub async fn sweep_once(data_dir: &Path) -> Result<u64> {
let cfg = net_router::load_router_config(data_dir).await?; let cfg = net_router::load_router_config(data_dir).await?;
if !cfg.configured { if !cfg.configured {
+19 -15
View File
@@ -15,25 +15,32 @@ pub enum PkgManager {
impl Router { impl Router {
/// Detect which package manager is available. /// Detect which package manager is available.
/// ///
/// - If `/usr/bin/opkg` exists → `PkgManager::Opkg` (nothing to do). /// Looks up `opkg`/`apk` via the router's `$PATH` (`command -v`) rather
/// - If `/usr/bin/apk` exists → run `apk update` (switching repos to HTTP /// 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 /// 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 /// `apk add opkg`. If opkg is in the repos → `Opkg`. If not (OpenWrt
/// 25.x) → `ApkNative`. /// 25.x) → `ApkNative`.
/// - Neither found → error. /// - Neither found → error.
pub fn opkg_check(&self) -> Result<PkgManager> { 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 { if code == 0 {
return Ok(PkgManager::Opkg); 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 { if apk_code == 0 {
info!("[{}] opkg not found — using apk (OpenWrt 25.x+)", self.host); info!("[{}] opkg not found — using apk (OpenWrt 25.x+)", self.host);
// Fresh images ship without a CA bundle; switch repos to HTTP so // Fresh images ship without a CA bundle; switch repos to HTTP so
// apk's wget can reach the package index without TLS verification. // 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")?; 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 { if update_code != 0 {
anyhow::bail!( anyhow::bail!(
"apk update failed (exit {}) — router may have no internet access. \ "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). // 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 { if add_code == 0 {
return Ok(PkgManager::Opkg); return Ok(PkgManager::Opkg);
} }
@@ -62,7 +69,7 @@ impl Router {
} }
anyhow::bail!( 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)" support package management (TollGate requires a standard OpenWrt build)"
); );
} }
@@ -70,31 +77,28 @@ impl Router {
/// `opkg update` — refresh package lists. /// `opkg update` — refresh package lists.
pub fn opkg_update(&self) -> Result<()> { pub fn opkg_update(&self) -> Result<()> {
info!("[{}] opkg update", self.host); info!("[{}] opkg update", self.host);
self.run_ok("/usr/bin/opkg update")?; self.run_ok("opkg update")?;
Ok(()) Ok(())
} }
/// Install a package, skipping if already installed. /// Install a package, skipping if already installed.
pub fn opkg_install(&self, package: &str) -> Result<()> { pub fn opkg_install(&self, package: &str) -> Result<()> {
// Check if already installed to avoid unnecessary network traffic. // Check if already installed to avoid unnecessary network traffic.
let (_, code) = self.run(&format!( let (_, code) = self.run(&format!("opkg list-installed | grep -q '^{} '", package))?;
"/usr/bin/opkg list-installed | grep -q '^{} '",
package
))?;
if code == 0 { if code == 0 {
info!("[{}] {} already installed", self.host, package); info!("[{}] {} already installed", self.host, package);
return Ok(()); return Ok(());
} }
info!("[{}] opkg install {}", self.host, package); info!("[{}] opkg install {}", self.host, package);
self.run_ok(&format!("/usr/bin/opkg install {}", package))?; self.run_ok(&format!("opkg install {}", package))?;
Ok(()) Ok(())
} }
/// Remove a package. /// Remove a package.
pub fn opkg_remove(&self, package: &str) -> Result<()> { pub fn opkg_remove(&self, package: &str) -> Result<()> {
info!("[{}] opkg remove {}", self.host, package); info!("[{}] opkg remove {}", self.host, package);
self.run_ok(&format!("/usr/bin/opkg remove {}", package))?; self.run_ok(&format!("opkg remove {}", package))?;
Ok(()) Ok(())
} }
@@ -121,7 +125,7 @@ impl Router {
} }
info!("[{}] apk add {}", self.host, package); info!("[{}] apk add {}", self.host, package);
self.run_ok(&format!("/usr/bin/apk add {}", package))?; self.run_ok(&format!("apk add {}", package))?;
Ok(()) Ok(())
} }
} }
+88 -13
View File
@@ -23,6 +23,14 @@ pub struct TollGateConfig {
pub min_steps: u32, pub min_steps: u32,
/// Whether the TollGate service should be running and enabled at boot. /// Whether the TollGate service should be running and enabled at boot.
pub enabled: bool, pub enabled: bool,
/// Operator's own Lightning address for the daemon's built-in payout
/// (the "owner" entry in `/etc/tollgate/identities.json`, `profit_share`
/// weight 0.79 in the upstream default). `None` leaves whatever is
/// already on the router untouched — which, on a router whose TollGate
/// wasn't provisioned through this project, is an unmodified upstream
/// placeholder nobody actually controls (confirmed live against
/// archy-x250-pa3 2026-09-07: shipped as `tollgate@minibits.cash`).
pub payout_address: Option<String>,
} }
impl Default for TollGateConfig { impl Default for TollGateConfig {
@@ -34,6 +42,7 @@ impl Default for TollGateConfig {
step_size_ms: 60_000, step_size_ms: 60_000,
min_steps: 1, min_steps: 1,
enabled: true, enabled: true,
payout_address: None,
} }
} }
} }
@@ -46,19 +55,27 @@ impl Default for TollGateConfig {
/// tollgate.main.enabled` etc.); changing pricing or the mint here has no /// tollgate.main.enabled` etc.); changing pricing or the mint here has no
/// effect on what the daemon advertises or accepts. /// effect on what the daemon advertises or accepts.
pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> { pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> {
router.uci_apply( let step_size = cfg.step_size_ms.to_string();
"tollgate", let min_steps = cfg.min_steps.to_string();
&[ let price_sats = cfg.price_sats.to_string();
("tollgate.main", "tollgate"),
("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }), let mut pairs = vec![
("tollgate.main.metric", "milliseconds"), ("tollgate.main", "tollgate"),
("tollgate.main.step_size", &cfg.step_size_ms.to_string()), ("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }),
("tollgate.main.min_steps", &cfg.min_steps.to_string()), ("tollgate.main.metric", "milliseconds"),
("tollgate.main.price_per_step", &cfg.price_sats.to_string()), ("tollgate.main.step_size", step_size.as_str()),
("tollgate.main.currency", "sat"), ("tollgate.main.min_steps", min_steps.as_str()),
("tollgate.main.mint_url", &cfg.mint_url), ("tollgate.main.price_per_step", price_sats.as_str()),
], ("tollgate.main.currency", "sat"),
)?; ("tollgate.main.mint_url", &cfg.mint_url),
];
// Status-display only (see doc comment above) — only written when the
// caller actually supplied one, so a reconfigure that doesn't touch
// payout leaves whatever's already there alone.
if let Some(addr) = &cfg.payout_address {
pairs.push(("tollgate.main.payout_address", addr));
}
router.uci_apply("tollgate", &pairs)?;
Ok(()) Ok(())
} }
@@ -97,3 +114,61 @@ pub fn apply_daemon_config(router: &Router, cfg: &TollGateConfig) -> Result<()>
.context("upload /etc/tollgate/config.json")?; .context("upload /etc/tollgate/config.json")?;
Ok(()) Ok(())
} }
/// Set the operator's own payout Lightning address in
/// `/etc/tollgate/identities.json` — the "owner" entry under
/// `public_identities` (`profit_share` weight 0.79 in the upstream default;
/// the other entries there are revenue-share addresses for the upstream
/// project's own maintainers and must never be touched by this function).
///
/// No-op when `payout_address` is `None` — the UI only sends one when the
/// operator has actually filled the field in, so a reconfigure of price/mint
/// alone never overwrites this. Merges into whatever identities.json already
/// exists (same reasoning as `apply_daemon_config`: `owned_identities` holds
/// the merchant's own private key and must survive untouched); creates an
/// "owner" entry if none exists yet rather than erroring, since a router
/// whose TollGate wasn't provisioned through this project may have any
/// upstream-default shape here.
///
/// Must run before the daemon restart in `restart_services` — like
/// `config.json`, `tollgate-wrt` only reads `identities.json` at startup.
pub fn apply_payout_identity(router: &Router, payout_address: Option<&str>) -> Result<()> {
let Some(address) = payout_address else {
return Ok(());
};
let existing = router.run_ok("cat /etc/tollgate/identities.json 2>/dev/null || echo '{}'")?;
let mut doc: serde_json::Value =
serde_json::from_str(existing.trim()).unwrap_or_else(|_| serde_json::json!({}));
let identities = doc
.as_object_mut()
.context("identities.json root is not a JSON object")?
.entry("public_identities")
.or_insert_with(|| serde_json::json!([]));
let identities = identities
.as_array_mut()
.context("identities.json public_identities is not an array")?;
match identities
.iter_mut()
.find(|i| i.get("name").and_then(|n| n.as_str()) == Some("owner"))
{
Some(owner) => {
owner["lightning_address"] = serde_json::json!(address);
}
None => {
identities.push(serde_json::json!({
"name": "owner",
"pubkey": "not currently used",
"lightning_address": address,
}));
}
}
let json_str = serde_json::to_string_pretty(&doc).context("serialize identities.json")?;
router
.upload_file("/etc/tollgate/identities.json", json_str.as_bytes())
.context("upload /etc/tollgate/identities.json")?;
Ok(())
}
+82 -14
View File
@@ -6,18 +6,53 @@ use crate::Router;
/// The OpenWrt package name for the TollGate reference implementation. /// The OpenWrt package name for the TollGate reference implementation.
const TOLLGATE_PACKAGE: &str = "tollgate-module-basic-go"; 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. /// Used when the package is not in any configured feed.
/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.2.0 /// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.5.0
fn ipk_url(arch: &str) -> Option<&'static str> { fn ipk_url(arch: &str) -> Option<String> {
match arch { let name = match arch {
"mips_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mips_24kc.ipk"), "mips_24kc" => "mips_24kc",
"mipsel_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mipsel_24kc.ipk"), "mipsel_24kc" => "mipsel_24kc",
"aarch64_cortex-a53" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a53.ipk"), "aarch64_cortex-a53" => "aarch64_cortex-a53",
"aarch64_cortex-a72" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a72.ipk"), "aarch64_cortex-a72" => "aarch64_cortex-a72",
"arm_cortex-a7" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/arm_cortex-a7.ipk"), "arm_cortex-a7" => "arm_cortex-a7",
_ => None, "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). /// 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. // Package not in any feed — download the .ipk directly.
let arch = router let arch = router.run_ok(
.run_ok("/usr/bin/opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'")?; "opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'",
)?;
let arch = arch.trim(); let arch = arch.trim();
let url = ipk_url(arch).ok_or_else(|| { 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 \ ". /etc/openwrt_release 2>/dev/null \
&& a=\"${DISTRIB_ARCH:-${OPENWRT_ARCH:-}}\" \ && a=\"${DISTRIB_ARCH:-${OPENWRT_ARCH:-}}\" \
&& [ -n \"$a\" ] && echo \"$a\" \ && [ -n \"$a\" ] && echo \"$a\" \
|| /usr/bin/apk --print-arch 2>/dev/null \ || apk --print-arch 2>/dev/null \
|| uname -m", || uname -m",
)?; )?;
// Normalise: uname -m returns bare "mipsel"/"mips"; map to 24kc variant // 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"); 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(|| { let url = ipk_url(arch).ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"No pre-built TollGate package for architecture '{}'. \ "No pre-built TollGate package for architecture '{}'. \
+7
View File
@@ -59,10 +59,17 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
config::apply(router, config)?; config::apply(router, config)?;
wifi::provision_ssid(router, config)?; wifi::provision_ssid(router, config)?;
// Must come after provision_ssid (creates the `tollgate` network this
// folds the upstream installer's own default AP onto) — see
// regate_upstream_default_aps for why this is needed at all.
wifi::regate_upstream_default_aps(router)
.context("re-gate upstream tollgate-module-basic-go default AP(s)")?;
// Must come after provision_ssid (which creates br-tollgate) and before // Must come after provision_ssid (which creates br-tollgate) and before
// the daemon restart below — config.json is only read at startup. // the daemon restart below — config.json is only read at startup.
config::apply_daemon_config(router, config) config::apply_daemon_config(router, config)
.context("write /etc/tollgate/config.json — tollgate-wrt reads this, not UCI")?; .context("write /etc/tollgate/config.json — tollgate-wrt reads this, not UCI")?;
config::apply_payout_identity(router, config.payout_address.as_deref())
.context("write /etc/tollgate/identities.json owner payout address")?;
// Also must come after provision_ssid: points gatewayinterface at // Also must come after provision_ssid: points gatewayinterface at
// br-tollgate, which provision_ssid is what creates. // br-tollgate, which provision_ssid is what creates.
nodogsplash::configure(router, config) nodogsplash::configure(router, config)
+43
View File
@@ -118,6 +118,49 @@ fn provision_firewall(router: &Router) -> Result<()> {
Ok(()) Ok(())
} }
/// Fold the upstream `tollgate-module-basic-go` installer's own default
/// AP(s) onto the gated `tollgate` network.
///
/// `install::install_ipk` runs the package's `/etc/uci-defaults/*` first-boot
/// scripts itself (no real package manager to trigger them on OpenWrt 25.x —
/// see its doc comment). Those upstream scripts rebrand OpenWrt's
/// factory-default wifi sections (`wireless.default_radioN`, present on
/// every fresh install) to a `TollGate-<serial>` SSID, but only ever touch
/// the SSID — they leave `network` at its original `lan` binding. Nothing
/// else in this project's own provisioning (`provision_ssid` above) ever
/// looks at those sections; it only manages the separate `wireless.tollgate`
/// SSID it creates itself. Left alone, the result is two open SSIDs
/// broadcasting side by side: ours (gated by NoDogSplash) and upstream's
/// (wide open on `lan`, with a direct route to whatever's plugged into the
/// wired LAN port).
///
/// Confirmed live against archy-x250-pa3 2026-09-07: a client joining
/// "TollGate-3458" landed on `br-lan` with unrestricted WAN forwarding and
/// zero NoDogSplash involvement — free, unmetered internet, no captive
/// portal, on the router's own admin network.
///
/// Must run after `provision_network` (needs the `tollgate` network/bridge
/// to already exist) and before the network/wifi restart in
/// `restart_services` picks the new binding up.
pub fn regate_upstream_default_aps(router: &Router) -> Result<()> {
let sections = router.run_ok(
"uci show wireless 2>/dev/null | grep -o '^wireless\\.default_radio[0-9]*' | sort -u",
)?;
for section in sections.lines().map(str::trim).filter(|s| !s.is_empty()) {
let network_key = format!("{}.network", section);
let current = router.uci_get(&network_key).unwrap_or_default();
if current == "lan" {
info!(
"[{}] Re-gating upstream default AP {} (was network=lan) onto the tollgate network",
router.host, section
);
router.uci_set(&network_key, "tollgate")?;
}
}
router.uci_commit(Some("wireless"))?;
Ok(())
}
/// Return the first available wireless radio device name (e.g. "radio0"). /// Return the first available wireless radio device name (e.g. "radio0").
fn detect_radio(router: &Router) -> Result<String> { fn detect_radio(router: &Router) -> Result<String> {
let out = let out =
+1
View File
@@ -10,6 +10,7 @@ disagree, the code wins and the doc is a bug.
- [Talking to your node](COMMANDS.md) — the conversational command surface - [Talking to your node](COMMANDS.md) — the conversational command surface
- [Seed Verification](SEED-VERIFICATION.md) — independently verify your 24-word backup - [Seed Verification](SEED-VERIFICATION.md) — independently verify your 24-word backup
- [Troubleshooting](troubleshooting.md) — common problems and how to resolve them - [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 - [Gamepad / Controller Navigation](GAMEPAD-NAV.md) — driving the UI from a controller
- [Pine voice commands](pine-voice-commands.md) — the voice-satellite phrase surface - [Pine voice commands](pine-voice-commands.md) — the voice-satellite phrase surface
+299
View File
@@ -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`).
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.10-alpha", "version": "1.8.11-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.10-alpha", "version": "1.8.11-alpha",
"dependencies": { "dependencies": {
"@scure/bip39": "^2.2.0", "@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.8.10-alpha", "version": "1.8.11-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",
@@ -64,10 +64,10 @@ describe('appSessionConfig', () => {
configurable: true, 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 // manifest.yml) — assert against the manifest-generated value, which is
// exactly what this test exists to protect. // 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', () => { it('does not treat service-only tcp ports as web launch surfaces', () => {
+41 -16
View File
@@ -36,6 +36,7 @@ interface TollGateStatus {
min_steps?: number min_steps?: number
currency?: string currency?: string
mint_url?: string mint_url?: string
payout_address?: string
} }
interface WanStatus { interface WanStatus {
@@ -115,6 +116,24 @@ const showConnectForm = ref(false)
const connecting = ref(false) const connecting = ref(false)
const connectedParams = ref<Record<string, string> | null>(null) 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 detecting = ref(false)
const detectError = ref('') const detectError = ref('')
const detectedCandidates = ref<string[]>([]) const detectedCandidates = ref<string[]>([])
@@ -131,6 +150,7 @@ const editPriceSats = ref(10)
const editStepSizeMin = ref(1) const editStepSizeMin = ref(1)
const editMinSteps = ref(1) const editMinSteps = ref(1)
const editMintUrl = ref('') const editMintUrl = ref('')
const editPayoutAddress = ref('')
const editEnabled = ref(true) const editEnabled = ref(true)
// WAN setup flow // WAN setup flow
@@ -271,11 +291,7 @@ async function provisionTollgate() {
provisionError.value = '' provisionError.value = ''
provisionSuccess.value = false provisionSuccess.value = false
try { try {
const params: Record<string, unknown> = { const params: Record<string, unknown> = { ...authParams() }
host: connectedParams.value?.host ?? status.value?.host,
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
}
await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 }) await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 })
provisionSuccess.value = true provisionSuccess.value = true
await load(connectedParams.value ?? undefined) await load(connectedParams.value ?? undefined)
@@ -292,6 +308,7 @@ function startEditTollgate() {
editStepSizeMin.value = Math.max(1, Math.round((tg?.step_size_ms ?? 60000) / 60000)) editStepSizeMin.value = Math.max(1, Math.round((tg?.step_size_ms ?? 60000) / 60000))
editMinSteps.value = tg?.min_steps ?? 1 editMinSteps.value = tg?.min_steps ?? 1
editMintUrl.value = tg?.mint_url ?? '' editMintUrl.value = tg?.mint_url ?? ''
editPayoutAddress.value = tg?.payout_address ?? ''
editEnabled.value = tg?.enabled ?? true editEnabled.value = tg?.enabled ?? true
updateTollgateError.value = '' updateTollgateError.value = ''
editingTollgate.value = true editingTollgate.value = true
@@ -302,13 +319,12 @@ async function saveTollgateConfig() {
updateTollgateError.value = '' updateTollgateError.value = ''
try { try {
const params: Record<string, unknown> = { const params: Record<string, unknown> = {
host: connectedParams.value?.host ?? status.value?.host, ...authParams(),
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
price_sats: editPriceSats.value, price_sats: editPriceSats.value,
step_size_ms: editStepSizeMin.value * 60_000, step_size_ms: editStepSizeMin.value * 60_000,
min_steps: editMinSteps.value, min_steps: editMinSteps.value,
mint_url: editMintUrl.value, mint_url: editMintUrl.value,
payout_address: editPayoutAddress.value,
enabled: editEnabled.value, enabled: editEnabled.value,
} }
await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 }) await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 })
@@ -336,11 +352,7 @@ async function scanWifi() {
wanStep.value = 'scanning' wanStep.value = 'scanning'
wanError.value = '' wanError.value = ''
try { try {
const params: Record<string, unknown> = { const params: Record<string, unknown> = { ...authParams() }
host: connectedParams.value?.host ?? status.value?.host,
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
}
const result = await rpcClient.call<{ networks: ScannedNetwork[] }>({ const result = await rpcClient.call<{ networks: ScannedNetwork[] }>({
method: 'openwrt.scan-wifi', method: 'openwrt.scan-wifi',
params, params,
@@ -367,9 +379,7 @@ async function configureWan() {
wanError.value = '' wanError.value = ''
try { try {
const params: Record<string, unknown> = { const params: Record<string, unknown> = {
host: connectedParams.value?.host ?? status.value?.host, ...authParams(),
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
ssid: selectedNetwork.value.ssid, ssid: selectedNetwork.value.ssid,
password: wanPassword.value, password: wanPassword.value,
encryption: selectedNetwork.value.encryption, encryption: selectedNetwork.value.encryption,
@@ -929,6 +939,21 @@ onMounted(() => {
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors font-mono text-xs" class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors font-mono text-xs"
/> />
</div> </div>
<div class="mb-4">
<label class="block text-xs text-white/40 mb-2">Payout Lightning address</label>
<input
v-model="editPayoutAddress"
type="text"
placeholder="you@yourwallet.example"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors font-mono text-xs"
/>
<p class="mt-2 text-xs text-white/40">
Where TollGate's built-in auto-payout sends your share once the on-router balance
crosses its threshold. Leave blank to keep whatever's already set on the router —
on a router not originally provisioned here, that may be an upstream default you
don't control.
</p>
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
:disabled="updatingTollgate" :disabled="updatingTollgate"
@@ -362,6 +362,18 @@ init()
</button> </button>
</div> </div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1"> <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 --> <!-- v1.8.10-alpha -->
<div> <div>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
@@ -369,9 +381,9 @@ init()
<span class="text-xs text-white/40">September 2, 2026</span> <span class="text-xs text-white/40">September 2, 2026</span>
</div> </div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10"> <div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>Lightning sends work again.</strong> v1.8.9's move to LND 0.21's supported payment route shipped without a fee budget, and the API treats a missing one as zero allowed fees — so every wallet send failed "No route to the recipient" all day, on perfectly healthy channels. Payments now carry a proper fee budget and a test keeps it from ever regressing.</p> <p><strong>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". The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't. Nodes without LND are untouched; an unreachable peer is retried gently.</p> <p><strong>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 says what's actually wrong, instead of "you have no channel".</strong> Trying to send while a channel you just opened was still confirming — or when all its balance sits on the far side — produced a modal claiming you had no channel at all, and payment routing failures even showed the receiving copy. The gate now reads your real channel list: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", and only a genuinely channel-less node is sent to open one.</p> <p><strong>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>
</div> </div>
<!-- v1.8.9-alpha --> <!-- v1.8.9-alpha -->
@@ -381,12 +393,13 @@ init()
<span class="text-xs text-white/40">September 1, 2026</span> <span class="text-xs text-white/40">September 1, 2026</span>
</div> </div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10"> <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>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>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>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>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>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>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>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>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>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>The Lightning wallet says what's actually wrong, instead of "you have no channel".</strong> Trying to send while a channel you just opened was still confirming — or when all its balance sits on the far side — produced a modal claiming you had no channel at all. The gate now looks at your real channel list: a confirming channel gets "it unlocks automatically once confirmed, nothing needed from you", a far-side balance gets "you can receive but there's nothing to send right now", and only a genuinely channel-less node is sent to open one.</p> <p><strong>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>
</div> </div>
<!-- v1.8.8-alpha --> <!-- v1.8.8-alpha -->
+17 -17
View File
@@ -1,29 +1,29 @@
{ {
"changelog": [ "changelog": [
"**Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered \"No route to the recipient\" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.", "**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.",
"**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.", "**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.",
"**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." "**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": [ "components": [
{ {
"current_version": "1.8.10-alpha", "current_version": "1.8.11-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.8.10-alpha", "new_version": "1.8.11-alpha",
"sha256": "6c8bd41fed44cd999cb360c00e1b66a2d19d19812cc2b0c8a1677eec2a9579e6", "sha256": "ae569054edd6b2491beb101815f6809bc00c95a7dbe86bd084bcb9a7c36e1853",
"size_bytes": 64178056 "size_bytes": 64179264
}, },
{ {
"current_version": "1.8.10-alpha", "current_version": "1.8.11-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago-frontend-1.8.10-alpha.tar.gz", "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.10-alpha.tar.gz", "name": "archipelago-frontend-1.8.11-alpha.tar.gz",
"new_version": "1.8.10-alpha", "new_version": "1.8.11-alpha",
"sha256": "6b25de8a8e1a4f7fe51594f9bbbe21f5820f417af47a8b309c2dbf8f8723b719", "sha256": "192fd0470b6ccf66e78c80b4a4c3af5468882b85d81959362a3bd11f88b9d71d",
"size_bytes": 97736297 "size_bytes": 97741740
} }
], ],
"release_date": "2026-09-01", "release_date": "2026-09-07",
"signature": "b69926bcb1851ff7d6a5b24519cd4a8015aab4ed4b588ee989d8ce6e3beaeb2cc0eb38078f522ded0d389fe53b7dbcdbf3f40c534b4bfafa5cf4a2ab2c59e40f", "signature": "6449ce6ef35a4ef4fa6d0923bb58a2bff52ea5430d5532496e8f0af9ed52eaec293f19d7bec272dc9bc1af5fb2cdfa0e46068c827a9a4dd9cbef92d1c5845301",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.10-alpha" "version": "1.8.11-alpha"
} }
+3244 -3242
View File
File diff suppressed because one or more lines are too long
+17 -17
View File
@@ -1,29 +1,29 @@
{ {
"changelog": [ "changelog": [
"**Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered \"No route to the recipient\" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.", "**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.",
"**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.", "**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.",
"**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." "**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": [ "components": [
{ {
"current_version": "1.8.10-alpha", "current_version": "1.8.11-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.8.10-alpha", "new_version": "1.8.11-alpha",
"sha256": "6c8bd41fed44cd999cb360c00e1b66a2d19d19812cc2b0c8a1677eec2a9579e6", "sha256": "ae569054edd6b2491beb101815f6809bc00c95a7dbe86bd084bcb9a7c36e1853",
"size_bytes": 64178056 "size_bytes": 64179264
}, },
{ {
"current_version": "1.8.10-alpha", "current_version": "1.8.11-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago-frontend-1.8.10-alpha.tar.gz", "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.10-alpha.tar.gz", "name": "archipelago-frontend-1.8.11-alpha.tar.gz",
"new_version": "1.8.10-alpha", "new_version": "1.8.11-alpha",
"sha256": "6b25de8a8e1a4f7fe51594f9bbbe21f5820f417af47a8b309c2dbf8f8723b719", "sha256": "192fd0470b6ccf66e78c80b4a4c3af5468882b85d81959362a3bd11f88b9d71d",
"size_bytes": 97736297 "size_bytes": 97741740
} }
], ],
"release_date": "2026-09-01", "release_date": "2026-09-07",
"signature": "b69926bcb1851ff7d6a5b24519cd4a8015aab4ed4b588ee989d8ce6e3beaeb2cc0eb38078f522ded0d389fe53b7dbcdbf3f40c534b4bfafa5cf4a2ab2c59e40f", "signature": "6449ce6ef35a4ef4fa6d0923bb58a2bff52ea5430d5532496e8f0af9ed52eaec293f19d7bec272dc9bc1af5fb2cdfa0e46068c827a9a4dd9cbef92d1c5845301",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.10-alpha" "version": "1.8.11-alpha"
} }
+85
View File
@@ -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
+11
View File
@@ -145,6 +145,11 @@ echo "Verifying public download URLs (full GET + size + sha256)..."
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$MANIFEST" \ "$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." || 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 # Assets are proven fetchable — only now may the manifest become live. First
# incorporate concurrent work, then promote in a dedicated commit. Until the # incorporate concurrent work, then promote in a dedicated commit. Until the
# final push succeeds the remote still serves the previous manifest. # 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) sys.exit(1 if bad else 0)
PY 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." echo "ISO for v${VERSION} published and verified on $REMOTE."