Commit Graph
2518 Commits
Author SHA1 Message Date
archipelagoandClaude Fable 5 91bbe4faa1 fix: portainer pin, bitcoin conf tolerance, gate login UI, named OTA origin
Portainer: nodes have been running :latest — which is 2.39.1 — while the
manifest pinned 2.19.4 from two years ago. The port migration recreated the
container onto that old pin and Portainer refused to start: it migrates a
database forward, never backward, so an existing install died with 'schema
version does not align' and My Apps showed 'app is not responding'
(100.82.34.38). 2.39.1 published as an immutable tag and pinned forward, so
existing databases keep working and older ones migrate up.

Bitcoin: complements PR #131. That removes the code which kept writing a
datadir bitcoin.conf; -allowignoredconf=1 additionally makes an existing
one non-fatal, so a node already carrying the file recovers on restart
instead of crash-looping until something reinstalls it.

App gate login: rebuilt against the dashboard's own design — rotating
intro backgrounds served from the gate, the glass panel, the Archipelago
mark in its gradient ring, the app's icon as a My Apps tile, and the glass
button. Crucially it no longer sends X-Frame-Options: DENY, which made
every gated app render as unreachable inside My Apps' embedded frame;
frame-ancestors expresses 'only this node may frame me', which
X-Frame-Options cannot.

OTA origin: primary mirror is now source.archipelago-foundation.org over
TLS instead of a bare IP on plaintext. The IP stays as an automatic
fallback for nodes whose DNS or clock is broken — both break TLS, and the
signature, not the transport, is what establishes trust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 12:44:57 -04:00
archipelagoandClaude Fable 5 08a725ba12 Merge PR #131: stop writing a datadir bitcoin.conf that conflicts with -conf
Root cause of the Bitcoin crash-loop on 100.82.34.38: since a597c1d9
bitcoind launches with -conf=/tmp/rpc.conf and never reads the datadir
bitcoin.conf, but write_bitcoin_conf / ensure_bitcoin_rpc_config /
run_bitcoin_rpc_repair kept writing one on every install and restart.
Bitcoin Core's own datadir-conflict check then refuses to start at all.

Conflict resolved in favour of the PR: HEAD still carried
write_bitcoin_conf, whose deletion is the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 12:25:26 -04:00
archipelago a9f9be2f0d Merge PR #125: FIPS last-known-good endpoint fallback for direct peering
LAN -> last-known-good -> anchor-tree escalation, npub-keyed endpoint store
persisted with 30-day retention.
2026-08-05 12:22:22 -04:00
archipelago e5612fff0f Merge PR #132: translate Cashu NUT error codes into plain-language messages
Mint failures surfaced raw JSON ({"detail":"proofs already spent"}) to
the user; now the top-level message is actionable while the raw body stays
in logs via {:#}.
2026-08-05 12:22:22 -04:00
archipelagoandClaude Fable 5 4ec53a9805 fix(ota): republish the .122 manifest — the rotation stranded every pre-.122 node
The manifest advertises exactly one version, so publishing .123 (new-key
signed) removed the only stepping stone across the rotation. A node on
.121 pins the OLD root, fetches the .123 manifest, fails signature
verification and refuses — permanently, because .122 is no longer offered
anywhere. Reproduced against the live URL: 'signed_by does not match the
pinned release-root anchor'. archy-shorty-s (.228) is on 1.7.121-alpha-dev
and in exactly this state.

Restoring the old-key-signed .122 manifest as the OTA pointer lets those
nodes take .122, which installs the new pin; .123 is republished once the
fleet has crossed. Safe in both directions: is_newer() is a strict
greater-than on the version triple, so a node already on .123 sees .122 as
older and does not downgrade.

The .123 release itself is untouched — tag, assets and catalog stand; only
the pointer moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 10:24:09 -04:00
archipelagoandClaude Fable 5 9b30daaf9c fix(security): restart a companion whose image was rebuilt underneath it
A rebuilt image never reached a running companion. ensure_image_present
rebuilds in place under the same tag, so the quadlet body is identical,
write_if_changed reports no change, and enable_now is a no-op on a running
service — the container keeps the old layers indefinitely.

That is precisely how archi-dev-box kept serving the LND, FIPS, Electrs and
Guardian screens on 0.0.0.0 after v1.7.123 rebuilt every one of those images
to bind loopback: correct images on disk, three-day-old containers still
running. Closing those ports needed a manual 'podman rm -f' per container,
which no other node would ever get. Compare the running container's image ID
against the built one and restart when they diverge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 10:17:58 -04:00
archipelago ea4c072183 chore: release v1.7.123-alpha
Demo images / Build & push demo images (push) Successful in 3m58s
v1.7.123-alpha
2026-08-05 09:35:47 -04:00
archipelagoandClaude Fable 5 cfd1b4c731 chore(trust): flip the signing checks to the new release root
v1.7.122-alpha was the last release signed with the old root — it is the
release that installed the new pin on every node. From v1.7.123 the new
root signs, and a node running .122+ rejects an old-key signature. The
ARCHY_RELEASE_ROOT_PUBKEY override is no longer needed either: the signer
built from this tree pins the same key we now sign with.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 09:31:28 -04:00
archipelagoandClaude Fable 5 27c1b151f8 docs(1.7.123): curate release notes and add the in-app What's New block
Demo images / Build & push demo images (push) Successful in 3m59s
Leads with the honest version: five screens were open and the previous
release's own audit reported them as fine, found by scanning from another
machine rather than asking the node. States plainly that what leaked was
the page, not credentials — the macaroon path was verified, not assumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 09:08:07 -04:00
archipelagoandClaude Fable 5 9c736f20b6 fix(security): publish the loopback-pinned UI images and pin the new tags
Fresh installs pull *-ui images from the registry, so the source fix alone
left a newly flashed node serving the Bitcoin, LND, Electrs, FIPS and
Guardian screens with no login. All five rebuilt and pushed to
146.59.87.168:3000/lfg2025 as 1.7.123-alpha AND :latest — both tags,
because first-boot resolves the pinned tag from image-versions.sh while the
daemon's companion installer hardcodes :latest, and a stale :latest would
have quietly undone the fix on exactly the path that rebuilds companions.

Verified by pulling each image back from the registry anonymously and
reading /etc/nginx/conf.d/default.conf inside it — a private package would
make fresh nodes fall back to a stale local image without saying so.

Also fixes the FOURTH copy of bitcoin-ui's listen directive
(scripts/reconcile-containers.sh wrote 'listen 8334' into the rendered
nginx.conf on every reconcile, which would have re-opened the port after
the image and template were both corrected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 09:07:05 -04:00
archipelagoandClaude Fable 5 abf0f56afc fix(security): close the five host-networked app UIs the audit could not see
Scanning archi-dev-box from OUTSIDE found five ports serving their screens
with no login — lnd-ui 18083, bitcoin-ui 8334, fips-ui 8336, electrs-ui
50002 and the Fedimint Guardian 8175 — none of which appeared in the gate's
unprotected list. They are host-networked, so Podman publishes nothing to
pin and their manifests declared 'ports: []'; the gate builds its map from
declared ports, so it neither protected them nor reported them. An audit
that reports success while five screens are open is worse than no audit.

Their nginx now listens on 127.0.0.1 instead of 0.0.0.0, and each port is
declared 'auth: gated' so the daemon owns the outside. 'bind:' on a
host-networked app is a statement of where the container listens, not a
publish instruction — quadlet already skips PublishPort in host mode.
Guardian 8175 is declared on the fedimint app because its companion has no
manifest, and the gate keys on port, not container.

Credential paths were NOT exposed and are verified so: /lnd-connect-info,
the /proxy/lnd/ passthrough, container logs and every RPC method through
these screens all return 401 unauthenticated. What leaked was the page
shell.

Also fixes the delivery gap that would have made this unshippable: only
bitcoin-ui, lnd-ui and electrs-ui were ever rsynced to
/opt/archipelago/docker, so edits to fips-ui and fedimint-ui reached nodes
through no path at all. All five now sync; the two whose rebuilds the
daemon owns are synced without being handed to container-specs.

Every remaining undeclared port is now declared with a stated reason —
gated: botfights 9100, router 8084, pine 10380; exempt with rationale:
fedimint consensus 8173/8174, gateway 8176/9737, netbird 8086/8087 (TLS +
own auth, and enrolled devices cannot hold a session), pine TLS 10381,
lightning-stack REST 8091 (macaroon, mirrors lnd). Zero undeclared ports
remain across all 56 manifests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 08:46:09 -04:00
archipelago 634640944c chore: release v1.7.122-alpha v1.7.122-alpha 2026-08-05 07:57:07 -04:00
archipelagoandClaude Fable 5 b92e16abc0 fix(release): sign v1.7.122 with the OLD root — the rotation moved the checks a release early
Demo images / Build & push demo images (push) Successful in 4m12s
The rotation commit pointed create-release.sh and publish-release-assets.sh
at the NEW root in the same commit that pins it in the binary. But the
release CARRYING the rotation must be signed with the OLD root: every node
is still running the previous binary, which pins the old key. So the
tooling would have rejected the only signature the fleet can accept, and
the signature it demanded would have ended OTA fleet-wide.

Both checks now expect the old DID for this cycle, with the flip to the new
one called out for v1.7.123+. sign-manifest.sh documents the
ARCHY_RELEASE_ROOT_PUBKEY override needed because the signer built from
this tree already pins the new anchor and would fail to verify its own
correct output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 07:48:51 -04:00
ssmithxandClaude Sonnet 5 53b158ce5a fix(wallet): translate Cashu NUT error codes into plain-language messages
Mint HTTP failures (swap/melt/mint-quote) were surfacing raw JSON bodies
like {"detail":"proofs already spent","code":11001} straight to the
user. Add a translator for the NUT-02/03/04/05 transaction-validation
error codes (10001-11017, 12001-12003; see
https://github.com/cashubtc/nuts/blob/main/error_codes.md) and layer it
onto the mint_client bail sites via anyhow context, so the top-level
message is actionable while the raw status/body stays available via
{:#} for logs. receive_token now surfaces the real reason (e.g. "This
ecash has already been redeemed") instead of a generic "Failed to
receive any proofs from token" when every mint in a token fails.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 09:41:44 +00:00
archipelagoandClaude Fable 5 c35e33d0a7 docs(1.7.122): curate release notes and add the in-app What's New block
Leads with what changes for the operator: app screens now require the node
password across LAN, Tailscale, mesh and Tor; the wallet/protocol ports that
must stay open stayed open; the mesh leak found during on-node verification;
nodes repairing their own legacy containers; and the signing-key rotation.
Known gaps disclosed, including the eleven still-undeclared ports and that
non-browser clients will now meet the login page.

The new block uses <strong> rather than the literal ** markers in earlier
entries, which render as asterisks in the modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:33:03 -04:00
archipelagoandClaude Opus 5 3f4b5524b1 chore(trust): rotate the release root to z6Mkfu5LT…DLWT
DO NOT MERGE INTO A RELEASE SIGNED WITH THE NEW KEY. See below.

The previous release root (z6Mkkid…q7ur, pinned 2026-07-02) was exposed
in a chat transcript and is treated as compromised. It signs both OTA
manifests and the app catalog, so anyone holding it could sign updates
the fleet would install.

Pins the new key in trust::anchor and moves EXPECTED_DID in all three
signing/publishing scripts.

ORDERING IS CRITICAL — nodes pin the OLD key:

  * The release CARRYING this commit must be signed with the OLD key.
    That is the only signature a node running the previous binary will
    accept, and it is what installs the binary pinning the new key.
  * Only the release AFTER that may be signed with the new key.
  * Signing this release with the new key makes every node reject it,
    ending OTA fleet-wide and requiring hands-on recovery per node.

sign-catalog.sh moves in the same commit, so the app catalog must also be
re-signed with the new key once this ships, or nodes accept the binary
and reject the catalog.

Key verified before pinning: the hex and the did:key are the same
keypair, checked with a base58 decoder round-tripped against the previous
known-good pair. An earlier candidate hex (cb830e13…) was rejected
because it decoded to a different DID than the one supplied — pinning it
would have made every node reject every future update.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:10:32 -04:00
archipelagoandClaude Fable 5 0f21f598aa fix(security): FIPS mesh relay must not republish auth: local ports
Caught verifying the gate fixes on archi-dev-box: [fips0-ULA]:32838
answered HTTP 200 straight from nbxplorer with no credential. The
catalog declares that port auth: local — host-local by intent, pinned to
loopback, the gate deliberately keeps its hands off — but the mesh relay
bridges a STATIC port list to 127.0.0.1, so it republished it to the
whole mesh. Same bug class as the Tor onion gap: a transport that
converges on the app loopback without consulting the declaration.

PortMap now records declared-local ports and the relay withholds them
(tearing down an existing bridge if a catalog refresh newly declares
one), alongside the declared-gated withhold. Undeclared ports keep
todays behaviour — silence is not an instruction in either direction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:40:28 -04:00
archipelagoandClaude Fable 5 d2e4b00789 fix(security): gate classifies from the catalog overlay and releases withdrawn claims
Dev-box verification of the Tor/FIPS fixes caught a pre-existing split
brain: the orchestrator publishes containers from the signed catalog's
embedded manifests (origin-wins), but the gate classified ports from the
stale disk manifests — so it externally bound nbxplorer 32838, a port
the catalog declares auth: local and pins to loopback. Reachable behind
a login, but reachable where it deliberately was not.

- build_port_map now consults the catalog overlay first, via the same
  parse/validate/image-only filter the orchestrator uses (moved to
  app_catalog::catalog_manifest_overlay so the two cannot diverge again).
- GatedPort carries . The gated set still includes undeclared
  Session-default ports for challenge/audit, but every action that
  REDIRECTS traffic — the torrc 127.0.0.2 repoint, the FIPS relay
  stand-down, the Tor-upstream bind — now keys on the declaration.
- The sweep releases held claims whose port left the gated set, so a
  catalog refresh that withdraws a port (gated → local/none) takes
  effect without a daemon restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 08:50:46 -04:00
archipelagoandClaude Fable 5 e46af8cfe5 feat(security): self-heal legacy containers on declared bind drift
Legacy pre-quadlet containers kept publishing 0.0.0.0 after the catalog
pinned their app to loopback, because host_port_bindings_drifted only
compared host PORT numbers — closing them needed a manual package.update
per app per node. The drift check now also compares the bind ADDRESS,
but only when the manifest declares one: an empty bind never fires,
since recreating a loopback-published container to wildcard on silence
is exactly the v1.7.121 Bitcoin-RPC incident. With this, every node
recreates its legacy containers to the declared state on its own after
the OTA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 08:14:01 -04:00
archipelagoandClaude Fable 5 f08ed79b8a fix(security): FIPS v6 relay hands gated ports to the app gate
The mesh relay is a raw unauthenticated forward to the app's loopback,
and whether it or the gate owned a fips0 ULA port was decided by a bind
race — the dev box happened to be safe because the gate bound first.
The relay now skips ports declared auth: gated and tears down any
existing bridge for a port that became gated since it was bridged
(catalog refresh), releasing the bind for the gate's next sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 08:14:00 -04:00
archipelagoandClaude Fable 5 3760a00ea3 fix(security): Tor onions for gated ports forward to the gate, not the app
Tor carries no session cookie, so HiddenServicePort → 127.0.0.1:<port>
reached the app around the gate — the last transport the gate did not
cover. The gate now binds 127.0.0.2 (its own loopback, distinct from the
app's 127.0.0.1, so no app needs a second port), and regenerate_torrc
forwards declared-gated ports there. Undeclared ports keep today's
target: absence of the field is not an instruction.

The 127.0.0.2 claim deliberately does not count toward the unprotected
audit — a port whose only claim is the Tor loopback is still wide open
on the LAN and must keep warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 08:13:59 -04:00
archipelagoandClaude Fable 5 8210ca0a2a chore(catalog): sign catalog with port auth policy — 20 UIs gated, exemptions declared
Embeds the manifest port declarations (bind: 127.0.0.1 + auth: gated on 20
HTTP UIs, auth: local on loopback backends, auth: none + rationale on
protocol ports) into the signed catalog so nodes enforce the app gate.
Also carries bitcoin-ui/lnd-ui 1.7.119 version drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:28:06 -04:00
archipelagoandClaude Fable 5 6d9d87caa6 chore: sync Cargo.lock with the 1.7.121-alpha version bump
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:09:37 -04:00
archipelagoandClaude Fable 5 16642ad8b8 feat(security): declare port auth policy across the app manifests
20 HTTP UIs move to bind: 127.0.0.1 + auth: gated (the daemon owns their
external addresses and authenticates every connection); 5 loopback-only
backends declare auth: local so the gate keeps its hands off. Protocol
ports (LND, bitcoin p2p, electrum, CLN, gitea SSH, Wyoming, mDNS/SSDP)
were already declared auth: none with rationales in earlier commits.

Inert until the catalog is re-signed: nodes act only on declared fields
delivered via the signed catalog, and the catalog overlay overrides these
disk manifests everywhere they are installed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:09:37 -04:00
archipelagoandClaude Opus 5 4e455167e9 fix(release): accept https remotes when publishing assets
Publishing v1.7.121-alpha failed on auth after the manifest had already
passed every check. The script required an `http://user:token@` remote,
which left only `gitea-vps2` — whose token is dead — and rejected
`gitea-ai`, the https remote whose credential actually works for git
push. Same Gitea instance (146.59.87.168, v1.27.1) either way, so the
restriction bought nothing and blocked the one usable path.

Accepts http and https, and carries the scheme through to the API URL
instead of hardcoding it.

Note for diagnosis next time: `/api/v1/repos/.../releases` is publicly
readable, so a 200 there does NOT prove the credential works. Use
`/api/v1/user`, which requires real auth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:23:28 -04:00
archipelago cd58242935 chore: release v1.7.121-alpha
Demo images / Build & push demo images (push) Successful in 3m26s
v1.7.121-alpha
2026-08-04 03:13:45 -04:00
archipelagoandClaude Opus 5 9d225473b1 docs(1.7.121): record what shipped, both gate incidents, and the .122 queue
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 02:49:36 -04:00
archipelagoandClaude Opus 5 e20d7a14fb docs(whats-new): add the v1.7.121-alpha block to the in-app modal
Demo images / Build & push demo images (push) Successful in 3m41s
The release gate requires every CHANGELOG version to have a matching
block in Settings > What's New. Generated by scripts/sync-whats-new.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 01:22:16 -04:00
archipelagoandClaude Opus 5 1929f6a870 style: rustfmt the appgate, federation and manifest changes
The release gate runs cargo fmt --check and these were hand-written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 01:00:29 -04:00
archipelagoandClaude Opus 5 9abf9072a3 docs(changelog): curate v1.7.121-alpha release notes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 00:51:46 -04:00
archipelagoandClaude Opus 5 ab2c8b6e96 fix(security): silence is not consent — undeclared ports are never acted on
Two live incidents on archi-dev-box today, one bug. Both times a safety
decision read an ABSENT manifest field as if it were a value, and a
node's installed manifests always lag the binary — so "absent" is the
state of essentially every port on every node.

  1. Gating any `session` port regardless of `bind` published Bitcoin's
     loopback-only RPC 8332 on the LAN, Tailscale and IPv6 within seconds
     of deploy.
  2. The `bind`-keyed replacement looked safe because it protected
     `bind: 127.0.0.1` ports — but LND's gRPC 10009 and REST 18080 carry
     an EMPTY bind, so they fell through. One container recreate from
     pinning them to loopback and breaking Zeus and every remote wallet.

`auth` is now `Option<PortAuth>`, separating two questions that were
conflated:

  * `auth_policy()` — what to CLASSIFY the port as. Undeclared reports as
    Session, i.e. shows in the audit as something that should be behind
    the gate. Reporting is always safe.
  * `auth_is_declared()` — whether the daemon may ACT. Only an explicit
    declaration authorises changing how a port is published.

Also reverts the daemon-side publish rewriting entirely. The node proved
it wrong twice over: the recreate path that actually ran was in
package::install, not podman_client, so the pin never fired; and even
`bind: 127.0.0.1` written directly into the node's manifest was
overridden by the signed catalog. Publishes are built in several places
and all of them already honour `bind`, so the migration belongs in the
catalog as data — not in daemon-side inference that can only ever cover
one path and guess wrong on the rest.

Tests: 75/75 container, incl. the LND wallet-port shape (`host: 10009`,
empty bind, no auth) asserted to be non-actionable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 00:36:43 -04:00
archipelagoandClaude Opus 5 edc9a172e9 fix(mesh): federated peers are messageable without meeting over LoRa first
Peering a node was not enough to message it — you had to be in radio
range once before chat worked, which defeats the point of federating.

`send_message` chose its transport from the attached radio:

    let use_typed_envelope =
        archy && matches!(device_type, Meshcore | Reticulum);

Only the typed path knows about FIPS/Tor. Everything else fell through to
`peer_dest_prefix`, which resolves an over-the-air ROUTING key — so on a
node running Meshtastic, or with no radio at all, sending to a federated
peer failed. It only worked once a LoRa advert had created a radio twin
for the same archipelago identity, which is precisely the "connect on
LoRa first" the operator hit.

Federation contacts are reachable off-radio by definition — that is what
`upsert_federation_peer` records with `reachable: true` — so the
transport choice must not depend on which radio is plugged in. A
federation-synthetic contact id now always takes the typed path.

This loses no radio-first behaviour: `send_typed_wire` already prefers a
REACHABLE radio twin when the payload fits the frame, and only then falls
back to FIPS and Tor. The fix routes federation contacts INTO that logic
rather than around it.

Test pins the predicate across every device type, including the two that
failed (Meshtastic, Unknown), and asserts ordinary radio contacts and
stock clients still route exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 00:35:02 -04:00
archipelagoandClaude Opus 5 719446c05f fix(companion): stop the endless rebuild loop on *-ui companions
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>
2026-08-03 18:55:28 -04:00
archipelagoandClaude Opus 5 3716b6e9c3 fix(security): two gate bugs that would have made the rollout a no-op
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>
2026-08-03 18:41:29 -04:00
archipelagoandClaude Opus 5 cc9e19589c fix(release): refuse to commit an unsigned OTA manifest
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>
2026-08-03 17:55:02 -04:00
archipelagoandClaude Opus 5 0de67ca6ae feat(security): app gate — authenticate every app port
Demo images / Build & push demo images (push) Successful in 4m18s
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>
2026-08-03 16:46:23 -04:00
archipelagoandClaude Opus 5 63d0183dd2 fix(ui): stop the dashboard cards leaving a backdrop-filter seam
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>
2026-08-03 16:45:32 -04:00
archipelagoandClaude Opus 5 0c4826f8cc feat(security): declare which app ports may skip authentication
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>
2026-08-03 13:51:15 -04:00
archipelagoandClaude Opus 5 24ce8b39e8 feat(security): require the node password to grant Trusted
Demo images / Build & push demo images (push) Successful in 4m22s
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>
2026-08-03 13:07:21 -04:00
archipelago f0b71f86aa docs(13): declare AIUI-01..06 in the canonical ROADMAP requirements line 2026-08-03 11:19:26 -04:00
archipelago 223afc26f7 docs(reqs): register AIUI-01..06 for phase 13 traceability 2026-08-03 11:18:33 -04:00
archipelago eb224709d7 docs(13): create phase plan — 15 plans in 8 waves 2026-08-03 11:16:09 -04:00
archipelagoandClaude Opus 5 60625499ae fix(13): make D-13 track independence real in the wave graph
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>
2026-08-03 11:12:06 -04:00
archipelagoandClaude Opus 5 203966030b docs(13): create phase plan — 15 plans in 8 waves
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>
2026-08-03 10:42:25 -04:00
archipelagoandClaude Opus 5 e00c73ed2d docs(1.7.121): pause point — task list, status and resume notes
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>
2026-08-03 10:34:36 -04:00
archipelagoandClaude Opus 5 5088aef556 fix(lnd-ui): pin the image and host-network it so OTA actually updates it
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>
2026-08-03 10:31:39 -04:00
archipelagoandClaude Opus 5 c0cfc72a05 fix(security): peers must not be able to grant themselves Trusted
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>
2026-08-03 10:31:16 -04:00
archipelago ff2cb5aa0e docs(13): add pattern map 2026-08-03 09:54:47 -04:00
archipelago 72b07fbe9c docs(13): generate AI-SPEC.md — hand-written Rust agent loop + domain context + eval strategy 2026-08-03 09:47:55 -04:00
archipelago d3ee5486ab docs(13): add validation strategy 2026-08-03 09:23:10 -04:00