Observed live on archi-dev-box: archy-fedimint-ui rebuilt 45 times in 10
minutes, every ~35s, indefinitely. bitcoin-ui, lnd-ui and electrs-ui were
all one reconcile away from the same loop.
`context_is_newer_than_image` decides to rebuild when the build context's
newest mtime is later than `podman image inspect .Created`. The rebuild
that follows is a full layer-cache hit, so podman reuses the identical
image and leaves .Created untouched — the condition that triggered the
rebuild is still true afterwards. The check cannot converge: it rebuilds
on every reconcile tick forever, burning CPU and churning the container.
It bites after any deploy that refreshes /opt/archipelago/docker/*, which
makes the contexts newer than the shipped images — so this is fleet-wide
on every OTA, not local to one node.
Fix: stamp the context mtime that was built into an image label and
compare against that instead. A label is part of the image config, so a
cache-hit build with a new value still produces a new image — the thing
being tested does change, and the comparison settles after exactly one
rebuild. Verified against real podman before writing it: two cache-hit
builds with different label values produced distinct image IDs
(6cfdbc9bcd3e vs a9b10eb9a558), each carrying its stamp; the indexed
inspect format was checked against an image with real labels, and a
missing label prints empty (handled, along with "<no value>").
Images built before this carry no label and fall back to .Created, so
behaviour is unchanged for them and each self-heals on its first
reconcile after upgrade — nodes fix themselves rather than needing the
manual `podman build --no-cache` pass this needed by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both found while setting up the on-node test, and both fail silently in
the same direction — the gate reports success while protecting nothing,
which is the exact failure the module was written to prevent.
1. Loopback-pinned ports were skipped entirely.
`identity.rs` dropped any port whose manifest sets `bind: 127.0.0.1`,
reasoning that a loopback publish is not externally reachable. But
`listener.rs` requires loopback-pinning as the PRECONDITION for gating —
while an app holds 0.0.0.0:<port> the kernel will not let the gate bind
that port at all. So the two contradicted each other: pinning an app, the
one action that lets the gate take over, was also what removed it from
the gated set. Completing the entire migration would have gated nothing,
and GateStatus would have reported zero unprotected ports while doing it.
`bind` cannot carry this decision, because two unrelated intentions
produce an identical loopback publish: Bitcoin's RPC 8332 is pinned so
the LAN CANNOT reach it (fronting it would newly expose it on every host
address, behind a login but exposed where it deliberately was not),
whereas a migrated app is pinned precisely so the gate CAN. Inferring
from `bind` breaks one or the other, so the intent is now declared:
`PortAuth::Local` means the first case. The three ports that are
host-local by intent (bitcoin-core/knots 8332, aiui 5180 — all already
`bind: 127.0.0.1`) say so, and a loopback publish with `auth: session`
stays gated. A test pins that property.
2. The port map was never refreshed.
`AppGate::refresh()` existed, was documented as making catalog changes
apply without a restart, and was called by nothing. The map was built
once in `new()`, so an app installed while the daemon runs would never be
gated — and would never appear in `unprotected` either, so the node would
report itself fully enforced while serving a brand-new app to anyone who
asked. The sweep now refreshes before classifying.
Tests: 22/22 appgate, 73/73 archipelago-container.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every cycle has needed a manual check that releases/manifest.json got
signed, because the script would happily commit and tag one that hadn't.
The signing step is conditional: with no TTY and no
RELEASE_MASTER_MNEMONIC it prints a warning and falls through. The commit
at step 7 then ran regardless, so the release commit — and its tag —
carried an unsigned manifest.
publish-release-assets.sh already refuses to ship one, but that backstop
arrives a step too late. Nodes fetch releases/manifest.json straight from
branch `main` (the same URLs this script prints for verification), so the
COMMIT is what exposes it to the fleet, not the publish. By the time
publishing is refused, the unsigned manifest is already on main and nodes
are already declining to auto-apply.
So the same gate now runs before the commit: presence of a signature,
signed_by matching the release root, and `ceremony verify` for the crypto.
A release commit carrying a manifest no node will accept has no valid use,
so this refuses to create one rather than leave a tag that has to be
re-cut. The earlier warning is corrected too — it promised the run would
continue, which is no longer true.
Verified the predicate against three manifests: signed -> allow, signature
stripped -> refuse, signed_by swapped to another DID -> refuse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reproduced again on this node today: with no session cookie, six app
ports answered HTTP 200 with their real UIs (18083 LND, 8334, 8175
Fedimint Guardian, 8336 FIPS Mesh, 8090, 7777), all bound 0.0.0.0 and so
served on every host address. Same bug class as the /lnd-connect-info
and /bitcoin-rpc/ leaks closed in v1.7.120, but across every app.
LAN, Tailscale, Tor and the FIPS mesh all converge on 127.0.0.1:<port>,
so this is one gate rather than four. It lives in the daemon rather than
a per-app sidecar (umbrel's app_proxy model): rootless, no extra
container per app, and it can reuse machinery that already exists.
It invents no authentication policy. verify_password, TOTP secret
decryption, verify_code with used-step replay protection, the session
store, and — importantly — the SAME LoginRateLimiter instance as the
JSON-RPC path, so an attacker cannot get a fresh budget of password
guesses by moving to an app port. Only the transport differs, an HTML
form instead of JSON-RPC, because a browser being sent to an app cannot
speak JSON-RPC.
2FA comes for free: a session still pending its TOTP step fails
validate(), so the gate rejects it without knowing what a second factor
is.
Details worth keeping:
- 401, not a redirect. A redirect to a login page is indistinguishable
from the app itself redirecting, and machine clients would follow it
and parse HTML as their API response.
- Cookie and Authorization are stripped before proxying. The app has no
use for the node session and must never be able to log or forward it.
- The challenge page names and pictures the app being opened, so the
visitor can confirm what they are authenticating to.
- device_tokens grew `apps: Option<Vec<String>>` and verify_for_app for
machine clients. None = node-wide, which every existing companion
token is; migrating them by guessing a scope would silently revoke
access nobody asked to revoke. An empty list is rejected rather than
minted, since it reads as unrestricted while authorising nothing.
The rollout is necessarily per-app and the gate is built to say so. A
container publishing 0.0.0.0:<port> claims every host address, so the
gate cannot bind that port until the app is pinned to bind: 127.0.0.1
and recreated — gate-first is impossible, and all-at-once would recreate
every container on a node simultaneously. Every port it cannot claim is
logged at warn each sweep and recorded in GateStatus::unprotected,
surfaced by security.app-gate-status. The failure mode being designed
against is a gate that binds nothing, logs at debug, and reports success
while every app stays exactly as open as before — worse than no gate,
because it stops anyone looking. Same reasoning that ruled out an
nft drop-in, whose absence is a silent no-op.
Not yet done: pinning the 39 gated ports to loopback, repointing
HiddenServicePort at the gate, and on-node verification.
Tests: 21/21 appgate, workspace builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A vertical line crossing both dashboard cards, appearing at random on
hover and hard to catch deliberately.
Diagnosed from the screenshot rather than by reproduction. Decoding it
and scanning column by column found a lone brightness step at CSS x=633
that never returns — every legitimate container edge in the page shows
up as a PAIR of steps 2px apart (the card borders at CSS 255, 288, 850,
875, 1437), so an unpaired one is not a border. Sampling by region
placed it inside the cards and nowhere else: 10/13 rows inside My Apps,
11/11 inside Wallet, 2/10 in the gap between them, 2/13 above them. Same
screen x in both cards, which means the boundary lives in screen space
and cuts whatever backdrop-filter surface it crosses.
style.css already neutralises backdrop-filter for the shared glass
classes inside the dashboard's animated perspective/scroll containers,
because Chromium/Brave mis-rasterise it there — that block was written
for the black-rectangle corruption. `.home-card-shell` declares its own
`backdrop-filter: blur(18px)` in Home.vue and was never added to the
list, so it was the only unmitigated blur surface on the dashboard.
That is exactly the set of pixels the seam appears in. A hover repaint
re-rasterises part of the backdrop, and the refreshed half meets the
stale half at the damage boundary.
Adding it to the existing list also makes the shell consistent with the
tiles beside it: its fill is already rgba(0,0,0,0.65), the same as
.glass-card, which renders unblurred here.
The list is hand-maintained, which is how this shipped — a component
declaring backdrop-filter in its own <style> is simply not covered and
nothing fails. So the fix comes with a test that parses Home.vue for
locally-declared backdrop-filter rules and asserts each is in the
mitigation list. Verified it catches the real bug: reverting the
one-line fix makes it fail naming `.home-card-shell`.
Tests: 3/3 new, vue-tsc clean, mitigation confirmed in the built CSS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Groundwork for the app gate (item 1): before anything can enforce
authentication on app ports, the node has to know which ports are
*supposed* to be reachable without it.
`PortMapping` grows `auth` (PortAuth::Session | None, defaulting to
Session) and `auth_rationale`. The default is deliberately the protected
one. Every app port on this node answered with no credential at all over
LAN, Tailscale, Tor and the FIPS mesh alike — reproduced 2026-08-03 —
precisely because exposure was what you got by saying nothing. Inverting
the default means a new app is protected unless its manifest argues for
an exemption.
Validation makes the argument mandatory: `auth: none` without a
rationale is rejected, and so is a rationale without `auth: none` (that
combination means the author wrote an exemption and did not get one —
shipping it silently would leave them believing otherwise).
17 ports across 12 apps are declared exempt, each with its reason. They
are the ports that cannot sit behind an HTTP login page at all: Lightning
p2p (BOLT-8 noise), LND gRPC/REST and CLN gRPC (macaroon / mutual TLS —
Zeus and remote wallets dial these directly), Bitcoin p2p gossip,
electrum wire protocol, Wyoming voice streams, git-over-SSH, and the UDP
discovery protocols (mDNS, SSDP, STUN). Everything else — 39 published
ports — now defaults to gated.
Bitcoin's RPC 8332 is deliberately NOT exempted: it is already
`bind: 127.0.0.1`, so the gate never sees it, and claiming an exemption
it does not need would put a line in the audit list that means nothing.
If the loopback bind is ever dropped, it fails closed.
Two corpus tests keep this honest: every shipped manifest must parse
under the new rules, and the exempt set is pinned at 17 so any change to
the node's unauthenticated surface has to be a deliberate edit.
Tests: 73/73 archipelago-container, workspace builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Promotion to Trusted is a privilege escalation — a Trusted peer can read
node state, be deployed to, and is exempt from the `!= Untrusted` gates
federation/DWN/messaging use. It must therefore cost a fresh proof that
the person at the keyboard is the operator, not merely that a session
cookie exists. Same reasoning as node.rotate-identity and TOTP setup,
both of which already re-verify.
Both entry points are covered:
- `federation.invite` gates on the RESOLVED level, not on an explicit
request for Trusted: "Link Your Nodes" sends no `trust_level` at all
and falls through to the Trusted default. The invite is a bearer grant
of Trusted to whoever redeems it, so minting it IS the escalation.
Observer invites are untouched.
- `federation.set-trust` gates only when the peer is not already
Trusted, so the dropdown re-emitting its own value doesn't demand a
password for a no-op.
Demotion is deliberately NOT gated: making something less privileged
must never be harder than leaving it alone, or the safe action becomes
the inconvenient one.
The backend is the sole authority on what counts as an escalation — it
returns a `PASSWORD_REQUIRED:`-prefixed error and the UI prompts and
retries only on that, so the rule lives in exactly one place and the
frontend never pre-judges. TrustPasswordModal.vue (modelled on
RotateDidModal.vue) serves both flows. NodeDetailModal's select snaps
back to the node's real level on change, since a cancelled or failed
promotion would otherwise leave the dropdown displaying a level the node
never accepted.
The operator path stamps TrustSource::Manual; set_trust_level grew an
`Option<TrustSource>` so automatic adjustments (the discovery-handshake
demotion safety net) pass None and leave the recorded provenance alone
rather than laundering an uninvited-join peer into looking approved.
Follow-up, deliberately out of scope: `federation.join` also reaches
Trusted when redeeming someone else's Trusted invite, with no re-auth.
Tests: 44/44 federation, 79/79 rpc-client, vue-tsc clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan-checker revision iteration 1 — 1 blocker + 2 warnings.
BLOCKER (context_compliance, D-13): the music track blocked phase
completion despite being locked as non-blocking. 13-15 depended on
13-11, which chains back through 13-07 to 13-04, so the phase could
not close without the entire music chain. Took the checker's option
(b): 13-15 depends_on is now ["13-06","13-09","13-14"] — 13-06 added
so the content-grid check stays a real gate, 13-11 dropped so no path
reaches 13-04/13-07/13-11. UAT step 7 is now content-only and blocking;
new step 7b is the music view as record-and-defer, the same shape step
10 already used for Routstr. Verified: 13-15's transitive closure
contains no music plan.
WARNING (scope_reduction): T-13-32 claimed the filebrowser-client.ts
JWT-in-query-string leak was "fixed" while only guaranteeing it was not
propagated. Now actually fixed — streamUrl returns a query-free
same-origin URL and relies on the path=/ cookie login() already sets;
filebrowser-client.ts and a new regression test are in 13-06's
files_modified. T-13-32 is scoped to new code; new T-13-39 owns the
pre-existing leak and names the residual (the JWT is still 24h, now
confined to the cookie jar).
WARNING (verification_derivation): the edge-probe reconciliation did
not match the files. Corrected in 13-VALIDATION.md — 10 probe findings
vs 9 edge entries kept apart, 13-07's 3 truths retagged as authored
rather than probe-surfaced. No truths deleted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AIUI conversational node control & content surfaces, decomposed tracer-first:
13-01 leads with one end-to-end read-only tool proving the whole spine
(AIUI chat -> postMessage -> authenticated RPC -> Rust agent loop -> real
node data), then expands.
Waves 1-8 across three tracks that stay independent per D-13:
- control/assistant: 13-01, 13-05, 13-08, 13-10, 13-12, 13-13, 13-14
- content: 13-06
- music library: 13-04, 13-07, 13-11 (no control/content plan depends on it)
- security & delivery: 13-02, 13-03, 13-09
- on-device sign-off: 13-15
Notable decisions recorded in the plans:
- Open Q1: delete-and-replace the live unauthenticated port-3142 Claude proxy
with a session-gated Rust forwarder; the OpenRouter open relay is removed.
- Open Q2: /aiui/-scoped CSP connect-src plus a per-session rate limit;
the iframe sandbox attribute is explicitly rejected with reasons.
- Open Q3: a live Routstr spike (13-03) gates the Routstr backend (13-13).
- Open Q4: one "assistant." dispatcher prefix arm, so the existing
session/CSRF/RBAC gate applies unchanged before dispatch.
- Promote (not add-alongside) CallerScope as the primary caller/permission
noun; the mesh-specific controls become one variant's resolution inputs.
- schemars rejected as an unaudited crate; JSON Schema is hand-written.
- AI-SPEC's `cargo test --test assistant_evals` corrected to an in-crate
module: core/archipelago is a binary-only crate with no lib target.
Also adds COVERAGE.md (Routstr capability matrix, every opt-out reasoned).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v1.7.120-alpha is shipped and verified; do not re-cut it. Two fixes landed
after it: federation trust escalation (c0cfc72a) and the lnd-ui OTA pin +
host networking (5088aef5).
The task file now carries a RESUME HERE block with the groundwork already
located for the next item (the password gate on granting Trusted) — the
exact helper, both entry points with line numbers, and the rule that
demotion stays ungated — so the next session does not repeat the search.
Paused here deliberately rather than starting the app-port auth work at
low context: it is the largest item, the operator asked for umbrelOS and
StartOS research first, and it is the same bug class as the leaks fixed
in v1.7.120 but across every app port and transport.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported: Framework PT took the OTA and got the new bitcoin-ui but not
lnd-ui. Two causes, both in the update path rather than the app.
1. LND_UI_IMAGE was "lnd-ui:latest" while BITCOIN_UI_IMAGE was pinned to
1.7.119-alpha. Podman does not re-pull a tag it already holds locally,
so a node that ever pulled lnd-ui:latest keeps that copy forever and
every subsequent release silently no-ops. Pinned to 1.7.119-alpha, so
a version change is what triggers the pull — the same mechanism that
made bitcoin-ui update correctly.
2. first-boot-containers.sh declared lnd-ui as bridge with -p 18083:80.
docker/lnd-ui/nginx.conf listens on 18083 DIRECTLY (it must, to proxy
the backend on 127.0.0.1:5678 same-origin), so that maps a host port
onto a container port nothing serves — reproduced on-node as HTTP 000.
This is the THIRD copy of the same declaration: container-specs.sh and
apps/lnd-ui/manifest.yml were both already corrected, this one was
missed, and it is the copy fresh installs use. Now host-networked with
no published ports, matching its siblings and the other two copies.
The underlying hazard is that one container spec lives in three files
that can disagree; recorded as a follow-up rather than refactored here.
Also opens .planning/RELEASE-1.7.121-TASKS.md — every outstanding item
for the next release with its evidence, so nothing in a fast-moving queue
gets lost between sessions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported: "peers seem to be slipping into trusted status somehow which is
absolutely terrible for security". Two independent fail-open paths, both
granting Trusted with no operator decision anywhere in the loop.
1. federation.peer-joined is UNAUTHENTICATED (middleware's no-session
list — federated peers call it over Tor without cookies) and reachable
on /rpc/v1, which is peer-allowed. It does verify an ed25519 signature,
but against THE PUBKEY THE CALLER SUPPLIED, so it proves the caller
holds its own key and nothing about whether we ever invited it. A join
presenting no invite_token fell through to
None => TrustLevel::Trusted.min(claimed_trust)
and claimed_trust itself defaults to Trusted when the field is absent.
So anything able to reach the node could generate a keypair, omit the
token, and be recorded as Trusted. Now capped at Observer: an invite
WE minted is the only path to Trusted. `min` is kept so a peer's own
lower claim is still honoured — this can only ever reduce trust.
2. merge_transitive_peers added every peer advertised by a Trusted source
as Trusted. That makes trust viral rather than transitive-by-one-hop:
the merged node is itself synced with, its peers merged in turn, so a
single invite anywhere in the graph eventually marked the entire graph
Trusted on every node. Now Observer — which is what this feature's own
spec always said. NodeStateSnapshot.federated_peers is documented as
"adds them as Observers on her side… doesn't auto-promote Observer-via-
Bob to Trusted". The code contradicted the comment directly above it.
Observer is deliberate rather than Untrusted: the merge exists for
routing, and Observer still passes the `!= Untrusted` gates that
federation, DWN and messaging actually check, so a legacy peer degrades
instead of breaking. Per the operator's decision, existing peers are NOT
auto-demoted — silently rewriting live trust relationships across the
fleet would be worse than the bug.
Instead they are made auditable: FederatedNode.trust_source records WHY a
level was granted (invite | uninvited-join | transitive-merge | manual).
It deliberately has no default provenance — None means "recorded before
this existed", which is exactly the population worth reviewing.
The one failing test was asserting the vulnerable behaviour
(merge_transitive_peers_skips_source_and_local_node expected Trusted); it
now asserts the security property and says why, so the escalation cannot
be reintroduced by making a test go green.
Verified: 42/42 federation tests, cargo check --all-targets clean.
Still open, tracked in .planning/RELEASE-1.7.121-TASKS.md: surface
trust_source in the UI, and require the node password to grant Trusted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verifies the AIUI-01 gating question against source (no tool-calling
anywhere in this codebase today; Pine's HA intents are read-only Q&A,
not an action-executing loop), surfaces a live unauthenticated
Claude-proxy exposure (port 3142) and a same-origin iframe sandbox
gap not previously named, and maps existing Cashu/Nostr primitives
onto the Routstr integration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the two release-process traps for the next cut: the manifest is
committed before signing (so the fleet would refuse the OTA), and
gitea-vps2 is the same server as gitea-ai with a dead token.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
create-release.sh builds and commits the manifest BEFORE the signing
step, so the release commit carried an UNSIGNED manifest. Nodes fetch
releases/manifest.json from branch main and refuse to auto-apply an
unsigned one, so publishing without this would have shipped an OTA the
fleet silently declines.
Signature verified against the pinned release root before committing:
signed_by did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur
Cargo.lock carries the 1.7.120-alpha version bump from the release build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AIUI is embedded and styled but not functional: the chat cannot act on the
node and its content views are not wired to real data. Phase 13 scopes making
it work — Pine's human-language intent->action capability reachable from typed
chat, conversational settings, and the peer-files/music/movies/node-content
surfaces rendered live.
The gating requirement is AIUI-04: a user-granted capability sandbox. An LLM in
the browser is now adjacent to wallet keys, macaroons and node identity, so
secrets stay server-side behind scoped tokens, capability grants default closed
and stay revocable, destructive operations need a human confirmation, and
peer-supplied text is treated as untrusted input to the model context. This must
not widen the Phase 10 hard-refuse gates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Generated by scripts/sync-whats-new.py, which the release gate checks.
Without it the Settings > What's New modal would have skipped straight
from v1.7.119 to v1.7.121 — the release notes users actually read, as
opposed to CHANGELOG.md which they do not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The release gate's cargo-fmt stage failed on my own additions — the
tests in message_types.rs and lnd/info.rs and the reconcile branch in
prod_orchestrator.rs were written programmatically and never passed
through rustfmt. Formatting only; rustfmt is semantics-preserving and
the gate re-runs the suites before building.
Caught by the gate rather than in review, which is the gate working. Also
a reminder that a piped command's exit code is the pipe's, not the
script's: the task notification reported success while the log said
CREATE_RELEASE_EXIT=1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bookkeeping left uncommitted by an earlier session. It records work that
is already shipped — bc9a210c routed Paid Files pictures/videos into the
app lightbox with a visible wait — so the checklist and the mapping table
were simply lagging the code.
Landed as its own commit rather than swept into the release: it is not my
edit, and the release script refuses to run with a dirty tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
States plainly that the speed is unchanged — the fix gates the teleported
chrome, not the KeepAlive caching that made tab switching instant.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported: the nav above the bottom bar — back buttons, the mesh tabs —
stayed stuck across other screens.
Cause is the KeepAlive work from phase 2, and specifically the half of it
that is invisible from the view's own file. Main tabs are KeepAlive'd, so
navigating DEACTIVATES a view instead of unmounting it. Content the view
Teleports to <body> is not in the view's DOM subtree, so deactivation
does not remove it and it keeps rendering over the destination screen.
Two offenders, matching the report exactly:
- Mesh.vue teleports its mobile TAB BAR and its chat BACK BUTTON to
<body>, gated only on `mobileShowChat` — never on whether Mesh was the
screen you were looking at.
- components/BackButton.vue teleports the shared mobile back button with
NO gate at all, so it leaked out of every view that uses it. Fixing the
shared component fixes every caller at once: Vue propagates
activated/deactivated from the KeepAlive boundary down through the
subtree, so a child can guard itself.
BaseModal already solved the transient-dialog half of this class in
204d4523 by closing on route change. That is the right fix for a dialog
and the wrong one for chrome: a tab bar has no "closed" state to fall
back to, and forcing one would lose the user's place. New
useViewActive() composable instead — chrome is simply not rendered while
its owner is off screen, and returns exactly as it was.
THE PERFORMANCE IS NOT SACRIFICED, which was the explicit constraint.
The Teleport is gated, not the view, so the instance stays cached and
revisiting a tab is still instant. A test pins this: setup() must run
exactly ONCE across a navigate-away-and-back round trip. If someone
later "fixes" this by dropping KeepAlive, that test fails.
Deliberately untouched: AppSession.vue, whose teleport is load-bearing —
its own comment records that moving the iframe node reloads the app, and
app-session is excluded from KeepAlive anyway so it cannot leak. Toasts,
the app launcher and the connection banner are app-level rather than
view-owned; gating those would be wrong.
Verified: 3 new tests; full suite 105 files / 848 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Includes what was NOT verified — the torrc block is deployed but dormant,
since regenerate_torrc only fires on a Tor services change and the change
is inert until bitcoind gets an -onion flag in Phase 12.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Leads with the reason to take the update: two ports handed anyone who
could reach them full control of the node's money. Written for an
operator, not a developer — what was exposed, who could reach it, and
what to treat as compromised.
Includes the gaps rather than burying them: the 5x lifecycle gate was not
run, two fleet nodes still share SSH host keys (rotation is a deliberate
operator decision, not an oversight), and Core can now reach Tor but is
not yet routed through it.
create-release.sh hard-fails without this section, so it lands before the
release run rather than during it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per the operator: every option umbrelOS surfaces must be reachable in the
UI, Knots-only options surfaced separately from the ones Core shares, and
network mode a setting whose DEFAULT is Tor rather than clearnet.
Scoped as a phase rather than done inline because bitcoind's arguments are
currently hardcoded in three places (first-boot-containers.sh,
container-specs.sh, apps/bitcoin-knots/manifest.yml) — the same
triplication that produced the lnd-ui HTTP 000 defect. There is nowhere
for a UI to write, so BTCSET-01 is a settings model those three render
FROM, not another restatement.
Two constraints recorded up front so they are not discovered late:
- Knots-only flags gated to Knots is a CORRECTNESS requirement — offering
one on Core yields a node that refuses to start.
- Several options are not freely reversible: txindex forces a reindex,
prune is destructive and needs a full resync to undo. On a node that is
somebody's wallet backend those must be labelled and gated, not
silently applied. Any change at all restarts bitcoind, interrupting
LND, electrs and the fedimint gateways.
Inbound onion is explicitly out of scope: it needs Tor's ControlPort,
which is deliberately disabled for security, so the node reaches .onion
peers but stays unlisted. The UI must say so rather than imply otherwise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Enabling half of "Bitcoin Core has no Tor proxy at all", handed over from
the app-UI work. Core reported `onion reachable=False, proxy=''` with all
11 peers on clearnet, and the reason was not a missing bitcoind flag: the
container sits on the archy-net bridge (10.89.0.0/24 here), so
127.0.0.1:9050 inside it is its OWN loopback. The host's Tor was
genuinely unreachable, and no flag on bitcoind could have fixed that
alone.
torrc now binds a second SOCKS listener on the archy-net gateway.
The gateway is DERIVED at runtime via `podman network inspect`, never
hardcoded: archy-net is created without an explicit subnet, so podman
allocates one. It is 10.89.0.0/24 on this node with no guarantee of that
elsewhere, and a hardcoded guess would fail silently — binding SOCKS to
an address no container can reach, which looks identical to working.
Two deliberate safety properties:
- FAIL CLOSED. If archy-net is absent or its inspect output does not
parse, no second listener is emitted and SOCKS stays loopback-only. An
exposure boundary is not something to widen on a guess.
- 127.0.0.1 is accepted FIRST in the SocksPolicy. SocksPolicy applies to
every SocksPort, so an accept-list naming only the bridge subnet would
have locked the daemon out of its own loopback SOCKS — breaking the
node's Tor usage in a way that looks nothing like "we added a
listener". The list is accept-loopback, accept-subnet, reject *.
This widens Tor SOCKS from loopback-only to the archy-net subnet, which
is a real change to the node's exposure surface and was explicitly
approved by the operator rather than assumed. Inbound onion for Core
remains impossible without reversing the deliberate "ControlPort disabled
for security" decision — this is outbound only, and the node stays
unlisted on Tor.
Not yet wired: bitcoind still has no -onion flag, because the operator
wants network mode to be a UI setting with Tor rather than clearnet as
the default. Hardcoding the flag in the three places that currently
define bitcoind's arguments would be the wrong shape for that, so it is
deferred to the settings work rather than done twice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Taking over a parked item from the app-UI work. AIUI's built index.html
emits ABSOLUTE /assets/<hashed> paths, so the browser asks for
/assets/index-BC2fBBaW.js. That lands in the MAIN UI's assets dir, where
it does not exist — the real files are in aiui/assets/. Both of AIUI's
two entry assets 404'd, so the embedded sidebar loaded nothing.
The config already contained a /aiui-assets/ location whose comment names
this exact problem ("AIUI may reference /assets/ without /aiui/ prefix"),
but it only catches requests to /aiui-assets/, a path AIUI never asks
for. It described the bug without fixing it.
/assets/ now falls back to a named location that rewrites into
aiui/assets/ and 404s from there. A fallback rather than copying the two
files up one level, because a frontend deploy replaces web-ui wholesale —
update.rs preserves the aiui/ DIRECTORY, not copies made into assets/ —
so a copy is erased by the very next deploy while this survives one.
Both server blocks (HTTP and HTTPS) are patched; named locations are
per-server, so each needs its own.
Verified on archi-dev-box after reload:
/assets/index-BC2fBBaW.js 200, 305256 bytes, application/javascript
/assets/index-BJkaQ2c4.css 200, 150716 bytes, text/css
/assets/does-not-exist.js 404 (the fallback is not over-broad)
/assets/index--lyLAgu1.js 200 (real main-UI chunks still come from
/assets/vendor-CmYeCqL_.js 200 the main dir — try_files hits them
/assets/index-CiMaoNII.css 200 before the fallback is consulted)
/ /aiui/ /health 200
Hash collision between the two builds is not a concern: Vite hashes are
content-derived, and any main-UI asset that exists is served by try_files
before the fallback runs.
Noted while doing this, not fixed here: the node's own
/etc/nginx/sites-enabled/archipelago is 378 lines BEHIND this repo file
(984 vs 1362) — it predates the IPv6 listener and the @asset_missing
no-store handling, among others. The node was patched minimally in its
own shape rather than overwritten, since a wholesale copy of a config
this diverged is not a safe unattended action.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deployed 6b3693dc to archi-dev-box off a clean tree and exercised the
real RPC surface. lnd.getinfo returns a real identity_pubkey through the
field that did not exist before this plan. mesh.lightning-peers returns
an empty array with success — the FED-05 empty edge proven on hardware,
not just in a unit test. Both refusal paths of mesh.send-lightning-info
observed live, including T-01-13's "no broadcast form".
The finding worth carrying to 01-06: this node's LND advertises no URI
(uris: []), so send-lightning-info correctly refuses rather than
shipping an empty advertisement a peer would store as an undialable
target. The receive and list halves work; the SEND half is inert on any
node whose LND has no externally reachable address. The picker must not
assume the local node always has something to share.
Corrects this summary's own earlier claim that nothing was exercised on
hardware — that was true when written and is not now. The mesh leg
proper (an advertisement crossing real RF into a peer's lightning_uri)
remains unproven and is still called out as such.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 3, completing 01-04.
mesh.lightning-peers returns the peers that have advertised a Lightning
URI: filtered, deduplicated, deterministically ordered, and an empty
array rather than an error when nobody has — "nobody yet" is a normal
state on a fresh node, not a fault.
mesh.send-lightning-info advertises this node's own URI to ONE chosen
peer. There is deliberately no broadcast form: this discloses the node's
payment endpoint, and who learns it is the operator's choice rather than
a side effect of being in radio range (T-01-13). It refuses to send when
LND advertises no URI, instead of sending an empty one a peer would
store as an undialable target.
The list-building and target-parsing logic is extracted into pure
functions because this file has no handler test harness and the
handlers need a live mesh service. That keeps the three contracts that
actually matter provable rather than merely readable:
- dedup is keyed on identity_pubkey_hex() — the AUTHENTICATING key,
lowercased — never the firmware routing key, so a radio contact and
its federation twin collapse to one entry (T-01-11)
- "newest advertisement wins" compares PARSED RFC3339 timestamps, not
strings: 09:30-01:00 is later than 10:00Z while sorting earlier as
text, and there is a test that fails if that is ever string-compared
- ordering is name-then-contact_id and asserted byte-identical across
eight rotations of the input, because a HashMap's iteration order is
not stable and a picker that reshuffles between reads means an
operator can click a different node than the one they aimed at
The peer allow-list is untouched: server.rs has an empty diff and
is_peer_allowed_path still occurs 13 times (T-01-15).
Verified: cargo test -p archipelago 1087 passed / 0 failed; clippy
--all-targets clean in every touched module (two useless_format lints in
the new test code fixed, not waived).
The SUMMARY records one deviation honestly: Task 1's tests were written
alongside its implementation rather than before, so no pre-implementation
failing output exists. A mutation test was run in its place — disabling
the pubkey validation fails 3 of the 5 tests — which proves the
assertions bind, and the mutation was reverted and verified gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tasks 1 and 2 of 01-04. This node's own shareable URI, and a mesh
message a peer uses to advertise theirs.
lnd.getinfo now deserializes identity_pubkey and uris, which its
response struct simply did not declare before (RESEARCH.md Pitfall 5).
The identity mapping is split into a pure map_identity() so it is
testable without a live LND. A pubkey that is not 66 hex characters maps
to None rather than being forwarded: the same rule lnd.openchannel
enforces, applied where the operator is reading their own node's
identity instead of at the moment they try to open a channel. An absent
field yields an honest absence — never a fabricated or placeholder
identity.
MeshMessageType::LightningInfo = 26 is additive on a wire format shared
with every fleet node: 26 was unused, so a peer that predates this fails
to decode it rather than mis-decoding it as something else. Its payload
is deliberately two fields — this rides LoRa, where every byte is paid
for on air, and the optional alias is skip_serializing_if so an absent
one costs nothing (asserted, not assumed).
is_valid_lightning_uri() validates before anything is stored, because
this is unauthenticated RF input: 66-hex pubkey, non-empty host, optional
numeric :port, exactly one '@'. It deliberately does NOT resolve or dial
the host — that would turn a received advertisement into an outbound
connection an attacker chose.
Two preservation hazards found while wiring MeshPeer.lightning_uri, both
of which would have silently emptied the picker:
- decode.rs's identity-advert path does a WHOLESALE insert, preserving
only advert_name and lat/lon by hand. Reticulum re-emits identity
adverts every announce tick, so a stored URI would have been wiped
about once a minute. Now preserved, alongside the same guard the name
and position already had.
- session.rs's refresh_contacts and mod.rs's federation seeding rebuild
the peer record wholesale too. Neither carries a Lightning datum, so
both now carry the previous value forward rather than nulling it.
A malformed inbound URI is rejected before the write, leaving any
previously stored good URI intact — otherwise anyone in range could
blank out a real peer's picker entry (T-01-12).
Verified: 5/5 new lnd::info tests, 18/18 mesh::message_types (5 new),
cargo check --all-targets clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Controlled test on archi-dev-box with operator approval. The daemon was
stopped first so the reconciler could not repair the state before the
re-exposure was confirmed — without a confirmed 200, the later 401 would
be consistent with the state never having been broken at all.
1. stale conf installed + container restarted -> POST /bitcoin-rpc/
returned 200 with a real block height and Allow-Origin: *
2. daemon started 20:00:36, nothing else touched
3. 20:02:19 reconcile rendered the conf and logged the expected warn
line naming bitcoin-ui/archy-bitcoin-ui, then restarted it
4. POST -> 401, Allow-Origin origin-scoped
5. conf byte-identical to the pre-test known-good, container healthy
Both halves are now proven on real hardware: a05956c4's template (the
gate works) and f6b5245b's delivery path (the gate reaches a container
the reconciler had been skipping).
Also records the operator's decision AGAINST credential rotation — no
macaroon, no Bitcoin RPC password — with the trade it accepts stated
plainly, so it is not silently re-litigated later.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second copy of the wiring fixed in aaa89789. apps/lnd-ui/manifest.yml still
declared network_policy: bridge with a 18083 -> 80 port mapping, while
docker/lnd-ui/nginx.conf listens on 18083 directly — it has to, so it can
proxy the backend on 127.0.0.1:5678 same-origin; the cross-origin fallback
is what broke this app on http-only nodes.
Publishing a host port to a container port where nothing listens is exactly
the failure reproduced on archi-dev-box when recreating from the matching
container-specs.sh entry: :18083 refusing connections, HTTP 000. Fixing
only the spec would have left the manifest as a live footgun for any code
path that provisions this app from its manifest instead.
Now host networking with an empty ports list, matching both what actually
runs and apps/bitcoin-ui/manifest.yml, which had it right all along.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things needed for the new UI to actually reach users.
The rendered nginx.conf served index.html with only ETag/Last-Modified and
no Cache-Control, so browsers applied heuristic caching to it. Confirmed on
archi-dev-box: after rebuilding and recreating the container, :8334 and
/app/bitcoin-ui/ both served the new markup immediately, but the app iframe
in the main UI kept showing the previous UI until a hard refresh.
docker/lnd-ui/nginx.conf has always carried this header, which is why only
bitcoin-ui showed the stale copy. Using "no-cache" (revalidate) rather than
"no-store" keeps the ETag doing its job when nothing has changed.
Validated by mounting the rendered config into a throwaway container from
the built image and running nginx -t. (An earlier attempt to test it inside
the running container was meaningless — conf.d/default.conf is a read-only
bind mount, so the copy failed and nginx -t just re-checked the original.)
The 8 container::bitcoin_ui tests still pass; their assertions cover the
placeholder, the 8332 proxy_pass and the listen directive, none of which
this touches.
BITCOIN_UI_IMAGE was still pinned to 1.7.84-alpha, so a fresh install would
pull a bitcoin-ui from many releases ago regardless of what the OTA ships —
first-boot-containers.sh tries the registry image before building from
source. Bumped to 1.7.119-alpha, matching the current release, and the
image is pushed under that tag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All four found by verifying on archi-dev-box rather than assuming.
container-specs.sh: archy-lnd-ui was specified as a BRIDGE container with
SPEC_PORTS="18083:80", but docker/lnd-ui/nginx.conf listens on 18083
directly (it must, to proxy the backend on 127.0.0.1:5678 same-origin).
Recreating from that spec publishes host 18083 to container port 80, where
nothing listens. Reproduced on the node: the app came back with :18083
refusing connections, HTTP 000. This never fired before because the running
containers are created by first-boot-containers.sh, which is host-networked
and never reads this file; the spec is only consulted when self-update.sh
rebuilds a UI image, and that only happens when a file under docker/lnd-ui/
changes — which is exactly what the previous two commits did. So the next
OTA would have taken lnd-ui down on every node. Now SPEC_NETWORK="host"
with no port mapping, matching what actually runs. NET_BIND_SERVICE dropped
with it: 18083 is unprivileged.
lnd-ui channels link: pointed at /apps/lnd/channels, but that route is a
CHILD of the /dashboard record in neode-ui's router, so the real path is
/dashboard/apps/lnd/channels. nginx's SPA fallback returns 200 for the
wrong path, so it failed as vue-router's NotFound view rather than an HTTP
404 — both the Payment Channels card and the Manage Channels button.
Both apps, copy buttons: navigator.clipboard only exists in a secure
context, and nodes serve these apps over plain http; the main UI also
embeds them in an iframe, where the async Clipboard API is separately gated
by the clipboard-write permission policy. Every copy button silently did
nothing there. Added an execCommand('copy') fallback behind a copyText()
helper and routed all six call sites through it.
lnd-ui Node ID: showed the bare pubkey whenever getinfo.uris was empty,
which is the common case — LND only populates uris once it is advertising
an external address. The bare pubkey is not what a peer pastes to open a
channel. The full pubkey@host:9735 URI is now built from the Tor onion
where available, falling back to this node's address, with a hint saying
which and what its reachability is. The QR encodes the URI too.
Verified on archi-dev-box: both images rebuilt and containers recreated
from the specs; lnd-ui and bitcoin-ui both serve 200 with the new assets;
and the RPCs the new tabs depend on all answer on the live node —
getblockstats returns every field the charts read, getpeerinfo returns 11
peers carrying relaytxes and network values the classifier handles.
Note for whoever tests bitcoin-ui's Insights/Peers tabs: /bitcoin-rpc/ now
sits behind auth_request /_session_check (a05956c4 et al), so it answers
401 to an unauthenticated curl by design. A logged-in browser sends the
session cookie same-origin, which is how those tabs get their data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Names the four open items explicitly, including the one that is easy to
lose: the reconcile fix is deployed but unexercised, so the node's 401
proves the template and not the delivery path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The node is closed and verified 401 with origin-scoped CORS. But an
unrelated bitcoin-ui rebuild at 18:36 cleared the stale conf before the
reconcile fix was deployed at 19:06, so the 401 proves a05956c4's
template and NOT the delivery path f6b5245b adds.
Window 14 closed (exposure gone, verified). Window 15 opened for the
delivery path, which is deployed but never exercised — bitcoin-ui is
still in the uninstall marker, so this node depends on that untested
path the next time its config has to change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
system.stats on archi-dev-box returns host_secrets with verdict 'per-node'
and three evidence lines (machine-id anchor 2026-04-09; every SSH host key
and the TLS key newer than the anchor). Previously proven against the file
contract in unit tests only.
Honest limitation: this is one node, not the dev pair — archy-x250-dev has
been offline for two days, so the second node is unreachable, not skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five review fixes across both node UIs.
1. LND now uses the app-store icon. It was shipping its own 182KB lnd.svg
while the app store, My Apps and the signed catalog all render
neode-ui/public/assets/img/app-icons/lnd.png (catalog.json points at
/assets/img/app-icons/lnd.png). Same file is now vendored into the
image, so the app header, the launcher and the store agree. That icon is
a full-bleed square with an opaque white background rather than a
transparent glyph, so it fills the frame and is clipped to the inner
radius — exactly how bitcoin-ui frames its own icon — instead of being
inset with padding on a dark plate.
2. Header no longer squishes at tablet widths. Both headers had a single
768px breakpoint, so between 768 and 1024 the title and description got
crushed against the controls on the right (four status cards on
bitcoin-ui) and overlapped. Both now use three breakpoints: fully
stacked and centred below 768, logo + title on one row with the controls
wrapped underneath below 1024, single row above. The app name and
description are centred on mobile.
3. QR codes stay square. .conn-layout is a flex row on desktop and flex
items stretch by default, so the white QR plate was being pulled to the
height of the fields column and the square QR sat letterboxed in it. The
plate is now a fixed square inside a black glass panel that absorbs the
extra height, so the panel matches the fields and the QR stays square.
4. Buttons read as one family. The Settings button and both modal dismiss
buttons used the flat .glass-button while every other button on the page
used .info-card-button; they now all use the latter, via new .compact
(inline) and .icon-only (square) variants so the shared style works at
button size rather than only as a full-width card.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The half that landed correctly (LND, clean 401) made the half that did
not harder to notice, because the first check an operator would run
returns a pass. Records the probes, the three-fact root cause, and the
pass condition for re-probing a node.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified live on archi-dev-box, code fix committed in f6b5245b but not
deployed. Deliberately logged as open rather than fixed: the defect that
matters to an operator is the running node, not the source tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found while VERIFYING a05956c4 on archi-dev-box rather than assuming it.
GET /lnd-connect-info is correctly 401 with no cookies over the LAN
address. But POST /bitcoin-rpc/ on :8334 still answered an
unauthenticated caller with a real block height, and still carried
`Access-Control-Allow-Origin: *`. The node looked patched. Half of it
was not.
The rendered /var/lib/archipelago/bitcoin-ui/nginx.conf was dated
2026-06-30 — the pre-fix version — even though the running binary
carries the new template. a05956c4's commit message claimed the
template "is re-rendered on every reconcile pass, so this ships
atomically with the binary". That is false in one specific state, and
this node was in it:
1. bitcoin-ui sits in the durable user-uninstalled marker.
2. reconcile returns on that marker BEFORE run_pre_start_hooks, which
is what renders the config.
3. The container keeps running regardless, because it is owned by
systemd via a Quadlet unit (archy-bitcoin-ui.service, active,
restarted 17:25 after the daemon restart) — not by this reconciler.
So a container systemd keeps alive, that the orchestrator has stopped
reconciling, never receives a config fix shipped inside the binary. An
OTA carrying a05956c4 would have silently failed to close this on every
node in that state, while the LND half closed correctly — the most
misleading possible outcome. archy-electrs-ui is in the same state on
this node, so it is not a one-app accident.
A container that is actually running is a live attack surface whatever a
marker says about it. Its security-relevant config is now reconciled
even behind the marker, and it is restarted so nginx actually loads it.
Deliberately narrow:
- Nothing is created, pulled, built, started or resurrected. The "must
stay removed" contract only ever gets weaker if a container is ALREADY
running, which by definition means it was never removed.
- A hook error is swallowed, not propagated: an app the user uninstalled
must not be able to fail the reconcile pass for everything after it.
- The pre-existing marker test still passes unchanged, which is what
proves the removal contract survived.
Verified: 11/11 reconcile tests and 9/9 bitcoin_ui tests pass, including
a new regression test that pins the whole chain — stale conf in, gate
present out, container restarted, nothing created.
No node has been touched. The live exposure on archi-dev-box stands
until this is deployed and the operator restarts archy-bitcoin-ui.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>