diff --git a/.planning/RELEASE-1.7.121-TASKS.md b/.planning/RELEASE-1.7.121-TASKS.md index ed5e769d..7542bda1 100644 --- a/.planning/RELEASE-1.7.121-TASKS.md +++ b/.planning/RELEASE-1.7.121-TASKS.md @@ -443,6 +443,70 @@ below is dead on every path. Pre-existing; spotted in the v1.7.120 build warning --- +## STATUS 2026-08-04 — what shipped in 1.7.121 and what did not + +### Shipped (committed + pushed) + +| Item | Commit | Verified | +|---|---|---| +| 3. Federation trust escalation | `c0cfc72a` | 42/42 federation tests | +| 3b. Trusted requires node password | `24ce8b39` | 44/44 + 79/79 + vue-tsc | +| 4. lnd-ui OTA pin + host networking | `5088aef5` | — | +| 1b. Manifest `auth:` declarations | `0c4826f8` | 73/73, all 56 manifests parse | +| 1c. App gate (engine + audit) | `0de67ca6` | 23/23 appgate | +| Dashboard backdrop-filter seam | `63d0183d` | 3/3, **live on archi-dev-box** | +| 7. Release refuses unsigned manifest | `cc9e1958` | dry-run: signed/stripped/wrong-signer | +| Gate safety model (`Option`) | `ab2c8b6e` | 75/75 incl. LND wallet-port case | +| Companion rebuild-loop | `719446c0` | podman behaviour proven first | +| 5. Federated peers messageable | `edc9a172` | predicate pinned across device types | + +### The two gate incidents — read before touching the gate again + +Both were ONE mistake: a safety decision read an ABSENT manifest field as a +value. A node's installed manifests always lag the binary, so "absent" is the +normal state, and the daemon acted on instructions no manifest ever gave. + +1. Gating any `session` port regardless of `bind` **published Bitcoin's + loopback-only RPC 8332 on the LAN/Tailscale/IPv6** within seconds of deploy. +2. The `bind`-keyed replacement looked safe (it protected `bind: 127.0.0.1`) + but LND's gRPC 10009 / REST 18080 carry an EMPTY bind — one container + recreate from pinning them to loopback and **breaking Zeus and every remote + wallet**. + +Now structural: `auth_policy()` classifies (undeclared → reported as +unprotected, always safe), `auth_is_declared()` gates action (undeclared → +never acted on). **Silence is not consent.** + +### Proven on the node, empirically, not by reasoning + +- Gate challenge → login → proxy works end to end over LAN and Tailscale. +- **Daemon-side publish rewriting was removed.** Publishes are built in several + places (`podman_client`, `package::install`, `stacks`); patching one covered + one — the strfry recreate went through another and the pin never fired. +- **Disk manifest edits do not apply to catalog-covered apps.** Even + `bind: 127.0.0.1` written into the node's strfry manifest was overridden by + the signed catalog. The catalog re-sign is REQUIRED; there is no shortcut. +- A loopback-bound host port is **unreachable** from a pasta container, so + loopback-pinning the Wyoming ports would break Home Assistant voice. + +### Open for 1.7.122 + +1. **Catalog re-sign** — `bind: 127.0.0.1` + `auth: session` on the ~39 gated + UI ports. This is what turns the gate from auditing into enforcing. Nothing + in code can substitute for it. +2. **Release-root rotation** — branch `rotate-release-root`, key + `did:key:z6Mkfu5LT…DLWT` / `1578adcc…4418`, validated as a real curve point. + **Sign the rotation release with the OLD key**; only the release after it + uses the new one. Re-sign the catalog too. +3. **Wyoming voice ports** (10200/10300/10400) — unauthenticated, and by the + operator's policy they should not be. Correct fix is co-locating Home + Assistant with the pine services on one container network so nothing is + published; needs a node running both. +4. **Item 2** filebrowser default login. **Items 6/6b** app updates + + multiversion (`versions[]` already exists, populated for 2 of 66 apps). +5. **`cargo-test-weekly` times out** at its 1500s cap on a loaded box — raise + the cap or split the stage; it is not a code failure. + ## RESUME HERE — next session **Landed this session (both pushed):** diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d5c579..c10242dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index cf0ca910..b6020cc1 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -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": [ diff --git a/apps/archy-mempool-web/manifest.yml b/apps/archy-mempool-web/manifest.yml index 6d84df8d..d18acc76 100644 --- a/apps/archy-mempool-web/manifest.yml +++ b/apps/archy-mempool-web/manifest.yml @@ -26,6 +26,8 @@ app: - host: 4080 container: 8080 protocol: tcp + bind: 127.0.0.1 + auth: gated environment: - FRONTEND_HTTP_PORT=8080 diff --git a/apps/archy-nbxplorer/manifest.yml b/apps/archy-nbxplorer/manifest.yml index 21ce43bf..a13c1bb7 100644 --- a/apps/archy-nbxplorer/manifest.yml +++ b/apps/archy-nbxplorer/manifest.yml @@ -33,6 +33,8 @@ app: - host: 32838 container: 32838 protocol: tcp + bind: 127.0.0.1 + auth: local volumes: - type: bind diff --git a/apps/barkd/manifest.yml b/apps/barkd/manifest.yml index 5ba3d40d..171ed869 100644 --- a/apps/barkd/manifest.yml +++ b/apps/barkd/manifest.yml @@ -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 diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml index 6cdd5faa..5e9db91a 100644 --- a/apps/bitcoin-core/manifest.yml +++ b/apps/bitcoin-core/manifest.yml @@ -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 diff --git a/apps/bitcoin-knots/manifest.yml b/apps/bitcoin-knots/manifest.yml index 9d7967d4..f74c6052 100644 --- a/apps/bitcoin-knots/manifest.yml +++ b/apps/bitcoin-knots/manifest.yml @@ -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 diff --git a/apps/bitcoin-ui/manifest.yml b/apps/bitcoin-ui/manifest.yml index a9e10f4d..fc96095b 100644 --- a/apps/bitcoin-ui/manifest.yml +++ b/apps/bitcoin-ui/manifest.yml @@ -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 diff --git a/apps/botfights/manifest.yml b/apps/botfights/manifest.yml index eba5410c..a7e3e1fd 100644 --- a/apps/botfights/manifest.yml +++ b/apps/botfights/manifest.yml @@ -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 diff --git a/apps/btcpay-server/manifest.yml b/apps/btcpay-server/manifest.yml index 8f42a95c..703a9f0c 100644 --- a/apps/btcpay-server/manifest.yml +++ b/apps/btcpay-server/manifest.yml @@ -45,6 +45,8 @@ app: - host: 23000 container: 49392 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/did-wallet/manifest.yml b/apps/did-wallet/manifest.yml index c89f6561..413df0a2 100644 --- a/apps/did-wallet/manifest.yml +++ b/apps/did-wallet/manifest.yml @@ -30,6 +30,8 @@ app: - host: 8088 container: 8080 protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/electrs-ui/manifest.yml b/apps/electrs-ui/manifest.yml index 0d5f6be3..d1bff168 100644 --- a/apps/electrs-ui/manifest.yml +++ b/apps/electrs-ui/manifest.yml @@ -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: [] diff --git a/apps/fedimint-clientd/manifest.yml b/apps/fedimint-clientd/manifest.yml index 38d81bba..898b31be 100644 --- a/apps/fedimint-clientd/manifest.yml +++ b/apps/fedimint-clientd/manifest.yml @@ -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 diff --git a/apps/fedimint-gateway/manifest.yml b/apps/fedimint-gateway/manifest.yml index 9363bb10..42ed8239 100644 --- a/apps/fedimint-gateway/manifest.yml +++ b/apps/fedimint-gateway/manifest.yml @@ -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 diff --git a/apps/fedimint/manifest.yml b/apps/fedimint/manifest.yml index 88f17e15..f69a7ddc 100644 --- a/apps/fedimint/manifest.yml +++ b/apps/fedimint/manifest.yml @@ -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 diff --git a/apps/filebrowser/manifest.yml b/apps/filebrowser/manifest.yml index 2d24bc14..a47b9431 100644 --- a/apps/filebrowser/manifest.yml +++ b/apps/filebrowser/manifest.yml @@ -27,6 +27,8 @@ app: - host: 8083 container: 80 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/fips-ui/manifest.yml b/apps/fips-ui/manifest.yml index 6983387c..c4c8474a 100644 --- a/apps/fips-ui/manifest.yml +++ b/apps/fips-ui/manifest.yml @@ -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: [] diff --git a/apps/gitea/manifest.yml b/apps/gitea/manifest.yml index 4926ba3f..0a4a2293 100644 --- a/apps/gitea/manifest.yml +++ b/apps/gitea/manifest.yml @@ -26,6 +26,8 @@ app: - host: 3001 container: 3000 protocol: tcp + bind: 127.0.0.1 + auth: gated - host: 2222 container: 22 protocol: tcp diff --git a/apps/grafana/manifest.yml b/apps/grafana/manifest.yml index ed6a6b42..3ef6938a 100644 --- a/apps/grafana/manifest.yml +++ b/apps/grafana/manifest.yml @@ -31,6 +31,8 @@ app: - host: 3000 container: 3000 protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/home-assistant/manifest.yml b/apps/home-assistant/manifest.yml index 135db17b..7a4c3a2f 100644 --- a/apps/home-assistant/manifest.yml +++ b/apps/home-assistant/manifest.yml @@ -30,6 +30,8 @@ app: - host: 8123 container: 8123 protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/immich/manifest.yml b/apps/immich/manifest.yml index 73fff674..a09b23dd 100644 --- a/apps/immich/manifest.yml +++ b/apps/immich/manifest.yml @@ -44,6 +44,8 @@ app: - host: 2283 container: 2283 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/indeedhub-redis/manifest.yml b/apps/indeedhub-redis/manifest.yml index c9997b0a..a2b84c29 100644 --- a/apps/indeedhub-redis/manifest.yml +++ b/apps/indeedhub-redis/manifest.yml @@ -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 diff --git a/apps/indeedhub/manifest.yml b/apps/indeedhub/manifest.yml index 471678c4..a71f6777 100644 --- a/apps/indeedhub/manifest.yml +++ b/apps/indeedhub/manifest.yml @@ -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. diff --git a/apps/jellyfin/manifest.yml b/apps/jellyfin/manifest.yml index 7234c1c8..94ab1424 100644 --- a/apps/jellyfin/manifest.yml +++ b/apps/jellyfin/manifest.yml @@ -25,6 +25,8 @@ app: - host: 8096 container: 8096 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/lightning-stack/manifest.yml b/apps/lightning-stack/manifest.yml index ac7fc9fe..e9befcd6 100644 --- a/apps/lightning-stack/manifest.yml +++ b/apps/lightning-stack/manifest.yml @@ -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 diff --git a/apps/lnd-ui/manifest.yml b/apps/lnd-ui/manifest.yml index 713a1afe..397f1b0b 100644 --- a/apps/lnd-ui/manifest.yml +++ b/apps/lnd-ui/manifest.yml @@ -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: [] diff --git a/apps/mempool-api/manifest.yml b/apps/mempool-api/manifest.yml index 24b628ce..9bf8fe27 100644 --- a/apps/mempool-api/manifest.yml +++ b/apps/mempool-api/manifest.yml @@ -42,6 +42,8 @@ app: - host: 8999 container: 8999 protocol: tcp + bind: 127.0.0.1 + auth: local volumes: - type: bind diff --git a/apps/mempool/manifest.yml b/apps/mempool/manifest.yml index fbaf7263..ee64420b 100644 --- a/apps/mempool/manifest.yml +++ b/apps/mempool/manifest.yml @@ -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 diff --git a/apps/morphos-server/manifest.yml b/apps/morphos-server/manifest.yml index dd032bf4..32975649 100644 --- a/apps/morphos-server/manifest.yml +++ b/apps/morphos-server/manifest.yml @@ -30,6 +30,8 @@ app: - host: 8089 container: 8080 protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/netbird-server/manifest.yml b/apps/netbird-server/manifest.yml index 994949f6..287be710 100644 --- a/apps/netbird-server/manifest.yml +++ b/apps/netbird-server/manifest.yml @@ -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 diff --git a/apps/netbird/manifest.yml b/apps/netbird/manifest.yml index 6464335a..32cf44d5 100644 --- a/apps/netbird/manifest.yml +++ b/apps/netbird/manifest.yml @@ -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 diff --git a/apps/nextcloud/manifest.yml b/apps/nextcloud/manifest.yml index a8165868..6fb16d44 100644 --- a/apps/nextcloud/manifest.yml +++ b/apps/nextcloud/manifest.yml @@ -25,6 +25,8 @@ app: - host: 8085 container: 80 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/nostr-rs-relay/manifest.yml b/apps/nostr-rs-relay/manifest.yml index 975a07ae..5bfb5bde 100644 --- a/apps/nostr-rs-relay/manifest.yml +++ b/apps/nostr-rs-relay/manifest.yml @@ -31,6 +31,8 @@ app: - host: 18081 container: 8080 protocol: tcp # HTTP/WebSocket + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/photoprism/manifest.yml b/apps/photoprism/manifest.yml index 485d5936..88cbcb73 100644 --- a/apps/photoprism/manifest.yml +++ b/apps/photoprism/manifest.yml @@ -24,6 +24,8 @@ app: - host: 2342 container: 2342 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/pine/manifest.yml b/apps/pine/manifest.yml index 3984cd3f..c673df3e 100644 --- a/apps/pine/manifest.yml +++ b/apps/pine/manifest.yml @@ -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 diff --git a/apps/portainer/manifest.yml b/apps/portainer/manifest.yml index 2307b504..868e114e 100644 --- a/apps/portainer/manifest.yml +++ b/apps/portainer/manifest.yml @@ -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 diff --git a/apps/router/manifest.yml b/apps/router/manifest.yml index fb4300d4..bada2040 100644 --- a/apps/router/manifest.yml +++ b/apps/router/manifest.yml @@ -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 diff --git a/apps/searxng/manifest.yml b/apps/searxng/manifest.yml index 1eeb727d..0727ff36 100644 --- a/apps/searxng/manifest.yml +++ b/apps/searxng/manifest.yml @@ -29,6 +29,8 @@ app: - host: 8888 container: 8080 protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/strfry/manifest.yml b/apps/strfry/manifest.yml index ef74ed72..eac12a1d 100644 --- a/apps/strfry/manifest.yml +++ b/apps/strfry/manifest.yml @@ -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 diff --git a/apps/uptime-kuma/manifest.yml b/apps/uptime-kuma/manifest.yml index e58f0bbe..391b3c88 100644 --- a/apps/uptime-kuma/manifest.yml +++ b/apps/uptime-kuma/manifest.yml @@ -26,6 +26,8 @@ app: - host: 3002 container: 3001 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/vaultwarden/manifest.yml b/apps/vaultwarden/manifest.yml index 2f3d49d3..1e85629f 100644 --- a/apps/vaultwarden/manifest.yml +++ b/apps/vaultwarden/manifest.yml @@ -25,6 +25,8 @@ app: - host: 8082 container: 80 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/core/Cargo.lock b/core/Cargo.lock index 620aaa6f..438ed6b2 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -104,7 +104,7 @@ dependencies = [ [[package]] name = "archipelago" -version = "1.7.120-alpha" +version = "1.7.125-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 4a3a9018..37724977 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.120-alpha" +version = "1.7.125-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 817c8514..b68d9028 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -412,6 +412,8 @@ impl RpcHandler { "mesh.send-channel" => self.handle_mesh_send_channel(params).await, "mesh.broadcast" => self.handle_mesh_broadcast().await, "mesh.reboot-radio" => self.handle_mesh_reboot_radio(params).await, + "mesh.rnode-config" => self.handle_mesh_rnode_config().await, + "mesh.rnode-config-apply" => self.handle_mesh_rnode_config_apply(params).await, "mesh.configure" => self.handle_mesh_configure(params).await, "mesh.send-invoice" => self.handle_mesh_send_invoice(params).await, "mesh.send-coordinate" => self.handle_mesh_send_coordinate(params).await, @@ -487,6 +489,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, diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs index d679de7e..8997c998 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -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)); } diff --git a/core/archipelago/src/api/rpc/mesh/messaging.rs b/core/archipelago/src/api/rpc/mesh/messaging.rs index 23facdc3..d66e529c 100644 --- a/core/archipelago/src/api/rpc/mesh/messaging.rs +++ b/core/archipelago/src/api/rpc/mesh/messaging.rs @@ -104,10 +104,115 @@ impl RpcHandler { .as_ref() .ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?; - svc.reboot_radio(seconds).await?; + let message = svc.reboot_radio(seconds).await?; info!(seconds, "Mesh radio reboot requested via RPC"); - Ok(serde_json::json!({ "reboot": true, "seconds": seconds })) + Ok(serde_json::json!({ "reboot": true, "seconds": seconds, "message": message })) + } + + /// mesh.rnode-config — persisted RF settings + the live radio state + /// (radio-confirmed values) for the LoRa settings panel. `live` is best- + /// effort: null with `live_error` when no Reticulum radio is connected. + pub(in crate::api::rpc) async fn handle_mesh_rnode_config(&self) -> Result { + let settings = mesh::rnode_settings::RNodeRfSettings::load(&self.config.data_dir).await; + let (live, live_error) = match self.mesh_service.read().await.as_ref() { + Some(svc) => match svc.radio_state().await { + Ok(state) => (Some(state), None), + Err(e) => (None, Some(format!("{e:#}"))), + }, + None => (None, Some("Mesh service not running".to_string())), + }; + Ok(serde_json::json!({ + "settings": settings, + "live": live, + "live_error": live_error, + })) + } + + /// mesh.rnode-config-apply — validate + persist the RF settings, restart + /// the radio daemon so they take effect, then read back the radio- + /// confirmed values as proof. Returns { applied, live, message }; a + /// failed read-back still reports the persisted settings with a clear + /// message instead of pretending success. + pub(in crate::api::rpc) async fn handle_mesh_rnode_config_apply( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let settings: mesh::rnode_settings::RNodeRfSettings = serde_json::from_value( + params + .get("settings") + .cloned() + .ok_or_else(|| anyhow::anyhow!("Missing 'settings'"))?, + ) + .map_err(|e| anyhow::anyhow!("Invalid settings: {e}"))?; + settings.validate()?; + settings.save(&self.config.data_dir).await?; + info!(?settings, "RNode RF settings persisted"); + + // Restart the radio daemon so the new args apply. No radio connected + // is fine — the settings apply on the next connect. + let service = self.mesh_service.read().await; + let Some(svc) = service.as_ref() else { + return Ok(serde_json::json!({ + "applied": false, + "message": "Settings saved. They apply when the mesh service next connects to the radio.", + })); + }; + if let Err(e) = svc.reboot_radio(2).await { + return Ok(serde_json::json!({ + "applied": false, + "message": format!( + "Settings saved, but the radio daemon restart failed: {e:#}. \ + They apply on the next reconnect." + ), + })); + } + + // Read-back: poll until the respawned daemon reports the radio online + // with our applied values (the respawn re-detects the RNode, ~15s). + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(45); + let mut last_live = None; + while tokio::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + if let Ok(state) = svc.radio_state().await { + let online = state + .get("online") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + last_live = Some(state); + if online { + break; + } + } + } + match last_live { + Some(live) => { + let confirmed = live + .get("r_frequency") + .and_then(|v| v.as_u64()) + .map(|f| f == settings.frequency) + .unwrap_or(false); + Ok(serde_json::json!({ + "applied": true, + "confirmed": confirmed, + "live": live, + "message": if confirmed { + "The radio confirmed it is now using the applied settings." + } else { + "Settings applied and the daemon restarted; the radio has not \ + confirmed the new values yet — recheck in a few seconds." + }, + })) + } + None => Ok(serde_json::json!({ + "applied": true, + "confirmed": false, + "live": null, + "message": "Settings applied and the daemon restarted, but it has not \ + reported the radio state yet — recheck in a few seconds.", + })), + } } /// mesh.configure — Enable/disable mesh and set device path. diff --git a/core/archipelago/src/api/rpc/middleware.rs b/core/archipelago/src/api/rpc/middleware.rs index 027ecef3..c4918ff4 100644 --- a/core/archipelago/src/api/rpc/middleware.rs +++ b/core/archipelago/src/api/rpc/middleware.rs @@ -85,6 +85,38 @@ 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", + // A frontend newer than the daemon calls methods it doesn't have. + // Masked, this reads as "the feature is broken" instead of "this + // node needs its update" — hit live the moment the .126 LoRa panel + // was deployed ahead of its binary (2026-08-06). + "Unknown method", + // RNode RF settings validation (mesh::rnode_settings::validate) — + // every one names the offending field and its legal range, which is + // the entire point of validating before touching the radio. + "frequency ", + "bandwidth ", + "spreading factor ", + "coding rate ", + "tx power ", + "airtime_limit_short", + "airtime_limit_long", + "port must be an absolute", + "Invalid settings", + "Missing 'settings'", + // Mesh preconditions the operator can act on directly. + "Mesh service not running", + "No mesh device connected", + "Mesh listener not running", + "MeshCore radios have no remote reboot", + "Radio state read-back", + "The radio daemon did not answer", + "The radio did not acknowledge", + "RNode interface is disabled", // 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 diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index f82f9678..a2e0d83e 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -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::::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 { - 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 { +/// 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 { 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 )), } diff --git a/core/archipelago/src/api/rpc/system/handlers.rs b/core/archipelago/src/api/rpc/system/handlers.rs index 72275f5b..80fab3a2 100644 --- a/core/archipelago/src/api/rpc/system/handlers.rs +++ b/core/archipelago/src/api/rpc/system/handlers.rs @@ -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 { + 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, + ) -> Result { + 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, diff --git a/core/archipelago/src/api/rpc/tor/mod.rs b/core/archipelago/src/api/rpc/tor/mod.rs index aad7dcec..6153a0b0 100644 --- a/core/archipelago/src/api/rpc/tor/mod.rs +++ b/core/archipelago/src/api/rpc/tor/mod.rs @@ -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 = 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, +) -> 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 = [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 = [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) { diff --git a/core/archipelago/src/app_ops.rs b/core/archipelago/src/app_ops.rs index c036d863..7311ade7 100644 --- a/core/archipelago/src/app_ops.rs +++ b/core/archipelago/src/app_ops.rs @@ -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", diff --git a/core/archipelago/src/appgate/identity.rs b/core/archipelago/src/appgate/identity.rs index 26ef272c..240e160d 100644 --- a/core/archipelago/src/appgate/identity.rs +++ b/core/archipelago/src/appgate/identity.rs @@ -26,6 +26,20 @@ pub struct GatedPort { pub app_name: String, /// Manifest-declared icon path (`metadata.icon`), when present. pub icon: Option, + /// 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, exempt: Vec, + local: std::collections::HashSet, } 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,42 @@ fn manifest_icon(manifest: &AppManifest) -> Option { /// 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 = HashMap::new(); + let mut seen_apps: std::collections::HashSet = std::collections::HashSet::new(); + + for (app_id, value) in crate::container::app_catalog::catalog_manifest_values() { + // Ports-only overlay: unlike the install path, classification also + // accepts BUILD-SOURCE manifests. The on-node-built companion UIs + // are exactly the apps whose gate policy (session_passthrough, + // auth: gated) must arrive reliably, and their disk manifests + // proved stale or absent fleet-wide in the v1.7.125 rollout. The + // gate's binds fail safely on conflict with a differently-published + // container, so a fresher catalog can only tighten, never expose. + let Some(manifest) = + crate::container::app_catalog::catalog_manifest_ports_overlay(&app_id, value) + else { + // Unparseable/invalid → 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 +181,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::() - .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 +191,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::() + .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 +327,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 = 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). diff --git a/core/archipelago/src/appgate/listener.rs b/core/archipelago/src/appgate/listener.rs index 10af2946..0a7844da 100644 --- a/core/archipelago/src/appgate/listener.rs +++ b/core/archipelago/src/appgate/listener.rs @@ -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, ) { // (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, status: &Arc>, - held: &mut HashMap<(u16, IpAddr), ()>, + held: &mut HashMap<(u16, IpAddr), tokio::task::JoinHandle<()>>, shutdown_rx: &tokio::sync::watch::Receiver, ) { // 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:. 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, app: GatedPort, mut shutdown_rx: tokio::sync::watch::Receiver, -) { +) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { loop { tokio::select! { @@ -287,7 +353,7 @@ fn spawn_accept_loop( _ = shutdown_rx.changed() => break, } } - }); + }) } #[cfg(test)] diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index 8ac1ef43..a3ed00ef 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -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 { + // 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 { + 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 { 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, port: u16) -> Response { +async fn proxy_to_app(req: Request, app: &GatedPort) -> Response { + let port = app.port; let path_and_query = req .uri() .path_and_query() @@ -364,10 +390,16 @@ async fn proxy_to_app(req: Request, port: u16) -> Response { 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, port: u16) -> Response { } } +/// 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, 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#"
"#, + 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#""#, esc(&data_uri)); - } - } - let letter = app - .app_name - .chars() - .next() - .map(|c| c.to_uppercase().to_string()) - .unwrap_or_else(|| "?".to_string()); - format!(r#"
{}
"#, 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#""#, 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#"
{}
"#, esc(&letter)) + }); + format!(r#"
{inner}
"#) } /// 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 { - 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 { + 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)); + // `-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, &'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 { + 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 {title} — {app_name} -
{body}
"#, +{backgrounds}
{body}
"#, 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 { let body = format!( - r#"{icon} + r#" +{icon}

Sign in to open {name}

This app is protected by your node password.

{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(" { - 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 { - // 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::>() + .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"), + } +} diff --git a/core/archipelago/src/container/app_catalog.rs b/core/archipelago/src/container/app_catalog.rs index fccb23ba..94c02e93 100644 --- a/core/archipelago/src/container/app_catalog.rs +++ b/core/archipelago/src/container/app_catalog.rs @@ -216,6 +216,73 @@ 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 { + 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) +} + +/// Like [`catalog_manifest_overlay`] but WITHOUT the build-source refusal — +/// for PORT CLASSIFICATION only, never for install/orchestration. +/// +/// The on-node-built companion UIs (lnd-ui, bitcoin-ui, electrs-ui, fips-ui) +/// are exactly the apps whose port policy (auth/bind/session_passthrough) +/// must reach the gate reliably, yet their build sources made the overlay +/// defer to DISK manifests — whose only delivery paths (frontend runtime +/// payload, per-node repo copies) proved stale or absent across the fleet in +/// the v1.7.125 rollout: nodes served ungated UIs or 401-dead panels until +/// hand-fixed. The signed catalog is fresher and operator-signed; and the +/// gate's address binds fail safely on conflict with a container that +/// publishes differently (logged as CANNOT PROTECT), so classifying from the +/// catalog cannot open anything the running container hasn't already opened. +pub fn catalog_manifest_ports_overlay( + app_id: &str, + value: serde_json::Value, +) -> Option { + let m: archipelago_container::manifest::AppManifest = serde_json::from_value(value).ok()?; + if m.app.id != app_id { + return None; + } + if m.validate().is_err() { + 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). diff --git a/core/archipelago/src/container/bitcoin_ui.rs b/core/archipelago/src/container/bitcoin_ui.rs index eec7526a..37759a54 100644 --- a/core/archipelago/src/container/bitcoin_ui.rs +++ b/core/archipelago/src/container/bitcoin_ui.rs @@ -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")); } } diff --git a/core/archipelago/src/container/bitcoin_ui_nginx.conf.template b/core/archipelago/src/container/bitcoin_ui_nginx.conf.template index 6e7e2bdb..ddd4c1b6 100644 --- a/core/archipelago/src/container/bitcoin_ui_nginx.conf.template +++ b/core/archipelago/src/container/bitcoin_ui_nginx.conf.template @@ -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; diff --git a/core/archipelago/src/container/companion.rs b/core/archipelago/src/container/companion.rs index 92658725..018ee63d 100644 --- a/core/archipelago/src/container/companion.rs +++ b/core/archipelago/src/container/companion.rs @@ -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 { + 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 { + 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/:latest` for build, registry diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 6aea283d..cb836168 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -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, + container_name_by_app_id: &std::collections::HashMap, +) -> 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 { 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::().ok()) - .unwrap_or(0); + .map(|(u, g)| (u.parse::().unwrap_or(0), g.parse::().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 { - 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 = { + let (manifests, container_name_by_app_id): ( + Vec, + std::collections::HashMap, + ) = { 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 = 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 = ["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 = 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 = + 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 " + + diff --git a/neode-ui/public/entropy/nav.js b/neode-ui/public/entropy/nav.js new file mode 100644 index 00000000..37987665 --- /dev/null +++ b/neode-ui/public/entropy/nav.js @@ -0,0 +1,29 @@ +// Scroll-spy for the sidebar, mirroring the dashboard's nav-tab-active state. +// External file (not inline) because the node's CSP is script-src 'self'. +(function () { + var links = Array.prototype.slice.call( + document.querySelectorAll('.sidebar-nav .sidebar-nav-item[href^="#"]') + ) + if (!links.length || !('IntersectionObserver' in window)) return + + var sections = links + .map(function (a) { return document.getElementById(a.getAttribute('href').slice(1)) }) + .filter(Boolean) + + function activate(id) { + links.forEach(function (a) { + a.classList.toggle('nav-tab-active', a.getAttribute('href') === '#' + id) + }) + } + + var visible = {} + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (e) { visible[e.target.id] = e.isIntersecting }) + // Topmost section currently on screen wins, so the highlight tracks reading position. + for (var i = 0; i < sections.length; i++) { + if (visible[sections[i].id]) { activate(sections[i].id); return } + } + }, { rootMargin: '-10% 0px -70% 0px', threshold: 0 }) + + sections.forEach(function (s) { observer.observe(s) }) +})() diff --git a/neode-ui/src/components/InstallVersionModal.vue b/neode-ui/src/components/InstallVersionModal.vue index 9754b190..99b6a9e9 100644 --- a/neode-ui/src/components/InstallVersionModal.vue +++ b/neode-ui/src/components/InstallVersionModal.vue @@ -2,11 +2,15 @@
+
diff --git a/neode-ui/src/components/ReceiveBitcoinModal.vue b/neode-ui/src/components/ReceiveBitcoinModal.vue index 085133e1..63cc4bcc 100644 --- a/neode-ui/src/components/ReceiveBitcoinModal.vue +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -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() } }) diff --git a/neode-ui/src/components/SendBitcoinModal.vue b/neode-ui/src/components/SendBitcoinModal.vue index 52b0720d..e5fb240e 100644 --- a/neode-ui/src/components/SendBitcoinModal.vue +++ b/neode-ui/src/components/SendBitcoinModal.vue @@ -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). diff --git a/neode-ui/src/components/WalletScanModal.vue b/neode-ui/src/components/WalletScanModal.vue index 2107ec1d..cb41c2e6 100644 --- a/neode-ui/src/components/WalletScanModal.vue +++ b/neode-ui/src/components/WalletScanModal.vue @@ -51,10 +51,16 @@

How do you want to read the QR?

- +

+ Your browser only allows live camera on HTTPS pages — the photo and paste options below always work. +

diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index a7ddc846..b8a80a56 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -863,12 +863,35 @@ export const useMeshStore = defineStore('mesh', () => { } async function rebootRadio(seconds = 2) { - return rpcClient.call<{ reboot: boolean; seconds: number }>({ + // Long timeout: Reticulum reboots restart the sidecar daemon and the + // backend waits for the acknowledgement instead of fire-and-forgetting. + return rpcClient.call<{ reboot: boolean; seconds: number; message?: string }>({ method: 'mesh.reboot-radio', params: { seconds }, + timeout: 30000, }) } + /** Persisted RNode RF settings + live radio-confirmed state (Reticulum). */ + async function getRnodeConfig() { + return rpcClient.call<{ + settings: Record + live: Record | null + live_error: string | null + }>({ method: 'mesh.rnode-config', timeout: 20000 }) + } + + /** Apply RNode RF settings: persists, restarts the radio daemon, waits for + * the radio's own read-back confirmation (up to ~50s). */ + async function applyRnodeConfig(settings: Record) { + return rpcClient.call<{ + applied: boolean + confirmed?: boolean + live?: Record | null + message: string + }>({ method: 'mesh.rnode-config-apply', params: { settings }, timeout: 70000 }) + } + async function getOutbox() { try { return await rpcClient.call<{ count: number; messages?: unknown[] }>({ method: 'mesh.outbox' }) @@ -1155,6 +1178,8 @@ export const useMeshStore = defineStore('mesh', () => { sendReply, sendReaction, rebootRadio, + getRnodeConfig, + applyRnodeConfig, getOutbox, sendReadReceipt, forwardMessage, diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index f6386335..36c3237f 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -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) {
-
+