Compare commits

...
Author SHA1 Message Date
archipelagoandClaude Fable 5 1948767083 chore: release v1.7.125-alpha
Demo images / Build & push demo images (push) Successful in 4m12s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 06:27:02 -04:00
archipelagoandClaude Fable 5 e875f15fc5 docs: curated changelog for v1.7.125-alpha
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 06:10:39 -04:00
archipelagoandClaude Fable 5 ed062481c3 style: cargo fmt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 05:32:53 -04:00
archipelagoandClaude Fable 5 d8748ee7ba fix(orchestrator): map container uids into the subuid range in the chown fallback
chown_for_rootless_container prefers `podman unshare chown` (which maps
container uid N through the userns), but when that failed once it fell
back to `sudo chown -R <literal>` — writing e.g. host uid 999 for
container uid 999 and reporting success. Host-999 maps to nobody inside
the userns, so the app could not open its own data while everything
claimed the chown worked: botfights on framework-pt crash-looped every
10s on SQLITE_CANTOPEN over a data dir the daemon itself had just
"fixed".

The sudo fallback now translates container ids (1..99999) to
subuid_base + id - 1 (fleet base 100000; container root maps to the
service user, 1000). Already-mapped ids and uid 0 pass through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 23:48:36 -04:00
archipelagoandClaude Fable 5 8469af5f4e fix(wallet): surface LND sweep refusals as readable errors
A sweep of 92 unconfirmed/dust sats failed with LND's debug-flavored
"insufficient input to create sweep tx: input_sum=0 BTC, output_sum=
0.00000092 BTC" — and the RPC sanitizer then masked even that behind
"Operation failed. Check server logs." (framework-pt, 2026-08-06).
The sweep mechanics are untouched (balance minus fee, as always) —
this only makes the refusal say WHY in plain language.

- lnd.sendcoins translates the sweep refusal: balance below Bitcoin's
  dust minimum or not yet confirmed, so no transaction can be built
  (LND's original message kept in parens).
- "Failed to send" joins the sanitizer's user-facing allowlist — the
  same lesson as "Insufficient balance"/"Payment failed" before it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 23:41:35 -04:00
archipelagoandClaude Fable 5 92cffe9d46 fix(reconciler): recreate an absent stack member when its siblings are live
The periodic reconcile runs ExistingOnly — merely listing a catalog
manifest must never install an app — and its only absent-container
recovery keyed on the last running-names snapshot, which ages out after
a few daemon restarts. An absent member of an installed stack then stays
absent forever: .38 ran indeedhub with no minio/postgres for days, nginx
down on 'host not found in upstream "minio"', and nothing ever put the
members back.

A live sibling container is proof the stack is installed on this node,
so an absent member is now treated as a hole to repair, not a choice to
respect: the recovery guard also fires when another member of the same
stack (app_ops::stack_member_app_ids) has a container in any state.
A stack with no containers at all is left untouched, and sibling app ids
resolve through the loaded-manifest container names (immich-postgres
runs as immich_postgres).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 22:50:42 -04:00
archipelagoandClaude Fable 5 d5ef4ef76e chore(catalog): sign catalog with session_passthrough + indeedhub-redis caps
Carries the two manifest-side halves of the .125 fix batch: the four
companion UIs (lnd-ui, bitcoin-ui, electrs-ui, fips-ui) declare
session_passthrough on their gated ports so the gate forwards the node
session their nginx proxies to the daemon, and indeedhub-redis gains
CHOWN+DAC_OVERRIDE so its entrypoint can traverse its own data dir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 22:16:13 -04:00
archipelagoandClaude Fable 5 6110a7a9a7 test: update stale drift guards (login-page A mark, 25 exempt ports)
Both predate this session's changes and were masked by the release
gate's cargo-test-weekly compile timeout:
- login_page_sources_its_art_from_the_gate still asserted the retired
  wordmark (logo-archipelago.svg); the login page ships the sidebar A
  mark (favico-black-v2.svg) since the 2026-08-05 rework.
- unauthenticated_ports_are_all_accounted_for lagged at 17; the
  v1.7.123 port-policy round grew the rationale-carrying exempt set
  to 25 (reviewed and enumerated in the test comment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 21:49:19 -04:00
archipelagoandClaude Fable 5 c972419840 fix(wallet): blank send/receive on every open; sweep shows amount; camera option stays visible
Demo images / Build & push demo images (push) Successful in 3m31s
Operator-reported (2026-08-05):

- Send/Receive modals reset to a blank slate on every open. Stale state —
  destination, amount, memo, and above all an armed "send all funds"
  toggle — silently carried into the next payment.
- Arming "send all funds" now shows the swept balance in the (disabled)
  amount field instead of a confusing 0; disarming or leaving the
  on-chain tab clears it.
- The scan modal no longer hides "Scan with camera" on plain-http desktop
  (browsers only allow getUserMedia on secure origins): the option stays
  visible with a one-line explanation, and choosing it surfaces the HTTPS
  requirement with photo/paste fallbacks. The companion app's native
  scanner path is untouched and still takes priority.
- What's New entry for v1.7.125-alpha.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 21:22:42 -04:00
archipelagoandClaude Fable 5 d0d9c032de fix(apps): session_passthrough on companion UI ports; indeedhub-redis caps
- lnd-ui/bitcoin-ui/electrs-ui/fips-ui declare session_passthrough: true
  on their gated ports — their nginx forwards the browser's node session
  to the daemon's authenticated endpoints, which the gate's cookie strip
  was discarding (every data call 401'd behind the gate).
- indeedhub-redis gains CHOWN + DAC_OVERRIDE: the alpine entrypoint runs
  as capability-stripped container-root and could not traverse the 0700
  appendonlydir owned by the redis uid — crash-looped ~4k restarts on
  archi-dev-box under the quadlet migration.

These reach nodes via the signed catalog re-sign (manifest overlay).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 21:22:07 -04:00
archipelagoandClaude Fable 5 ada59acdd5 fix(appgate+container): stop stripping app cookies; create named volumes correctly
Two daemon bugs, one debugging arc (2026-08-05, operator-reported):

1. The app gate removed the ENTIRE Cookie header before proxying. That
   broke the data plane of every first-party companion UI behind the gate
   (lnd-ui/bitcoin-ui/electrs-ui/fips-ui render their shell, then every
   /proxy/* and /lnd-connect-info call 401s — observed as "LND UI
   unreachable"), and silently logged users out of every gated app with
   its own cookie login (vaultwarden, nextcloud, gitea) on each request.
   The gate now strips only its own cookie pairs (session, csrf_token);
   a new per-port manifest opt-in `session_passthrough: true` forwards
   the node session to first-party UIs whose nginx proxies the daemon's
   authenticated endpoints. Undeclared ports never get passthrough.

2. podman_client::create_container sent named volumes to the libpod API
   as bind mounts with the bare volume name as source, so creating any
   manifest app with a `type: volume` mount failed. On .38 the reconciler
   removed indeedhub-postgres/-minio for env drift and then could never
   create their replacements, leaving the stack half-missing forever.
   Named volumes now ride the spec's `volumes` field ({Name, Dest,
   Options}). Also: the reconcile-failure log now prints the full anyhow
   chain — `%e` showed only "create_container X" and hid the real error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 21:21:52 -04:00
archipelagoandClaude Fable 5 4ace62fad9 chore(catalog): sign catalog with the bitcoin and fedimint fixes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:21:21 -04:00
archipelagoandClaude Fable 5 e7592dc9c9 fix(fedimint): stop declaring 8175 — it belongs to the UI companion, not fedimintd
Demo images / Build & push demo images (push) Successful in 3m32s
Declaring the Guardian UI port on the fedimint app made the orchestrator try
to publish 8175 from fedimintd, colliding with archy-fedimint-ui which
already holds it: start_container failed on every reconcile and fedimint
crash-looped (100.82.34.38). The companion's nginx pinned to 127.0.0.1 is
what actually closes that port; the gate reports it rather than fronting it.

Also: app-login page uses the sidebar's 'A' mark instead of the full
wordmark, is pinned to the small viewport so it stays centred and the
keyboard overlays rather than scrolls it, and the install-version modal
icon uses object-contain so a non-square icon is no longer cropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:12:36 -04:00
archipelagoandClaude Fable 5 188411b79c chore(catalog): sign catalog with the repaired bitcoin start script
Unbreaks Bitcoin on every node running the 1.7.124 catalog: the embedded
start script had a shell syntax error, so bitcoind never launched and the
app vanished. Delivered by catalog rather than a release because manifests
reach nodes through the signed catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:54:37 -04:00
archipelagoandClaude Fable 5 e88c51d80b fix(bitcoin): repair the startup script I broke in 1.7.124, and gate against it
The bitcoin app vanished from updated nodes: the container exited instantly
with 'sh: Syntax error: "fi" unexpected'. My 1.7.124 change added an
explanatory comment INSIDE the manifest's folded YAML scalar (>-), where
'#' is not a comment — it is literal text that reaches the shell. Folding
joins lines with spaces, so the comment swallowed the 'if ... then' while
the more-indented echo survived as its own line, leaving an orphan 'fi'.
bitcoind never ran, the container exited, and the app disappeared from the
UI because detection is container-based.

Explanations now live above the '- >-' line where YAML really treats them
as comments. The loopback-conf tolerance (-allowignoredconf=1) is unchanged
and still needed.

Adds scripts/check-manifest-shell.py to the release gate: it runs 'sh -n'
over every embedded manifest script and rejects '#' inside these scalars.
Nothing validated this shell before — no YAML parse or Rust test could have
caught it, and it only failed on the node, after signing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:45:02 -04:00
archipelago c9f1d87dd6 chore: release v1.7.124-alpha 2026-08-05 16:42:57 -04:00
archipelagoandClaude Fable 5 4f8c76c67e style: rustfmt the regenerated app_ports list
generate-app-catalog.py writes APP_LAUNCH_PORTS one entry per line; rustfmt
packs it. The release gate checks formatting, so the generated file has to
be formatted after regeneration or every catalog sync fails the gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:30:13 -04:00
archipelagoandClaude Fable 5 d5ca612e2b chore(catalog): sync catalogs to the manifests for 1.7.124
Demo images / Build & push demo images (push) Successful in 3m42s
Portainer's image reaches the public catalog (the release gate caught the
manifest and catalog disagreeing), and fips-ui 8336 joins the mesh relay's
port list now that it declares a port — it is auth: gated, so the relay
withholds it rather than bridging it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:27:19 -04:00
archipelagoandClaude Fable 5 0a374c80a6 style: rustfmt the merged PR #125 hunks and the mirror test; sync Cargo.lock
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:24:31 -04:00
archipelagoandClaude Fable 5 6668359875 chore: bump to 1.7.124-alpha ahead of the release run
Demo images / Build & push demo images (push) Successful in 3m42s
Pre-bumped so the release gate compiles the test profile at the final
version — create-release bumps after the gate, so the gate would otherwise
run on the old version and the bump would invalidate the cache, timing out
cargo-test-weekly on the compile rather than the tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:10:49 -04:00
archipelagoandClaude Fable 5 cc709fd7da docs(1.7.124): curate release notes and add the in-app What's New block
Demo images / Build & push demo images (push) Successful in 3m46s
Leads with the update that switched nodes off and left them unable to
switch back on — the one an operator most needs to understand, and the
reason to take this release promptly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:58:28 -04:00
archipelagoandClaude Fable 5 9dd02359e4 feat(settings): session timeout is configurable from the UI
Demo images / Build & push demo images (push) Successful in 3m43s
auth.session-policy.get/set plus a card under Account. Presented as two
plain questions rather than the token mechanism underneath, because the
distinction that matters to an operator is which control actually ends a
session: the dashboard polls constantly, so an idle timeout alone never
fires on an open tab — the absolute cap is what guarantees it.

Values are clamped server-side and the stored result is echoed back, so
the bounds are discoverable instead of an error. Presets rather than a free
number field: a box accepting '5' invites locking yourself out. A short
idle choice warns that it is the payments-industry posture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:29:17 -04:00
archipelagoandClaude Fable 5 81033ed6f5 fix(ota): repair a stale Restart=on-failure unit that leaves nodes dead after update
austin-sapien (100.70.96.88) sat dead for over two hours after taking
v1.7.122 — 'server starting' in the UI, service inactive, exit status
0/SUCCESS. It did not crash: the in-process updater replaces the binary and
exits cleanly for systemd to restart it, and that node's unit still carried
Restart=on-failure from an older install. systemd read the clean exit as
success and left it stopped. Every node with the old unit has this waiting
for it on the next update.

self-update.sh does refresh units, but the in-process update path never
runs it, so nothing was repairing them. The daemon now checks its own unit
at boot and rewrites only the Restart= line, so a node that starts even
once ends up with a policy that survives the next update.

Also carries the session-policy wiring: validate() now honours the
configured idle and absolute limits and the per-device class, instead of
the single hard-coded 24h constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 13:50:16 -04:00
archipelagoandClaude Fable 5 d8647f6576 fix(mesh): tabbed tools column on very wide screens; add configurable session policy
Demo images / Build & push demo images (push) Successful in 3m36s
Mesh right panel: a >=2560px screen hid the tab bar and stacked all five
tool panels in fixed grid rows. On a real display that clipped the Bitcoin,
Dead Man and AI headings to a few pixels each, letterboxed the map, and
pushed Radio Settings into a scroll — more screen producing a worse view.
Very wide now uses the same tabbed column as every other desktop width,
with the selected panel filling the column and the map running edge to edge
(it is the one panel with nothing to scroll).

Session policy: idle timeout, absolute cap and a re-prompt-for-funds flag,
persisted and clamped. Two tokens already existed — a session token and a
30-day login token — so the knob changes how long a quiet tab stays usable
without putting a long-lived credential on every request. Kiosk screens are
exempt from the idle timeout (nobody is there to log a TV back in) but keep
the absolute cap so a stolen box does not stay authenticated forever. The
cap is not optional theatre: idle alone never fires on a polling dashboard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 13:03:31 -04:00
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
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 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
ssmithx 0b2c36f095 fix(bitcoin): stop writing a datadir bitcoin.conf that conflicts with -conf=/tmp/rpc.conf
Since a597c1d9 (bitcoind RPC creds off argv), bitcoin-core and bitcoin-knots
launch bitcoind with -conf=/tmp/rpc.conf and pass all other settings as CLI
args; bitcoind never reads /var/lib/archipelago/bitcoin/bitcoin.conf again.

write_bitcoin_conf, ensure_bitcoin_rpc_config, and bootstrap's
run_bitcoin_rpc_repair were never updated to match — they kept writing/
"repairing" server=/rpcbind=/rpcallowip=/listen= into that datadir file on
every install, reinstall, and service restart. Bitcoin Core's own
datadir-conflict safety check then refuses to start whenever that file
exists alongside an explicit -conf= arg, so the write and every repair
of it directly caused the crash it was trying to prevent.

Also drop the "restart already-running container after bitcoin.conf
repair" adoption-path branch: it assumed bind settings live in that file
and needs a restart to pick them up, which hasn't been true since
a597c1d9 — the running container's CLI args are already correct.

Replaces both writers with remove_stale_bitcoin_conf(), which renames
(not deletes) any leftover file so already-affected nodes self-heal on
next install/restart instead of staying permanently broken.

bitcoin_data_volume_gb is removed as dead code (it only fed the deleted
prune= line in write_bitcoin_conf, itself unused since a597c1d9 hardcoded
-prune=550 in the manifest's small-disk branch).

Investigated after a crash loop on archy-x250-beta; full incident
timeline and patch rationale in bitcoin-conf-crash-patch.md.
2026-08-01 19:28:25 +00:00
archipelagoandClaude Fable 5 c0a5635ba3 feat(fips): A3.10 — last-known-good endpoint fallback for direct peering
New fips/endpoints.rs: an npub-keyed store (<data_dir>/fips-endpoints.json)
of every endpoint a peer was last seen connected at (fipsctl show peers
transport_addr/transport_type — covers LAN, Tailscale, and WAN alike),
refreshed each anchor tick, 30-day retention.

The anchor tick now escalates LAN → last-known-good → anchor tree: any
federation peer with a fips npub that is neither currently connected nor
covered by a live LAN direct entry gets its last-known-good endpoint
re-dialed (idempotent fipsctl connect, bounded by apply()'s per-connect
cap). This productizes the hand-applied .116↔.198 Tailscale fix of
2026-07-20 and closes RC2's "no endpoint fallback" gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 07:33:32 -04:00
103 changed files with 3026 additions and 608 deletions
+45
View File
@@ -1,5 +1,50 @@
# Changelog
## v1.7.125-alpha (2026-08-06)
- **The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.
- **IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app.
- **A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled.
- **Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed "send all funds" toggle — no longer quietly carries over into the next payment. Choosing "send all funds" also shows the amount being swept instead of a confusing 0.
- **A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with "check server logs"; it now explains that no transaction can be built from those coins.
- **The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as "the scanner is gone". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.
- **App data folders can no longer be "repaired" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied.
- Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
## v1.7.124-alpha (2026-08-05)
- **The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update.
- **Portainer opens again.** Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1, their stored data was written by that version, and the app list pinned a version from two years earlier — so when the container was rebuilt it landed on the old one, which will not read newer data. The correct version is now pinned, older installs upgrade cleanly, and no data was touched.
- **Bitcoin starts reliably again.** A leftover settings file in the Bitcoin folder — one the node itself kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes that file, removes stale copies, and treats any that remain as harmless.
- **Every app screen opens from My Apps again.** The login gate refused to be displayed inside another page at all, which is exactly how My Apps opens an app, so protected apps appeared broken. It now allows only your own node to display it, and refuses everyone else — a distinction the old setting could not express.
- **The app login screen now looks like the node's own.** Same rotating artwork, the same panel, the Archipelago mark, and the app's real icon shown as a tile the way My Apps shows it, instead of a plain box with a letter.
- **The Mesh screen uses wide displays properly.** On very large screens it stacked all five panels on top of each other, clipping three of the headings to a sliver and squeezing the map into a letterbox — more screen producing a worse view. It now shows one panel at a time, filling the space, with the map running edge to edge.
- **You can choose how long you stay signed in.** Settings → Account now offers an inactivity timeout and a hard limit, plus an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle, because there is nobody there to sign them back in.
- Updates now come from `source.archipelago-foundation.org` rather than a bare address, with the old one kept as an automatic fallback for nodes whose clock or name lookup is off. Also included: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
## v1.7.123-alpha (2026-08-05)
- **Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself.
- **What was actually readable was the page, not your money.** Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was verified directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.
- All five now serve only to the node itself, with the login gate in front of them, exactly like the twenty app screens closed in the previous release.
- **Every port on the node now has a stated policy — there are no undecided ones left.** Eleven ports previously had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now require the node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections (federation members authenticate to the federation), NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session, and its dashboard needs its own certificate), Pine's secure listener, and the Lightning REST port, which wallets reach with a macaroon exactly as before.
- Fresh installs are covered too, not just existing nodes. The five screens are delivered as prebuilt images, so a newly flashed node would have come up open even after this fix. All five were rebuilt, published, and then pulled back and inspected to confirm the fix is really inside them.
- Two delivery faults fixed alongside, either of which would have silently undone the above: two of the five screens were reaching nodes through no update path at all, so edits to them never arrived; and a fourth copy of the Bitcoin screen's configuration was being rewritten on every health check, which would have re-opened that port after everything else was corrected.
- Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release.
## v1.7.122-alpha (2026-08-04)
- **Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121.
- **The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.
- **A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.
- **Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it.
- Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair.
- The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart.
- **The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.
- Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
## v1.7.121-alpha (2026-08-04)
- **Making another node "Trusted" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them.
+1 -1
View File
@@ -442,7 +442,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
+2
View File
@@ -26,6 +26,8 @@ app:
- host: 4080
container: 8080
protocol: tcp
bind: 127.0.0.1
auth: gated
environment:
- FRONTEND_HTTP_PORT=8080
+2
View File
@@ -33,6 +33,8 @@ app:
- host: 32838
container: 32838
protocol: tcp
bind: 127.0.0.1
auth: local
volumes:
- type: bind
+2
View File
@@ -51,6 +51,8 @@ app:
- host: 3535
container: 3535
protocol: tcp
bind: 127.0.0.1
auth: local
volumes:
# Holds the wallet DB, mnemonic and auth token. ARK funds are recoverable
+5 -2
View File
@@ -38,6 +38,9 @@ app:
RPC_CONF="/tmp/rpc.conf";
umask 077;
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2;
fi;
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
@@ -46,9 +49,9 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
derived_env:
- key: DISK_GB
+5 -2
View File
@@ -38,6 +38,9 @@ app:
RPC_CONF="/tmp/rpc.conf";
umask 077;
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2;
fi;
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
@@ -46,9 +49,9 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
derived_env:
- key: DISK_GB
+16 -1
View File
@@ -31,7 +31,22 @@ app:
# proxies to 127.0.0.1:8332 which is where the bitcoin backend binds
# its RPC. `ports:` is intentionally empty because host networking
# bypasses port mapping.
ports: []
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate had no idea
# the port existed: it was neither protected nor listed as unprotected, and
# served the Bitcoin screen unauthenticated on every interface.
ports:
- host: 8334
container: 8334
protocol: tcp
bind: 127.0.0.1
auth: gated
# First-party companion UI: its nginx forwards the node session cookie
# to the daemon's authenticated endpoints; without passthrough the gate
# strips it and every data call 401s while the page shell renders.
session_passthrough: true
volumes:
# Bind-mount the rendered nginx.conf read-only. The prod orchestrator
+2
View File
@@ -62,6 +62,8 @@ app:
- host: 9100
container: 9100
protocol: tcp # Web UI + API
bind: 127.0.0.1
auth: gated
volumes:
# A bare relative source (was "botfights-data", no leading slash) is
+2
View File
@@ -45,6 +45,8 @@ app:
- host: 23000
container: 49392
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+2
View File
@@ -30,6 +30,8 @@ app:
- host: 8088
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+16 -1
View File
@@ -23,7 +23,22 @@ app:
network_policy: host
# Host networking: nginx listens on 50002 directly on the host IP.
ports: []
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate had no idea
# the port existed: it was neither protected nor listed as unprotected, and
# served the Electrs screen unauthenticated on every interface.
ports:
- host: 50002
container: 50002
protocol: tcp
bind: 127.0.0.1
auth: gated
# First-party companion UI: its nginx forwards the node session cookie
# to the daemon's authenticated endpoints; without passthrough the gate
# strips it and every data call 401s while the page shell renders.
session_passthrough: true
volumes: []
+2
View File
@@ -66,6 +66,8 @@ app:
- host: 8178
container: 8080
protocol: tcp
bind: 127.0.0.1
auth: local
volumes:
# Same dir the first-boot bundled path uses + where the wallet bridge reads
+8
View File
@@ -60,9 +60,17 @@ app:
- host: 8176
container: 8176
protocol: tcp
auth: none
auth_rationale: >-
Fedimint gateway API, protected by its own bcrypt password (--bcrypt-password-hash)
and reached by federation peers and clients that cannot hold a browser session.
- host: 9737
container: 9737
protocol: tcp
auth: none
auth_rationale: >-
LDK Lightning p2p for the gateway. The BOLT-8 noise handshake authenticates and
encrypts the connection itself.
volumes:
- type: bind
+17
View File
@@ -50,14 +50,31 @@ app:
- host: 8173
container: 8173
protocol: tcp
auth: none
auth_rationale: >-
Fedimint guardian consensus. Other guardians speak the federation's own
authenticated protocol here; a login page would break consensus.
- host: 8174
container: 8174
protocol: tcp
auth: none
auth_rationale: >-
Fedimint guardian API for federation clients, which authenticate to the
federation itself and cannot hold a browser session.
# Public launch port 8175 is owned by archy-fedimint-ui, which serves a
# wait page while Bitcoin syncs and proxies here after fedimintd starts.
# 8175 is NOT declared here. It is served by the archy-fedimint-ui
# companion, a different container, and declaring it on this app made the
# orchestrator try to publish 8175 from fedimintd — colliding with the
# companion that already holds it, so start_container failed forever and
# fedimint crash-looped (100.82.34.38, 2026-08-05). The companion's nginx
# is pinned to 127.0.0.1, which is what actually closes that port; the
# gate reports it rather than fronting it.
- host: 8177
container: 8175
protocol: tcp
bind: 127.0.0.1
auth: local
volumes:
- type: bind
+2
View File
@@ -27,6 +27,8 @@ app:
- host: 8083
container: 80
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+16 -1
View File
@@ -27,7 +27,22 @@ app:
# Host networking: nginx listens on 8336 directly on the host IP and
# proxies to 127.0.0.1:5678 (the archipelago RPC). `ports:` is
# intentionally empty because host networking bypasses port mapping.
ports: []
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate had no idea
# the port existed: it was neither protected nor listed as unprotected, and
# served the FIPS mesh screen unauthenticated on every interface.
ports:
- host: 8336
container: 8336
protocol: tcp
bind: 127.0.0.1
auth: gated
# First-party companion UI: its nginx forwards the node session cookie
# to the daemon's authenticated endpoints; without passthrough the gate
# strips it and every data call 401s while the page shell renders.
session_passthrough: true
volumes: []
+2
View File
@@ -26,6 +26,8 @@ app:
- host: 3001
container: 3000
protocol: tcp
bind: 127.0.0.1
auth: gated
- host: 2222
container: 22
protocol: tcp
+2
View File
@@ -31,6 +31,8 @@ app:
- host: 3000
container: 3000
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+2
View File
@@ -30,6 +30,8 @@ app:
- host: 8123
container: 8123
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+2
View File
@@ -44,6 +44,8 @@ app:
- host: 2283
container: 2283
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+7 -1
View File
@@ -22,7 +22,13 @@ app:
memory_limit: 256Mi
security:
capabilities: [SETGID, SETUID]
# The alpine entrypoint runs as container-root, `find`s /data to chown
# anything not owned by the redis user, then su-execs to it. Under the
# orchestrator's --cap-drop=ALL, root cannot traverse the 0700
# appendonlydir owned by uid 999 without DAC_OVERRIDE (observed
# crash-looping ~4k restarts on archi-dev-box) — CHOWN is what the find's
# -exec chown needs on adopted legacy data.
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]
readonly_root: false
network_policy: isolated
+2
View File
@@ -38,6 +38,8 @@ app:
- host: 7778
container: 7777
protocol: tcp # Web UI. Port 7777 on the host is reserved for the Nostr relay.
bind: 127.0.0.1
auth: gated
# Writable scratch the baked nginx needs; matches the legacy installer's
# --tmpfs /run + /var/cache/nginx.
+2
View File
@@ -25,6 +25,8 @@ app:
- host: 8096
container: 8096
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+5
View File
@@ -41,9 +41,14 @@ app:
auth: none
auth_rationale: >-
LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.
# Mirrors lnd's 18080 exemption — same LND REST API, same macaroon auth.
- host: 8091
container: 8080
protocol: tcp # REST/Web UI
auth: none
auth_rationale: >-
LND REST, authenticated by macaroon over TLS. A browser login page would break
Zeus and every non-browser wallet client, exactly as for lnd's 18080.
volumes:
- type: bind
+16 -1
View File
@@ -35,7 +35,22 @@ app:
# port to a container port where nothing listens. scripts/container-specs.sh
# carried the identical mistake and was fixed alongside this; recreating from
# it on archi-dev-box left :18083 refusing connections.
ports: []
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate had no idea
# the port existed: it was neither protected nor listed as unprotected, and
# served the LND screen unauthenticated on every interface.
ports:
- host: 18083
container: 18083
protocol: tcp
bind: 127.0.0.1
auth: gated
# First-party companion UI: its nginx forwards the node session cookie
# to the daemon's authenticated endpoints; without passthrough the gate
# strips it and every data call 401s while the page shell renders.
session_passthrough: true
volumes: []
+2
View File
@@ -42,6 +42,8 @@ app:
- host: 8999
container: 8999
protocol: tcp
bind: 127.0.0.1
auth: local
volumes:
- type: bind
+2
View File
@@ -33,6 +33,8 @@ app:
- host: 4080
container: 8080 # mempool-frontend nginx listens on 8080 (FRONTEND_HTTP_PORT=8080)
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+2
View File
@@ -30,6 +30,8 @@ app:
- host: 8089
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+5
View File
@@ -48,6 +48,11 @@ app:
- host: 8086
container: 80
protocol: tcp # management API + embedded OIDC issuer (/oauth2)
auth: none
auth_rationale: >-
NetBird management API and its OIDC issuer. Enrolled devices authenticate
themselves with setup keys and JWTs, and they cannot hold a browser session —
a login page here would disconnect every VPN client on the network.
- host: 3478
container: 3478
protocol: udp # STUN — must be UDP; tcp here breaks relay discovery
+5
View File
@@ -44,6 +44,11 @@ app:
- host: 8087
container: 443
protocol: tcp
auth: none
auth_rationale: >-
NetBird dashboard over TLS, with its own login. The gate speaks plain HTTP,
so fronting this port would break the secure context the dashboard requires
(issue #15) and the certificate clients pin.
volumes:
- type: bind
+2
View File
@@ -25,6 +25,8 @@ app:
- host: 8085
container: 80
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+2
View File
@@ -31,6 +31,8 @@ app:
- host: 18081
container: 8080
protocol: tcp # HTTP/WebSocket
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+2
View File
@@ -24,6 +24,8 @@ app:
- host: 2342
container: 2342
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+7
View File
@@ -53,9 +53,16 @@ app:
- host: 10380
container: 80
protocol: tcp
bind: 127.0.0.1
auth: gated
- host: 10381
container: 443
protocol: tcp
auth: none
auth_rationale: >-
Pine's TLS listener. The gate speaks plain HTTP, so fronting this port would
break the secure context navigator.bluetooth needs for WiFi provisioning.
The plain-HTTP entry point (10380) is gated, and it is what the UI opens.
volumes:
- type: bind
+3 -1
View File
@@ -6,7 +6,7 @@ app:
category: development
container:
image: 146.59.87.168:3000/lfg2025/portainer:2.19.4
image: 146.59.87.168:3000/lfg2025/portainer:2.39.1
pull_policy: if-not-present
data_uid: "1000:1000"
@@ -27,6 +27,8 @@ app:
- host: 9000
container: 9000
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+2
View File
@@ -30,6 +30,8 @@ app:
- host: 8084
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
- host: 5353
container: 5353
protocol: udp # mDNS/Bonjour
+2
View File
@@ -29,6 +29,8 @@ app:
- host: 8888
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+2
View File
@@ -29,6 +29,8 @@ app:
- host: 8090
container: 7777
protocol: tcp # HTTP/WebSocket (strfry listens on 7777)
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+2
View File
@@ -26,6 +26,8 @@ app:
- host: 3002
container: 3001
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+2
View File
@@ -25,6 +25,8 @@ app:
- host: 8082
container: 80
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.120-alpha"
version = "1.7.125-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.121-alpha"
version = "1.7.125-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -472,6 +472,8 @@ impl RpcHandler {
"system.disk-cleanup" => self.handle_system_disk_cleanup().await,
"system.reboot" => self.handle_system_reboot(params).await,
"system.factory-reset" => self.handle_system_factory_reset(params).await,
"auth.session-policy.get" => self.handle_session_policy_get().await,
"auth.session-policy.set" => self.handle_session_policy_set(params).await,
"system.settings.get" => self.handle_system_settings_get(params).await,
"system.settings.set" => self.handle_system_settings_set(params).await,
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
@@ -192,6 +192,19 @@ impl RpcHandler {
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
// LND's sweep refusal reads like a debug dump ("insufficient
// input to create sweep tx: input_sum=0 BTC, output_sum=…").
// input_sum=0 with a tiny output means the wallet's coins are
// unconfirmed or below Bitcoin's dust minimum — say that
// (framework-pt sweep of 92 sats, 2026-08-06).
if msg.contains("insufficient input to create sweep tx") {
return Err(anyhow::anyhow!(
"Failed to send: your on-chain balance is too small or still \
unconfirmed to sweep. Bitcoin cannot build a transaction from \
coins below the dust minimum (~546 sats) or from funds that \
have not confirmed yet. (LND: {msg})"
));
}
return Err(anyhow::anyhow!("Failed to send: {}", msg));
}
@@ -79,6 +79,11 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
// them in the first place (ecash send, 2026-07-22).
"Insufficient balance",
"Insufficient funds",
// On-chain send/sweep refusals from LND ("Failed to send: your
// on-chain balance is too small or still unconfirmed to sweep…").
// Masking sent the operator to journalctl again (framework-pt
// sweep, 2026-08-06) — same lesson as the two above.
"Failed to send",
// Lightning payment failures carry LND's reason ("invoice expired.
// Valid until …", "no route", …) — the user can act on every one of
// them, and masking sent the operator to journalctl (invoice-expired
+54 -195
View File
@@ -307,19 +307,24 @@ impl RpcHandler {
let deps = self.gate_install_deps(package_id).await?;
check_bitcoin_pruning_compatibility(package_id).await?;
log_optional_dep_info(package_id, &deps);
let repaired_bitcoin_conf =
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
// Materialise the RPC password file before any install path
// runs. The orchestrator path resolves secret_env from
// /var/lib/archipelago/secrets/bitcoin-rpc-password at start
// time; if the file is missing, bitcoind exits within ms.
// bitcoin_rpc_credentials() generates + persists on first
// call (OnceCell-cached), so this is idempotent.
let _ = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
ensure_bitcoin_rpc_config().await?
} else {
false
};
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
// Materialise the RPC password file before any install path
// runs. The orchestrator path resolves secret_env from
// /var/lib/archipelago/secrets/bitcoin-rpc-password at start
// time; if the file is missing, bitcoind exits within ms.
// bitcoin_rpc_credentials() generates + persists on first
// call (OnceCell-cached), so this is idempotent.
let _ = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
// A stale datadir bitcoin.conf from an older install conflicts
// with the container's -conf=/tmp/rpc.conf launch (see
// apps/bitcoin-core & bitcoin-knots manifest.yml) and makes
// Bitcoin Core refuse to start at all. Clear it before
// (re)install. Unlike the old bind-setting "repair" this was
// replacing, it never requires restarting an already-running
// container — bitcoind doesn't read this file, so removing it
// changes nothing at runtime.
remove_stale_bitcoin_conf().await?;
}
// For orchestrator-managed apps, skip the legacy "container exists →
// adopt + return" probe entirely. The orchestrator's own install path
@@ -389,37 +394,7 @@ impl RpcHandler {
.trim()
.to_string();
if state == "running" && repaired_bitcoin_conf {
info!(
"Restarting existing container {} after bitcoin.conf RPC repair",
package_id
);
let restart_output = tokio::process::Command::new("podman")
.args(["restart", package_id])
.output()
.await
.context(
"Failed to restart existing container after bitcoin.conf repair",
)?;
if !restart_output.status.success() {
let stderr = String::from_utf8_lossy(&restart_output.stderr);
install_log(&format!(
"INSTALL ADOPT FAIL: {} - restart after RPC repair failed: {}",
package_id, stderr
))
.await;
return Err(anyhow::anyhow!(
"Container {} exists but failed to restart after RPC repair: {}",
package_id,
stderr
));
}
let _ = tokio::process::Command::new("podman")
.args(["restart", "archy-bitcoin-ui"])
.output()
.await;
wait_for_adopted_container(package_id, package_id).await?;
} else if state != "running" {
if state != "running" {
// Start the stopped/exited container
info!("Starting existing container {} (was {})", package_id, state);
let start_output = tokio::process::Command::new("podman")
@@ -715,9 +690,13 @@ impl RpcHandler {
}
}
// Pre-install: write config files BEFORE chown (dir is still owned by archipelago user)
// Pre-install: clear a stale datadir bitcoin.conf BEFORE chown (dir is
// still owned by archipelago user). bitcoind is launched with
// -conf=/tmp/rpc.conf (see apps/bitcoin-core & bitcoin-knots
// manifest.yml) and never reads a datadir bitcoin.conf — if one
// exists, Bitcoin Core's own safety check refuses to start at all.
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await?;
remove_stale_bitcoin_conf().await?;
}
if package_id == "lnd" {
@@ -1435,101 +1414,13 @@ impl RpcHandler {
}
}
/// Write bitcoin.conf with rpcauth (salted HMAC hash, no plaintext password).
async fn write_bitcoin_conf(&self, rpc_user: &str, rpc_pass: &str) -> Result<()> {
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
// Idempotent: once bitcoin-knots (or a prior install) has started,
// the data dir is chowned into the container's user namespace
// (e.g. UID 100100 on the host) with 700 perms — the archipelago
// daemon can no longer stat or write there. Treat any non-NotFound
// error on the conf as "conf already provisioned by the container
// user" and skip. Matches the lnd.conf behavior below.
match tokio::fs::metadata(&conf_path).await {
Ok(_) => {
ensure_bitcoin_rpc_config().await?;
info!("bitcoin.conf already exists, ensured Bitcoin RPC config");
return Ok(());
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => {
ensure_bitcoin_rpc_config().await?;
info!("bitcoin.conf path inaccessible, ensured Bitcoin RPC config via host helper");
return Ok(());
}
}
use hmac::{Hmac, Mac};
use sha2::Sha256;
// KEY-05: the salt is half of the stored `rpcauth=` credential line, so
// source named and draw guarded.
let mut salt_bytes = [0u8; 16];
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut salt_bytes).map_err(|e| {
anyhow::anyhow!("Refusing to build an rpcauth line from degenerate salt entropy: {e}")
})?;
let salt_hex = hex::encode(salt_bytes);
let mut mac = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes())
.expect("HMAC accepts any key length");
mac.update(rpc_pass.as_bytes());
let hash_hex = hex::encode(mac.finalize().into_bytes());
let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex);
// Default to full archive — operators with 2TB+ drives shouldn't be
// silently pruned down to 550 MB. Users who want a pruned node can
// set `prune=N` in bitcoin.conf themselves after install.
//
// printtoconsole=0: bitcoind already writes debug.log in the datadir
// (self-shrunk on restart); duplicating it to stdout pushed every IBD
// "UpdateTip" line through conmon into journald (>1 GB/day). Deep
// debugging uses /var/lib/archipelago/bitcoin/debug.log.
// rpcbind=0.0.0.0 is REQUIRED inside a container: with rpcallowip set
// but no rpcbind, bitcoind binds RPC to 127.0.0.1 in the container
// netns only — LND / the Bitcoin UI dialing bitcoin-knots:8332 over
// the bridge get connection refused (fresh-install LND crash-loop +
// bitcoin-rpc 502, seen on the 1.7.99 ISO). The port publish stays
// 127.0.0.1-only on the host, so exposure is unchanged.
// Prune sized to the data volume. A full archive needs ~810 GB and
// grows; silently writing an unpruned config onto a small disk fills
// it mid-IBD (framework node 2026-07-14: unpruned mainnet on a 205 GB
// volume). Volumes with real archival headroom (≥1.2 TB) stay full
// archive; smaller ones get prune = 25% of the volume, clamped to
// [550 MB, 100 GB], leaving room for LND/apps sharing the disk.
let prune_line = match bitcoin_data_volume_gb().await {
Some(total_gb) if total_gb > 0 && total_gb < 1200 => {
let prune_mb = ((total_gb as f64 * 0.25 * 1024.0) as u64).clamp(550, 100_000);
info!(
volume_gb = total_gb,
prune_mb, "Data volume below archival size — enabling sized bitcoin prune"
);
format!("prune={}\n", prune_mb)
}
_ => String::new(),
};
let bitcoin_conf = format!(
"\
# rpcauth: salted hash only - no plaintext password in config or CLI\n\
{}\n\
server=1\n\
rpcbind=0.0.0.0\n\
rpcallowip=0.0.0.0/0\n\
listen=1\n\
rpcthreads=16\n\
rpcworkqueue=256\n\
printtoconsole=0\n\
{}",
rpcauth_line, prune_line
);
tokio::fs::create_dir_all(bitcoin_dir)
.await
.context("Failed to create bitcoin data directory")?;
tokio::fs::write(&conf_path, bitcoin_conf)
.await
.context("Failed to write bitcoin.conf")?;
info!("Created bitcoin.conf with rpcauth (no plaintext credentials)");
Ok(())
}
// write_bitcoin_conf removed: bitcoind is launched with -conf=/tmp/rpc.conf
// (see apps/bitcoin-core & bitcoin-knots manifest.yml, commit a597c1d9)
// and never reads a datadir bitcoin.conf. Writing one here created a
// fatal "-conf vs default bitcoin.conf" conflict on every subsequent
// start (Bitcoin Core's own datadir-conflict safety check). See
// `remove_stale_bitcoin_conf` below, which replaces both this and
// `ensure_bitcoin_rpc_config`.
/// Write LND config file with Bitcoin RPC credentials.
async fn write_lnd_conf(&self, rpc_user: &str, rpc_pass: &str) -> Result<()> {
@@ -2624,28 +2515,12 @@ async fn wait_for_adopted_container(package_id: &str, container_name: &str) -> R
))
}
/// Total size (GB) of the filesystem holding the bitcoin data dir, via
/// `df -k`. None when df fails (containers, exotic mounts) — callers treat
/// unknown as "don't prune" to preserve archival defaults on big iron.
async fn bitcoin_data_volume_gb() -> Option<u64> {
let target = if std::path::Path::new("/var/lib/archipelago").exists() {
"/var/lib/archipelago"
} else {
"/"
};
let output = tokio::process::Command::new("df")
.args(["-k", target])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.lines().nth(1)?;
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
Some(kb / 1024 / 1024)
}
// bitcoin_data_volume_gb removed with write_bitcoin_conf: it only fed that
// function's volume-aware `prune=` line, which bitcoind never read either
// (see remove_stale_bitcoin_conf). The manifest's shell entrypoint already
// computes DISK_GB_VALUE and hardcodes -prune=550 on small volumes — a
// real volume-aware prune fix belongs there, not in a conf file nothing
// reads. Tracked as follow-up in bitcoin-conf-crash-patch.md.
/// One-shot probe: does bitcoind answer an authenticated getblockchaininfo?
/// Works during IBD (the call answers with progress while syncing). Goes via
@@ -2723,52 +2598,36 @@ async fn wait_for_bitcoin_rpc_gate(package_id: &str) -> Result<()> {
Ok(())
}
async fn ensure_bitcoin_rpc_config() -> Result<bool> {
/// bitcoind reads only `/tmp/rpc.conf` + CLI args at container start (see
/// apps/bitcoin-core & bitcoin-knots manifest.yml, commit a597c1d9) — it
/// never reads a datadir bitcoin.conf. A leftover file from an older install
/// (or a manual edit) makes Bitcoin Core's own datadir-conflict safety check
/// refuse to start ("-conf=... vs default bitcoin.conf"). Remove it — via
/// the same host-privileged path the old writer/repairer used, since the
/// dir may already be chowned into the container's UID namespace by a
/// previous start — instead of "repairing" it into existence.
async fn remove_stale_bitcoin_conf() -> Result<bool> {
let script = r#"
set -eu
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
[ -f "$conf" ] || exit 0
changed=0
tmp=$(mktemp)
awk -F= '
/^(server|txindex|rpcbind|rpcallowip|rpcport|listen|bind|dbcache|rpcthreads|rpcworkqueue)=/ {
if (seen[$1]++) next
}
{ print }
' "$conf" > "$tmp"
if ! cmp -s "$conf" "$tmp"; then
cat "$tmp" > "$conf"
changed=1
fi
rm -f "$tmp"
ensure_line() {
line="$1"
key="${line%%=*}"
if ! grep -q "^${key}=" "$conf"; then
printf '%s\n' "$line" >> "$conf"
changed=1
fi
}
ensure_line server=1
ensure_line rpcbind=0.0.0.0
ensure_line rpcallowip=0.0.0.0/0
ensure_line listen=1
ensure_line rpcthreads=16
ensure_line rpcworkqueue=256
[ "$changed" -eq 0 ] && exit 0
mv "$conf" "$conf.disabled-$(date +%s)"
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
.await
.context("ensure bitcoin.conf RPC bind settings")?;
.context("remove stale bitcoin.conf")?;
match status.code() {
Some(0) => Ok(false),
Some(2) => {
install_log("INSTALL REPAIR: bitcoin.conf RPC bind settings added").await;
install_log(
"INSTALL REPAIR: removed stale bitcoin.conf (conflicts with -conf=/tmp/rpc.conf launch)",
)
.await;
Ok(true)
}
_ => Err(anyhow::anyhow!(
"bitcoin.conf RPC repair helper exited with {}",
"bitcoin.conf removal helper exited with {}",
status
)),
}
@@ -1011,6 +1011,59 @@ impl RpcHandler {
}
}
/// auth.session-policy.get — how long a login lasts on this node.
pub(in crate::api::rpc) async fn handle_session_policy_get(&self) -> Result<serde_json::Value> {
let policy = crate::settings::session_policy::load(&self.config.data_dir).await;
Ok(serde_json::json!({
"idle_timeout_secs": policy.idle_timeout_secs,
"absolute_timeout_secs": policy.absolute_timeout_secs,
"reauth_for_funds": policy.reauth_for_funds,
}))
}
/// auth.session-policy.set — change it.
///
/// Values are clamped rather than rejected: the caller learns what was
/// actually stored from the reply, which is friendlier than an error and
/// makes the bounds discoverable. Fields are individually optional so the
/// UI can change one control without having to send the others back.
pub(in crate::api::rpc) async fn handle_session_policy_set(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or(serde_json::json!({}));
let current = crate::settings::session_policy::load(&self.config.data_dir).await;
let policy = crate::settings::session_policy::SessionPolicy {
idle_timeout_secs: params
.get("idle_timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(current.idle_timeout_secs),
absolute_timeout_secs: match params.get("absolute_timeout_secs") {
// Explicit null means "no absolute cap", which is different
// from the field being absent (leave it as it is).
Some(serde_json::Value::Null) => None,
Some(v) => v.as_u64().or(current.absolute_timeout_secs),
None => current.absolute_timeout_secs,
},
reauth_for_funds: params
.get("reauth_for_funds")
.and_then(|v| v.as_bool())
.unwrap_or(current.reauth_for_funds),
};
let saved = crate::settings::session_policy::save(&self.config.data_dir, policy).await?;
tracing::info!(
idle = saved.idle_timeout_secs,
absolute = ?saved.absolute_timeout_secs,
reauth_for_funds = saved.reauth_for_funds,
"session policy updated"
);
Ok(serde_json::json!({
"idle_timeout_secs": saved.idle_timeout_secs,
"absolute_timeout_secs": saved.absolute_timeout_secs,
"reauth_for_funds": saved.reauth_for_funds,
}))
}
/// system.settings.set — Write a settings value
pub(in crate::api::rpc) async fn handle_system_settings_set(
&self,
+59 -5
View File
@@ -222,6 +222,19 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re
lines.push("# ControlPort disabled for security".to_string());
lines.push(String::new());
// Ports whose manifests declare `auth: gated` forward to the gate's own
// loopback (127.0.0.2, where the app-gate listener binds — see
// `appgate::listener::GATE_TOR_UPSTREAM`) instead of the app's 127.0.0.1.
// Tor carries no session cookie, so an onion pointed at the app is an
// unauthenticated bypass of the gate. Declared-gated ports only: an
// undeclared port keeps today's target, because absence of the field is
// not an instruction (the v1.7.121 incident rule).
let gated_ports: std::collections::HashSet<u16> = crate::appgate::identity::build_port_map()
.gated_ports()
.filter(|g| g.declared)
.map(|g| g.port)
.collect();
for svc in &config.services {
if !svc.enabled {
continue;
@@ -240,7 +253,7 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re
lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string());
}
} else {
lines.push(format!("HiddenServicePort 80 127.0.0.1:{}", svc.local_port));
lines.push(app_hidden_service_port_line(svc.local_port, &gated_ports));
}
lines.push(String::new());
@@ -248,6 +261,24 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re
let content = lines.join("\n");
let staging = "/var/lib/archipelago/tor-config/torrc.staged";
write_staged_torrc(&content, staging).await
}
/// The `HiddenServicePort` line for an HTTP app onion. Gated ports forward to
/// the gate's Tor upstream; everything else to the app itself.
fn app_hidden_service_port_line(
local_port: u16,
gated_ports: &std::collections::HashSet<u16>,
) -> String {
let upstream = if gated_ports.contains(&local_port) {
crate::appgate::listener::GATE_TOR_UPSTREAM.to_string()
} else {
"127.0.0.1".to_string()
};
format!("HiddenServicePort 80 {}:{}", upstream, local_port)
}
async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
let config_dir = Path::new(staging)
.parent()
.unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
@@ -256,14 +287,37 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re
.await
.context("Failed to write staged torrc")?;
debug!(
"Staged torrc with {} enabled services",
config.services.iter().filter(|s| s.enabled).count()
);
debug!("Staged torrc ({} bytes)", content.len());
Ok(())
}
#[cfg(test)]
mod torrc_tests {
use super::app_hidden_service_port_line;
use std::collections::HashSet;
#[test]
fn gated_port_forwards_to_the_gate_not_the_app() {
let gated: HashSet<u16> = [8082u16].into_iter().collect();
assert_eq!(
app_hidden_service_port_line(8082, &gated),
"HiddenServicePort 80 127.0.0.2:8082"
);
}
#[test]
fn undeclared_port_keeps_the_app_loopback_target() {
// Absence of `auth: gated` is not an instruction — the onion keeps
// pointing at the app, exactly as before this change.
let gated: HashSet<u16> = [8082u16].into_iter().collect();
assert_eq!(
app_hidden_service_port_line(9100, &gated),
"HiddenServicePort 80 127.0.0.1:9100"
);
}
}
// ─── Hostname Sync ───────────────────────────────────────────────
pub(in crate::api::rpc) async fn sync_single_hostname(name: &str, address: &str) {
+2 -1
View File
@@ -75,7 +75,8 @@ pub fn address_caching_dependents(package_id: &str) -> &'static [&'static str] {
/// The package whose lifecycle lock covers `app_id`: the stack package when
/// `app_id` is a member (RPC ops on "mempool" hold the "mempool" lock while
/// they drive archy-mempool-web), otherwise the app itself.
fn owning_package(app_id: &str) -> &str {
/// Also consulted by the reconciler's absent-stack-member recovery.
pub fn owning_package(app_id: &str) -> &str {
const STACKS: &[&str] = &[
"immich",
"indeedhub",
+247 -100
View File
@@ -26,6 +26,20 @@ pub struct GatedPort {
pub app_name: String,
/// Manifest-declared icon path (`metadata.icon`), when present.
pub icon: Option<String>,
/// True only when the manifest says `auth: gated` in so many words.
///
/// The gated set deliberately also carries undeclared Session-default
/// ports (so the gate challenges them wherever it can already stand, and
/// the audit reports them). But everything that CHANGES where traffic
/// goes — the torrc repoint to 127.0.0.2, the FIPS relay stand-down, the
/// Tor-upstream bind — must key on this flag: acting on an undeclared
/// port is the v1.7.121 incident class, whatever the action.
pub declared: bool,
/// Manifest opt-in (`session_passthrough: true` on the port): forward the
/// node session cookie to the app on authorised requests. First-party
/// companion UIs proxy that cookie to the daemon's authenticated
/// endpoints; for every other app the gate strips its own credential.
pub session_passthrough: bool,
}
/// A port deliberately left unauthenticated, and the manifest's stated reason.
@@ -48,6 +62,7 @@ pub struct ExemptPort {
pub struct PortMap {
gated: HashMap<u16, GatedPort>,
exempt: Vec<ExemptPort>,
local: std::collections::HashSet<u16>,
}
impl PortMap {
@@ -64,8 +79,21 @@ impl PortMap {
&self.exempt
}
/// Declared `auth: local` — host-local by intent, so NOTHING may make it
/// externally reachable.
///
/// The gate honours this by keeping its hands off, but it is not the only
/// thing that can publish a port: the FIPS mesh relay bridges the fips0
/// ULA to `127.0.0.1` for a static port list, and it forwarded nbxplorer
/// 32838 — declared `local` and pinned to loopback — to the mesh
/// unauthenticated (archi-dev-box 2026-08-04). Anything that republishes
/// a loopback port must consult this set first.
pub fn is_declared_local(&self, port: u16) -> bool {
self.local.contains(&port)
}
pub fn is_empty(&self) -> bool {
self.gated.is_empty() && self.exempt.is_empty()
self.gated.is_empty() && self.exempt.is_empty() && self.local.is_empty()
}
}
@@ -101,13 +129,35 @@ fn manifest_icon(manifest: &AppManifest) -> Option<String> {
/// Classify every published port across all installed manifests.
///
/// The first directory that yields a manifest for an app id wins, so a node's
/// `/opt/archipelago/apps` copy shadows a repo checkout rather than merging
/// with it — otherwise a stale checked-out manifest could re-open a port the
/// installed one gates.
/// The signed catalog's embedded manifests are consulted FIRST, because they
/// are what the orchestrator actually publishes containers from
/// (origin-wins; see `app_catalog::catalog_manifest_overlay`). Classifying
/// from disk alone made the gate act on policy the node was no longer
/// running: the catalog declared nbxplorer `auth: local` and pinned it to
/// loopback, the stale disk manifest declared nothing, and the gate
/// externally bound a deliberately host-local port (archi-dev-box
/// 2026-08-04).
///
/// After the catalog, the first directory that yields a manifest for an app
/// id wins, so a node's `/opt/archipelago/apps` copy shadows a repo checkout
/// rather than merging with it — otherwise a stale checked-out manifest could
/// re-open a port the installed one gates.
pub fn build_port_map() -> PortMap {
let mut map = PortMap::default();
let mut seen_apps: HashMap<String, PathBuf> = HashMap::new();
let mut seen_apps: std::collections::HashSet<String> = std::collections::HashSet::new();
for (app_id, value) in crate::container::app_catalog::catalog_manifest_values() {
let Some(manifest) =
crate::container::app_catalog::catalog_manifest_overlay(&app_id, value)
else {
// Unparseable/invalid/build-source → the orchestrator falls back
// to disk for this app, so classification must too.
continue;
};
if seen_apps.insert(app_id) {
classify_manifest(&manifest, &mut map);
}
}
for dir in apps_dirs() {
let Ok(entries) = std::fs::read_dir(&dir) else {
@@ -124,100 +174,8 @@ pub fn build_port_map() -> PortMap {
// would have published.
continue;
};
let app_id = manifest.app.id.clone();
if seen_apps.contains_key(&app_id) {
continue;
}
seen_apps.insert(app_id.clone(), path);
let icon = manifest_icon(&manifest);
let app_name = if manifest.app.name.trim().is_empty() {
app_id.clone()
} else {
manifest.app.name.clone()
};
for port in &manifest.app.ports {
let protocol = if port.protocol.is_empty() {
"tcp"
} else {
port.protocol.as_str()
};
match port.auth_policy() {
PortAuth::None => map.exempt.push(ExemptPort {
port: port.host,
app_id: app_id.clone(),
rationale: port
.auth_rationale
.clone()
.unwrap_or_else(|| "(no rationale recorded)".to_string()),
protocol: protocol.to_string(),
}),
// Declared host-local. Not gated and not reported as
// exposed, because it is neither — see PortAuth::Local
// for why this cannot be inferred from `bind`.
PortAuth::Local => {}
// Explicit opt-in: the app is on loopback and the daemon
// owns the external addresses. This is the ONLY way a
// port gets bound by the gate, regardless of `bind`.
PortAuth::Gated => {
map.gated.insert(
port.host,
GatedPort {
port: port.host,
app_id: app_id.clone(),
app_name: app_name.clone(),
icon: icon.clone(),
},
);
}
PortAuth::Session => {
// UDP cannot carry an HTTP challenge. Such a port has
// no business defaulting into the gated set where it
// would look protected without being protectable —
// surface it as an unrationalised exemption instead,
// which is honest and shows up in the audit list.
if protocol != "tcp" {
map.exempt.push(ExemptPort {
port: port.host,
app_id: app_id.clone(),
rationale: format!(
"{protocol} cannot carry an HTTP challenge; declare auth: none \
with a rationale to record why this is safe"
),
protocol: protocol.to_string(),
});
continue;
}
// A loopback publish is skipped, and this is the
// safety property of the whole module: the gate must
// never be the reason a port becomes reachable
// somewhere it was not. `session` is the DEFAULT, so
// it is what every un-migrated manifest carries —
// and a node's installed manifests always lag the
// repo. Binding those externally published Bitcoin
// RPC across the LAN within seconds of deploy
// (archi-dev-box 2026-08-03). Taking over a port is
// opt-in only: `auth: gated`, shipped in the same
// manifest edit as the loopback pin.
if port
.bind
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
{
continue;
}
map.gated.insert(
port.host,
GatedPort {
port: port.host,
app_id: app_id.clone(),
app_name: app_name.clone(),
icon: icon.clone(),
},
);
}
}
if seen_apps.insert(manifest.app.id.clone()) {
classify_manifest(&manifest, &mut map);
}
}
}
@@ -226,6 +184,111 @@ pub fn build_port_map() -> PortMap {
map
}
/// Classify one manifest's ports into the map. Split from [`build_port_map`]
/// so the catalog-overlay pass and the disk pass cannot diverge.
fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
let app_id = manifest.app.id.clone();
let icon = manifest_icon(manifest);
let app_name = if manifest.app.name.trim().is_empty() {
app_id.clone()
} else {
manifest.app.name.clone()
};
for port in &manifest.app.ports {
let protocol = if port.protocol.is_empty() {
"tcp"
} else {
port.protocol.as_str()
};
match port.auth_policy() {
PortAuth::None => map.exempt.push(ExemptPort {
port: port.host,
app_id: app_id.clone(),
rationale: port
.auth_rationale
.clone()
.unwrap_or_else(|| "(no rationale recorded)".to_string()),
protocol: protocol.to_string(),
}),
// Declared host-local. Not gated and not reported as
// exposed, because it is neither — see PortAuth::Local
// for why this cannot be inferred from `bind`. Recorded so
// the mesh relay (and any future republisher) can refuse to
// expose it.
PortAuth::Local => {
map.local.insert(port.host);
}
// Explicit opt-in: the app is on loopback and the daemon
// owns the external addresses. This is the ONLY way a
// port gets bound by the gate, regardless of `bind`.
PortAuth::Gated => {
map.gated.insert(
port.host,
GatedPort {
port: port.host,
app_id: app_id.clone(),
app_name: app_name.clone(),
icon: icon.clone(),
declared: true,
session_passthrough: port.session_passthrough,
},
);
}
PortAuth::Session => {
// UDP cannot carry an HTTP challenge. Such a port has
// no business defaulting into the gated set where it
// would look protected without being protectable —
// surface it as an unrationalised exemption instead,
// which is honest and shows up in the audit list.
if protocol != "tcp" {
map.exempt.push(ExemptPort {
port: port.host,
app_id: app_id.clone(),
rationale: format!(
"{protocol} cannot carry an HTTP challenge; declare auth: none \
with a rationale to record why this is safe"
),
protocol: protocol.to_string(),
});
continue;
}
// A loopback publish is skipped, and this is the
// safety property of the whole module: the gate must
// never be the reason a port becomes reachable
// somewhere it was not. `session` is the DEFAULT, so
// it is what every un-migrated manifest carries —
// and a node's installed manifests always lag the
// repo. Binding those externally published Bitcoin
// RPC across the LAN within seconds of deploy
// (archi-dev-box 2026-08-03). Taking over a port is
// opt-in only: `auth: gated`, shipped in the same
// manifest edit as the loopback pin.
if port
.bind
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
{
continue;
}
map.gated.insert(
port.host,
GatedPort {
port: port.host,
app_id: app_id.clone(),
app_name: app_name.clone(),
icon: icon.clone(),
declared: false,
// An undeclared port never gets the node session —
// passthrough is an explicit manifest opt-in only.
session_passthrough: false,
},
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -257,6 +320,90 @@ mod tests {
}
}
fn manifest(yaml: &str) -> AppManifest {
AppManifest::parse(yaml).expect("test manifest must parse")
}
const BASE: &str = r#"
app:
id: testapp
name: Test App
version: "1.0"
container:
image: example.org/testapp:1.0
"#;
/// `auth: gated` is the only classification allowed to redirect traffic —
/// torrc repoints, relay stand-down, and the 127.0.0.2 bind all key on
/// `declared`. An undeclared Session port is challenged and audited but
/// must never be `declared`.
#[test]
fn declared_tracks_the_manifest_not_the_default() {
let mut map = PortMap::default();
classify_manifest(
&manifest(&format!(
"{BASE} ports:\n - host: 8090\n container: 7777\n protocol: tcp\n bind: 127.0.0.1\n auth: gated\n"
)),
&mut map,
);
assert!(map.gated(8090).expect("gated").declared);
let mut map = PortMap::default();
classify_manifest(
&manifest(&format!(
"{BASE} ports:\n - host: 9100\n container: 9100\n protocol: tcp\n"
)),
&mut map,
);
let undeclared = map.gated(9100).expect("session default is challenged");
assert!(
!undeclared.declared,
"an absent auth field must never read as an instruction"
);
}
/// `auth: local` keeps the gate's hands off entirely — the port is
/// neither gated nor exempt-reported — but it IS recorded, so the mesh
/// relay can refuse to republish a deliberately host-local port.
#[test]
fn local_ports_are_untouched_but_recorded() {
let mut map = PortMap::default();
classify_manifest(
&manifest(&format!(
"{BASE} ports:\n - host: 32838\n container: 32838\n protocol: tcp\n bind: 127.0.0.1\n auth: local\n"
)),
&mut map,
);
assert!(map.gated(32838).is_none());
assert!(map.exempt_ports().is_empty());
assert!(
map.is_declared_local(32838),
"the mesh relay needs this to refuse bridging a host-local port"
);
assert!(!map.is_declared_local(3000));
}
/// The real corpus: every port the FIPS relay can bridge must be safe to
/// bridge. A port that is declared `local` (host-local by intent) or
/// declared `gated` (the app gate owns its external addresses) must be
/// withheld by the relay — this asserts the two sets the relay consults
/// actually classify the live manifests, so a future manifest edit that
/// re-opens one is caught here rather than on a node.
#[test]
fn relay_port_list_respects_local_and_gated_declarations() {
let map = build_port_map();
let relay_would_expose: Vec<u16> = crate::fips::app_ports::APP_LAUNCH_PORTS
.iter()
.copied()
.filter(|p| map.is_declared_local(*p))
.collect();
assert!(
!relay_would_expose.is_empty(),
"expected the corpus to contain at least one local port in the relay list \
(32838/8999) if this fails the guard is untested, not unnecessary"
);
}
/// Protocol ports that wallets dial directly must never end up gated —
/// this is the constraint that decided the design (Zeus and electrum
/// clients keep working untouched).
+73 -7
View File
@@ -44,6 +44,15 @@ use tracing::{debug, info, warn};
/// apps are installed while the daemon runs.
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
/// The gate's own loopback address, distinct from the app's `127.0.0.1`.
///
/// Tor cannot present a session cookie, so `HiddenServicePort → 127.0.0.1`
/// reaches the app around the gate. Instead torrc forwards gated ports to
/// this address (`api/rpc/tor`), where the gate — not the app — listens. A
/// second loopback address rather than a second port number, so no app needs
/// a port it did not declare.
pub const GATE_TOR_UPSTREAM: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 2));
/// A port the gate should own but could not claim, and why.
#[derive(Debug, Clone, serde::Serialize)]
pub struct UnprotectedPort {
@@ -142,8 +151,12 @@ pub async fn run(
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
// (port, addr) pairs already served, so a sweep does not rebind what it
// already holds.
let mut held: HashMap<(u16, IpAddr), ()> = HashMap::new();
// already holds. The accept-loop handle is kept so a claim can be
// RELEASED when its port leaves the gated set — a catalog refresh
// declaring a port `local`/`none` must make the gate let go without a
// daemon restart, or the stale bind keeps republishing a port the
// catalog just withdrew (nbxplorer 32838, archi-dev-box 2026-08-04).
let mut held: HashMap<(u16, IpAddr), tokio::task::JoinHandle<()>> = HashMap::new();
let mut interval = tokio::time::interval(SWEEP_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
@@ -160,7 +173,7 @@ pub async fn run(
async fn sweep(
gate: &Arc<AppGate>,
status: &Arc<RwLock<GateStatus>>,
held: &mut HashMap<(u16, IpAddr), ()>,
held: &mut HashMap<(u16, IpAddr), tokio::task::JoinHandle<()>>,
shutdown_rx: &tokio::sync::watch::Receiver<bool>,
) {
// Re-read the manifests every sweep rather than trusting the map built
@@ -170,6 +183,23 @@ async fn sweep(
// enforced while serving a brand-new app to anyone who asked.
gate.refresh().await;
let port_map = gate.port_map().await;
// Release claims whose port left the gated set (or whose Tor-upstream
// claim lost its declaration). Aborting the accept loop drops the
// listener, freeing the address for whoever now legitimately owns it —
// the app itself, or nobody.
held.retain(|(port, addr), handle| {
let keep = match port_map.gated(*port) {
None => false,
Some(app) => *addr != GATE_TOR_UPSTREAM || app.declared,
};
if !keep {
handle.abort();
info!(port, %addr, "app gate released a claim: port is no longer gated here");
}
keep
});
let addresses = host_addresses().await;
if addresses.is_empty() {
debug!("app gate: no external addresses yet");
@@ -192,6 +222,10 @@ async fn sweep(
let mut claimed_any = false;
let mut blocked = false;
// External addresses first, then the gate's Tor upstream. 127.0.0.2
// deliberately does NOT count toward `claimed_any`: the warning below
// is about external exposure, and a port whose only claim is the Tor
// loopback is still wide open on the LAN.
for &addr in &addresses {
let key = (app.port, addr);
if held.contains_key(&key) {
@@ -201,19 +235,48 @@ async fn sweep(
}
match TcpListener::bind(SocketAddr::new(addr, app.port)).await {
Ok(listener) => {
held.insert(key, ());
let handle =
spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone());
held.insert(key, handle);
claimed.push((app.port, addr.to_string()));
claimed_any = true;
info!(
port = app.port, %addr, app = %app.app_id,
"app gate claimed an app port"
);
spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone());
}
// Almost always the app itself holding 0.0.0.0:<port>.
Err(_) => blocked = true,
}
}
// The Tor upstream is bound for DECLARED gated ports only: torrc only
// repoints an onion at 127.0.0.2 for a declared port, and standing a
// challenge on an undeclared port's would-be upstream would change
// where its traffic goes on nothing but a default.
if app.declared {
let tor_key = (app.port, GATE_TOR_UPSTREAM);
if held.contains_key(&tor_key) {
claimed.push((app.port, GATE_TOR_UPSTREAM.to_string()));
} else {
match TcpListener::bind(SocketAddr::new(GATE_TOR_UPSTREAM, app.port)).await {
Ok(listener) => {
let handle = spawn_accept_loop(
listener,
gate.clone(),
app.clone(),
shutdown_rx.clone(),
);
held.insert(tor_key, handle);
claimed.push((app.port, GATE_TOR_UPSTREAM.to_string()));
info!(
port = app.port, app = %app.app_id,
"app gate claimed the Tor upstream (127.0.0.2)"
);
}
Err(_) => blocked = true,
}
}
}
if blocked && !claimed_any {
warn!(
@@ -251,12 +314,15 @@ async fn app_is_listening(port: u16) -> bool {
.is_some()
}
/// Returns the accept-loop task handle so the sweep can release the claim
/// (abort → listener drops → address freed) when the port leaves the gated
/// set. In-flight connections finish on their own tasks.
fn spawn_accept_loop(
listener: TcpListener,
gate: Arc<AppGate>,
app: GatedPort,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
tokio::select! {
@@ -287,7 +353,7 @@ fn spawn_accept_loop(
_ = shutdown_rx.changed() => break,
}
}
});
})
}
#[cfg(test)]
+407 -55
View File
@@ -136,7 +136,7 @@ impl AppGate {
}
match self.authorize(req.headers(), &app.app_id).await {
Authorization::Allow => proxy_to_app(req, app.port).await,
Authorization::Allow => proxy_to_app(req, app).await,
// 401 rather than 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 if it were their API
@@ -154,6 +154,11 @@ impl AppGate {
action: &str,
client_ip: IpAddr,
) -> Response<Body> {
// Assets are GET and pre-auth by nature: the login page cannot
// render its own background or logo without them.
if let Some(name) = action.strip_prefix("asset/") {
return self.serve_asset(name);
}
if req.method() != Method::POST {
return login_page(app, None, StatusCode::OK);
}
@@ -187,6 +192,26 @@ impl AppGate {
}
}
/// Static assets the login page needs, served from the gate's own origin.
///
/// The backgrounds are ~1 MB each, so inlining them as data URIs would
/// bloat every challenge response. Serving them here keeps the page
/// byte-identical to the dashboard's login while the CSP stays tight:
/// `img-src 'self' data:` and nothing else.
fn serve_asset(&self, name: &str) -> Response<Body> {
let Some((bytes, mime)) = read_ui_asset(name) else {
return not_found();
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
// Immutable art; caching it costs nothing and keeps the login
// instant on a repeat challenge.
.header(header::CACHE_CONTROL, "public, max-age=86400")
.body(Body::from(bytes))
.expect("asset response builds")
}
async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response<Body> {
let password = field(form, "password").unwrap_or_default();
@@ -350,7 +375,8 @@ fn percent_decode(input: &str) -> String {
}
/// Forward an authorised request to the app on loopback.
async fn proxy_to_app(req: Request<Body>, port: u16) -> Response<Body> {
async fn proxy_to_app(req: Request<Body>, app: &GatedPort) -> Response<Body> {
let port = app.port;
let path_and_query = req
.uri()
.path_and_query()
@@ -364,10 +390,16 @@ async fn proxy_to_app(req: Request<Body>, port: u16) -> Response<Body> {
let (mut parts, body) = req.into_parts();
parts.uri = uri;
// Strip the gate's own credential before it reaches the app: the app has
// no use for the node session and should never be in a position to log,
// echo, or forward it.
parts.headers.remove(header::COOKIE);
// Strip the gate's own credential before it reaches the app the app
// should never be in a position to log, echo, or forward the node
// session. But ONLY the gate's cookies: apps run their own cookie logins
// (vaultwarden, nextcloud, gitea…), and removing the whole header logged
// every one of them out on each request. Companion UIs that proxy the
// daemon's authenticated endpoints opt in to keeping the session via
// `session_passthrough: true` on their gated port.
if !app.session_passthrough {
strip_gate_cookies(&mut parts.headers);
}
parts.headers.remove(header::AUTHORIZATION);
let client = hyper::Client::new();
@@ -377,6 +409,44 @@ async fn proxy_to_app(req: Request<Body>, port: u16) -> Response<Body> {
}
}
/// Cookie names owned by the gate/daemon, never the app's to see.
const GATE_COOKIE_NAMES: &[&str] = &["session", "csrf_token"];
/// Remove the gate's own cookie pairs from the Cookie header, preserving the
/// app's cookies (its login/session/prefs) untouched. Drops the header
/// entirely when nothing remains.
fn strip_gate_cookies(headers: &mut hyper::HeaderMap) {
let Some(cookie) = headers.get(header::COOKIE) else {
return;
};
let Ok(raw) = cookie.to_str() else {
// Not valid UTF-8 — can't safely filter pairs, so fail closed.
headers.remove(header::COOKIE);
return;
};
let kept: Vec<&str> = raw
.split(';')
.map(str::trim)
.filter(|pair| {
let name = pair.split('=').next().unwrap_or("").trim();
!GATE_COOKIE_NAMES.contains(&name)
})
.filter(|pair| !pair.is_empty())
.collect();
if kept.is_empty() {
headers.remove(header::COOKIE);
return;
}
match header::HeaderValue::from_str(&kept.join("; ")) {
Ok(v) => {
headers.insert(header::COOKIE, v);
}
Err(_) => {
headers.remove(header::COOKIE);
}
}
}
fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
// No Domain attribute, so the cookie is host-only. Cookies ignore port,
// which is what makes one sign-in cover the dashboard and every app port
@@ -428,43 +498,161 @@ fn esc(s: &str) -> String {
/// none. Inlined as a data URI rather than linked: the gate is answering on
/// the app's own port, so any asset URL would either hit the unauthenticated
/// app behind it or a different origin the browser may not reach.
/// One stacked layer per background, each delayed so they cross-fade in turn.
fn background_layers() -> String {
let step = LOGIN_BACKGROUNDS.len() as u32 * 9 / LOGIN_BACKGROUNDS.len() as u32;
LOGIN_BACKGROUNDS
.iter()
.enumerate()
.map(|(i, name)| {
format!(
r#"<div class="bg" style="background-image:url('{prefix}asset/{name}');animation-delay:{delay}s"></div>"#,
prefix = GATE_PREFIX,
delay = i as u32 * step,
)
})
.collect()
}
fn icon_markup(app: &GatedPort) -> String {
if let Some(path) = &app.icon {
if let Some(data_uri) = read_icon_data_uri(path) {
return format!(r#"<img class="icon" src="{}" alt="">"#, esc(&data_uri));
}
}
let letter = app
.app_name
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string());
format!(r#"<div class="icon lettermark">{}</div>"#, esc(&letter))
let inner = app
.icon
.as_deref()
.and_then(read_icon_data_uri)
// A manifest that names no icon still gets one: the dashboard already
// ships icons named after the app, so fall back to those before
// giving up. Without this EVERY gated app showed a lettermark,
// because no manifest declares metadata.icon (archi-dev-box,
// 2026-08-05).
.or_else(|| {
icon_candidates(&app.app_id)
.iter()
.find_map(|c| read_icon_data_uri(c))
})
.map(|data_uri| format!(r#"<img class="icon" src="{}" alt="">"#, esc(&data_uri)))
.unwrap_or_else(|| {
let letter = app
.app_name
.chars()
.find(|c| c.is_alphanumeric())
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string());
format!(r#"<div class="icon lettermark">{}</div>"#, esc(&letter))
});
format!(r#"<div class="tile">{inner}</div>"#)
}
/// Icons live with the web UI. Only files under the icon directory are read,
/// and only known image extensions — the path comes from a manifest, which is
/// signed, but treating it as untrusted costs nothing.
fn read_icon_data_uri(icon_path: &str) -> Option<String> {
let name = std::path::Path::new(icon_path).file_name()?.to_str()?;
let mime = match name.rsplit_once('.')?.1.to_ascii_lowercase().as_str() {
"svg" => "image/svg+xml",
"png" => "image/png",
"webp" => "image/webp",
"jpg" | "jpeg" => "image/jpeg",
_ => return None,
/// Icon basenames to try for an app id, best first.
///
/// The shipped icon set is named for the *product*, while app ids carry
/// packaging detail — `filebrowser` vs `file-browser`, `morphos-server` vs
/// `morphos` — and the per-app screens (`lnd-ui`, `bitcoin-ui`, `electrs-ui`)
/// have no icon of their own but obviously belong to the app they front.
/// Resolving those here keeps the mapping in one readable place instead of
/// adding a `metadata.icon` line to every manifest, which would have to be
/// re-signed into the catalog to take effect.
fn icon_candidates(app_id: &str) -> Vec<String> {
let mut out = vec![app_id.to_string()];
let alias = match app_id {
"filebrowser" => Some("file-browser"),
"home-assistant" => Some("homeassistant"),
"morphos-server" => Some("morphos"),
"barkd" => Some("bark"),
"archy-mempool-web" | "mempool-api" => Some("mempool"),
"lnd-ui" | "lightning-stack" => Some("lnd"),
"bitcoin-ui" => Some("bitcoin-core"),
"electrs-ui" => Some("electrumx"),
"fips-ui" | "aiui" | "did-wallet" => Some("archipelago-a"),
"fedimint-gateway" | "fedimint-clientd" => Some("fedimint"),
_ => None,
};
out.extend(alias.map(str::to_string));
// `<app>-ui` / `-server` / `-web` front an app whose icon is the bare name.
for suffix in ["-ui", "-server", "-web"] {
if let Some(base) = app_id.strip_suffix(suffix) {
out.push(base.to_string());
}
}
out
}
/// Backgrounds the login cycles through, matching the dashboard's own
/// `/login` art. Cross-faded by CSS alone — the CSP forbids script, and a
/// rotation that needs JavaScript would not survive it.
const LOGIN_BACKGROUNDS: [&str; 4] = [
"bg-intro.jpg",
"bg-intro-4.webp",
"bg-intro-6.webp",
"bg-intro-3.jpg",
];
/// Assets the gate will serve, by exact name. An allowlist rather than a path
/// join: the name arrives in a URL, and the gate answers before any
/// authentication, so nothing here may be caller-controlled beyond this set.
fn read_ui_asset(name: &str) -> Option<(Vec<u8>, &'static str)> {
let allowed = LOGIN_BACKGROUNDS.contains(&name) || name == "favico-black-v2.svg";
if !allowed {
return None;
}
let mime = icon_mime(name.rsplit_once('.')?.1)?;
for root in [
"/opt/archipelago/web-ui/assets/img/app-icons",
"web/dist/neode-ui/assets/img/app-icons",
"/opt/archipelago/web-ui/assets/img",
"web/dist/neode-ui/assets/img",
"neode-ui/public/assets/img",
"/opt/archipelago/web-ui/assets/icon",
"web/dist/neode-ui/assets/icon",
"neode-ui/public/assets/icon",
] {
let candidate = std::path::Path::new(root).join(name);
if let Ok(bytes) = std::fs::read(&candidate) {
if bytes.len() > 512 * 1024 {
return None;
if let Ok(bytes) = std::fs::read(std::path::Path::new(root).join(name)) {
return Some((bytes, mime));
}
}
None
}
const ICON_ROOTS: [&str; 2] = [
"/opt/archipelago/web-ui/assets/img/app-icons",
"web/dist/neode-ui/assets/img/app-icons",
];
fn icon_mime(ext: &str) -> Option<&'static str> {
match ext.to_ascii_lowercase().as_str() {
"svg" => Some("image/svg+xml"),
"png" => Some("image/png"),
"webp" => Some("image/webp"),
"jpg" | "jpeg" => Some("image/jpeg"),
_ => None,
}
}
/// Read an app icon as a `data:` URI.
///
/// `icon_ref` may be a filename or path with an extension (a manifest's
/// `metadata.icon`), or a bare name such as an app id — in which case the
/// known extensions are tried in turn. Only the file name is used; the
/// directories searched are fixed, so a manifest cannot point the gate at an
/// arbitrary path.
fn read_icon_data_uri(icon_ref: &str) -> Option<String> {
let name = std::path::Path::new(icon_ref).file_name()?.to_str()?;
let candidates: Vec<(String, &str)> = match name.rsplit_once('.') {
Some((_, ext)) => vec![(name.to_string(), icon_mime(ext)?)],
None => ["svg", "png", "webp", "jpg"]
.iter()
.filter_map(|ext| Some((format!("{name}.{ext}"), icon_mime(ext)?)))
.collect(),
};
for (file, mime) in candidates {
for root in ICON_ROOTS {
let candidate = std::path::Path::new(root).join(&file);
if let Ok(bytes) = std::fs::read(&candidate) {
if bytes.len() > 512 * 1024 {
continue;
}
return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
}
return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
}
}
None
@@ -484,29 +672,91 @@ fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Respons
<meta name="robots" content="noindex">
<title>{title} {app_name}</title>
<style>
/* The dashboard's own /login, rebuilt in static CSS: the same rotating
intro art, .glass-card panel, .glass-button action and transparent
white-bordered inputs from neode-ui/src/style.css. Written longhand
rather than shared with the SPA because the gate answers before any
bundle exists, and the CSP forbids external stylesheets and script. */
:root {{ color-scheme: dark; }}
* {{ box-sizing: border-box; }}
body {{ margin:0; min-height:100vh; display:grid; place-items:center;
background:#0b0f14; color:#e6edf3; font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif; }}
.card {{ width:min(92vw,380px); padding:2rem; background:#121820;
border:1px solid #223; border-radius:14px; text-align:center; }}
.icon {{ width:64px; height:64px; border-radius:14px; margin:0 auto 1rem; display:block; object-fit:cover; }}
.lettermark {{ display:grid; place-items:center; background:#1d2733; font-size:28px; font-weight:600; }}
h1 {{ font-size:1.15rem; margin:0 0 .25rem; }}
p.sub {{ margin:0 0 1.5rem; color:#8b98a5; font-size:.9rem; }}
input {{ width:100%; padding:.7rem .8rem; margin-bottom:.75rem; border-radius:9px;
border:1px solid #2b3947; background:#0d131a; color:#e6edf3; font-size:1rem; }}
input:focus {{ outline:2px solid #3b82f6; outline-offset:1px; }}
button {{ width:100%; padding:.7rem; border:0; border-radius:9px; background:#3b82f6;
color:#fff; font-size:1rem; font-weight:600; cursor:pointer; }}
button:hover {{ background:#2f6fd6; }}
.err {{ background:#3b1519; border:1px solid #7f1d1d; color:#fca5a5;
padding:.6rem .8rem; border-radius:9px; margin-bottom:1rem; font-size:.9rem; }}
html {{ height:100%; }}
body {{ margin:0; color:#fff; background:#05070a; overflow:hidden;
font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;
/* Fixed to the viewport rather than a tall scrolling page: an on-screen
keyboard then overlays the card instead of scrolling it away, and the
card stays optically centred. min-height:100vh scrolled with the
keyboard on mobile and left the card off-centre (reported 2026-08-05). */
position:fixed; inset:0;
display:grid; place-items:center; padding:1rem;
height:100vh; height:100svh; }}
/* Very short viewports (landscape phone, or a keyboard eating most of it):
allow the card to scroll INSIDE the fixed frame rather than overflow. */
@media (max-height:640px) {{
body {{ align-items:start; overflow-y:auto; padding-top:3rem; }}
}}
/* Rotating backgrounds: each layer holds its image and cross-fades on a
shared cycle, so the art moves the way /login does with no script. */
.bg {{ position:fixed; inset:0; z-index:0; background-size:cover;
background-position:center; opacity:0; animation:bg-cycle {cycle}s infinite; }}
.bg::after {{ content:''; position:absolute; inset:0;
background:linear-gradient(180deg, rgba(0,0,0,.35), rgba(0,0,0,.72)); }}
@keyframes bg-cycle {{
0% {{ opacity:0; }} 4% {{ opacity:1; }}
{hold}% {{ opacity:1; }} {fade}% {{ opacity:0; }} 100% {{ opacity:0; }}
}}
main {{ position:relative; z-index:1; width:min(92vw,28rem); }}
.card {{ padding:2rem; padding-top:3.5rem; position:relative;
background:rgba(0,0,0,.65); backdrop-filter:blur(18px);
-webkit-backdrop-filter:blur(18px); border:1px solid rgba(255,255,255,.18);
border-radius:1rem; box-shadow:0 8px 24px rgba(0,0,0,.45); text-align:center; }}
/* The Archipelago mark, half in and half out of the panel — same placement
and gradient ring as Login.vue. */
.logo {{ position:absolute; top:-2.5rem; left:50%; transform:translateX(-50%);
width:5rem; height:5rem; border-radius:9999px; padding:3px;
background:linear-gradient(135deg, rgba(255,255,255,.6) 0%, rgba(0,0,0,.8) 100%);
box-shadow:0 8px 24px rgba(0,0,0,.5); }}
.logo img {{ width:100%; height:100%; border-radius:9999px; display:block;
background:#000; padding:.5rem; }}
/* The app's own tile, in the My Apps shape: 18px-rounded square on dark
glass with the same inner highlight and drop shadow. */
.tile {{ width:60px; height:60px; border-radius:18px; margin:0 auto .75rem;
background:rgba(0,0,0,.72); box-shadow:0 8px 18px rgba(0,0,0,.38); }}
.tile .icon {{ width:100%; height:100%; border-radius:18px; display:block;
object-fit:cover; border:1px solid rgba(255,255,255,.18);
background:radial-gradient(circle at 35% 28%, rgba(255,255,255,.1), rgba(255,255,255,0) 42%),
linear-gradient(145deg, rgba(22,22,24,.96), rgba(0,0,0,.96));
box-shadow:inset 0 1px 0 rgba(255,255,255,.12), inset 0 -10px 24px rgba(0,0,0,.34); }}
.lettermark {{ display:grid; place-items:center; font-size:1.6rem; font-weight:600;
color:rgba(255,255,255,.9); }}
h1 {{ font-size:1.5rem; font-weight:600; margin:0 0 .4rem;
color:rgba(255,255,255,.96); text-shadow:0 2px 6px rgba(0,0,0,.4); }}
p.sub {{ margin:0 0 1.75rem; color:rgba(255,255,255,.6); font-size:.875rem; }}
input {{ width:100%; padding:.75rem 1rem; margin-bottom:1rem; border-radius:.5rem;
border:1px solid rgba(255,255,255,.2); background:transparent; color:#fff;
font-size:1rem; transition:border-color .2s ease; }}
input::placeholder {{ color:rgba(255,255,255,.4); }}
input:focus {{ outline:none; border-color:rgba(255,255,255,.4);
box-shadow:0 0 0 1px rgba(255,255,255,.2); }}
button {{ width:100%; min-height:44px; padding:.75rem 1.25rem; border:none;
border-radius:.75rem; background:rgba(0,0,0,.6);
backdrop-filter:blur(24px); -webkit-backdrop-filter:blur(24px);
box-shadow:0 8px 24px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.22);
color:rgba(255,255,255,.9); font-size:1rem; font-weight:500; cursor:pointer;
transition:background-color .2s ease, transform .3s cubic-bezier(.4,0,.2,1); }}
button:hover {{ background:rgba(0,0,0,.7); }}
button:active {{ transform:translateY(1px); }}
.err {{ background:rgba(239,68,68,.2); border:1px solid rgba(239,68,68,.4);
color:#fecaca; padding:.75rem; border-radius:.5rem; margin-bottom:1rem;
font-size:.875rem; text-align:left; }}
</style></head>
<body><main class="card">{body}</main></body></html>"#,
<body>{backgrounds}<main><div class="card">{body}</div></main></body></html>"#,
title = esc(title),
app_name = esc(&app.app_name),
body = body,
backgrounds = background_layers(),
cycle = LOGIN_BACKGROUNDS.len() as u32 * 9,
hold = 100 / LOGIN_BACKGROUNDS.len() as u32,
fade = 100 / LOGIN_BACKGROUNDS.len() as u32 + 4,
);
Response::builder()
.status(status)
@@ -514,10 +764,18 @@ button:hover {{ background:#2f6fd6; }}
// The gate answers on the app's own port for an unauthenticated
// caller; nothing here should be cached or framed.
.header(header::CACHE_CONTROL, "no-store")
.header("X-Frame-Options", "DENY")
// NOT X-Frame-Options: DENY. My Apps opens an app in an embedded
// frame, so a blanket DENY made every gated app render as "app is
// not responding" the moment the gate challenged it (reported on
// 100.82.34.38, 2026-08-05). frame-ancestors is the modern control
// and can be precise: only pages from this same node may frame the
// login, on any port or scheme, which is exactly the dashboard.
// Anything else — another site embedding it to harvest the node
// password — is still refused.
.header(
"Content-Security-Policy",
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'self'",
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
form-action 'self'; frame-ancestors 'self' http://*:* https://*:*",
)
.body(Body::from(html))
.expect("static response builds")
@@ -528,7 +786,8 @@ button:hover {{ background:#2f6fd6; }}
/// password by an unexplained page.
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
let body = format!(
r#"{icon}
r#"<div class="logo"><img src="{prefix}asset/favico-black-v2.svg" alt="Archipelago"></div>
{icon}
<h1>Sign in to open {name}</h1>
<p class="sub">This app is protected by your node password.</p>
{err}
@@ -578,6 +837,8 @@ mod tests {
app_id: "strfry".to_string(),
app_name: "Strfry Relay".to_string(),
icon: None,
declared: true,
session_passthrough: false,
}
}
@@ -635,11 +896,64 @@ mod tests {
assert!(!html.contains("<img src=x"));
}
/// The challenge must be framable by this node's own dashboard — My Apps
/// opens apps in an embedded frame, and a blanket `X-Frame-Options: DENY`
/// turned every gated app into "app is not responding" (100.82.34.38,
/// 2026-08-05). It must still be uncacheable, and still refuse to be
/// framed by a foreign origin, which `frame-ancestors` expresses and
/// `X-Frame-Options` cannot.
#[test]
fn challenge_pages_are_not_cacheable_or_framable() {
fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
assert_eq!(resp.headers()["X-Frame-Options"], "DENY");
assert!(
!resp.headers().contains_key("X-Frame-Options"),
"X-Frame-Options cannot express 'my own node on another port' — it \
blocked the dashboard's own frame"
);
let csp = resp.headers()["Content-Security-Policy"].to_str().unwrap();
assert!(csp.contains("frame-ancestors 'self'"));
assert!(csp.contains("form-action 'self'"));
}
/// The login page must render entirely from the gate's own origin: the
/// CSP allows no external host, so a background or logo that 404s leaves
/// a black page rather than the dashboard's art.
#[tokio::test]
async fn login_page_sources_its_art_from_the_gate() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body).to_string();
assert!(html.contains(&format!("{GATE_PREFIX}asset/favico-black-v2.svg")));
for name in LOGIN_BACKGROUNDS {
assert!(
html.contains(&format!("{GATE_PREFIX}asset/{name}")),
"background {name} is not referenced"
);
}
// Every referenced asset must be one the gate will actually serve.
// The logo is the sidebar A mark (favico-black-v2.svg) since the
// 2026-08-05 login-page rework — the old wordmark is off the
// allowlist on purpose.
assert!(read_ui_asset("favico-black-v2.svg").is_some() || cfg!(not(debug_assertions)));
}
/// The allowlist is the whole security boundary for asset serving: the
/// name arrives in a URL and is read before any authentication.
#[test]
fn asset_serving_refuses_anything_off_the_allowlist() {
for name in [
"../../../etc/passwd",
"/etc/passwd",
"db.sqlite3",
"manifest.yml",
"",
] {
assert!(
read_ui_asset(name).is_none(),
"{name} must not be servable by the gate"
);
}
}
#[tokio::test]
@@ -681,6 +995,44 @@ mod tests {
);
}
/// The gate must remove ONLY its own cookie pairs: an app's login cookie
/// riding the same header has to survive, or every gated app with its
/// own auth (vaultwarden, nextcloud, gitea) is logged out on each
/// request — the 2026-08-05 companion-UI/"app logged me out" regression.
#[test]
fn strip_gate_cookies_keeps_app_cookies() {
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
"session=abc; vw_session=keepme; csrf_token=def; theme=dark"
.parse()
.unwrap(),
);
strip_gate_cookies(&mut headers);
assert_eq!(
headers.get(header::COOKIE).unwrap().to_str().unwrap(),
"vw_session=keepme; theme=dark"
);
}
#[test]
fn strip_gate_cookies_drops_header_when_only_gate_cookies() {
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
"session=abc; csrf_token=def".parse().unwrap(),
);
strip_gate_cookies(&mut headers);
assert!(headers.get(header::COOKIE).is_none());
}
#[test]
fn strip_gate_cookies_no_header_is_a_noop() {
let mut headers = HeaderMap::new();
strip_gate_cookies(&mut headers);
assert!(headers.get(header::COOKIE).is_none());
}
/// The load-bearing 2FA property: a session still awaiting its TOTP code
/// fails `validate()`, so the gate rejects it without knowing anything
/// about second factors.
+66 -37
View File
@@ -154,9 +154,9 @@ pub async fn ensure_doctor_installed() {
}
match run_bitcoin_rpc_repair().await {
Ok(true) => {
info!("Repaired Bitcoin RPC bind settings; running Bitcoin containers left untouched")
info!("Removed stale bitcoin.conf; running Bitcoin containers left untouched")
}
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
Ok(false) => debug!("No stale bitcoin.conf found"),
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
}
match run_apps_dir_repair().await {
@@ -621,52 +621,30 @@ exit 2
}
async fn run_bitcoin_rpc_repair() -> Result<bool> {
// Older installs can have a container-owned bitcoin.conf with only rpcauth
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
// without a manual uninstall/reinstall. Bind/port stay in the container
// command line to avoid duplicate RPC endpoint definitions.
// bitcoind is launched with -conf=/tmp/rpc.conf and never reads a
// datadir bitcoin.conf (apps/bitcoin-core & bitcoin-knots manifest.yml,
// commit a597c1d9 — bind/port live only on the container command line).
// A leftover file from an older install makes Bitcoin Core's own
// datadir-conflict safety check refuse to start on every subsequent
// start. Remove it instead of "repairing" it into existence — this
// previously wrote server=/rpcbind=/rpcallowip=/listen= into the file,
// which is exactly what caused the conflict.
let script = r#"
set -eu
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
[ -f "$conf" ] || exit 0
changed=0
ensure_line() {
line="$1"
key="${line%%=*}"
if ! grep -q "^${key}=" "$conf"; then
printf '%s\n' "$line" >> "$conf"
changed=1
fi
}
ensure_line server=1
# rpcbind=0.0.0.0 is required inside the container: with rpcallowip set but
# no rpcbind, bitcoind binds RPC to the container's loopback only and every
# dial over the container network (LND, bitcoin-ui) is refused the fresh-
# install "LND took 5 attempts" / bitcoin-rpc 502 failure (host publish stays
# 127.0.0.1-only, so exposure is unchanged).
ensure_line rpcbind=0.0.0.0
ensure_line rpcallowip=0.0.0.0/0
ensure_line listen=1
# Log-volume fix: printtoconsole=1 duplicated every log line (incl. per-block
# IBD "UpdateTip" spam) into journald via conmon on top of the datadir
# debug.log bitcoind already writes. Console off; debug.log stays (bitcoind
# self-shrinks it on restart).
if grep -q '^printtoconsole=1' "$conf"; then
sed -i 's/^printtoconsole=1$/printtoconsole=0/' "$conf"
changed=1
fi
[ "$changed" -eq 0 ] && exit 0
mv "$conf" "$conf.disabled-$(date +%s)"
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
.await
.context("repair bitcoin.conf RPC bind settings")?;
.context("remove stale bitcoin.conf RPC bind settings")?;
match status.code() {
Some(0) => Ok(false),
// Do not restart Bitcoin from bootstrap. During IBD, an automatic
// restart can cost hours of progress. The repaired file is only a
// fallback for future starts; current containers keep their command-line
// RPC args until an operator or update intentionally restarts them.
// restart can cost hours of progress. Removing the stale file is
// only a fallback for future starts; current containers keep their
// command-line RPC args regardless.
Some(2) => Ok(true),
_ => {
warn!("Bitcoin RPC repair helper exited with {}", status);
@@ -1293,3 +1271,54 @@ mod tests {
assert_ne!(outcome, PodmanHealOutcome::Healthy);
}
}
/// Repair this node's own systemd restart policy.
///
/// The in-process updater replaces the binary and then asks systemd to
/// restart the service, treating `Restart=always` on the unit as its second
/// net if that request is ever lost. On austin-sapien (2026-08-05) the unit
/// was an old one carrying `Restart=on-failure`: the daemon exited cleanly
/// (status 0), systemd read that as success, and the node sat dead for over
/// two hours after a routine update — "server starting" in the UI, with
/// nothing to start it.
///
/// A node cannot be relied on to fix this via `self-update.sh` (which does
/// refresh units) because the in-process update path never runs it. So the
/// daemon checks its own unit at boot: any node that starts even once ends
/// up with a policy that survives the next update. Deliberately narrow —
/// only the `Restart=` line is touched, so local edits elsewhere in the unit
/// are preserved.
pub async fn ensure_restart_policy() {
const UNIT: &str = "/etc/systemd/system/archipelago.service";
let Ok(body) = fs::read_to_string(UNIT).await else {
return; // not a systemd install (container, dev box) — nothing to do
};
if !body.lines().any(|l| {
let l = l.trim();
l.starts_with("Restart=") && l != "Restart=always"
}) {
return; // already correct, or no Restart= line to repair
}
let patched: String = body
.lines()
.map(|l| {
if l.trim().starts_with("Restart=") && l.trim() != "Restart=always" {
"Restart=always"
} else {
l
}
})
.collect::<Vec<_>>()
.join("\n");
match write_root_if_needed(UNIT, &patched).await {
Ok(true) => {
tracing::warn!(
"repaired archipelago.service Restart= policy to always — this node would \
have stayed dead after an in-process update"
);
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
}
Ok(false) => {}
Err(e) => tracing::warn!(error = %e, "could not repair archipelago.service restart policy"),
}
}
@@ -216,6 +216,46 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
.collect()
}
/// A catalog-embedded manifest as the node actually applies it: parsed,
/// id-checked, validated, and image-only (build-source manifests defer to
/// disk). `None` = the caller must fall back to the disk manifest.
///
/// Shared between the orchestrator's load overlay and the app gate's port
/// classification so both answer "which manifest governs this app?" from the
/// same origin. They diverged once — the orchestrator published containers
/// from the catalog while the gate classified from stale disk manifests, and
/// the gate externally bound a port the catalog had declared `auth: local`
/// (nbxplorer 32838, archi-dev-box 2026-08-04).
pub fn catalog_manifest_overlay(
app_id: &str,
value: serde_json::Value,
) -> Option<archipelago_container::manifest::AppManifest> {
let m: archipelago_container::manifest::AppManifest = match serde_json::from_value(value) {
Ok(m) => m,
Err(e) => {
tracing::warn!(app = %app_id, error = %e,
"skipping unparseable catalog manifest; using disk fallback");
return None;
}
};
if m.app.id != app_id {
tracing::warn!(catalog_id = %app_id, manifest_id = %m.app.id,
"skipping catalog manifest: embedded app id mismatches catalog key");
return None;
}
if let Err(e) = m.validate() {
tracing::warn!(app = %app_id, error = %e,
"skipping invalid catalog manifest; using disk fallback");
return None;
}
if m.app.container.build.is_some() {
tracing::debug!(app = %app_id,
"catalog manifest has a build source; deferring to disk (phase 1 = image-only)");
return None;
}
Some(m)
}
/// The catalog's default/latest version string for an app (the top-level
/// `version` field), if covered. Used to decide whether an install-time
/// selection should pin (older) or track-latest (default).
+1 -1
View File
@@ -293,6 +293,6 @@ mod tests {
// Lock in the core shape so a bad template edit doesn't ship.
assert!(TEMPLATE.contains("proxy_pass http://127.0.0.1:8332/"));
assert!(TEMPLATE.contains("location /bitcoin-rpc/"));
assert!(TEMPLATE.contains("listen 8334"));
assert!(TEMPLATE.contains("listen 127.0.0.1:8334"));
}
}
@@ -1,5 +1,12 @@
server {
listen 8334;
# Loopback ONLY. This container is host-networked, so this nginx binds the
# HOST's address directly — `listen 8334;` meant every interface, and the
# app gate could never stand in front of it (there is no podman publish to
# pin, and the manifest declared no port, so the gate neither protected it
# nor reported it — it served this page to anyone who asked, on LAN,
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
# external addresses and authenticate them; see appgate::listener.
listen 127.0.0.1:8334;
server_name _;
root /usr/share/nginx/html;
index index.html;
@@ -214,10 +214,59 @@ pub async fn install_one(spec: &CompanionSpec) -> Result<()> {
}
// Start is idempotent — if already running, systemctl returns 0.
quadlet::enable_now(&unit.service_name()).await?;
// A rebuilt image does NOT reach a container that is already running.
// `ensure_image_present` rebuilds in place under the same tag, so the unit
// body is byte-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 exactly 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: the images were
// correct on disk and the running containers were three days old
// (2026-08-05). Compare image IDs and restart when they diverge.
if let Some(running) = container_image_id(spec.name).await {
if let Some(built) = image_id(&image).await {
if running != built {
info!(
companion = spec.name,
"running container uses a stale image; restarting onto the rebuilt one"
);
quadlet::restart_service(&unit.service_name()).await?;
}
}
}
info!(companion = spec.name, "companion started");
Ok(())
}
/// Image ID a container is actually running, or `None` when it does not exist.
async fn container_image_id(name: &str) -> Option<String> {
let out = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.Image}}"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!id.is_empty()).then_some(id)
}
/// Current ID behind an image reference, or `None` when absent.
async fn image_id(image_ref: &str) -> Option<String> {
let out = tokio::process::Command::new("podman")
.args(["image", "inspect", image_ref, "--format", "{{.Id}}"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!id.is_empty()).then_some(id)
}
/// Build companion image locally if a Dockerfile exists, otherwise
/// pull from the lfg2025 registry. Returns the image ref the quadlet
/// should reference (`localhost/<base>:latest` for build, registry
@@ -104,6 +104,32 @@ fn dependency_manifests_required_by_active_apps<'a>(
required
}
/// Whether `app_id` is a member of a known multi-container stack that has at
/// least one OTHER member with a live container (any state). A live sibling
/// proves the stack is installed on this node, so an absent member is a hole
/// to repair — while a stack with no containers at all stays untouched
/// (uninstalled, or never installed here). Sibling app ids resolve to
/// container names through the loaded-manifest map when available (immich's
/// `immich-postgres` app id runs as container `immich_postgres`), falling
/// back to the id itself.
fn absent_stack_member_with_live_sibling(
app_id: &str,
present_containers: &HashSet<String>,
container_name_by_app_id: &std::collections::HashMap<String, String>,
) -> bool {
let stack = crate::app_ops::owning_package(app_id);
let members = crate::app_ops::stack_member_app_ids(stack);
members.iter().any(|member| {
*member != app_id
&& present_containers.contains(
container_name_by_app_id
.get(*member)
.map(String::as_str)
.unwrap_or(member),
)
})
}
fn manifest_dependency_app_ids(manifest: &AppManifest) -> Vec<String> {
manifest
.app
@@ -246,10 +272,10 @@ fn build_fingerprint_stamp_path(data_dir: &Path, tag: &str) -> PathBuf {
}
async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
let uid = uid_gid
let (uid, gid) = uid_gid
.split_once(':')
.and_then(|(uid, _)| uid.parse::<u32>().ok())
.unwrap_or(0);
.map(|(u, g)| (u.parse::<u32>().unwrap_or(0), g.parse::<u32>().unwrap_or(0)))
.unwrap_or((0, 0));
if uid > 0 && uid < 100_000 {
let output = tokio::process::Command::new("podman")
@@ -262,9 +288,22 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
}
}
let status = host_sudo(&["chown", "-R", uid_gid, path])
// Host-side fallback. A CONTAINER-namespace id must be translated into
// the subuid range first: `sudo chown 999` writes literal host uid 999,
// which maps to nobody inside the userns — the app then can't open its
// own files while the chown reported success (botfights SQLITE_CANTOPEN
// crash-loop, framework-pt 2026-08-06). Container uid N (N>=1) lives at
// subuid_base + N - 1; the fleet provisions base 100000. uid 0 and
// already-mapped ids (>=100000) pass through untouched.
let host_uid_gid = if uid > 0 && uid < 100_000 {
let map = |id: u32| if id == 0 { 1000 } else { 100_000 + id - 1 };
format!("{}:{}", map(uid), map(gid))
} else {
uid_gid.to_string()
};
let status = host_sudo(&["chown", "-R", &host_uid_gid, path])
.await
.with_context(|| format!("sudo chown -R {uid_gid} {path}"))?;
.with_context(|| format!("sudo chown -R {host_uid_gid} {path}"))?;
if status.success() {
return Ok(());
}
@@ -595,10 +634,20 @@ async fn wait_for_manifest_host_ports(
/// `podman inspect --format '{{json .HostConfig.PortBindings}}'` emits, e.g.
/// `{"8080/tcp":[{"HostIp":"","HostPort":"18080"}]}`. Returns true only when a
/// manifest container-port is positively published to a *different* host port
/// than the manifest now asks for. Absence of a binding is deliberately NOT
/// treated as drift here — that case is handled by the host-port repair/restart
/// path and by host-networked apps that publish nothing — so we never trigger a
/// destructive recreate on a false positive.
/// than the manifest now asks for — or, when the manifest DECLARES a bind
/// address, to a different host address. Absence of a binding is deliberately
/// NOT treated as drift here — that case is handled by the host-port
/// repair/restart path and by host-networked apps that publish nothing — so we
/// never trigger a destructive recreate on a false positive.
///
/// The bind comparison is what lets a node self-heal after a catalog refresh
/// pins an app to loopback for the app gate: a legacy (pre-quadlet) container
/// still publishing `0.0.0.0:P` against a manifest that now declares
/// `bind: 127.0.0.1` is recreated to the declared state, exactly as
/// `package.update` would. An EMPTY manifest bind means "no instruction" and
/// never fires this — recreating a loopback-published container to wildcard on
/// silence is precisely the v1.7.121 incident class (Bitcoin RPC republished
/// on the LAN).
fn host_port_bindings_drifted(
port_bindings_json: &str,
manifest_ports: &[archipelago_container::manifest::PortMapping],
@@ -626,10 +675,26 @@ fn host_port_bindings_drifted(
}
let expected = port.host.to_string();
let matches_expected = bindings.iter().any(|b| {
b.get("HostPort")
let host_port_ok = b
.get("HostPort")
.and_then(|h| h.as_str())
.map(|h| h == expected)
.unwrap_or(false)
.unwrap_or(false);
if !host_port_ok {
return false;
}
// Only a DECLARED bind participates; podman reports a wildcard
// publish as "" or "0.0.0.0".
if port.bind.is_empty() {
return true;
}
let actual_ip = b.get("HostIp").and_then(|h| h.as_str()).unwrap_or("");
let actual = if actual_ip.is_empty() {
"0.0.0.0"
} else {
actual_ip
};
actual == port.bind
});
if !matches_expected {
return true;
@@ -1157,30 +1222,7 @@ struct LoadedManifest {
/// source (build contexts aren't registry-distributed yet — phase 1 is
/// image-only). See `docs/registry-manifest-design.md`.
fn catalog_manifest_to_overlay(app_id: &str, value: serde_json::Value) -> Option<AppManifest> {
let m: AppManifest = match serde_json::from_value(value) {
Ok(m) => m,
Err(e) => {
tracing::warn!(app = %app_id, error = %e,
"skipping unparseable catalog manifest; using disk fallback");
return None;
}
};
if m.app.id != app_id {
tracing::warn!(catalog_id = %app_id, manifest_id = %m.app.id,
"skipping catalog manifest: embedded app id mismatches catalog key");
return None;
}
if let Err(e) = m.validate() {
tracing::warn!(app = %app_id, error = %e,
"skipping invalid catalog manifest; using disk fallback");
return None;
}
if m.app.container.build.is_some() {
tracing::debug!(app = %app_id,
"catalog manifest has a build source; deferring to disk (phase 1 = image-only)");
return None;
}
Some(m)
crate::container::app_catalog::catalog_manifest_overlay(app_id, value)
}
struct OrchestratorState {
@@ -1651,13 +1693,16 @@ impl ProdContainerOrchestrator {
// app whose container vanished (e.g. a wedged teardown cleared by a
// reboot) instead of leaving it down. See the immich .198 incident.
let was_running = crate::crash_recovery::load_last_running_names(&self.data_dir).await;
let manifests: Vec<LoadedManifest> = {
let (manifests, container_name_by_app_id): (
Vec<LoadedManifest>,
std::collections::HashMap<String, String>,
) = {
let state = self.state.read().await;
let dependency_required = dependency_manifests_required_by_active_apps(
state.manifests.values().map(|lm| &lm.manifest),
&user_stopped,
);
state
let filtered = state
.manifests
.iter()
.filter(|(app_id, _)| !state.disabled.contains(*app_id))
@@ -1667,8 +1712,25 @@ impl ProdContainerOrchestrator {
&& !user_stopped.contains(&compute_container_name(&lm.manifest)))
})
.map(|(_, lm)| lm.clone())
.collect()
.collect();
// Unfiltered id→container-name map for the absent-stack-member
// recovery below: a sibling may be excluded from this pass (e.g.
// user-stopped) yet its live container still proves the stack is
// installed.
let names = state
.manifests
.iter()
.map(|(id, lm)| (id.clone(), compute_container_name(&lm.manifest)))
.collect();
(filtered, names)
};
// Live container names (any state), for the same recovery check.
let present_containers: std::collections::HashSet<String> = self
.runtime
.list_containers()
.await
.map(|cs| cs.into_iter().map(|c| c.name).collect())
.unwrap_or_default();
let mut report = ReconcileReport::default();
let disk_gb = self.disk_gb().await;
// Register every candidate before the (sequential, possibly slow)
@@ -1735,7 +1797,20 @@ impl ProdContainerOrchestrator {
Ok(ReconcileAction::Left(reason))
if mode == ReconcileMode::ExistingOnly
&& reason == "absent"
&& was_running.contains(&compute_container_name(&lm.manifest)) =>
&& (was_running.contains(&compute_container_name(&lm.manifest))
// Absent STACK MEMBER whose siblings have live
// containers: the stack is installed, so the
// missing member is a hole, not a choice. The
// was_running snapshot ages out after a few daemon
// restarts, which left indeedhub-minio/-postgres
// permanently absent on .38 (2026-08-06) — nginx
// down on `host not found in upstream "minio"`
// with nothing ever recreating the members.
|| absent_stack_member_with_live_sibling(
&app_id,
&present_containers,
&container_name_by_app_id,
)) =>
{
tracing::warn!(
app_id = %app_id,
@@ -1751,7 +1826,10 @@ impl ProdContainerOrchestrator {
}
Ok(action) => report.record(&app_id, action),
Err(e) => {
tracing::error!(app_id = %app_id, error = %e, "reconcile failed");
// `{:#}` prints the whole anyhow chain — `%e` alone showed
// only the outer context ("create_container X") and hid
// the actual libpod error for days.
tracing::error!(app_id = %app_id, error = %format!("{e:#}"), "reconcile failed");
report.failures.push((app_id, e.to_string()));
}
}
@@ -4437,6 +4515,7 @@ mod tests {
bind: String::new(),
auth: None,
auth_rationale: None,
session_passthrough: false,
}
}
@@ -4444,6 +4523,61 @@ mod tests {
items.iter().map(|s| s.to_string()).collect()
}
/// The .38 indeedhub incident class: an absent stack member must be
/// recovered when its siblings have live containers (the stack is
/// installed), and left alone when the whole stack is gone or the app
/// is not a stack member at all.
#[test]
fn absent_stack_member_recovery_requires_a_live_sibling() {
let present: HashSet<String> = ["indeedhub-redis", "indeedhub-relay", "indeedhub"]
.iter()
.map(|s| s.to_string())
.collect();
let names = std::collections::HashMap::new();
// Missing members of a stack with live siblings → recover.
assert!(absent_stack_member_with_live_sibling(
"indeedhub-minio",
&present,
&names
));
assert!(absent_stack_member_with_live_sibling(
"indeedhub-postgres",
&present,
&names
));
// Whole stack absent → NOT recovered (uninstalled stays uninstalled).
let empty = HashSet::new();
assert!(!absent_stack_member_with_live_sibling(
"indeedhub-minio",
&empty,
&names
));
// Non-stack app → never.
assert!(!absent_stack_member_with_live_sibling(
"vaultwarden",
&present,
&names
));
// An app's OWN container being present proves nothing about siblings.
let only_self: HashSet<String> = std::iter::once("indeedhub-minio".to_string()).collect();
assert!(!absent_stack_member_with_live_sibling(
"indeedhub-minio",
&only_self,
&names
));
// App-id → container-name mapping is honoured (immich_postgres runs
// under an underscore name while its app id is hyphenated).
let mut mapped = std::collections::HashMap::new();
mapped.insert("immich-postgres".to_string(), "immich_postgres".to_string());
let immich_present: HashSet<String> =
std::iter::once("immich_postgres".to_string()).collect();
assert!(absent_stack_member_with_live_sibling(
"immich-redis",
&immich_present,
&mapped
));
}
#[test]
fn command_drift_tolerates_quadlet_entrypoint_split() {
// Quadlet writes Entrypoint=sh + Exec=-lc "<script>", so podman
@@ -4569,6 +4703,76 @@ mod tests {
));
}
fn bound_port(
host: u16,
container: u16,
bind: &str,
) -> archipelago_container::manifest::PortMapping {
archipelago_container::manifest::PortMapping {
bind: bind.to_string(),
..port(host, container)
}
}
#[test]
fn bind_drift_detected_when_declared_loopback_but_published_wildcard() {
// The legacy-container case: a pre-quadlet container still publishes
// 0.0.0.0 while the catalog-delivered manifest pins the app to
// loopback for the app gate. Must recreate, or the port stays open on
// every interface and the gate can never claim it.
for wildcard in [r#""""#, r#""0.0.0.0""#] {
let bindings = format!(r#"{{"80/tcp":[{{"HostIp":{wildcard},"HostPort":"8082"}}]}}"#);
assert!(host_port_bindings_drifted(
&bindings,
&[bound_port(8082, 80, "127.0.0.1")]
));
}
}
#[test]
fn no_bind_drift_when_declared_loopback_and_published_loopback() {
let bindings = r#"{"80/tcp":[{"HostIp":"127.0.0.1","HostPort":"8082"}]}"#;
assert!(!host_port_bindings_drifted(
bindings,
&[bound_port(8082, 80, "127.0.0.1")]
));
}
#[test]
fn no_bind_drift_on_undeclared_bind() {
// Silence is not consent (v1.7.121 incident class): an EMPTY manifest
// bind must never recreate a loopback-published container to
// wildcard — that is how Bitcoin's RPC got republished on the LAN.
let bindings = r#"{"8332/tcp":[{"HostIp":"127.0.0.1","HostPort":"8332"}]}"#;
assert!(!host_port_bindings_drifted(bindings, &[port(8332, 8332)]));
}
#[test]
fn multi_bind_publish_satisfies_each_declared_entry() {
// Same host/container pair listed twice (loopback + archy-net
// gateway): both declared binds are present in the actual publish.
let bindings = r#"{"8332/tcp":[
{"HostIp":"127.0.0.1","HostPort":"8332"},
{"HostIp":"10.89.0.1","HostPort":"8332"}
]}"#;
assert!(!host_port_bindings_drifted(
bindings,
&[
bound_port(8332, 8332, "127.0.0.1"),
bound_port(8332, 8332, "10.89.0.1")
]
));
// And a wildcard-only publish drifts BOTH declared entries.
let wildcard = r#"{"8332/tcp":[{"HostIp":"","HostPort":"8332"}]}"#;
assert!(host_port_bindings_drifted(
wildcard,
&[
bound_port(8332, 8332, "127.0.0.1"),
bound_port(8332, 8332, "10.89.0.1")
]
));
}
#[test]
fn missing_secret_error_names_the_secret() {
use archipelago_container::manifest::SecretsProvider;
+2 -2
View File
@@ -7,6 +7,6 @@
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8888, 8999, 9000, 9100, 10380, 11434, 18081,
18083, 23000, 32838, 50002,
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
18081, 18083, 23000, 32838, 50002,
];
+205
View File
@@ -0,0 +1,205 @@
//! Last-known-good FIPS peer endpoints (A3.10).
//!
//! The LAN direct-peering tick (`anchors::lan_fips_anchors`) only helps peers
//! we can currently see on the LAN. When a federation peer's LAN path is gone
//! (renumbered network, remote site, mDNS blackout) the only route left is the
//! anchor spanning tree — the exact hairpin RC2 calls out. But if we were EVER
//! connected to that peer directly, the daemon knew a working endpoint for it
//! (`fipsctl show peers` → `transport_addr`/`transport_type`, which covers
//! LAN, Tailscale, and WAN endpoints alike). This module persists those
//! npub-keyed endpoints and re-offers them as dial candidates when the live
//! paths disappear: LAN → last-known-good → anchor tree.
//!
//! Persisted at `<data_dir>/fips-endpoints.json`. Entries are refreshed every
//! time the peer is seen connected and dropped after `RETENTION` without a
//! sighting, so a peer that genuinely moved doesn't get dialed at a stale
//! address forever ( `fipsctl connect` to a dead address is harmless but not
//! free).
use std::collections::HashMap;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tokio::fs;
use super::anchors::SeedAnchor;
const FILE_NAME: &str = "fips-endpoints.json";
/// Forget endpoints not seen connected for this long (seconds) — 30 days.
const RETENTION_SECS: u64 = 30 * 24 * 60 * 60;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct KnownEndpoint {
/// "ip:port" as reported by the daemon (`transport_addr`).
pub address: String,
/// "udp" | "tcp" (`transport_type`).
pub transport: String,
/// Unix seconds of the last time this peer was seen connected here.
pub last_ok_unix: u64,
}
/// A currently-connected peer as parsed from `fipsctl show peers`.
#[derive(Debug, Clone)]
pub struct ConnectedPeer {
pub npub: String,
pub address: String,
pub transport: String,
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub async fn load(data_dir: &Path) -> HashMap<String, KnownEndpoint> {
let path = data_dir.join(FILE_NAME);
match fs::read(&path).await {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => HashMap::new(),
}
}
async fn save(data_dir: &Path, map: &HashMap<String, KnownEndpoint>) -> Result<()> {
let path = data_dir.join(FILE_NAME);
let tmp = data_dir.join(format!("{FILE_NAME}.tmp"));
fs::write(&tmp, serde_json::to_vec_pretty(map)?).await?;
fs::rename(&tmp, &path).await?;
Ok(())
}
/// Merge the currently-connected peers into the store (refreshing their
/// timestamps), prune expired entries, persist, and return the updated map.
/// Persistence failures are non-fatal — the in-memory result is still
/// returned so this tick's fallback logic works.
pub async fn record_connected(
data_dir: &Path,
connected: &[ConnectedPeer],
) -> HashMap<String, KnownEndpoint> {
let mut map = load(data_dir).await;
let now = now_unix();
let before = map.clone();
for p in connected {
if p.npub.is_empty() || p.address.is_empty() {
continue;
}
map.insert(
p.npub.clone(),
KnownEndpoint {
address: p.address.clone(),
transport: p.transport.clone(),
last_ok_unix: now,
},
);
}
map.retain(|_, e| now.saturating_sub(e.last_ok_unix) <= RETENTION_SECS);
if map != before {
if let Err(e) = save(data_dir, &map).await {
tracing::debug!("fips endpoint store save failed (non-fatal): {e}");
}
}
map
}
/// Build fallback anchors for federation peers whose live paths are gone:
/// every `wanted_npub` that is neither currently connected nor covered by a
/// live LAN direct entry, but has a last-known-good endpoint, becomes a dial
/// candidate. `fipsctl connect` is idempotent and failure-tolerant, so a
/// stale candidate costs one failed dial, bounded by apply()'s per-connect
/// timeout.
pub fn fallback_anchors(
known: &HashMap<String, KnownEndpoint>,
wanted_npubs: &[String],
connected_npubs: &[String],
lan_direct: &[SeedAnchor],
) -> Vec<SeedAnchor> {
let mut out = Vec::new();
for npub in wanted_npubs {
if connected_npubs.iter().any(|c| c == npub) {
continue;
}
if lan_direct.iter().any(|a| &a.npub == npub) {
continue;
}
if let Some(e) = known.get(npub) {
out.push(SeedAnchor {
npub: npub.clone(),
address: e.address.clone(),
transport: e.transport.clone(),
label: "last-known-good endpoint (direct FIPS)".to_string(),
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn ep(addr: &str) -> KnownEndpoint {
KnownEndpoint {
address: addr.to_string(),
transport: "udp".to_string(),
last_ok_unix: now_unix(),
}
}
#[tokio::test]
async fn record_and_reload_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let connected = vec![ConnectedPeer {
npub: "npub1aaa".into(),
address: "100.114.134.21:2121".into(),
transport: "udp".into(),
}];
let map = record_connected(dir.path(), &connected).await;
assert_eq!(map["npub1aaa"].address, "100.114.134.21:2121");
let reloaded = load(dir.path()).await;
assert_eq!(reloaded, map);
}
#[tokio::test]
async fn expired_entries_are_pruned_on_record() {
let dir = tempfile::tempdir().unwrap();
let mut stale = HashMap::new();
stale.insert(
"npub1old".to_string(),
KnownEndpoint {
address: "10.0.0.1:2121".into(),
transport: "udp".into(),
last_ok_unix: now_unix() - RETENTION_SECS - 60,
},
);
save(dir.path(), &stale).await.unwrap();
let map = record_connected(dir.path(), &[]).await;
assert!(map.is_empty());
}
#[test]
fn fallback_skips_connected_and_lan_covered_peers() {
let mut known = HashMap::new();
known.insert("npub1gone".to_string(), ep("100.1.2.3:2121"));
known.insert("npub1conn".to_string(), ep("100.1.2.4:2121"));
known.insert("npub1lan".to_string(), ep("100.1.2.5:2121"));
let wanted: Vec<String> = ["npub1gone", "npub1conn", "npub1lan", "npub1never"]
.iter()
.map(|s| s.to_string())
.collect();
let connected = vec!["npub1conn".to_string()];
let lan = vec![SeedAnchor {
npub: "npub1lan".into(),
address: "192.168.63.198:2121".into(),
transport: "udp".into(),
label: "LAN".into(),
}];
let out = fallback_anchors(&known, &wanted, &connected, &lan);
assert_eq!(out.len(), 1);
assert_eq!(out[0].npub, "npub1gone");
assert_eq!(out[0].address, "100.1.2.3:2121");
// npub1never has no stored endpoint → nothing to dial.
}
}
+1
View File
@@ -29,6 +29,7 @@ pub mod anchors;
pub mod app_ports;
pub mod config;
pub mod dial;
pub mod endpoints;
pub mod iface;
pub mod service;
pub mod telemetry;
+46
View File
@@ -227,6 +227,52 @@ pub async fn peer_connectivity_summary(anchor_candidates: &[String]) -> (u32, bo
(authenticated_peer_count, anchor_connected)
}
/// Currently-connected peers with their live endpoints, from
/// `fipsctl show peers` (`transport_addr`/`transport_type`). Feeds the
/// last-known-good endpoint store (A3.10); empty on any failure.
pub async fn connected_peer_endpoints() -> Vec<crate::fips::endpoints::ConnectedPeer> {
let peers_json = match Command::new("sudo")
.args(["-n", "fipsctl", "show", "peers"])
.output()
.await
{
Ok(o) if o.status.success() => o.stdout,
_ => return Vec::new(),
};
let parsed: serde_json::Value = match serde_json::from_slice(&peers_json) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
parsed
.get("peers")
.and_then(|p| p.as_array())
.map(|peers| {
peers
.iter()
.filter(|p| {
p.get("connectivity")
.and_then(|c| c.as_str())
.map(|s| s == "connected")
.unwrap_or(false)
})
.filter_map(|p| {
let npub = p.get("npub").and_then(|n| n.as_str())?;
let address = p.get("transport_addr").and_then(|a| a.as_str())?;
let transport = p
.get("transport_type")
.and_then(|t| t.as_str())
.unwrap_or("udp");
Some(crate::fips::endpoints::ConnectedPeer {
npub: npub.to_string(),
address: address.to_string(),
transport: transport.to_string(),
})
})
.collect()
})
.unwrap_or_default()
}
/// Read the upstream daemon's public key at `/etc/fips/fips.pub` and return
/// it as a bech32 npub. Returns `Ok(None)` if the file doesn't exist — used
/// as a fallback on legacy/dev nodes where no seed-derived key exists.
+5
View File
@@ -409,6 +409,11 @@ async fn main() -> Result<()> {
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
tokio::spawn(bootstrap::ensure_kiosk_hardened());
// Repair our own restart policy before anything else can need it: a node
// whose unit still says Restart=on-failure stays dead after the next
// in-process update, because the daemon exits cleanly to be restarted.
tokio::spawn(bootstrap::ensure_restart_policy());
// HDMI audio: install the PipeWire stack + audio-router daemon on kiosk
// nodes (older ISOs shipped no audio stack; the router also heals the
// boot-time ELD race that leaves HDMI silently unavailable).
+74 -5
View File
@@ -847,6 +847,39 @@ impl Server {
if !direct.is_empty() {
let _ = crate::fips::anchors::apply(&direct).await;
}
// A3.10 — endpoint fallback for direct peering. Record
// where currently-connected peers actually are (their
// transport_addr covers LAN, Tailscale, and WAN alike),
// then re-dial the last-known-good endpoint of every
// federation peer whose live paths are gone: not
// connected now, no LAN direct entry this tick. Escala-
// tion order is LAN → last-known-good → anchor tree;
// a stale candidate costs one bounded failed dial.
let connected = crate::fips::service::connected_peer_endpoints().await;
let known =
crate::fips::endpoints::record_connected(&data_dir, &connected).await;
let wanted: Vec<String> = reg
.all_peers()
.await
.iter()
.filter_map(|p| p.fips_npub.clone())
.collect();
let connected_npubs: Vec<String> =
connected.iter().map(|c| c.npub.clone()).collect();
let fallback = crate::fips::endpoints::fallback_anchors(
&known,
&wanted,
&connected_npubs,
&direct,
);
if !fallback.is_empty() {
tracing::info!(
count = fallback.len(),
"dialing last-known-good endpoints for disconnected federation peers"
);
let _ = crate::fips::anchors::apply(&fallback).await;
}
}
let next = if daemon_restarting && fast_retries < MAX_FAST_RETRIES {
@@ -1145,16 +1178,52 @@ fn fips_app_relay_addr(ip: std::net::Ipv6Addr, port: u16) -> SocketAddr {
/// without a daemon restart. Each relay binds to the fips0 ULA only and
/// forwards raw TCP to the same port on IPv4 loopback.
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
use std::collections::HashSet;
let mut bridged: HashSet<u16> = HashSet::new();
use std::collections::HashMap;
let mut bridged: HashMap<u16, tokio::task::JoinHandle<()>> = HashMap::new();
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = interval.tick() => {
let Some(fips_ip) = crate::fips::iface::fips0_ula() else { continue };
// This relay is a raw unauthenticated forward from the mesh to
// the app's loopback, so it must refuse two classes of port:
//
// * `auth: gated` — the app gate owns the fips0 ULA for these,
// and bridging one would bypass the login page. Which of the
// two won the bind used to be a race.
// * `auth: local` — host-local BY INTENT. Bridging one makes a
// port reachable from the whole mesh that was deliberately
// never externally reachable: nbxplorer 32838 answered HTTP
// 200 over the mesh with no credential (archi-dev-box
// 2026-08-04) purely because it appeared in the static port
// list below.
//
// Undeclared ports keep today's behaviour — silence is not an
// instruction in either direction, and this relay predates the
// declarations.
let port_map = crate::appgate::identity::build_port_map();
let gate_owned: std::collections::HashSet<u16> = port_map
.gated_ports()
.filter(|g| g.declared)
.map(|g| g.port)
.collect();
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
if bridged.contains(&port) {
let withhold = if gate_owned.contains(&port) {
Some("port is now gate-owned")
} else if port_map.is_declared_local(port) {
Some("port is declared auth: local (host-local by intent)")
} else {
None
};
if let Some(reason) = withhold {
if let Some(handle) = bridged.remove(&port) {
handle.abort();
info!(port, reason, "v6 relay released a bridge");
}
continue;
}
if bridged.contains_key(&port) {
continue;
}
// ONLY bridge a port that a running app already answers on
@@ -1181,10 +1250,9 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
// EADDRINUSE = fipsd or another process already answers
// on this mesh address/port, so stay out of the way.
let Ok(listener) = bind_v6_only(addr) else { continue };
bridged.insert(port);
debug!("v6 relay bridging [{fips_ip}]:{port} -> 127.0.0.1:{port}");
let mut rx = shutdown_rx.clone();
tokio::spawn(async move {
let handle = tokio::spawn(async move {
loop {
tokio::select! {
accepted = listener.accept() => {
@@ -1205,6 +1273,7 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
}
}
});
bridged.insert(port, handle);
}
}
_ = shutdown_rx.changed() => return,
+39 -4
View File
@@ -40,12 +40,19 @@ struct Session {
created_at: SystemTime,
last_activity: SystemTime,
session_type: SessionType,
/// What kind of screen this login came from. A TV on the wall must not
/// be signed out for sitting still — nobody is there to type a password
/// back in — while a browser must be.
device_class: crate::settings::session_policy::DeviceClass,
}
#[derive(Clone)]
pub struct SessionStore {
sessions: Arc<RwLock<HashMap<[u8; 32], Session>>>,
persist_path: PathBuf,
/// Where the session policy lives. Held rather than looked up globally
/// so tests can point at a temp dir.
data_dir: PathBuf,
}
/// On-disk representation of a persisted session (only Full sessions, no TOTP secrets).
@@ -67,6 +74,7 @@ impl SessionStore {
Self {
sessions: Arc::new(RwLock::new(sessions)),
persist_path,
data_dir: PathBuf::from("/var/lib/archipelago"),
}
}
@@ -75,9 +83,17 @@ impl SessionStore {
/// machine's real /var/lib/archipelago/sessions.json.
#[cfg(test)]
pub fn new_for_tests(persist_path: PathBuf) -> Self {
// data_dir shares the temp path's parent so a test that writes a
// policy file is honoured, and one that doesn't gets the defaults
// rather than the dev machine's real configuration.
let data_dir = persist_path
.parent()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
Self {
sessions: Arc::new(RwLock::new(HashMap::new())),
persist_path,
data_dir,
}
}
@@ -120,6 +136,7 @@ impl SessionStore {
created_at,
last_activity,
session_type: SessionType::Full,
device_class: crate::settings::session_policy::DeviceClass::Browser,
},
);
}
@@ -160,6 +177,7 @@ impl SessionStore {
created_at: now,
last_activity: now,
session_type: SessionType::Full,
device_class: crate::settings::session_policy::DeviceClass::Browser,
};
let mut sessions = self.sessions.write().await;
@@ -184,6 +202,10 @@ impl SessionStore {
totp_secret,
attempts: 0,
},
// A half-finished login is always treated as a browser: it lives
// for PENDING_SESSION_TTL either way, and a kiosk exemption on a
// session that has not passed 2FA yet would be the wrong default.
device_class: crate::settings::session_policy::DeviceClass::Browser,
};
self.sessions.write().await.insert(hash, session);
token
@@ -192,19 +214,23 @@ impl SessionStore {
/// Validate a full session token. Returns true if the session exists and hasn't expired.
/// Updates last_activity on successful validation (inactivity-based expiry).
pub async fn validate(&self, token: &str) -> bool {
let policy = self.policy().await;
let hash = hash_token(token);
let mut sessions = self.sessions.write().await;
if let Some(session) = sessions.get_mut(&hash) {
if !matches!(session.session_type, SessionType::Full) {
return false;
}
if session
let idle = session
.last_activity
.elapsed()
.unwrap_or_default()
.as_secs()
>= FULL_SESSION_TTL
{
.as_secs();
let age = session.created_at.elapsed().unwrap_or_default().as_secs();
// Both limits, not just idleness: the dashboard polls, so an
// idle timeout alone would never fire on an open tab. The
// absolute cap is what actually guarantees a login ends.
if policy.is_expired(session.device_class, age, idle) {
sessions.remove(&hash);
return false;
}
@@ -215,6 +241,13 @@ impl SessionStore {
}
}
/// The operator's session policy, re-read from disk rather than cached
/// for the process lifetime so a change in Settings takes effect on the
/// next request instead of the next restart.
pub async fn policy(&self) -> crate::settings::session_policy::SessionPolicy {
crate::settings::session_policy::load(&self.data_dir).await
}
/// Get the TOTP secret from a pending session. Returns None if not a valid pending session.
/// Increments the attempt counter.
pub async fn get_pending_secret(&self, token: &str) -> Option<Vec<u8>> {
@@ -259,6 +292,7 @@ impl SessionStore {
created_at: now,
last_activity: now,
session_type: SessionType::Full,
device_class: crate::settings::session_policy::DeviceClass::Browser,
},
);
Self::save_to_disk(&sessions, &self.persist_path).await;
@@ -300,6 +334,7 @@ impl SessionStore {
created_at: now,
last_activity: now,
session_type: SessionType::Full,
device_class: crate::settings::session_policy::DeviceClass::Browser,
},
);
Self::save_to_disk(&sessions, &self.persist_path).await;
+1
View File
@@ -4,4 +4,5 @@
//! call sites (deep in the transport / RPC / ingest stacks) don't need
//! to thread a data_dir or Arc through the entire call graph.
pub mod session_policy;
pub mod transport;
@@ -0,0 +1,200 @@
//! How long a login lasts, and who gets to say so.
//!
//! # Why this is configurable rather than a constant
//!
//! There is no single correct session lifetime. The same node can be a
//! wall-mounted TV in a living room that must never ask for a password
//! mid-film, and a wallet holding real funds where PCI DSS-style guidance
//! says fifteen minutes. Both are legitimate; the operator knows which one
//! this node is and we do not.
//!
//! # The two tokens
//!
//! * **Session token** — short-lived, refreshed silently on every
//! authenticated request. This is what the browser sends; if it leaks, it
//! is useful only until [`SessionPolicy::idle_timeout_secs`] of silence.
//! * **Login (remember) token** — long-lived, and its *only* power is to
//! mint a fresh session token. Kept separate so raising the convenience
//! knob does not put a 30-day bearer credential on every request.
//!
//! Raising the idle timeout therefore does not weaken the credential that
//! actually travels; it only changes how long a quiet tab stays usable.
//!
//! # Why an absolute cap exists at all
//!
//! Idle timeout alone can be defeated by any page that polls — the
//! dashboard polls constantly, so an idle timeout would never fire while a
//! tab is open. The absolute cap is what guarantees a login eventually
//! ends, which is the property an auditor actually asks about.
use serde::{Deserialize, Serialize};
use std::path::Path;
const FILE_PATH: &str = "settings/session_policy.json";
/// Bounds. A setting that can be made meaningless is not a setting, and one
/// that can lock the operator out of their own node is a footgun.
const MIN_IDLE_SECS: u64 = 60;
const MAX_IDLE_SECS: u64 = 90 * 24 * 3600;
const MIN_ABSOLUTE_SECS: u64 = 300;
const MAX_ABSOLUTE_SECS: u64 = 365 * 24 * 3600;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DeviceClass {
/// Ordinary browser on a phone or laptop. Policy applies as configured.
Browser,
/// A screen nobody logs into — a wall-mounted dashboard or TV. Being
/// signed out mid-view is the failure mode here, not a stale session:
/// the device is physically in the home, and there is no keyboard to
/// re-authenticate with. Exempt from the idle timeout, still subject to
/// the absolute cap so a stolen box does not stay authenticated forever.
Kiosk,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionPolicy {
/// Silence after which a session token stops validating.
pub idle_timeout_secs: u64,
/// Hard ceiling from login, regardless of activity. `None` = no cap.
pub absolute_timeout_secs: Option<u64>,
/// Re-prompt for the password before actions that move money, however
/// fresh the session is. Independent of the timeouts on purpose: it is
/// the control that matters when funds are involved, and it costs the
/// operator nothing the rest of the time.
pub reauth_for_funds: bool,
}
impl Default for SessionPolicy {
fn default() -> Self {
Self {
// A day of silence, matching the previous hard-coded constant so
// existing nodes see no behaviour change until someone chooses.
idle_timeout_secs: 86_400,
// 30 days, aligned with the login token's own lifetime: a
// session that outlived the token which could refresh it would
// be an oddity.
absolute_timeout_secs: Some(30 * 24 * 3600),
reauth_for_funds: true,
}
}
}
impl SessionPolicy {
/// Clamp to the supported range. Applied on load as well as on save, so
/// a hand-edited file cannot disable expiry by writing `0`.
pub fn sanitized(mut self) -> Self {
self.idle_timeout_secs = self.idle_timeout_secs.clamp(MIN_IDLE_SECS, MAX_IDLE_SECS);
self.absolute_timeout_secs = self
.absolute_timeout_secs
.map(|v| v.clamp(MIN_ABSOLUTE_SECS, MAX_ABSOLUTE_SECS))
// An absolute cap below the idle timeout would expire sessions
// while they are still active, which reads as random logouts.
.map(|v| v.max(self.idle_timeout_secs));
self
}
/// Idle timeout for a given device, or `None` when idleness is not a
/// reason to expire (kiosk screens).
pub fn idle_timeout_for(&self, class: DeviceClass) -> Option<u64> {
match class {
DeviceClass::Browser => Some(self.idle_timeout_secs),
DeviceClass::Kiosk => None,
}
}
/// Has a session expired? `age` is time since login, `idle` since last
/// use. Both are checked because either alone is insufficient: idle
/// never fires on a polling dashboard, and absolute alone leaves a
/// forgotten tab usable for a month.
pub fn is_expired(&self, class: DeviceClass, age_secs: u64, idle_secs: u64) -> bool {
if let Some(limit) = self.absolute_timeout_secs {
if age_secs >= limit {
return true;
}
}
match self.idle_timeout_for(class) {
Some(limit) => idle_secs >= limit,
None => false,
}
}
}
pub async fn load(data_dir: &Path) -> SessionPolicy {
let path = data_dir.join(FILE_PATH);
match tokio::fs::read(&path).await {
Ok(bytes) => serde_json::from_slice::<SessionPolicy>(&bytes)
.map(SessionPolicy::sanitized)
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "session policy unreadable; using defaults");
SessionPolicy::default()
}),
Err(_) => SessionPolicy::default(),
}
}
pub async fn save(data_dir: &Path, policy: SessionPolicy) -> anyhow::Result<SessionPolicy> {
let policy = policy.sanitized();
let path = data_dir.join(FILE_PATH);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp = path.with_extension("json.tmp");
tokio::fs::write(&tmp, serde_json::to_vec_pretty(&policy)?).await?;
tokio::fs::rename(&tmp, &path).await?;
Ok(policy)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_the_previous_hardcoded_behaviour() {
let p = SessionPolicy::default();
assert_eq!(p.idle_timeout_secs, 86_400);
assert!(p.reauth_for_funds);
}
#[test]
fn expiry_cannot_be_disabled_by_hand_editing_the_file() {
let p = SessionPolicy {
idle_timeout_secs: 0,
absolute_timeout_secs: Some(0),
reauth_for_funds: false,
}
.sanitized();
assert!(p.idle_timeout_secs >= MIN_IDLE_SECS);
assert!(p.absolute_timeout_secs.unwrap() >= MIN_ABSOLUTE_SECS);
}
#[test]
fn absolute_cap_is_never_shorter_than_idle() {
// Otherwise a session dies while actively in use, which the operator
// experiences as being logged out at random.
let p = SessionPolicy {
idle_timeout_secs: 7 * 24 * 3600,
absolute_timeout_secs: Some(3600),
reauth_for_funds: true,
}
.sanitized();
assert_eq!(p.absolute_timeout_secs.unwrap(), p.idle_timeout_secs);
}
#[test]
fn a_kiosk_never_expires_from_idleness_but_still_has_a_ceiling() {
let p = SessionPolicy::default();
let a_week = 7 * 24 * 3600;
assert!(!p.is_expired(DeviceClass::Kiosk, 60, a_week));
assert!(p.is_expired(DeviceClass::Browser, 60, a_week));
// The absolute cap still applies to the TV.
assert!(p.is_expired(DeviceClass::Kiosk, 31 * 24 * 3600, 0));
}
#[test]
fn a_polling_dashboard_still_eventually_expires() {
// idle never grows because the page polls; only the cap saves us.
let p = SessionPolicy::default();
assert!(p.is_expired(DeviceClass::Browser, 30 * 24 * 3600, 0));
}
}
+18 -3
View File
@@ -16,13 +16,28 @@ use ed25519_dalek::VerifyingKey;
/// Hex of the pinned Ed25519 release-root public key (32 bytes / 64 hex chars).
///
/// Pinned 2026-07-02 from the release-root signing ceremony
/// (signer did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). The
/// ROTATED 2026-08-04 to did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT.
///
/// The previous root (z6Mkkid…q7ur, pinned 2026-07-02) was exposed in a chat
/// transcript and is treated as compromised.
///
/// Rotation is ORDERING-CRITICAL. Nodes pin the OLD key, so the release that
/// carries this change must itself be signed with the OLD key — that is the
/// only signature a node running the previous binary will accept. Only the
/// release AFTER it may be signed with the new key. Signing the rotation
/// release with the new key makes every node reject it and ends OTA
/// fleet-wide, recoverable only by touching each node by hand.
///
/// Verified before pinning: this hex and the did:key above are the same
/// keypair (the did:key encodes exactly these 32 bytes), checked with a
/// decoder round-tripped against the previous known-good pair. An earlier
/// candidate hex was rejected because it did not match the stated DID.
/// The
/// corresponding mnemonic is held offline by the publisher — see
/// `docs/workstream-b-signing-runbook.md`. Regenerate/verify with:
/// `RELEASE_MASTER_MNEMONIC=… archipelago ceremony pubkey`.
pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> =
Some("5d15cbee8a108f7dd288c02d29a1d9d71f198acc99186aad8008b4f28d469951");
Some("1578adccf137024159dd936f44a56e8869ac7775785962f7e92e2faf2c034418");
const ENV_OVERRIDE: &str = "ARCHY_RELEASE_ROOT_PUBKEY";
+48 -13
View File
@@ -74,7 +74,20 @@ fn is_newer(candidate: &str, current: &str) -> bool {
}
}
/// Primary OTA origin. Named host over TLS rather than the bare IP it used
/// to be: the IP pinned the fleet to one machine and one plaintext port, so
/// moving or fronting the origin meant an OTA to change where OTAs come
/// from — the one update you cannot ship if the origin is unreachable. The
/// signature is what establishes trust (see `trust::anchor`), not the
/// transport, but HTTPS also stops a network observer seeing which version
/// a node runs.
const DEFAULT_UPDATE_MANIFEST_URL: &str =
"https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json";
/// The previous IP-based origin, kept as an automatic fallback so a node
/// whose DNS or TLS is broken still updates. Dropped from the mirror list
/// once the fleet has moved.
const LEGACY_UPDATE_MANIFEST_URL: &str =
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
const UPDATE_STATE_FILE: &str = "update_state.json";
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
@@ -113,10 +126,19 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf {
}
fn default_mirrors() -> Vec<UpdateMirror> {
vec![UpdateMirror {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Server 1 (OVH)".to_string(),
}]
vec![
UpdateMirror {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Archipelago Foundation".to_string(),
},
// Fallback, tried only if the named origin fails: a node whose DNS
// or clock is wrong (both break TLS) must still be able to update
// itself, and the signature check is what makes either source safe.
UpdateMirror {
url: LEGACY_UPDATE_MANIFEST_URL.to_string(),
label: "Direct (fallback)".to_string(),
},
]
}
/// Load the operator-configured mirror list. Returns defaults if the
@@ -186,15 +208,18 @@ fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
}
for mirror in list.iter_mut() {
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
mirror.label = "Server 1 (OVH)".to_string();
mirror.label = "Archipelago Foundation".to_string();
} else if mirror.url == LEGACY_UPDATE_MANIFEST_URL {
mirror.label = "Direct (fallback)".to_string();
}
}
list.sort_by_key(|m| {
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
0
} else {
1
}
// Named origin first, its IP fallback second, anything the operator
// added after that. Ordering matters: the list is tried in order, so a
// stale entry sitting first costs a timeout on every check.
list.sort_by_key(|m| match m.url.as_str() {
u if u == DEFAULT_UPDATE_MANIFEST_URL => 0,
u if u == LEGACY_UPDATE_MANIFEST_URL => 1,
_ => 2,
});
}
@@ -2373,8 +2398,18 @@ mod tests {
async fn test_load_mirrors_returns_defaults_when_absent() {
let dir = tempfile::tempdir().unwrap();
let list = load_mirrors(dir.path()).await.unwrap();
assert_eq!(list.len(), 1);
assert!(list[0].url.contains("146.59.87.168"));
// The named origin leads, its IP fallback follows. A node with broken
// DNS or a wrong clock (both break TLS) must still have a way to
// update; the signature is what makes either source trustworthy.
assert_eq!(list.len(), 2);
assert!(
list[0]
.url
.starts_with("https://source.archipelago-foundation.org/"),
"the named origin must be primary, got {}",
list[0].url
);
assert!(list[1].url.contains("146.59.87.168"));
assert!(
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
"tx1138 was retired as a release server and must not be a default mirror"
+12 -2
View File
@@ -1040,6 +1040,12 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
let mut wallet = load_wallet(data_dir).await?;
let mut received_total = 0u64;
// MintClient translates the mint's NUT error code into plain language and
// puts it at the top of the error chain (see `mint_error` in
// mint_client.rs); `{}` surfaces that, `{:#}` keeps the raw status/body
// for the log. Remember the last one so a total failure can tell the user
// *why* instead of just "nothing was received".
let mut last_reason: Option<String> = None;
// Swap proofs at each mint
for entry in &token.token {
@@ -1051,14 +1057,18 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
received_total += amount;
}
Err(e) => {
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e);
last_reason = Some(e.to_string());
// Continue with other mints if any
}
}
}
if received_total == 0 {
anyhow::bail!("Failed to receive any proofs from token");
match last_reason {
Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason),
None => anyhow::bail!("Failed to receive any proofs from token"),
}
}
wallet.record_tx(
+71 -5
View File
@@ -59,6 +59,72 @@ pub struct MintResult {
pub proofs: Vec<Proof>,
}
/// Translate a Cashu NUT "transaction validation" error code into plain
/// language a wallet user can act on. Mints respond to a rejected request
/// with `{"code": N, "detail": "..."}`; `detail` is implementation-defined
/// free text, but `code` is the stable identifier from the spec
/// (https://github.com/cashubtc/nuts/blob/main/error_codes.md). Covers the
/// 10001-11017 "proof/transaction validation" range plus the 12001-12003
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call
/// can actually hit. Returns `None` for anything else (e.g. Lightning/quote
/// codes in the 20000s) so the caller falls back to the mint's own `detail`.
fn describe_mint_error_code(code: i64) -> Option<&'static str> {
Some(match code {
10001 => "The mint rejected these coins as invalid.",
11001 => "This ecash has already been redeemed — it can't be claimed twice.",
11002 => "This ecash is already being redeemed elsewhere — try again in a moment.",
11003 => "The mint already issued new coins for this exact request — there's nothing left to redeem.",
11004 => "This request is still being processed by the mint — try again in a moment.",
11005 => "The token's amounts don't add up (inputs don't match outputs) — it may be corrupt.",
11006 => "That amount is outside the range this mint allows.",
11007 => "This token contains duplicate coins — it may be corrupt or already used.",
11008 => "The mint rejected this as a duplicate request.",
11009 | 11010 => "This token mixes incompatible currency units — the mint rejected it.",
11011 => "That Lightning invoice has no amount, which isn't supported here.",
11012 => "The amount requested doesn't match the Lightning invoice.",
11013 => "The mint doesn't support this currency unit.",
11014 | 11015 => "This token has too many coins for the mint to process in one request.",
11016 => "Duplicate quote IDs were sent in this request.",
11017 => "Too many items were sent in a single request.",
12001 => "The mint no longer recognizes the keyset that signed this token.",
12002 => "The mint's signing key for this token is inactive.",
12003 => "The mint's signing key for this token has expired.",
_ => return None,
})
}
/// Parse a mint's error body (`{"code": N, "detail": "..."}`) and pick the
/// best user-facing message: the plain-language translation when we know the
/// code, otherwise the mint's own `detail` text, otherwise the raw body.
fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
let code = parsed
.as_ref()
.and_then(|v| v.get("code"))
.and_then(|c| c.as_i64());
let detail = parsed
.as_ref()
.and_then(|v| v.get("detail"))
.and_then(|d| d.as_str());
if let Some(friendly) = code.and_then(describe_mint_error_code) {
return friendly.to_string();
}
match detail {
Some(d) if !d.is_empty() => d.to_string(),
_ => format!("mint returned {} with no further detail", status),
}
}
/// Build the error for a failed mint HTTP call: `op` + status + raw body as
/// the technical cause (visible via `{:#}` in logs), with the plain-language
/// translation layered on top via `.context()` so `{}` — what reaches the
/// wallet user — shows something actionable instead of raw mint JSON.
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
let friendly = describe_mint_error_body(status, body);
anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly)
}
/// HTTP client for a single Cashu mint.
pub struct MintClient {
url: String,
@@ -146,7 +212,7 @@ impl MintClient {
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Mint quote failed ({}): {}", status, body);
return Err(mint_error("Mint quote", status, &body));
}
res.json().await.context("Failed to parse mint quote")
@@ -212,7 +278,7 @@ impl MintClient {
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Mint tokens failed ({}): {}", status, body);
return Err(mint_error("Minting tokens", status, &body));
}
let body: serde_json::Value = res.json().await.context("Failed to parse mint response")?;
@@ -266,7 +332,7 @@ impl MintClient {
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Melt quote failed ({}): {}", status, body);
return Err(mint_error("Melt quote", status, &body));
}
res.json().await.context("Failed to parse melt quote")
@@ -293,7 +359,7 @@ impl MintClient {
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Melt failed ({}): {}", status, body);
return Err(mint_error("Melt", status, &body));
}
res.json().await.context("Failed to parse melt response")
@@ -337,7 +403,7 @@ impl MintClient {
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Swap failed ({}): {}", status, body);
return Err(mint_error("Swap", status, &body));
}
let body: serde_json::Value = res.json().await.context("Failed to parse swap response")?;
+23 -1
View File
@@ -599,6 +599,19 @@ pub struct PortMapping {
/// means the author expected an exemption they did not get.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_rationale: Option<String>,
/// Forward the node session cookie to the app on authorised requests.
///
/// The gate normally strips its own credential before proxying — an app
/// must never be in a position to log or replay the node session. The
/// first-party companion UIs (lnd-ui, bitcoin-ui, electrs-ui, fips-ui)
/// are the exception their design requires: their nginx forwards the
/// browser's session cookie to the daemon's authenticated endpoints
/// (`/proxy/lnd/*`, `/rpc/v1`, `/lnd-connect-info`), so stripping it
/// breaks every data call behind the gate with a 401 while the page
/// shell still renders (observed as "LND UI unreachable", 2026-08-05).
/// Only meaningful on a `auth: gated` port.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub session_passthrough: bool,
}
impl PortMapping {
@@ -626,6 +639,7 @@ impl From<(u16, u16)> for PortMapping {
bind: String::new(),
auth: None,
auth_rationale: None,
session_passthrough: false,
}
}
}
@@ -1703,9 +1717,17 @@ app:
}
}
exempt.sort();
// 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2),
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack
// 8091/9738/10010, lnd 9735/10009/18080, netbird 3478/8086/8087,
// pine TLS 10381 + the three voice ports (10200/10300/10400 — the
// disclosed known gap), router SSDP/mDNS 1900/5353. Every one is a
// deliberate, rationale-carrying exemption; the release-gate test
// stage timed out that cycle, so the count here lagged at 17.
assert_eq!(
exempt.len(),
17,
25,
"unauthenticated port set changed — review before updating this count: {exempt:?}"
);
}
+15
View File
@@ -366,6 +366,7 @@ impl PodmanClient {
}
let mut mounts = Vec::new();
let mut named_volumes = Vec::new();
for volume in &manifest.app.volumes {
if volume.volume_type == "tmpfs" {
let options: Vec<String> = volume
@@ -382,6 +383,19 @@ impl PodmanClient {
"type": "tmpfs",
"options": options,
}));
} else if volume.volume_type == "volume" {
// Named podman volume. The libpod create spec carries these in
// the separate `volumes` field ({Name, Dest, Options}), NOT in
// `mounts`: sending one as a bind mount makes the API treat
// the bare volume name as a host path and the create fails —
// which left indeedhub-postgres/-minio permanently absent on
// legacy-path nodes (the reconciler removed the old container
// for drift, then could never create its replacement).
named_volumes.push(serde_json::json!({
"Name": volume.source,
"Dest": volume.target,
"Options": volume.options,
}));
} else {
mounts.push(serde_json::json!({
"destination": volume.target,
@@ -464,6 +478,7 @@ impl PodmanClient {
"image": image_ref,
"portmappings": port_mappings,
"mounts": mounts,
"volumes": named_volumes,
"env": env_map,
"secret_env": secret_env_map,
"labels": labels_map,
+8 -1
View File
@@ -1,5 +1,12 @@
server {
listen 50002;
# Loopback ONLY. This container is host-networked, so this nginx binds the
# HOST's address directly `listen 50002;` meant every interface, and the
# app gate could never stand in front of it (there is no podman publish to
# pin, and the manifest declared no port, so the gate neither protected it
# nor reported it it served this page to anyone who asked, on LAN,
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
# external addresses and authenticate them; see appgate::listener.
listen 127.0.0.1:50002;
server_name _;
root /usr/share/nginx/html;
+8 -1
View File
@@ -1,5 +1,12 @@
server {
listen 8175;
# Loopback ONLY. This container is host-networked, so this nginx binds the
# HOST's address directly `listen 8175;` meant every interface, and the
# app gate could never stand in front of it (there is no podman publish to
# pin, and the manifest declared no port, so the gate neither protected it
# nor reported it it served this page to anyone who asked, on LAN,
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
# external addresses and authenticate them; see appgate::listener.
listen 127.0.0.1:8175;
server_name _;
proxy_intercept_errors on;
+8 -1
View File
@@ -1,5 +1,12 @@
server {
listen 8336;
# Loopback ONLY. This container is host-networked, so this nginx binds the
# HOST's address directly `listen 8336;` meant every interface, and the
# app gate could never stand in front of it (there is no podman publish to
# pin, and the manifest declared no port, so the gate neither protected it
# nor reported it it served this page to anyone who asked, on LAN,
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
# external addresses and authenticate them; see appgate::listener.
listen 127.0.0.1:8336;
server_name _;
root /usr/share/nginx/html;
index index.html;
+8 -1
View File
@@ -1,7 +1,14 @@
server {
# Host-networked: listen on the app's own port directly (NOT 80, which the
# host's main nginx already owns). The app is reached at http(s)://<node>:18083.
listen 18083;
# Loopback ONLY. This container is host-networked, so this nginx binds the
# HOST's address directly `listen 18083;` meant every interface, and the
# app gate could never stand in front of it (there is no podman publish to
# pin, and the manifest declared no port, so the gate neither protected it
# nor reported it it served this page to anyone who asked, on LAN,
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
# external addresses and authenticate them; see appgate::listener.
listen 127.0.0.1:18083;
server_name _;
root /usr/share/nginx/html;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.121-alpha",
"version": "1.7.125-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.121-alpha",
"version": "1.7.125-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.121-alpha",
"version": "1.7.125-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+1 -1
View File
@@ -442,7 +442,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
@@ -2,11 +2,15 @@
<BaseModal :show="show" title="" max-width="max-w-lg" @close="emit('close')">
<!-- Header: app icon + "Install Bitcoin Knots/Core" -->
<div class="flex items-center gap-4 mb-5 -mt-2">
<!-- object-contain, not the default fill: app icons are not all square
(bitcoin-knots is not), so a fixed 56x56 box distorted or cropped the
mark against the rounded corners. Contain plus a dark plate shows the
whole icon whatever its aspect ratio. -->
<img
v-if="app?.icon"
:src="app.icon"
:alt="app?.title || ''"
class="w-14 h-14 rounded-xl shadow-lg shrink-0"
class="w-14 h-14 rounded-xl shadow-lg shrink-0 object-contain bg-black/40 p-1"
/>
<div v-else class="w-14 h-14 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<svg class="w-7 h-7 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -107,7 +107,21 @@ const props = defineProps<{
const emit = defineEmits<{ close: []; received: []; scan: [] }>()
watch(() => props.show, (open) => {
if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) {
if (!open) return
// Blank slate on every open: a leftover amount/memo/token or a previous
// invoice quietly carrying into a new receive flow is exactly the stale-
// state class the operator flagged on the send modal (2026-08-05).
receiveMethod.value = 'onchain'
invoiceAmount.value = 0
invoiceMemo.value = ''
invoiceResult.value = ''
onchainAddress.value = ''
arkAddress.value = ''
ecashToken.value = ''
ecashResult.value = ''
error.value = ''
processing.value = false
if (props.autoGenerate && receiveMethod.value === 'onchain') {
void receive()
}
})
+47 -5
View File
@@ -327,15 +327,57 @@ const isSweep = computed(() => sendMethod.value === 'onchain' && sendAll.value)
function toggleSendAll() {
sendAll.value = !sendAll.value
if (sendAll.value && onchainBalance.value === null) {
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
.then((res) => { onchainBalance.value = res.balance_sats || 0 })
.catch(() => { /* balance hint is best-effort */ })
if (!sendAll.value) {
// Disarming clears the field a swept-balance figure left behind reads
// as a typed amount.
amount.value = 0
return
}
// Arming shows the swept balance IN the (disabled) amount field a field
// stuck at 0 while "send all" is lit read as "sending nothing" (operator
// feedback 2026-08-05). Refresh the figure on every arm.
const applyBalance = () => {
if (sendAll.value && onchainBalance.value !== null) amount.value = onchainBalance.value
}
applyBalance()
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
.then((res) => { onchainBalance.value = res.balance_sats || 0; applyBalance() })
.catch(() => { /* balance hint is best-effort */ })
}
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
// (and drops the swept-balance figure it wrote into the amount field).
watch(sendMethod, (m) => {
if (m !== 'onchain' && sendAll.value) {
sendAll.value = false
amount.value = 0
}
})
// Every open starts from a blank slate. Stale state from the previous send
// destination, amount, and above all an armed "send all funds" toggle is
// dangerous to inherit invisibly (operator feedback 2026-08-05).
watch(() => props.show, (shown) => {
if (!shown) return
sendMethod.value = 'lightning'
amountUnit.value = 'sats'
amountEntry.value = 0
dest.value = ''
error.value = ''
successInfo.value = null
ecashToken.value = ''
sendAll.value = false
onchainBalance.value = null
feePreset.value = 'standard'
customConfTarget.value = null
customSatPerVbyte.value = null
resolvedFeeParams.value = {}
feeEstimate.value = null
confirming.value = false
confirmBalance.value = null
invoiceAmountSats.value = null
processing.value = false
})
// --- On-chain network fee: presets map to LND confirmation targets; custom
// --- takes a block target or an explicit sat/vB rate (rate wins).
+8 -2
View File
@@ -51,10 +51,16 @@
</svg>
<p class="text-sm text-white/60 text-center">How do you want to read the QR?</p>
<!-- hasNativeQr: on the companion (plain http, no getUserMedia)
the native bridge still provides a live camera -->
<button v-if="!liveCameraUnavailable || hasNativeQr" @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
the native bridge still provides a live camera. On plain-http
desktop the button stays VISIBLE hiding it read as "the
scanner is gone" (operator, 2026-08-05); choosing it surfaces
the browser's HTTPS requirement with the fallbacks instead. -->
<button @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
Scan with camera
</button>
<p v-if="liveCameraUnavailable && !hasNativeQr" class="text-[11px] text-white/40 text-center -mt-1">
Your browser only allows live camera on HTTPS pages the photo and paste options below always work.
</p>
<button @click="photoInput?.click()" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
Upload / take a photo of the QR
</button>
+1 -6
View File
@@ -311,31 +311,26 @@ const showChatPanel = computed(() =>
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
)
const showBitcoinPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'bitcoin'
if (isMobile.value) return mobileTab.value === 'bitcoin'
return activeTab.value === 'bitcoin'
})
const showDeadmanPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'deadman'
if (isMobile.value) return mobileTab.value === 'deadman'
return activeTab.value === 'deadman'
})
const showAssistantPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'assistant'
if (isMobile.value) return mobileTab.value === 'assistant'
return activeTab.value === 'assistant'
})
const showMapPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'map'
if (isMobile.value) return mobileTab.value === 'map'
return activeTab.value === 'map'
})
const showDevicePanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'device'
if (isMobile.value) return mobileTab.value === 'device'
return activeTab.value === 'device'
@@ -2683,7 +2678,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<!-- Tools panels (3rd column on wide screens) -->
<div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
<div v-if="isWideDesktop && !isVeryWideDesktop" class="mesh-tools-tab-bar">
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">Bitcoin</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
Dead Man
@@ -4,12 +4,15 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"aiui": 5180,
"archy-mempool-web": 4080,
"archy-nbxplorer": 32838,
"bitcoin-ui": 8334,
"botfights": 9100,
"btcpay-server": 23000,
"did-wallet": 8088,
"electrs-ui": 50002,
"electrumx": 50002,
"fedimint": 8175,
"filebrowser": 8083,
"fips-ui": 8336,
"gitea": 3001,
"grafana": 3000,
"homeassistant": 8123,
+20 -11
View File
@@ -43,17 +43,26 @@
.mesh-columns-wide .mesh-chat-card { grid-column: 2; grid-row: 1; min-height: 0; overflow: hidden; }
.mesh-columns-wide .mesh-tools-wrapper { grid-column: 3; grid-row: 1; display: flex; flex-direction: column; gap: 0; min-height: 0; overflow: hidden; }
.mesh-columns-wide .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; margin-bottom: 12px; }
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(420px, 0.95fr); }
.mesh-columns-very-wide .mesh-tools-wrapper { display: grid; grid-template-rows: minmax(0, 1fr) minmax(0, 0.85fr) minmax(0, 1fr); gap: 12px; overflow: hidden; }
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-deadman-panel,
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-assistant-panel,
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-map-panel { min-height: 0; height: 100%; overflow: hidden; }
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-bitcoin-panel,
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-deadman-panel,
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-assistant-panel,
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-map-panel { flex: 1 1 auto; min-height: 0; height: auto; }
.mesh-columns-very-wide .mesh-tools-tab-bar { display: none; }
/* A very wide screen gets a roomier third column but the SAME tabbed
panel as every other desktop width. It used to stack Bitcoin, Dead Man,
AI, the map and Device on top of each other in fixed grid rows, which on
a real 2560px display clipped the first three headings to a few pixels,
letterboxed the map, and pushed Radio Settings into a scroll more
screen producing a worse view (reported with a screenshot 2026-08-05).
One tab at a time, filling the column, is what makes the map edge to
edge and every control reachable without scrolling. */
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(460px, 1fr); }
.mesh-columns-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-deadman-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-assistant-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-device-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel {
flex: 1 1 auto; min-height: 0; height: auto; overflow-y: auto;
}
/* The map is the one panel with nothing to scroll: let it consume the
column edge to edge rather than sitting in a letterbox. */
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel { overflow: hidden; padding: 0; }
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel > * { height: 100%; width: 100%; }
.mesh-columns-wide .mesh-mobile-back-btn,
.mesh-columns-wide .mesh-tab-bar { display: none; }
.mesh-status-card { padding: 16px; flex-shrink: 0; }
@@ -362,6 +362,70 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.125-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.125-alpha</span>
<span class="text-xs text-white/40">August 6, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.</strong> Since the gate went up, those screens would load their frame and then show every number as unreachable. The gate was deliberately hiding your login from the apps it protects right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins Vaultwarden, Nextcloud, Gitea on every single request; that stops too.</p>
<p><strong>IndeeHub heals itself.</strong> Three separate faults: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted about ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage the node would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. All three are fixed, and the storage fault fixes the same trap for every future multi-part app.</p>
<p><strong>Send and Receive open clean every time.</strong> Whatever you typed last an address, an amount, and above all an armed "send all funds" toggle no longer quietly carries over into the next payment. And choosing "send all funds" now shows the amount being swept instead of a confusing 0.</p>
<p><strong>The camera scanner option no longer vanishes on desktop.</strong> Browsers only allow the live camera on secure (HTTPS) pages, and the scan window used to silently hide the camera choice on plain connections, which read as "the scanner is gone". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.</p>
<p>Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.</p>
</div>
</div>
<!-- v1.7.124-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.124-alpha</span>
<span class="text-xs text-white/40">August 5, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>The most important fix here: some nodes were left switched off by their own update, and could not switch themselves back on.</strong> The node replaces its program and then exits, expecting the system to start it again but nodes installed from older images carried a setting that only restarts the program if it <em>crashes</em>. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts.</p>
<p><strong>Portainer opens again.</strong> Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1 and their stored data was written by that version, while the app list pinned a version from two years earlier so a rebuilt container landed on the old one, which will not read newer data. The correct version is pinned now, older installs upgrade cleanly, and no data was touched.</p>
<p><strong>Bitcoin starts reliably again.</strong> A leftover settings file in the Bitcoin folder one the node kept rewriting and Bitcoin no longer reads is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes it, clears stale copies, and treats any that remain as harmless.</p>
<p><strong>Every app screen opens from My Apps again.</strong> The login gate refused to be displayed inside another page at all which is exactly how My Apps opens an app so protected apps looked broken. It now allows only your own node to display it and refuses everyone else.</p>
<p><strong>The app login screen looks like the node's own now:</strong> same rotating artwork, same panel, the Archipelago mark, and the app's real icon as a tile the way My Apps shows it, instead of a plain box with a letter.</p>
<p><strong>The Mesh screen uses wide displays properly.</strong> On very large screens it stacked all five panels, clipping three headings to a sliver and squeezing the map into a letterbox. It now shows one panel at a time, filling the space, with the map edge to edge.</p>
<p><strong>You can choose how long you stay signed in.</strong> Settings Account offers an inactivity timeout, a hard limit, and an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle there is nobody there to sign them back in.</p>
<p>Updates now come from source.archipelago-foundation.org rather than a bare address, with the old one kept as an automatic fallback. Also: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.</p>
<p>Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.</p>
</div>
</div>
<!-- v1.7.123-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.123-alpha</span>
<span class="text-xs text-white/40">August 5, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>Five more screens on your node were readable by anyone who could reach it and the last release's own check said they were fine.</strong> The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all and the node builds its list of what to protect from exactly those descriptions. It neither protected them nor listed them as unprotected. Found by scanning the node from another machine instead of asking the node about itself.</p>
<p><strong>What was readable was the page, not your money.</strong> Every request on those ports that could have returned a credential the Lightning connection details, the wallet passthrough, container logs, and every node command already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was checked directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.</p>
<p>All five now serve only to the node itself, with the login gate in front, exactly like the twenty app screens closed in the last release.</p>
<p><strong>Every port on your node now has a stated policy none are left undecided.</strong> Eleven had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now ask for your node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections, NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session), Pine's secure listener, and the Lightning REST port that wallets reach with a macaroon exactly as before.</p>
<p>Freshly flashed nodes are covered too, not only existing ones those five screens ship as prebuilt images, so a new node would otherwise have come up open even after this fix. All five were rebuilt, published, then pulled back and inspected to confirm the fix is really inside them.</p>
<p>Known gaps, disclosed rather than buried: non-browser clients phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release.</p>
</div>
</div>
<!-- v1.7.122-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.122-alpha</span>
<span class="text-xs text-white/40">August 4, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>Your apps now ask for your node password before they open over your home network, Tailscale, the mesh and Tor alike.</strong> Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app.</p>
<p><strong>The things that must stay open stayed open.</strong> Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers those connections carry their own proof of identity and a login page would simply break them. Every one of these exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.</p>
<p><strong>A private address on your node was answering the mesh without a password.</strong> One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that it forwarded requests from the whole mesh straight to it. Found while checking the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.</p>
<p><strong>Tor addresses no longer skip the login.</strong> An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session. Those addresses now arrive at the login gate first, closing the last route that went around it.</p>
<p>Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after being told to move, and each would have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting.</p>
<p>The node had been reading two different sets of instructions about its own apps the signed list it downloads, and older copies on disk which is how a port meant to stay private was briefly opened. Both now come from the signed list.</p>
<p><strong>The key that signs these updates has been replaced.</strong> The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity it is the one that teaches your node the new one.</p>
<p>Known gaps, disclosed rather than buried: eleven app ports still have no stated policy BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting is what caused two incidents this week. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser apps phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web will meet the login page and need an access token; say the word if you use one and it can be exempted. The 5x real-node lifecycle gate was not run for this release.</p>
</div>
</div>
<!-- v1.7.121-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/app'
import AccountInfoSection from '@/views/settings/AccountInfoSection.vue'
import ChangePasswordSection from '@/views/settings/ChangePasswordSection.vue'
import TwoFactorSection from '@/views/settings/TwoFactorSection.vue'
import SessionTimeoutSection from '@/views/settings/SessionTimeoutSection.vue'
const router = useRouter()
const { t } = useI18n()
@@ -24,6 +25,7 @@ async function handleLogout() {
<AccountInfoSection />
<ChangePasswordSection />
<TwoFactorSection />
<SessionTimeoutSection />
<!-- Logout Button -->
<button
@@ -0,0 +1,151 @@
<script setup lang="ts">
/**
* How long a login lasts on this node.
*
* Presented as two plain questions "sign me out after quiet" and "always
* sign me out after" rather than as the two-token mechanism underneath.
* The distinction that matters to the operator is that the second one is
* what actually guarantees a login ends: this dashboard polls constantly,
* so an idle timeout alone never fires on an open tab.
*/
import { ref, onMounted, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
type Policy = {
idle_timeout_secs: number
absolute_timeout_secs: number | null
reauth_for_funds: boolean
}
const idle = ref<number>(86400)
const absolute = ref<number | null>(30 * 24 * 3600)
const reauthForFunds = ref(true)
const loading = ref(true)
const saving = ref(false)
const saved = ref(false)
const error = ref('')
// Offered as presets rather than a free number field: the useful values are
// few, and a box accepting "5" invites someone to lock themselves out.
const idleChoices = [
{ label: '15 minutes', value: 15 * 60 },
{ label: '1 hour', value: 3600 },
{ label: '1 day', value: 86400 },
{ label: '1 week', value: 7 * 24 * 3600 },
{ label: '30 days', value: 30 * 24 * 3600 },
]
const absoluteChoices = [
{ label: '1 day', value: 86400 },
{ label: '1 week', value: 7 * 24 * 3600 },
{ label: '30 days', value: 30 * 24 * 3600 },
{ label: '90 days', value: 90 * 24 * 3600 },
{ label: 'Never', value: null },
]
const shortIdleWarning = computed(() => idle.value <= 3600)
async function load() {
loading.value = true
try {
const p = await rpcClient.call<Policy>({ method: 'auth.session-policy.get', params: {} })
idle.value = p.idle_timeout_secs
absolute.value = p.absolute_timeout_secs
reauthForFunds.value = p.reauth_for_funds
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
saved.value = false
error.value = ''
try {
// The backend clamps and returns what it stored, so reflect that back
// rather than assuming our values were taken verbatim.
const p = await rpcClient.call<Policy>({
method: 'auth.session-policy.set',
params: {
idle_timeout_secs: idle.value,
absolute_timeout_secs: absolute.value,
reauth_for_funds: reauthForFunds.value,
},
})
idle.value = p.idle_timeout_secs
absolute.value = p.absolute_timeout_secs
reauthForFunds.value = p.reauth_for_funds
saved.value = true
setTimeout(() => { saved.value = false }, 2500)
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="mb-6">
<h3 class="text-base font-medium text-white/90 mb-1">Session timeout</h3>
<p class="text-sm text-white/60 mb-4">
How long this node keeps you signed in. TV and kiosk screens are never
signed out for sitting idle there is nobody there to sign them back in.
</p>
<div v-if="error" role="alert" class="mb-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg text-red-200 text-sm">
{{ error }}
</div>
<div v-if="!loading" class="space-y-4">
<div>
<label for="idle-timeout" class="block text-sm font-medium text-white/80 mb-2">Sign me out after this much inactivity</label>
<select
id="idle-timeout"
v-model.number="idle"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
>
<option v-for="c in idleChoices" :key="c.value" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
</select>
<p v-if="shortIdleWarning" class="text-xs text-orange-300/80 mt-2">
Short timeouts are what payment-industry rules ask for when funds are involved expect to sign in often.
</p>
</div>
<div>
<label for="absolute-timeout" class="block text-sm font-medium text-white/80 mb-2">Always sign me out after</label>
<select
id="absolute-timeout"
v-model="absolute"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
>
<option v-for="c in absoluteChoices" :key="String(c.value)" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
</select>
<p class="text-xs text-white/50 mt-2">
Counts from when you signed in, whatever you are doing. This is the one that
guarantees a session ends: an open dashboard is never idle, so the setting
above would not fire on it.
</p>
</div>
<label class="flex items-start gap-3 cursor-pointer">
<input v-model="reauthForFunds" type="checkbox" class="mt-1 accent-orange-500" />
<span class="text-sm text-white/80">
Ask for my password again before sending funds
<span class="block text-xs text-white/50">Recommended. Applies however recently you signed in.</span>
</span>
</label>
<button
:disabled="saving"
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 disabled:opacity-50"
@click="save"
>
{{ saving ? 'Saving…' : (saved ? 'Saved' : 'Save session settings') }}
</button>
</div>
</div>
</template>
+24 -23
View File
@@ -1,34 +1,35 @@
{
"changelog": [
"**Making another node \"Trusted\" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them.",
"**Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports — so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits.",
"The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard — that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it.",
"The Lightning screen will actually update from now on. Its image was set to \"latest\", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered.",
"Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched — so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check.",
"Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open — Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol — now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over.",
"Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged — producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops.",
"Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login — the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision)."
"**The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.",
"**IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app.",
"**A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled.",
"**Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed \"send all funds\" toggle — no longer quietly carries over into the next payment. Choosing \"send all funds\" also shows the amount being swept instead of a confusing 0.",
"**A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with \"check server logs\"; it now explains that no transaction can be built from those coins.",
"**The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as \"the scanner is gone\". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.",
"**App data folders can no longer be \"repaired\" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied.",
"Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.",
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release."
],
"components": [
{
"current_version": "1.7.121-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.121-alpha/archipelago",
"current_version": "1.7.125-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.121-alpha",
"sha256": "be5ef9fb284f539b06329d4108be53e55ae8cdb06cf1cf4beb90363de364706d",
"size_bytes": 54870968
"new_version": "1.7.125-alpha",
"sha256": "080bc83cb10b3ebe532497917d96b7af3766c36c30119cb0f348de36162d9e9d",
"size_bytes": 55060216
},
{
"current_version": "1.7.121-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.121-alpha/archipelago-frontend-1.7.121-alpha.tar.gz",
"name": "archipelago-frontend-1.7.121-alpha.tar.gz",
"new_version": "1.7.121-alpha",
"sha256": "7898a9c11fa30cadc8f0fcf814bba1e3870d20663472f4c40e8e663b2359958f",
"size_bytes": 210526689
"current_version": "1.7.125-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago-frontend-1.7.125-alpha.tar.gz",
"name": "archipelago-frontend-1.7.125-alpha.tar.gz",
"new_version": "1.7.125-alpha",
"sha256": "0bff6f169767043928d9189ab53045c9e4f7c523697b42bf770b2b485c2902f1",
"size_bytes": 210532813
}
],
"release_date": "2026-08-04",
"signature": "9d871c946e941b3c13f75fb799d8428841147267f4565920993ddd3aa0cd52d6d1a74481303cbbb26e68e6e5d44e2b711c9b7a22ad7dd73c94b4d3e39a0e2803",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.121-alpha"
"release_date": "2026-08-06",
"signature": "975157cc59527679f3de846a4a93d11e27f6ad48eed215fd769574dfc6db36a687867a35aeb6e705d41667ea800b948f53ce7f80bfcdb5a6e1820e50613d4403",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.7.125-alpha"
}
+161 -20
View File
@@ -25,6 +25,7 @@
"name": "AI Assistant",
"ports": [
{
"auth": "local",
"bind": "127.0.0.1",
"container": 80,
"host": 5180,
@@ -228,6 +229,8 @@
"name": "Mempool Web",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8080,
"host": 4080,
"protocol": "tcp"
@@ -302,6 +305,8 @@
"name": "NBXplorer",
"ports": [
{
"auth": "local",
"bind": "127.0.0.1",
"container": 32838,
"host": 32838,
"protocol": "tcp"
@@ -374,6 +379,8 @@
"name": "Ark Wallet",
"ports": [
{
"auth": "local",
"bind": "127.0.0.1",
"container": 3535,
"host": 3535,
"protocol": "tcp"
@@ -414,7 +421,7 @@
},
"container": {
"custom_args": [
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
],
"data_uid": "100101:100101",
"derived_env": [
@@ -462,12 +469,15 @@
"name": "Bitcoin Core",
"ports": [
{
"auth": "local",
"bind": "127.0.0.1",
"container": 8332,
"host": 8332,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP.",
"container": 8333,
"host": 8333,
"protocol": "tcp"
@@ -557,7 +567,7 @@
},
"container": {
"custom_args": [
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
],
"data_uid": "100101:100101",
"derived_env": [
@@ -605,12 +615,15 @@
"name": "Bitcoin Knots",
"ports": [
{
"auth": "local",
"bind": "127.0.0.1",
"container": 8332,
"host": 8332,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP.",
"container": 8333,
"host": 8333,
"protocol": "tcp"
@@ -671,7 +684,7 @@
]
},
"bitcoin-ui": {
"image": "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.84-alpha",
"image": "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.123-alpha",
"manifest": {
"app": {
"container": {
@@ -698,7 +711,16 @@
},
"id": "bitcoin-ui",
"name": "Bitcoin UI",
"ports": [],
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8334,
"host": 8334,
"protocol": "tcp",
"session_passthrough": true
}
],
"resources": {
"memory_limit": "128Mi"
},
@@ -719,7 +741,7 @@
]
}
},
"version": "1.7.84-alpha"
"version": "1.7.123-alpha"
},
"botfights": {
"manifest": {
@@ -792,6 +814,8 @@
"name": "BotFights",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 9100,
"host": 9100,
"protocol": "tcp"
@@ -931,6 +955,8 @@
"name": "BTCPay Server",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 49392,
"host": 23000,
"protocol": "tcp"
@@ -1001,11 +1027,15 @@
"name": "Core Lightning (CLN)",
"ports": [
{
"auth": "none",
"auth_rationale": "Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.",
"container": 9735,
"host": 9736,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "Core Lightning gRPC, authenticated by mutual TLS client certificates.",
"container": 9835,
"host": 9835,
"protocol": "tcp"
@@ -1075,6 +1105,8 @@
"name": "Web5 DID Wallet",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8080,
"host": 8088,
"protocol": "tcp"
@@ -1115,7 +1147,7 @@
"version": "1.0.0"
},
"electrs-ui": {
"image": "146.59.87.168:3000/lfg2025/electrs-ui:latest",
"image": "146.59.87.168:3000/lfg2025/electrs-ui:1.7.123-alpha",
"manifest": {
"app": {
"container": {
@@ -1138,7 +1170,16 @@
},
"id": "electrs-ui",
"name": "Electrs UI",
"ports": [],
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 50002,
"host": 50002,
"protocol": "tcp",
"session_passthrough": true
}
],
"resources": {
"memory_limit": "64Mi"
},
@@ -1150,7 +1191,7 @@
"volumes": []
}
},
"version": "latest"
"version": "1.7.123-alpha"
},
"electrumx": {
"image": "146.59.87.168:3000/lfg2025/electrumx:v1.18.0",
@@ -1219,6 +1260,8 @@
"name": "ElectrumX",
"ports": [
{
"auth": "none",
"auth_rationale": "Electrum wire protocol over TCP. Electrum wallets speak it directly and cannot hold a session cookie.",
"container": 50001,
"host": 50001,
"protocol": "tcp"
@@ -1332,16 +1375,22 @@
"name": "Fedimint Guardian",
"ports": [
{
"auth": "none",
"auth_rationale": "Fedimint guardian consensus. Other guardians speak the federation's own authenticated protocol here; a login page would break consensus.",
"container": 8173,
"host": 8173,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "Fedimint guardian API for federation clients, which authenticate to the federation itself and cannot hold a browser session.",
"container": 8174,
"host": 8174,
"protocol": "tcp"
},
{
"auth": "local",
"bind": "127.0.0.1",
"container": 8175,
"host": 8177,
"protocol": "tcp"
@@ -1416,6 +1465,8 @@
"name": "Fedimint Client",
"ports": [
{
"auth": "local",
"bind": "127.0.0.1",
"container": 8080,
"host": 8178,
"protocol": "tcp"
@@ -1521,11 +1572,15 @@
"name": "Fedimint Gateway",
"ports": [
{
"auth": "none",
"auth_rationale": "Fedimint gateway API, protected by its own bcrypt password (--bcrypt-password-hash) and reached by federation peers and clients that cannot hold a browser session.",
"container": 8176,
"host": 8176,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "LDK Lightning p2p for the gateway. The BOLT-8 noise handshake authenticates and encrypts the connection itself.",
"container": 9737,
"host": 9737,
"protocol": "tcp"
@@ -1601,6 +1656,8 @@
"name": "File Browser",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 80,
"host": 8083,
"protocol": "tcp"
@@ -1671,7 +1728,16 @@
},
"id": "fips-ui",
"name": "FIPS Mesh",
"ports": [],
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8336,
"host": 8336,
"protocol": "tcp",
"session_passthrough": true
}
],
"resources": {
"memory_limit": "128Mi"
},
@@ -1753,11 +1819,15 @@
},
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 3000,
"host": 3001,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "Git over SSH, authenticated by the user's own SSH keypair. Not HTTP, so the gate cannot serve a login page here.",
"container": 22,
"host": 2222,
"protocol": "tcp"
@@ -1842,6 +1912,8 @@
"name": "Grafana",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 3000,
"host": 3000,
"protocol": "tcp"
@@ -1925,6 +1997,8 @@
"name": "Home Assistant",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8123,
"host": 8123,
"protocol": "tcp"
@@ -2034,6 +2108,8 @@
"name": "Immich",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 2283,
"host": 2283,
"protocol": "tcp"
@@ -2273,6 +2349,8 @@
"name": "IndeeHub",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 7777,
"host": 7778,
"protocol": "tcp"
@@ -2647,6 +2725,8 @@
},
"security": {
"capabilities": [
"CHOWN",
"DAC_OVERRIDE",
"SETGID",
"SETUID"
],
@@ -2765,6 +2845,8 @@
"name": "Jellyfin",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8096,
"host": 8096,
"protocol": "tcp"
@@ -2852,16 +2934,22 @@
"name": "Lightning Stack",
"ports": [
{
"auth": "none",
"auth_rationale": "Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.",
"container": 9735,
"host": 9738,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.",
"container": 10009,
"host": 10010,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "LND REST, authenticated by macaroon over TLS. A browser login page would break Zeus and every non-browser wallet client, exactly as for lnd's 18080.",
"container": 8080,
"host": 8091,
"protocol": "tcp"
@@ -2950,16 +3038,22 @@
"name": "LND",
"ports": [
{
"auth": "none",
"auth_rationale": "Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.",
"container": 9735,
"host": 9735,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "LND gRPC, authenticated by macaroon over TLS. Zeus and other remote wallets depend on reaching this directly.",
"container": 10009,
"host": 10009,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "LND REST, authenticated by macaroon over TLS. A browser login page would break Zeus and every non-browser wallet client.",
"container": 8080,
"host": 18080,
"protocol": "tcp"
@@ -2998,7 +3092,7 @@
"version": "v0.18.4-beta"
},
"lnd-ui": {
"image": "146.59.87.168:3000/lfg2025/lnd-ui:latest",
"image": "146.59.87.168:3000/lfg2025/lnd-ui:1.7.123-alpha",
"manifest": {
"app": {
"container": {
@@ -3027,23 +3121,26 @@
"name": "LND UI",
"ports": [
{
"container": 80,
"auth": "gated",
"bind": "127.0.0.1",
"container": 18083,
"host": 18083,
"protocol": "tcp"
"protocol": "tcp",
"session_passthrough": true
}
],
"resources": {
"memory_limit": "64Mi"
},
"security": {
"network_policy": "bridge",
"network_policy": "host",
"readonly_root": false
},
"version": "1.0.0",
"volumes": []
}
},
"version": "latest"
"version": "1.7.123-alpha"
},
"mempool": {
"image": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1",
@@ -3093,6 +3190,8 @@
"name": "Mempool Explorer",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8080,
"host": 4080,
"protocol": "tcp"
@@ -3196,6 +3295,8 @@
"name": "Mempool API",
"ports": [
{
"auth": "local",
"bind": "127.0.0.1",
"container": 8999,
"host": 8999,
"protocol": "tcp"
@@ -3255,6 +3356,8 @@
"name": "MorphOS Server",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8080,
"host": 8089,
"protocol": "tcp"
@@ -3360,6 +3463,8 @@
"name": "NetBird",
"ports": [
{
"auth": "none",
"auth_rationale": "NetBird dashboard over TLS, with its own login. The gate speaks plain HTTP, so fronting this port would break the secure context the dashboard requires (issue #15) and the certificate clients pin.",
"container": 443,
"host": 8087,
"protocol": "tcp"
@@ -3564,11 +3669,15 @@
"name": "NetBird Server",
"ports": [
{
"auth": "none",
"auth_rationale": "NetBird management API and its OIDC issuer. Enrolled devices authenticate themselves with setup keys and JWTs, and they cannot hold a browser session — a login page here would disconnect every VPN client on the network.",
"container": 80,
"host": 8086,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "STUN over UDP for NAT traversal; it must answer unauthenticated probes to do its job at all.",
"container": 3478,
"host": 3478,
"protocol": "udp"
@@ -3653,6 +3762,8 @@
"name": "Nextcloud",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 80,
"host": 8085,
"protocol": "tcp"
@@ -3731,6 +3842,8 @@
},
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8080,
"host": 18081,
"protocol": "tcp"
@@ -3832,6 +3945,8 @@
"name": "PhotoPrism",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 2342,
"host": 2342,
"protocol": "tcp"
@@ -3951,11 +4066,15 @@
"name": "Pine",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 80,
"host": 10380,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "Pine's TLS listener. The gate speaks plain HTTP, so fronting this port would break the secure context navigator.bluetooth needs for WiFi provisioning. The plain-HTTP entry point (10380) is gated, and it is what the UI opens.",
"container": 443,
"host": 10381,
"protocol": "tcp"
@@ -4066,6 +4185,8 @@
"name": "Pine Wake Word (openWakeWord)",
"ports": [
{
"auth": "none",
"auth_rationale": "Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.",
"container": 10400,
"host": 10400,
"protocol": "tcp"
@@ -4144,6 +4265,8 @@
"name": "Pine Piper (TTS)",
"ports": [
{
"auth": "none",
"auth_rationale": "Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.",
"container": 10200,
"host": 10200,
"protocol": "tcp"
@@ -4226,6 +4349,8 @@
"name": "Pine Whisper (STT)",
"ports": [
{
"auth": "none",
"auth_rationale": "Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.",
"container": 10300,
"host": 10300,
"protocol": "tcp"
@@ -4256,13 +4381,13 @@
"version": "3.4.2"
},
"portainer": {
"image": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
"image": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
"manifest": {
"app": {
"category": "development",
"container": {
"data_uid": "1000:1000",
"image": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
"image": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
"pull_policy": "if-not-present"
},
"dependencies": [
@@ -4298,6 +4423,8 @@
"name": "Portainer",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 9000,
"host": 9000,
"protocol": "tcp"
@@ -4347,7 +4474,7 @@
]
}
},
"version": "2.19.4"
"version": "2.39.1"
},
"router": {
"manifest": {
@@ -4389,16 +4516,22 @@
},
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8080,
"host": 8084,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "mDNS is UDP multicast service discovery; gating it would break .local name resolution for every device on the LAN.",
"container": 5353,
"host": 5353,
"protocol": "udp"
},
{
"auth": "none",
"auth_rationale": "SSDP/UPnP discovery is UDP multicast — there is no HTTP request to gate and no client that could hold a session.",
"container": 1900,
"host": 1900,
"protocol": "udp"
@@ -4478,6 +4611,8 @@
"name": "SearXNG",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 8080,
"host": 8888,
"protocol": "tcp"
@@ -4549,6 +4684,8 @@
},
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 7777,
"host": 8090,
"protocol": "tcp"
@@ -4639,6 +4776,8 @@
"name": "Uptime Kuma",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 3001,
"host": 3002,
"protocol": "tcp"
@@ -4720,6 +4859,8 @@
"name": "Vaultwarden",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 80,
"host": 8082,
"protocol": "tcp"
@@ -4756,7 +4897,7 @@
}
},
"schema": 1,
"signature": "1fe1b962317212c15b83c9ae8b0b2957f9663bb7dda3f4d117123aab496ddd4f94fa48f7d4abfd4be5b771510aed51f8ede870e55cb3fd21ce2453bea1d3510e",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"updated": "2026-07-31"
"signature": "a9a0bf60aa6c47ae970a6c7c3e19e9390ee7af157f425c2e38b1bbb194f0315b73ddf16bc46244b36390454787a1ddecc559be806098fdf569d3315f388b9006",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"updated": "2026-08-06"
}
+24 -23
View File
@@ -1,34 +1,35 @@
{
"changelog": [
"**Making another node \"Trusted\" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them.",
"**Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports — so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits.",
"The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard — that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it.",
"The Lightning screen will actually update from now on. Its image was set to \"latest\", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered.",
"Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched — so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check.",
"Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open — Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol — now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over.",
"Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged — producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops.",
"Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login — the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision)."
"**The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.",
"**IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app.",
"**A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled.",
"**Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed \"send all funds\" toggle — no longer quietly carries over into the next payment. Choosing \"send all funds\" also shows the amount being swept instead of a confusing 0.",
"**A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with \"check server logs\"; it now explains that no transaction can be built from those coins.",
"**The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as \"the scanner is gone\". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.",
"**App data folders can no longer be \"repaired\" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied.",
"Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.",
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release."
],
"components": [
{
"current_version": "1.7.121-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.121-alpha/archipelago",
"current_version": "1.7.125-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.121-alpha",
"sha256": "be5ef9fb284f539b06329d4108be53e55ae8cdb06cf1cf4beb90363de364706d",
"size_bytes": 54870968
"new_version": "1.7.125-alpha",
"sha256": "080bc83cb10b3ebe532497917d96b7af3766c36c30119cb0f348de36162d9e9d",
"size_bytes": 55060216
},
{
"current_version": "1.7.121-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.121-alpha/archipelago-frontend-1.7.121-alpha.tar.gz",
"name": "archipelago-frontend-1.7.121-alpha.tar.gz",
"new_version": "1.7.121-alpha",
"sha256": "7898a9c11fa30cadc8f0fcf814bba1e3870d20663472f4c40e8e663b2359958f",
"size_bytes": 210526689
"current_version": "1.7.125-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago-frontend-1.7.125-alpha.tar.gz",
"name": "archipelago-frontend-1.7.125-alpha.tar.gz",
"new_version": "1.7.125-alpha",
"sha256": "0bff6f169767043928d9189ab53045c9e4f7c523697b42bf770b2b485c2902f1",
"size_bytes": 210532813
}
],
"release_date": "2026-08-04",
"signature": "9d871c946e941b3c13f75fb799d8428841147267f4565920993ddd3aa0cd52d6d1a74481303cbbb26e68e6e5d44e2b711c9b7a22ad7dd73c94b4d3e39a0e2803",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.121-alpha"
"release_date": "2026-08-06",
"signature": "975157cc59527679f3de846a4a93d11e27f6ad48eed215fd769574dfc6db36a687867a35aeb6e705d41667ea800b948f53ce7f80bfcdb5a6e1820e50613d4403",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.7.125-alpha"
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Syntax-check the shell embedded in apps/*/manifest.yml.
A manifest can carry a whole startup script in `container.custom_args` /
`entrypoint`. Nothing validated it, so a broken one shipped through the
signed catalog and only failed on the node as a container that exits
instantly and an app that vanishes from the UI.
Two checks, both learned from v1.7.124 (bitcoin-knots / bitcoin-core):
1. `sh -n` the snippet. The break was `sh: Syntax error: "fi" unexpected`,
which no YAML parse and no Rust test could have caught.
2. Reject `#` inside the snippet. These are YAML **folded** scalars (`>-`),
where `#` is NOT a comment — it is literal text that reaches the shell,
and because folding joins lines with spaces it comments out the rest of
the folded line. That is exactly how an `if ... then` was swallowed while
its more-indented body survived, leaving an orphan `fi`. Put explanations
above the `- >-` line, where YAML really does treat them as comments.
"""
from __future__ import annotations
import glob
import os
import subprocess
import sys
import tempfile
import yaml
# Long enough to be a script rather than a flag.
MIN_SCRIPT_LEN = 60
def snippets(path: str):
with open(path, encoding="utf-8") as fh:
data = yaml.safe_load(fh)
container = ((data or {}).get("app") or {}).get("container") or {}
for key in ("custom_args", "entrypoint"):
value = container.get(key)
if not isinstance(value, list):
continue
for i, part in enumerate(value):
if isinstance(part, str) and len(part) >= MIN_SCRIPT_LEN:
yield f"{key}[{i}]", part
def main() -> int:
failures = []
checked = 0
for path in sorted(glob.glob("apps/*/manifest.yml")):
app = os.path.basename(os.path.dirname(path))
try:
found = list(snippets(path))
except Exception as exc: # noqa: BLE001 — report, don't crash the gate
failures.append(f"{app}: manifest does not parse: {exc}")
continue
for where, script in found:
checked += 1
if "#" in script:
failures.append(
f"{app} {where}: contains '#'. In a folded YAML scalar that is not a "
f"comment — it reaches the shell and comments out the rest of the "
f"folded line. Move the explanation above the '- >-' line."
)
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as tmp:
tmp.write(script)
tmp_path = tmp.name
try:
proc = subprocess.run(
["sh", "-n", tmp_path], capture_output=True, text=True, check=False
)
finally:
os.unlink(tmp_path)
if proc.returncode != 0:
failures.append(f"{app} {where}: {proc.stderr.strip()}")
for f in failures:
print(f"MANIFEST-SHELL {f}", file=sys.stderr)
print(f'{{"snippets_checked": {checked}, "failures": {len(failures)}}}')
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+5 -1
View File
@@ -240,7 +240,11 @@ install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION
# warning and falls through — and the commit then happened anyway. A release
# commit carrying a manifest no node will accept has no valid use, so refuse
# to create one rather than leave a tag that has to be re-cut.
EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur"
# Release root ROTATED 2026-08-05. v1.7.122-alpha was the last release signed
# with the old root (z6Mkkid…q7ur) — it is the release that installed this
# pin on every node. From v1.7.123 onward the new root signs, and nodes
# running .122+ reject anything signed with the old key.
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then
echo "" >&2
+11 -5
View File
@@ -45,7 +45,13 @@ SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0"
FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0"
NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.19.4"
# 2.39.1 is what the fleet has actually been running via the moving :latest
# tag, and it is the version that wrote their databases. Pinning back to
# 2.19.4 (2 years older) made Portainer refuse to start the moment a
# container was recreated: "database schema version does not align with the
# server version" — it migrates a DB forward, never backward. Pinned
# forward and published as a concrete tag so this is reproducible.
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.39.1"
# Networking
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"
@@ -84,7 +90,7 @@ STRFRY_IMAGE="$ARCHY_REGISTRY/strfry:1.0.4"
NOSTR_VPN_IMAGE="$ARCHY_REGISTRY/nostr-vpn:v0.3.7"
NOSTR_VPN_UI_IMAGE="$ARCHY_REGISTRY/nostr-vpn-ui:latest"
FIPS_IMAGE="$ARCHY_REGISTRY/fips:v0.1.0"
FIPS_UI_IMAGE="$ARCHY_REGISTRY/fips-ui:latest"
FIPS_UI_IMAGE="$ARCHY_REGISTRY/fips-ui:1.7.123-alpha"
# AI / Routing
ROUTSTR_IMAGE="$ARCHY_REGISTRY/routstr:v0.4.3"
@@ -117,9 +123,9 @@ PENPOT_EXPORTER_IMAGE="$ARCHY_REGISTRY/penpot-exporter:2.4"
PENPOT_FRONTEND_IMAGE="$ARCHY_REGISTRY/penpot-frontend:2.4"
# Custom UI containers (built from docker/ dirs, pushed to registry)
BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.119-alpha"
LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:1.7.119-alpha"
ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:latest"
BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.123-alpha"
LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:1.7.123-alpha"
ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:1.7.123-alpha"
# Base images
NGINX_ALPINE_IMAGE="$ARCHY_REGISTRY/nginx:1.27.4-alpine"
+16 -7
View File
@@ -29,7 +29,9 @@ fail() { echo "Error: $*" >&2; exit 1; }
# with the pinned release-root anchor refuse to auto-apply unsigned manifests,
# and enforcement will tighten to hard-reject — an unsigned publish would
# strand them. Grep proves presence; ceremony verify proves the crypto.
EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur"
# Release root ROTATED 2026-08-05; see create-release.sh. New root from
# v1.7.123 onward.
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \
|| fail "releases/manifest.json is not signed by the release root — run: bash scripts/sign-manifest.sh"
@@ -39,18 +41,25 @@ if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
fi
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
# https is accepted as well as http. Requiring http:// meant the only remote
# whose credential actually works for git push (the https one) was rejected,
# while the http remote it forced you to use had a dead token — so publishing
# failed on auth after the manifest had already passed every check
# (v1.7.121-alpha, 2026-08-04). The scheme is carried through to the API URL
# rather than assumed.
case "$remote_url" in
http://*@*) ;;
*) fail "$REMOTE must be an authenticated http:// Gitea remote URL for API uploads" ;;
http://*@*|https://*@*) ;;
*) fail "$REMOTE must be an authenticated http(s):// Gitea remote URL for API uploads" ;;
esac
auth=${remote_url#http://}
auth=${auth%@*}
host_path=${remote_url#http://$auth@}
scheme=${remote_url%%://*}
rest=${remote_url#*://}
auth=${rest%%@*}
host_path=${rest#*@}
host=${host_path%%/*}
repo_path=${host_path#*/}
repo_path=${repo_path%.git}
api="http://$host/api/v1/repos/$repo_path"
api="$scheme://$host/api/v1/repos/$repo_path"
release_url="$api/releases/tags/v${VERSION}"
echo "Pushing main and v${VERSION} to $REMOTE..."
+6 -1
View File
@@ -809,7 +809,12 @@ ensure_bitcoin_ui_nginx_conf() {
tmp="${CONF_PATH}.tmp.$$"
sudo tee "$tmp" >/dev/null << EOF
server {
listen 8334;
# Loopback ONLY — this is the fourth copy of this declaration (the others
# are the Rust template in container/bitcoin_ui_nginx.conf.template, the
# image, and the manifest). Host networking means this nginx binds the
# HOST's address, so \`listen 8334;\` served the Bitcoin screen on every
# interface with no login. The app gate owns the external addresses now.
listen 127.0.0.1:8334;
server_name _;
root /usr/share/nginx/html;
index index.html;
+23 -3
View File
@@ -321,7 +321,17 @@ fi
UI_DOCKER_DEST="/opt/archipelago/docker"
sudo mkdir -p "$UI_DOCKER_DEST"
UI_REBUILD_LIST=""
for ui in bitcoin-ui lnd-ui electrs-ui; do
# fips-ui and fedimint-ui are synced but NOT added to UI_REBUILD_LIST below:
# container-specs.sh has no spec for either (and their container names break
# the archy-<ui> assumption — the FIPS one is plain `fips-ui`). Their rebuilds
# come from elsewhere — the daemon's companion installer for fedimint-ui, the
# orchestrator's build context for fips-ui — but BOTH read
# /opt/archipelago/docker/<ui>, and nothing was ever updating that directory.
# So source edits to those two trees reached nodes through no path at all:
# their nginx kept listening on 0.0.0.0 and served the Guardian and FIPS
# screens unauthenticated on every interface (found by scanning archi-dev-box
# from outside, 2026-08-05 — the in-node audit could not see them).
for ui in bitcoin-ui lnd-ui electrs-ui fips-ui fedimint-ui; do
src="$REPO_DIR/docker/$ui"
dst="$UI_DOCKER_DEST/$ui"
[ -d "$src" ] || continue
@@ -335,9 +345,19 @@ for ui in bitcoin-ui lnd-ui electrs-ui; do
dst_hash=$( (cd "$dst" && find . -type f | LC_ALL=C sort | xargs sha256sum 2>/dev/null) | sha256sum | cut -d' ' -f1)
fi
if [ "$src_hash" != "$dst_hash" ]; then
log "UI source changed for $ui; syncing and marking for rebuild"
log "UI source changed for $ui; syncing"
sudo rsync -a --delete "$src/" "$dst/"
UI_REBUILD_LIST="$UI_REBUILD_LIST $ui"
case "$ui" in
# Rebuilt below from container-specs.sh.
bitcoin-ui|lnd-ui|electrs-ui)
UI_REBUILD_LIST="$UI_REBUILD_LIST $ui" ;;
# Synced only — rebuilt by the daemon (companion installer /
# orchestrator build context), which watches this directory.
# Adding them to the rebuild list would fail: no spec exists and
# the container names are not archy-<ui>.
*)
log " $ui synced; rebuild is owned by the daemon" ;;
esac
else
ok "UI source unchanged for $ui"
fi

Some files were not shown because too many files have changed in this diff Show More