Compare commits

..
Author SHA1 Message Date
DorianandClaude Fable 5 f7208e1769 chore: release v1.7.115-alpha
Signed OTA manifest (release root verified). Also: create-release-manifest
tarball perms check made SIGPIPE-proof so releases cut on macOS too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:24:24 +01:00
DorianandClaude Fable 5 77ee39a3df fix(server+quadlet): app UIs work over the mesh — v6 relay + v4-pinned publishes
Rootless podman's wildcard publish claims [::] but BLACK-HOLES inbound
v6 (accepts, forwards nothing — vaultwarden 'empty response' from the
phone, 2026-07-26), while most apps got no v6 listener at all. Two
halves: quadlets now publish unbound ports on 0.0.0.0 explicitly
(frees the v6 side; the one-time drift/recreate at upgrade is the
deploy vehicle), and the daemon runs a self-selecting V6ONLY relay on
every catalog launch port forwarding raw TCP to the v4 loopback
listener. Rescans every 60s so new installs bridge without a restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:48:16 +01:00
DorianandClaude Fable 5 02917cc22c fix(fips): app launch ports allowed through the mesh firewall
The companion opens catalog apps by direct port over the mesh; the
fips0 default-deny baseline blocked every one of them (apps 'stuck'
from the phone, 2026-07-26). Core now writes a second fips.d drop-in
from the generated catalog launch-port list on every install/upgrade.
Service/RPC ports (bitcoind 8332, LND 10009, Tor) stay closed.
generate-app-catalog.py emits the Rust port list alongside the TS one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:23:46 +01:00
DorianandClaude Fable 5 325b9ea9c9 chore: bump version to 1.7.115-alpha (release prep)
Demo images / Build & push demo images (push) Successful in 3m6s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:05:09 +01:00
DorianandClaude Fable 5 9739bbb3db docs+fix: ship fips0 web-UI firewall allowance from core; changelog + What's New for v1.7.115-alpha
Demo images / Build & push demo images (push) Has been cancelled
fips::config::install() now writes /etc/fips/fips.d/80-web-ui.nft
(tcp 80/8443 accept) and reloads the baseline on every install/upgrade —
the hardening firewall default-denies inbound on fips0 and the UI was
unreachable over the mesh without it (root-caused live 2026-07-26).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:03:25 +01:00
DorianandClaude Fable 5 4db1b85fc5 fix(server): web UI listens on IPv6 — the mesh could never load it
The mesh is IPv6-only; the main web listener bound 0.0.0.0 only, so a
phone reaching a node over its fips0 ULA got RST on :80
(ERR_CONNECTION_ABORTED) — UI over mesh was structurally impossible.
Confirmed 2026-07-26 on framework-pt: v4:80 = 200, v6:80 = refused.
Mirror an IPv4-any main listener with a V6ONLY [::] socket on the same
port; V6ONLY so it coexists with the v4 listener regardless of
net.ipv6.bindv6only.

Also: fips.yaml generator carries the fast-reconnect profile (validated
live on framework-pt since 2026-07-24; snapshot tests updated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 21:58:11 +01:00
archipelago a78aa02890 chore: release v1.7.114-alpha
Demo images / Build & push demo images (push) Successful in 3m1s
2026-07-26 13:40:03 -04:00
archipelagoandClaude Fable 5 0365cc0f9d docs: changelog + What's New for v1.7.114-alpha
Demo images / Build & push demo images (push) Successful in 2m52s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 12:24:36 -04:00
archipelagoandClaude Fable 5 0880824fb3 fix(ui): seed QRs use the SeedQR standard so hardware wallets can scan them
Demo images / Build & push demo images (push) Successful in 2m52s
Plain-text seed QRs didn't scan into Passport Prime — wallets that
import seeds by QR (Passport, SeedSigner, Keystone, Nunchuk, Sparrow)
expect the SeedQR standard: each BIP39 word as its zero-padded 4-digit
wordlist index, concatenated into a numeric QR.

- new utils/seedqr.ts encodes BIP39 words per the SeedSigner spec
  (@scure/bip39 wordlist; vector-checked abandon=0000, zoo=2047)
- new shared SeedRevealPanel (Words/QR tabs, tap-to-reveal blur) now
  backs the LND reveal AND the Settings→Backup recovery-phrase reveal,
  so every current and future seed reveal behaves the same
- onboarding seed + Settings reveal: QR defaults to SeedQR with a
  plain-text toggle
- LND seed stays plain-text-only with an explicit note: aezeed is not
  BIP39 and only restores into LND-based wallets (Zeus/Blixt/another
  node) — SeedQR-encoding it would just mislead

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 12:09:43 -04:00
archipelagoandClaude Fable 5 c0d34bd836 fix(mesh): serialize serial-port opens between listener and RPC probe
Observed live on .116 after the dedup/backoff fixes: the kiosk browser's
hot-swap auto-probe (mesh.probe-device) still interleaved its handshakes
with the listener's open sequence on the same tty every backoff window —
Linux double-opens ttys silently, both handshakes corrupted each other,
and every collision's open() DTR/RTS-reset the board again. The retry
heuristic from 5f01ec31 narrowed but couldn't close the race.

A static PORT_OPEN_LOCK now covers the listener's whole device-open
sequence and each probe attempt, so exactly one prober touches the port
at a time. With this + the settle/backoff/dedup fixes, the .116 radio
completed its first MeshCore handshake in days (node-fa161601, 8
contacts) and the session has been stable since.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 08:05:42 -04:00
archipelagoandClaude Fable 5 c8d0dda656 feat(ui): Words / QR code tabs on LND seed reveal + onboarding seed
Demo images / Build & push demo images (push) Successful in 2m54s
Both seed screens get the wallet-settings segmented tab style: words
stay the default first view; the QR tab renders the space-joined seed
words for wallets that support seed import by scan. The LND reveal QR
sits behind the same tap-to-reveal blur as the words, and both panes
warn that the code is equivalent to the words.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:34:19 -04:00
archipelagoandClaude Fable 5 57c6a4d512 style(ui): grey status dots for closing/force-closing channel cards
Demo images / Build & push demo images (push) Successful in 2m55s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:19:59 -04:00
archipelagoandClaude Fable 5 a32e40da31 fix(mesh): stop probing one physical radio as two devices
/dev/mesh-radio is a udev symlink to a ttyUSB*/ttyACM* node that is also
in SERIAL_CANDIDATES, so one board was detected, probed and DTR/RTS-reset
twice per reconnect cycle (and shown twice in the UI):

- detect_serial_devices() dedupes candidates by canonical path, keeping
  the stable /dev/mesh-radio name
- auto-detect fallback skips the preferred path it just probed this cycle
  instead of immediately resetting the same board again
- probe_device's active-session guard compares canonical paths, so a
  probe via the alias can no longer open the tty the live session holds

Together with the 2s boot-settle and stable-session backoff gate, this
takes a plugged-in CP2102/ESP32 board from up to 9 reset events per
cycle at a permanent 5s retry floor down to one probe sequence per
backoff window — enough for the radio to actually finish booting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:19:54 -04:00
archipelagoandClaude Fable 5 449ed7c1f7 fix(mesh): don't reset reconnect backoff for sessions that die young
Extracted from beff5dd5 on archy-hwconfig (the rest of that commit is
flash-feature code staying on its branch): a device that connects then
drops within seconds — e.g. mid-boot-loop — kept resetting backoff to
5s forever, and every retry's open() toggles DTR/RTS which itself
resets ESP32-family boards. Backoff now only resets after a session
survives STABLE_SESSION_THRESHOLD (20s), so an unstable device gets
progressively longer quiet gaps to actually finish booting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:13:27 -04:00
ssmithxandarchipelago 729cee573a fix(mesh): orphaned listener task could race a fresh one on the same port
Traced why a device that's alive and USB-enumerating correctly could still
never complete a single protocol handshake, indefinitely: journal logs
showed genuinely concurrent connection attempts on the same port
(duplicate "Opened serial port"/"Starting X handshake" lines within
microseconds of each other, from what should be sequential probe steps) —
two independent listener sessions were racing on the same tty, each
corrupting the other's reads/writes.

Root cause was in MeshService::stop() combined with mesh::flash's earlier
STOP_LISTENER_TIMEOUT fix: stop() calls `self.listener_handle.take()`
(clearing the field to None immediately) and then awaits the handle with
no bound of its own. When a caller wraps the whole stop() call in a
timeout (as the flash job does, to avoid hanging the RPC response), a slow
listener — mid multi-candidate probe when the shutdown signal arrives —
would cause that outer timeout to cancel the await. But by then
`listener_handle` was already None, so MeshService believed the listener
was stopped, while the actual task kept running, orphaned (dropping a
JoinHandle does not abort the task). A later start() then spawned a
genuinely new listener, racing the orphaned one forever on the same port.

Fixed at the source: stop() now awaits its own handle through a mutable
reference (not by value) with its own bounded timeout, so on timeout it
still owns the handle and can call .abort() on it directly — guaranteeing
the task is actually gone before stop() returns, regardless of how any
caller wraps it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit b14af20d1a)
2026-07-26 07:09:26 -04:00
ssmithxandarchipelago 5b7ebd85b6 fix(mesh): DTR/RTS reset settle time was far shorter than real boot time
Every one of Reticulum/Meshcore/Meshtastic's open() deasserts DTR/RTS on
every connection attempt (needed to clear stale line state, but the
transition itself resets ESP32-S3 native-USB boards and CP2102/CH340-
bridged boards wired for Arduino-style auto-reset — acknowledged in the
existing code comments). Each only waited 300ms before expecting a
handshake response — nowhere near real firmware boot time (LoRa radio
init alone routinely takes longer).

A single auto-detect cycle tries multiple protocols in sequence
(Reticulum, then Meshcore, then Meshtastic), each with its own open() and
thus its own reset. With only 300ms of settle per attempt, a board could
plausibly never finish booting from one attempt's reset before the next
attempt's open() reset it again — a self-sustaining "never finishes
booting" loop that would look identical to firmware/hardware flakiness
from the logs, regardless of which firmware family was actually flashed.
Confirmed live 2026-07-23 on both a Heltec V3 (Meshtastic) and V4
(Meshcore): continuous device-side FROM_RADIO_REBOOTED / boot-loop
symptoms with zero config-write-triggered reboots (manage_radio was false
for part of the test), pointing at the connection layer itself rather
than firmware config provisioning.

Bumped the settle delay from 300ms to 2s in Meshcore's and Meshtastic's
open() — full protocol handshakes that need real boot time. Left
reticulum.rs's probe_rnode settle at 300ms deliberately: it's a
cheap/fast KISS-detect gate designed to fail quickly for non-RNode
firmware (documented elsewhere as "~1s"), not a full handshake, and each
subsequent protocol's own open()+settle is what actually needs to cover
real boot time regardless of what probe_rnode did moments before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 5799c37111)
2026-07-26 07:09:24 -04:00
archipelagoandClaude Fable 5 c6f11f8ddb feat(wallet): on-chain send fee control + BTC/sats amount entry
Demo images / Build & push demo images (push) Successful in 2m55s
- sats/BTC unit toggle on the on-chain amount field with live conversion
  hint; canonical value stays sats end-to-end
- Fast / Standard / Slow fee presets (1 / 6 / 144 block targets) plus a
  custom pane taking target blocks or an explicit sat/vB rate
- confirm pane shows LND's fee estimate for the chosen speed via the new
  lnd.estimatefee RPC (GET /v1/transactions/fee)
- lnd.sendcoins now accepts target_conf / sat_per_vbyte with the same
  mutual-exclusion + range validation as channel opens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:05:10 -04:00
archipelagoandClaude Fable 5 a26090e561 feat(ui): lightning channels All/Active/Pending/Closed tabs + closed-channel history
Demo images / Build & push demo images (push) Successful in 3m6s
Consumes the lnd.closedchannels RPC and closing/force_closing statuses
that shipped backend-side in 00b7e179 but never reached the panel:
- tab bar with per-state counts; pending covers pending_open/closing/force_closing
- closed-channel cards with close type, settled balance, block height and
  closing-tx explorer link
- closing/force_closing status dots + closing-tx link on in-flight closes
- Close button hidden for channels already mid-close

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 06:51:48 -04:00
Claude 2609f60e6c feat(release): gated ISO build pipeline
- scripts/build-iso-release.sh: single gated command from signed release
  to tested ISO (preflight version/signature parity, release gate harness,
  strict catalog drift, full Rust suite, artifact version checks, build,
  mount smoke, best-effort QEMU boot)
- scripts/iso-smoke-test.sh: standalone mount-level ISO verification incl.
  stale-binary version assertion inside the payload
- .gitea/workflows/build-iso.yml: resurrected ISO CI as dispatch-only job
  calling the gated orchestrator (old one was stranded in _archived/)
- tests/release/run.sh: catalog drift now runs --strict (was silently
  always-pass)
- test-iso-qemu.sh: headless mode used -append without -kernel, which
  QEMU rejects; use -display none so -serial file: capture works

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:34:54 -04:00
archipelagoandClaude Fable 5 c4c558954b chore: sign v1.7.113-alpha release manifest
Demo images / Build & push demo images (push) Successful in 2m59s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 15:14:07 -04:00
36 changed files with 1711 additions and 123 deletions
+62
View File
@@ -0,0 +1,62 @@
name: Build Archipelago release ISO (gated)
# Resurrected from image-recipe/_archived/.gitea-workflows/build-iso-dev.yml.
# Dispatch-only on purpose: the ISO is cut per release, not per push, and
# the iso-builder runner is a live node — builds are deliberate events.
on:
workflow_dispatch:
jobs:
build-iso:
runs-on: iso-builder
timeout-minutes: 180
steps:
- name: Sync source to workspace
run: |
# Direct fetch + sync (actions/checkout token is broken on this Gitea)
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main
echo "=== Source at commit: $(git log --oneline -1) ==="
- name: Install ISO build dependencies
run: |
if dpkg -s debootstrap squashfs-tools xorriso isolinux syslinux-common mtools \
grub-efi-amd64-bin grub-pc-bin grub-common >/dev/null 2>&1; then
echo "ISO build deps already installed, skipping apt"
else
sudo apt-get update -qq
sudo apt-get install -y -qq \
debootstrap squashfs-tools xorriso \
isolinux syslinux-common mtools \
grub-efi-amd64-bin grub-pc-bin grub-common
fi
- name: Build backend + frontend if stale
run: |
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
cd "$REPO_DIR"
. "$HOME/.cargo/env" 2>/dev/null || true
VERSION=$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
if ! strings core/target/release/archipelago 2>/dev/null | grep -qF "$VERSION"; then
cargo build --release --manifest-path core/Cargo.toml -p archipelago
fi
if ! grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js 2>/dev/null; then
(cd neode-ui && npm ci && npm run build)
fi
- name: Gated ISO build (gates + build + smoke + qemu)
run: |
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
cd "$REPO_DIR"
. "$HOME/.cargo/env" 2>/dev/null || true
bash scripts/build-iso-release.sh
- name: Report artifacts
if: always()
run: |
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
ls -lh "$REPO_DIR"/image-recipe/results/*.iso 2>/dev/null | tail -3 || echo "no ISO produced"
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## v1.7.115-alpha (2026-07-26)
- The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.
- The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.
- Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.
## v1.7.114-alpha (2026-07-26)
- Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.
- The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.
- Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.
- Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.
- Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.
## v1.7.113-alpha (2026-07-25)
- Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it.
+1 -1
View File
@@ -95,7 +95,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.112-alpha"
version = "1.7.115-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.113-alpha"
version = "1.7.115-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -129,6 +129,7 @@ impl RpcHandler {
"lnd.closechannel" => self.handle_lnd_closechannel(params).await,
"lnd.newaddress" => self.handle_lnd_newaddress().await,
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
+109 -1
View File
@@ -124,16 +124,41 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
}
// Fee control: either a confirmation target or an explicit fee rate
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
if target_conf.is_some() && sat_per_vbyte.is_some() {
return Err(anyhow::anyhow!(
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
));
}
if let Some(tc) = target_conf {
if !(1..=1008).contains(&tc) {
return Err(anyhow::anyhow!(
"Invalid target_conf: must be between 1 and 1008 blocks"
));
}
}
if let Some(rate) = sat_per_vbyte {
if !(1..=5000).contains(&rate) {
return Err(anyhow::anyhow!(
"Invalid sat_per_vbyte: must be between 1 and 5000"
));
}
}
info!(
addr = addr,
amount = amount,
send_all = send_all,
target_conf = target_conf,
sat_per_vbyte = sat_per_vbyte,
"Sending on-chain Bitcoin"
);
let (client, macaroon_hex) = self.lnd_client().await?;
let send_body = match amount {
let mut send_body = match amount {
Some(amount) => serde_json::json!({
"addr": addr,
"amount": amount.to_string(),
@@ -143,6 +168,13 @@ impl RpcHandler {
"send_all": true,
}),
};
if let Some(tc) = target_conf {
send_body["target_conf"] = serde_json::json!(tc);
}
if let Some(rate) = sat_per_vbyte {
// LND REST encodes uint64 as a JSON string
send_body["sat_per_vbyte"] = serde_json::json!(rate.to_string());
}
let resp = client
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
@@ -171,6 +203,82 @@ impl RpcHandler {
Ok(serde_json::json!({ "txid": txid }))
}
/// Estimate the on-chain fee for sending `amount` sats to `addr` at a
/// confirmation target. Returns `{ fee_sat, sat_per_vbyte }` so the send
/// UI can show the cost of each preset before the user confirms.
pub(in crate::api::rpc) async fn handle_lnd_estimatefee(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let addr = params
.get("addr")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
}
let amount = params
.get("amount")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
if !(546..=21_000_000 * 100_000_000).contains(&amount) {
return Err(anyhow::anyhow!("Invalid amount"));
}
let target_conf = params
.get("target_conf")
.and_then(|v| v.as_i64())
.unwrap_or(6);
if !(1..=1008).contains(&target_conf) {
return Err(anyhow::anyhow!(
"Invalid target_conf: must be between 1 and 1008 blocks"
));
}
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/transactions/fee"))
.query(&[
(format!("AddrToAmount[{addr}]"), amount.to_string()),
("target_conf".to_string(), target_conf.to_string()),
])
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to estimate fee")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse fee estimate response")?;
if !status.is_success() {
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to estimate fee: {}", msg));
}
// LND REST encodes int64 as JSON strings
let fee_sat = body
.get("fee_sat")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
let sat_per_vbyte = body
.get("sat_per_vbyte")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
Ok(serde_json::json!({
"fee_sat": fee_sat,
"sat_per_vbyte": sat_per_vbyte,
}))
}
/// Create a Lightning invoice.
/// Create a Lightning invoice and return `(bolt11, payment_hash_hex)`.
///
+17 -10
View File
@@ -248,10 +248,17 @@ impl QuadletUnit {
} else {
proto.as_str()
};
// Keep the rendered directive byte-identical for unbound
// ports so existing units don't read as drifted.
// Unbound publishes are pinned to 0.0.0.0: rootlessport's
// wildcard bind claims [::] too but BLACK-HOLES inbound v6
// (accepts, forwards nothing — "empty response" from the
// mesh, confirmed 2026-07-26). Pinning v4 frees the port's
// v6 side for the daemon's mesh relay (app_port_v6_relay_loop),
// which forwards to the v4 loopback listener that works.
// NOTE: this changes the rendered directive for unbound
// ports on purpose — the one-time drift/recreate at upgrade
// is the deploy vehicle for the fix.
if bind.is_empty() {
let _ = writeln!(s, "PublishPort={host}:{container}/{p}");
let _ = writeln!(s, "PublishPort=0.0.0.0:{host}:{container}/{p}");
} else {
let _ = writeln!(s, "PublishPort={bind}:{host}:{container}/{p}");
}
@@ -1018,7 +1025,7 @@ mod tests {
u.network = NetworkMode::Bridge("archy-net".into());
u.ports = vec![(3000, 3000, "tcp".into(), String::new())];
let s = u.render();
assert!(s.contains("PublishPort=3000:3000/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:3000:3000/tcp"));
}
#[test]
@@ -1167,8 +1174,8 @@ mod tests {
let s = u.render();
assert!(s.contains("PublishPort=127.0.0.1:8332:8332/tcp"));
assert!(s.contains("PublishPort=10.89.0.1:8332:8332/tcp"));
assert!(s.contains("PublishPort=8333:8333/tcp"));
assert!(!s.contains("PublishPort=8332:8332/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:8333:8333/tcp"));
assert!(!s.contains("PublishPort=0.0.0.0:8332:8332/tcp"));
}
#[test]
@@ -1200,8 +1207,8 @@ mod tests {
..QuadletUnit::default()
};
let s = u.render();
assert!(s.contains("PublishPort=8332:8332/tcp"));
assert!(s.contains("PublishPort=8333:8333/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:8332:8332/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:8333:8333/tcp"));
assert!(s.contains("Environment=BITCOIN_RPC_USER=archipelago"));
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
@@ -1317,7 +1324,7 @@ app:
let m = AppManifest::parse(yaml).expect("manifest must parse");
let s = QuadletUnit::from_manifest(&m, "searxng").render();
assert!(s.contains("PublishPort=8888:8080/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:8888:8080/tcp"));
assert!(!s.contains("Network=host"));
}
@@ -1746,7 +1753,7 @@ app:
assert!(body.contains("Network=archy-net"));
assert!(body.contains("NetworkAlias=lnd"));
assert!(body.contains("PodmanArgs=--network-alias=lnd"));
assert!(body.contains("PublishPort=10009:10009/tcp"));
assert!(body.contains("PublishPort=0.0.0.0:10009:10009/tcp"));
assert!(body.contains("Volume=/var/lib/archipelago/lnd:/root/.lnd:Z"));
assert!(body.contains("Environment=LND_NETWORK=mainnet"));
assert!(body.contains("PodmanArgs=--memory=1024m"));
+44
View File
@@ -0,0 +1,44 @@
//! Generated by scripts/generate-app-catalog.py. Do not edit manually.
//!
//! Catalog app launch ports (the web UIs the companion opens by direct
//! port). Used to write the fips0 firewall allowance drop-in so app UIs
//! are reachable over the mesh; ports of apps that aren't installed have
//! no listener, so allowing them is inert.
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283,
2342,
3000,
3001,
3002,
4080,
5180,
7778,
8080,
8081,
8082,
8083,
8084,
8085,
8087,
8088,
8089,
8090,
8096,
8123,
8175,
8176,
8240,
8334,
8888,
8999,
9000,
9100,
10380,
11434,
18081,
18083,
23000,
32838,
50002,
];
+119
View File
@@ -48,6 +48,30 @@ pub struct FipsConfig {
pub struct NodeSection {
pub identity: IdentitySection,
pub discovery: DiscoverySection,
pub retry: RetrySection,
pub rate_limit: RateLimitSection,
}
/// Fast-reconnect profile (`node.retry.*`). Upstream defaults (5s base
/// doubling to 300s) are tuned for stable always-on links; a node redialing
/// a recycled anchor sat off-mesh for ~90s. Field names verified live on
/// 2026-07-24: a daemon restarted with these keys in fips.yaml and peered.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RetrySection {
pub base_interval_secs: u64,
pub max_backoff_secs: u64,
pub max_retries: u32,
}
/// Session-handshake resend pacing (`node.rate_limit.*`). Stock 1s x2.0
/// gaps out to 8-16s between resends exactly when a route has just
/// appeared; 400ms x1.5 keeps continuous coverage through the connect
/// window (phone measured session-after-route: 225ms).
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RateLimitSection {
pub handshake_resend_interval_ms: u64,
pub handshake_resend_backoff: f64,
pub handshake_max_resends: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
@@ -60,6 +84,14 @@ pub struct IdentitySection {
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DiscoverySection {
/// Lookup completion timeout. Lookups fired while the tree position is
/// still settling are doomed; failing them fast (5s, not 10s) lets the
/// 1s-backoff retry find the route the moment it exists.
pub timeout_secs: u64,
pub backoff_base_secs: u64,
pub backoff_max_secs: u64,
pub retry_interval_secs: u64,
pub max_attempts: u8,
pub lan: LanDiscoverySection,
}
@@ -123,8 +155,23 @@ impl Default for FipsConfig {
node: NodeSection {
identity: IdentitySection { persistent: true },
discovery: DiscoverySection {
timeout_secs: 5,
backoff_base_secs: 1,
backoff_max_secs: 30,
retry_interval_secs: 2,
max_attempts: 3,
lan: LanDiscoverySection { enabled: true },
},
retry: RetrySection {
base_interval_secs: 1,
max_backoff_secs: 30,
max_retries: 30,
},
rate_limit: RateLimitSection {
handshake_resend_interval_ms: 400,
handshake_resend_backoff: 1.5,
handshake_max_resends: 10,
},
},
tun: TunSection {
enabled: true,
@@ -186,6 +233,65 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
let _ = tokio::fs::remove_file(&stage).await;
install_result?;
// The release-hardening firewall (/etc/fips/fips.nft, provisioned
// out-of-band) default-denies inbound on fips0 — without an explicit
// allowance the node's web UI is unreachable over the mesh (phones got
// RST on :80 with a healthy session; root-caused 2026-07-26 on
// framework-pt). Ship the allowance as a fips.d drop-in on every
// install/upgrade so no node ever regresses to a UI-less mesh.
sudo_install_dir("/etc/fips/fips.d").await?;
let dropin = "# Written by archipelago on every daemon config install.\n\
# Allows the web UI + peer API through the fips0\n\
# default-deny inbound baseline (fips.nft).\n\
tcp dport 80 accept\n\
tcp dport 8443 accept\n";
let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
tokio::fs::write(&nft_stage, dropin)
.await
.context("Failed to stage web-ui nft drop-in")?;
let nft_install = sudo_install_file(&nft_stage, "/etc/fips/fips.d/80-web-ui.nft", "0644").await;
let _ = tokio::fs::remove_file(&nft_stage).await;
nft_install?;
// App launch ports: the companion opens catalog apps by direct port
// over the mesh. Ports of apps that aren't installed have no listener,
// so the allowance is inert until an app exists to answer.
let port_list = super::app_ports::APP_LAUNCH_PORTS
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ");
let app_dropin = format!(
"# Written by archipelago on every daemon config install.\n\
# Catalog app launch ports (web UIs) allowed through the fips0\n\
# default-deny inbound baseline. Service/RPC ports stay closed.\n\
tcp dport {{ {port_list} }} accept\n"
);
let app_stage = std::env::temp_dir().join(format!("fips-appports-{}.nft", std::process::id()));
tokio::fs::write(&app_stage, app_dropin)
.await
.context("Failed to stage app-ports nft drop-in")?;
let app_install =
sudo_install_file(&app_stage, "/etc/fips/fips.d/85-app-ports.nft", "0644").await;
let _ = tokio::fs::remove_file(&app_stage).await;
app_install?;
// Make the allowance live immediately; a no-op error when the
// hardening baseline isn't installed on this node yet.
if tokio::fs::try_exists("/etc/fips/fips.nft").await.unwrap_or(false) {
match Command::new("sudo")
.args(["nft", "-f", "/etc/fips/fips.nft"])
.output()
.await
{
Ok(out) if !out.status.success() => tracing::warn!(
"nft reload after web-ui drop-in failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
),
Err(e) => tracing::warn!("nft reload after web-ui drop-in failed: {e}"),
_ => {}
}
}
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
// Heal a legacy fips_key.pub that was written as bech32 npub text
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
@@ -318,8 +424,21 @@ node:
identity:
persistent: true
discovery:
timeout_secs: 5
backoff_base_secs: 1
backoff_max_secs: 30
retry_interval_secs: 2
max_attempts: 3
lan:
enabled: true
retry:
base_interval_secs: 1
max_backoff_secs: 30
max_retries: 30
rate_limit:
handshake_resend_interval_ms: 400
handshake_resend_backoff: 1.5
handshake_max_resends: 10
tun:
enabled: true
name: fips0
+1
View File
@@ -26,6 +26,7 @@
#![allow(dead_code)]
pub mod anchors;
pub mod app_ports;
pub mod config;
pub mod dial;
pub mod iface;
+19 -5
View File
@@ -87,6 +87,18 @@ const RECONNECT_DELAY_INIT: Duration = Duration::from_secs(5);
/// Maximum reconnect delay (cap for exponential backoff).
const RECONNECT_DELAY_MAX: Duration = Duration::from_secs(60);
/// Minimum time a session must run before we trust it enough to reset
/// backoff to the minimum. Without this gate, a device that connects then
/// fails again within a couple of seconds (e.g. mid-boot-loop) never backs
/// off — every retry immediately re-opens the port, which toggles DTR/RTS
/// (resets many ESP32 boards' MCU on native-USB and CP2102/CH340
/// auto-reset-circuit boards alike), turning a device that's merely
/// unstable into a self-sustaining boot loop that outlasts whatever
/// triggered the original instability. Confirmed live 2026-07-23: a Heltec
/// V3 stuck retrying every ~5-15s for 5+ minutes after a failed firmware
/// flash left it in a marginal state.
const STABLE_SESSION_THRESHOLD: Duration = Duration::from_secs(20);
/// Number of consecutive write failures before we consider the device dead
/// and trigger a reconnection cycle.
const MAX_CONSECUTIVE_WRITE_FAILURES: u32 = 3;
@@ -560,6 +572,7 @@ pub fn spawn_mesh_listener(
return;
}
let session_start = std::time::Instant::now();
match session::run_mesh_session(
&state,
&data_dir,
@@ -582,13 +595,14 @@ pub fn spawn_mesh_listener(
{
Ok(()) => {
info!("Mesh session ended cleanly");
// Session was established before ending — reset backoff
reconnect_delay = RECONNECT_DELAY_INIT;
// Only trust a session that actually ran for a while —
// see STABLE_SESSION_THRESHOLD's doc comment.
if session_start.elapsed() >= STABLE_SESSION_THRESHOLD {
reconnect_delay = RECONNECT_DELAY_INIT;
}
}
Err(e) => {
// Check if session was ever connected (vs failed to open)
let was_connected = state.status.read().await.device_connected;
if was_connected {
if session_start.elapsed() >= STABLE_SESSION_THRESHOLD {
reconnect_delay = RECONNECT_DELAY_INIT;
}
error!("Mesh session error: {} (retry in {:?})", e, reconnect_delay);
+38 -7
View File
@@ -269,10 +269,23 @@ async fn auto_detect_and_open(
our_ed_pubkey_hex: &str,
our_x25519_pubkey_hex: &str,
device_kind: Option<DeviceType>,
skip_path: Option<&str>,
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
let paths = super::super::serial::detect_serial_devices().await;
let mut paths = super::super::serial::detect_serial_devices().await;
// When falling back from a just-failed preferred path, don't probe that
// same device again in the same cycle — every open() toggles DTR/RTS,
// which resets ESP32-family boards, and back-to-back re-probes are what
// keeps a mid-boot board from ever finishing its boot.
if let Some(skip) = skip_path {
let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| p.into());
let skip_canon = canon(skip);
paths.retain(|p| canon(p) != skip_canon);
}
if paths.is_empty() {
anyhow::bail!("No serial devices found in /dev");
anyhow::bail!(match skip_path {
Some(skip) => format!("No serial devices found in /dev besides {skip}, which was already probed this cycle"),
None => "No serial devices found in /dev".to_string(),
});
}
for path in &paths {
debug!(path = %path, "Probing for mesh radio device");
@@ -359,6 +372,16 @@ pub struct DeviceProbe {
pub max_contacts: Option<u16>,
}
/// Serializes serial-port open sequences between the listener's session
/// opens and the RPC probe (`mesh.probe-device`). Linux happily double-opens
/// a tty, and two concurrent handshakes corrupt each other into silence —
/// observed live on .116 (2026-07-26): the kiosk browser's hot-swap
/// auto-probe collided with the listener's cycle on every backoff window, so
/// neither ever succeeded, and each collision's open() DTR/RTS-reset the
/// board again. The probe's retry-across-idle-gaps heuristic (5f01ec31)
/// narrowed but could not close the race; this closes it.
static PORT_OPEN_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// Probe a serial port for a mesh radio WITHOUT provisioning it: identify the
/// firmware (same strict Reticulum→Meshcore→Meshtastic order as auto-detect,
/// for the same RNode-wedging reason) and read what's currently configured on
@@ -366,11 +389,10 @@ pub struct DeviceProbe {
/// loop can pick the device up afterwards. Reticulum uses the bare KISS
/// DETECT probe — no daemon spawn just to identify a stick.
pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
// The listener's reconnect loop may hold this port for ~10s of every
// backoff cycle, and Linux happily double-opens a tty — two concurrent
// handshakes corrupt each other into silence (observed on framework-pt:
// every firmware "failed" while the listener was mid-cycle). Retry across
// the listener's idle gaps instead of failing on first collision.
// Retries kept even with PORT_OPEN_LOCK closing the double-open race:
// the board may still be mid-boot from a previous open's DTR/RTS reset,
// and a later attempt after a quiet gap can succeed where the first
// couldn't.
let mut last_err = None;
for attempt in 0..3u32 {
if attempt > 0 {
@@ -385,6 +407,7 @@ pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
}
async fn probe_device_once(path: &str) -> Result<DeviceProbe> {
let _port_guard = PORT_OPEN_LOCK.lock().await;
if super::super::reticulum::probe_rnode(path).await.is_ok() {
return Ok(DeviceProbe {
path: path.to_string(),
@@ -963,6 +986,11 @@ pub(super) async fn run_mesh_session(
// set, otherwise try the preferred serial path, falling back to
// auto-detect. TCP mode is additive/dev-only; it never changes behavior
// for existing serial/RNode deployments where `reticulum_tcp` is None.
//
// The whole open sequence runs under PORT_OPEN_LOCK so an RPC probe
// can't interleave its own handshakes on the same tty (see the lock's
// doc comment). Held only until the device is opened, then released.
let port_guard = PORT_OPEN_LOCK.lock().await;
let (device_path, mut device, device_info) = if let Some(tcp_cfg) = &reticulum_tcp {
open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await?
} else if let Some(path) = preferred_path {
@@ -986,6 +1014,7 @@ pub(super) async fn run_mesh_session(
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
device_kind,
Some(path),
)
.await?
}
@@ -996,9 +1025,11 @@ pub(super) async fn run_mesh_session(
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
device_kind,
None,
)
.await?
};
drop(port_guard);
// Update status
{
+12 -3
View File
@@ -217,11 +217,20 @@ impl MeshtasticDevice {
path
))?;
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
// boards reset on a DTR/RTS transition, so deassert both and settle
// before the handshake below.
// boards (and CP2102/CH340-bridged boards wired for Arduino-style
// auto-reset) reset on a DTR/RTS transition, so deassert both and
// settle before the handshake below. 300ms is nowhere near a real
// firmware boot time (LoRa radio init alone can take longer) —
// confirmed live 2026-07-23: with every one of Reticulum/Meshcore/
// Meshtastic's open() doing this same reset, a single auto-detect
// cycle trying multiple protocols in sequence kept re-resetting the
// board before it ever finished booting from the PREVIOUS attempt's
// reset, on both a Heltec V3 and V4, regardless of firmware family —
// a self-sustaining "never finishes booting" loop with a boot-time
// root cause hiding behind what looked like a per-protocol failure.
let _ = port.set_dtr(false);
let _ = port.set_rts(false);
tokio::time::sleep(Duration::from_millis(300)).await;
tokio::time::sleep(Duration::from_millis(2000)).await;
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
Ok(Self {
+50 -4
View File
@@ -38,6 +38,14 @@ use tokio::sync::watch;
use tracing::{error, info, warn};
const MESH_CONFIG_FILE: &str = "mesh-config.json";
/// How long `MeshService::stop()` waits for the listener task to notice its
/// shutdown signal and exit gracefully before force-aborting it. See
/// `stop()`'s doc comment for the real incident this guards against: without
/// a hard abort fallback, a slow-to-notice listener could be left running
/// forever, orphaned, racing a later independently-started listener on the
/// same serial port.
const LISTENER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(15);
const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.json";
const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
@@ -967,8 +975,36 @@ impl MeshService {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(true);
}
if let Some(handle) = self.listener_handle.take() {
let _ = handle.await;
if let Some(mut handle) = self.listener_handle.take() {
// Bounded wait for graceful shutdown, with a hard abort as
// fallback — confirmed live 2026-07-23: a caller-side timeout
// wrapping stop() (mesh::flash's STOP_LISTENER_TIMEOUT) cancelled
// this await when the listener was slow to notice its shutdown
// signal (mid multi-candidate probe), but `.take()` above had
// already cleared `listener_handle` to None — so MeshService
// believed it was stopped while the task kept running, orphaned
// (dropping a JoinHandle does not abort the task it points to).
// A later start() then spawned a second, fully independent
// listener session racing the orphaned one on the same serial
// port — neither could ever get a clean response, so every
// mesh.configure/probe against that device failed indefinitely
// even though the device itself was fine.
//
// Awaiting `&mut handle` (not `handle` by value) is what makes
// the fallback possible: the Future is polled through the
// reference, so if the timeout fires, this task's own `handle`
// binding is still ours to call `.abort()` on afterward —
// unlike moving `handle` into the timeout future outright, which
// would drop (and thus orphan) it on timeout with nothing left
// to abort.
if tokio::time::timeout(LISTENER_SHUTDOWN_TIMEOUT, &mut handle)
.await
.is_err()
{
warn!("Mesh listener did not shut down gracefully in time — aborting it");
handle.abort();
let _ = handle.await;
}
}
if let Some(handle) = self.deadman_handle.take() {
handle.abort();
@@ -1027,8 +1063,18 @@ impl MeshService {
/// with the reconnect loop (whichever loses just retries).
pub async fn probe_device(&self, path: &str) -> Result<listener::DeviceProbe> {
let status = self.state.status.read().await;
if status.device_connected && status.device_path.as_deref() == Some(path) {
anyhow::bail!("{path} is the active mesh radio — already connected");
if status.device_connected {
if let Some(active) = status.device_path.as_deref() {
// Compare canonical paths: /dev/mesh-radio is a symlink to the
// ttyUSB*/ttyACM* node, and a probe through the alias would
// still open the very tty the live session is holding.
let canon = |p: &str| {
std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p))
};
if canon(active) == canon(path) {
anyhow::bail!("{path} is the active mesh radio — already connected");
}
}
}
drop(status);
listener::probe_device(path).await
+28 -3
View File
@@ -58,11 +58,20 @@ impl MeshcoreDevice {
path
))?;
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
// boards reset on a DTR/RTS transition, so deassert both and settle
// before the handshake below.
// boards (and CP2102/CH340-bridged boards wired for Arduino-style
// auto-reset) reset on a DTR/RTS transition, so deassert both and
// settle before the handshake below. 300ms is nowhere near a real
// firmware boot time (LoRa radio init alone can take longer) —
// confirmed live 2026-07-23: with every one of Reticulum/Meshcore/
// Meshtastic's open() doing this same reset, a single auto-detect
// cycle trying multiple protocols in sequence kept re-resetting the
// board before it ever finished booting from the PREVIOUS attempt's
// reset, on both a Heltec V3 and V4, regardless of firmware family —
// a self-sustaining "never finishes booting" loop with a boot-time
// root cause hiding behind what looked like a per-protocol failure.
let _ = port.set_dtr(false);
let _ = port.set_rts(false);
tokio::time::sleep(Duration::from_millis(300)).await;
tokio::time::sleep(Duration::from_millis(2000)).await;
info!(path = %path, baud = BAUD_RATE, "Opened serial port");
@@ -547,14 +556,30 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
/// Scan for serial devices that could be Meshcore radios.
/// Returns paths to existing serial device files.
///
/// Candidates are deduplicated by canonical path: /dev/mesh-radio is a udev
/// symlink to a ttyUSB*/ttyACM* node that is ALSO in the candidate list, so
/// without this one physical board shows up (and gets probed, and gets its
/// DTR/RTS reset toggled) twice per cycle. The first candidate wins, which
/// keeps the stable /dev/mesh-radio name when the symlink exists.
pub async fn detect_serial_devices() -> Vec<String> {
let mut devices = Vec::new();
let mut seen_canonical: Vec<std::path::PathBuf> = Vec::new();
for path in SERIAL_CANDIDATES {
if tokio::fs::metadata(path).await.is_ok() {
if likely_non_mesh_serial_device(path) {
debug!(path = %path, "Skipping known non-mesh serial device");
continue;
}
let canonical = tokio::fs::canonicalize(path)
.await
.unwrap_or_else(|_| std::path::PathBuf::from(path));
if seen_canonical.contains(&canonical) {
debug!(path = %path, canonical = %canonical.display(),
"Skipping alias of an already-detected serial device");
continue;
}
seen_canonical.push(canonical);
devices.push(path.to_string());
}
}
+122
View File
@@ -988,8 +988,54 @@ impl Server {
main_addr,
));
// The mesh is IPv6-only: a phone reaching the node over its fips0
// ULA lands on port 80 over v6, where a 0.0.0.0 listener never
// answers — the UI was structurally unreachable over the mesh
// (RST -> ERR_CONNECTION_ABORTED, confirmed 2026-07-26: v4:80 = 200,
// v6:80 = refused). Mirror an IPv4-any main listener with a
// V6ONLY [::] socket on the same port — v6-only so it coexists
// with the v4 listener regardless of net.ipv6.bindv6only.
let v4_any_port = match main_addr {
SocketAddr::V4(v4) if v4.ip().is_unspecified() => Some(v4.port()),
_ => None,
};
let v6_task = if let Some(port) = v4_any_port {
let v6_addr = SocketAddr::new(
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
port,
);
match bind_v6_only(v6_addr) {
Ok(listener) => {
info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr);
Some(tokio::spawn(accept_loop(
self.api_handler.clone(),
listener,
active_connections.clone(),
false, // same semantics as the main listener
tx.subscribe(),
v6_addr,
)))
}
Err(e) => {
warn!("IPv6 web listener bind {} failed: {} — UI stays v4-only", v6_addr, e);
None
}
}
} else {
None
};
// Peer listener: late-binding so we don't need an archipelago
// restart when fips0 comes up after onboarding.
// App UIs over the mesh: rootless podman's port forwarder binds
// IPv4 only for most apps, so a phone dialing [ULA]:8123 got
// nothing even with the firewall open (HA/FileBrowser/Gitea/
// Portainer/Pine all v4-only on 2026-07-26; a few bind [::]
// themselves). Bridge each catalog launch port to its v4 loopback
// listener with a V6ONLY relay — self-selecting: where the app
// already answers on v6 the bind fails and the app wins.
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
let peer_task = tokio::spawn(peer_late_bind_loop(
self.api_handler.clone(),
active_connections.clone(),
@@ -1012,6 +1058,10 @@ impl Server {
}
let _ = main_task.await;
if let Some(t) = v6_task {
let _ = t.await;
}
relay_task.abort();
let _ = peer_task.await;
info!("Shutdown complete");
@@ -1019,6 +1069,78 @@ impl Server {
}
}
/// Bind a V6ONLY `[::]` TCP listener. V6ONLY is set explicitly so the
/// socket never claims the IPv4 side (which the main listener owns) —
/// without it, Linux hosts with `net.ipv6.bindv6only=0` would fail with
/// EADDRINUSE.
fn bind_v6_only(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
let socket = socket2::Socket::new(
socket2::Domain::IPV6,
socket2::Type::STREAM,
Some(socket2::Protocol::TCP),
)?;
socket.set_only_v6(true)?;
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(1024)?;
tokio::net::TcpListener::from_std(socket.into())
}
/// IPv6→IPv4 relay for catalog app launch ports (see the spawn site for
/// why). Rescans every 60s so ports of freshly installed apps get bridged
/// without a daemon restart. Each relay is a V6ONLY listener forwarding
/// raw TCP to the same port on IPv4 loopback.
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
use std::collections::HashSet;
let mut bridged: HashSet<u16> = HashSet::new();
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = interval.tick() => {
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
if bridged.contains(&port) {
continue;
}
let addr = SocketAddr::new(
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
port,
);
// EADDRINUSE = the app itself (or a prior relay) already
// answers on v6 — exactly when we must stay out of the way.
let Ok(listener) = bind_v6_only(addr) else { continue };
bridged.insert(port);
debug!("v6 relay bridging [::]:{port} -> 127.0.0.1:{port}");
let mut rx = shutdown_rx.clone();
tokio::spawn(async move {
loop {
tokio::select! {
accepted = listener.accept() => {
let Ok((mut inbound, _)) = accepted else { break };
tokio::spawn(async move {
let Ok(mut outbound) = tokio::net::TcpStream::connect(
("127.0.0.1", port),
)
.await else { return };
let _ = tokio::io::copy_bidirectional(
&mut inbound,
&mut outbound,
)
.await;
});
}
_ = rx.changed() => break,
}
}
});
}
}
_ = shutdown_rx.changed() => return,
}
}
}
/// Poll every 30s for `fips0`'s ULA; when it appears, bind the peer
/// listener and run the normal accept loop. If the bind fails (port
/// already taken, permissions), log and keep retrying. Returns on
+2 -1
View File
@@ -162,7 +162,8 @@ All endpoints use JSON-RPC over HTTP POST to `/rpc/v1`.
| `lnd.openchannel` | `{ pubkey: string, amount: number }` | `{ funding_txid: string }` | Yes |
| `lnd.closechannel` | `{ channel_point: string }` | `{ closing_txid: string }` | Yes |
| `lnd.newaddress` | — | `{ address: string }` | Yes |
| `lnd.sendcoins` | `{ addr: string, amount: number }` | `{ txid: string }` | Yes |
| `lnd.sendcoins` | `{ addr: string, amount?: number, send_all?: bool, target_conf?: number, sat_per_vbyte?: number }` | `{ txid: string }` | Yes |
| `lnd.estimatefee` | `{ addr: string, amount: number, target_conf?: number }` | `{ fee_sat: number, sat_per_vbyte: number }` | Yes |
| `lnd.createinvoice` | `{ amount: number, memo?: string }` | `{ payment_request: string }` | Yes |
| `lnd.payinvoice` | `{ payment_request: string }` | `{ preimage: string }` | Yes |
| `lnd.create-psbt` | `{ outputs: object, ... }` | `{ psbt: string }` | Yes |
+1 -1
View File
@@ -83,7 +83,7 @@ QEMU_ARGS=(
# Display mode
if [ "$NOGRAPHIC" = true ]; then
QEMU_ARGS+=(-nographic -append "console=ttyS0")
QEMU_ARGS+=(-display none)
else
QEMU_ARGS+=(-vga virtio -display default)
fi
+23 -2
View File
@@ -3453,12 +3453,26 @@ app.post('/rpc/v1', (req, res) => {
{ chan_id: '840921088114689', remote_pubkey: '03abcdef12345678901234567890123456789012345678901234567890abcdef12', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, status: 'active', channel_point: randomHex(32) + ':1', peer_alias: 'WalletOfSatoshi' },
{ chan_id: '840921088114690', remote_pubkey: '02fedcba98765432109876543210987654321098765432109876543210fedcba98', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Voltage' },
{ chan_id: '840921088114691', remote_pubkey: '03456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: false, status: 'inactive', channel_point: randomHex(32) + ':0', peer_alias: 'Kraken' },
{ chan_id: '', remote_pubkey: '028d98b9969fbed53784a36617eb489a59ab6dc9b9d77fcdca9ff55307cd98e3c4', capacity: 500000, local_balance: 500000, remote_balance: 0, active: false, status: 'pending_open', channel_point: randomHex(32) + ':0', peer_alias: 'ACINQ' },
{ chan_id: '', remote_pubkey: '02c16cca44562b590dd279c942200bdccfd4f990c3a69fad620c10ef2f8228eaff', capacity: 800000, local_balance: 350000, remote_balance: 450000, active: false, status: 'closing', channel_point: randomHex(32) + ':0', closing_txid: randomHex(32), peer_alias: 'Bitrefill' },
]
return res.json({
result: {
channels,
total_outbound: channels.reduce((s, c) => s + c.local_balance, 0),
total_inbound: channels.reduce((s, c) => s + c.remote_balance, 0),
// Totals sum open channels only, matching the real backend.
total_outbound: channels.filter(c => ['active', 'inactive'].includes(c.status)).reduce((s, c) => s + c.local_balance, 0),
total_inbound: channels.filter(c => ['active', 'inactive'].includes(c.status)).reduce((s, c) => s + c.remote_balance, 0),
},
})
}
case 'lnd.closedchannels': {
return res.json({
result: {
channels: [
{ chan_id: '840921088110001', remote_pubkey: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f', capacity: 1000000, settled_balance: 612000, close_type: 'COOPERATIVE_CLOSE', closing_tx_hash: randomHex(32), channel_point: randomHex(32) + ':0', close_height: 903112 },
{ chan_id: '840921088110002', remote_pubkey: '0298f6074a454a1f5345cb2a7c6f9fce206cd0bf675d177cdbf0ca7508dd28852f', capacity: 250000, settled_balance: 0, close_type: 'REMOTE_FORCE_CLOSE', closing_tx_hash: randomHex(32), channel_point: randomHex(32) + ':1', close_height: 897540 },
],
},
})
}
@@ -3518,6 +3532,13 @@ app.post('/rpc/v1', (req, res) => {
})
}
case 'lnd.estimatefee': {
// ~141 vB P2WPKH spend at a rate that scales with urgency
const target = params?.target_conf || 6
const rate = target <= 1 ? 22 : target <= 6 ? 8 : 2
return res.json({ result: { fee_sat: rate * 141, sat_per_vbyte: rate } })
}
case 'lnd.decodepayreq': {
return res.json({
result: {
+57 -3
View File
@@ -1,13 +1,14 @@
{
"name": "neode-ui",
"version": "1.7.113-alpha",
"version": "1.7.115-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.113-alpha",
"version": "1.7.115-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"buffer": "^6.0.3",
@@ -149,6 +150,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1810,6 +1812,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1833,6 +1836,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2882,6 +2886,18 @@
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3538,6 +3554,28 @@
"win32"
]
},
"node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz",
"integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.2.0",
"@scure/base": "2.2.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
"version": "3.0.0-pre1",
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
@@ -3885,6 +3923,7 @@
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/geojson": "*"
}
@@ -3934,6 +3973,7 @@
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cac": "^6.7.14",
"colorette": "^2.0.20",
@@ -4434,6 +4474,7 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -4919,6 +4960,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5937,6 +5979,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -8129,6 +8172,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -8178,6 +8222,7 @@
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.1.0",
"data-urls": "^5.0.0",
@@ -8280,7 +8325,8 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/leven": {
"version": "3.1.0",
@@ -9036,6 +9082,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -9697,6 +9744,7 @@
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -10952,6 +11000,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11207,6 +11256,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11448,6 +11498,7 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -11610,6 +11661,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11623,6 +11675,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -11715,6 +11768,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.113-alpha",
"version": "1.7.115-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -24,6 +24,7 @@
"generate-welcome-speech": "node scripts/generate-welcome-speech.js"
},
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"buffer": "^6.0.3",
@@ -75,7 +75,7 @@
</div>
<!-- No Channels -->
<div v-else-if="channels.length === 0" key="empty" class="glass-card p-8 text-center">
<div v-else-if="channels.length === 0 && closedChannels.length === 0" key="empty" class="glass-card p-8 text-center">
<svg class="w-16 h-16 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
@@ -85,6 +85,23 @@
<!-- Channel List -->
<div v-else key="channels" class="space-y-3">
<!-- Status tabs -->
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors flex items-center justify-center gap-1.5"
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>
{{ tab.label }}
<span
class="px-1.5 py-0.5 rounded-full text-[10px] leading-none"
:class="activeTab === tab.key ? 'bg-white/15 text-white/80' : 'bg-white/10 text-white/40'"
>{{ tab.count }}</span>
</button>
</div>
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -96,7 +113,7 @@
{{ error }}
</div>
<div
v-for="ch in channels"
v-for="ch in filteredChannels"
:key="ch.chan_id || ch.channel_point"
class="glass-card p-4"
:class="{ 'bg-white/5': compact }"
@@ -109,12 +126,14 @@
'bg-green-400': channelStatus(ch) === 'active',
'bg-yellow-400': channelStatus(ch) === 'pending_open',
'bg-red-400': channelStatus(ch) === 'inactive',
'bg-gray-400': channelStatus(ch) === 'closing',
'bg-gray-500': channelStatus(ch) === 'force_closing',
}"
></span>
<span class="text-white/80 text-sm font-medium capitalize">{{ channelStatus(ch).replace('_', ' ') }}</span>
</div>
<button
v-if="channelStatus(ch) !== 'pending_open'"
v-if="!['pending_open', 'closing', 'force_closing'].includes(channelStatus(ch))"
@click="confirmClose(ch)"
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
>
@@ -148,9 +167,10 @@
</p>
</div>
<!-- Funding tx -->
<div v-if="fundingTxid(ch)" class="flex justify-end">
<!-- Funding / closing tx -->
<div v-if="fundingTxid(ch) || ch.closing_txid" class="flex justify-end gap-3">
<button
v-if="fundingTxid(ch)"
@click="openInMempool(fundingTxid(ch))"
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
:title="fundingTxid(ch)"
@@ -160,8 +180,66 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
<button
v-if="ch.closing_txid"
@click="openInMempool(ch.closing_txid!)"
class="flex items-center gap-1 text-orange-400/70 hover:text-orange-400 text-xs transition-colors"
:title="ch.closing_txid"
>
Closing tx in Mempool
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</div>
</div>
<!-- Closed channel history (All + Closed tabs) -->
<div
v-for="ch in filteredClosed"
:key="'closed-' + (ch.chan_id || ch.channel_point || ch.closing_tx_hash)"
class="glass-card p-4 opacity-75"
:class="{ 'bg-white/5': compact }"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-white/30"></span>
<span class="text-white/60 text-sm font-medium">Closed</span>
<span v-if="closeTypeLabel(ch)" class="text-white/40 text-xs">· {{ closeTypeLabel(ch) }}</span>
</div>
<span v-if="ch.close_height" class="text-white/35 text-xs">Block {{ ch.close_height.toLocaleString() }}</span>
</div>
<p class="text-white/40 text-xs font-mono mb-3 truncate" :title="ch.remote_pubkey">
{{ ch.remote_pubkey }}
</p>
<div class="flex justify-between text-xs text-white/50 mb-2">
<span>Settled: {{ formatSats(ch.settled_balance) }}</span>
<span>Capacity: {{ formatSats(ch.capacity) }}</span>
</div>
<div v-if="ch.closing_tx_hash" class="flex justify-end">
<button
@click="openInMempool(ch.closing_tx_hash)"
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
:title="ch.closing_tx_hash"
>
Closing tx in Mempool
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</div>
</div>
<!-- Per-tab empty state -->
<div
v-if="filteredChannels.length === 0 && filteredClosed.length === 0"
class="glass-card p-6 text-center"
>
<p class="text-white/50 text-sm">{{ emptyTabMessage }}</p>
</div>
</div>
</Transition>
@@ -299,7 +377,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useTxExplorer } from '@/composables/useTxExplorer'
@@ -314,6 +392,18 @@ interface Channel {
active: boolean
status?: string
channel_point?: string
closing_txid?: string
}
interface ClosedChannel {
chan_id?: string
remote_pubkey: string
capacity: number
settled_balance: number
close_type?: string
closing_tx_hash?: string
channel_point?: string
close_height?: number
}
/** Status with a fallback derived from `active` for backends that omit it */
@@ -321,6 +411,48 @@ function channelStatus(ch: Channel): string {
return ch.status ?? (ch.active ? 'active' : 'inactive')
}
type ChannelTab = 'all' | 'active' | 'pending' | 'closed'
const activeTab = ref<ChannelTab>('all')
/** pending_open, closing and force_closing all live on the Pending tab */
function isPendingState(ch: Channel): boolean {
return ['pending_open', 'closing', 'force_closing'].includes(channelStatus(ch))
}
const tabs = computed((): { key: ChannelTab; label: string; count: number }[] => [
{ key: 'all', label: 'All', count: channels.value.length + closedChannels.value.length },
{ key: 'active', label: 'Active', count: channels.value.filter(ch => !isPendingState(ch)).length },
{ key: 'pending', label: 'Pending', count: channels.value.filter(isPendingState).length },
{ key: 'closed', label: 'Closed', count: closedChannels.value.length },
])
const filteredChannels = computed((): Channel[] => {
switch (activeTab.value) {
case 'closed': return []
case 'active': return channels.value.filter(ch => !isPendingState(ch))
case 'pending': return channels.value.filter(isPendingState)
default: return channels.value
}
})
const filteredClosed = computed((): ClosedChannel[] =>
activeTab.value === 'all' || activeTab.value === 'closed' ? closedChannels.value : []
)
const emptyTabMessage = computed((): string => {
switch (activeTab.value) {
case 'active': return 'No open channels.'
case 'pending': return 'No pending or closing channels.'
case 'closed': return 'No closed channels yet.'
default: return 'No channels yet.'
}
})
/** "COOPERATIVE_CLOSE" / "cooperative_close" → "cooperative close" */
function closeTypeLabel(ch: ClosedChannel): string {
return (ch.close_type || '').toLowerCase().replace(/_/g, ' ')
}
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
@@ -333,6 +465,7 @@ const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: n
const loading = ref(true)
const error = ref<string | null>(null)
const channels = ref<Channel[]>([])
const closedChannels = ref<ClosedChannel[]>([])
const summary = ref({ total_inbound: 0, total_outbound: 0 })
// Olympus by ZEUS the LSP node behind the Zeus mobile wallet.
@@ -419,6 +552,17 @@ async function loadChannels() {
total_inbound: result.total_inbound || 0,
total_outbound: result.total_outbound || 0,
}
// Closed history is a separate RPC a failure here keeps the previous
// list rather than blanking the main channel view.
try {
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
method: 'lnd.closedchannels',
timeout: 15000,
})
closedChannels.value = closed.channels || []
} catch {
/* keep previous closed list */
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load channels'
if (!hadChannels) channels.value = []
+124
View File
@@ -0,0 +1,124 @@
<template>
<div>
<div class="flex gap-1 mb-3 p-1 bg-white/5 rounded-lg">
<button
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
:key="tab.key"
type="button"
@click="seedTab = tab.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ tab.label }}</button>
</div>
<template v-if="seedTab === 'words'">
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ hidden ? 'reveal' : 'hide' }}.</p>
<div class="relative">
<div
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
:class="hidden ? 'blur-md' : ''"
@click="hidden = !hidden"
>
<div v-for="(w, i) in words" :key="i" class="flex items-center gap-1.5 text-sm">
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
<span class="text-white font-mono">{{ w }}</span>
</div>
</div>
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
</div>
</template>
<template v-else>
<p class="text-sm text-white/60 mb-3">
{{ aezeed ? 'Scan to copy the words into another device.' : 'Scan into a wallet that imports seeds by QR.' }}
Tap to {{ hidden ? 'reveal' : 'hide' }}.
</p>
<div class="relative">
<div
class="flex justify-center p-3 bg-white/5 rounded-lg transition-all"
:class="hidden ? 'blur-md' : ''"
@click="hidden = !hidden"
>
<canvas ref="qrCanvas" class="rounded-lg bg-white p-2"></canvas>
</div>
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
</div>
<div v-if="!aezeed && seedQrAvailable" class="flex justify-center mt-2">
<div class="flex p-0.5 bg-white/5 rounded-md">
<button
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
:key="f.key"
type="button"
@click="qrFormat = f.key"
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ f.label }}</button>
</div>
</div>
<p class="text-xs text-white/40 mt-2">
<template v-if="aezeed">
The code contains your seed words as plain text treat it exactly like the words
themselves. Note: this is an LND <span class="font-mono">aezeed</span>, not a BIP39
phrase it restores into LND-based wallets (Zeus, Blixt, another Archipelago node),
not into hardware wallets like Passport.
</template>
<template v-else-if="qrFormat === 'seedqr'">
SeedQR scans into Passport, SeedSigner, Keystone and other wallets that import
seeds by QR. Treat this code exactly like the words themselves.
</template>
<template v-else>
Plain text words for wallets that read the phrase as text. Treat this code exactly
like the words themselves.
</template>
</p>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
// Shared seed reveal body: Words / QR code tabs behind a tap-to-reveal blur.
// Words are always the first view. For BIP39 seeds the QR defaults to the
// SeedQR standard (4-digit wordlist indices what Passport Prime, SeedSigner,
// Keystone etc. import), with a plain-text option. `aezeed` seeds (LND) are
// NOT BIP39 and no hardware wallet can import them, so they only ever get the
// plain-text QR plus an explanation SeedQR-encoding one would be dishonest.
const props = defineProps<{ words: string[]; aezeed?: boolean }>()
const seedTab = ref<'words' | 'qr'>('words')
const qrFormat = ref<'seedqr' | 'text'>(props.aezeed ? 'text' : 'seedqr')
const seedQrAvailable = ref(!props.aezeed)
const hidden = ref(true)
const qrCanvas = ref<HTMLCanvasElement | null>(null)
async function renderQr() {
await nextTick()
if (!qrCanvas.value || props.words.length === 0) return
try {
let payload = props.words.join(' ')
if (!props.aezeed && qrFormat.value === 'seedqr') {
const { toSeedQrDigits } = await import('@/utils/seedqr')
const digits = await toSeedQrDigits(props.words)
if (digits) {
payload = digits
} else {
// Not a BIP39 phrase after all only plain text is honest.
seedQrAvailable.value = false
qrFormat.value = 'text'
return // the qrFormat watcher re-renders as text
}
}
const QRCode = await import('qrcode')
await QRCode.toCanvas(qrCanvas.value, payload, { width: 260, margin: 1 })
} catch { /* QR is a convenience — the words remain authoritative */ }
}
watch(seedTab, (t) => { if (t === 'qr') void renderQr() })
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderQr() })
watch(() => props.words, () => {
seedTab.value = 'words' // fresh reveal always shows words first
hidden.value = true
seedQrAvailable.value = !props.aezeed
qrFormat.value = props.aezeed ? 'text' : 'seedqr'
})
</script>
+188 -7
View File
@@ -78,6 +78,10 @@
</span>
<span class="text-sm font-medium text-white/80">{{ confirmAmount.toLocaleString() }} sats</span>
</div>
<div v-if="effectiveMethod === 'onchain'" class="flex items-center justify-between">
<span class="text-xs text-white/50">Network fee</span>
<span class="text-sm font-medium text-white/80">{{ feeEstimateLabel }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Balance after</span>
<span class="text-sm font-medium" :class="insufficient ? 'text-red-400' : 'text-white/80'">
@@ -117,7 +121,19 @@
<div class="mb-3">
<div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
<div class="flex items-center gap-2">
<label class="text-white/60 text-sm">{{ amountLabel }}</label>
<!-- sats/BTC entry toggle (on-chain only) -->
<div v-if="sendMethod === 'onchain'" class="flex p-0.5 bg-white/5 rounded-md">
<button
v-for="u in (['sats', 'btc'] as const)"
:key="u"
@click="setAmountUnit(u)"
class="px-2 py-0.5 rounded text-[11px] font-medium transition-colors"
:class="amountUnit === u ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ u === 'btc' ? 'BTC' : 'sats' }}</button>
</div>
</div>
<span v-if="pastedInvoiceAmount !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
<button
v-if="sendMethod === 'onchain'"
@@ -131,10 +147,11 @@
</button>
</div>
<input
v-model.number="amount"
v-model.number="amountEntry"
type="number"
min="1"
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : '1000'"
:min="amountUnit === 'btc' && sendMethod === 'onchain' ? '0.00000001' : '1'"
:step="amountUnit === 'btc' && sendMethod === 'onchain' ? '0.00000001' : '1'"
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : amountUnit === 'btc' && sendMethod === 'onchain' ? '0.001' : '1000'"
:disabled="sendAll || pastedInvoiceAmount !== null"
class="w-full input-glass disabled:opacity-50"
/>
@@ -147,6 +164,7 @@
<p v-else-if="effectiveMethod === 'lightning' && dest.trim()" class="text-xs text-white/50 mt-1">
Zero-amount invoice enter how many sats to pay.
</p>
<p v-else-if="unitConversionHint" class="text-xs text-white/40 mt-1">{{ unitConversionHint }}</p>
</div>
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
@@ -165,6 +183,48 @@
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div>
<!-- Network fee (on-chain only) -->
<div v-if="sendMethod === 'onchain'" class="mb-3">
<label class="text-white/60 text-sm block mb-1">Network fee</label>
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
<button
v-for="preset in onchainFeePresets"
:key="preset.key"
@click="feePreset = preset.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ preset.label }}</button>
</div>
<p v-if="feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
{{ onchainFeePresets.find(p => p.key === feePreset)?.hint }}
</p>
<div v-else class="grid grid-cols-2 gap-3 mt-2">
<div>
<label class="text-white/60 text-xs block mb-1">Target blocks</label>
<input
v-model.number="customConfTarget"
type="number"
min="1"
max="1008"
placeholder="6"
class="w-full input-glass"
/>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
<input
v-model.number="customSatPerVbyte"
type="number"
min="1"
max="5000"
placeholder="—"
class="w-full input-glass"
/>
</div>
<p class="text-white/40 text-xs col-span-2">Set one sats per vByte takes precedence when both are set</p>
</div>
</div>
<div v-if="ecashToken" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
<!-- QR so the recipient can scan the token straight off this screen
@@ -208,7 +268,47 @@ const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
// 'auto' remains in the type for the effectiveMethod logic but is no longer
// offered as a tab (hidden per operator request 2026-07-22).
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'fedimint' | 'ark'>('lightning')
const amount = ref<number>(0)
// --- Amount entry with a sats/BTC unit toggle (on-chain). `amountEntry` is
// --- what the user types in the chosen unit; `amount` stays the canonical
// --- sats value the rest of the flow reads and writes.
const amountUnit = ref<'sats' | 'btc'>('sats')
const amountEntry = ref<number>(0)
const amount = computed<number>({
get: () =>
amountUnit.value === 'btc'
? Math.round((amountEntry.value || 0) * 100_000_000)
: Math.floor(amountEntry.value || 0),
set: (sats: number) => {
amountEntry.value = amountUnit.value === 'btc' ? (sats || 0) / 100_000_000 : sats || 0
},
})
/** Switch entry unit, converting whatever is already typed. */
function setAmountUnit(unit: 'sats' | 'btc') {
if (unit === amountUnit.value) return
const sats = amount.value
amountUnit.value = unit
amount.value = sats
}
// Only the on-chain tab offers BTC entry leaving it snaps back to sats so
// the lightning/ecash flows (and their sats-only hints) stay consistent.
watch(sendMethod, (m) => {
if (m !== 'onchain' && amountUnit.value !== 'sats') setAmountUnit('sats')
})
const amountLabel = computed(() =>
sendMethod.value === 'onchain' && amountUnit.value === 'btc' ? 'Amount (BTC)' : t('sendBitcoin.amountSats')
)
const unitConversionHint = computed(() => {
if (sendMethod.value !== 'onchain' || !amountEntry.value) return ''
return amountUnit.value === 'btc'
? `= ${amount.value.toLocaleString()} sats`
: `= ${(amount.value / 100_000_000).toFixed(8).replace(/0+$/, '').replace(/\.$/, '')} BTC`
})
const dest = ref('')
const processing = ref(false)
const error = ref('')
@@ -248,6 +348,78 @@ function toggleSendAll() {
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
// --- On-chain network fee: presets map to LND confirmation targets; custom
// --- takes a block target or an explicit sat/vB rate (rate wins).
type OnchainFeePreset = 'fast' | 'standard' | 'slow' | 'custom'
const onchainFeePresets: { key: OnchainFeePreset; label: string; hint?: string; confTarget?: number }[] = [
{ key: 'fast', label: 'Fast', hint: 'Targets the next block (~10 minutes)', confTarget: 1 },
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
{ key: 'slow', label: 'Slow', hint: 'Confirms within ~144 blocks (about a day)', confTarget: 144 },
{ key: 'custom', label: 'Custom' },
]
const feePreset = ref<OnchainFeePreset>('standard')
const customConfTarget = ref<number | null>(null)
const customSatPerVbyte = ref<number | null>(null)
// Resolved at review time so confirm + send use the same params.
const resolvedFeeParams = ref<{ target_conf?: number; sat_per_vbyte?: number }>({})
function onchainFeeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
if (feePreset.value !== 'custom') {
return { target_conf: onchainFeePresets.find(p => p.key === feePreset.value)?.confTarget ?? 6 }
}
const rate = customSatPerVbyte.value
const conf = customConfTarget.value
if (rate != null && rate !== 0) {
if (rate < 1 || rate > 5000) { error.value = 'Sats per vByte must be between 1 and 5000'; return null }
return { sat_per_vbyte: Math.floor(rate) }
}
if (conf != null && conf !== 0) {
if (conf < 1 || conf > 1008) { error.value = 'Target blocks must be between 1 and 1008'; return null }
return { target_conf: Math.floor(conf) }
}
error.value = 'Custom fee requires target blocks or sats per vByte'
return null
}
// Fee estimate for the confirm pane (best-effort LND's own estimator).
const feeEstimate = ref<{ fee_sat: number; sat_per_vbyte: number } | null>(null)
const feeEstimateLoading = ref(false)
const feeEstimateLabel = computed(() => {
if (feeEstimate.value) {
return `~${feeEstimate.value.fee_sat.toLocaleString()} sats · ${feeEstimate.value.sat_per_vbyte} sat/vB`
}
if (resolvedFeeParams.value.sat_per_vbyte) {
return `${resolvedFeeParams.value.sat_per_vbyte} sat/vB (custom)`
}
if (feeEstimateLoading.value) return '…'
return isSweep.value ? 'deducted from swept amount' : 'estimated at broadcast'
})
async function loadFeeEstimate() {
feeEstimate.value = null
// Explicit sat/vB shows as-is; sweeps have no fixed amount to estimate on.
if (resolvedFeeParams.value.sat_per_vbyte || isSweep.value) return
const addr = dest.value.trim()
const amt = confirmAmount.value
if (!addr || amt < 546) return
feeEstimateLoading.value = true
try {
const res = await rpcClient.call<{ fee_sat: number; sat_per_vbyte: number }>({
method: 'lnd.estimatefee',
params: { addr, amount: amt, target_conf: resolvedFeeParams.value.target_conf ?? 6 },
timeout: 10000,
})
if (res.fee_sat > 0) feeEstimate.value = res
} catch {
/* estimate is a preview — the label falls back to prose */
} finally {
feeEstimateLoading.value = false
}
}
// Clipboard read needs a secure context (or the companion bridge); hide the
// button where it can't work the textarea still accepts a manual paste.
const canReadClipboard = typeof navigator !== 'undefined' && !!navigator.clipboard?.readText
@@ -367,6 +539,15 @@ function review() {
if (!isSweep.value && confirmAmount.value <= 0 && invoiceAmountSats.value === null) {
error.value = t('sendBitcoin.amountSats'); return
}
if (method === 'onchain') {
const fee = onchainFeeParams()
if (!fee) return
resolvedFeeParams.value = fee
void loadFeeEstimate()
} else {
resolvedFeeParams.value = {}
feeEstimate.value = null
}
void loadConfirmBalance()
confirming.value = true
}
@@ -449,8 +630,8 @@ async function send() {
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: isSweep.value
? { addr: dest.value.trim(), send_all: true }
: { addr: dest.value.trim(), amount: amount.value },
? { addr: dest.value.trim(), send_all: true, ...resolvedFeeParams.value }
: { addr: dest.value.trim(), amount: amount.value, ...resolvedFeeParams.value },
})
successInfo.value = {
amount: paidAmount,
+21
View File
@@ -0,0 +1,21 @@
/**
* SeedQR encoding (SeedSigner standard, supported by Passport/Passport Prime,
* SeedSigner, Keystone, Nunchuk, Sparrow, ): each BIP39 word becomes its
* zero-padded 4-digit wordlist index (00002047), concatenated into one
* digit stream and rendered as a numeric-mode QR. A 24-word seed is 96
* digits. Spec: github.com/SeedSigner/seedsigner/blob/main/docs/seed_qr
*
* Only valid for real BIP39 mnemonics LND's aezeed shares the wordlist but
* is NOT BIP39, and hardware wallets cannot import it; never SeedQR-encode it.
*/
export async function toSeedQrDigits(words: string[]): Promise<string | null> {
if (words.length === 0) return null
const { wordlist } = await import('@scure/bip39/wordlists/english.js')
const digits: string[] = []
for (const raw of words) {
const idx = wordlist.indexOf(raw.trim().toLowerCase())
if (idx < 0) return null // not a BIP39 word — caller falls back to text
digits.push(idx.toString().padStart(4, '0'))
}
return digits.join('')
}
+59 -1
View File
@@ -44,7 +44,19 @@
<!-- Word Grid -->
<div v-if="words.length > 0" class="w-full max-w-[600px]">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-1 sm:gap-1.5">
<!-- Words / QR tabs words first; QR for wallets that import by scan -->
<div class="flex gap-1 mb-2 p-1 bg-white/5 rounded-lg">
<button
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
:key="tab.key"
type="button"
@click="seedTab = tab.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ tab.label }}</button>
</div>
<div v-if="seedTab === 'words'" class="grid grid-cols-2 sm:grid-cols-4 gap-1 sm:gap-1.5">
<div
v-for="(word, i) in words"
:key="i"
@@ -55,6 +67,26 @@
</div>
</div>
<div v-else class="flex flex-col items-center gap-2 py-2">
<canvas ref="seedQrCanvas" class="rounded-lg bg-white p-2"></canvas>
<div class="flex p-0.5 bg-white/5 rounded-md">
<button
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
:key="f.key"
type="button"
@click="qrFormat = f.key"
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ f.label }}</button>
</div>
<p class="text-xs text-white/50 text-center max-w-[420px]">
{{ qrFormat === 'seedqr'
? 'SeedQR — scans into Passport, SeedSigner, Keystone and other wallets that import seeds by QR.'
: 'Plain text words — for wallets that read the phrase as text.' }}
Treat this code exactly like the words themselves.
</p>
</div>
<!-- Warning -->
<div class="mt-3 bg-orange-500/10 border border-orange-500/20 rounded-lg px-3 py-2.5">
<p class="text-xs sm:text-sm text-orange-300/90">
@@ -99,6 +131,32 @@ import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter()
const continueButton = ref<HTMLButtonElement | null>(null)
const words = ref<string[]>([])
// Words / QR code view of the seed words are the default first view.
// The QR tab defaults to SeedQR (BIP39 word-index digit stream), the format
// hardware wallets like Passport Prime / SeedSigner actually import; plain
// text stays available for wallets that read the phrase as text.
const seedTab = ref<'words' | 'qr'>('words')
const qrFormat = ref<'seedqr' | 'text'>('seedqr')
const seedQrCanvas = ref<HTMLCanvasElement | null>(null)
async function renderSeedQr() {
await nextTick()
if (!seedQrCanvas.value || words.value.length === 0) return
try {
let payload = words.value.join(' ')
if (qrFormat.value === 'seedqr') {
const { toSeedQrDigits } = await import('@/utils/seedqr')
const digits = await toSeedQrDigits(words.value)
if (digits) payload = digits
else qrFormat.value = 'text' // non-BIP39 word only text is honest
}
const QRCode = await import('qrcode')
await QRCode.toCanvas(seedQrCanvas.value, payload, { width: 240, margin: 1 })
} catch { /* QR is a convenience — the words remain authoritative */ }
}
watch(seedTab, (tab) => { if (tab === 'qr') void renderSeedQr() })
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderSeedQr() })
const confirmed = ref(false)
const loading = ref(false)
const waitingForServer = ref(false)
@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
// Lightning seed (aezeed) backup card. Shown on the LND app detail page.
// The backend captures the aezeed at wallet-init time; until the user
@@ -166,20 +167,8 @@ async function confirmBackedUp() {
</template>
<template v-else>
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
<div class="relative">
<div
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
:class="wordsHidden ? 'blur-md' : ''"
@click="wordsHidden = !wordsHidden"
>
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
<span class="text-white font-mono">{{ w }}</span>
</div>
</div>
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
</div>
<SeedRevealPanel :words="revealedWords" aezeed />
<div class="flex gap-2 pt-4">
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
<button type="button" :disabled="acking" @click="confirmBackedUp" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
@@ -362,6 +362,32 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.115-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.7.115-alpha</span>
<span class="text-xs text-white/40">July 26, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.</p>
<p>The node's web interface also answers on IPv6 everywhere it answers on IPv4 the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.</p>
<p>Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.</p>
</div>
</div>
<!-- v1.7.114-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.7.114-alpha</span>
<span class="text-xs text-white/40">July 26, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.</p>
<p>The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.</p>
<p>Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.</p>
<p>Type on-chain amounts in whichever unit you think in a sats/BTC switch on the amount field converts as you type.</p>
<p>Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.</p>
</div>
</div>
<!-- v1.7.113-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
+2 -14
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
const { t } = useI18n()
@@ -329,20 +330,7 @@ defineExpose({ loadBackups })
</template>
<template v-else>
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
<div class="relative">
<div
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
:class="wordsHidden ? 'blur-md' : ''"
@click="wordsHidden = !wordsHidden"
>
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
<span class="text-white font-mono">{{ w }}</span>
</div>
</div>
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
</div>
<SeedRevealPanel :words="revealedWords" />
<div class="flex gap-2 pt-4">
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>
+19 -18
View File
@@ -1,28 +1,29 @@
{
"version": "1.7.113-alpha",
"release_date": "2026-07-25",
"changelog": [
"Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet \u2014 only the amount you meant to send leaves it.",
"Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list.",
"The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance.",
"The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match."
"The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.",
"The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.",
"Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades."
],
"components": [
{
"current_version": "1.7.115-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.115-alpha/archipelago",
"name": "archipelago",
"current_version": "1.7.113-alpha",
"new_version": "1.7.113-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.113-alpha/archipelago",
"sha256": "3027a33f6e98ddd87b7a37e852501c1e4323823174aa5e164f4d484e9221b201",
"size_bytes": 51560408
"new_version": "1.7.115-alpha",
"sha256": "d4e2d7cb65bf7ac6461eb11642fb8ff9254be636c173a016ad1834d4073a7cee",
"size_bytes": 51768592
},
{
"name": "archipelago-frontend-1.7.113-alpha.tar.gz",
"current_version": "1.7.113-alpha",
"new_version": "1.7.113-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.113-alpha/archipelago-frontend-1.7.113-alpha.tar.gz",
"sha256": "f830d6d2180ac9fa9623fe5120f4591a53b18ae1f85cd17716517f783ead71cf",
"size_bytes": 177963652
"current_version": "1.7.115-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.115-alpha/archipelago-frontend-1.7.115-alpha.tar.gz",
"name": "archipelago-frontend-1.7.115-alpha.tar.gz",
"new_version": "1.7.115-alpha",
"sha256": "9c131c881016a4e451e2ea3688bc9114302adcb5a2b58e485f3273abcebf07bb",
"size_bytes": 178060942
}
]
],
"release_date": "2026-07-26",
"signature": "80a76615a43e4b715b564143ffc7acf4e3897a921c0b6338c757963becfc6c979c118a7f32554c82ff32de283a4a0c0a566f27c98d4ffd7b6d93747a1f3ea103",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.115-alpha"
}
+19 -18
View File
@@ -1,28 +1,29 @@
{
"version": "1.7.113-alpha",
"release_date": "2026-07-25",
"changelog": [
"Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet \u2014 only the amount you meant to send leaves it.",
"Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list.",
"The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance.",
"The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match."
"The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.",
"The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.",
"Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades."
],
"components": [
{
"current_version": "1.7.115-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.115-alpha/archipelago",
"name": "archipelago",
"current_version": "1.7.113-alpha",
"new_version": "1.7.113-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.113-alpha/archipelago",
"sha256": "3027a33f6e98ddd87b7a37e852501c1e4323823174aa5e164f4d484e9221b201",
"size_bytes": 51560408
"new_version": "1.7.115-alpha",
"sha256": "d4e2d7cb65bf7ac6461eb11642fb8ff9254be636c173a016ad1834d4073a7cee",
"size_bytes": 51768592
},
{
"name": "archipelago-frontend-1.7.113-alpha.tar.gz",
"current_version": "1.7.113-alpha",
"new_version": "1.7.113-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.113-alpha/archipelago-frontend-1.7.113-alpha.tar.gz",
"sha256": "f830d6d2180ac9fa9623fe5120f4591a53b18ae1f85cd17716517f783ead71cf",
"size_bytes": 177963652
"current_version": "1.7.115-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.115-alpha/archipelago-frontend-1.7.115-alpha.tar.gz",
"name": "archipelago-frontend-1.7.115-alpha.tar.gz",
"new_version": "1.7.115-alpha",
"sha256": "9c131c881016a4e451e2ea3688bc9114302adcb5a2b58e485f3273abcebf07bb",
"size_bytes": 178060942
}
]
],
"release_date": "2026-07-26",
"signature": "80a76615a43e4b715b564143ffc7acf4e3897a921c0b6338c757963becfc6c979c118a7f32554c82ff32de283a4a0c0a566f27c98d4ffd7b6d93747a1f3ea103",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.115-alpha"
}
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env bash
# Gated ISO release build — the single command that turns a signed release
# on `main` into a tested installer ISO.
#
# Stages (fail-fast, each logged with timing):
# 0. preflight — Linux, clean tree on main, version parity across
# Cargo.toml / package.json / releases/manifest.json /
# CHANGELOG / git tag, manifest signature present
# 1. gates — tests/release/run.sh (static + frontend + backend
# slice), strict catalog drift, FULL cargo test suite
# 2. artifacts — release binary embeds the version, frontend dist
# matches, AIUI present (OTA-strip regression guard)
# 3. build — image-recipe/build-debian-iso.sh (unbundled by default)
# 4. smoke — scripts/iso-smoke-test.sh (mount-level, version-checked)
# 5. qemu — headless boot test (skippable with --no-qemu)
#
# Usage:
# scripts/build-iso-release.sh [--skip-gates] [--no-qemu] [--bundled] [--rc N]
#
# The ISO is NOT signed here — run scripts/sign-iso-checksums.sh with the
# offline RELEASE_MASTER_MNEMONIC afterwards (publisher only).
set -u
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO"
SKIP_GATES=0 NO_QEMU=0 UNBUNDLED=1 RC_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-gates) SKIP_GATES=1 ;;
--no-qemu) NO_QEMU=1 ;;
--bundled) UNBUNDLED=0 ;;
--rc) RC_OVERRIDE="${2:?--rc needs a number}"; shift ;;
*) echo "unknown flag: $1" >&2; exit 2 ;;
esac
shift
done
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
PASS=() FAIL=()
stage() { # stage <name> <cmd...>
local name="$1"; shift
local t0=$SECONDS
echo
echo "═══ [$name] $*"
if "$@"; then
echo "═══ [$name] PASS ($((SECONDS - t0))s)"
PASS+=("$name")
else
local rc=$?
echo "═══ [$name] FAIL exit=$rc ($((SECONDS - t0))s)"
FAIL+=("$name")
summary 1
fi
}
summary() {
echo
echo "──────── ISO release build summary ────────"
printf 'PASS: %s\n' "${PASS[@]:-none}"
[[ ${#FAIL[@]} -gt 0 ]] && printf 'FAIL: %s\n' "${FAIL[@]}"
exit "${1:-0}"
}
# ── Stage 0: preflight ───────────────────────────────────────────────
preflight() {
[ "$(uname -s)" = "Linux" ] || { echo "ISO builds run on Linux only"; return 1; }
local branch; branch="$(git rev-parse --abbrev-ref HEAD)"
[ "$branch" = "main" ] || { echo "must build from main (on: $branch)"; return 1; }
if [ -n "$(git status --porcelain)" ]; then
echo "working tree is not clean — release ISOs build from committed state only:"
git status --porcelain | head -20
return 1
fi
VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
local ui_ver manifest_ver
ui_ver="$(python3 -c 'import json;print(json.load(open("neode-ui/package.json"))["version"])')"
manifest_ver="$(python3 -c 'import json;print(json.load(open("releases/manifest.json"))["version"])')"
echo " Cargo.toml: $VERSION"
echo " package.json: $ui_ver"
echo " releases/manifest: $manifest_ver"
[ "$VERSION" = "$ui_ver" ] || { echo "version mismatch Cargo vs package.json"; return 1; }
[ "$VERSION" = "$manifest_ver" ] || { echo "version mismatch Cargo vs releases/manifest.json"; return 1; }
head -5 CHANGELOG.md | grep -qF "v$VERSION" \
|| { echo "CHANGELOG.md top entry is not v$VERSION"; return 1; }
git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null \
|| { echo "tag v$VERSION does not exist — cut the release first (scripts/create-release.sh)"; return 1; }
# The ISO must only ever be cut from a ceremony-signed manifest.
python3 - <<'EOF' || return 1
import json, sys
m = json.load(open("releases/manifest.json"))
sig, by = m.get("signature"), m.get("signed_by", "")
if not sig or not by.startswith("did:key:"):
print("releases/manifest.json is UNSIGNED — run the signing ceremony first")
sys.exit(1)
print(f" manifest signed by {by[:32]}…")
EOF
echo " version: $VERSION @ $(git rev-parse --short HEAD), tree clean, manifest signed"
}
stage "preflight" preflight
VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
# ── Stage 1: gates ───────────────────────────────────────────────────
if [ "$SKIP_GATES" = "0" ]; then
stage "release-gate-harness" bash tests/release/run.sh
stage "catalog-drift-strict" python3 scripts/check-app-catalog-drift.py --release --strict
# Full Rust suite — the release harness only runs a 6-module slice;
# ~1000 tests otherwise go unverified at ISO time (hardening plan §H).
stage "cargo-test-full" timeout 5400 env CARGO_INCREMENTAL=0 \
nice -n 10 cargo test --manifest-path core/Cargo.toml -p archipelago --bin archipelago
else
echo; echo "═══ [gates] SKIPPED (--skip-gates)"
fi
# ── Stage 2: artifact verification ───────────────────────────────────
verify_artifacts() {
local bin="core/target/release/archipelago"
[ -x "$bin" ] || { echo "missing release binary $bin — build it first"; return 1; }
strings "$bin" | grep -qF "$VERSION" \
|| { echo "release binary does not embed $VERSION — stale build"; return 1; }
echo " backend binary embeds $VERSION ($(du -h "$bin" | cut -f1))"
[ -f web/dist/neode-ui/index.html ] || { echo "missing frontend dist"; return 1; }
grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js \
|| { echo "frontend dist does not contain $VERSION — stale build"; return 1; }
echo " frontend dist contains $VERSION"
# AIUI must ride inside the dist BEFORE packaging or OTA upgrades
# silently strip it from nodes in the field.
[ -f web/dist/neode-ui/aiui/index.html ] \
|| { echo "AIUI missing from web/dist/neode-ui/aiui — fold it in before building"; return 1; }
echo " AIUI present in frontend dist"
}
stage "verify-artifacts" verify_artifacts
# ── Stage 3: build the ISO ───────────────────────────────────────────
build_iso() {
local env_args=(
UNBUNDLED="$UNBUNDLED"
BUILD_FROM_SOURCE=0
DEV_SERVER=localhost
ARCHIPELAGO_BIN="$REPO/core/target/release/archipelago"
)
[ -n "$RC_OVERRIDE" ] && env_args+=(RC="$RC_OVERRIDE")
sudo -E env "${env_args[@]}" nice -n 5 bash image-recipe/build-debian-iso.sh
}
stage "build-iso" build_iso
find_iso() {
ls -t "$REPO"/image-recipe/results/archipelago-installer-"$VERSION"*-x86_64_RC*.iso 2>/dev/null | head -1
}
ISO="$(find_iso)"
[ -n "$ISO" ] || { echo "FAIL: no ISO produced for $VERSION in image-recipe/results/"; FAIL+=("locate-iso"); summary 1; }
# ── Stage 4: mount-level smoke test ──────────────────────────────────
stage "iso-smoke" bash scripts/iso-smoke-test.sh "$ISO" "$VERSION"
# ── Stage 5: QEMU boot test (best-effort) ────────────────────────────
# The ISO's kernel cmdline has no serial console, so the serial-log
# sanity grep can miss a perfectly healthy boot. Run it, report it,
# but don't fail an otherwise-green build on it.
if [ "$NO_QEMU" = "0" ] && command -v qemu-system-x86_64 >/dev/null 2>&1; then
echo
echo "═══ [qemu-boot] (best-effort) test-iso-qemu.sh $ISO 180"
if bash image-recipe/_archived/test-iso-qemu.sh "$ISO" 180; then
echo "═══ [qemu-boot] PASS"
PASS+=("qemu-boot")
else
echo "═══ [qemu-boot] INCONCLUSIVE (not gating — verify on real hardware)"
PASS+=("qemu-boot(inconclusive)")
fi
else
echo; echo "═══ [qemu-boot] SKIPPED"
fi
# ── Done ─────────────────────────────────────────────────────────────
SHA_FILE="$ISO.sha256"
[ -f "$SHA_FILE" ] || (cd "$(dirname "$ISO")" && sha256sum "$(basename "$ISO")" > "$SHA_FILE")
echo
echo "════════════════════════════════════════════════════"
echo " ISO RELEASE BUILD COMPLETE — v$VERSION"
echo "════════════════════════════════════════════════════"
echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
echo " SHA256: $(cut -d' ' -f1 "$SHA_FILE")"
echo
echo " Next steps (publisher, offline mnemonic required):"
echo " 1. scripts/sign-iso-checksums.sh $ISO"
echo " 2. upload ISO + .sha256 + signed checksum JSON alongside the"
echo " v$VERSION Gitea release assets"
summary 0
+1 -1
View File
@@ -142,7 +142,7 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
# SIGPIPE-safe: use awk to read only the first line and exit,
# then terminate the tar pipeline explicitly so `pipefail`+SIGPIPE
# don't kill the whole `set -euo pipefail` script.
root_mode=$(tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null | awk 'NR==1{print $1; exit}')
root_mode=$({ tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null || true; } | awk 'NR==1{print $1; exit}')
case "$root_mode" in
drwxr-xr-x|drwxr-x*x*)
echo " Tarball root perms OK: $root_mode"
+45
View File
@@ -141,6 +141,32 @@ def render_app_session_config(manifests: dict[str, dict[str, Any]]) -> str:
return "\n".join(lines)
def render_rust_ports(ports: dict[str, int], extra_ports: list[int]) -> str:
"""Rust constant of catalog launch ports for the fips0 firewall drop-in
(core/archipelago/src/fips/app_ports.rs). Extra ports cover the frontend's
APP_PORTS overrides (companions/aliases) that have no manifest of their own.
"""
distinct = sorted(set(list(ports.values()) + extra_ports))
lines = [
"//! Generated by scripts/generate-app-catalog.py. Do not edit manually.",
"//!",
"//! Catalog app launch ports (the web UIs the companion opens by direct",
"//! port). Used to write the fips0 firewall allowance drop-in so app UIs",
"//! are reachable over the mesh; ports of apps that aren\'t installed have",
"//! no listener, so allowing them is inert.",
"",
"pub const APP_LAUNCH_PORTS: &[u16] = &[",
]
lines.extend(f" {port}," for port in distinct)
lines.extend(["];", ""])
return "\n".join(lines)
# Keep in lockstep with APP_PORTS overrides in
# neode-ui/src/views/appSession/appSessionConfig.ts.
RUST_EXTRA_PORTS = [8334, 50002, 18083, 11434, 8081, 8240, 8175, 8176, 8080]
def sync_catalog(path: Path, manifests: dict[str, dict[str, Any]]) -> int:
with path.open("r", encoding="utf-8") as fh:
catalog = json.load(fh)
@@ -178,6 +204,11 @@ def main() -> int:
default=[],
help="Catalog JSON path to update. May be passed multiple times.",
)
parser.add_argument(
"--rust-app-ports",
default="core/archipelago/src/fips/app_ports.rs",
help="Generated Rust launch-port list for the fips0 firewall drop-in. Empty string to skip.",
)
parser.add_argument(
"--app-session-config",
default="neode-ui/src/views/appSession/generatedAppSessionConfig.ts",
@@ -201,6 +232,20 @@ def main() -> int:
print(f"{path}: updated")
else:
print(f"{path}: updated 0 fields")
if args.rust_app_ports:
ports = {
app_id: port
for app_id, app in manifests.items()
if (port := manifest_launch_port(app))
}
rust_path = Path(args.rust_app_ports)
rust_content = render_rust_ports(ports, RUST_EXTRA_PORTS)
rust_old = rust_path.read_text(encoding="utf-8") if rust_path.exists() else ""
if rust_old != rust_content:
rust_path.write_text(rust_content, encoding="utf-8")
print(f"{rust_path}: updated")
else:
print(f"{rust_path}: updated 0 fields")
print(f"total_updated={total}")
return 0
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Mount-level smoke test for an Archipelago installer ISO.
#
# Verifies boot plumbing (BIOS + UEFI + live-boot), the auto-installer
# payload, and — the check that has bitten before — that the backend
# binary inside the ISO actually embeds the version the filename claims.
#
# Usage:
# scripts/iso-smoke-test.sh <path-to-iso> [expected-version]
#
# expected-version defaults to core/archipelago/Cargo.toml. Needs sudo
# (loop mount). Exits non-zero on the first hard failure; prints a
# PASS/FAIL table either way.
set -u
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ISO="${1:-}"
EXPECTED_VERSION="${2:-$(grep -m1 '^version' "$REPO/core/archipelago/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/')}"
if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
echo "usage: $0 <path-to-iso> [expected-version]" >&2
exit 2
fi
FAIL=0
ok() { echo " OK: $*"; }
bad() { echo " FAIL: $*"; FAIL=1; }
warn() { echo " WARN: $*"; }
echo "ISO smoke test"
echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
echo " Version: $EXPECTED_VERSION (expected)"
# ── Filename ↔ version parity (gap: ISO version can silently drift) ──
case "$(basename "$ISO")" in
*"$EXPECTED_VERSION"*) ok "filename contains $EXPECTED_VERSION" ;;
*) bad "filename does not contain expected version $EXPECTED_VERSION" ;;
esac
MNT="$(mktemp -d)"
INITRD_DIR=""
cleanup() {
sudo umount "$MNT" 2>/dev/null || true
rmdir "$MNT" 2>/dev/null || true
[ -n "$INITRD_DIR" ] && sudo rm -rf "$INITRD_DIR" 2>/dev/null
}
trap cleanup EXIT
if ! sudo mount -o loop,ro "$ISO" "$MNT"; then
echo " FAIL: could not loop-mount ISO" >&2
exit 1
fi
# ── Required boot + installer files ──────────────────────────────────
for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
isolinux/isolinux.bin isolinux/isolinux.cfg \
boot/grub/grub.cfg EFI/BOOT/BOOTX64.EFI \
archipelago/auto-install.sh archipelago/rootfs.tar; do
if [ -e "$MNT/$f" ]; then
ok "$f ($(sudo du -h "$MNT/$f" 2>/dev/null | cut -f1))"
else
bad "missing $f"
fi
done
# ── GRUB must boot the live system ───────────────────────────────────
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
ok "grub.cfg has boot=live"
else
bad "grub.cfg missing boot=live"
fi
# ── initrd must contain live-boot scripts ────────────────────────────
if command -v unmkinitramfs >/dev/null 2>&1; then
INITRD_DIR="$(mktemp -d)"
sudo unmkinitramfs "$MNT/live/initrd.img" "$INITRD_DIR" 2>/dev/null
if [ -e "$INITRD_DIR/scripts/live" ] || [ -e "$INITRD_DIR/main/scripts/live" ]; then
ok "initrd has live-boot scripts"
else
bad "initrd missing live-boot scripts"
fi
else
warn "unmkinitramfs not installed — skipping initrd live-boot check"
fi
# ── Backend binary inside the ISO embeds the expected version ────────
# (the v1.4.0-binary-in-a-v1.5-ISO incident: a stale captured binary
# shipped and the fleet rejected its fips.yaml on Activate)
BIN_IN_ISO=""
if [ -f "$MNT/archipelago/bin/archipelago" ]; then
BIN_IN_ISO="$MNT/archipelago/bin/archipelago"
if sudo strings "$BIN_IN_ISO" 2>/dev/null | grep -qF "$EXPECTED_VERSION"; then
ok "payload backend binary embeds $EXPECTED_VERSION"
else
bad "payload backend binary does NOT embed $EXPECTED_VERSION (stale binary)"
fi
else
# Fall back to the copy inside rootfs.tar
TMPBIN="$(mktemp -d)"
if sudo tar -xf "$MNT/archipelago/rootfs.tar" -C "$TMPBIN" \
usr/local/bin/archipelago 2>/dev/null; then
if sudo strings "$TMPBIN/usr/local/bin/archipelago" | grep -qF "$EXPECTED_VERSION"; then
ok "rootfs backend binary embeds $EXPECTED_VERSION"
else
bad "rootfs backend binary does NOT embed $EXPECTED_VERSION (stale binary)"
fi
else
bad "no backend binary found at archipelago/bin/ or in rootfs.tar"
fi
sudo rm -rf "$TMPBIN"
fi
# ── Frontend payload present ─────────────────────────────────────────
if [ -f "$MNT/archipelago/web-ui/index.html" ]; then
ok "frontend payload (archipelago/web-ui/index.html)"
if [ -f "$MNT/archipelago/web-ui/aiui/index.html" ]; then
ok "AIUI included in frontend payload"
else
warn "AIUI missing from archipelago/web-ui (verify rootfs copy before shipping)"
fi
else
warn "no archipelago/web-ui payload on ISO (frontend may live in rootfs.tar only)"
fi
echo
if [ "$FAIL" = "1" ]; then
echo "ISO SMOKE TEST: FAILED"
exit 1
fi
echo "ISO SMOKE TEST: PASSED"
+1 -1
View File
@@ -62,7 +62,7 @@ summary() {
# ── Stage 1: static ──────────────────────────────────────────────────
stage "git-diff-check" git diff --check
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict
# Every release must surface its CHANGELOG entry in the Settings "What's New"
# modal. The modal hardcodes a block per version and has drifted behind before
# (sat at v1.7.84 while the fleet shipped to v1.7.92). Fail if any CHANGELOG