A Minibits token could not be redeemed on framework-pt: the mint answered
POST /v1/swap with a bare 422, which the RPC sanitizer turned into
"Operation failed. Check server logs for details." The journal had the
real reason:
inputs[0].id: NUT02: ID length invalid, expected 8 bytes (short/v1)
or 33 bytes (v2)
The token carried keyset id 01fc0ec0e59cd6fa — exactly the first 8 bytes
of the mint's active 33-byte id 01fc0ec0e59cd6fa01b7a88f…a821. NUT-02 v2
ids are 33 bytes behind a 0x01 version byte; the sending wallet cut it to
the 8 bytes that were the whole id under v1. The mint reads the version,
expects 33 bytes, and rejects it — so the length complaint is right even
though 8 bytes is legal for a 0x00-prefixed v1 id.
The id only names which keyset signed a proof, and the short form is a
prefix of the full one, so it can be repaired: before swapping, any
8-byte 0x01-prefixed id is expanded against GET /v1/keysets (new
MintClient::get_keysets — it lists inactive keysets too, and coins from a
retired keyset stay spendable). Preferring the active keyset on a prefix
tie. Attempting this is safe: an id naming the wrong keyset fails
signature verification at the mint and no coins move. Anything already
valid, or with no unambiguous match, is passed through so the mint's own
error still reaches the operator.
Token decoding now also checks keyset ids locally, so an id that is not
hex or is neither NUT-02 length fails with a message naming the format
instead of a raw 422 from the mint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A ghost is a container whose process tree is still running while podman
has no record of it: the exit-command's `cleanup --rm` deletes the record,
conmon and the payload survive. It keeps owning exactly what the app needs
— the published host port and the file locks in its data dir — so the
replacement container either fails to bind ("address already in use") or
starts and dies on the lock, and Restart=always loops it there forever.
Nothing in the stack could see it: every podman-level stop/rm/recreate
misses a container podman lost.
Seen twice now: 752 restarts on a fleet node (2026-08-10) and again on the
dev box today, where Gitea flapped until it fell out of My Apps. Both were
cleared by hand; container-doctor.sh has the same logic but is an
out-of-band script the daemon never calls.
- New container::ghost_reaper: finds conmon processes whose 64-hex
container id is absent from `podman ps -a --no-trunc -q`, then kills the
payload's children and conmon (TERM, 5s grace, then KILL — the Gitea
ghost ignored TERM). Id-based, never name-based: killing by name would
hit the live managed container. A failed `podman ps` reaps nothing
rather than treating every container as a ghost.
- Hooked at repair_before_package_start (covers package.start,
package.restart and the orchestrator start path) and in the boot
reconciler's 30s tick, so ghosts are cleared before an app is asked to
start and swept for every app continuously.
Restart feedback: the lifecycle RPCs return {"status":"restarting"} in
milliseconds and work in the background, so "Restarting..." flashed for a
few frames and the buttons went idle while the app was still down — the
click read as a no-op. The hero buttons now show a spinner and hold it off
the node's own state (starting/stopping/restarting/updating, plus running
+ health=starting), and the just-clicked action is held until the backend
confirms it picked the work up, with a 12s cap so an unresponsive node
still releases the controls.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Some apps carry a complete account system and are broken by an upstream
challenge: git clients speak basic-auth (not browser cookies), and a
BTCPay checkout link handed to a customer must open for that customer.
Both were behind the gate's login page — the "non-browser clients need an
access token" gap disclosed in five consecutive releases.
- New manifest port policy `auth: open`: the daemon still fronts the port
exactly like `gated` (loopback pin, external binds, frame-header fixes,
app-down retry page, Tor upstream) but serves it without the login
challenge. Requires auth_rationale, same burden of proof as `none`.
Gitea 3001 and BTCPay 23000 declare it.
- Runtime operator override per app (security.set-app-gate → app-configs/
<id>.json "gateEnabled"), surfaced as Settings → app → Access control.
Wins over the manifest in both directions and applies on the next
request — no restart, and it works today on catalog-covered apps whose
signed manifest still says `gated`.
- The gate resolves policy per-request from the live port map, so a
toggle takes effect without waiting for the 60s rebind sweep. "Off"
never releases the port: gated apps are loopback-pinned, so releasing
would strand them, not open them.
- security.app-gate-status now reports gate_enabled + any override.
- New guard test pins the `auth: open` set (both entries reviewed); the
`auth: none` count moves 25 → 26, absorbing pre-existing drift from the
phoenixd onboarding (loopback JSON API with its own generated password).
- Docs: the manifest spec's ports row documented only host/container/
protocol — bind, auth, auth_rationale and session_passthrough were
undocumented. Added a full "Ports & the app gate" section plus a
developer-guide entry telling app authors to enforce their own auth
regardless, since the operator can flip the gate either way.
Verified live on archi-dev-box from an external address: gated → 401 gate
page; override off → Gitea 200 own page, BTCPay 302 to its own login,
git-over-HTTP info/refs 200; override on → 401 again; clear → default.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Companion tap/scroll/type now works INSIDE cross-origin app iframes and
kiosk tabs. The web relay synthesizes untrusted DOM events in the top
document, which can never cross an origin boundary — so apps served
through the appgate were dead to the remote. The kiosk Chromium now
exposes a loopback-only CDP port (default origin check intact, no
--remote-allow-origins) and a backend bridge (api/handler/cdp.rs)
dispatches validated companion input as Input.dispatchKeyEvent /
dispatchMouseEvent / mouseWheel — trusted events that hit-test through
any frame, move real focus, and insert text like a physical device.
- Session keeper self-heals across kiosk Chromium restarts; inert on
nodes without a kiosk unit (falls back to the existing relay path).
- The kiosk relay subscriber self-tags (?kiosk=1) and the backend mutes
its key/click/scroll messages while the bridge is live, so input never
applies twice; cursor moves still flow for the on-screen cursor.
- While companion input is active the native OS pointer is hidden
(cursor:none, auto-restores 30s after the last event) so the dead
physical-mouse cursor doesn't sit next to the virtual one.
- docs/tv-input-iframe-apps.md scope note updated: gamepad keys stay on
uinput; CDP is for companion pointer/typing only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every Reticulum-heard peer surfaced as rssi=0 — indistinguishable from a
real 0 dBm reading and, worse, from "heard over the TCP bridge with no
radio involved at all", which made a TCP-fed mesh look like working RF
during the 2026-08-16 radio diagnosis.
- Sidecar: announce handler now uses the 4-arg RNS dispatch to get the
announce packet hash and reports per-announce rssi/snr from Reticulum's
packet-stat cache; LXMF deliveries report message.rssi/snr/q (LXMF
already populates them on direct RNode hops). All None over TCP or
multi-hop — the honest RF-vs-internet discriminator.
- Rust: ReticulumPeer caches last_rssi/last_snr from announce and recv
events (a TCP-relayed announce never blanks a real RF reading), and
get_contacts surfaces them so refresh_contacts propagates real values.
- Identity discovery no longer hardcodes rssi 0: unknown is now None
end-to-end and logged as such.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause observed live 2026-08-16: onboarding/seed-restore rewrite
identity/node_key on disk but server_info.pubkey is only seeded at boot,
so until the next restart every peer-joined advertised the stale boot key
while signing with the new seed-derived key — deterministically rejected
by every receiver, once per 90s heal tick, forever.
- seed.generate / seed.restore now refresh server_info.pubkey in the live
snapshot immediately (mirrors the DID-rotation handler).
- The 90s heal loop advertises the SAME key it signs with (disk identity,
like federation sync already did) instead of the boot snapshot.
- notify_join no longer logs "delivered" for an HTTP-200 JSON-RPC
rejection; in-band errors are terminal (identical signed bytes can
never succeed on retry).
- The heal loop backs off per peer (doubling toward a daily re-assert)
instead of re-notifying every 90s forever — Observer-held peers never
appear in Trusted-only exported hints, so they_list_us could never
become true for them.
- Receiver now binds the DID to the advertised pubkey (the old check was
self-referential) and logs malformed signatures distinctly from
genuine mismatches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three root causes from the 2026-08-16 framework-pt incident where a
replugged radio detected but never connected:
- detect_serial_devices scanned a hardcoded ttyUSB0-2/ttyACM0-2 list, so
a radio enumerating at index 3+ was permanently invisible. Now scans
/dev for all ttyUSB*/ttyACM* nodes (deterministic order, /dev/mesh-radio
alias still first and still wins the dedup).
- An operator rnode-rf-settings.json port override silently outranked the
device_path the user just chose in the detection modal. mesh.configure
now clears a stale override when a different device is configured
(symlink-resolved compare keeps /dev/mesh-radio aliases intact).
- Espressif native-USB boards (303a, ESP32-S2/S3/C3 RNodes) had no udev
rule, so they never got the stable /dev/mesh-radio alias and a persisted
alias path dangled after a port move.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
setup-node-ca.sh writes one 'listen <addr>:443 ssl;' per LAN address at
the moment it runs (per-address on purpose — Tailscale holds :443 on the
tailnet address) and its idempotency guard never revisits them. nginx
REFUSES TO START while any listen address is missing, so this takes the
entire dashboard down, not just HTTPS:
1. the node moves networks and the old address is gone; or
2. nginx starts before DHCP assigns the address — and nginx.service
ships no Restart=, making that single race permanent.
Both hit archi-dev-box today: nginx dead since boot on 'bind() to
192.168.63.240:443 failed (99: Cannot assign requested address)', the
dashboard simply unreachable, which is exactly the symptom a user with
no screen cannot diagnose.
run_nginx_listener_repair drops listeners for absent addresses, adds one
per present address (CGNAT excluded), installs behind with
rollback, then starts nginx if it is down and gives it a
Restart=on-failure drop-in so the boot race stops being fatal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A codebase sweep for siblings of e282c059 (blocking network I/O parked
on the tokio runtime) found the openwrt fix was incomplete:
- openwrt.scan: scan_subnet is async in name only — up to 255 SEQUENTIAL
blocking TCP probes at 500ms each (~2 min on a /24 that silently
drops) plus a blocking SSH verify per candidate. One click of 'scan
for routers' held a worker for that whole time. Now spawn_blocking.
- provision-tollgate / scan-wifi / configure-wan still ran their SSH
exchanges inline; bounded_tcp caps each socket op but a session is
many sequential ops (provision runs opkg install over SSH), so worst
case was minutes. All three now spawn_blocking.
- network::check_dns: blocking glibc to_socket_addrs with no app-level
bound, on every Server-tab load via network.diagnostics. Against a
stale resolver — the moved-network case — that is 5-40s per refresh.
Now spawn_blocking plus a 5s cap, so the tile reports 'no DNS'
instead of hanging.
Verified false positives left alone: every other bare TcpStream::connect
targets 127.0.0.1 (fails instantly), and every remote reqwest client
already sets a timeout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
check_upnp_available uses a blocking std UdpSocket and, on a network
with no UPnP gateway (the normal case right after a node moves), runs
out its full 3s read timeout. Inline on the runtime that blocked a
worker on every call, from four call sites. Same class as the OpenWrt
SSH stall (e282c059), smaller blast radius — move it to spawn_blocking.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Router::connect/connect_password did a blocking std TcpStream::connect
with no timeout, inline on the tokio runtime. Against a router that
stayed behind when its node moved networks (framework-pt, 2026-08-15),
every dashboard poll of openwrt.get-status parked a worker thread for
the OS connect timeout (~2 min) — overlapping polls stalled unrelated
RPCs for 25s+ at a time, sessions timed out, and TOTP codes expired
before the backend verified them.
- bounded_tcp(): 5s connect timeout + 30s read/write timeouts on the
session socket, shared by both connect paths.
- openwrt.get-status runs its SSH exchange on spawn_blocking, so even a
slow router can only slow its own tile, never the API.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Small-disk nodes (<1000 GB data volume) keep the same dynamic
prune-vs-archival logic but now retain ~50 GB of recent blocks instead
of the bare 550 MB minimum. Takes effect for catalog-covered installs
at the next catalog regeneration + signing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both failure modes are from framework-pt relocating (2026-08-15):
- archy-ha-btc-rpc-proxy bound socat to the LAN IP baked in at unit
generation; after a move the address no longer exists and the unit
restart-looped forever (counter 2446). run_ha_rpc_proxy_bind_repair
rewrites ExecStart to compute the bind address at each start, so
Restart=always itself heals any future move.
- homeassistant's quadlet pointed at the domain image ref with --pull
never while local storage held the same name:tag under the bare-IP
registry ref (catalog signing rename) — 761 restarts on 'image not
known'. run_pull_never_image_repair retags a matching local image;
it deliberately never pulls.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The welcome banner picked its address with 'hostname -I | awk {print $1}',
so a node with WireGuard up advertised 10.44.0.1 — its own tunnel address,
present on EVERY node — as its web ui / ssh address. Off-tunnel that is
unreachable, and after a headless box moves to a new network it is exactly
the wrong thing to trust (framework-pt, 2026-08-15).
- Pick the default route's source address; fall back to the first address
that is not WireGuard 10.44/16, CGNAT 100.64/10, or loopback.
- Also print http://<hostname>.local when avahi is up — the one address
that survives any DHCP change, which is the real answer for headless
boxes that move between networks.
- scripts/welcome-banner.sh is the new canonical copy, embedded in the
binary (tor-helper pattern): bootstrap::run_welcome_banner_sync rewrites
/etc/profile.d/archipelago.sh on ISO-installed nodes at startup, so the
fix reaches the deployed fleet with the next OTA instead of only fresh
ISOs. Machines without an installer-baked banner are left untouched.
- Same fix inlined in the live ISO builder's PROFILE heredoc
(image-recipe/_archived/build-auto-installer-iso.sh).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The badge is now the dashboard login's AnimatedLogo, square for square:
inline SVG (20 white rects, 100ms stagger, 3s loop) inside the same
gradient ring. The old <img> of favico-black-v2.svg baked a second ring
into the ring and couldn't animate; the asset leaves the gate allowlist
since nothing references it now.
- The submit button is .glass-button longhand: hover lift + lightening +
rim glow, active press, disabled dim — the flat darken-only hover read
as broken next to /login.
- Loading state: submitting flips the button to spinner + 'Signing in…'/
'Verifying…' and disables it, via a single inline script admitted by
CSP sha256 hash (not unsafe-inline; injected markup stays inert, and
the page still works as a plain POST without JS).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The D-04 Routstr leg was fallback-only — never user-selectable, and its
Nostr discovery parses a docs-shaped event content ({endpoints, models,
pricing}) that live kind-38421 announcements don't actually carry
({name, about}), so it could never match a real provider. This adds the
explicit path AIUI's model picker needs: /aiui/api/routstr/models
passes through the live aggregator catalog (the instance routstr.com's
own frontend queries; the canonical api.routstr.com 404s), and
/aiui/api/routstr/chat/completions makes one paid, non-streaming,
OpenAI-shaped call — session-gated, egress-screened (S3), refused
without an armed operator budget (D-05), paid via auto_pay_token,
change and refused-request tokens redeemed back into the wallet so a
failed attempt nets zero (verified live: quoted=1 reclaimed=1 net=0).
nginx template gains the location in both server blocks (T-13-15).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- lnd.createinvoice now returns r_hash_hex; new lnd.invoicestatus RPC
looks the invoice up (SETTLED + amt_paid_sat). E2E-verified on this
box: real invoice minted, status polls settled:false until paid.
- Receive modal: Lightning polls settlement every 3s and flips to the
on-chain-style success view — straight to the green check + amount
(no broadcast step; settlement is final). Raw bolt11 text removed:
QR + CopyButton only. State fully reset per open/close.
- CopyButton is now the wallet's only copy affordance: the ark-address
and ecash-token holdouts swapped in, their ad-hoc handlers deleted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WebUI RULE (operator, 2026-08-14): never point users at a terminal. The
certificate section told users to run setup-node-ca.sh by hand — it now
has a Generate button backed by system.node-ca.generate, which runs the
idempotent script server-side (live-tested: generated and /ca.crt serves).
Routstr budget panel moves directly under the Claude API key card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The animated federation map froze the framework-pt 4K TV: the launcher
held every machine to the HD 5500-era choppy-audio flags (single raster
thread, GpuRasterization banned) while the map wrote SVG attrs at 60fps.
- Launcher: two flag tiers. legacy = the proven conservative set; modern
(Intel gen8+, 'NNth Gen' models, AMD Ryzen) = default raster threads +
GPU rasterization. Classified from /proc/cpuinfo (11 model strings
covered by tests in-session); KIOSK_GRAPHICS=performance|quality in
kiosk-display.conf overrides; headless unchanged. Reaches deployed
kiosks via the include_str! self-heal, same as the vsync fix.
- system.kiosk-display.get/set: carries a 'graphics' field alongside
'preset'; setting one no longer clobbers the other.
- Settings → Display: Graphics picker (Auto / Compatibility / Quality).
- NetworkMap3D: kiosks default to the 2D projection (remembered toggle
still works) and tick at half rate with carried-over deltas — same
spin speed, half the paint cost.
- Changelog: curated Unreleased notes for all of the above + the gate
frame-embedding fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Apps that ship X-Frame-Options (Alby Hub: DENY) or a CSP frame-ancestors
directive rendered as a dead grey pane in the dashboard's embedded app
session; the historical fix was a bespoke per-app nginx strip proxy
(gitea). The gate now removes X-Frame-Options and strips ONLY the
frame-ancestors directive from proxied responses — the rest of the app's
CSP passes through untouched. The clickjacking threat those headers
address is handled the same way the gate's own pages handle it: every
proxied request is authenticated first, and the gate already declares
permissive frame-ancestors on its own responses. Unit-tested; verified
live on archi-dev-box (Alby Hub embeds, CSP intact).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>