Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef35d65b7 | ||
|
|
3b3500a7dd | ||
|
|
2f0f7fd388 | ||
|
|
b300a720db | ||
|
|
5b6d278c46 | ||
|
|
699669a5f7 | ||
|
|
54431fc856 | ||
|
|
3409db569e | ||
|
|
cb71c25ea0 | ||
|
|
c5eeb31055 | ||
|
|
966db4810a | ||
|
|
51a5473e22 | ||
|
|
1872fc20ee | ||
|
|
cbd463e980 | ||
|
|
9df580bf2b | ||
|
|
aee7ecaac1 | ||
|
|
e51ceaa250 | ||
|
|
7c9559aa57 |
+45
-5
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.5-alpha (2026-08-30)
|
||||
|
||||
- **Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.
|
||||
|
||||
- **A frozen node now explains itself — and comes back on its own.** The host now captures a memory dump into /var/crash when the kernel panics *or* wedges (a hung kiosk used to sit dead until someone power-cycled it; now it dumps, reboots itself, and leaves the evidence behind), and records failing-memory signals (ECC errors) into a database as they happen. This is the first change delivered by a new host-update channel: the node's own updater now carries OS-level packages and settings to already-deployed machines — the crash-kernel's memory reservation is the one part that waits for a reboot, and the node says so rather than pretending.
|
||||
|
||||
- **Uninstalling an app can no longer report success when it failed.** The declarative path used to swallow every teardown error and report the app uninstalled, leaving the tile behind and the truth in the logs. A failed uninstall now stops and shows the real per-app errors, so "still there" is never presented as "gone".
|
||||
|
||||
- **Pictures to internet-only mesh contacts work now.** Sending an attachment inline always took the radio path and failed with "Peer is federation-only (no radio twin)" for contacts reachable only over the internet — and the size-adviser kept recommending a radio transfer those peers can't receive. Both fixed: inline sends route over the federation when that's the only way to reach the peer, and the advice no longer offers radio-only transfers to radio-unreachable contacts.
|
||||
|
||||
- **Disk cleanup finally has honest numbers.** Space "free" on a drive was counted including the slice the filesystem keeps reserved for root — roughly 5% of the disk, 92 GB on one dev box — so the automatic cleanup that's supposed to kick in at 90% never triggered and stale container images piled up unnoticed. Reserved space now counts as used, which is what the threshold was always meant to measure.
|
||||
|
||||
- **Three small screens that were lying to you, fixed.** The "Bitcoin is synced — fund your wallet" toast no longer appears on a node where the wallet it means (LND) isn't installed — it points at installing LND instead. The seed-reveal screen hides its third prompt unless the password actually fails to decrypt (the backup passphrase only exists if you set one). And multi-version store cards stop quoting a version number you'll be asked to choose on the next screen anyway.
|
||||
|
||||
- **Mesh notifications survive a refresh, and a stale router no longer hides the fix.** Radio message unread counts are now remembered per contact instead of guessed from session state (the "one new message showed 11 unread" bug), cover Meshtastic, MeshCore and Reticulum alike, and deep-link to the right conversation; a single new message announces itself once. Separately, when the cached router address goes stale, the error card gains a "Reconfigure router" action instead of a Retry loop that can never succeed.
|
||||
|
||||
- **The app updater now knows what upstream shipped.** Every app's manifest records where it comes from — including the odd corners (GitLab-only projects, ghcr-only images) — and a checker sweeps all of them against upstream releases, so a pin that quietly rots for months is now visible instead of invisible. The first full sweep found 27 pins behind; the safe patch-level ones shipped with this release (strfry, BTCPay Server 2.4.3, the two nginx frontends), and the major jumps that may carry data migrations are deliberately held for their own careful passes.
|
||||
|
||||
## v1.8.4-alpha (2026-08-20)
|
||||
|
||||
- **Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the "app is restarting" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.
|
||||
@@ -291,6 +309,12 @@
|
||||
- More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.
|
||||
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.
|
||||
|
||||
## v1.7.107-alpha (2026-07-20)
|
||||
|
||||
- Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.
|
||||
- Your node rejoins the mesh faster after an update. Applying this update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried. It now notices the restart and reconnects within seconds.
|
||||
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle — two separate faults that had been failing the build.
|
||||
|
||||
## v1.7.106-alpha (2026-07-20)
|
||||
|
||||
- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.
|
||||
@@ -670,11 +694,13 @@
|
||||
|
||||
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
|
||||
- Live diagnostics on a fleet node confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
|
||||
- The gap this closes: apps launched through the orchestrator previously skipped the legacy start-time repair path entirely, so the same stale metadata the old flow cleaned up silently broke the new one. Both paths now converge on the same repairs.
|
||||
|
||||
## v1.7.64-alpha (2026-05-18)
|
||||
|
||||
- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows.
|
||||
- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing.
|
||||
- For operators mid-incident this changes the recovery loop: a failed apply can now be retried immediately from the System Update page instead of waiting out a throttle window while a node sits half-updated.
|
||||
|
||||
## v1.7.63-alpha (2026-05-18)
|
||||
|
||||
@@ -784,6 +810,18 @@
|
||||
- Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages.
|
||||
- Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`.
|
||||
|
||||
## v1.7.51-alpha (2026-04-30)
|
||||
|
||||
- Stack installs now adopt containers that already exist instead of failing on them — a repair or reinstall over leftover containers completes, and the adopted container's readiness is waited on like any fresh start.
|
||||
- Failed installs come with evidence: the install path waits for its containers, and when one doesn't become healthy it captures that container's logs, so the error on screen names the real culprit instead of a bare timeout.
|
||||
- Bitcoin RPC bindings are ensured as part of install, and the startup self-heal path gained additional ground for already-deployed nodes.
|
||||
|
||||
## v1.7.50-alpha (2026-04-30)
|
||||
|
||||
- The OTA bridge older nodes needed: deployed binaries only knew how to apply two artifacts (the backend binary and the frontend archive), so the scripts, app specs and docker assets newer releases carry never reached them. This release packs those payloads inside the frontend tarball — the one channel old binaries do apply — and the new backend promotes them into /opt once it starts.
|
||||
- Runtime payloads are staged into timestamped directories and promoted atomically; a failed extraction cleans up its staging area instead of leaving half-written state for the next update to trip over.
|
||||
- This is the release that un-sticks the fleet's update pipeline: from here on, an OTA can carry more than the two artifacts, and app installs on updated nodes use the specs that match their backend.
|
||||
|
||||
## v1.7.49-alpha (2026-04-30)
|
||||
|
||||
- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up.
|
||||
@@ -795,12 +833,15 @@
|
||||
|
||||
## v1.7.48-alpha (2026-04-29)
|
||||
|
||||
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where /run/containers wasn't pre-created. ExecStartPre now creates it. Existing nodes need a one-time `systemctl edit archipelago` to add the mkdir; ISO installs from this version forward have the fix baked in.
|
||||
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where that runtime directory wasn't pre-created — the failure surfaced in systemd's mount-namespace setup before the service itself ever ran.
|
||||
- ExecStartPre now creates /run/containers before the service starts, so the node's service manager finds the directory it needs on every boot; ISO installs from this version forward have the fix baked in.
|
||||
- Existing nodes pick the fix up with a one-time `systemctl edit archipelago` adding the mkdir — after which the boot failure does not recur.
|
||||
|
||||
## v1.7.47-alpha (2026-04-29)
|
||||
|
||||
- Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed.
|
||||
- ElectrumX initial indexing is faster too. Its CPU cap is removed, container memory is 4GB, and its internal cache is now 3GB (default was 1.2GB).
|
||||
- The result: a fresh node's first hours are measurably shorter — initial block download and ElectrumX indexing were the two longest post-install waits, and both now run at the hardware's limit.
|
||||
|
||||
## v1.7.46-alpha (2026-04-29)
|
||||
|
||||
@@ -823,10 +864,9 @@
|
||||
|
||||
## v1.7.44-alpha (2026-04-28)
|
||||
|
||||
43de3b73 feat(orchestrator): complete container migration and release hardening
|
||||
ce39430b feat(self-update): sync and rebuild UI containers on OTA
|
||||
72dec5aa fix(lnd-ui): align container port across all specs
|
||||
83aacdf2 chore(release): archive ISO build recipes, tarball-only releases
|
||||
- Container orchestration migration completed, with release hardening across the app lifecycle — installs, updates and removals now run through one orchestrator path instead of the split legacy/Podman flows.
|
||||
- OTA updates now rebuild and sync the app UI containers they carry, so an updated app serves the UI image that matches its backend instead of whatever happened to be on disk.
|
||||
- LND UI port handling is aligned across all runtime specs, and release packaging moved to tarball-only payloads with the ISO build recipes archived — update payloads now carry only the files existing nodes need.
|
||||
|
||||
|
||||
All notable changes to Archipelago will be documented in this file.
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.4-alpha"
|
||||
version = "1.8.5-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.4-alpha"
|
||||
version = "1.8.5-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
//! Host-level fixups: OS packages, kernel parameters and system services the
|
||||
//! node needs, delivered by the same signed-binary OTA that ships everything
|
||||
//! else (docs/system-level-ota-design.md).
|
||||
//!
|
||||
//! Scope and posture — read before adding anything here:
|
||||
//!
|
||||
//! * **Idempotent + non-fatal.** Every step is a no-op when the host already
|
||||
//! has the desired state, and a failure (offline box, locked dpkg, missing
|
||||
//! package in the release's Debian suite) logs a warning and moves on. A
|
||||
//! host fixup must never be able to stop the node from starting.
|
||||
//! * **Curated, pinned intent — not dist-upgrade automation.** We deliver the
|
||||
//! specific packages and settings a release deliberately adds (crash
|
||||
//! capture, hardware-error logging, later: unattended-upgrades posture, host
|
||||
//! firewall). Regular Debian upgrades stay with the operator; this channel
|
||||
//! never silently swaps a kernel or a libc.
|
||||
//! * **Fresh installs converge too.** The ISO bakes the same end state in
|
||||
//! (Dockerfile.rootfs, auto-install.sh cmdline), so the fixup is a no-op on
|
||||
//! new machines and only does real work on already-deployed nodes.
|
||||
//! * **Kernel cmdline can't move at runtime.** `crashkernel=` reserves memory
|
||||
//! at boot; the fixup writes GRUB and update-grub so the change lands on the
|
||||
//! next reboot, and says so in the log. Everything else (packages, sysctls,
|
||||
//! services) applies immediately.
|
||||
//!
|
||||
//! First payload (#144, docs/kdump-rasdaemon-design.md): kdump + rasdaemon —
|
||||
//! post-mortem and hardware-error capture:
|
||||
//! * kdump-tools/kexec-tools/rasdaemon installed
|
||||
//! * /etc/default/kdump-tools: USE_KDUMP=1, dumps to /var/crash, compressed
|
||||
//! core collector
|
||||
//! * /etc/sysctl.d/99-archipelago-kdump.conf: a wedged node dumps and
|
||||
//! reboots rather than sitting dead until power-cycled
|
||||
//! * crashkernel=256M appended to the installed GRUB cmdline (next reboot)
|
||||
//! * /var/crash pruned to the two newest dumps
|
||||
//!
|
||||
//! The module is skipped on dev boxes (same guard bootstrap::run uses) and on
|
||||
//! hosts without dpkg.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::update::host_sudo;
|
||||
|
||||
/// Packages the node's host must have. Keep this list short and justified —
|
||||
/// every entry is state we now own on the fleet's OS images.
|
||||
const HOST_PACKAGES: &[&str] = &["kdump-tools", "kexec-tools", "makedumpfile", "rasdaemon"];
|
||||
|
||||
/// Crash-kernel reservation. 256M covers the capture kernel plus makedumpfile
|
||||
/// on the fleet's 16–64GB amd64 machines (~1–2% of RAM, permanently reserved).
|
||||
/// The arm image (RPi) is out of scope for phase 1 — see the design doc.
|
||||
const CRASHKERNEL_PARAM: &str = "crashkernel=256M";
|
||||
|
||||
const KDUMP_SYSDROPIN_PATH: &str = "/etc/sysctl.d/99-archipelago-kdump.conf";
|
||||
const KDUMP_SYSDROPIN: &str = "\
|
||||
# Archipelago kdump policy (#144). A wedged kiosk is useless until someone
|
||||
# power-cycles it — capture the evidence, then reboot by itself. Dumps land in
|
||||
# /var/crash (see docs/kdump-rasdaemon-design.md); keep-2 pruning is done by
|
||||
# the host fixup pass, not a timer.
|
||||
kernel.panic = 10
|
||||
kernel.panic_on_oops = 1
|
||||
kernel.hung_task_panic = 1
|
||||
kernel.hardlockup_panic = 1
|
||||
";
|
||||
|
||||
/// How many dumps to keep in /var/crash. Two ≈ 4 GiB worst case on the 30 GiB
|
||||
/// unencrypted root — the partition usage itself is tracked by disk_monitor.
|
||||
const KEEP_DUMPS: usize = 2;
|
||||
|
||||
/// Entry point, spawned from main.rs at startup like the other ensure_* heals.
|
||||
pub async fn ensure_host_fixups() {
|
||||
// Dev-box guard (same rationale as bootstrap::run): on contributor
|
||||
// machines /home/archipelago/archy is a symlink into a git checkout and
|
||||
// the host is the contributor's own OS — never touch it.
|
||||
let home_archy = std::path::Path::new("/home/archipelago/archy");
|
||||
if tokio::fs::symlink_metadata(home_archy)
|
||||
.await
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
debug!("/home/archipelago/archy is a symlink — skipping host fixups (dev box)");
|
||||
return;
|
||||
}
|
||||
// Non-Debian hosts: nothing we manage here applies.
|
||||
if tokio::fs::symlink_metadata("/usr/bin/dpkg").await.is_err() {
|
||||
debug!("no dpkg on this host — skipping host fixups");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = run_host_fixups().await {
|
||||
warn!("host fixups failed (non-fatal): {:#}", e);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_host_fixups() -> Result<()> {
|
||||
// 1. Packages — install only what's missing; a locked/offline apt must
|
||||
// never block anything downstream (steps below degrade to no-ops).
|
||||
match ensure_packages().await {
|
||||
Ok(true) => info!("host fixups: installed missing packages"),
|
||||
Ok(false) => debug!("host fixups: all packages present"),
|
||||
Err(e) => warn!("host fixups: package install failed (non-fatal): {:#}", e),
|
||||
}
|
||||
|
||||
// 2. kdump config + sysctl drop-in + GRUB cmdline + services. One helper
|
||||
// per concern so a failure in one logs and leaves the others running.
|
||||
if let Err(e) = ensure_kdump_sysdropin().await {
|
||||
warn!(
|
||||
"host fixups: kdump sysctl drop-in failed (non-fatal): {:#}",
|
||||
e
|
||||
);
|
||||
}
|
||||
if let Err(e) = ensure_kdump_defaults().await {
|
||||
warn!(
|
||||
"host fixups: kdump-tools config failed (non-fatal): {:#}",
|
||||
e
|
||||
);
|
||||
}
|
||||
match ensure_crashkernel_cmdline().await? {
|
||||
true => {
|
||||
warn!("host fixups: crashkernel= written to GRUB — takes effect on the NEXT reboot")
|
||||
}
|
||||
false => debug!("host fixups: crashkernel already in GRUB cmdline"),
|
||||
}
|
||||
if let Err(e) = ensure_rasdaemon_enabled().await {
|
||||
warn!("host fixups: rasdaemon enable failed (non-fatal): {:#}", e);
|
||||
}
|
||||
if let Err(e) = prune_crash_dumps().await {
|
||||
debug!("host fixups: /var/crash prune skipped: {:#}", e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True if any package was installed. Mirrors the polkit repair's apt posture:
|
||||
/// install without `apt-get update` first; only if that fails (fresh suite,
|
||||
/// stale index), update once and retry. Both under timeout, both non-fatal.
|
||||
async fn ensure_packages() -> Result<bool> {
|
||||
// Package names are a fixed internal allowlist. Do not embed shell quote
|
||||
// characters in WANTED: quotes produced by variable expansion are data,
|
||||
// so dpkg-query would look for a package literally named 'kdump-tools'.
|
||||
let wanted = HOST_PACKAGES.join(" ");
|
||||
let script = format!(
|
||||
r#"
|
||||
set -u
|
||||
WANTED="{wanted}"
|
||||
MISSING=""
|
||||
for p in $WANTED; do
|
||||
dpkg-query -W -f='${{Status}}' "$p" 2>/dev/null | grep -q 'install ok installed' || MISSING="$MISSING $p"
|
||||
done
|
||||
[ -z "$MISSING" ] && exit 0
|
||||
timeout 240 apt-get install -y --no-install-recommends $MISSING >/dev/null 2>&1 \
|
||||
|| timeout 240 sh -c 'apt-get update >/dev/null 2>&1 && apt-get install -y --no-install-recommends $MISSING >/dev/null 2>&1' \
|
||||
|| exit 3
|
||||
exit 2
|
||||
"#
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("install host packages")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => Ok(true),
|
||||
code => anyhow::bail!("host package install exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the sysctl drop-in and apply it live (these four keys are all
|
||||
/// runtime-settable, so the hang/panic policy takes effect without a reboot).
|
||||
async fn ensure_kdump_sysdropin() -> Result<()> {
|
||||
let script = format!(
|
||||
r#"
|
||||
set -u
|
||||
PATH_FILE='{KDUMP_SYSDROPIN_PATH}'
|
||||
CONTENT_FILE=/tmp/archy-kdump-sysctl.$$.tmp
|
||||
cat > "$CONTENT_FILE" <<'SYSEOF'
|
||||
{KDUMP_SYSDROPIN}SYSEOF
|
||||
if [ -f "$PATH_FILE" ] && cmp -s "$CONTENT_FILE" "$PATH_FILE"; then
|
||||
rm -f "$CONTENT_FILE"
|
||||
exit 0
|
||||
fi
|
||||
mv "$CONTENT_FILE" "$PATH_FILE"
|
||||
chmod 644 "$PATH_FILE"
|
||||
sysctl --system >/dev/null 2>&1 || true
|
||||
exit 2
|
||||
"#
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("write kdump sysctl drop-in")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(()),
|
||||
Some(2) => {
|
||||
info!("host fixups: installed {KDUMP_SYSDROPIN_PATH} (hang/panic policy)");
|
||||
Ok(())
|
||||
}
|
||||
code => anyhow::bail!("kdump sysctl drop-in exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Point kdump-tools at /var/crash with a compressed core collector. Works on
|
||||
/// the package's shipped defaults file (USE_KDUMP=0, commented KDUMP_COREDIR)
|
||||
/// and on any state we already wrote — pure line surgery, idempotent.
|
||||
fn kdump_defaults_script(conf: &str) -> String {
|
||||
r#"
|
||||
set -u
|
||||
CONF='@@CONF@@'
|
||||
[ -f "$CONF" ] || exit 3
|
||||
CHANGED=0
|
||||
# Remove the one malformed line emitted by the old systemd-run environment
|
||||
# expansion bug before it was disabled. It makes every kdump-config invocation
|
||||
# print an error while sourcing this file.
|
||||
if grep -Fqx '=""' "$CONF"; then
|
||||
sed -i '/^=""$/d' "$CONF"
|
||||
CHANGED=1
|
||||
fi
|
||||
set_kv() {
|
||||
# Canonicalise KEY to one double-quoted assignment. Older fixup versions
|
||||
# could append duplicates because their exact-value check did not accept
|
||||
# double quotes; collapsing them also makes future passes idempotent.
|
||||
KEY="$1"; VAL="$2"
|
||||
EXPECTED="${KEY}=\"${VAL}\""
|
||||
COUNT=$(grep -c "^${KEY}=" "$CONF" 2>/dev/null || true)
|
||||
if [ "$COUNT" -eq 1 ] && grep -Fqx "$EXPECTED" "$CONF"; then
|
||||
return
|
||||
fi
|
||||
sed -i "/^${KEY}=/d" "$CONF"
|
||||
printf '\n%s\n' "$EXPECTED" >> "$CONF"
|
||||
CHANGED=1
|
||||
}
|
||||
set_kv USE_KDUMP 1
|
||||
set_kv KDUMP_COREDIR /var/crash
|
||||
set_kv CORE_COLLECTOR 'makedumpfile -l --message-level 1 -d 31'
|
||||
[ "$CHANGED" -eq 1 ] || exit 0
|
||||
systemctl enable kdump-tools >/dev/null 2>&1 || true
|
||||
exit 2
|
||||
"#
|
||||
.replace("@@CONF@@", conf)
|
||||
}
|
||||
|
||||
async fn ensure_kdump_defaults() -> Result<()> {
|
||||
let script = kdump_defaults_script("/etc/default/kdump-tools");
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("configure kdump-tools")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(()),
|
||||
Some(2) => {
|
||||
info!("host fixups: kdump-tools configured (USE_KDUMP=1, /var/crash)");
|
||||
Ok(())
|
||||
}
|
||||
code => anyhow::bail!("kdump-tools config exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the installed GRUB cmdline to one fixed `crashkernel=` reservation and
|
||||
/// run update-grub. Debian's kdump-tools package installs a grub.d snippet that
|
||||
/// otherwise appends its own range-based reservation after ours; on amd64 that
|
||||
/// silently wins and reserves only 192M instead of the intended 256M.
|
||||
/// The reservation itself only exists after the next reboot — memory cannot
|
||||
/// be set aside at runtime — so the caller must log the reboot caveat.
|
||||
/// Returns true if the generated cmdline changed.
|
||||
async fn ensure_crashkernel_cmdline() -> Result<bool> {
|
||||
let script = format!(
|
||||
r#"
|
||||
set -u
|
||||
GRUB=/etc/default/grub
|
||||
KDUMP_GRUB=/etc/default/grub.d/kdump-tools.cfg
|
||||
PARAM='{CRASHKERNEL_PARAM}'
|
||||
[ -f "$GRUB" ] || exit 3
|
||||
CHANGED=0
|
||||
# kdump-tools sources this after /etc/default/grub and unconditionally appends
|
||||
# crashkernel=512M-:192M. Neutralize that package default: Archipelago owns the
|
||||
# explicit fixed reservation in GRUB_CMDLINE_LINUX_DEFAULT below.
|
||||
if [ -f "$KDUMP_GRUB" ] && grep -qE '^[^#]*crashkernel=' "$KDUMP_GRUB"; then
|
||||
printf '%s\n' '# Archipelago owns crashkernel sizing in /etc/default/grub.' > "$KDUMP_GRUB"
|
||||
CHANGED=1
|
||||
fi
|
||||
LINE=$(grep -E '^GRUB_CMDLINE_LINUX_DEFAULT=' "$GRUB" | head -1)
|
||||
[ -n "$LINE" ] || exit 3
|
||||
# Remove any prior value before appending ours, so repeated fixups can never
|
||||
# create conflicting parameters whose kernel precedence is easy to misread.
|
||||
NEWLINE=$(printf '%s' "$LINE" | sed -E "s/[[:space:]]+crashkernel=[^ \"']+//g; s/\"$/ $PARAM\"/")
|
||||
if [ "$NEWLINE" != "$LINE" ]; then
|
||||
sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|$NEWLINE|" "$GRUB"
|
||||
CHANGED=1
|
||||
fi
|
||||
[ "$CHANGED" -eq 1 ] || exit 0
|
||||
timeout 120 update-grub >/dev/null 2>&1 || true
|
||||
exit 2
|
||||
"#
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("set crashkernel= in GRUB")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => Ok(true),
|
||||
code => anyhow::bail!("crashkernel cmdline fixup exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_rasdaemon_enabled() -> Result<()> {
|
||||
let status = host_sudo(&["systemctl", "enable", "--now", "rasdaemon"])
|
||||
.await
|
||||
.context("enable rasdaemon")?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("systemctl enable --now rasdaemon exited with {status}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep only the newest [`KEEP_DUMPS`] dumps in /var/crash. Called on every
|
||||
/// fixup pass rather than by a timer: the pass runs at every startup, which is
|
||||
/// exactly the cadence at which new dumps appear (a dump ends in a reboot).
|
||||
fn crash_dump_prune_script() -> String {
|
||||
format!(
|
||||
r#"
|
||||
set -u
|
||||
DIR=${{ARCHIPELAGO_CRASH_DIR:-/var/crash}}
|
||||
[ -d "$DIR" ] || exit 0
|
||||
KEEP={KEEP_DUMPS}
|
||||
# kdump-tools keeps its lock and kexec command files beside timestamped dump
|
||||
# directories. Count and prune directories only: treating those bookkeeping
|
||||
# files as dumps can delete the sole freshly captured vmcore on startup.
|
||||
COUNT=$(find "$DIR" -mindepth 1 -maxdepth 1 -type d -printf . | wc -c)
|
||||
[ "$COUNT" -gt "$KEEP" ] || exit 0
|
||||
find "$DIR" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\0' \
|
||||
| sort -zrn \
|
||||
| tail -z -n +"$((KEEP + 1))" \
|
||||
| cut -z -d ' ' -f 2- \
|
||||
| xargs -0r rm -rf --
|
||||
exit 2
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
async fn prune_crash_dumps() -> Result<()> {
|
||||
let script = crash_dump_prune_script();
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("prune /var/crash")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(()),
|
||||
Some(2) => {
|
||||
info!("host fixups: pruned old dumps in /var/crash (keep {KEEP_DUMPS})");
|
||||
Ok(())
|
||||
}
|
||||
code => anyhow::bail!("/var/crash prune exited with {code:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sysctl_dropin_carries_the_full_hang_capture_policy() {
|
||||
for key in [
|
||||
"kernel.panic = 10",
|
||||
"kernel.panic_on_oops = 1",
|
||||
"kernel.hung_task_panic = 1",
|
||||
"kernel.hardlockup_panic = 1",
|
||||
] {
|
||||
assert!(KDUMP_SYSDROPIN.contains(key), "drop-in missing {key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_list_is_exactly_the_kdump_rasdaemon_set() {
|
||||
assert_eq!(
|
||||
HOST_PACKAGES,
|
||||
&["kdump-tools", "kexec-tools", "makedumpfile", "rasdaemon"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crashkernel_param_is_sized_and_unprefixed() {
|
||||
assert_eq!(CRASHKERNEL_PARAM, "crashkernel=256M");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_dumps_is_two() {
|
||||
assert_eq!(KEEP_DUMPS, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kdump_defaults_repairs_old_malformed_line_and_is_idempotent() {
|
||||
use std::{fs, process::Command};
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let conf = root.path().join("kdump-tools");
|
||||
let bin = root.path().join("bin");
|
||||
fs::create_dir(&bin).unwrap();
|
||||
fs::write(bin.join("systemctl"), "#!/bin/sh\nexit 0\n").unwrap();
|
||||
assert!(Command::new("chmod")
|
||||
.args(["+x"])
|
||||
.arg(bin.join("systemctl"))
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
fs::write(
|
||||
&conf,
|
||||
"# package defaults\n=\"\"\nUSE_KDUMP=0\nUSE_KDUMP=\"1\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let script = kdump_defaults_script(conf.to_str().unwrap());
|
||||
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap());
|
||||
let first = Command::new("sh")
|
||||
.args(["-lc", &script])
|
||||
.env("PATH", &path)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert_eq!(first.code(), Some(2));
|
||||
let repaired = fs::read_to_string(&conf).unwrap();
|
||||
assert!(!repaired.lines().any(|line| line == "=\"\""));
|
||||
assert_eq!(repaired.matches("USE_KDUMP=").count(), 1);
|
||||
assert!(repaired.contains("USE_KDUMP=\"1\""));
|
||||
assert!(repaired.contains("KDUMP_COREDIR=\"/var/crash\""));
|
||||
assert!(repaired.contains("CORE_COLLECTOR=\"makedumpfile -l --message-level 1 -d 31\""));
|
||||
|
||||
let second = Command::new("sh")
|
||||
.args(["-lc", &script])
|
||||
.env("PATH", path)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(second.success());
|
||||
assert_eq!(fs::read_to_string(conf).unwrap(), repaired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_pruning_ignores_kdump_bookkeeping_files() {
|
||||
use std::{fs, process::Command};
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let crash = root.path();
|
||||
fs::write(crash.join("kdump_lock"), []).unwrap();
|
||||
fs::write(crash.join("kexec_cmd"), "kexec -p").unwrap();
|
||||
|
||||
for (name, epoch) in [("old dump", "100"), ("middle", "200"), ("newest", "300")] {
|
||||
let path = crash.join(name);
|
||||
fs::create_dir(&path).unwrap();
|
||||
fs::write(path.join("vmcore"), name).unwrap();
|
||||
assert!(Command::new("touch")
|
||||
.args(["-d", &format!("@{epoch}")])
|
||||
.arg(&path)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
}
|
||||
|
||||
let status = Command::new("sh")
|
||||
.args(["-lc", &crash_dump_prune_script()])
|
||||
.env("ARCHIPELAGO_CRASH_DIR", crash)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert_eq!(status.code(), Some(2));
|
||||
assert!(!crash.join("old dump").exists());
|
||||
assert!(crash.join("middle").join("vmcore").exists());
|
||||
assert!(crash.join("newest").join("vmcore").exists());
|
||||
assert!(crash.join("kdump_lock").exists());
|
||||
assert!(crash.join("kexec_cmd").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_pruning_does_nothing_when_only_bookkeeping_files_exist() {
|
||||
use std::{fs, process::Command};
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for name in ["kdump_lock", "kexec_cmd", "another-marker"] {
|
||||
fs::write(root.path().join(name), []).unwrap();
|
||||
}
|
||||
let status = Command::new("sh")
|
||||
.args(["-lc", &crash_dump_prune_script()])
|
||||
.env("ARCHIPELAGO_CRASH_DIR", root.path())
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 3);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ mod entropy;
|
||||
mod federation;
|
||||
mod fips;
|
||||
mod health_monitor;
|
||||
mod host_fixups;
|
||||
mod host_ip;
|
||||
mod identity;
|
||||
mod identity_manager;
|
||||
@@ -435,6 +436,12 @@ async fn main() -> Result<()> {
|
||||
// iframe on kiosk nodes (docs/tv-input-iframe-apps.md).
|
||||
tokio::spawn(bootstrap::ensure_gamepad_keys());
|
||||
|
||||
// Host-level fixups (#144 + docs/system-level-ota-design.md): kdump +
|
||||
// rasdaemon — crash/hardware-error capture delivered to already-deployed
|
||||
// nodes over the signed binary OTA. Idempotent, non-fatal, background;
|
||||
// the crashkernel= GRUB edit lands on the next reboot.
|
||||
tokio::spawn(host_fixups::ensure_host_fixups());
|
||||
|
||||
// Mesh access: mirror IPv4-published app ports onto [::] so direct-port
|
||||
// app URLs (http://[<fips0 ULA>]:<port>) work from the companion.
|
||||
tokio::spawn(mesh_ports::run_mesh_port_mirror());
|
||||
|
||||
@@ -1487,6 +1487,11 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
|
||||
"--quiet",
|
||||
"--collect",
|
||||
"--pipe",
|
||||
// Shell snippets passed as one argument must reach the child intact.
|
||||
// systemd-run otherwise expands $VAR/${VAR} against the manager's
|
||||
// environment before `sh -lc` can see them (and usually replaces them
|
||||
// with empty strings).
|
||||
"--expand-environment=no",
|
||||
"--",
|
||||
];
|
||||
full.extend_from_slice(args);
|
||||
@@ -1506,6 +1511,7 @@ pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Outp
|
||||
"--quiet",
|
||||
"--collect",
|
||||
"--pipe",
|
||||
"--expand-environment=no",
|
||||
"--",
|
||||
];
|
||||
full.extend_from_slice(args);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# HANDOFF — companion-agent work queue (2026-08-30)
|
||||
|
||||
**For: the companion agent.** Compiled from the 2026-08-30 issue-triage
|
||||
session. The tracker now labels the companion-owned issues `companion-agent`
|
||||
(#128, #139); this document adds the pointers and one small residual that
|
||||
isn't worth its own issue until it's being fixed.
|
||||
|
||||
## Pointers
|
||||
|
||||
- **App source:** `Android/` in this repo (Kotlin/Gradle). Release notes
|
||||
live in `Android/COMPANION_RELEASE.md`.
|
||||
- **Served artifact:** `neode-ui/public/packages/archipelago-companion.apk`
|
||||
+ `archipelago-companion.json` (currently **0.5.27 / versionCode 47**).
|
||||
Shipping a companion change means refreshing both in the same commit
|
||||
(versionCode +1) plus a COMPANION_RELEASE.md entry; nodes serve the file
|
||||
from the web bundle. The deploy/verify pipeline (aapt badging, size
|
||||
checks, node redeploy) is documented in
|
||||
`docs/HANDOFF-2026-07-23-companion-apk-deploy.md`.
|
||||
- **Web bridge:** `window.ArchipelagoNative` (JS interface the WebView
|
||||
injects); `isCompanionApp()` in `neode-ui/src/utils/openExternal.ts` is
|
||||
the canonical detection helper; `appLauncher.ts` shows the gating pattern.
|
||||
|
||||
## Work queue
|
||||
|
||||
### 1. Residual of #61 — companion-gate the store banner + intro overlay (small)
|
||||
|
||||
What #61 fixed was the AUTO-popup: `CompanionIntroOverlay` skips its
|
||||
mounted auto-show when `IN_COMPANION_APP` (the `ArchipelagoNative` bridge
|
||||
is present). Two paths are still ungated, so a user already inside the
|
||||
companion WebView still gets "install the companion" pitches:
|
||||
|
||||
- `<CompanionBanner />` in `neode-ui/src/views/Discover.vue:156` renders
|
||||
unconditionally.
|
||||
- `openCompanionIntro()` (`neode-ui/src/composables/useCompanionIntro.ts`)
|
||||
is an explicit trigger that intentionally bypasses the once-per-browser
|
||||
gate — but nothing companion-checks its callers.
|
||||
|
||||
Fix: gate the banner render and the intro-trigger entry points on
|
||||
`isCompanionApp()`, same pattern as `appLauncher.ts` (lines ~236/~341).
|
||||
Verify inside the companion WebView (banner absent, no manual path can pop
|
||||
the overlay). Land it in the web UI here; the APK doesn't change.
|
||||
|
||||
### 2. #128 — GrapheneOS phone backup & restore (feature)
|
||||
|
||||
Reporter's problem: losing your phone, or wiping it to cross a border.
|
||||
Reporter's suggestion: "part of the companion app or passport prime combo".
|
||||
The companion owns the phone side: trigger a GrapheneOS backup, transport
|
||||
it, and restore it onto a wiped device — coordinated with the node's
|
||||
existing encrypted-backup envelope (ADR-005: ChaCha20-Poly1305 +
|
||||
Argon2id, `core/archipelago/src/backup.rs`). **Reuse that envelope; do not
|
||||
invent a second backup format.** Node-side storage/quota/scheduling is
|
||||
tracked separately on the roadmap — coordinate before assuming node-side
|
||||
surface beyond the existing backup RPCs.
|
||||
|
||||
### 3. #139 — Nostr Bunker: companion-side remote signer (feature)
|
||||
|
||||
"Remote signer with companion app?" — the phone side of NIP-46: a bunker
|
||||
client in the companion (pairing with a node-side bunker service via
|
||||
QR/URI, a signature approve/deny UX that makes what's being signed legible,
|
||||
and saved-remote-bunker management). Background research already exists:
|
||||
`docs/nostr-signer-login-research.md`. The node-side bunker hosting is
|
||||
roadmap-tracked separately; this issue's companion label covers the
|
||||
phone-side integration.
|
||||
|
||||
## Working rules (same as the node repo)
|
||||
|
||||
- Small commits, pushed immediately; vitest for web-side changes; the
|
||||
Kotlin app's on-device flows get verified on a real device before the
|
||||
APK ships.
|
||||
- Node-side Rust changes are out of scope for the companion queue —
|
||||
anything that needs them goes through the labeled issues on the tracker.
|
||||
- Done = artifact refreshed (APK + json meta) so a web-bundle deploy can
|
||||
serve it, plus the issue updated with what shipped.
|
||||
@@ -54,6 +54,9 @@ step-by-step guides, and some predate the current implementation.
|
||||
- [Dual Ecash](dual-ecash-design.md)
|
||||
- [Hardware Signer](hardware-signer-design.md)
|
||||
- [Manifest Hooks](manifest-hooks-design.md)
|
||||
- [Peering & Federation Trust](peering-trust-model.md) — naming/semantics of trust levels vs discovery (#134)
|
||||
- [kdump + rasdaemon Troubleshooting](kdump-rasdaemon-design.md) — post-mortem and hardware-error capture on nodes (#144)
|
||||
- [System-Level OTA](system-level-ota-design.md) — how host-level packages/config reach already-deployed nodes
|
||||
- [Meshroller Integration](meshroller-integration-design.md)
|
||||
- [Nostr Git Source Hosting](nostr-git-source-hosting.md)
|
||||
- [Nostr Identity Import](nostr-identity-import-plan.md) · [Nostr Signer Login (research)](nostr-signer-login-research.md)
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
# Release Notes Backlog
|
||||
|
||||
## Next Release Required Work
|
||||
## Required Work — completed 2026-08-30, before the v1.8.5-alpha cut
|
||||
|
||||
- Backfill missing or thin historical release notes before cutting the next release.
|
||||
- Audit every `CHANGELOG.md` section from `v1.7.44-alpha` through the current release.
|
||||
- Replace raw commit-hash entries with user/operator-facing bullets that explain behavior changes, operational impact, validation, and known limitations.
|
||||
- Ensure `releases/manifest.json` changelog entries come from curated `CHANGELOG.md` notes only.
|
||||
- [x] Backfill missing or thin historical release notes before cutting the next release.
|
||||
Eight sections backfilled, sourced from the Settings "What's New" blocks,
|
||||
the old-lineage release commits, and the diffs of the self-contained
|
||||
hotfix releases: **v1.7.44** (was raw commit-hash lines), **v1.7.47,
|
||||
v1.7.48, v1.7.64, v1.7.65** (were thin), and **v1.7.50, v1.7.51,
|
||||
v1.7.107** (sections were missing entirely — real releases with tags but
|
||||
no changelog section; v1.7.107 was restored verbatim from the curated
|
||||
version that existed at `35e9c624` and was later lost). The What's New
|
||||
modal blocks for the three restored versions were generated by
|
||||
`scripts/sync-whats-new.py`, which now passes with all 92 versions.
|
||||
- [x] Audit every `CHANGELOG.md` section from `v1.7.44-alpha` through the
|
||||
current release. Mechanical inventory of all 92 sections in range:
|
||||
every section carries ≥3 curated bullets, zero raw commit-hash entries.
|
||||
- [x] Replace raw commit-hash entries with user/operator-facing bullets
|
||||
that explain behavior changes, operational impact, validation, and
|
||||
known limitations. The only offender was v1.7.44 (four raw hash lines,
|
||||
now curated).
|
||||
- [x] Ensure `releases/manifest.json` changelog entries come from curated
|
||||
`CHANGELOG.md` notes only. Satisfied by construction:
|
||||
`create-release-manifest.sh` reads the changelog from `CHANGELOG.md`,
|
||||
and `check-release-manifest.sh` rejects manifests with fewer than three
|
||||
bullets or raw git-log lines before publishing.
|
||||
|
||||
## Release Note Policy
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# kdump + rasdaemon — post-mortem and hardware-error capture (#144)
|
||||
|
||||
Status: IMPLEMENTED (phase 1) — decisions approved 2026-08-30: hang capture ON,
|
||||
crashkernel=256M, ship the backfill with this release, phase-2 UI deferred.
|
||||
Delivery: image-recipe (Dockerfile.rootfs, auto-install.sh cmdline) +
|
||||
`core/archipelago/src/host_fixups.rs` (existing nodes, see
|
||||
docs/system-level-ota-design.md) + `tests/lifecycle/os-audit.sh` section D.
|
||||
Owner: node image (image-recipe) + lifecycle gate
|
||||
Issue: #144 — "Configure kdump and rasdaemon for troubleshooting"
|
||||
|
||||
## The problem
|
||||
|
||||
When a fleet node hard-locks or a memory stick starts failing, today we get
|
||||
nothing: a frozen kiosk is power-cycled and the evidence is gone; a DIMM
|
||||
throwing correctable ECC errors for weeks is invisible until it starts
|
||||
corrupting things. Two standard kernel mechanisms capture this evidence:
|
||||
|
||||
- **kdump** — reserves a small crash kernel at boot; on a kernel panic (or,
|
||||
configured so, a hang) the running kernel hands the machine over to the
|
||||
crash kernel, which writes a compressed dump of memory to disk and
|
||||
reboots. The node comes back by itself *and* leaves a post-mortem.
|
||||
- **rasdaemon** — a userspace daemon that records hardware error events
|
||||
(correctable/uncorrectable ECC per DIMM, PCIe AER) from EDAC/sysfs into a
|
||||
sqlite database: persistent evidence of degrading hardware with no crash
|
||||
required.
|
||||
|
||||
## Facts the design rests on
|
||||
|
||||
- Installed-disk layout (auto-install.sh): BIOS boot 1MiB · EFI 512MiB ·
|
||||
**root ext4 30GiB, unencrypted** · data (rest, LUKS).
|
||||
- The data partition is LUKS and unlocked late by the node itself — the
|
||||
crash kernel must never be asked to handle key material.
|
||||
- The installed system's kernel command line is written by
|
||||
auto-install.sh:1810 (`GRUB_CMDLINE_LINUX_DEFAULT="quiet splash …"`).
|
||||
- Packages land via `Dockerfile.rootfs` (trixie) with `systemctl enable`
|
||||
in the same RUN block (nginx/tor/avahi pattern).
|
||||
- Kernel cmdline cannot be changed by OTA — it lives in GRUB. Existing
|
||||
nodes need a backfill step (bootstrap) plus a deliberate reboot.
|
||||
|
||||
## Design
|
||||
|
||||
### kdump
|
||||
|
||||
- **Packages:** `kdump-tools kexec-tools` added to Dockerfile.rootfs.
|
||||
- **Command line:** append `crashkernel=256M` to
|
||||
`GRUB_CMDLINE_LINUX_DEFAULT` in auto-install.sh. 256M covers the capture
|
||||
kernel plus makedumpfile on the fleet's 16–64GB amd64 machines (~1–2% of
|
||||
RAM reserved, permanently). The arm image (RPi, config.txt boot) is out
|
||||
of scope for phase 1.
|
||||
- **Dump target:** `local filesystem /var/crash` — on the unencrypted 30GiB
|
||||
root, deliberately *not* the encrypted data partition. No key handling
|
||||
in the crash initramfs, no dependency on the node's own unlock logic.
|
||||
- **Core collector:** `makedumpfile -l --message-level 1 -d 31`
|
||||
(compressed, zero/free pages excluded) — a dump lands at roughly 5–15%
|
||||
of RAM, i.e. ~1–2 GiB on a 16 GiB machine.
|
||||
- **Retention:** keep the **2 newest** dumps only. A small systemd timer
|
||||
(or kdump-tools' `KDUMP_POST_SCRIPT`) prunes older vmcores; a full root
|
||||
partition is already caught by disk_monitor's usage tracking. Two dumps
|
||||
≈ 4 GiB worst case on 30 GiB root — safe.
|
||||
- **When to dump — the deliberate trade-off (decision needed):**
|
||||
- Baseline: dump on real panics (`kernel.panic` path) — no behavioral
|
||||
change to a wedged node.
|
||||
- Recommended for this fleet: also enable hang capture
|
||||
(`kernel.hung_task_panic=1`, hardlockup via NMI watchdog). A kiosk
|
||||
that hard-locks is useless until power-cycled anyway; converting the
|
||||
hang into "dump + automatic reboot" turns every freeze into evidence
|
||||
*and* self-heals the node. Cost: a genuinely-busy-but-alive machine
|
||||
that trips the watchdog reboots — the threshold is kernel-default
|
||||
conservative (40s), so this should be rare.
|
||||
|
||||
### rasdaemon
|
||||
|
||||
- **Packages:** `rasdaemon`; `systemctl enable rasdaemon` in the
|
||||
Dockerfile.rootfs enable block (same pattern as nginx).
|
||||
- **Storage:** its default sqlite DB at
|
||||
`/var/lib/rasdaemon/ras-mc_event.db` on the unencrypted root.
|
||||
- **Human access today:** `ras-mc-ctl --summary` / `--errors` over SSH.
|
||||
No UI in phase 1.
|
||||
|
||||
### Surfacing (phase 2 — separate follow-up, not in this cut)
|
||||
|
||||
A small read-only `system.diagnostics` surface: last-crash timestamp and
|
||||
vmcore sizes from `/var/crash`, plus ECC error totals per DIMM from the
|
||||
rasdaemon DB — shown in Settings → System. Deliberately deferred: capture
|
||||
first, UI once there is something to show and a node in the fleet has
|
||||
actually produced a dump.
|
||||
|
||||
### Existing nodes (phase 1.5 backfill)
|
||||
|
||||
The OTA cannot change the bootloader. Bootstrap (which already delivers
|
||||
fixes to existing nodes) appends `crashkernel=256M` (and the chosen
|
||||
panic/hang params) to `/etc/default/grub` on machines that don't have it,
|
||||
and enables `rasdaemon` via the node's package install path. **Takes
|
||||
effect on the next reboot** — the operator reboots nodes when applying the
|
||||
release; no special ceremony needed beyond that.
|
||||
|
||||
## Testing
|
||||
|
||||
- Image: the new packages appear in the ISO; QEMU boot smoke
|
||||
(build-iso-release.sh stage 5) still green.
|
||||
- Lifecycle gate additions (bats, archi-dev-box first): `kdump-config show`
|
||||
reports a loaded crash kernel reservation; `systemctl is-active
|
||||
rasdaemon`; `/etc/default/grub` carries `crashkernel=`.
|
||||
- Live drill (once, on archi-dev-box, not in the gate): trigger
|
||||
`sysrq c` → vmcore appears in `/var/crash`, node reboots itself,
|
||||
second boot is clean. Keep this manual — it reboots the box.
|
||||
|
||||
## Implementation touchpoints
|
||||
|
||||
1. `image-recipe/build/auto-installer/Dockerfile.rootfs` — packages +
|
||||
`systemctl enable rasdaemon`.
|
||||
2. `image-recipe/build/auto-installer/installer-iso/archipelago/auto-install.sh:1810`
|
||||
— append `crashkernel=256M` (+ hang params if approved) to
|
||||
`GRUB_CMDLINE_LINUX_DEFAULT`.
|
||||
3. `kdump-tools` config: `/etc/default/kdump-tools` (dump target
|
||||
`/var/crash`, core_collector line, `KDUMP_POST_SCRIPT` or timer for
|
||||
retention).
|
||||
4. Bootstrap backfill for existing nodes.
|
||||
5. `tests/lifecycle` — presence assertions (crash kernel reserved,
|
||||
rasdaemon active).
|
||||
|
||||
## Decisions needed before implementation
|
||||
|
||||
1. **Hang capture on or off?** Recommended ON (`hung_task_panic=1` +
|
||||
NMI watchdog): every hard lockup becomes a dump + self-reboot. OFF
|
||||
means dumps only on true panics; wedged nodes still need the button.
|
||||
2. **crashkernel=256M vs 320M** — 256M is the common default for
|
||||
16–64GB machines; 320M if we expect large io-heavy kernels.
|
||||
3. **Backfill now or new-installs-only?** Recommended: ship the backfill
|
||||
with the next release so the whole fleet gains capture on reboot.
|
||||
4. Phase-2 UI surfacing scope — confirm "later" so phase 1 stays small.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Peering & Federation Trust — naming and semantics
|
||||
|
||||
Status: TERMINOLOGY SET — records what the code does today (#134).
|
||||
Deferred: the "don't advertise my peers" opt-out (see §Open questions).
|
||||
|
||||
The code is the authority; this doc gives names to the four concepts that
|
||||
issue #134 showed get conflated in conversation. Where a name changed in
|
||||
user-facing discussion, the term below is the one to use everywhere
|
||||
(UI copy, docs, issues, reviews).
|
||||
|
||||
## The four concepts
|
||||
|
||||
| Term (use this) | What it is | Where it lives |
|
||||
|---|---|---|
|
||||
| **Trusted peer** | A node THIS operator invited and verified: bilateral DID challenge over an out-of-band invite code (`federation::sync`, ADR-007). The only level that grants full access. | `TrustLevel::Trusted`, set via `TrustSource::Invite` or `Manual` |
|
||||
| **Discovered peer** | A peer we learned about from a Trusted peer's advertised list — the transitive merge. Never better than **Observer**: `TRUST IS NOT TRANSITIVE` (sync.rs guard). | `TrustLevel::Observer`, `TrustSource::TransitiveMerge` |
|
||||
| **Routing hint** | What a Discovered peer actually contributes: an address that lets us route directly over FIPS without a second invite hop. Reachability, not trust. | Observer-level sync + FIPS endpoint records |
|
||||
| **Peer advertisement** | The act of a Trusted peer sharing its own peer list during sync. This is the *mechanism* #134 observed — a feature, not a leak. | sync.rs merge path |
|
||||
|
||||
## The two rules that make it sound
|
||||
|
||||
1. **Trust requires an operator decision, always traceable.** Every trust
|
||||
level carries a `TrustSource`. Only a minted invite (or an explicit
|
||||
operator change) can produce `Trusted`; uninvited joins and transitive
|
||||
merges are hard-capped at `Observer` — a peer can never expand our
|
||||
trusted set on its own authority.
|
||||
2. **Discovery is transitive; trust is not.** Seeing more nodes through a
|
||||
Trusted peer is expected and useful (routing). Granting those nodes
|
||||
anything is an operator action, never automatic.
|
||||
|
||||
## Why a Trusted peer advertising its list is by design
|
||||
|
||||
Without advertisement, every new node needs a direct invite from every node
|
||||
that wants to reach it — the invite graph becomes the routing bottleneck
|
||||
AdDR-007 set out to remove. With it, one invite makes a node *reachable* to
|
||||
the trusted set (routing hints), while *authorization* still requires each
|
||||
operator's own invite. Reachability ≠ access.
|
||||
|
||||
## Open questions (deferred, tracked in #134)
|
||||
|
||||
- **"Don't advertise my peers"** — an operator privacy toggle suppressing
|
||||
peer advertisement during sync. Small code change, real design questions:
|
||||
it hides peers who may WANT discovery, and it degrades the routing benefit
|
||||
for every node trusting you. Needs a product decision, not just code.
|
||||
- **Tier vocabulary in the UI** — whether to surface "Observer" as such or
|
||||
a friendlier term ("Connected"/"Visible") — part of the TODO.md peering
|
||||
trust-model item.
|
||||
@@ -0,0 +1,82 @@
|
||||
# System-Level OTA — host fixups
|
||||
|
||||
Status: Implemented (first payload shipped alongside this doc)
|
||||
Owner: `core/archipelago/src/host_fixups.rs`
|
||||
Related: docs/kdump-rasdaemon-design.md (first payload), CLAUDE.md invariants
|
||||
|
||||
## The problem
|
||||
|
||||
The binary OTA updates the node's own software, and the signed app catalog
|
||||
updates apps. But the **host OS** — Debian packages, kernel parameters,
|
||||
system services — previously moved only through ISO re-installs. A node
|
||||
deployed a year ago can be running today's node software on a host that
|
||||
never gained anything the image learned since. Issue #99 (missing polkit
|
||||
rule on old nodes) and the audio-stack heal were each hand-carved
|
||||
one-off bootstrap repairs; there was no general channel and no stated
|
||||
policy for touching the host from the node.
|
||||
|
||||
## The mechanism
|
||||
|
||||
`host_fixups::ensure_host_fixups()` — spawned from `main.rs` at startup
|
||||
alongside the other `ensure_*` heals, in the background, best-effort:
|
||||
|
||||
1. **Dev-box guard** — skip when `/home/archipelago/archy` is a symlink
|
||||
(contributor checkout) and when there's no dpkg (non-Debian host).
|
||||
2. **Packages** — install only what's missing, from a curated, in-code
|
||||
list (`HOST_PACKAGES`), `apt-get install` first, one `apt-get update`
|
||||
retry, both under timeout, never fatal (offline/locked-dpkg nodes
|
||||
converge on a later boot).
|
||||
3. **Configuration** — idempotent per-concern helpers writing root-owned
|
||||
config (via the existing `host_sudo` path): sysctl drop-ins, service
|
||||
defaults, GRUB cmdline, service enablement.
|
||||
4. **Reporting** — every step logs what it did; failures log warnings and
|
||||
move on. A host fixup must never be able to stop the node from starting.
|
||||
|
||||
### Why embedded-in-the-binary rather than fetched
|
||||
|
||||
Same reasoning as the tor-helper (`bootstrap.rs`): the signed binary OTA
|
||||
is the only authenticated delivery channel every node already trusts and
|
||||
pulls on schedule. Fixups compiled into the binary travel with a version,
|
||||
are reviewable in git, and can't be served to a subset of the fleet.
|
||||
|
||||
## Policy — what may travel this channel
|
||||
|
||||
| May | May not |
|
||||
|---|---|
|
||||
| Specific, pinned packages the node needs (kdump-tools, rasdaemon, …) | `dist-upgrade` or silent kernel/libc swaps — regular Debian upgrades stay with the operator |
|
||||
| Kernel *parameters* via GRUB/sysctl — with the next-reboot caveat logged loudly | Anything requiring a secret, or touching LUKS key material |
|
||||
| Service enablement + config the image also bakes in | Divergence: the ISO must converge to the SAME end state so fresh installs are a no-op |
|
||||
| Small, reviewable, per-concern Rust functions with tests | Shell-script-of-things payloads beyond a single concern |
|
||||
|
||||
The rule: **the ISO and the fixup must express the same intent twice,
|
||||
in reviewable places** — Dockerfile.rootfs/auto-install.sh for fresh
|
||||
installs, `host_fixups.rs` for the deployed fleet. A change that lands in
|
||||
one and not the other is a bug.
|
||||
|
||||
## Kernel cmdline caveat
|
||||
|
||||
`crashkernel=` (and any future `hugepages=`-style reservation) only takes
|
||||
effect at boot: the fixup writes `/etc/default/grub` + `update-grub` and
|
||||
logs `takes effect on the NEXT reboot`. Operators reboot nodes when
|
||||
applying releases; no special ceremony is required beyond that, but the
|
||||
lifecycle gate grades this state honestly (WARN for written-but-not-yet-
|
||||
rebooted, FAIL for never-written — see `tests/lifecycle/os-audit.sh`
|
||||
section D).
|
||||
|
||||
## Verification story
|
||||
|
||||
- Unit tests pin the policy constants and script shapes
|
||||
(`host_fixups` tests in `core/archipelago`).
|
||||
- `tests/lifecycle/os-audit.sh` section D asserts the end state on a real
|
||||
node (config present, crashkernel reserved or pending reboot, hang
|
||||
policy live, rasdaemon active).
|
||||
- The lifecycle gate runs on archi-dev-box per release; the QEMU ISO
|
||||
smoke covers fresh installs.
|
||||
|
||||
## Future payloads (candidates, not commitments)
|
||||
|
||||
- `unattended-upgrades` posture + a default-deny host nftables ruleset
|
||||
(the §F hardening-plan item — needs its own design first).
|
||||
- Host firewall rules for mesh/WG ports.
|
||||
- Chronic: anything the image learns post-deploy that old nodes must
|
||||
converge on (the polkit and audio precedents, formalized).
|
||||
@@ -567,6 +567,33 @@ RUN mkdir -p /etc/polkit-1/rules.d && \
|
||||
> /etc/polkit-1/rules.d/49-archipelago-networkmanager.rules && \
|
||||
chmod 644 /etc/polkit-1/rules.d/49-archipelago-networkmanager.rules
|
||||
|
||||
# kdump + rasdaemon (#144, docs/kdump-rasdaemon-design.md): crash dumps and
|
||||
# hardware-error capture on the host. Packages + config are baked in for fresh
|
||||
# installs; the binary's host_fixups module delivers the identical end state to
|
||||
# already-deployed nodes over OTA (idempotent no-op here once applied).
|
||||
RUN set -eu; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends kdump-tools kexec-tools rasdaemon; \
|
||||
apt-get clean; rm -rf /var/lib/apt/lists/*; \
|
||||
CONF=/etc/default/kdump-tools; \
|
||||
sed -i 's|^#\?USE_KDUMP=.*|USE_KDUMP="1"|' "$CONF"; \
|
||||
grep -q '^KDUMP_COREDIR=' "$CONF" \
|
||||
&& sed -i 's|^KDUMP_COREDIR=.*|KDUMP_COREDIR="/var/crash"|' "$CONF" \
|
||||
|| printf '\nKDUMP_COREDIR="/var/crash"\n' >> "$CONF"; \
|
||||
grep -q '^CORE_COLLECTOR=' "$CONF" \
|
||||
&& sed -i 's|^CORE_COLLECTOR=.*|CORE_COLLECTOR="makedumpfile -l --message-level 1 -d 31"|' "$CONF" \
|
||||
|| printf '\nCORE_COLLECTOR="makedumpfile -l --message-level 1 -d 31"\n' >> "$CONF"; \
|
||||
printf '%s\n' \
|
||||
'# Archipelago kdump policy (#144). A wedged kiosk is useless until someone' \
|
||||
'# power-cycles it — capture the evidence, then reboot by itself. Dumps land in' \
|
||||
'# /var/crash (see docs/kdump-rasdaemon-design.md); keep-2 pruning is done by' \
|
||||
'# the host fixup pass, not a timer.' \
|
||||
'kernel.panic = 10' \
|
||||
'kernel.panic_on_oops = 1' \
|
||||
'kernel.hung_task_panic = 1' \
|
||||
'kernel.hardlockup_panic = 1' \
|
||||
> /etc/sysctl.d/99-archipelago-kdump.conf
|
||||
|
||||
# Enable services
|
||||
RUN systemctl enable NetworkManager || true && \
|
||||
systemctl enable polkit || systemctl enable polkit.service || true && \
|
||||
@@ -580,7 +607,9 @@ RUN systemctl enable NetworkManager || true && \
|
||||
systemctl enable archipelago-update.timer || true && \
|
||||
systemctl enable archipelago-doctor.timer || true && \
|
||||
systemctl enable archipelago-tor-helper.path || true && \
|
||||
systemctl enable nostr-relay || true
|
||||
systemctl enable nostr-relay || true && \
|
||||
systemctl enable rasdaemon || true && \
|
||||
systemctl enable kdump-tools || true
|
||||
# archipelago-fips.service + archipelago-wg.service + archipelago-wg-address.service
|
||||
# stay installed and enabled. They all use `ConditionPathExists=` on their
|
||||
# respective seed-derived key files, so on a fresh pre-onboarding boot
|
||||
@@ -3715,8 +3744,15 @@ if [ -d "$BOOT_MEDIA/archipelago/plymouth-theme" ]; then
|
||||
ln -sf /usr/share/plymouth/themes/archipelago/archipelago.plymouth \
|
||||
/mnt/target/etc/alternatives/default.plymouth 2>/dev/null || true
|
||||
# Configure clean boot: splash, suppress kernel noise, hide cursor
|
||||
sed -i 's/GRUB_CMDLINE_LINUX_DEFAULT=".*"/GRUB_CMDLINE_LINUX_DEFAULT="quiet splash loglevel=0 rd.systemd.show_status=false vt.global_cursor_default=0 acpi=force"/' \
|
||||
sed -i 's/GRUB_CMDLINE_LINUX_DEFAULT=".*"/GRUB_CMDLINE_LINUX_DEFAULT="quiet splash loglevel=0 rd.systemd.show_status=false vt.global_cursor_default=0 acpi=force crashkernel=256M"/' \
|
||||
/mnt/target/etc/default/grub 2>/dev/null || true
|
||||
# kdump-tools ships a grub.d snippet that appends crashkernel=512M-:192M
|
||||
# after this line. The later value silently wins on amd64, so neutralize
|
||||
# the package default and keep Archipelago's explicit fixed reservation.
|
||||
if [ -f /mnt/target/etc/default/grub.d/kdump-tools.cfg ]; then
|
||||
printf '%s\n' '# Archipelago owns crashkernel sizing in /etc/default/grub.' \
|
||||
> /mnt/target/etc/default/grub.d/kdump-tools.cfg
|
||||
fi
|
||||
echo " Installed Archipelago Plymouth theme on target"
|
||||
fi
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.4-alpha",
|
||||
"version": "1.8.5-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.4-alpha",
|
||||
"version": "1.8.5-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.4-alpha",
|
||||
"version": "1.8.5-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -571,6 +571,18 @@
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
|
||||
"repoUrl": "https://github.com/ACINQ/phoenixd"
|
||||
},
|
||||
{
|
||||
"id": "cuprate",
|
||||
"title": "Cuprate",
|
||||
"version": "0.1.0-preview",
|
||||
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
|
||||
"icon": "/assets/img/app-icons/cuprate.svg",
|
||||
"author": "Cuprate contributors",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
|
||||
"repoUrl": "https://github.com/Cuprate/cuprate"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed bottom-5 left-1/2 -translate-x-1/2 z-40 w-[min(92vw,420px)] glass-card px-4 py-3 flex items-start gap-3 shadow-xl"
|
||||
>
|
||||
<svg class="w-5 h-5 text-white/60 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393C6.957 3.83 17.043 3.83 22.606 9.393" />
|
||||
</svg>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white">No network connection</p>
|
||||
<p class="text-xs text-white/60 mt-0.5">This node has no cable or WiFi link yet. You can set up WiFi now — it also works without internet.</p>
|
||||
<div class="flex gap-2 mt-2.5">
|
||||
<button
|
||||
class="px-3 py-1.5 glass-button rounded-lg text-xs font-medium"
|
||||
@click="goToWifi"
|
||||
>
|
||||
Connect to WiFi
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs text-white/50 hover:text-white transition-colors"
|
||||
@click="dismissed = true"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-white/40 hover:text-white transition-colors shrink-0" aria-label="Dismiss" @click="dismissed = true">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
/** True when at least one physical interface is up (ethernet or WiFi).
|
||||
* Exported for tests — the component only needs this one pure decision. */
|
||||
export function hasPhysicalLink(interfaces: { type: string; state: string }[]): boolean {
|
||||
return interfaces.some(
|
||||
(iface) => (iface.type === 'ethernet' || iface.type === 'wifi') && iface.state === 'up',
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
/**
|
||||
* Onboarding-only "no network at all" callout (#145).
|
||||
*
|
||||
* A fresh install without a cable can leave a user stranded: the WiFi
|
||||
* settings live in Server → Network and nothing points there. This floats
|
||||
* over the onboarding steps whenever the node has NO physical link (no
|
||||
* ethernet up, no WiFi associated) and deep-links to the WiFi picker.
|
||||
*
|
||||
* Deliberately scoped the other way too: Archipelago is offline-first, so
|
||||
* "no internet" must NEVER nag — only "no link at all" qualifies, and the
|
||||
* callout is onboarding-context only (the wrapper renders it on
|
||||
* /onboarding/* routes; logged-in users have their own places to look).
|
||||
*/
|
||||
const router = useRouter()
|
||||
|
||||
const dismissed = ref(false)
|
||||
const hasLink = ref<boolean | null>(null)
|
||||
|
||||
const visible = computed(() => !dismissed.value && hasLink.value === false)
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let inFlight = false
|
||||
|
||||
async function check() {
|
||||
if (inFlight) return
|
||||
inFlight = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ interfaces: { type: string; state: string }[] }>({
|
||||
method: 'network.list-interfaces',
|
||||
dedup: true,
|
||||
maxRetries: 1,
|
||||
})
|
||||
hasLink.value = hasPhysicalLink(res?.interfaces ?? [])
|
||||
} catch {
|
||||
// Node busy or RPC not ready during early onboarding — never nag on a
|
||||
// failed probe; treat unknown as "don't show".
|
||||
hasLink.value = null
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToWifi() {
|
||||
dismissed.value = true
|
||||
// Server.vue consumes ?open=wifi by popping the WiFi picker on arrival.
|
||||
router.push('/dashboard/server?open=wifi')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
check()
|
||||
// A cable gets plugged in mid-onboarding; poll gently so the callout
|
||||
// dismisses itself the moment a link exists.
|
||||
timer = setInterval(check, 15_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<input
|
||||
:type="revealed ? 'text' : 'password'"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:autocomplete="autocomplete"
|
||||
class="w-full px-3 py-2 pr-10 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
@keyup.enter="$emit('enter')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 px-3 text-white/40 hover:text-white/80 transition-colors"
|
||||
:aria-label="revealed ? 'Hide password' : 'Show password'"
|
||||
:title="revealed ? 'Hide password' : 'Show password'"
|
||||
@click="revealed = !revealed"
|
||||
>
|
||||
<!-- eye -->
|
||||
<svg v-if="!revealed" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<!-- eye-off -->
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Password input with a reveal toggle (#145). Introduced for the WiFi SSID
|
||||
* password — a fresh-install user typing a long wifi key into a TV from
|
||||
* across the room needs to see what they typed — and written reusable so
|
||||
* other password fields can adopt it without re-deriving the eye icon.
|
||||
*/
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
autocomplete?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'enter'): void
|
||||
}>()
|
||||
|
||||
const revealed = ref(false)
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import OnboardingNetworkCallout, { hasPhysicalLink } from '../OnboardingNetworkCallout.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
// #145: a fresh install with no cable strands the user — the callout points
|
||||
// at the WiFi picker, and ONLY when no physical link exists. Archipelago is
|
||||
// offline-first, so "no internet" must never nag: only "no link at all".
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: { call: vi.fn() },
|
||||
}))
|
||||
|
||||
const push = vi.fn()
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push }),
|
||||
}))
|
||||
|
||||
const call = vi.mocked(rpcClient.call)
|
||||
|
||||
function mountCallout() {
|
||||
return mount(OnboardingNetworkCallout)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('hasPhysicalLink (pure decision)', () => {
|
||||
it('no interfaces at all → no link', () => {
|
||||
expect(hasPhysicalLink([])).toBe(false)
|
||||
})
|
||||
|
||||
it('ethernet up → link', () => {
|
||||
expect(hasPhysicalLink([{ type: 'ethernet', state: 'up' }])).toBe(true)
|
||||
})
|
||||
|
||||
it('wifi up → link', () => {
|
||||
expect(hasPhysicalLink([{ type: 'wifi', state: 'up' }])).toBe(true)
|
||||
})
|
||||
|
||||
it('physical interface present but down → no link', () => {
|
||||
expect(
|
||||
hasPhysicalLink([
|
||||
{ type: 'ethernet', state: 'down' },
|
||||
{ type: 'wifi', state: 'down' },
|
||||
]),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('virtual interfaces that happen to be up do NOT count as a link', () => {
|
||||
expect(
|
||||
hasPhysicalLink([
|
||||
{ type: 'bridge', state: 'up' },
|
||||
{ type: 'loopback', state: 'up' },
|
||||
]),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OnboardingNetworkCallout (component)', () => {
|
||||
beforeEach(() => {
|
||||
call.mockReset()
|
||||
})
|
||||
|
||||
it('shows when the node has no physical link, and offers the WiFi picker', async () => {
|
||||
call.mockResolvedValue({
|
||||
interfaces: [
|
||||
{ type: 'ethernet', state: 'down' },
|
||||
{ type: 'wifi', state: 'down' },
|
||||
],
|
||||
})
|
||||
const wrapper = mountCallout()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('No network connection')
|
||||
expect(wrapper.text()).toContain('Connect to WiFi')
|
||||
|
||||
await wrapper.findAll('button').find(b => b.text() === 'Connect to WiFi')!.trigger('click')
|
||||
expect(push).toHaveBeenCalledWith('/dashboard/server?open=wifi')
|
||||
})
|
||||
|
||||
it('stays hidden once any physical link exists — offline-first, no nagging', async () => {
|
||||
call.mockResolvedValue({ interfaces: [{ type: 'ethernet', state: 'up' }] })
|
||||
const wrapper = mountCallout()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('div.fixed').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('never shows on a failed probe — early onboarding, RPC not ready yet', async () => {
|
||||
call.mockRejectedValue(new Error('not ready'))
|
||||
const wrapper = mountCallout()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('div.fixed').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides when dismissed, even with no link', async () => {
|
||||
call.mockResolvedValue({ interfaces: [{ type: 'wifi', state: 'down' }] })
|
||||
const wrapper = mountCallout()
|
||||
await flushPromises()
|
||||
|
||||
const dismiss = wrapper.findAll('button').find(b => b.text() === 'Dismiss')!
|
||||
expect(dismiss).toBeDefined()
|
||||
await dismiss.trigger('click')
|
||||
expect(wrapper.find('div.fixed').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PasswordRevealInput from '../PasswordRevealInput.vue'
|
||||
|
||||
// #145: the reveal toggle exists so a fresh-install user typing a WiFi key
|
||||
// from across the room can see what they typed. The contract: masked by
|
||||
// default, one tap reveals, v-model and enter behave like a plain input.
|
||||
|
||||
describe('PasswordRevealInput', () => {
|
||||
it('masks by default and reveals on toggle', async () => {
|
||||
const wrapper = mount(PasswordRevealInput, {
|
||||
props: { modelValue: 'hunter2', placeholder: 'WiFi password' },
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('type')).toBe('password')
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(input.attributes('type')).toBe('text')
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(input.attributes('type')).toBe('password')
|
||||
})
|
||||
|
||||
it('syncs v-model through update:modelValue', async () => {
|
||||
const wrapper = mount(PasswordRevealInput, { props: { modelValue: '' } })
|
||||
await wrapper.find('input').setValue('s3cret')
|
||||
const emitted = wrapper.emitted('update:modelValue') as string[][]
|
||||
expect(emitted[emitted.length - 1]).toEqual(['s3cret'])
|
||||
})
|
||||
|
||||
it('emits enter on Enter keyup — the WiFi modal submits from the keyboard', async () => {
|
||||
const wrapper = mount(PasswordRevealInput, { props: { modelValue: 'pw' } })
|
||||
await wrapper.find('input').trigger('keyup.enter')
|
||||
expect(wrapper.emitted('enter')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('passes placeholder and disabled through to the input', () => {
|
||||
const wrapper = mount(PasswordRevealInput, {
|
||||
props: { modelValue: '', placeholder: 'WiFi password', disabled: true },
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('placeholder')).toBe('WiFi password')
|
||||
expect(input.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -48,6 +48,10 @@
|
||||
<!-- Content with 3D transitions -->
|
||||
<div class="perspective-container-wrapper">
|
||||
<div class="perspective-container">
|
||||
<!-- #145: fresh install with no cable — point at the WiFi picker. -->
|
||||
<!-- Onboarding routes only: logged-in sessions are offline-first and
|
||||
must never be nagged about connectivity. -->
|
||||
<OnboardingNetworkCallout v-if="isOnboardingRoute" />
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition :name="transitionName">
|
||||
<div :key="route.path" class="view-wrapper">
|
||||
@@ -64,9 +68,15 @@
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { resumeAudioContext, startSynthwave } from '@/composables/useLoginSounds'
|
||||
import OnboardingNetworkCallout from '@/components/OnboardingNetworkCallout.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const currentBackground = ref('bg-intro.jpg')
|
||||
|
||||
// #145: the no-network callout follows the user across onboarding steps, but
|
||||
// this wrapper also hosts /login (and could host more later) — scope the
|
||||
// callout to the onboarding flow only.
|
||||
const isOnboardingRoute = computed(() => route.path.startsWith('/onboarding/'))
|
||||
const isGlitching = ref(false)
|
||||
const isTransitioning = ref(false)
|
||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
@@ -970,6 +970,7 @@ onUnmounted(() => disarmVpnPoll())
|
||||
// outside a <KeepAlive> boundary (confirmed by ServerNetworkRefresh.test.ts,
|
||||
// which mounts this view bare), so a bare mount must not silently skip them.
|
||||
onMounted(() => {
|
||||
consumeOpenWifiQuery()
|
||||
checkTorStatus(); loadNetworkData(); loadInterfaces(); loadTorServices(); loadVpnPeers(); loadFipsSummary(); loadDiskStatus()
|
||||
armServerEntryEffects()
|
||||
})
|
||||
@@ -977,6 +978,20 @@ onMounted(() => {
|
||||
watch(showWifiModal, (open) => { if (open) scanWifi() })
|
||||
watch(showDnsModal, (open) => { if (open) { dnsSelectedProvider.value = networkData.value.dnsProvider || 'system'; dnsError.value = '' } })
|
||||
|
||||
// #145: onboarding's no-network callout deep-links here with ?open=wifi so a
|
||||
// fresh-install user lands straight in the WiFi picker. Read from the real
|
||||
// URL (the dashboard's SPA router keeps it in sync) rather than vue-router —
|
||||
// the KeepAlive-mounted view has no router guarantee at test-mount time —
|
||||
// and consume it (history.replaceState) so a tab-return never re-pops.
|
||||
function consumeOpenWifiQuery() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('open') !== 'wifi') return
|
||||
params.delete('open')
|
||||
const qs = params.toString()
|
||||
history.replaceState(history.state, '', window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash)
|
||||
showWifiModal.value = true
|
||||
}
|
||||
|
||||
async function restartServices() {
|
||||
restarting.value = true; servicesRunning.value = false
|
||||
try { await rpcClient.restartServer(); logsToast.value = 'Services restarting...'; setTimeout(() => { logsToast.value = '' }, 4000) }
|
||||
|
||||
@@ -147,12 +147,12 @@
|
||||
<!-- WiFi password prompt -->
|
||||
<div v-if="wifiConnecting" class="mt-4 pt-4 border-t border-white/10">
|
||||
<p class="text-sm text-white/80 mb-2">Connect to <span class="font-medium text-white">{{ wifiSelectedSsid }}</span></p>
|
||||
<input
|
||||
<PasswordRevealInput
|
||||
v-model="localWifiPassword"
|
||||
type="password"
|
||||
placeholder="WiFi password"
|
||||
class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30 mb-3"
|
||||
@keyup.enter="$emit('connectWifi', localWifiPassword)"
|
||||
:disabled="wifiSubmitting"
|
||||
class="mb-3"
|
||||
@enter="$emit('connectWifi', localWifiPassword)"
|
||||
/>
|
||||
<p v-if="wifiError" class="text-sm text-red-400 mb-3">{{ wifiError }}</p>
|
||||
<div class="flex gap-2">
|
||||
@@ -231,6 +231,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import PasswordRevealInput from '@/components/PasswordRevealInput.vue'
|
||||
|
||||
defineProps<{
|
||||
showAddServiceModal: boolean
|
||||
|
||||
@@ -362,6 +362,59 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.107-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.107-alpha</span>
|
||||
<span class="text-xs text-white/40">July 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.</p>
|
||||
<p>Your node rejoins the mesh faster after an update. Applying this update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried. It now notices the restart and reconnects within seconds.</p>
|
||||
<p>Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle — two separate faults that had been failing the build.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.51-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.51-alpha</span>
|
||||
<span class="text-xs text-white/40">April 30, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Stack installs now adopt containers that already exist instead of failing on them — a repair or reinstall over leftover containers completes, and the adopted container's readiness is waited on like any fresh start.</p>
|
||||
<p>Failed installs come with evidence: the install path waits for its containers, and when one doesn't become healthy it captures that container's logs, so the error on screen names the real culprit instead of a bare timeout.</p>
|
||||
<p>Bitcoin RPC bindings are ensured as part of install, and the startup self-heal path gained additional ground for already-deployed nodes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.50-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.50-alpha</span>
|
||||
<span class="text-xs text-white/40">April 30, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The OTA bridge older nodes needed: deployed binaries only knew how to apply two artifacts (the backend binary and the frontend archive), so the scripts, app specs and docker assets newer releases carry never reached them. This release packs those payloads inside the frontend tarball — the one channel old binaries do apply — and the new backend promotes them into /opt once it starts.</p>
|
||||
<p>Runtime payloads are staged into timestamped directories and promoted atomically; a failed extraction cleans up its staging area instead of leaving half-written state for the next update to trip over.</p>
|
||||
<p>This is the release that un-sticks the fleet's update pipeline: from here on, an OTA can carry more than the two artifacts, and app installs on updated nodes use the specs that match their backend.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.5-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.5-alpha</span>
|
||||
<span class="text-xs text-white/40">August 30, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>**Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.</p>
|
||||
<p>**A frozen node now explains itself — and comes back on its own.** The host now captures a memory dump into /var/crash when the kernel panics *or* wedges (a hung kiosk used to sit dead until someone power-cycled it; now it dumps, reboots itself, and leaves the evidence behind), and records failing-memory signals (ECC errors) into a database as they happen. This is the first change delivered by a new host-update channel: the node's own updater now carries OS-level packages and settings to already-deployed machines — the crash-kernel's memory reservation is the one part that waits for a reboot, and the node says so rather than pretending.</p>
|
||||
<p>**Uninstalling an app can no longer report success when it failed.** The declarative path used to swallow every teardown error and report the app uninstalled, leaving the tile behind and the truth in the logs. A failed uninstall now stops and shows the real per-app errors, so "still there" is never presented as "gone".</p>
|
||||
<p>**Pictures to internet-only mesh contacts work now.** Sending an attachment inline always took the radio path and failed with "Peer is federation-only (no radio twin)" for contacts reachable only over the internet — and the size-adviser kept recommending a radio transfer those peers can't receive. Both fixed: inline sends route over the federation when that's the only way to reach the peer, and the advice no longer offers radio-only transfers to radio-unreachable contacts.</p>
|
||||
<p>**Disk cleanup finally has honest numbers.** Space "free" on a drive was counted including the slice the filesystem keeps reserved for root — roughly 5% of the disk, 92 GB on one dev box — so the automatic cleanup that's supposed to kick in at 90% never triggered and stale container images piled up unnoticed. Reserved space now counts as used, which is what the threshold was always meant to measure.</p>
|
||||
<p>**Three small screens that were lying to you, fixed.** The "Bitcoin is synced — fund your wallet" toast no longer appears on a node where the wallet it means (LND) isn't installed — it points at installing LND instead. The seed-reveal screen hides its third prompt unless the password actually fails to decrypt (the backup passphrase only exists if you set one). And multi-version store cards stop quoting a version number you'll be asked to choose on the next screen anyway.</p>
|
||||
<p>**Mesh notifications survive a refresh, and a stale router no longer hides the fix.** Radio message unread counts are now remembered per contact instead of guessed from session state (the "one new message showed 11 unread" bug), cover Meshtastic, MeshCore and Reticulum alike, and deep-link to the right conversation; a single new message announces itself once. Separately, when the cached router address goes stale, the error card gains a "Reconfigure router" action instead of a Retry loop that can never succeed.</p>
|
||||
<p>**The app updater now knows what upstream shipped.** Every app's manifest records where it comes from — including the odd corners (GitLab-only projects, ghcr-only images) — and a checker sweeps all of them against upstream releases, so a pin that quietly rots for months is now visible instead of invisible. The first full sweep found 27 pins behind; the safe patch-level ones shipped with this release (strfry, BTCPay Server 2.4.3, the two nginx frontends), and the major jumps that may carry data migrations are deliberately held for their own careful passes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.4-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+19
-21
@@ -1,33 +1,31 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the \"app is restarting\" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.",
|
||||
"**The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed.",
|
||||
"**While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input.",
|
||||
"**\"Are you sure?\" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote.",
|
||||
"**A mesh radio now connects no matter which port it's plugged into — or replugged into.** Moving a radio to a different USB port could leave the mesh silently down: the node only checked a short fixed list of port names (a radio landing outside it was invisible), a hand-set serial-port override quietly outranked the device you'd just approved in the \"Radio detected\" window, and one whole family of boards (Espressif-based radios like recent Heltec/T-Deck models) never received a stable device name at all — the exact combination found live on a fleet machine this week. All three are fixed: every serial port is scanned, choosing a radio in the detection window clears any stale override, and Espressif boards get the same stable name as everyone else.",
|
||||
"**Mesh signal strength is honest now.** Every peer heard over Reticulum radio reported a signal strength of exactly 0 — which is also what you'd see with no radio at all, and what peers reached over the internet showed. Real receptions now show their true signal reading, and anything that arrived over a relay or the internet says so by showing none — so \"the radio is working\" and \"the internet is doing the radio's job\" no longer look identical. (The reading depends on the radio's firmware reporting it; boards that don't report per-packet signal stats show \"unknown\" rather than a made-up number, and the new radio diagnostics show at a glance whether yours reports them.)",
|
||||
"**A background error that repeated every 90 seconds, forever, is gone.** After setting up a node from its recovery phrase, the node kept introducing itself to its federation partners with its old temporary identity papers while signing with its new ones — every partner rejected the introduction, and both sides logged an error about it every minute and a half until the next restart. The identity switch now updates everything at once, a rejected introduction is no longer misreported as delivered, and a partner who has already answered is no longer re-asked on every cycle."
|
||||
"**Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.",
|
||||
"**A frozen node now explains itself — and comes back on its own.** The host now captures a memory dump into /var/crash when the kernel panics *or* wedges (a hung kiosk used to sit dead until someone power-cycled it; now it dumps, reboots itself, and leaves the evidence behind), and records failing-memory signals (ECC errors) into a database as they happen. This is the first change delivered by a new host-update channel: the node's own updater now carries OS-level packages and settings to already-deployed machines — the crash-kernel's memory reservation is the one part that waits for a reboot, and the node says so rather than pretending.",
|
||||
"**Uninstalling an app can no longer report success when it failed.** The declarative path used to swallow every teardown error and report the app uninstalled, leaving the tile behind and the truth in the logs. A failed uninstall now stops and shows the real per-app errors, so \"still there\" is never presented as \"gone\".",
|
||||
"**Pictures to internet-only mesh contacts work now.** Sending an attachment inline always took the radio path and failed with \"Peer is federation-only (no radio twin)\" for contacts reachable only over the internet — and the size-adviser kept recommending a radio transfer those peers can't receive. Both fixed: inline sends route over the federation when that's the only way to reach the peer, and the advice no longer offers radio-only transfers to radio-unreachable contacts.",
|
||||
"**Disk cleanup finally has honest numbers.** Space \"free\" on a drive was counted including the slice the filesystem keeps reserved for root — roughly 5% of the disk, 92 GB on one dev box — so the automatic cleanup that's supposed to kick in at 90% never triggered and stale container images piled up unnoticed. Reserved space now counts as used, which is what the threshold was always meant to measure."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.4-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago",
|
||||
"current_version": "1.8.5-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.4-alpha",
|
||||
"sha256": "c4d3a4fdecfdc2a972f808c7f98225b29dc65333418038bb016a5f0bd79d3551",
|
||||
"size_bytes": 63850680
|
||||
"new_version": "1.8.5-alpha",
|
||||
"sha256": "54e91944c6395a53c8ac87ea97f61e8a7fc5ffd133f017180931ba4fa9566239",
|
||||
"size_bytes": 63934504
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.4-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago-frontend-1.8.4-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.4-alpha.tar.gz",
|
||||
"new_version": "1.8.4-alpha",
|
||||
"sha256": "790de85816a7ad49480dc99134022279e4f40b69bd9b0983a6393373a3bdd7cc",
|
||||
"size_bytes": 97641658
|
||||
"current_version": "1.8.5-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago-frontend-1.8.5-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.5-alpha.tar.gz",
|
||||
"new_version": "1.8.5-alpha",
|
||||
"sha256": "a5d773e8225bfd8a34dfc74acf38bdc2c39bc138c62bf9f4137d0efd72cce3e9",
|
||||
"size_bytes": 97657151
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-20",
|
||||
"signature": "94ffb717166c6062ddbea97476942285903d629543bf3b9ec435697a1b8c31243dedb0f3c8f874daf3d9c0974a6342bf42b7e9d34b00680aa63a1d441dbc4805",
|
||||
"release_date": "2026-08-31",
|
||||
"signature": "f0bcec4935588ee428ebabfbb6e08e00dbd42202af92763073a7f98f4785925ca3565bf96fecbdd1695eff77fcf906783fd9053d82730b11c75441c6f5a60b05",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.4-alpha"
|
||||
"version": "1.8.5-alpha"
|
||||
}
|
||||
|
||||
+123
-13
@@ -505,6 +505,10 @@
|
||||
"network_policy": "bridge",
|
||||
"readonly_root": true
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "gitlab",
|
||||
"repo": "ark-bitcoin/bark"
|
||||
},
|
||||
"version": "0.3.0",
|
||||
"volumes": [
|
||||
{
|
||||
@@ -978,13 +982,13 @@
|
||||
"version": "1.2.11"
|
||||
},
|
||||
"btcpay": {
|
||||
"image": "docker.io/btcpayserver/btcpayserver:2.4.2",
|
||||
"image": "docker.io/btcpayserver/btcpayserver:2.4.3",
|
||||
"images": {
|
||||
"archy-btcpay-db": "source.archipelago-foundation.org/lfg2025/postgres:15.17",
|
||||
"archy-nbxplorer": "source.archipelago-foundation.org/lfg2025/nbxplorer:2.6.0",
|
||||
"btcpay-server": "docker.io/btcpayserver/btcpayserver:2.4.2"
|
||||
"btcpay-server": "docker.io/btcpayserver/btcpayserver:2.4.3"
|
||||
},
|
||||
"version": "2.4.2"
|
||||
"version": "2.4.3"
|
||||
},
|
||||
"btcpay-server": {
|
||||
"manifest": {
|
||||
@@ -1000,7 +1004,7 @@
|
||||
"template": "{{HOST_IP}}:23000"
|
||||
}
|
||||
],
|
||||
"image": "docker.io/btcpayserver/btcpayserver:2.4.2",
|
||||
"image": "docker.io/btcpayserver/btcpayserver:2.4.3",
|
||||
"network": "archy-net",
|
||||
"pull_policy": "if-not-present",
|
||||
"secret_env": [
|
||||
@@ -1097,7 +1101,7 @@
|
||||
"kind": "github",
|
||||
"repo": "btcpayserver/btcpayserver"
|
||||
},
|
||||
"version": "2.4.2",
|
||||
"version": "2.4.3",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
@@ -1110,7 +1114,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "2.4.2"
|
||||
"version": "2.4.3"
|
||||
},
|
||||
"core-lightning": {
|
||||
"manifest": {
|
||||
@@ -1205,6 +1209,96 @@
|
||||
"image": "source.archipelago-foundation.org/lfg2025/cryptpad:2024.12.0",
|
||||
"version": "2024.12.0"
|
||||
},
|
||||
"cuprate": {
|
||||
"manifest": {
|
||||
"app": {
|
||||
"category": "money",
|
||||
"container": {
|
||||
"custom_args": [
|
||||
"--config-file",
|
||||
"/home/cuprate/Cuprated.toml"
|
||||
],
|
||||
"data_uid": "1000:1000",
|
||||
"image": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
|
||||
"network": "archy-net",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "300Gi"
|
||||
}
|
||||
],
|
||||
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
|
||||
"files": [
|
||||
{
|
||||
"content": "network = \"Mainnet\"\ntarget_max_memory = 3000000000\n\n[rpc.restricted]\nenable = true\n",
|
||||
"overwrite": false,
|
||||
"path": "/var/lib/archipelago/cuprate/Cuprated.toml"
|
||||
}
|
||||
],
|
||||
"health_check": {
|
||||
"endpoint": "localhost:18090",
|
||||
"interval": "30s",
|
||||
"retries": 3,
|
||||
"start_period": "5m",
|
||||
"timeout": "5s",
|
||||
"type": "tcp"
|
||||
},
|
||||
"id": "cuprate",
|
||||
"metadata": {
|
||||
"author": "Cuprate",
|
||||
"category": "money",
|
||||
"icon": "/assets/img/app-icons/cuprate.svg",
|
||||
"repo": "https://github.com/Cuprate/cuprate",
|
||||
"tier": "optional"
|
||||
},
|
||||
"name": "Cuprate",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Monero p2p gossip. Peers are anonymous by design and speak the Monero wire protocol, not HTTP.",
|
||||
"container": 18080,
|
||||
"host": 18183,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.",
|
||||
"container": 18089,
|
||||
"host": 18090,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_limit": 0,
|
||||
"disk_limit": "300Gi",
|
||||
"memory_limit": "4Gi"
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [],
|
||||
"network_policy": "isolated",
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": true
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "github",
|
||||
"repo": "Cuprate/cuprate"
|
||||
},
|
||||
"version": "0.1.0-preview",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/var/lib/archipelago/cuprate",
|
||||
"target": "/home/cuprate",
|
||||
"type": "bind"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "0.1.0-preview"
|
||||
},
|
||||
"did-wallet": {
|
||||
"manifest": {
|
||||
"app": {
|
||||
@@ -2375,6 +2469,10 @@
|
||||
"network_policy": "isolated",
|
||||
"readonly_root": false
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "ghcr",
|
||||
"repo": "immich-app/postgres"
|
||||
},
|
||||
"version": "14-vectorchord0.4.3-pgvectors0.2.0",
|
||||
"volumes": [
|
||||
{
|
||||
@@ -2788,6 +2886,10 @@
|
||||
"network_policy": "isolated",
|
||||
"readonly_root": false
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "github",
|
||||
"repo": "minio/minio"
|
||||
},
|
||||
"version": "RELEASE.2024-11-07T00-52-20Z",
|
||||
"volumes": [
|
||||
{
|
||||
@@ -3175,6 +3277,10 @@
|
||||
"seccomp_profile": "default",
|
||||
"user": 1000
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "manual",
|
||||
"url": "no public listing for lightninglabs/lightning-stack — verify by hand"
|
||||
},
|
||||
"version": "0.12.0",
|
||||
"volumes": [
|
||||
{
|
||||
@@ -3625,7 +3731,7 @@
|
||||
"key": "/var/lib/archipelago/netbird/tls.key"
|
||||
}
|
||||
],
|
||||
"image": "docker.io/library/nginx:1.31.3-alpine",
|
||||
"image": "docker.io/library/nginx:1.31.4-alpine",
|
||||
"network": "netbird-net",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
@@ -4315,7 +4421,7 @@
|
||||
"key": "/var/lib/archipelago/pine/tls.key"
|
||||
}
|
||||
],
|
||||
"image": "docker.io/library/nginx:1.31.3-alpine",
|
||||
"image": "docker.io/library/nginx:1.31.4-alpine",
|
||||
"network": "archy-net",
|
||||
"network_aliases": [
|
||||
"pine"
|
||||
@@ -4701,6 +4807,10 @@
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": false
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "dockerhub",
|
||||
"repo": "rhasspy/wyoming-whisper"
|
||||
},
|
||||
"version": "3.4.2",
|
||||
"volumes": [
|
||||
{
|
||||
@@ -4998,7 +5108,7 @@
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
"image": "dockurr/strfry:1.1.1",
|
||||
"image": "dockurr/strfry:1.1.2",
|
||||
"image_signature": "cosign://...",
|
||||
"pull_policy": "verify-signature"
|
||||
},
|
||||
@@ -5055,7 +5165,7 @@
|
||||
"kind": "github",
|
||||
"repo": "hoytech/strfry"
|
||||
},
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
@@ -5076,7 +5186,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "1.1.1"
|
||||
"version": "1.1.2"
|
||||
},
|
||||
"tailscale": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/tailscale:stable",
|
||||
@@ -5256,7 +5366,7 @@
|
||||
}
|
||||
},
|
||||
"schema": 1,
|
||||
"signature": "97628de24e3ffa17f639c663e19881cf6dea8c79aab272fe9c5442a4e951b3f0d257fee21aa9ce6158e3824b56a337acb71805f6fd34245e48345b86b46ec007",
|
||||
"signature": "da5b6b183ac46c062945c27abdc06affb558e805e1ccf67ac0ee17e5e3dd85cc05a0656dd83bdacb1e1d237445145d00995f55e77209e1cbb2b6d8ce47084e0a",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"updated": "2026-08-19"
|
||||
"updated": "2026-08-30"
|
||||
}
|
||||
|
||||
+19
-21
@@ -1,33 +1,31 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the \"app is restarting\" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.",
|
||||
"**The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed.",
|
||||
"**While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input.",
|
||||
"**\"Are you sure?\" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote.",
|
||||
"**A mesh radio now connects no matter which port it's plugged into — or replugged into.** Moving a radio to a different USB port could leave the mesh silently down: the node only checked a short fixed list of port names (a radio landing outside it was invisible), a hand-set serial-port override quietly outranked the device you'd just approved in the \"Radio detected\" window, and one whole family of boards (Espressif-based radios like recent Heltec/T-Deck models) never received a stable device name at all — the exact combination found live on a fleet machine this week. All three are fixed: every serial port is scanned, choosing a radio in the detection window clears any stale override, and Espressif boards get the same stable name as everyone else.",
|
||||
"**Mesh signal strength is honest now.** Every peer heard over Reticulum radio reported a signal strength of exactly 0 — which is also what you'd see with no radio at all, and what peers reached over the internet showed. Real receptions now show their true signal reading, and anything that arrived over a relay or the internet says so by showing none — so \"the radio is working\" and \"the internet is doing the radio's job\" no longer look identical. (The reading depends on the radio's firmware reporting it; boards that don't report per-packet signal stats show \"unknown\" rather than a made-up number, and the new radio diagnostics show at a glance whether yours reports them.)",
|
||||
"**A background error that repeated every 90 seconds, forever, is gone.** After setting up a node from its recovery phrase, the node kept introducing itself to its federation partners with its old temporary identity papers while signing with its new ones — every partner rejected the introduction, and both sides logged an error about it every minute and a half until the next restart. The identity switch now updates everything at once, a rejected introduction is no longer misreported as delivered, and a partner who has already answered is no longer re-asked on every cycle."
|
||||
"**Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.",
|
||||
"**A frozen node now explains itself — and comes back on its own.** The host now captures a memory dump into /var/crash when the kernel panics *or* wedges (a hung kiosk used to sit dead until someone power-cycled it; now it dumps, reboots itself, and leaves the evidence behind), and records failing-memory signals (ECC errors) into a database as they happen. This is the first change delivered by a new host-update channel: the node's own updater now carries OS-level packages and settings to already-deployed machines — the crash-kernel's memory reservation is the one part that waits for a reboot, and the node says so rather than pretending.",
|
||||
"**Uninstalling an app can no longer report success when it failed.** The declarative path used to swallow every teardown error and report the app uninstalled, leaving the tile behind and the truth in the logs. A failed uninstall now stops and shows the real per-app errors, so \"still there\" is never presented as \"gone\".",
|
||||
"**Pictures to internet-only mesh contacts work now.** Sending an attachment inline always took the radio path and failed with \"Peer is federation-only (no radio twin)\" for contacts reachable only over the internet — and the size-adviser kept recommending a radio transfer those peers can't receive. Both fixed: inline sends route over the federation when that's the only way to reach the peer, and the advice no longer offers radio-only transfers to radio-unreachable contacts.",
|
||||
"**Disk cleanup finally has honest numbers.** Space \"free\" on a drive was counted including the slice the filesystem keeps reserved for root — roughly 5% of the disk, 92 GB on one dev box — so the automatic cleanup that's supposed to kick in at 90% never triggered and stale container images piled up unnoticed. Reserved space now counts as used, which is what the threshold was always meant to measure."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.4-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago",
|
||||
"current_version": "1.8.5-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.4-alpha",
|
||||
"sha256": "c4d3a4fdecfdc2a972f808c7f98225b29dc65333418038bb016a5f0bd79d3551",
|
||||
"size_bytes": 63850680
|
||||
"new_version": "1.8.5-alpha",
|
||||
"sha256": "54e91944c6395a53c8ac87ea97f61e8a7fc5ffd133f017180931ba4fa9566239",
|
||||
"size_bytes": 63934504
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.4-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago-frontend-1.8.4-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.4-alpha.tar.gz",
|
||||
"new_version": "1.8.4-alpha",
|
||||
"sha256": "790de85816a7ad49480dc99134022279e4f40b69bd9b0983a6393373a3bdd7cc",
|
||||
"size_bytes": 97641658
|
||||
"current_version": "1.8.5-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago-frontend-1.8.5-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.5-alpha.tar.gz",
|
||||
"new_version": "1.8.5-alpha",
|
||||
"sha256": "a5d773e8225bfd8a34dfc74acf38bdc2c39bc138c62bf9f4137d0efd72cce3e9",
|
||||
"size_bytes": 97657151
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-20",
|
||||
"signature": "94ffb717166c6062ddbea97476942285903d629543bf3b9ec435697a1b8c31243dedb0f3c8f874daf3d9c0974a6342bf42b7e9d34b00680aa63a1d441dbc4805",
|
||||
"release_date": "2026-08-31",
|
||||
"signature": "f0bcec4935588ee428ebabfbb6e08e00dbd42202af92763073a7f98f4785925ca3565bf96fecbdd1695eff77fcf906783fd9053d82730b11c75441c6f5a60b05",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.4-alpha"
|
||||
"version": "1.8.5-alpha"
|
||||
}
|
||||
|
||||
@@ -84,6 +84,60 @@ else
|
||||
warn "unmkinitramfs not installed — skipping initrd live-boot check"
|
||||
fi
|
||||
|
||||
# ── Host crash-capture payload (#144) ──────────────────────────────
|
||||
# Check the built artifact, not just the ISO builder source: otherwise a stale
|
||||
# rootfs.tar can silently omit the packages/configuration while the build passes.
|
||||
ROOTFS="$MNT/archipelago/rootfs.tar"
|
||||
if [ -f "$ROOTFS" ]; then
|
||||
DPKG_STATUS="$(sudo tar -xOf "$ROOTFS" var/lib/dpkg/status 2>/dev/null || true)"
|
||||
for package in kdump-tools kexec-tools makedumpfile rasdaemon; do
|
||||
if awk -v wanted="$package" 'BEGIN { RS=""; FS="\n" }
|
||||
$0 ~ "(^|\\n)Package: " wanted "(\\n|$)" &&
|
||||
$0 ~ "(^|\\n)Status: install ok installed(\\n|$)" { found=1 }
|
||||
END { exit !found }' <<<"$DPKG_STATUS"; then
|
||||
ok "rootfs package installed: $package"
|
||||
else
|
||||
bad "rootfs package missing/not installed: $package"
|
||||
fi
|
||||
done
|
||||
|
||||
KDUMP_DEFAULTS="$(sudo tar -xOf "$ROOTFS" etc/default/kdump-tools 2>/dev/null || true)"
|
||||
if grep -qE '^USE_KDUMP=.?1' <<<"$KDUMP_DEFAULTS"; then
|
||||
ok "rootfs enables kdump"
|
||||
else
|
||||
bad "rootfs /etc/default/kdump-tools does not enable kdump"
|
||||
fi
|
||||
if grep -qE '^KDUMP_COREDIR=.?/var/crash' <<<"$KDUMP_DEFAULTS"; then
|
||||
ok "rootfs sends crash dumps to /var/crash"
|
||||
else
|
||||
bad "rootfs kdump target is not /var/crash"
|
||||
fi
|
||||
|
||||
KDUMP_SYSCTL="$(sudo tar -xOf "$ROOTFS" etc/sysctl.d/99-archipelago-kdump.conf 2>/dev/null || true)"
|
||||
for setting in 'kernel.panic = 10' 'kernel.panic_on_oops = 1' \
|
||||
'kernel.hung_task_panic = 1' 'kernel.hardlockup_panic = 1'; do
|
||||
if grep -Fqx "$setting" <<<"$KDUMP_SYSCTL"; then
|
||||
ok "rootfs sysctl: $setting"
|
||||
else
|
||||
bad "rootfs missing sysctl: $setting"
|
||||
fi
|
||||
done
|
||||
else
|
||||
bad "cannot inspect crash-capture payload: missing archipelago/rootfs.tar"
|
||||
fi
|
||||
|
||||
AUTO_INSTALL="$MNT/archipelago/auto-install.sh"
|
||||
if grep -qF 'crashkernel=256M' "$AUTO_INSTALL" 2>/dev/null; then
|
||||
ok "installer writes crashkernel=256M"
|
||||
else
|
||||
bad "installer does not write crashkernel=256M"
|
||||
fi
|
||||
if grep -qF 'Archipelago owns crashkernel sizing in /etc/default/grub' "$AUTO_INSTALL" 2>/dev/null; then
|
||||
ok "installer neutralizes the conflicting Debian kdump GRUB default"
|
||||
else
|
||||
bad "installer does not neutralize the conflicting Debian kdump GRUB default"
|
||||
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)
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
# C. FM-guards — the concrete failure modes that have bitten the
|
||||
# fleet: port-drift (FM8), secret-completeness (FM2),
|
||||
# orphaned container states (FM9), OTA wedge (FM12)
|
||||
# D. Host capture (#144) — kdump + rasdaemon baseline: crash dumps configured
|
||||
# and reserved, hang policy live, ECC recording running
|
||||
#
|
||||
# Everything here is READ-ONLY: no install/stop/start/uninstall, no service bounce.
|
||||
# Safe to run against a live production node. It is the per-boot building block the
|
||||
@@ -226,6 +228,50 @@ section_c() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ══ Section D — host capture (#144): kdump + rasdaemon ═══════════════════════
|
||||
section_d() {
|
||||
echo
|
||||
echo "== D. Host capture — crash + hardware-error evidence (#144) =="
|
||||
if [[ "$ARCHY_LOCAL" != "1" ]]; then
|
||||
record WARN "kdump + rasdaemon baseline" "remote node — host checks skipped"
|
||||
return
|
||||
fi
|
||||
# D1. kdump enabled in config (image bakes it in; OTA host fixups converge)
|
||||
if grep -qE '^USE_KDUMP=.?1' /etc/default/kdump-tools 2>/dev/null; then
|
||||
record PASS "kdump-tools configured" "USE_KDUMP=1, dumps to /var/crash"
|
||||
else
|
||||
record FAIL "kdump-tools configured" "/etc/default/kdump-tools missing USE_KDUMP=1 — host fixup didn't land"
|
||||
fi
|
||||
# D2. crashkernel reservation — grade the memory the kernel actually
|
||||
# reserved, not merely the first matching cmdline token. Debian's
|
||||
# kdump-tools.cfg used to append a second crashkernel= range after our 256M;
|
||||
# the audit falsely passed while /sys reported only 192M reserved.
|
||||
local crash_size expected_size=$((256 * 1024 * 1024))
|
||||
crash_size=$(cat /sys/kernel/kexec_crash_size 2>/dev/null || echo 0)
|
||||
[[ "$crash_size" =~ ^[0-9]+$ ]] || crash_size=0
|
||||
if (( crash_size >= expected_size )); then
|
||||
record PASS "crashkernel reserved" "$((crash_size / 1024 / 1024))MiB actually reserved"
|
||||
elif (( crash_size > 0 )); then
|
||||
record FAIL "crashkernel reserved" "$((crash_size / 1024 / 1024))MiB reserved; expected >=256MiB (conflicting cmdline?)"
|
||||
elif grep -q 'crashkernel=256M' /etc/default/grub 2>/dev/null; then
|
||||
record WARN "crashkernel reserved" "256M written to GRUB — applies on next reboot"
|
||||
else
|
||||
record FAIL "crashkernel reserved" "no reservation and crashkernel=256M absent from GRUB"
|
||||
fi
|
||||
# D3. hang/panic capture policy — runtime-settable, expected immediately
|
||||
if [[ "$(cat /proc/sys/kernel/hung_task_panic 2>/dev/null)" == "1" ]]; then
|
||||
record PASS "hang-capture policy live" "kernel.hung_task_panic=1"
|
||||
else
|
||||
record FAIL "hang-capture policy live" "kernel.hung_task_panic!=1 — sysctl drop-in not applied"
|
||||
fi
|
||||
# D4. rasdaemon recording hardware errors (ECC/AER events → sqlite)
|
||||
if systemctl is-active --quiet rasdaemon 2>/dev/null; then
|
||||
record PASS "rasdaemon active" "hardware-error events recorded to /var/lib/rasdaemon"
|
||||
else
|
||||
record FAIL "rasdaemon active" "service not running — package missing or host fixup failed"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── run ────────────────────────────────────────────────────────────────────────
|
||||
echo "=============================================================="
|
||||
echo " OS-wide audit — ${BASE_URL} ($(date '+%Y-%m-%d %H:%M:%S'))"
|
||||
@@ -237,6 +283,9 @@ if (( FAIL == 0 )) || [[ -n "$SESSION" ]]; then
|
||||
section_b
|
||||
section_c
|
||||
fi
|
||||
# Host-capture baseline is independent of RPC health: a wedged backend must
|
||||
# not mask that the node also stopped capturing evidence.
|
||||
section_d
|
||||
|
||||
echo
|
||||
echo "=============================================================="
|
||||
|
||||
Reference in New Issue
Block a user