Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6db66db87f |
@@ -443,70 +443,6 @@ 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<PortAuth>`) | `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):**
|
||||
|
||||
@@ -1,38 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -442,7 +442,7 @@
|
||||
"author": "Portainer",
|
||||
"category": "development",
|
||||
"tier": "optional",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
||||
"repoUrl": "https://github.com/portainer/portainer",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
|
||||
@@ -26,8 +26,6 @@ app:
|
||||
- host: 4080
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
environment:
|
||||
- FRONTEND_HTTP_PORT=8080
|
||||
|
||||
@@ -33,8 +33,6 @@ app:
|
||||
- host: 32838
|
||||
container: 32838
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -51,8 +51,6 @@ 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
|
||||
|
||||
@@ -38,16 +38,6 @@ app:
|
||||
RPC_CONF="/tmp/rpc.conf";
|
||||
umask 077;
|
||||
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
|
||||
# A stray bitcoin.conf in the datadir is FATAL when -conf points
|
||||
# elsewhere: bitcoind refuses to start with "contains a bitcoin.conf
|
||||
# file which is ignored", and the app crash-loops (100.82.34.38,
|
||||
# 2026-08-05 — Exited(1) every few seconds). Our -conf carries the
|
||||
# RPC credentials and the flags below are the authoritative config,
|
||||
# so the datadir file is legacy debris; say so out loud rather than
|
||||
# failing, and let bitcoind start.
|
||||
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
|
||||
echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2;
|
||||
fi;
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||
@@ -56,9 +46,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" -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;
|
||||
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;
|
||||
else
|
||||
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;
|
||||
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;
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
|
||||
@@ -38,16 +38,6 @@ app:
|
||||
RPC_CONF="/tmp/rpc.conf";
|
||||
umask 077;
|
||||
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
|
||||
# A stray bitcoin.conf in the datadir is FATAL when -conf points
|
||||
# elsewhere: bitcoind refuses to start with "contains a bitcoin.conf
|
||||
# file which is ignored", and the app crash-loops (100.82.34.38,
|
||||
# 2026-08-05 — Exited(1) every few seconds). Our -conf carries the
|
||||
# RPC credentials and the flags below are the authoritative config,
|
||||
# so the datadir file is legacy debris; say so out loud rather than
|
||||
# failing, and let bitcoind start.
|
||||
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
|
||||
echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2;
|
||||
fi;
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||
@@ -56,9 +46,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" -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;
|
||||
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;
|
||||
else
|
||||
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;
|
||||
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;
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
|
||||
@@ -31,18 +31,7 @@ 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.
|
||||
# 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
|
||||
ports: []
|
||||
|
||||
volumes:
|
||||
# Bind-mount the rendered nginx.conf read-only. The prod orchestrator
|
||||
|
||||
@@ -62,8 +62,6 @@ 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
|
||||
|
||||
@@ -45,8 +45,6 @@ app:
|
||||
- host: 23000
|
||||
container: 49392
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -30,8 +30,6 @@ app:
|
||||
- host: 8088
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -23,18 +23,7 @@ app:
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 50002 directly on the host IP.
|
||||
# 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
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
|
||||
@@ -66,8 +66,6 @@ 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
|
||||
|
||||
@@ -60,17 +60,9 @@ 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
|
||||
|
||||
@@ -50,34 +50,14 @@ 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.
|
||||
# Declared HERE because that companion has no manifest of its own, and the
|
||||
# gate keys on the port rather than the container: without this entry it
|
||||
# served the Guardian UI unauthenticated on every interface and never
|
||||
# appeared in the audit. Its nginx is pinned to 127.0.0.1
|
||||
# (docker/fedimint-ui/nginx.conf) so the gate can own the outside.
|
||||
- host: 8175
|
||||
container: 8175
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
- host: 8177
|
||||
container: 8175
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -27,8 +27,6 @@ app:
|
||||
- host: 8083
|
||||
container: 80
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -27,18 +27,7 @@ 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.
|
||||
# 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
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
|
||||
@@ -26,8 +26,6 @@ app:
|
||||
- host: 3001
|
||||
container: 3000
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
- host: 2222
|
||||
container: 22
|
||||
protocol: tcp
|
||||
|
||||
@@ -31,8 +31,6 @@ app:
|
||||
- host: 3000
|
||||
container: 3000
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -30,8 +30,6 @@ app:
|
||||
- host: 8123
|
||||
container: 8123
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -44,8 +44,6 @@ app:
|
||||
- host: 2283
|
||||
container: 2283
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -38,8 +38,6 @@ 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.
|
||||
|
||||
@@ -25,8 +25,6 @@ app:
|
||||
- host: 8096
|
||||
container: 8096
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -41,14 +41,9 @@ 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
|
||||
|
||||
@@ -35,18 +35,7 @@ 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.
|
||||
# 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
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
|
||||
@@ -42,8 +42,6 @@ app:
|
||||
- host: 8999
|
||||
container: 8999
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -33,8 +33,6 @@ 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
|
||||
|
||||
@@ -30,8 +30,6 @@ app:
|
||||
- host: 8089
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -48,11 +48,6 @@ 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
|
||||
|
||||
@@ -44,11 +44,6 @@ 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
|
||||
|
||||
@@ -25,8 +25,6 @@ app:
|
||||
- host: 8085
|
||||
container: 80
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -31,8 +31,6 @@ app:
|
||||
- host: 18081
|
||||
container: 8080
|
||||
protocol: tcp # HTTP/WebSocket
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -24,8 +24,6 @@ app:
|
||||
- host: 2342
|
||||
container: 2342
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -53,16 +53,9 @@ 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
|
||||
|
||||
@@ -6,7 +6,7 @@ app:
|
||||
category: development
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/portainer:2.39.1
|
||||
image: 146.59.87.168:3000/lfg2025/portainer:2.19.4
|
||||
pull_policy: if-not-present
|
||||
data_uid: "1000:1000"
|
||||
|
||||
@@ -27,8 +27,6 @@ app:
|
||||
- host: 9000
|
||||
container: 9000
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -30,8 +30,6 @@ app:
|
||||
- host: 8084
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
- host: 5353
|
||||
container: 5353
|
||||
protocol: udp # mDNS/Bonjour
|
||||
|
||||
@@ -29,8 +29,6 @@ app:
|
||||
- host: 8888
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -29,8 +29,6 @@ app:
|
||||
- host: 8090
|
||||
container: 7777
|
||||
protocol: tcp # HTTP/WebSocket (strfry listens on 7777)
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -26,8 +26,6 @@ app:
|
||||
- host: 3002
|
||||
container: 3001
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -25,8 +25,6 @@ app:
|
||||
- host: 8082
|
||||
container: 80
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.124-alpha"
|
||||
version = "1.7.120-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.124-alpha"
|
||||
version = "1.7.120-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -472,8 +472,6 @@ 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,
|
||||
|
||||
@@ -307,24 +307,19 @@ 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);
|
||||
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?;
|
||||
}
|
||||
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
|
||||
};
|
||||
|
||||
// For orchestrator-managed apps, skip the legacy "container exists →
|
||||
// adopt + return" probe entirely. The orchestrator's own install path
|
||||
@@ -394,7 +389,37 @@ impl RpcHandler {
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if state != "running" {
|
||||
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" {
|
||||
// Start the stopped/exited container
|
||||
info!("Starting existing container {} (was {})", package_id, state);
|
||||
let start_output = tokio::process::Command::new("podman")
|
||||
@@ -690,13 +715,9 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Pre-install: write config files BEFORE chown (dir is still owned by archipelago user)
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
remove_stale_bitcoin_conf().await?;
|
||||
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await?;
|
||||
}
|
||||
|
||||
if package_id == "lnd" {
|
||||
@@ -1414,13 +1435,101 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 bitcoin.conf with rpcauth (salted HMAC hash, no plaintext password).
|
||||
async fn write_bitcoin_conf(&self, rpc_user: &str, rpc_pass: &str) -> Result<()> {
|
||||
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
|
||||
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
|
||||
|
||||
// Idempotent: once bitcoin-knots (or a prior install) has started,
|
||||
// the data dir is chowned into the container's user namespace
|
||||
// (e.g. UID 100100 on the host) with 700 perms — the archipelago
|
||||
// daemon can no longer stat or write there. Treat any non-NotFound
|
||||
// error on the conf as "conf already provisioned by the container
|
||||
// user" and skip. Matches the lnd.conf behavior below.
|
||||
match tokio::fs::metadata(&conf_path).await {
|
||||
Ok(_) => {
|
||||
ensure_bitcoin_rpc_config().await?;
|
||||
info!("bitcoin.conf already exists, ensured Bitcoin RPC config");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(_) => {
|
||||
ensure_bitcoin_rpc_config().await?;
|
||||
info!("bitcoin.conf path inaccessible, ensured Bitcoin RPC config via host helper");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
// KEY-05: the salt is half of the stored `rpcauth=` credential line, so
|
||||
// source named and draw guarded.
|
||||
let mut salt_bytes = [0u8; 16];
|
||||
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut salt_bytes).map_err(|e| {
|
||||
anyhow::anyhow!("Refusing to build an rpcauth line from degenerate salt entropy: {e}")
|
||||
})?;
|
||||
let salt_hex = hex::encode(salt_bytes);
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(rpc_pass.as_bytes());
|
||||
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||
let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex);
|
||||
|
||||
// Default to full archive — operators with 2TB+ drives shouldn't be
|
||||
// silently pruned down to 550 MB. Users who want a pruned node can
|
||||
// set `prune=N` in bitcoin.conf themselves after install.
|
||||
//
|
||||
// printtoconsole=0: bitcoind already writes debug.log in the datadir
|
||||
// (self-shrunk on restart); duplicating it to stdout pushed every IBD
|
||||
// "UpdateTip" line through conmon into journald (>1 GB/day). Deep
|
||||
// debugging uses /var/lib/archipelago/bitcoin/debug.log.
|
||||
// rpcbind=0.0.0.0 is REQUIRED inside a container: with rpcallowip set
|
||||
// but no rpcbind, bitcoind binds RPC to 127.0.0.1 in the container
|
||||
// netns only — LND / the Bitcoin UI dialing bitcoin-knots:8332 over
|
||||
// the bridge get connection refused (fresh-install LND crash-loop +
|
||||
// bitcoin-rpc 502, seen on the 1.7.99 ISO). The port publish stays
|
||||
// 127.0.0.1-only on the host, so exposure is unchanged.
|
||||
// Prune sized to the data volume. A full archive needs ~810 GB and
|
||||
// grows; silently writing an unpruned config onto a small disk fills
|
||||
// it mid-IBD (framework node 2026-07-14: unpruned mainnet on a 205 GB
|
||||
// volume). Volumes with real archival headroom (≥1.2 TB) stay full
|
||||
// archive; smaller ones get prune = 25% of the volume, clamped to
|
||||
// [550 MB, 100 GB], leaving room for LND/apps sharing the disk.
|
||||
let prune_line = match bitcoin_data_volume_gb().await {
|
||||
Some(total_gb) if total_gb > 0 && total_gb < 1200 => {
|
||||
let prune_mb = ((total_gb as f64 * 0.25 * 1024.0) as u64).clamp(550, 100_000);
|
||||
info!(
|
||||
volume_gb = total_gb,
|
||||
prune_mb, "Data volume below archival size — enabling sized bitcoin prune"
|
||||
);
|
||||
format!("prune={}\n", prune_mb)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let bitcoin_conf = format!(
|
||||
"\
|
||||
# rpcauth: salted hash only - no plaintext password in config or CLI\n\
|
||||
{}\n\
|
||||
server=1\n\
|
||||
rpcbind=0.0.0.0\n\
|
||||
rpcallowip=0.0.0.0/0\n\
|
||||
listen=1\n\
|
||||
rpcthreads=16\n\
|
||||
rpcworkqueue=256\n\
|
||||
printtoconsole=0\n\
|
||||
{}",
|
||||
rpcauth_line, prune_line
|
||||
);
|
||||
tokio::fs::create_dir_all(bitcoin_dir)
|
||||
.await
|
||||
.context("Failed to create bitcoin data directory")?;
|
||||
tokio::fs::write(&conf_path, bitcoin_conf)
|
||||
.await
|
||||
.context("Failed to write bitcoin.conf")?;
|
||||
info!("Created bitcoin.conf with rpcauth (no plaintext credentials)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write LND config file with Bitcoin RPC credentials.
|
||||
async fn write_lnd_conf(&self, rpc_user: &str, rpc_pass: &str) -> Result<()> {
|
||||
@@ -2515,12 +2624,28 @@ async fn wait_for_adopted_container(package_id: &str, container_name: &str) -> R
|
||||
))
|
||||
}
|
||||
|
||||
// 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.
|
||||
/// Total size (GB) of the filesystem holding the bitcoin data dir, via
|
||||
/// `df -k`. None when df fails (containers, exotic mounts) — callers treat
|
||||
/// unknown as "don't prune" to preserve archival defaults on big iron.
|
||||
async fn bitcoin_data_volume_gb() -> Option<u64> {
|
||||
let target = if std::path::Path::new("/var/lib/archipelago").exists() {
|
||||
"/var/lib/archipelago"
|
||||
} else {
|
||||
"/"
|
||||
};
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["-k", target])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let line = stdout.lines().nth(1)?;
|
||||
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
|
||||
Some(kb / 1024 / 1024)
|
||||
}
|
||||
|
||||
/// One-shot probe: does bitcoind answer an authenticated getblockchaininfo?
|
||||
/// Works during IBD (the call answers with progress while syncing). Goes via
|
||||
@@ -2598,36 +2723,52 @@ async fn wait_for_bitcoin_rpc_gate(package_id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// bitcoind reads only `/tmp/rpc.conf` + CLI args at container start (see
|
||||
/// apps/bitcoin-core & bitcoin-knots manifest.yml, commit a597c1d9) — it
|
||||
/// never reads a datadir bitcoin.conf. A leftover file from an older install
|
||||
/// (or a manual edit) makes Bitcoin Core's own datadir-conflict safety check
|
||||
/// refuse to start ("-conf=... vs default bitcoin.conf"). Remove it — via
|
||||
/// the same host-privileged path the old writer/repairer used, since the
|
||||
/// dir may already be chowned into the container's UID namespace by a
|
||||
/// previous start — instead of "repairing" it into existence.
|
||||
async fn remove_stale_bitcoin_conf() -> Result<bool> {
|
||||
async fn ensure_bitcoin_rpc_config() -> Result<bool> {
|
||||
let script = r#"
|
||||
set -eu
|
||||
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
|
||||
[ -f "$conf" ] || exit 0
|
||||
mv "$conf" "$conf.disabled-$(date +%s)"
|
||||
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
|
||||
exit 2
|
||||
"#;
|
||||
let status = host_sudo(&["sh", "-lc", script])
|
||||
.await
|
||||
.context("remove stale bitcoin.conf")?;
|
||||
.context("ensure bitcoin.conf RPC bind settings")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => {
|
||||
install_log(
|
||||
"INSTALL REPAIR: removed stale bitcoin.conf (conflicts with -conf=/tmp/rpc.conf launch)",
|
||||
)
|
||||
.await;
|
||||
install_log("INSTALL REPAIR: bitcoin.conf RPC bind settings added").await;
|
||||
Ok(true)
|
||||
}
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"bitcoin.conf removal helper exited with {}",
|
||||
"bitcoin.conf RPC repair helper exited with {}",
|
||||
status
|
||||
)),
|
||||
}
|
||||
|
||||
@@ -1011,59 +1011,6 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// auth.session-policy.get — how long a login lasts on this node.
|
||||
pub(in crate::api::rpc) async fn handle_session_policy_get(&self) -> Result<serde_json::Value> {
|
||||
let policy = crate::settings::session_policy::load(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!({
|
||||
"idle_timeout_secs": policy.idle_timeout_secs,
|
||||
"absolute_timeout_secs": policy.absolute_timeout_secs,
|
||||
"reauth_for_funds": policy.reauth_for_funds,
|
||||
}))
|
||||
}
|
||||
|
||||
/// auth.session-policy.set — change it.
|
||||
///
|
||||
/// Values are clamped rather than rejected: the caller learns what was
|
||||
/// actually stored from the reply, which is friendlier than an error and
|
||||
/// makes the bounds discoverable. Fields are individually optional so the
|
||||
/// UI can change one control without having to send the others back.
|
||||
pub(in crate::api::rpc) async fn handle_session_policy_set(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or(serde_json::json!({}));
|
||||
let current = crate::settings::session_policy::load(&self.config.data_dir).await;
|
||||
let policy = crate::settings::session_policy::SessionPolicy {
|
||||
idle_timeout_secs: params
|
||||
.get("idle_timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(current.idle_timeout_secs),
|
||||
absolute_timeout_secs: match params.get("absolute_timeout_secs") {
|
||||
// Explicit null means "no absolute cap", which is different
|
||||
// from the field being absent (leave it as it is).
|
||||
Some(serde_json::Value::Null) => None,
|
||||
Some(v) => v.as_u64().or(current.absolute_timeout_secs),
|
||||
None => current.absolute_timeout_secs,
|
||||
},
|
||||
reauth_for_funds: params
|
||||
.get("reauth_for_funds")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(current.reauth_for_funds),
|
||||
};
|
||||
let saved = crate::settings::session_policy::save(&self.config.data_dir, policy).await?;
|
||||
tracing::info!(
|
||||
idle = saved.idle_timeout_secs,
|
||||
absolute = ?saved.absolute_timeout_secs,
|
||||
reauth_for_funds = saved.reauth_for_funds,
|
||||
"session policy updated"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"idle_timeout_secs": saved.idle_timeout_secs,
|
||||
"absolute_timeout_secs": saved.absolute_timeout_secs,
|
||||
"reauth_for_funds": saved.reauth_for_funds,
|
||||
}))
|
||||
}
|
||||
|
||||
/// system.settings.set — Write a settings value
|
||||
pub(in crate::api::rpc) async fn handle_system_settings_set(
|
||||
&self,
|
||||
|
||||
@@ -222,19 +222,6 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re
|
||||
lines.push("# ControlPort disabled for security".to_string());
|
||||
lines.push(String::new());
|
||||
|
||||
// Ports whose manifests declare `auth: gated` forward to the gate's own
|
||||
// loopback (127.0.0.2, where the app-gate listener binds — see
|
||||
// `appgate::listener::GATE_TOR_UPSTREAM`) instead of the app's 127.0.0.1.
|
||||
// Tor carries no session cookie, so an onion pointed at the app is an
|
||||
// unauthenticated bypass of the gate. Declared-gated ports only: an
|
||||
// undeclared port keeps today's target, because absence of the field is
|
||||
// not an instruction (the v1.7.121 incident rule).
|
||||
let gated_ports: std::collections::HashSet<u16> = crate::appgate::identity::build_port_map()
|
||||
.gated_ports()
|
||||
.filter(|g| g.declared)
|
||||
.map(|g| g.port)
|
||||
.collect();
|
||||
|
||||
for svc in &config.services {
|
||||
if !svc.enabled {
|
||||
continue;
|
||||
@@ -253,7 +240,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(app_hidden_service_port_line(svc.local_port, &gated_ports));
|
||||
lines.push(format!("HiddenServicePort 80 127.0.0.1:{}", svc.local_port));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
@@ -261,24 +248,6 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re
|
||||
|
||||
let content = lines.join("\n");
|
||||
let staging = "/var/lib/archipelago/tor-config/torrc.staged";
|
||||
write_staged_torrc(&content, staging).await
|
||||
}
|
||||
|
||||
/// The `HiddenServicePort` line for an HTTP app onion. Gated ports forward to
|
||||
/// the gate's Tor upstream; everything else to the app itself.
|
||||
fn app_hidden_service_port_line(
|
||||
local_port: u16,
|
||||
gated_ports: &std::collections::HashSet<u16>,
|
||||
) -> String {
|
||||
let upstream = if gated_ports.contains(&local_port) {
|
||||
crate::appgate::listener::GATE_TOR_UPSTREAM.to_string()
|
||||
} else {
|
||||
"127.0.0.1".to_string()
|
||||
};
|
||||
format!("HiddenServicePort 80 {}:{}", upstream, local_port)
|
||||
}
|
||||
|
||||
async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
|
||||
let config_dir = Path::new(staging)
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
|
||||
@@ -287,37 +256,14 @@ async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
|
||||
.await
|
||||
.context("Failed to write staged torrc")?;
|
||||
|
||||
debug!("Staged torrc ({} bytes)", content.len());
|
||||
debug!(
|
||||
"Staged torrc with {} enabled services",
|
||||
config.services.iter().filter(|s| s.enabled).count()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod torrc_tests {
|
||||
use super::app_hidden_service_port_line;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn gated_port_forwards_to_the_gate_not_the_app() {
|
||||
let gated: HashSet<u16> = [8082u16].into_iter().collect();
|
||||
assert_eq!(
|
||||
app_hidden_service_port_line(8082, &gated),
|
||||
"HiddenServicePort 80 127.0.0.2:8082"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undeclared_port_keeps_the_app_loopback_target() {
|
||||
// Absence of `auth: gated` is not an instruction — the onion keeps
|
||||
// pointing at the app, exactly as before this change.
|
||||
let gated: HashSet<u16> = [8082u16].into_iter().collect();
|
||||
assert_eq!(
|
||||
app_hidden_service_port_line(9100, &gated),
|
||||
"HiddenServicePort 80 127.0.0.1:9100"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hostname Sync ───────────────────────────────────────────────
|
||||
|
||||
pub(in crate::api::rpc) async fn sync_single_hostname(name: &str, address: &str) {
|
||||
|
||||
@@ -26,15 +26,6 @@ pub struct GatedPort {
|
||||
pub app_name: String,
|
||||
/// Manifest-declared icon path (`metadata.icon`), when present.
|
||||
pub icon: Option<String>,
|
||||
/// True only when the manifest says `auth: gated` in so many words.
|
||||
///
|
||||
/// The gated set deliberately also carries undeclared Session-default
|
||||
/// ports (so the gate challenges them wherever it can already stand, and
|
||||
/// the audit reports them). But everything that CHANGES where traffic
|
||||
/// goes — the torrc repoint to 127.0.0.2, the FIPS relay stand-down, the
|
||||
/// Tor-upstream bind — must key on this flag: acting on an undeclared
|
||||
/// port is the v1.7.121 incident class, whatever the action.
|
||||
pub declared: bool,
|
||||
}
|
||||
|
||||
/// A port deliberately left unauthenticated, and the manifest's stated reason.
|
||||
@@ -57,7 +48,6 @@ pub struct ExemptPort {
|
||||
pub struct PortMap {
|
||||
gated: HashMap<u16, GatedPort>,
|
||||
exempt: Vec<ExemptPort>,
|
||||
local: std::collections::HashSet<u16>,
|
||||
}
|
||||
|
||||
impl PortMap {
|
||||
@@ -74,21 +64,8 @@ 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.local.is_empty()
|
||||
self.gated.is_empty() && self.exempt.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,35 +101,13 @@ fn manifest_icon(manifest: &AppManifest) -> Option<String> {
|
||||
|
||||
/// Classify every published port across all installed manifests.
|
||||
///
|
||||
/// 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.
|
||||
/// 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: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
for (app_id, value) in crate::container::app_catalog::catalog_manifest_values() {
|
||||
let Some(manifest) =
|
||||
crate::container::app_catalog::catalog_manifest_overlay(&app_id, value)
|
||||
else {
|
||||
// Unparseable/invalid/build-source → the orchestrator falls back
|
||||
// to disk for this app, so classification must too.
|
||||
continue;
|
||||
};
|
||||
if seen_apps.insert(app_id) {
|
||||
classify_manifest(&manifest, &mut map);
|
||||
}
|
||||
}
|
||||
let mut seen_apps: HashMap<String, PathBuf> = HashMap::new();
|
||||
|
||||
for dir in apps_dirs() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
@@ -169,8 +124,100 @@ pub fn build_port_map() -> PortMap {
|
||||
// would have published.
|
||||
continue;
|
||||
};
|
||||
if seen_apps.insert(manifest.app.id.clone()) {
|
||||
classify_manifest(&manifest, &mut map);
|
||||
let app_id = manifest.app.id.clone();
|
||||
if seen_apps.contains_key(&app_id) {
|
||||
continue;
|
||||
}
|
||||
seen_apps.insert(app_id.clone(), path);
|
||||
|
||||
let icon = manifest_icon(&manifest);
|
||||
let app_name = if manifest.app.name.trim().is_empty() {
|
||||
app_id.clone()
|
||||
} else {
|
||||
manifest.app.name.clone()
|
||||
};
|
||||
|
||||
for port in &manifest.app.ports {
|
||||
let protocol = if port.protocol.is_empty() {
|
||||
"tcp"
|
||||
} else {
|
||||
port.protocol.as_str()
|
||||
};
|
||||
match port.auth_policy() {
|
||||
PortAuth::None => map.exempt.push(ExemptPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
rationale: port
|
||||
.auth_rationale
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(no rationale recorded)".to_string()),
|
||||
protocol: protocol.to_string(),
|
||||
}),
|
||||
// Declared host-local. Not gated and not reported as
|
||||
// exposed, because it is neither — see PortAuth::Local
|
||||
// for why this cannot be inferred from `bind`.
|
||||
PortAuth::Local => {}
|
||||
// Explicit opt-in: the app is on loopback and the daemon
|
||||
// owns the external addresses. This is the ONLY way a
|
||||
// port gets bound by the gate, regardless of `bind`.
|
||||
PortAuth::Gated => {
|
||||
map.gated.insert(
|
||||
port.host,
|
||||
GatedPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
app_name: app_name.clone(),
|
||||
icon: icon.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
PortAuth::Session => {
|
||||
// UDP cannot carry an HTTP challenge. Such a port has
|
||||
// no business defaulting into the gated set where it
|
||||
// would look protected without being protectable —
|
||||
// surface it as an unrationalised exemption instead,
|
||||
// which is honest and shows up in the audit list.
|
||||
if protocol != "tcp" {
|
||||
map.exempt.push(ExemptPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
rationale: format!(
|
||||
"{protocol} cannot carry an HTTP challenge; declare auth: none \
|
||||
with a rationale to record why this is safe"
|
||||
),
|
||||
protocol: protocol.to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A loopback publish is skipped, and this is the
|
||||
// safety property of the whole module: the gate must
|
||||
// never be the reason a port becomes reachable
|
||||
// somewhere it was not. `session` is the DEFAULT, so
|
||||
// it is what every un-migrated manifest carries —
|
||||
// and a node's installed manifests always lag the
|
||||
// repo. Binding those externally published Bitcoin
|
||||
// RPC across the LAN within seconds of deploy
|
||||
// (archi-dev-box 2026-08-03). Taking over a port is
|
||||
// opt-in only: `auth: gated`, shipped in the same
|
||||
// manifest edit as the loopback pin.
|
||||
if port
|
||||
.bind
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
map.gated.insert(
|
||||
port.host,
|
||||
GatedPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
app_name: app_name.clone(),
|
||||
icon: icon.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,107 +226,6 @@ 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
PortAuth::Session => {
|
||||
// UDP cannot carry an HTTP challenge. Such a port has
|
||||
// no business defaulting into the gated set where it
|
||||
// would look protected without being protectable —
|
||||
// surface it as an unrationalised exemption instead,
|
||||
// which is honest and shows up in the audit list.
|
||||
if protocol != "tcp" {
|
||||
map.exempt.push(ExemptPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
rationale: format!(
|
||||
"{protocol} cannot carry an HTTP challenge; declare auth: none \
|
||||
with a rationale to record why this is safe"
|
||||
),
|
||||
protocol: protocol.to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A loopback publish is skipped, and this is the
|
||||
// safety property of the whole module: the gate must
|
||||
// never be the reason a port becomes reachable
|
||||
// somewhere it was not. `session` is the DEFAULT, so
|
||||
// it is what every un-migrated manifest carries —
|
||||
// and a node's installed manifests always lag the
|
||||
// repo. Binding those externally published Bitcoin
|
||||
// RPC across the LAN within seconds of deploy
|
||||
// (archi-dev-box 2026-08-03). Taking over a port is
|
||||
// opt-in only: `auth: gated`, shipped in the same
|
||||
// manifest edit as the loopback pin.
|
||||
if port
|
||||
.bind
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
map.gated.insert(
|
||||
port.host,
|
||||
GatedPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
app_name: app_name.clone(),
|
||||
icon: icon.clone(),
|
||||
declared: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -311,90 +257,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest(yaml: &str) -> AppManifest {
|
||||
AppManifest::parse(yaml).expect("test manifest must parse")
|
||||
}
|
||||
|
||||
const BASE: &str = r#"
|
||||
app:
|
||||
id: testapp
|
||||
name: Test App
|
||||
version: "1.0"
|
||||
container:
|
||||
image: example.org/testapp:1.0
|
||||
"#;
|
||||
|
||||
/// `auth: gated` is the only classification allowed to redirect traffic —
|
||||
/// torrc repoints, relay stand-down, and the 127.0.0.2 bind all key on
|
||||
/// `declared`. An undeclared Session port is challenged and audited but
|
||||
/// must never be `declared`.
|
||||
#[test]
|
||||
fn declared_tracks_the_manifest_not_the_default() {
|
||||
let mut map = PortMap::default();
|
||||
classify_manifest(
|
||||
&manifest(&format!(
|
||||
"{BASE} ports:\n - host: 8090\n container: 7777\n protocol: tcp\n bind: 127.0.0.1\n auth: gated\n"
|
||||
)),
|
||||
&mut map,
|
||||
);
|
||||
assert!(map.gated(8090).expect("gated").declared);
|
||||
|
||||
let mut map = PortMap::default();
|
||||
classify_manifest(
|
||||
&manifest(&format!(
|
||||
"{BASE} ports:\n - host: 9100\n container: 9100\n protocol: tcp\n"
|
||||
)),
|
||||
&mut map,
|
||||
);
|
||||
let undeclared = map.gated(9100).expect("session default is challenged");
|
||||
assert!(
|
||||
!undeclared.declared,
|
||||
"an absent auth field must never read as an instruction"
|
||||
);
|
||||
}
|
||||
|
||||
/// `auth: local` keeps the gate's hands off entirely — the port is
|
||||
/// neither gated nor exempt-reported — but it IS recorded, so the mesh
|
||||
/// relay can refuse to republish a deliberately host-local port.
|
||||
#[test]
|
||||
fn local_ports_are_untouched_but_recorded() {
|
||||
let mut map = PortMap::default();
|
||||
classify_manifest(
|
||||
&manifest(&format!(
|
||||
"{BASE} ports:\n - host: 32838\n container: 32838\n protocol: tcp\n bind: 127.0.0.1\n auth: local\n"
|
||||
)),
|
||||
&mut map,
|
||||
);
|
||||
assert!(map.gated(32838).is_none());
|
||||
assert!(map.exempt_ports().is_empty());
|
||||
assert!(
|
||||
map.is_declared_local(32838),
|
||||
"the mesh relay needs this to refuse bridging a host-local port"
|
||||
);
|
||||
assert!(!map.is_declared_local(3000));
|
||||
}
|
||||
|
||||
/// The real corpus: every port the FIPS relay can bridge must be safe to
|
||||
/// bridge. A port that is declared `local` (host-local by intent) or
|
||||
/// declared `gated` (the app gate owns its external addresses) must be
|
||||
/// withheld by the relay — this asserts the two sets the relay consults
|
||||
/// actually classify the live manifests, so a future manifest edit that
|
||||
/// re-opens one is caught here rather than on a node.
|
||||
#[test]
|
||||
fn relay_port_list_respects_local_and_gated_declarations() {
|
||||
let map = build_port_map();
|
||||
let relay_would_expose: Vec<u16> = crate::fips::app_ports::APP_LAUNCH_PORTS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| map.is_declared_local(*p))
|
||||
.collect();
|
||||
assert!(
|
||||
!relay_would_expose.is_empty(),
|
||||
"expected the corpus to contain at least one local port in the relay list \
|
||||
(32838/8999) — if this fails the guard is untested, not unnecessary"
|
||||
);
|
||||
}
|
||||
|
||||
/// Protocol ports that wallets dial directly must never end up gated —
|
||||
/// this is the constraint that decided the design (Zeus and electrum
|
||||
/// clients keep working untouched).
|
||||
|
||||
@@ -44,15 +44,6 @@ 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 {
|
||||
@@ -151,12 +142,8 @@ pub async fn run(
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
// (port, addr) pairs already served, so a sweep does not rebind what it
|
||||
// already holds. 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();
|
||||
// already holds.
|
||||
let mut held: HashMap<(u16, IpAddr), ()> = HashMap::new();
|
||||
let mut interval = tokio::time::interval(SWEEP_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
@@ -173,7 +160,7 @@ pub async fn run(
|
||||
async fn sweep(
|
||||
gate: &Arc<AppGate>,
|
||||
status: &Arc<RwLock<GateStatus>>,
|
||||
held: &mut HashMap<(u16, IpAddr), tokio::task::JoinHandle<()>>,
|
||||
held: &mut HashMap<(u16, IpAddr), ()>,
|
||||
shutdown_rx: &tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
// Re-read the manifests every sweep rather than trusting the map built
|
||||
@@ -183,23 +170,6 @@ 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");
|
||||
@@ -222,10 +192,6 @@ 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) {
|
||||
@@ -235,48 +201,19 @@ async fn sweep(
|
||||
}
|
||||
match TcpListener::bind(SocketAddr::new(addr, app.port)).await {
|
||||
Ok(listener) => {
|
||||
let handle =
|
||||
spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone());
|
||||
held.insert(key, handle);
|
||||
held.insert(key, ());
|
||||
claimed.push((app.port, addr.to_string()));
|
||||
claimed_any = true;
|
||||
info!(
|
||||
port = app.port, %addr, app = %app.app_id,
|
||||
"app gate claimed an app port"
|
||||
);
|
||||
spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone());
|
||||
}
|
||||
// Almost always the app itself holding 0.0.0.0:<port>.
|
||||
Err(_) => blocked = true,
|
||||
}
|
||||
}
|
||||
// The Tor upstream is bound for DECLARED gated ports only: torrc only
|
||||
// repoints an onion at 127.0.0.2 for a declared port, and standing a
|
||||
// challenge on an undeclared port's would-be upstream would change
|
||||
// where its traffic goes on nothing but a default.
|
||||
if app.declared {
|
||||
let tor_key = (app.port, GATE_TOR_UPSTREAM);
|
||||
if held.contains_key(&tor_key) {
|
||||
claimed.push((app.port, GATE_TOR_UPSTREAM.to_string()));
|
||||
} else {
|
||||
match TcpListener::bind(SocketAddr::new(GATE_TOR_UPSTREAM, app.port)).await {
|
||||
Ok(listener) => {
|
||||
let handle = spawn_accept_loop(
|
||||
listener,
|
||||
gate.clone(),
|
||||
app.clone(),
|
||||
shutdown_rx.clone(),
|
||||
);
|
||||
held.insert(tor_key, handle);
|
||||
claimed.push((app.port, GATE_TOR_UPSTREAM.to_string()));
|
||||
info!(
|
||||
port = app.port, app = %app.app_id,
|
||||
"app gate claimed the Tor upstream (127.0.0.2)"
|
||||
);
|
||||
}
|
||||
Err(_) => blocked = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if blocked && !claimed_any {
|
||||
warn!(
|
||||
@@ -314,15 +251,12 @@ async fn app_is_listening(port: u16) -> bool {
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Returns the accept-loop task handle so the sweep can release the claim
|
||||
/// (abort → listener drops → address freed) when the port leaves the gated
|
||||
/// set. In-flight connections finish on their own tasks.
|
||||
fn spawn_accept_loop(
|
||||
listener: TcpListener,
|
||||
gate: Arc<AppGate>,
|
||||
app: GatedPort,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -353,7 +287,7 @@ fn spawn_accept_loop(
|
||||
_ = shutdown_rx.changed() => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -154,11 +154,6 @@ impl AppGate {
|
||||
action: &str,
|
||||
client_ip: IpAddr,
|
||||
) -> Response<Body> {
|
||||
// Assets are GET and pre-auth by nature: the login page cannot
|
||||
// render its own background or logo without them.
|
||||
if let Some(name) = action.strip_prefix("asset/") {
|
||||
return self.serve_asset(name);
|
||||
}
|
||||
if req.method() != Method::POST {
|
||||
return login_page(app, None, StatusCode::OK);
|
||||
}
|
||||
@@ -192,26 +187,6 @@ impl AppGate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Static assets the login page needs, served from the gate's own origin.
|
||||
///
|
||||
/// The backgrounds are ~1 MB each, so inlining them as data URIs would
|
||||
/// bloat every challenge response. Serving them here keeps the page
|
||||
/// byte-identical to the dashboard's login while the CSP stays tight:
|
||||
/// `img-src 'self' data:` and nothing else.
|
||||
fn serve_asset(&self, name: &str) -> Response<Body> {
|
||||
let Some((bytes, mime)) = read_ui_asset(name) else {
|
||||
return not_found();
|
||||
};
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime)
|
||||
// Immutable art; caching it costs nothing and keeps the login
|
||||
// instant on a repeat challenge.
|
||||
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
||||
.body(Body::from(bytes))
|
||||
.expect("asset response builds")
|
||||
}
|
||||
|
||||
async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response<Body> {
|
||||
let password = field(form, "password").unwrap_or_default();
|
||||
|
||||
@@ -453,158 +428,43 @@ fn esc(s: &str) -> String {
|
||||
/// none. Inlined as a data URI rather than linked: the gate is answering on
|
||||
/// the app's own port, so any asset URL would either hit the unauthenticated
|
||||
/// app behind it or a different origin the browser may not reach.
|
||||
/// One stacked layer per background, each delayed so they cross-fade in turn.
|
||||
fn background_layers() -> String {
|
||||
let step = LOGIN_BACKGROUNDS.len() as u32 * 9 / LOGIN_BACKGROUNDS.len() as u32;
|
||||
LOGIN_BACKGROUNDS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
format!(
|
||||
r#"<div class="bg" style="background-image:url('{prefix}asset/{name}');animation-delay:{delay}s"></div>"#,
|
||||
prefix = GATE_PREFIX,
|
||||
delay = i as u32 * step,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn icon_markup(app: &GatedPort) -> String {
|
||||
let inner = app
|
||||
.icon
|
||||
.as_deref()
|
||||
.and_then(read_icon_data_uri)
|
||||
// A manifest that names no icon still gets one: the dashboard already
|
||||
// ships icons named after the app, so fall back to those before
|
||||
// giving up. Without this EVERY gated app showed a lettermark,
|
||||
// because no manifest declares metadata.icon (archi-dev-box,
|
||||
// 2026-08-05).
|
||||
.or_else(|| {
|
||||
icon_candidates(&app.app_id)
|
||||
.iter()
|
||||
.find_map(|c| read_icon_data_uri(c))
|
||||
})
|
||||
.map(|data_uri| format!(r#"<img class="icon" src="{}" alt="">"#, esc(&data_uri)))
|
||||
.unwrap_or_else(|| {
|
||||
let letter = app
|
||||
.app_name
|
||||
.chars()
|
||||
.find(|c| c.is_alphanumeric())
|
||||
.map(|c| c.to_uppercase().to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
format!(r#"<div class="icon lettermark">{}</div>"#, esc(&letter))
|
||||
});
|
||||
format!(r#"<div class="tile">{inner}</div>"#)
|
||||
if let Some(path) = &app.icon {
|
||||
if let Some(data_uri) = read_icon_data_uri(path) {
|
||||
return format!(r#"<img class="icon" src="{}" alt="">"#, esc(&data_uri));
|
||||
}
|
||||
}
|
||||
let letter = app
|
||||
.app_name
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.to_uppercase().to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
format!(r#"<div class="icon lettermark">{}</div>"#, esc(&letter))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Icon basenames to try for an app id, best first.
|
||||
///
|
||||
/// The shipped icon set is named for the *product*, while app ids carry
|
||||
/// packaging detail — `filebrowser` vs `file-browser`, `morphos-server` vs
|
||||
/// `morphos` — and the per-app screens (`lnd-ui`, `bitcoin-ui`, `electrs-ui`)
|
||||
/// have no icon of their own but obviously belong to the app they front.
|
||||
/// Resolving those here keeps the mapping in one readable place instead of
|
||||
/// adding a `metadata.icon` line to every manifest, which would have to be
|
||||
/// re-signed into the catalog to take effect.
|
||||
fn icon_candidates(app_id: &str) -> Vec<String> {
|
||||
let mut out = vec![app_id.to_string()];
|
||||
let alias = match app_id {
|
||||
"filebrowser" => Some("file-browser"),
|
||||
"home-assistant" => Some("homeassistant"),
|
||||
"morphos-server" => Some("morphos"),
|
||||
"barkd" => Some("bark"),
|
||||
"archy-mempool-web" | "mempool-api" => Some("mempool"),
|
||||
"lnd-ui" | "lightning-stack" => Some("lnd"),
|
||||
"bitcoin-ui" => Some("bitcoin-core"),
|
||||
"electrs-ui" => Some("electrumx"),
|
||||
"fips-ui" | "aiui" | "did-wallet" => Some("archipelago-a"),
|
||||
"fedimint-gateway" | "fedimint-clientd" => Some("fedimint"),
|
||||
_ => None,
|
||||
fn read_icon_data_uri(icon_path: &str) -> Option<String> {
|
||||
let name = std::path::Path::new(icon_path).file_name()?.to_str()?;
|
||||
let mime = match name.rsplit_once('.')?.1.to_ascii_lowercase().as_str() {
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"webp" => "image/webp",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
_ => return None,
|
||||
};
|
||||
out.extend(alias.map(str::to_string));
|
||||
// `<app>-ui` / `-server` / `-web` front an app whose icon is the bare name.
|
||||
for suffix in ["-ui", "-server", "-web"] {
|
||||
if let Some(base) = app_id.strip_suffix(suffix) {
|
||||
out.push(base.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Backgrounds the login cycles through, matching the dashboard's own
|
||||
/// `/login` art. Cross-faded by CSS alone — the CSP forbids script, and a
|
||||
/// rotation that needs JavaScript would not survive it.
|
||||
const LOGIN_BACKGROUNDS: [&str; 4] = [
|
||||
"bg-intro.jpg",
|
||||
"bg-intro-4.webp",
|
||||
"bg-intro-6.webp",
|
||||
"bg-intro-3.jpg",
|
||||
];
|
||||
|
||||
/// Assets the gate will serve, by exact name. An allowlist rather than a path
|
||||
/// join: the name arrives in a URL, and the gate answers before any
|
||||
/// authentication, so nothing here may be caller-controlled beyond this set.
|
||||
fn read_ui_asset(name: &str) -> Option<(Vec<u8>, &'static str)> {
|
||||
let allowed = LOGIN_BACKGROUNDS.contains(&name) || name == "logo-archipelago.svg";
|
||||
if !allowed {
|
||||
return None;
|
||||
}
|
||||
let mime = icon_mime(name.rsplit_once('.')?.1)?;
|
||||
for root in [
|
||||
"/opt/archipelago/web-ui/assets/img",
|
||||
"web/dist/neode-ui/assets/img",
|
||||
"neode-ui/public/assets/img",
|
||||
"/opt/archipelago/web-ui/assets/img/app-icons",
|
||||
"web/dist/neode-ui/assets/img/app-icons",
|
||||
] {
|
||||
if let Ok(bytes) = std::fs::read(std::path::Path::new(root).join(name)) {
|
||||
return Some((bytes, mime));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
const ICON_ROOTS: [&str; 2] = [
|
||||
"/opt/archipelago/web-ui/assets/img/app-icons",
|
||||
"web/dist/neode-ui/assets/img/app-icons",
|
||||
];
|
||||
|
||||
fn icon_mime(ext: &str) -> Option<&'static str> {
|
||||
match ext.to_ascii_lowercase().as_str() {
|
||||
"svg" => Some("image/svg+xml"),
|
||||
"png" => Some("image/png"),
|
||||
"webp" => Some("image/webp"),
|
||||
"jpg" | "jpeg" => Some("image/jpeg"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an app icon as a `data:` URI.
|
||||
///
|
||||
/// `icon_ref` may be a filename or path with an extension (a manifest's
|
||||
/// `metadata.icon`), or a bare name such as an app id — in which case the
|
||||
/// known extensions are tried in turn. Only the file name is used; the
|
||||
/// directories searched are fixed, so a manifest cannot point the gate at an
|
||||
/// arbitrary path.
|
||||
fn read_icon_data_uri(icon_ref: &str) -> Option<String> {
|
||||
let name = std::path::Path::new(icon_ref).file_name()?.to_str()?;
|
||||
let candidates: Vec<(String, &str)> = match name.rsplit_once('.') {
|
||||
Some((_, ext)) => vec![(name.to_string(), icon_mime(ext)?)],
|
||||
None => ["svg", "png", "webp", "jpg"]
|
||||
.iter()
|
||||
.filter_map(|ext| Some((format!("{name}.{ext}"), icon_mime(ext)?)))
|
||||
.collect(),
|
||||
};
|
||||
for (file, mime) in candidates {
|
||||
for root in ICON_ROOTS {
|
||||
let candidate = std::path::Path::new(root).join(&file);
|
||||
if let Ok(bytes) = std::fs::read(&candidate) {
|
||||
if bytes.len() > 512 * 1024 {
|
||||
continue;
|
||||
}
|
||||
return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
|
||||
let candidate = std::path::Path::new(root).join(name);
|
||||
if let Ok(bytes) = std::fs::read(&candidate) {
|
||||
if bytes.len() > 512 * 1024 {
|
||||
return None;
|
||||
}
|
||||
return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -624,79 +484,29 @@ fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Respons
|
||||
<meta name="robots" content="noindex">
|
||||
<title>{title} — {app_name}</title>
|
||||
<style>
|
||||
/* The dashboard's own /login, rebuilt in static CSS: the same rotating
|
||||
intro art, .glass-card panel, .glass-button action and transparent
|
||||
white-bordered inputs from neode-ui/src/style.css. Written longhand
|
||||
rather than shared with the SPA because the gate answers before any
|
||||
bundle exists, and the CSP forbids external stylesheets and script. */
|
||||
:root {{ color-scheme: dark; }}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{ margin:0; min-height:100vh; display:grid; place-items:center; padding:1rem;
|
||||
background:#05070a; color:#fff; overflow:hidden;
|
||||
font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif; }}
|
||||
/* Rotating backgrounds: each layer holds its image and cross-fades on a
|
||||
shared cycle, so the art moves the way /login does with no script. */
|
||||
.bg {{ position:fixed; inset:0; z-index:0; background-size:cover;
|
||||
background-position:center; opacity:0; animation:bg-cycle {cycle}s infinite; }}
|
||||
.bg::after {{ content:''; position:absolute; inset:0;
|
||||
background:linear-gradient(180deg, rgba(0,0,0,.35), rgba(0,0,0,.72)); }}
|
||||
@keyframes bg-cycle {{
|
||||
0% {{ opacity:0; }} 4% {{ opacity:1; }}
|
||||
{hold}% {{ opacity:1; }} {fade}% {{ opacity:0; }} 100% {{ opacity:0; }}
|
||||
}}
|
||||
main {{ position:relative; z-index:1; width:min(92vw,28rem); }}
|
||||
.card {{ padding:2rem; padding-top:3.5rem; position:relative;
|
||||
background:rgba(0,0,0,.65); backdrop-filter:blur(18px);
|
||||
-webkit-backdrop-filter:blur(18px); border:1px solid rgba(255,255,255,.18);
|
||||
border-radius:1rem; box-shadow:0 8px 24px rgba(0,0,0,.45); text-align:center; }}
|
||||
/* The Archipelago mark, half in and half out of the panel — same placement
|
||||
and gradient ring as Login.vue. */
|
||||
.logo {{ position:absolute; top:-2.5rem; left:50%; transform:translateX(-50%);
|
||||
width:5rem; height:5rem; border-radius:9999px; padding:3px;
|
||||
background:linear-gradient(135deg, rgba(255,255,255,.6) 0%, rgba(0,0,0,.8) 100%);
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.5); }}
|
||||
.logo img {{ width:100%; height:100%; border-radius:9999px; display:block;
|
||||
background:#000; padding:.5rem; }}
|
||||
/* The app's own tile, in the My Apps shape: 18px-rounded square on dark
|
||||
glass with the same inner highlight and drop shadow. */
|
||||
.tile {{ width:60px; height:60px; border-radius:18px; margin:0 auto .75rem;
|
||||
background:rgba(0,0,0,.72); box-shadow:0 8px 18px rgba(0,0,0,.38); }}
|
||||
.tile .icon {{ width:100%; height:100%; border-radius:18px; display:block;
|
||||
object-fit:cover; border:1px solid rgba(255,255,255,.18);
|
||||
background:radial-gradient(circle at 35% 28%, rgba(255,255,255,.1), rgba(255,255,255,0) 42%),
|
||||
linear-gradient(145deg, rgba(22,22,24,.96), rgba(0,0,0,.96));
|
||||
box-shadow:inset 0 1px 0 rgba(255,255,255,.12), inset 0 -10px 24px rgba(0,0,0,.34); }}
|
||||
.lettermark {{ display:grid; place-items:center; font-size:1.6rem; font-weight:600;
|
||||
color:rgba(255,255,255,.9); }}
|
||||
h1 {{ font-size:1.5rem; font-weight:600; margin:0 0 .4rem;
|
||||
color:rgba(255,255,255,.96); text-shadow:0 2px 6px rgba(0,0,0,.4); }}
|
||||
p.sub {{ margin:0 0 1.75rem; color:rgba(255,255,255,.6); font-size:.875rem; }}
|
||||
input {{ width:100%; padding:.75rem 1rem; margin-bottom:1rem; border-radius:.5rem;
|
||||
border:1px solid rgba(255,255,255,.2); background:transparent; color:#fff;
|
||||
font-size:1rem; transition:border-color .2s ease; }}
|
||||
input::placeholder {{ color:rgba(255,255,255,.4); }}
|
||||
input:focus {{ outline:none; border-color:rgba(255,255,255,.4);
|
||||
box-shadow:0 0 0 1px rgba(255,255,255,.2); }}
|
||||
button {{ width:100%; min-height:44px; padding:.75rem 1.25rem; border:none;
|
||||
border-radius:.75rem; background:rgba(0,0,0,.6);
|
||||
backdrop-filter:blur(24px); -webkit-backdrop-filter:blur(24px);
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.22);
|
||||
color:rgba(255,255,255,.9); font-size:1rem; font-weight:500; cursor:pointer;
|
||||
transition:background-color .2s ease, transform .3s cubic-bezier(.4,0,.2,1); }}
|
||||
button:hover {{ background:rgba(0,0,0,.7); }}
|
||||
button:active {{ transform:translateY(1px); }}
|
||||
.err {{ background:rgba(239,68,68,.2); border:1px solid rgba(239,68,68,.4);
|
||||
color:#fecaca; padding:.75rem; border-radius:.5rem; margin-bottom:1rem;
|
||||
font-size:.875rem; text-align:left; }}
|
||||
body {{ margin:0; min-height:100vh; display:grid; place-items:center;
|
||||
background:#0b0f14; color:#e6edf3; font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif; }}
|
||||
.card {{ width:min(92vw,380px); padding:2rem; background:#121820;
|
||||
border:1px solid #223; border-radius:14px; text-align:center; }}
|
||||
.icon {{ width:64px; height:64px; border-radius:14px; margin:0 auto 1rem; display:block; object-fit:cover; }}
|
||||
.lettermark {{ display:grid; place-items:center; background:#1d2733; font-size:28px; font-weight:600; }}
|
||||
h1 {{ font-size:1.15rem; margin:0 0 .25rem; }}
|
||||
p.sub {{ margin:0 0 1.5rem; color:#8b98a5; font-size:.9rem; }}
|
||||
input {{ width:100%; padding:.7rem .8rem; margin-bottom:.75rem; border-radius:9px;
|
||||
border:1px solid #2b3947; background:#0d131a; color:#e6edf3; font-size:1rem; }}
|
||||
input:focus {{ outline:2px solid #3b82f6; outline-offset:1px; }}
|
||||
button {{ width:100%; padding:.7rem; border:0; border-radius:9px; background:#3b82f6;
|
||||
color:#fff; font-size:1rem; font-weight:600; cursor:pointer; }}
|
||||
button:hover {{ background:#2f6fd6; }}
|
||||
.err {{ background:#3b1519; border:1px solid #7f1d1d; color:#fca5a5;
|
||||
padding:.6rem .8rem; border-radius:9px; margin-bottom:1rem; font-size:.9rem; }}
|
||||
</style></head>
|
||||
<body>{backgrounds}<main><div class="card">{body}</div></main></body></html>"#,
|
||||
<body><main class="card">{body}</main></body></html>"#,
|
||||
title = esc(title),
|
||||
app_name = esc(&app.app_name),
|
||||
body = body,
|
||||
backgrounds = background_layers(),
|
||||
cycle = LOGIN_BACKGROUNDS.len() as u32 * 9,
|
||||
hold = 100 / LOGIN_BACKGROUNDS.len() as u32,
|
||||
fade = 100 / LOGIN_BACKGROUNDS.len() as u32 + 4,
|
||||
);
|
||||
Response::builder()
|
||||
.status(status)
|
||||
@@ -704,18 +514,10 @@ button:active {{ transform:translateY(1px); }}
|
||||
// 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")
|
||||
// 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("X-Frame-Options", "DENY")
|
||||
.header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
|
||||
form-action 'self'; frame-ancestors 'self' http://*:* https://*:*",
|
||||
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'self'",
|
||||
)
|
||||
.body(Body::from(html))
|
||||
.expect("static response builds")
|
||||
@@ -726,8 +528,7 @@ button:active {{ transform:translateY(1px); }}
|
||||
/// password by an unexplained page.
|
||||
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
|
||||
let body = format!(
|
||||
r#"<div class="logo"><img src="{prefix}asset/logo-archipelago.svg" alt="Archipelago"></div>
|
||||
{icon}
|
||||
r#"{icon}
|
||||
<h1>Sign in to open {name}</h1>
|
||||
<p class="sub">This app is protected by your node password.</p>
|
||||
{err}
|
||||
@@ -777,7 +578,6 @@ mod tests {
|
||||
app_id: "strfry".to_string(),
|
||||
app_name: "Strfry Relay".to_string(),
|
||||
icon: None,
|
||||
declared: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,61 +635,11 @@ mod tests {
|
||||
assert!(!html.contains("<img src=x"));
|
||||
}
|
||||
|
||||
/// The challenge must be framable by this node's own dashboard — My Apps
|
||||
/// opens apps in an embedded frame, and a blanket `X-Frame-Options: DENY`
|
||||
/// turned every gated app into "app is not responding" (100.82.34.38,
|
||||
/// 2026-08-05). It must still be uncacheable, and still refuse to be
|
||||
/// framed by a foreign origin, which `frame-ancestors` expresses and
|
||||
/// `X-Frame-Options` cannot.
|
||||
#[test]
|
||||
fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() {
|
||||
fn challenge_pages_are_not_cacheable_or_framable() {
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
|
||||
assert!(
|
||||
!resp.headers().contains_key("X-Frame-Options"),
|
||||
"X-Frame-Options cannot express 'my own node on another port' — it \
|
||||
blocked the dashboard's own frame"
|
||||
);
|
||||
let csp = resp.headers()["Content-Security-Policy"].to_str().unwrap();
|
||||
assert!(csp.contains("frame-ancestors 'self'"));
|
||||
assert!(csp.contains("form-action 'self'"));
|
||||
}
|
||||
|
||||
/// The login page must render entirely from the gate's own origin: the
|
||||
/// CSP allows no external host, so a background or logo that 404s leaves
|
||||
/// a black page rather than the dashboard's art.
|
||||
#[tokio::test]
|
||||
async fn login_page_sources_its_art_from_the_gate() {
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body).to_string();
|
||||
assert!(html.contains(&format!("{GATE_PREFIX}asset/logo-archipelago.svg")));
|
||||
for name in LOGIN_BACKGROUNDS {
|
||||
assert!(
|
||||
html.contains(&format!("{GATE_PREFIX}asset/{name}")),
|
||||
"background {name} is not referenced"
|
||||
);
|
||||
}
|
||||
// Every referenced asset must be one the gate will actually serve.
|
||||
assert!(read_ui_asset("logo-archipelago.svg").is_some() || cfg!(not(debug_assertions)));
|
||||
}
|
||||
|
||||
/// The allowlist is the whole security boundary for asset serving: the
|
||||
/// name arrives in a URL and is read before any authentication.
|
||||
#[test]
|
||||
fn asset_serving_refuses_anything_off_the_allowlist() {
|
||||
for name in [
|
||||
"../../../etc/passwd",
|
||||
"/etc/passwd",
|
||||
"db.sqlite3",
|
||||
"manifest.yml",
|
||||
"",
|
||||
] {
|
||||
assert!(
|
||||
read_ui_asset(name).is_none(),
|
||||
"{name} must not be servable by the gate"
|
||||
);
|
||||
}
|
||||
assert_eq!(resp.headers()["X-Frame-Options"], "DENY");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -154,9 +154,9 @@ pub async fn ensure_doctor_installed() {
|
||||
}
|
||||
match run_bitcoin_rpc_repair().await {
|
||||
Ok(true) => {
|
||||
info!("Removed stale bitcoin.conf; running Bitcoin containers left untouched")
|
||||
info!("Repaired Bitcoin RPC bind settings; running Bitcoin containers left untouched")
|
||||
}
|
||||
Ok(false) => debug!("No stale bitcoin.conf found"),
|
||||
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
|
||||
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_apps_dir_repair().await {
|
||||
@@ -621,30 +621,52 @@ exit 2
|
||||
}
|
||||
|
||||
async fn run_bitcoin_rpc_repair() -> Result<bool> {
|
||||
// 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.
|
||||
// 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.
|
||||
let script = r#"
|
||||
set -eu
|
||||
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
|
||||
[ -f "$conf" ] || exit 0
|
||||
mv "$conf" "$conf.disabled-$(date +%s)"
|
||||
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
|
||||
exit 2
|
||||
"#;
|
||||
let status = host_sudo(&["sh", "-lc", script])
|
||||
.await
|
||||
.context("remove stale bitcoin.conf RPC bind settings")?;
|
||||
.context("repair 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. Removing the stale file is
|
||||
// only a fallback for future starts; current containers keep their
|
||||
// command-line RPC args regardless.
|
||||
// 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.
|
||||
Some(2) => Ok(true),
|
||||
_ => {
|
||||
warn!("Bitcoin RPC repair helper exited with {}", status);
|
||||
@@ -1271,54 +1293,3 @@ mod tests {
|
||||
assert_ne!(outcome, PodmanHealOutcome::Healthy);
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair this node's own systemd restart policy.
|
||||
///
|
||||
/// The in-process updater replaces the binary and then asks systemd to
|
||||
/// restart the service, treating `Restart=always` on the unit as its second
|
||||
/// net if that request is ever lost. On austin-sapien (2026-08-05) the unit
|
||||
/// was an old one carrying `Restart=on-failure`: the daemon exited cleanly
|
||||
/// (status 0), systemd read that as success, and the node sat dead for over
|
||||
/// two hours after a routine update — "server starting" in the UI, with
|
||||
/// nothing to start it.
|
||||
///
|
||||
/// A node cannot be relied on to fix this via `self-update.sh` (which does
|
||||
/// refresh units) because the in-process update path never runs it. So the
|
||||
/// daemon checks its own unit at boot: any node that starts even once ends
|
||||
/// up with a policy that survives the next update. Deliberately narrow —
|
||||
/// only the `Restart=` line is touched, so local edits elsewhere in the unit
|
||||
/// are preserved.
|
||||
pub async fn ensure_restart_policy() {
|
||||
const UNIT: &str = "/etc/systemd/system/archipelago.service";
|
||||
let Ok(body) = fs::read_to_string(UNIT).await else {
|
||||
return; // not a systemd install (container, dev box) — nothing to do
|
||||
};
|
||||
if !body.lines().any(|l| {
|
||||
let l = l.trim();
|
||||
l.starts_with("Restart=") && l != "Restart=always"
|
||||
}) {
|
||||
return; // already correct, or no Restart= line to repair
|
||||
}
|
||||
let patched: String = body
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.trim().starts_with("Restart=") && l.trim() != "Restart=always" {
|
||||
"Restart=always"
|
||||
} else {
|
||||
l
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
match write_root_if_needed(UNIT, &patched).await {
|
||||
Ok(true) => {
|
||||
tracing::warn!(
|
||||
"repaired archipelago.service Restart= policy to always — this node would \
|
||||
have stayed dead after an in-process update"
|
||||
);
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(error = %e, "could not repair archipelago.service restart policy"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,46 +216,6 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A catalog-embedded manifest as the node actually applies it: parsed,
|
||||
/// id-checked, validated, and image-only (build-source manifests defer to
|
||||
/// disk). `None` = the caller must fall back to the disk manifest.
|
||||
///
|
||||
/// Shared between the orchestrator's load overlay and the app gate's port
|
||||
/// classification so both answer "which manifest governs this app?" from the
|
||||
/// same origin. They diverged once — the orchestrator published containers
|
||||
/// from the catalog while the gate classified from stale disk manifests, and
|
||||
/// the gate externally bound a port the catalog had declared `auth: local`
|
||||
/// (nbxplorer 32838, archi-dev-box 2026-08-04).
|
||||
pub fn catalog_manifest_overlay(
|
||||
app_id: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Option<archipelago_container::manifest::AppManifest> {
|
||||
let m: archipelago_container::manifest::AppManifest = match serde_json::from_value(value) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(app = %app_id, error = %e,
|
||||
"skipping unparseable catalog manifest; using disk fallback");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if m.app.id != app_id {
|
||||
tracing::warn!(catalog_id = %app_id, manifest_id = %m.app.id,
|
||||
"skipping catalog manifest: embedded app id mismatches catalog key");
|
||||
return None;
|
||||
}
|
||||
if let Err(e) = m.validate() {
|
||||
tracing::warn!(app = %app_id, error = %e,
|
||||
"skipping invalid catalog manifest; using disk fallback");
|
||||
return None;
|
||||
}
|
||||
if m.app.container.build.is_some() {
|
||||
tracing::debug!(app = %app_id,
|
||||
"catalog manifest has a build source; deferring to disk (phase 1 = image-only)");
|
||||
return None;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// The catalog's default/latest version string for an app (the top-level
|
||||
/// `version` field), if covered. Used to decide whether an install-time
|
||||
/// selection should pin (older) or track-latest (default).
|
||||
|
||||
@@ -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 127.0.0.1:8334"));
|
||||
assert!(TEMPLATE.contains("listen 8334"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
server {
|
||||
# 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;
|
||||
listen 8334;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
@@ -214,59 +214,10 @@ pub async fn install_one(spec: &CompanionSpec) -> Result<()> {
|
||||
}
|
||||
// Start is idempotent — if already running, systemctl returns 0.
|
||||
quadlet::enable_now(&unit.service_name()).await?;
|
||||
|
||||
// A rebuilt image does NOT reach a container that is already running.
|
||||
// `ensure_image_present` rebuilds in place under the same tag, so the unit
|
||||
// body is byte-identical, `write_if_changed` reports no change, and
|
||||
// `enable_now` is a no-op on a running service — the container keeps the
|
||||
// old layers indefinitely. That is exactly how archi-dev-box kept serving
|
||||
// the LND, FIPS, Electrs and Guardian screens on 0.0.0.0 after v1.7.123
|
||||
// rebuilt every one of those images to bind loopback: the images were
|
||||
// correct on disk and the running containers were three days old
|
||||
// (2026-08-05). Compare image IDs and restart when they diverge.
|
||||
if let Some(running) = container_image_id(spec.name).await {
|
||||
if let Some(built) = image_id(&image).await {
|
||||
if running != built {
|
||||
info!(
|
||||
companion = spec.name,
|
||||
"running container uses a stale image; restarting onto the rebuilt one"
|
||||
);
|
||||
quadlet::restart_service(&unit.service_name()).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(companion = spec.name, "companion started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Image ID a container is actually running, or `None` when it does not exist.
|
||||
async fn container_image_id(name: &str) -> Option<String> {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{.Image}}"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!id.is_empty()).then_some(id)
|
||||
}
|
||||
|
||||
/// Current ID behind an image reference, or `None` when absent.
|
||||
async fn image_id(image_ref: &str) -> Option<String> {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["image", "inspect", image_ref, "--format", "{{.Id}}"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!id.is_empty()).then_some(id)
|
||||
}
|
||||
|
||||
/// Build companion image locally if a Dockerfile exists, otherwise
|
||||
/// pull from the lfg2025 registry. Returns the image ref the quadlet
|
||||
/// should reference (`localhost/<base>:latest` for build, registry
|
||||
|
||||
@@ -595,20 +595,10 @@ 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 — 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).
|
||||
/// 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.
|
||||
fn host_port_bindings_drifted(
|
||||
port_bindings_json: &str,
|
||||
manifest_ports: &[archipelago_container::manifest::PortMapping],
|
||||
@@ -636,26 +626,10 @@ fn host_port_bindings_drifted(
|
||||
}
|
||||
let expected = port.host.to_string();
|
||||
let matches_expected = bindings.iter().any(|b| {
|
||||
let host_port_ok = b
|
||||
.get("HostPort")
|
||||
b.get("HostPort")
|
||||
.and_then(|h| h.as_str())
|
||||
.map(|h| h == expected)
|
||||
.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
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if !matches_expected {
|
||||
return true;
|
||||
@@ -1183,7 +1157,30 @@ struct LoadedManifest {
|
||||
/// source (build contexts aren't registry-distributed yet — phase 1 is
|
||||
/// image-only). See `docs/registry-manifest-design.md`.
|
||||
fn catalog_manifest_to_overlay(app_id: &str, value: serde_json::Value) -> Option<AppManifest> {
|
||||
crate::container::app_catalog::catalog_manifest_overlay(app_id, value)
|
||||
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)
|
||||
}
|
||||
|
||||
struct OrchestratorState {
|
||||
@@ -4572,76 +4569,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
fn bound_port(
|
||||
host: u16,
|
||||
container: u16,
|
||||
bind: &str,
|
||||
) -> archipelago_container::manifest::PortMapping {
|
||||
archipelago_container::manifest::PortMapping {
|
||||
bind: bind.to_string(),
|
||||
..port(host, container)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_drift_detected_when_declared_loopback_but_published_wildcard() {
|
||||
// The legacy-container case: a pre-quadlet container still publishes
|
||||
// 0.0.0.0 while the catalog-delivered manifest pins the app to
|
||||
// loopback for the app gate. Must recreate, or the port stays open on
|
||||
// every interface and the gate can never claim it.
|
||||
for wildcard in [r#""""#, r#""0.0.0.0""#] {
|
||||
let bindings = format!(r#"{{"80/tcp":[{{"HostIp":{wildcard},"HostPort":"8082"}}]}}"#);
|
||||
assert!(host_port_bindings_drifted(
|
||||
&bindings,
|
||||
&[bound_port(8082, 80, "127.0.0.1")]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_bind_drift_when_declared_loopback_and_published_loopback() {
|
||||
let bindings = r#"{"80/tcp":[{"HostIp":"127.0.0.1","HostPort":"8082"}]}"#;
|
||||
assert!(!host_port_bindings_drifted(
|
||||
bindings,
|
||||
&[bound_port(8082, 80, "127.0.0.1")]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_bind_drift_on_undeclared_bind() {
|
||||
// Silence is not consent (v1.7.121 incident class): an EMPTY manifest
|
||||
// bind must never recreate a loopback-published container to
|
||||
// wildcard — that is how Bitcoin's RPC got republished on the LAN.
|
||||
let bindings = r#"{"8332/tcp":[{"HostIp":"127.0.0.1","HostPort":"8332"}]}"#;
|
||||
assert!(!host_port_bindings_drifted(bindings, &[port(8332, 8332)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_bind_publish_satisfies_each_declared_entry() {
|
||||
// Same host/container pair listed twice (loopback + archy-net
|
||||
// gateway): both declared binds are present in the actual publish.
|
||||
let bindings = r#"{"8332/tcp":[
|
||||
{"HostIp":"127.0.0.1","HostPort":"8332"},
|
||||
{"HostIp":"10.89.0.1","HostPort":"8332"}
|
||||
]}"#;
|
||||
assert!(!host_port_bindings_drifted(
|
||||
bindings,
|
||||
&[
|
||||
bound_port(8332, 8332, "127.0.0.1"),
|
||||
bound_port(8332, 8332, "10.89.0.1")
|
||||
]
|
||||
));
|
||||
// And a wildcard-only publish drifts BOTH declared entries.
|
||||
let wildcard = r#"{"8332/tcp":[{"HostIp":"","HostPort":"8332"}]}"#;
|
||||
assert!(host_port_bindings_drifted(
|
||||
wildcard,
|
||||
&[
|
||||
bound_port(8332, 8332, "127.0.0.1"),
|
||||
bound_port(8332, 8332, "10.89.0.1")
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_secret_error_names_the_secret() {
|
||||
use archipelago_container::manifest::SecretsProvider;
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
|
||||
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
|
||||
18081, 18083, 23000, 32838, 50002,
|
||||
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8888, 8999, 9000, 9100, 10380, 11434, 18081,
|
||||
18083, 23000, 32838, 50002,
|
||||
];
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
//! Last-known-good FIPS peer endpoints (A3.10).
|
||||
//!
|
||||
//! The LAN direct-peering tick (`anchors::lan_fips_anchors`) only helps peers
|
||||
//! we can currently see on the LAN. When a federation peer's LAN path is gone
|
||||
//! (renumbered network, remote site, mDNS blackout) the only route left is the
|
||||
//! anchor spanning tree — the exact hairpin RC2 calls out. But if we were EVER
|
||||
//! connected to that peer directly, the daemon knew a working endpoint for it
|
||||
//! (`fipsctl show peers` → `transport_addr`/`transport_type`, which covers
|
||||
//! LAN, Tailscale, and WAN endpoints alike). This module persists those
|
||||
//! npub-keyed endpoints and re-offers them as dial candidates when the live
|
||||
//! paths disappear: LAN → last-known-good → anchor tree.
|
||||
//!
|
||||
//! Persisted at `<data_dir>/fips-endpoints.json`. Entries are refreshed every
|
||||
//! time the peer is seen connected and dropped after `RETENTION` without a
|
||||
//! sighting, so a peer that genuinely moved doesn't get dialed at a stale
|
||||
//! address forever ( `fipsctl connect` to a dead address is harmless but not
|
||||
//! free).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::fs;
|
||||
|
||||
use super::anchors::SeedAnchor;
|
||||
|
||||
const FILE_NAME: &str = "fips-endpoints.json";
|
||||
/// Forget endpoints not seen connected for this long (seconds) — 30 days.
|
||||
const RETENTION_SECS: u64 = 30 * 24 * 60 * 60;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct KnownEndpoint {
|
||||
/// "ip:port" as reported by the daemon (`transport_addr`).
|
||||
pub address: String,
|
||||
/// "udp" | "tcp" (`transport_type`).
|
||||
pub transport: String,
|
||||
/// Unix seconds of the last time this peer was seen connected here.
|
||||
pub last_ok_unix: u64,
|
||||
}
|
||||
|
||||
/// A currently-connected peer as parsed from `fipsctl show peers`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectedPeer {
|
||||
pub npub: String,
|
||||
pub address: String,
|
||||
pub transport: String,
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> HashMap<String, KnownEndpoint> {
|
||||
let path = data_dir.join(FILE_NAME);
|
||||
match fs::read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
Err(_) => HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save(data_dir: &Path, map: &HashMap<String, KnownEndpoint>) -> Result<()> {
|
||||
let path = data_dir.join(FILE_NAME);
|
||||
let tmp = data_dir.join(format!("{FILE_NAME}.tmp"));
|
||||
fs::write(&tmp, serde_json::to_vec_pretty(map)?).await?;
|
||||
fs::rename(&tmp, &path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge the currently-connected peers into the store (refreshing their
|
||||
/// timestamps), prune expired entries, persist, and return the updated map.
|
||||
/// Persistence failures are non-fatal — the in-memory result is still
|
||||
/// returned so this tick's fallback logic works.
|
||||
pub async fn record_connected(
|
||||
data_dir: &Path,
|
||||
connected: &[ConnectedPeer],
|
||||
) -> HashMap<String, KnownEndpoint> {
|
||||
let mut map = load(data_dir).await;
|
||||
let now = now_unix();
|
||||
let before = map.clone();
|
||||
for p in connected {
|
||||
if p.npub.is_empty() || p.address.is_empty() {
|
||||
continue;
|
||||
}
|
||||
map.insert(
|
||||
p.npub.clone(),
|
||||
KnownEndpoint {
|
||||
address: p.address.clone(),
|
||||
transport: p.transport.clone(),
|
||||
last_ok_unix: now,
|
||||
},
|
||||
);
|
||||
}
|
||||
map.retain(|_, e| now.saturating_sub(e.last_ok_unix) <= RETENTION_SECS);
|
||||
if map != before {
|
||||
if let Err(e) = save(data_dir, &map).await {
|
||||
tracing::debug!("fips endpoint store save failed (non-fatal): {e}");
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Build fallback anchors for federation peers whose live paths are gone:
|
||||
/// every `wanted_npub` that is neither currently connected nor covered by a
|
||||
/// live LAN direct entry, but has a last-known-good endpoint, becomes a dial
|
||||
/// candidate. `fipsctl connect` is idempotent and failure-tolerant, so a
|
||||
/// stale candidate costs one failed dial, bounded by apply()'s per-connect
|
||||
/// timeout.
|
||||
pub fn fallback_anchors(
|
||||
known: &HashMap<String, KnownEndpoint>,
|
||||
wanted_npubs: &[String],
|
||||
connected_npubs: &[String],
|
||||
lan_direct: &[SeedAnchor],
|
||||
) -> Vec<SeedAnchor> {
|
||||
let mut out = Vec::new();
|
||||
for npub in wanted_npubs {
|
||||
if connected_npubs.iter().any(|c| c == npub) {
|
||||
continue;
|
||||
}
|
||||
if lan_direct.iter().any(|a| &a.npub == npub) {
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = known.get(npub) {
|
||||
out.push(SeedAnchor {
|
||||
npub: npub.clone(),
|
||||
address: e.address.clone(),
|
||||
transport: e.transport.clone(),
|
||||
label: "last-known-good endpoint (direct FIPS)".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ep(addr: &str) -> KnownEndpoint {
|
||||
KnownEndpoint {
|
||||
address: addr.to_string(),
|
||||
transport: "udp".to_string(),
|
||||
last_ok_unix: now_unix(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_and_reload_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let connected = vec![ConnectedPeer {
|
||||
npub: "npub1aaa".into(),
|
||||
address: "100.114.134.21:2121".into(),
|
||||
transport: "udp".into(),
|
||||
}];
|
||||
let map = record_connected(dir.path(), &connected).await;
|
||||
assert_eq!(map["npub1aaa"].address, "100.114.134.21:2121");
|
||||
let reloaded = load(dir.path()).await;
|
||||
assert_eq!(reloaded, map);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_entries_are_pruned_on_record() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut stale = HashMap::new();
|
||||
stale.insert(
|
||||
"npub1old".to_string(),
|
||||
KnownEndpoint {
|
||||
address: "10.0.0.1:2121".into(),
|
||||
transport: "udp".into(),
|
||||
last_ok_unix: now_unix() - RETENTION_SECS - 60,
|
||||
},
|
||||
);
|
||||
save(dir.path(), &stale).await.unwrap();
|
||||
let map = record_connected(dir.path(), &[]).await;
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_skips_connected_and_lan_covered_peers() {
|
||||
let mut known = HashMap::new();
|
||||
known.insert("npub1gone".to_string(), ep("100.1.2.3:2121"));
|
||||
known.insert("npub1conn".to_string(), ep("100.1.2.4:2121"));
|
||||
known.insert("npub1lan".to_string(), ep("100.1.2.5:2121"));
|
||||
let wanted: Vec<String> = ["npub1gone", "npub1conn", "npub1lan", "npub1never"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let connected = vec!["npub1conn".to_string()];
|
||||
let lan = vec![SeedAnchor {
|
||||
npub: "npub1lan".into(),
|
||||
address: "192.168.63.198:2121".into(),
|
||||
transport: "udp".into(),
|
||||
label: "LAN".into(),
|
||||
}];
|
||||
let out = fallback_anchors(&known, &wanted, &connected, &lan);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].npub, "npub1gone");
|
||||
assert_eq!(out[0].address, "100.1.2.3:2121");
|
||||
// npub1never has no stored endpoint → nothing to dial.
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ pub mod anchors;
|
||||
pub mod app_ports;
|
||||
pub mod config;
|
||||
pub mod dial;
|
||||
pub mod endpoints;
|
||||
pub mod iface;
|
||||
pub mod service;
|
||||
pub mod telemetry;
|
||||
|
||||
@@ -227,52 +227,6 @@ pub async fn peer_connectivity_summary(anchor_candidates: &[String]) -> (u32, bo
|
||||
(authenticated_peer_count, anchor_connected)
|
||||
}
|
||||
|
||||
/// Currently-connected peers with their live endpoints, from
|
||||
/// `fipsctl show peers` (`transport_addr`/`transport_type`). Feeds the
|
||||
/// last-known-good endpoint store (A3.10); empty on any failure.
|
||||
pub async fn connected_peer_endpoints() -> Vec<crate::fips::endpoints::ConnectedPeer> {
|
||||
let peers_json = match Command::new("sudo")
|
||||
.args(["-n", "fipsctl", "show", "peers"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(o) if o.status.success() => o.stdout,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
let parsed: serde_json::Value = match serde_json::from_slice(&peers_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
parsed
|
||||
.get("peers")
|
||||
.and_then(|p| p.as_array())
|
||||
.map(|peers| {
|
||||
peers
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.get("connectivity")
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s == "connected")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter_map(|p| {
|
||||
let npub = p.get("npub").and_then(|n| n.as_str())?;
|
||||
let address = p.get("transport_addr").and_then(|a| a.as_str())?;
|
||||
let transport = p
|
||||
.get("transport_type")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("udp");
|
||||
Some(crate::fips::endpoints::ConnectedPeer {
|
||||
npub: npub.to_string(),
|
||||
address: address.to_string(),
|
||||
transport: transport.to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the upstream daemon's public key at `/etc/fips/fips.pub` and return
|
||||
/// it as a bech32 npub. Returns `Ok(None)` if the file doesn't exist — used
|
||||
/// as a fallback on legacy/dev nodes where no seed-derived key exists.
|
||||
|
||||
@@ -409,11 +409,6 @@ async fn main() -> Result<()> {
|
||||
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
|
||||
tokio::spawn(bootstrap::ensure_kiosk_hardened());
|
||||
|
||||
// Repair our own restart policy before anything else can need it: a node
|
||||
// whose unit still says Restart=on-failure stays dead after the next
|
||||
// in-process update, because the daemon exits cleanly to be restarted.
|
||||
tokio::spawn(bootstrap::ensure_restart_policy());
|
||||
|
||||
// HDMI audio: install the PipeWire stack + audio-router daemon on kiosk
|
||||
// nodes (older ISOs shipped no audio stack; the router also heals the
|
||||
// boot-time ELD race that leaves HDMI silently unavailable).
|
||||
|
||||
@@ -847,39 +847,6 @@ impl Server {
|
||||
if !direct.is_empty() {
|
||||
let _ = crate::fips::anchors::apply(&direct).await;
|
||||
}
|
||||
|
||||
// A3.10 — endpoint fallback for direct peering. Record
|
||||
// where currently-connected peers actually are (their
|
||||
// transport_addr covers LAN, Tailscale, and WAN alike),
|
||||
// then re-dial the last-known-good endpoint of every
|
||||
// federation peer whose live paths are gone: not
|
||||
// connected now, no LAN direct entry this tick. Escala-
|
||||
// tion order is LAN → last-known-good → anchor tree;
|
||||
// a stale candidate costs one bounded failed dial.
|
||||
let connected = crate::fips::service::connected_peer_endpoints().await;
|
||||
let known =
|
||||
crate::fips::endpoints::record_connected(&data_dir, &connected).await;
|
||||
let wanted: Vec<String> = reg
|
||||
.all_peers()
|
||||
.await
|
||||
.iter()
|
||||
.filter_map(|p| p.fips_npub.clone())
|
||||
.collect();
|
||||
let connected_npubs: Vec<String> =
|
||||
connected.iter().map(|c| c.npub.clone()).collect();
|
||||
let fallback = crate::fips::endpoints::fallback_anchors(
|
||||
&known,
|
||||
&wanted,
|
||||
&connected_npubs,
|
||||
&direct,
|
||||
);
|
||||
if !fallback.is_empty() {
|
||||
tracing::info!(
|
||||
count = fallback.len(),
|
||||
"dialing last-known-good endpoints for disconnected federation peers"
|
||||
);
|
||||
let _ = crate::fips::anchors::apply(&fallback).await;
|
||||
}
|
||||
}
|
||||
|
||||
let next = if daemon_restarting && fast_retries < MAX_FAST_RETRIES {
|
||||
@@ -1178,52 +1145,16 @@ fn fips_app_relay_addr(ip: std::net::Ipv6Addr, port: u16) -> SocketAddr {
|
||||
/// without a daemon restart. Each relay binds to the fips0 ULA only and
|
||||
/// forwards raw TCP to the same port on IPv4 loopback.
|
||||
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
|
||||
use std::collections::HashMap;
|
||||
let mut bridged: HashMap<u16, tokio::task::JoinHandle<()>> = HashMap::new();
|
||||
use std::collections::HashSet;
|
||||
let mut bridged: HashSet<u16> = HashSet::new();
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
let Some(fips_ip) = crate::fips::iface::fips0_ula() else { continue };
|
||||
// This relay is a raw unauthenticated forward from the mesh to
|
||||
// the app's loopback, so it must refuse two classes of port:
|
||||
//
|
||||
// * `auth: gated` — the app gate owns the fips0 ULA for these,
|
||||
// and bridging one would bypass the login page. Which of the
|
||||
// two won the bind used to be a race.
|
||||
// * `auth: local` — host-local BY INTENT. Bridging one makes a
|
||||
// port reachable from the whole mesh that was deliberately
|
||||
// never externally reachable: nbxplorer 32838 answered HTTP
|
||||
// 200 over the mesh with no credential (archi-dev-box
|
||||
// 2026-08-04) purely because it appeared in the static port
|
||||
// list below.
|
||||
//
|
||||
// Undeclared ports keep today's behaviour — silence is not an
|
||||
// instruction in either direction, and this relay predates the
|
||||
// declarations.
|
||||
let port_map = crate::appgate::identity::build_port_map();
|
||||
let gate_owned: std::collections::HashSet<u16> = port_map
|
||||
.gated_ports()
|
||||
.filter(|g| g.declared)
|
||||
.map(|g| g.port)
|
||||
.collect();
|
||||
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
|
||||
let withhold = if gate_owned.contains(&port) {
|
||||
Some("port is now gate-owned")
|
||||
} else if port_map.is_declared_local(port) {
|
||||
Some("port is declared auth: local (host-local by intent)")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(reason) = withhold {
|
||||
if let Some(handle) = bridged.remove(&port) {
|
||||
handle.abort();
|
||||
info!(port, reason, "v6 relay released a bridge");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if bridged.contains_key(&port) {
|
||||
if bridged.contains(&port) {
|
||||
continue;
|
||||
}
|
||||
// ONLY bridge a port that a running app already answers on
|
||||
@@ -1250,9 +1181,10 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
|
||||
// EADDRINUSE = fipsd or another process already answers
|
||||
// on this mesh address/port, so stay out of the way.
|
||||
let Ok(listener) = bind_v6_only(addr) else { continue };
|
||||
bridged.insert(port);
|
||||
debug!("v6 relay bridging [{fips_ip}]:{port} -> 127.0.0.1:{port}");
|
||||
let mut rx = shutdown_rx.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
@@ -1273,7 +1205,6 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
|
||||
}
|
||||
}
|
||||
});
|
||||
bridged.insert(port, handle);
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => return,
|
||||
|
||||
@@ -40,19 +40,12 @@ struct Session {
|
||||
created_at: SystemTime,
|
||||
last_activity: SystemTime,
|
||||
session_type: SessionType,
|
||||
/// What kind of screen this login came from. A TV on the wall must not
|
||||
/// be signed out for sitting still — nobody is there to type a password
|
||||
/// back in — while a browser must be.
|
||||
device_class: crate::settings::session_policy::DeviceClass,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionStore {
|
||||
sessions: Arc<RwLock<HashMap<[u8; 32], Session>>>,
|
||||
persist_path: PathBuf,
|
||||
/// Where the session policy lives. Held rather than looked up globally
|
||||
/// so tests can point at a temp dir.
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// On-disk representation of a persisted session (only Full sessions, no TOTP secrets).
|
||||
@@ -74,7 +67,6 @@ impl SessionStore {
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(sessions)),
|
||||
persist_path,
|
||||
data_dir: PathBuf::from("/var/lib/archipelago"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,17 +75,9 @@ impl SessionStore {
|
||||
/// machine's real /var/lib/archipelago/sessions.json.
|
||||
#[cfg(test)]
|
||||
pub fn new_for_tests(persist_path: PathBuf) -> Self {
|
||||
// data_dir shares the temp path's parent so a test that writes a
|
||||
// policy file is honoured, and one that doesn't gets the defaults
|
||||
// rather than the dev machine's real configuration.
|
||||
let data_dir = persist_path
|
||||
.parent()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
persist_path,
|
||||
data_dir,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +120,6 @@ impl SessionStore {
|
||||
created_at,
|
||||
last_activity,
|
||||
session_type: SessionType::Full,
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -177,7 +160,6 @@ impl SessionStore {
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
session_type: SessionType::Full,
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
};
|
||||
|
||||
let mut sessions = self.sessions.write().await;
|
||||
@@ -202,10 +184,6 @@ impl SessionStore {
|
||||
totp_secret,
|
||||
attempts: 0,
|
||||
},
|
||||
// A half-finished login is always treated as a browser: it lives
|
||||
// for PENDING_SESSION_TTL either way, and a kiosk exemption on a
|
||||
// session that has not passed 2FA yet would be the wrong default.
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
};
|
||||
self.sessions.write().await.insert(hash, session);
|
||||
token
|
||||
@@ -214,23 +192,19 @@ impl SessionStore {
|
||||
/// Validate a full session token. Returns true if the session exists and hasn't expired.
|
||||
/// Updates last_activity on successful validation (inactivity-based expiry).
|
||||
pub async fn validate(&self, token: &str) -> bool {
|
||||
let policy = self.policy().await;
|
||||
let hash = hash_token(token);
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(session) = sessions.get_mut(&hash) {
|
||||
if !matches!(session.session_type, SessionType::Full) {
|
||||
return false;
|
||||
}
|
||||
let idle = session
|
||||
if session
|
||||
.last_activity
|
||||
.elapsed()
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let age = session.created_at.elapsed().unwrap_or_default().as_secs();
|
||||
// Both limits, not just idleness: the dashboard polls, so an
|
||||
// idle timeout alone would never fire on an open tab. The
|
||||
// absolute cap is what actually guarantees a login ends.
|
||||
if policy.is_expired(session.device_class, age, idle) {
|
||||
.as_secs()
|
||||
>= FULL_SESSION_TTL
|
||||
{
|
||||
sessions.remove(&hash);
|
||||
return false;
|
||||
}
|
||||
@@ -241,13 +215,6 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// The operator's session policy, re-read from disk rather than cached
|
||||
/// for the process lifetime so a change in Settings takes effect on the
|
||||
/// next request instead of the next restart.
|
||||
pub async fn policy(&self) -> crate::settings::session_policy::SessionPolicy {
|
||||
crate::settings::session_policy::load(&self.data_dir).await
|
||||
}
|
||||
|
||||
/// Get the TOTP secret from a pending session. Returns None if not a valid pending session.
|
||||
/// Increments the attempt counter.
|
||||
pub async fn get_pending_secret(&self, token: &str) -> Option<Vec<u8>> {
|
||||
@@ -292,7 +259,6 @@ impl SessionStore {
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
session_type: SessionType::Full,
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
},
|
||||
);
|
||||
Self::save_to_disk(&sessions, &self.persist_path).await;
|
||||
@@ -334,7 +300,6 @@ impl SessionStore {
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
session_type: SessionType::Full,
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
},
|
||||
);
|
||||
Self::save_to_disk(&sessions, &self.persist_path).await;
|
||||
|
||||
@@ -4,5 +4,4 @@
|
||||
//! call sites (deep in the transport / RPC / ingest stacks) don't need
|
||||
//! to thread a data_dir or Arc through the entire call graph.
|
||||
|
||||
pub mod session_policy;
|
||||
pub mod transport;
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
//! How long a login lasts, and who gets to say so.
|
||||
//!
|
||||
//! # Why this is configurable rather than a constant
|
||||
//!
|
||||
//! There is no single correct session lifetime. The same node can be a
|
||||
//! wall-mounted TV in a living room that must never ask for a password
|
||||
//! mid-film, and a wallet holding real funds where PCI DSS-style guidance
|
||||
//! says fifteen minutes. Both are legitimate; the operator knows which one
|
||||
//! this node is and we do not.
|
||||
//!
|
||||
//! # The two tokens
|
||||
//!
|
||||
//! * **Session token** — short-lived, refreshed silently on every
|
||||
//! authenticated request. This is what the browser sends; if it leaks, it
|
||||
//! is useful only until [`SessionPolicy::idle_timeout_secs`] of silence.
|
||||
//! * **Login (remember) token** — long-lived, and its *only* power is to
|
||||
//! mint a fresh session token. Kept separate so raising the convenience
|
||||
//! knob does not put a 30-day bearer credential on every request.
|
||||
//!
|
||||
//! Raising the idle timeout therefore does not weaken the credential that
|
||||
//! actually travels; it only changes how long a quiet tab stays usable.
|
||||
//!
|
||||
//! # Why an absolute cap exists at all
|
||||
//!
|
||||
//! Idle timeout alone can be defeated by any page that polls — the
|
||||
//! dashboard polls constantly, so an idle timeout would never fire while a
|
||||
//! tab is open. The absolute cap is what guarantees a login eventually
|
||||
//! ends, which is the property an auditor actually asks about.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
const FILE_PATH: &str = "settings/session_policy.json";
|
||||
|
||||
/// Bounds. A setting that can be made meaningless is not a setting, and one
|
||||
/// that can lock the operator out of their own node is a footgun.
|
||||
const MIN_IDLE_SECS: u64 = 60;
|
||||
const MAX_IDLE_SECS: u64 = 90 * 24 * 3600;
|
||||
const MIN_ABSOLUTE_SECS: u64 = 300;
|
||||
const MAX_ABSOLUTE_SECS: u64 = 365 * 24 * 3600;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DeviceClass {
|
||||
/// Ordinary browser on a phone or laptop. Policy applies as configured.
|
||||
Browser,
|
||||
/// A screen nobody logs into — a wall-mounted dashboard or TV. Being
|
||||
/// signed out mid-view is the failure mode here, not a stale session:
|
||||
/// the device is physically in the home, and there is no keyboard to
|
||||
/// re-authenticate with. Exempt from the idle timeout, still subject to
|
||||
/// the absolute cap so a stolen box does not stay authenticated forever.
|
||||
Kiosk,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionPolicy {
|
||||
/// Silence after which a session token stops validating.
|
||||
pub idle_timeout_secs: u64,
|
||||
/// Hard ceiling from login, regardless of activity. `None` = no cap.
|
||||
pub absolute_timeout_secs: Option<u64>,
|
||||
/// Re-prompt for the password before actions that move money, however
|
||||
/// fresh the session is. Independent of the timeouts on purpose: it is
|
||||
/// the control that matters when funds are involved, and it costs the
|
||||
/// operator nothing the rest of the time.
|
||||
pub reauth_for_funds: bool,
|
||||
}
|
||||
|
||||
impl Default for SessionPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// A day of silence, matching the previous hard-coded constant so
|
||||
// existing nodes see no behaviour change until someone chooses.
|
||||
idle_timeout_secs: 86_400,
|
||||
// 30 days, aligned with the login token's own lifetime: a
|
||||
// session that outlived the token which could refresh it would
|
||||
// be an oddity.
|
||||
absolute_timeout_secs: Some(30 * 24 * 3600),
|
||||
reauth_for_funds: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionPolicy {
|
||||
/// Clamp to the supported range. Applied on load as well as on save, so
|
||||
/// a hand-edited file cannot disable expiry by writing `0`.
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
self.idle_timeout_secs = self.idle_timeout_secs.clamp(MIN_IDLE_SECS, MAX_IDLE_SECS);
|
||||
self.absolute_timeout_secs = self
|
||||
.absolute_timeout_secs
|
||||
.map(|v| v.clamp(MIN_ABSOLUTE_SECS, MAX_ABSOLUTE_SECS))
|
||||
// An absolute cap below the idle timeout would expire sessions
|
||||
// while they are still active, which reads as random logouts.
|
||||
.map(|v| v.max(self.idle_timeout_secs));
|
||||
self
|
||||
}
|
||||
|
||||
/// Idle timeout for a given device, or `None` when idleness is not a
|
||||
/// reason to expire (kiosk screens).
|
||||
pub fn idle_timeout_for(&self, class: DeviceClass) -> Option<u64> {
|
||||
match class {
|
||||
DeviceClass::Browser => Some(self.idle_timeout_secs),
|
||||
DeviceClass::Kiosk => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Has a session expired? `age` is time since login, `idle` since last
|
||||
/// use. Both are checked because either alone is insufficient: idle
|
||||
/// never fires on a polling dashboard, and absolute alone leaves a
|
||||
/// forgotten tab usable for a month.
|
||||
pub fn is_expired(&self, class: DeviceClass, age_secs: u64, idle_secs: u64) -> bool {
|
||||
if let Some(limit) = self.absolute_timeout_secs {
|
||||
if age_secs >= limit {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
match self.idle_timeout_for(class) {
|
||||
Some(limit) => idle_secs >= limit,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> SessionPolicy {
|
||||
let path = data_dir.join(FILE_PATH);
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice::<SessionPolicy>(&bytes)
|
||||
.map(SessionPolicy::sanitized)
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "session policy unreadable; using defaults");
|
||||
SessionPolicy::default()
|
||||
}),
|
||||
Err(_) => SessionPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save(data_dir: &Path, policy: SessionPolicy) -> anyhow::Result<SessionPolicy> {
|
||||
let policy = policy.sanitized();
|
||||
let path = data_dir.join(FILE_PATH);
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, serde_json::to_vec_pretty(&policy)?).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_previous_hardcoded_behaviour() {
|
||||
let p = SessionPolicy::default();
|
||||
assert_eq!(p.idle_timeout_secs, 86_400);
|
||||
assert!(p.reauth_for_funds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_cannot_be_disabled_by_hand_editing_the_file() {
|
||||
let p = SessionPolicy {
|
||||
idle_timeout_secs: 0,
|
||||
absolute_timeout_secs: Some(0),
|
||||
reauth_for_funds: false,
|
||||
}
|
||||
.sanitized();
|
||||
assert!(p.idle_timeout_secs >= MIN_IDLE_SECS);
|
||||
assert!(p.absolute_timeout_secs.unwrap() >= MIN_ABSOLUTE_SECS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_cap_is_never_shorter_than_idle() {
|
||||
// Otherwise a session dies while actively in use, which the operator
|
||||
// experiences as being logged out at random.
|
||||
let p = SessionPolicy {
|
||||
idle_timeout_secs: 7 * 24 * 3600,
|
||||
absolute_timeout_secs: Some(3600),
|
||||
reauth_for_funds: true,
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(p.absolute_timeout_secs.unwrap(), p.idle_timeout_secs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kiosk_never_expires_from_idleness_but_still_has_a_ceiling() {
|
||||
let p = SessionPolicy::default();
|
||||
let a_week = 7 * 24 * 3600;
|
||||
assert!(!p.is_expired(DeviceClass::Kiosk, 60, a_week));
|
||||
assert!(p.is_expired(DeviceClass::Browser, 60, a_week));
|
||||
// The absolute cap still applies to the TV.
|
||||
assert!(p.is_expired(DeviceClass::Kiosk, 31 * 24 * 3600, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polling_dashboard_still_eventually_expires() {
|
||||
// idle never grows because the page polls; only the cap saves us.
|
||||
let p = SessionPolicy::default();
|
||||
assert!(p.is_expired(DeviceClass::Browser, 30 * 24 * 3600, 0));
|
||||
}
|
||||
}
|
||||
@@ -74,20 +74,7 @@ fn is_newer(candidate: &str, current: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary OTA origin. Named host over TLS rather than the bare IP it used
|
||||
/// to be: the IP pinned the fleet to one machine and one plaintext port, so
|
||||
/// moving or fronting the origin meant an OTA to change where OTAs come
|
||||
/// from — the one update you cannot ship if the origin is unreachable. The
|
||||
/// signature is what establishes trust (see `trust::anchor`), not the
|
||||
/// transport, but HTTPS also stops a network observer seeing which version
|
||||
/// a node runs.
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||
"https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
|
||||
/// The previous IP-based origin, kept as an automatic fallback so a node
|
||||
/// whose DNS or TLS is broken still updates. Dropped from the mirror list
|
||||
/// once the fleet has moved.
|
||||
const LEGACY_UPDATE_MANIFEST_URL: &str =
|
||||
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
const UPDATE_STATE_FILE: &str = "update_state.json";
|
||||
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
|
||||
@@ -126,19 +113,10 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
fn default_mirrors() -> Vec<UpdateMirror> {
|
||||
vec![
|
||||
UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Archipelago Foundation".to_string(),
|
||||
},
|
||||
// Fallback, tried only if the named origin fails: a node whose DNS
|
||||
// or clock is wrong (both break TLS) must still be able to update
|
||||
// itself, and the signature check is what makes either source safe.
|
||||
UpdateMirror {
|
||||
url: LEGACY_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Direct (fallback)".to_string(),
|
||||
},
|
||||
]
|
||||
vec![UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Server 1 (OVH)".to_string(),
|
||||
}]
|
||||
}
|
||||
|
||||
/// Load the operator-configured mirror list. Returns defaults if the
|
||||
@@ -208,18 +186,15 @@ fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
|
||||
}
|
||||
for mirror in list.iter_mut() {
|
||||
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
mirror.label = "Archipelago Foundation".to_string();
|
||||
} else if mirror.url == LEGACY_UPDATE_MANIFEST_URL {
|
||||
mirror.label = "Direct (fallback)".to_string();
|
||||
mirror.label = "Server 1 (OVH)".to_string();
|
||||
}
|
||||
}
|
||||
// Named origin first, its IP fallback second, anything the operator
|
||||
// added after that. Ordering matters: the list is tried in order, so a
|
||||
// stale entry sitting first costs a timeout on every check.
|
||||
list.sort_by_key(|m| match m.url.as_str() {
|
||||
u if u == DEFAULT_UPDATE_MANIFEST_URL => 0,
|
||||
u if u == LEGACY_UPDATE_MANIFEST_URL => 1,
|
||||
_ => 2,
|
||||
list.sort_by_key(|m| {
|
||||
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2398,18 +2373,8 @@ mod tests {
|
||||
async fn test_load_mirrors_returns_defaults_when_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = load_mirrors(dir.path()).await.unwrap();
|
||||
// The named origin leads, its IP fallback follows. A node with broken
|
||||
// DNS or a wrong clock (both break TLS) must still have a way to
|
||||
// update; the signature is what makes either source trustworthy.
|
||||
assert_eq!(list.len(), 2);
|
||||
assert!(
|
||||
list[0]
|
||||
.url
|
||||
.starts_with("https://source.archipelago-foundation.org/"),
|
||||
"the named origin must be primary, got {}",
|
||||
list[0].url
|
||||
);
|
||||
assert!(list[1].url.contains("146.59.87.168"));
|
||||
assert_eq!(list.len(), 1);
|
||||
assert!(list[0].url.contains("146.59.87.168"));
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
|
||||
"tx1138 was retired as a release server and must not be a default mirror"
|
||||
|
||||
@@ -1040,12 +1040,6 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mut received_total = 0u64;
|
||||
// MintClient translates the mint's NUT error code into plain language and
|
||||
// puts it at the top of the error chain (see `mint_error` in
|
||||
// mint_client.rs); `{}` surfaces that, `{:#}` keeps the raw status/body
|
||||
// for the log. Remember the last one so a total failure can tell the user
|
||||
// *why* instead of just "nothing was received".
|
||||
let mut last_reason: Option<String> = None;
|
||||
|
||||
// Swap proofs at each mint
|
||||
for entry in &token.token {
|
||||
@@ -1057,18 +1051,14 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
received_total += amount;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e);
|
||||
last_reason = Some(e.to_string());
|
||||
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
|
||||
// Continue with other mints if any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if received_total == 0 {
|
||||
match last_reason {
|
||||
Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason),
|
||||
None => anyhow::bail!("Failed to receive any proofs from token"),
|
||||
}
|
||||
anyhow::bail!("Failed to receive any proofs from token");
|
||||
}
|
||||
|
||||
wallet.record_tx(
|
||||
|
||||
@@ -59,72 +59,6 @@ pub struct MintResult {
|
||||
pub proofs: Vec<Proof>,
|
||||
}
|
||||
|
||||
/// Translate a Cashu NUT "transaction validation" error code into plain
|
||||
/// language a wallet user can act on. Mints respond to a rejected request
|
||||
/// with `{"code": N, "detail": "..."}`; `detail` is implementation-defined
|
||||
/// free text, but `code` is the stable identifier from the spec
|
||||
/// (https://github.com/cashubtc/nuts/blob/main/error_codes.md). Covers the
|
||||
/// 10001-11017 "proof/transaction validation" range plus the 12001-12003
|
||||
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call
|
||||
/// can actually hit. Returns `None` for anything else (e.g. Lightning/quote
|
||||
/// codes in the 20000s) so the caller falls back to the mint's own `detail`.
|
||||
fn describe_mint_error_code(code: i64) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
10001 => "The mint rejected these coins as invalid.",
|
||||
11001 => "This ecash has already been redeemed — it can't be claimed twice.",
|
||||
11002 => "This ecash is already being redeemed elsewhere — try again in a moment.",
|
||||
11003 => "The mint already issued new coins for this exact request — there's nothing left to redeem.",
|
||||
11004 => "This request is still being processed by the mint — try again in a moment.",
|
||||
11005 => "The token's amounts don't add up (inputs don't match outputs) — it may be corrupt.",
|
||||
11006 => "That amount is outside the range this mint allows.",
|
||||
11007 => "This token contains duplicate coins — it may be corrupt or already used.",
|
||||
11008 => "The mint rejected this as a duplicate request.",
|
||||
11009 | 11010 => "This token mixes incompatible currency units — the mint rejected it.",
|
||||
11011 => "That Lightning invoice has no amount, which isn't supported here.",
|
||||
11012 => "The amount requested doesn't match the Lightning invoice.",
|
||||
11013 => "The mint doesn't support this currency unit.",
|
||||
11014 | 11015 => "This token has too many coins for the mint to process in one request.",
|
||||
11016 => "Duplicate quote IDs were sent in this request.",
|
||||
11017 => "Too many items were sent in a single request.",
|
||||
12001 => "The mint no longer recognizes the keyset that signed this token.",
|
||||
12002 => "The mint's signing key for this token is inactive.",
|
||||
12003 => "The mint's signing key for this token has expired.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a mint's error body (`{"code": N, "detail": "..."}`) and pick the
|
||||
/// best user-facing message: the plain-language translation when we know the
|
||||
/// code, otherwise the mint's own `detail` text, otherwise the raw body.
|
||||
fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
|
||||
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
|
||||
let code = parsed
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("code"))
|
||||
.and_then(|c| c.as_i64());
|
||||
let detail = parsed
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("detail"))
|
||||
.and_then(|d| d.as_str());
|
||||
|
||||
if let Some(friendly) = code.and_then(describe_mint_error_code) {
|
||||
return friendly.to_string();
|
||||
}
|
||||
match detail {
|
||||
Some(d) if !d.is_empty() => d.to_string(),
|
||||
_ => format!("mint returned {} with no further detail", status),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the error for a failed mint HTTP call: `op` + status + raw body as
|
||||
/// the technical cause (visible via `{:#}` in logs), with the plain-language
|
||||
/// translation layered on top via `.context()` so `{}` — what reaches the
|
||||
/// wallet user — shows something actionable instead of raw mint JSON.
|
||||
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
|
||||
let friendly = describe_mint_error_body(status, body);
|
||||
anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly)
|
||||
}
|
||||
|
||||
/// HTTP client for a single Cashu mint.
|
||||
pub struct MintClient {
|
||||
url: String,
|
||||
@@ -212,7 +146,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
return Err(mint_error("Mint quote", status, &body));
|
||||
anyhow::bail!("Mint quote failed ({}): {}", status, body);
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse mint quote")
|
||||
@@ -278,7 +212,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
return Err(mint_error("Minting tokens", status, &body));
|
||||
anyhow::bail!("Mint tokens failed ({}): {}", status, body);
|
||||
}
|
||||
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse mint response")?;
|
||||
@@ -332,7 +266,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
return Err(mint_error("Melt quote", status, &body));
|
||||
anyhow::bail!("Melt quote failed ({}): {}", status, body);
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse melt quote")
|
||||
@@ -359,7 +293,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
return Err(mint_error("Melt", status, &body));
|
||||
anyhow::bail!("Melt failed ({}): {}", status, body);
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse melt response")
|
||||
@@ -403,7 +337,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
return Err(mint_error("Swap", status, &body));
|
||||
anyhow::bail!("Swap failed ({}): {}", status, body);
|
||||
}
|
||||
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse swap response")?;
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
server {
|
||||
# Loopback ONLY. This container is host-networked, so this nginx binds the
|
||||
# HOST's address directly — `listen 50002;` meant every interface, and the
|
||||
# app gate could never stand in front of it (there is no podman publish to
|
||||
# pin, and the manifest declared no port, so the gate neither protected it
|
||||
# nor reported it — it served this page to anyone who asked, on LAN,
|
||||
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
|
||||
# external addresses and authenticate them; see appgate::listener.
|
||||
listen 127.0.0.1:50002;
|
||||
listen 50002;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
server {
|
||||
# Loopback ONLY. This container is host-networked, so this nginx binds the
|
||||
# HOST's address directly — `listen 8175;` meant every interface, and the
|
||||
# app gate could never stand in front of it (there is no podman publish to
|
||||
# pin, and the manifest declared no port, so the gate neither protected it
|
||||
# nor reported it — it served this page to anyone who asked, on LAN,
|
||||
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
|
||||
# external addresses and authenticate them; see appgate::listener.
|
||||
listen 127.0.0.1:8175;
|
||||
listen 8175;
|
||||
server_name _;
|
||||
|
||||
proxy_intercept_errors on;
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
server {
|
||||
# Loopback ONLY. This container is host-networked, so this nginx binds the
|
||||
# HOST's address directly — `listen 8336;` meant every interface, and the
|
||||
# app gate could never stand in front of it (there is no podman publish to
|
||||
# pin, and the manifest declared no port, so the gate neither protected it
|
||||
# nor reported it — it served this page to anyone who asked, on LAN,
|
||||
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
|
||||
# external addresses and authenticate them; see appgate::listener.
|
||||
listen 127.0.0.1:8336;
|
||||
listen 8336;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
server {
|
||||
# Host-networked: listen on the app's own port directly (NOT 80, which the
|
||||
# host's main nginx already owns). The app is reached at http(s)://<node>:18083.
|
||||
# Loopback ONLY. This container is host-networked, so this nginx binds the
|
||||
# HOST's address directly — `listen 18083;` meant every interface, and the
|
||||
# app gate could never stand in front of it (there is no podman publish to
|
||||
# pin, and the manifest declared no port, so the gate neither protected it
|
||||
# nor reported it — it served this page to anyone who asked, on LAN,
|
||||
# Tailscale and the mesh alike). Binding loopback lets the daemon claim the
|
||||
# external addresses and authenticate them; see appgate::listener.
|
||||
listen 127.0.0.1:18083;
|
||||
listen 18083;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.124-alpha",
|
||||
"version": "1.7.120-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.124-alpha",
|
||||
"version": "1.7.120-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.124-alpha",
|
||||
"version": "1.7.120-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -442,7 +442,7 @@
|
||||
"author": "Portainer",
|
||||
"category": "development",
|
||||
"tier": "optional",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
||||
"repoUrl": "https://github.com/portainer/portainer",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
|
||||
@@ -311,26 +311,31 @@ 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'
|
||||
@@ -2678,7 +2683,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
|
||||
<!-- Tools panels (3rd column on wide screens) -->
|
||||
<div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
|
||||
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
|
||||
<div v-if="isWideDesktop && !isVeryWideDesktop" class="mesh-tools-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">Bitcoin</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
|
||||
Dead Man
|
||||
|
||||
@@ -4,15 +4,12 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
"aiui": 5180,
|
||||
"archy-mempool-web": 4080,
|
||||
"archy-nbxplorer": 32838,
|
||||
"bitcoin-ui": 8334,
|
||||
"botfights": 9100,
|
||||
"btcpay-server": 23000,
|
||||
"did-wallet": 8088,
|
||||
"electrs-ui": 50002,
|
||||
"electrumx": 50002,
|
||||
"fedimint": 8175,
|
||||
"filebrowser": 8083,
|
||||
"fips-ui": 8336,
|
||||
"gitea": 3001,
|
||||
"grafana": 3000,
|
||||
"homeassistant": 8123,
|
||||
|
||||
@@ -43,26 +43,17 @@
|
||||
.mesh-columns-wide .mesh-chat-card { grid-column: 2; grid-row: 1; min-height: 0; overflow: hidden; }
|
||||
.mesh-columns-wide .mesh-tools-wrapper { grid-column: 3; grid-row: 1; display: flex; flex-direction: column; gap: 0; min-height: 0; overflow: hidden; }
|
||||
.mesh-columns-wide .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; margin-bottom: 12px; }
|
||||
/* A very wide screen gets a roomier third column — but the SAME tabbed
|
||||
panel as every other desktop width. It used to stack Bitcoin, Dead Man,
|
||||
AI, the map and Device on top of each other in fixed grid rows, which on
|
||||
a real 2560px display clipped the first three headings to a few pixels,
|
||||
letterboxed the map, and pushed Radio Settings into a scroll — more
|
||||
screen producing a worse view (reported with a screenshot 2026-08-05).
|
||||
One tab at a time, filling the column, is what makes the map edge to
|
||||
edge and every control reachable without scrolling. */
|
||||
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(460px, 1fr); }
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-deadman-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-assistant-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-device-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel {
|
||||
flex: 1 1 auto; min-height: 0; height: auto; overflow-y: auto;
|
||||
}
|
||||
/* The map is the one panel with nothing to scroll: let it consume the
|
||||
column edge to edge rather than sitting in a letterbox. */
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel { overflow: hidden; padding: 0; }
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel > * { height: 100%; width: 100%; }
|
||||
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(420px, 0.95fr); }
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper { display: grid; grid-template-rows: minmax(0, 1fr) minmax(0, 0.85fr) minmax(0, 1fr); gap: 12px; overflow: hidden; }
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-deadman-panel,
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-assistant-panel,
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-map-panel { min-height: 0; height: 100%; overflow: hidden; }
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-bitcoin-panel,
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-deadman-panel,
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-assistant-panel,
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-map-panel { flex: 1 1 auto; min-height: 0; height: auto; }
|
||||
.mesh-columns-very-wide .mesh-tools-tab-bar { display: none; }
|
||||
.mesh-columns-wide .mesh-mobile-back-btn,
|
||||
.mesh-columns-wide .mesh-tab-bar { display: none; }
|
||||
.mesh-status-card { padding: 16px; flex-shrink: 0; }
|
||||
|
||||
@@ -362,73 +362,6 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.124-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.124-alpha</span>
|
||||
<span class="text-xs text-white/40">August 5, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>The most important fix here: some nodes were left switched off by their own update, and could not switch themselves back on.</strong> The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it <em>crashes</em>. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts.</p>
|
||||
<p><strong>Portainer opens again.</strong> Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1 and their stored data was written by that version, while the app list pinned a version from two years earlier — so a rebuilt container landed on the old one, which will not read newer data. The correct version is pinned now, older installs upgrade cleanly, and no data was touched.</p>
|
||||
<p><strong>Bitcoin starts reliably again.</strong> A leftover settings file in the Bitcoin folder — one the node kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes it, clears stale copies, and treats any that remain as harmless.</p>
|
||||
<p><strong>Every app screen opens from My Apps again.</strong> The login gate refused to be displayed inside another page at all — which is exactly how My Apps opens an app — so protected apps looked broken. It now allows only your own node to display it and refuses everyone else.</p>
|
||||
<p><strong>The app login screen looks like the node's own now:</strong> same rotating artwork, same panel, the Archipelago mark, and the app's real icon as a tile the way My Apps shows it, instead of a plain box with a letter.</p>
|
||||
<p><strong>The Mesh screen uses wide displays properly.</strong> On very large screens it stacked all five panels, clipping three headings to a sliver and squeezing the map into a letterbox. It now shows one panel at a time, filling the space, with the map edge to edge.</p>
|
||||
<p><strong>You can choose how long you stay signed in.</strong> Settings → Account offers an inactivity timeout, a hard limit, and an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle — there is nobody there to sign them back in.</p>
|
||||
<p>Updates now come from source.archipelago-foundation.org rather than a bare address, with the old one kept as an automatic fallback. Also: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.</p>
|
||||
<p>Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.123-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.123-alpha</span>
|
||||
<span class="text-xs text-white/40">August 5, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>Five more screens on your node were readable by anyone who could reach it — and the last release's own check said they were fine.</strong> The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It neither protected them nor listed them as unprotected. Found by scanning the node from another machine instead of asking the node about itself.</p>
|
||||
<p><strong>What was readable was the page, not your money.</strong> Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was checked directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.</p>
|
||||
<p>All five now serve only to the node itself, with the login gate in front, exactly like the twenty app screens closed in the last release.</p>
|
||||
<p><strong>Every port on your node now has a stated policy — none are left undecided.</strong> Eleven had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now ask for your node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections, NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session), Pine's secure listener, and the Lightning REST port that wallets reach with a macaroon exactly as before.</p>
|
||||
<p>Freshly flashed nodes are covered too, not only existing ones — those five screens ship as prebuilt images, so a new node would otherwise have come up open even after this fix. All five were rebuilt, published, then pulled back and inspected to confirm the fix is really inside them.</p>
|
||||
<p>Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.122-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.122-alpha</span>
|
||||
<span class="text-xs text-white/40">August 4, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.</strong> Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app.</p>
|
||||
<p><strong>The things that must stay open stayed open.</strong> Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.</p>
|
||||
<p><strong>A private address on your node was answering the mesh without a password.</strong> One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while checking the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.</p>
|
||||
<p><strong>Tor addresses no longer skip the login.</strong> An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session. Those addresses now arrive at the login gate first, closing the last route that went around it.</p>
|
||||
<p>Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after being told to move, and each would have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting.</p>
|
||||
<p>The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened. Both now come from the signed list.</p>
|
||||
<p><strong>The key that signs these updates has been replaced.</strong> The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.</p>
|
||||
<p>Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting is what caused two incidents this week. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser apps — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will meet the login page and need an access token; say the word if you use one and it can be exempted. The 5x real-node lifecycle gate was not run for this release.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.121-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.121-alpha</span>
|
||||
<span class="text-xs text-white/40">August 4, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>**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.</p>
|
||||
<p>**Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports — so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits.</p>
|
||||
<p>The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard — that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it.</p>
|
||||
<p>The Lightning screen will actually update from now on. Its image was set to "latest", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered.</p>
|
||||
<p>Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched — so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check.</p>
|
||||
<p>Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open — Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol — now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over.</p>
|
||||
<p>Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged — producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops.</p>
|
||||
<p>Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login — the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision).</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.120-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useAppStore } from '@/stores/app'
|
||||
import AccountInfoSection from '@/views/settings/AccountInfoSection.vue'
|
||||
import ChangePasswordSection from '@/views/settings/ChangePasswordSection.vue'
|
||||
import TwoFactorSection from '@/views/settings/TwoFactorSection.vue'
|
||||
import SessionTimeoutSection from '@/views/settings/SessionTimeoutSection.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
@@ -25,7 +24,6 @@ async function handleLogout() {
|
||||
<AccountInfoSection />
|
||||
<ChangePasswordSection />
|
||||
<TwoFactorSection />
|
||||
<SessionTimeoutSection />
|
||||
|
||||
<!-- Logout Button -->
|
||||
<button
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* How long a login lasts on this node.
|
||||
*
|
||||
* Presented as two plain questions — "sign me out after quiet" and "always
|
||||
* sign me out after" — rather than as the two-token mechanism underneath.
|
||||
* The distinction that matters to the operator is that the second one is
|
||||
* what actually guarantees a login ends: this dashboard polls constantly,
|
||||
* so an idle timeout alone never fires on an open tab.
|
||||
*/
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
type Policy = {
|
||||
idle_timeout_secs: number
|
||||
absolute_timeout_secs: number | null
|
||||
reauth_for_funds: boolean
|
||||
}
|
||||
|
||||
const idle = ref<number>(86400)
|
||||
const absolute = ref<number | null>(30 * 24 * 3600)
|
||||
const reauthForFunds = ref(true)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Offered as presets rather than a free number field: the useful values are
|
||||
// few, and a box accepting "5" invites someone to lock themselves out.
|
||||
const idleChoices = [
|
||||
{ label: '15 minutes', value: 15 * 60 },
|
||||
{ label: '1 hour', value: 3600 },
|
||||
{ label: '1 day', value: 86400 },
|
||||
{ label: '1 week', value: 7 * 24 * 3600 },
|
||||
{ label: '30 days', value: 30 * 24 * 3600 },
|
||||
]
|
||||
const absoluteChoices = [
|
||||
{ label: '1 day', value: 86400 },
|
||||
{ label: '1 week', value: 7 * 24 * 3600 },
|
||||
{ label: '30 days', value: 30 * 24 * 3600 },
|
||||
{ label: '90 days', value: 90 * 24 * 3600 },
|
||||
{ label: 'Never', value: null },
|
||||
]
|
||||
|
||||
const shortIdleWarning = computed(() => idle.value <= 3600)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const p = await rpcClient.call<Policy>({ method: 'auth.session-policy.get', params: {} })
|
||||
idle.value = p.idle_timeout_secs
|
||||
absolute.value = p.absolute_timeout_secs
|
||||
reauthForFunds.value = p.reauth_for_funds
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
error.value = ''
|
||||
try {
|
||||
// The backend clamps and returns what it stored, so reflect that back
|
||||
// rather than assuming our values were taken verbatim.
|
||||
const p = await rpcClient.call<Policy>({
|
||||
method: 'auth.session-policy.set',
|
||||
params: {
|
||||
idle_timeout_secs: idle.value,
|
||||
absolute_timeout_secs: absolute.value,
|
||||
reauth_for_funds: reauthForFunds.value,
|
||||
},
|
||||
})
|
||||
idle.value = p.idle_timeout_secs
|
||||
absolute.value = p.absolute_timeout_secs
|
||||
reauthForFunds.value = p.reauth_for_funds
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2500)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-base font-medium text-white/90 mb-1">Session timeout</h3>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
How long this node keeps you signed in. TV and kiosk screens are never
|
||||
signed out for sitting idle — there is nobody there to sign them back in.
|
||||
</p>
|
||||
|
||||
<div v-if="error" role="alert" class="mb-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg text-red-200 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="!loading" class="space-y-4">
|
||||
<div>
|
||||
<label for="idle-timeout" class="block text-sm font-medium text-white/80 mb-2">Sign me out after this much inactivity</label>
|
||||
<select
|
||||
id="idle-timeout"
|
||||
v-model.number="idle"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
|
||||
>
|
||||
<option v-for="c in idleChoices" :key="c.value" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
|
||||
</select>
|
||||
<p v-if="shortIdleWarning" class="text-xs text-orange-300/80 mt-2">
|
||||
Short timeouts are what payment-industry rules ask for when funds are involved — expect to sign in often.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="absolute-timeout" class="block text-sm font-medium text-white/80 mb-2">Always sign me out after</label>
|
||||
<select
|
||||
id="absolute-timeout"
|
||||
v-model="absolute"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
|
||||
>
|
||||
<option v-for="c in absoluteChoices" :key="String(c.value)" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
|
||||
</select>
|
||||
<p class="text-xs text-white/50 mt-2">
|
||||
Counts from when you signed in, whatever you are doing. This is the one that
|
||||
guarantees a session ends: an open dashboard is never idle, so the setting
|
||||
above would not fire on it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input v-model="reauthForFunds" type="checkbox" class="mt-1 accent-orange-500" />
|
||||
<span class="text-sm text-white/80">
|
||||
Ask for my password again before sending funds
|
||||
<span class="block text-xs text-white/50">Recommended. Applies however recently you signed in.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
:disabled="saving"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 disabled:opacity-50"
|
||||
@click="save"
|
||||
>
|
||||
{{ saving ? 'Saving…' : (saved ? 'Saved' : 'Save session settings') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+25
-24
@@ -1,35 +1,36 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**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."
|
||||
"**Security, and the reason to take this update: two ports on your node handed anyone who could reach them complete control of your money, with no password.** The Lightning app's port answered a plain web request with the LND admin macaroon, the TLS certificate and the node's onion address — everything needed to drain the wallet remotely, and the onion meant an attacker kept that ability even after losing access to your network. The Bitcoin app's port reached Bitcoin Core's control interface using credentials the node itself supplied on the caller's behalf, with a wallet loaded. Anything on your home network, your Tailscale network or the mesh could use either one. Both now require you to be logged in. If your node has been reachable by anyone you do not fully trust, treat the Lightning macaroon and the Bitcoin RPC password as known to them.",
|
||||
"The Bitcoin and Lightning app screens can no longer be published as public Tor addresses automatically. They were one app-id away from being handed a worldwide, permanent address as a silent side effect of being installed — which would have re-opened the hole above to the entire internet. Turning Tor on for them deliberately still works; it just never happens on its own.",
|
||||
"**Fixes shipped inside the program now actually reach apps that your system keeps running.** A container the node had been told to uninstall, but that the system service manager kept alive anyway, was quietly skipped by the part of the node that applies configuration — so it never received updates that shipped with the program. This was found the hard way: the Bitcoin control-interface fix above appeared to be installed and silently was not, while the Lightning half applied correctly, which is the most misleading way for a security fix to fail. Both halves are now proven to land on a real node.",
|
||||
"The Lightning and Bitcoin node screens have been rebuilt to match what umbrelOS offers. Lightning gains Overview, Channels, Activity, Insights, Connect and Settings tabs with a sats/BTC switch; Bitcoin gains Insights, Peers, Connect and Sharing. Along the way: every copy button on those screens silently did nothing (the browser blocks clipboard access inside an embedded page) and now works; the channels link led to a dead page; and Node ID showed a bare key instead of the full address someone can actually connect to.",
|
||||
"Updates to the Bitcoin screen show up without a hard refresh. The page was being cached by the browser, so a freshly updated screen kept rendering the previous one.",
|
||||
"The AI sidebar loads again. It was asking for its program files at an address that pointed at the main app's files, where they do not exist, so it silently loaded nothing.",
|
||||
"The navigation above the bottom bar no longer follows you between screens. Back buttons and the mesh tab bar stayed pinned over every other page once you had visited the screen that owns them. Keeping tabs loaded in the background — the change that made switching between them instant — means leaving a screen hides it rather than destroying it, and this floating navigation sits outside the screen it belongs to, so it was never being hidden with it. It is now tied to whether its own screen is on display. The speed is unchanged: the screens are still kept loaded, so returning to one is still instant.",
|
||||
"Wallet: Lightning actions are now offered based on whether you actually have a usable channel rather than just a running node, sending is gated the same way, and an invoice you cannot yet receive offers to install a Lightning node instead of simply failing.",
|
||||
"Onboarding and viewing fixes: the \"I have written down my recovery words\" tickbox is findable on short screens, paid pictures and videos open in the app's own viewer with a visible loading state instead of a blank browser tab, picture-in-picture survives changing tabs, and the FIPS/Tor labels on peer cards stay put instead of wrapping into the card below.",
|
||||
"Key-material hardening across the node: a node that is already set up refuses to have its identity replaced by an unauthenticated request; first-boot secret generation now fails loudly instead of silently continuing with shared keys; the node proves its TLS certificate and key are actually a matching pair; the Bitcoin Core wallet path that kept a second copy of your spending key outside the encrypted store has been removed; and every place the node generates a key, token or nonce now names its source of randomness explicitly, enforced at build time."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.124-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago",
|
||||
"current_version": "1.7.120-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.120-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.124-alpha",
|
||||
"sha256": "bc91e7d09083dad17934dc2bad211b4eb1d22fd799e032a94fa73df025ba272d",
|
||||
"size_bytes": 54701560
|
||||
"new_version": "1.7.120-alpha",
|
||||
"sha256": "304255655a22bae605d728d44e857ed170a19833b6237861fdf7d852d25d9680",
|
||||
"size_bytes": 54017008
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.124-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago-frontend-1.7.124-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.124-alpha.tar.gz",
|
||||
"new_version": "1.7.124-alpha",
|
||||
"sha256": "346ed6472fb647bcf45299e4d93b9f555142bf1229912030fbafe9966fd410a7",
|
||||
"size_bytes": 210531432
|
||||
"current_version": "1.7.120-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.120-alpha/archipelago-frontend-1.7.120-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.120-alpha.tar.gz",
|
||||
"new_version": "1.7.120-alpha",
|
||||
"sha256": "cb9ea4dfcea3ac93dfb1ce1dca96ea30c74a4e6354471cde4d8f75c0441850a5",
|
||||
"size_bytes": 210519311
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-05",
|
||||
"signature": "40f5ffca026addd00c4aac933edf8faa05a60d9b981341bc0bfaae078812a65ad6656886b4a9d533476759dbb511fb4b050e518cd0060568f7b33035069e600e",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.7.124-alpha"
|
||||
"release_date": "2026-08-03",
|
||||
"signature": "e76e0ca5f249111a0a57df07f790997b1a4facf97da11a2d13fcb7ec9b80aea82925244d6083544504260b776ca4317cf44774e2c37bfaa13afae248e9675601",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.120-alpha"
|
||||
}
|
||||
|
||||
+19
-161
@@ -25,7 +25,6 @@
|
||||
"name": "AI Assistant",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 80,
|
||||
"host": 5180,
|
||||
@@ -229,8 +228,6 @@
|
||||
"name": "Mempool Web",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 4080,
|
||||
"protocol": "tcp"
|
||||
@@ -305,8 +302,6 @@
|
||||
"name": "NBXplorer",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 32838,
|
||||
"host": 32838,
|
||||
"protocol": "tcp"
|
||||
@@ -379,8 +374,6 @@
|
||||
"name": "Ark Wallet",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 3535,
|
||||
"host": 3535,
|
||||
"protocol": "tcp"
|
||||
@@ -421,7 +414,7 @@
|
||||
},
|
||||
"container": {
|
||||
"custom_args": [
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; # A stray bitcoin.conf in the datadir is FATAL when -conf points # elsewhere: bitcoind refuses to start with \"contains a bitcoin.conf # file which is ignored\", and the app crash-loops (100.82.34.38, # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the # RPC credentials and the flags below are the authoritative config, # so the datadir file is legacy debris; say so out loud rather than # failing, and let bitcoind start. if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
||||
],
|
||||
"data_uid": "100101:100101",
|
||||
"derived_env": [
|
||||
@@ -469,15 +462,12 @@
|
||||
"name": "Bitcoin Core",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8332,
|
||||
"host": 8332,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP.",
|
||||
"container": 8333,
|
||||
"host": 8333,
|
||||
"protocol": "tcp"
|
||||
@@ -567,7 +557,7 @@
|
||||
},
|
||||
"container": {
|
||||
"custom_args": [
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; # A stray bitcoin.conf in the datadir is FATAL when -conf points # elsewhere: bitcoind refuses to start with \"contains a bitcoin.conf # file which is ignored\", and the app crash-loops (100.82.34.38, # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the # RPC credentials and the flags below are the authoritative config, # so the datadir file is legacy debris; say so out loud rather than # failing, and let bitcoind start. if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
||||
],
|
||||
"data_uid": "100101:100101",
|
||||
"derived_env": [
|
||||
@@ -615,15 +605,12 @@
|
||||
"name": "Bitcoin Knots",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8332,
|
||||
"host": 8332,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP.",
|
||||
"container": 8333,
|
||||
"host": 8333,
|
||||
"protocol": "tcp"
|
||||
@@ -684,7 +671,7 @@
|
||||
]
|
||||
},
|
||||
"bitcoin-ui": {
|
||||
"image": "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.123-alpha",
|
||||
"image": "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.84-alpha",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
@@ -711,15 +698,7 @@
|
||||
},
|
||||
"id": "bitcoin-ui",
|
||||
"name": "Bitcoin UI",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8334,
|
||||
"host": 8334,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"ports": [],
|
||||
"resources": {
|
||||
"memory_limit": "128Mi"
|
||||
},
|
||||
@@ -740,7 +719,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "1.7.123-alpha"
|
||||
"version": "1.7.84-alpha"
|
||||
},
|
||||
"botfights": {
|
||||
"manifest": {
|
||||
@@ -813,8 +792,6 @@
|
||||
"name": "BotFights",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 9100,
|
||||
"host": 9100,
|
||||
"protocol": "tcp"
|
||||
@@ -954,8 +931,6 @@
|
||||
"name": "BTCPay Server",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 49392,
|
||||
"host": 23000,
|
||||
"protocol": "tcp"
|
||||
@@ -1026,15 +1001,11 @@
|
||||
"name": "Core Lightning (CLN)",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.",
|
||||
"container": 9735,
|
||||
"host": 9736,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Core Lightning gRPC, authenticated by mutual TLS client certificates.",
|
||||
"container": 9835,
|
||||
"host": 9835,
|
||||
"protocol": "tcp"
|
||||
@@ -1104,8 +1075,6 @@
|
||||
"name": "Web5 DID Wallet",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 8088,
|
||||
"protocol": "tcp"
|
||||
@@ -1146,7 +1115,7 @@
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"electrs-ui": {
|
||||
"image": "146.59.87.168:3000/lfg2025/electrs-ui:1.7.123-alpha",
|
||||
"image": "146.59.87.168:3000/lfg2025/electrs-ui:latest",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
@@ -1169,15 +1138,7 @@
|
||||
},
|
||||
"id": "electrs-ui",
|
||||
"name": "Electrs UI",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 50002,
|
||||
"host": 50002,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"ports": [],
|
||||
"resources": {
|
||||
"memory_limit": "64Mi"
|
||||
},
|
||||
@@ -1189,7 +1150,7 @@
|
||||
"volumes": []
|
||||
}
|
||||
},
|
||||
"version": "1.7.123-alpha"
|
||||
"version": "latest"
|
||||
},
|
||||
"electrumx": {
|
||||
"image": "146.59.87.168:3000/lfg2025/electrumx:v1.18.0",
|
||||
@@ -1258,8 +1219,6 @@
|
||||
"name": "ElectrumX",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Electrum wire protocol over TCP. Electrum wallets speak it directly and cannot hold a session cookie.",
|
||||
"container": 50001,
|
||||
"host": 50001,
|
||||
"protocol": "tcp"
|
||||
@@ -1373,29 +1332,16 @@
|
||||
"name": "Fedimint Guardian",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Fedimint guardian consensus. Other guardians speak the federation's own authenticated protocol here; a login page would break consensus.",
|
||||
"container": 8173,
|
||||
"host": 8173,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Fedimint guardian API for federation clients, which authenticate to the federation itself and cannot hold a browser session.",
|
||||
"container": 8174,
|
||||
"host": 8174,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8175,
|
||||
"host": 8175,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8175,
|
||||
"host": 8177,
|
||||
"protocol": "tcp"
|
||||
@@ -1470,8 +1416,6 @@
|
||||
"name": "Fedimint Client",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 8178,
|
||||
"protocol": "tcp"
|
||||
@@ -1577,15 +1521,11 @@
|
||||
"name": "Fedimint Gateway",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Fedimint gateway API, protected by its own bcrypt password (--bcrypt-password-hash) and reached by federation peers and clients that cannot hold a browser session.",
|
||||
"container": 8176,
|
||||
"host": 8176,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "LDK Lightning p2p for the gateway. The BOLT-8 noise handshake authenticates and encrypts the connection itself.",
|
||||
"container": 9737,
|
||||
"host": 9737,
|
||||
"protocol": "tcp"
|
||||
@@ -1661,8 +1601,6 @@
|
||||
"name": "File Browser",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 80,
|
||||
"host": 8083,
|
||||
"protocol": "tcp"
|
||||
@@ -1733,15 +1671,7 @@
|
||||
},
|
||||
"id": "fips-ui",
|
||||
"name": "FIPS Mesh",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8336,
|
||||
"host": 8336,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"ports": [],
|
||||
"resources": {
|
||||
"memory_limit": "128Mi"
|
||||
},
|
||||
@@ -1823,15 +1753,11 @@
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 3000,
|
||||
"host": 3001,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Git over SSH, authenticated by the user's own SSH keypair. Not HTTP, so the gate cannot serve a login page here.",
|
||||
"container": 22,
|
||||
"host": 2222,
|
||||
"protocol": "tcp"
|
||||
@@ -1916,8 +1842,6 @@
|
||||
"name": "Grafana",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 3000,
|
||||
"host": 3000,
|
||||
"protocol": "tcp"
|
||||
@@ -2001,8 +1925,6 @@
|
||||
"name": "Home Assistant",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8123,
|
||||
"host": 8123,
|
||||
"protocol": "tcp"
|
||||
@@ -2112,8 +2034,6 @@
|
||||
"name": "Immich",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 2283,
|
||||
"host": 2283,
|
||||
"protocol": "tcp"
|
||||
@@ -2353,8 +2273,6 @@
|
||||
"name": "IndeeHub",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 7777,
|
||||
"host": 7778,
|
||||
"protocol": "tcp"
|
||||
@@ -2847,8 +2765,6 @@
|
||||
"name": "Jellyfin",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8096,
|
||||
"host": 8096,
|
||||
"protocol": "tcp"
|
||||
@@ -2936,22 +2852,16 @@
|
||||
"name": "Lightning Stack",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.",
|
||||
"container": 9735,
|
||||
"host": 9738,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.",
|
||||
"container": 10009,
|
||||
"host": 10010,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "LND REST, authenticated by macaroon over TLS. A browser login page would break Zeus and every non-browser wallet client, exactly as for lnd's 18080.",
|
||||
"container": 8080,
|
||||
"host": 8091,
|
||||
"protocol": "tcp"
|
||||
@@ -3040,22 +2950,16 @@
|
||||
"name": "LND",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.",
|
||||
"container": 9735,
|
||||
"host": 9735,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "LND gRPC, authenticated by macaroon over TLS. Zeus and other remote wallets depend on reaching this directly.",
|
||||
"container": 10009,
|
||||
"host": 10009,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "LND REST, authenticated by macaroon over TLS. A browser login page would break Zeus and every non-browser wallet client.",
|
||||
"container": 8080,
|
||||
"host": 18080,
|
||||
"protocol": "tcp"
|
||||
@@ -3094,7 +2998,7 @@
|
||||
"version": "v0.18.4-beta"
|
||||
},
|
||||
"lnd-ui": {
|
||||
"image": "146.59.87.168:3000/lfg2025/lnd-ui:1.7.123-alpha",
|
||||
"image": "146.59.87.168:3000/lfg2025/lnd-ui:latest",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
@@ -3123,9 +3027,7 @@
|
||||
"name": "LND UI",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 18083,
|
||||
"container": 80,
|
||||
"host": 18083,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
@@ -3134,14 +3036,14 @@
|
||||
"memory_limit": "64Mi"
|
||||
},
|
||||
"security": {
|
||||
"network_policy": "host",
|
||||
"network_policy": "bridge",
|
||||
"readonly_root": false
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"volumes": []
|
||||
}
|
||||
},
|
||||
"version": "1.7.123-alpha"
|
||||
"version": "latest"
|
||||
},
|
||||
"mempool": {
|
||||
"image": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1",
|
||||
@@ -3191,8 +3093,6 @@
|
||||
"name": "Mempool Explorer",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 4080,
|
||||
"protocol": "tcp"
|
||||
@@ -3296,8 +3196,6 @@
|
||||
"name": "Mempool API",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8999,
|
||||
"host": 8999,
|
||||
"protocol": "tcp"
|
||||
@@ -3357,8 +3255,6 @@
|
||||
"name": "MorphOS Server",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 8089,
|
||||
"protocol": "tcp"
|
||||
@@ -3464,8 +3360,6 @@
|
||||
"name": "NetBird",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "NetBird dashboard over TLS, with its own login. The gate speaks plain HTTP, so fronting this port would break the secure context the dashboard requires (issue #15) and the certificate clients pin.",
|
||||
"container": 443,
|
||||
"host": 8087,
|
||||
"protocol": "tcp"
|
||||
@@ -3670,15 +3564,11 @@
|
||||
"name": "NetBird Server",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "NetBird management API and its OIDC issuer. Enrolled devices authenticate themselves with setup keys and JWTs, and they cannot hold a browser session — a login page here would disconnect every VPN client on the network.",
|
||||
"container": 80,
|
||||
"host": 8086,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "STUN over UDP for NAT traversal; it must answer unauthenticated probes to do its job at all.",
|
||||
"container": 3478,
|
||||
"host": 3478,
|
||||
"protocol": "udp"
|
||||
@@ -3763,8 +3653,6 @@
|
||||
"name": "Nextcloud",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 80,
|
||||
"host": 8085,
|
||||
"protocol": "tcp"
|
||||
@@ -3843,8 +3731,6 @@
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 18081,
|
||||
"protocol": "tcp"
|
||||
@@ -3946,8 +3832,6 @@
|
||||
"name": "PhotoPrism",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 2342,
|
||||
"host": 2342,
|
||||
"protocol": "tcp"
|
||||
@@ -4067,15 +3951,11 @@
|
||||
"name": "Pine",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 80,
|
||||
"host": 10380,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Pine's TLS listener. The gate speaks plain HTTP, so fronting this port would break the secure context navigator.bluetooth needs for WiFi provisioning. The plain-HTTP entry point (10380) is gated, and it is what the UI opens.",
|
||||
"container": 443,
|
||||
"host": 10381,
|
||||
"protocol": "tcp"
|
||||
@@ -4186,8 +4066,6 @@
|
||||
"name": "Pine Wake Word (openWakeWord)",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.",
|
||||
"container": 10400,
|
||||
"host": 10400,
|
||||
"protocol": "tcp"
|
||||
@@ -4266,8 +4144,6 @@
|
||||
"name": "Pine Piper (TTS)",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.",
|
||||
"container": 10200,
|
||||
"host": 10200,
|
||||
"protocol": "tcp"
|
||||
@@ -4350,8 +4226,6 @@
|
||||
"name": "Pine Whisper (STT)",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.",
|
||||
"container": 10300,
|
||||
"host": 10300,
|
||||
"protocol": "tcp"
|
||||
@@ -4382,13 +4256,13 @@
|
||||
"version": "3.4.2"
|
||||
},
|
||||
"portainer": {
|
||||
"image": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
|
||||
"image": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"category": "development",
|
||||
"container": {
|
||||
"data_uid": "1000:1000",
|
||||
"image": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
|
||||
"image": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
@@ -4424,8 +4298,6 @@
|
||||
"name": "Portainer",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 9000,
|
||||
"host": 9000,
|
||||
"protocol": "tcp"
|
||||
@@ -4475,7 +4347,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "2.39.1"
|
||||
"version": "2.19.4"
|
||||
},
|
||||
"router": {
|
||||
"manifest": {
|
||||
@@ -4517,22 +4389,16 @@
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 8084,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "mDNS is UDP multicast service discovery; gating it would break .local name resolution for every device on the LAN.",
|
||||
"container": 5353,
|
||||
"host": 5353,
|
||||
"protocol": "udp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "SSDP/UPnP discovery is UDP multicast — there is no HTTP request to gate and no client that could hold a session.",
|
||||
"container": 1900,
|
||||
"host": 1900,
|
||||
"protocol": "udp"
|
||||
@@ -4612,8 +4478,6 @@
|
||||
"name": "SearXNG",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 8888,
|
||||
"protocol": "tcp"
|
||||
@@ -4685,8 +4549,6 @@
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 7777,
|
||||
"host": 8090,
|
||||
"protocol": "tcp"
|
||||
@@ -4777,8 +4639,6 @@
|
||||
"name": "Uptime Kuma",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 3001,
|
||||
"host": 3002,
|
||||
"protocol": "tcp"
|
||||
@@ -4860,8 +4720,6 @@
|
||||
"name": "Vaultwarden",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 80,
|
||||
"host": 8082,
|
||||
"protocol": "tcp"
|
||||
@@ -4898,7 +4756,7 @@
|
||||
}
|
||||
},
|
||||
"schema": 1,
|
||||
"signature": "b10aa65a9a6e91a6d421deff6e578c080c6bafa0185d54412df72b9814a694e9c9ccd803f7e8baa6d0ae93c1b71b3e20ed1626769445cc2f484c4a8bbd1b9901",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"updated": "2026-08-05"
|
||||
"signature": "1fe1b962317212c15b83c9ae8b0b2957f9663bb7dda3f4d117123aab496ddd4f94fa48f7d4abfd4be5b771510aed51f8ede870e55cb3fd21ce2453bea1d3510e",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"updated": "2026-07-31"
|
||||
}
|
||||
|
||||
+25
-24
@@ -1,35 +1,36 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**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."
|
||||
"**Security, and the reason to take this update: two ports on your node handed anyone who could reach them complete control of your money, with no password.** The Lightning app's port answered a plain web request with the LND admin macaroon, the TLS certificate and the node's onion address — everything needed to drain the wallet remotely, and the onion meant an attacker kept that ability even after losing access to your network. The Bitcoin app's port reached Bitcoin Core's control interface using credentials the node itself supplied on the caller's behalf, with a wallet loaded. Anything on your home network, your Tailscale network or the mesh could use either one. Both now require you to be logged in. If your node has been reachable by anyone you do not fully trust, treat the Lightning macaroon and the Bitcoin RPC password as known to them.",
|
||||
"The Bitcoin and Lightning app screens can no longer be published as public Tor addresses automatically. They were one app-id away from being handed a worldwide, permanent address as a silent side effect of being installed — which would have re-opened the hole above to the entire internet. Turning Tor on for them deliberately still works; it just never happens on its own.",
|
||||
"**Fixes shipped inside the program now actually reach apps that your system keeps running.** A container the node had been told to uninstall, but that the system service manager kept alive anyway, was quietly skipped by the part of the node that applies configuration — so it never received updates that shipped with the program. This was found the hard way: the Bitcoin control-interface fix above appeared to be installed and silently was not, while the Lightning half applied correctly, which is the most misleading way for a security fix to fail. Both halves are now proven to land on a real node.",
|
||||
"The Lightning and Bitcoin node screens have been rebuilt to match what umbrelOS offers. Lightning gains Overview, Channels, Activity, Insights, Connect and Settings tabs with a sats/BTC switch; Bitcoin gains Insights, Peers, Connect and Sharing. Along the way: every copy button on those screens silently did nothing (the browser blocks clipboard access inside an embedded page) and now works; the channels link led to a dead page; and Node ID showed a bare key instead of the full address someone can actually connect to.",
|
||||
"Updates to the Bitcoin screen show up without a hard refresh. The page was being cached by the browser, so a freshly updated screen kept rendering the previous one.",
|
||||
"The AI sidebar loads again. It was asking for its program files at an address that pointed at the main app's files, where they do not exist, so it silently loaded nothing.",
|
||||
"The navigation above the bottom bar no longer follows you between screens. Back buttons and the mesh tab bar stayed pinned over every other page once you had visited the screen that owns them. Keeping tabs loaded in the background — the change that made switching between them instant — means leaving a screen hides it rather than destroying it, and this floating navigation sits outside the screen it belongs to, so it was never being hidden with it. It is now tied to whether its own screen is on display. The speed is unchanged: the screens are still kept loaded, so returning to one is still instant.",
|
||||
"Wallet: Lightning actions are now offered based on whether you actually have a usable channel rather than just a running node, sending is gated the same way, and an invoice you cannot yet receive offers to install a Lightning node instead of simply failing.",
|
||||
"Onboarding and viewing fixes: the \"I have written down my recovery words\" tickbox is findable on short screens, paid pictures and videos open in the app's own viewer with a visible loading state instead of a blank browser tab, picture-in-picture survives changing tabs, and the FIPS/Tor labels on peer cards stay put instead of wrapping into the card below.",
|
||||
"Key-material hardening across the node: a node that is already set up refuses to have its identity replaced by an unauthenticated request; first-boot secret generation now fails loudly instead of silently continuing with shared keys; the node proves its TLS certificate and key are actually a matching pair; the Bitcoin Core wallet path that kept a second copy of your spending key outside the encrypted store has been removed; and every place the node generates a key, token or nonce now names its source of randomness explicitly, enforced at build time."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.124-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago",
|
||||
"current_version": "1.7.120-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.120-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.124-alpha",
|
||||
"sha256": "bc91e7d09083dad17934dc2bad211b4eb1d22fd799e032a94fa73df025ba272d",
|
||||
"size_bytes": 54701560
|
||||
"new_version": "1.7.120-alpha",
|
||||
"sha256": "304255655a22bae605d728d44e857ed170a19833b6237861fdf7d852d25d9680",
|
||||
"size_bytes": 54017008
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.124-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago-frontend-1.7.124-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.124-alpha.tar.gz",
|
||||
"new_version": "1.7.124-alpha",
|
||||
"sha256": "346ed6472fb647bcf45299e4d93b9f555142bf1229912030fbafe9966fd410a7",
|
||||
"size_bytes": 210531432
|
||||
"current_version": "1.7.120-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.120-alpha/archipelago-frontend-1.7.120-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.120-alpha.tar.gz",
|
||||
"new_version": "1.7.120-alpha",
|
||||
"sha256": "cb9ea4dfcea3ac93dfb1ce1dca96ea30c74a4e6354471cde4d8f75c0441850a5",
|
||||
"size_bytes": 210519311
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-05",
|
||||
"signature": "40f5ffca026addd00c4aac933edf8faa05a60d9b981341bc0bfaae078812a65ad6656886b4a9d533476759dbb511fb4b050e518cd0060568f7b33035069e600e",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.7.124-alpha"
|
||||
"release_date": "2026-08-03",
|
||||
"signature": "e76e0ca5f249111a0a57df07f790997b1a4facf97da11a2d13fcb7ec9b80aea82925244d6083544504260b776ca4317cf44774e2c37bfaa13afae248e9675601",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.120-alpha"
|
||||
}
|
||||
|
||||
@@ -240,10 +240,6 @@ install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION
|
||||
# warning and falls through — and the commit then happened anyway. A release
|
||||
# commit carrying a manifest no node will accept has no valid use, so refuse
|
||||
# to create one rather than leave a tag that has to be re-cut.
|
||||
# Release root ROTATED 2026-08-05. v1.7.122-alpha was the last release signed
|
||||
# with the old root (z6Mkkid…q7ur) — it is the release that installed this
|
||||
# pin on every node. From v1.7.123 onward the new root signs, and nodes
|
||||
# running .122+ reject anything signed with the old key.
|
||||
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
|
||||
if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|
||||
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then
|
||||
|
||||
@@ -45,13 +45,7 @@ SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
|
||||
CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0"
|
||||
FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0"
|
||||
NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
|
||||
# 2.39.1 is what the fleet has actually been running via the moving :latest
|
||||
# tag, and it is the version that wrote their databases. Pinning back to
|
||||
# 2.19.4 (2 years older) made Portainer refuse to start the moment a
|
||||
# container was recreated: "database schema version does not align with the
|
||||
# server version" — it migrates a DB forward, never backward. Pinned
|
||||
# forward and published as a concrete tag so this is reproducible.
|
||||
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.39.1"
|
||||
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.19.4"
|
||||
|
||||
# Networking
|
||||
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"
|
||||
@@ -90,7 +84,7 @@ STRFRY_IMAGE="$ARCHY_REGISTRY/strfry:1.0.4"
|
||||
NOSTR_VPN_IMAGE="$ARCHY_REGISTRY/nostr-vpn:v0.3.7"
|
||||
NOSTR_VPN_UI_IMAGE="$ARCHY_REGISTRY/nostr-vpn-ui:latest"
|
||||
FIPS_IMAGE="$ARCHY_REGISTRY/fips:v0.1.0"
|
||||
FIPS_UI_IMAGE="$ARCHY_REGISTRY/fips-ui:1.7.123-alpha"
|
||||
FIPS_UI_IMAGE="$ARCHY_REGISTRY/fips-ui:latest"
|
||||
|
||||
# AI / Routing
|
||||
ROUTSTR_IMAGE="$ARCHY_REGISTRY/routstr:v0.4.3"
|
||||
@@ -123,9 +117,9 @@ PENPOT_EXPORTER_IMAGE="$ARCHY_REGISTRY/penpot-exporter:2.4"
|
||||
PENPOT_FRONTEND_IMAGE="$ARCHY_REGISTRY/penpot-frontend:2.4"
|
||||
|
||||
# Custom UI containers (built from docker/ dirs, pushed to registry)
|
||||
BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.123-alpha"
|
||||
LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:1.7.123-alpha"
|
||||
ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:1.7.123-alpha"
|
||||
BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.119-alpha"
|
||||
LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:1.7.119-alpha"
|
||||
ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:latest"
|
||||
|
||||
# Base images
|
||||
NGINX_ALPINE_IMAGE="$ARCHY_REGISTRY/nginx:1.27.4-alpine"
|
||||
|
||||
@@ -29,8 +29,6 @@ fail() { echo "Error: $*" >&2; exit 1; }
|
||||
# with the pinned release-root anchor refuse to auto-apply unsigned manifests,
|
||||
# and enforcement will tighten to hard-reject — an unsigned publish would
|
||||
# strand them. Grep proves presence; ceremony verify proves the crypto.
|
||||
# Release root ROTATED 2026-08-05; see create-release.sh. New root from
|
||||
# v1.7.123 onward.
|
||||
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
|
||||
grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|
||||
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \
|
||||
@@ -41,25 +39,18 @@ if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
|
||||
fi
|
||||
|
||||
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
|
||||
# https is accepted as well as http. Requiring http:// meant the only remote
|
||||
# whose credential actually works for git push (the https one) was rejected,
|
||||
# while the http remote it forced you to use had a dead token — so publishing
|
||||
# failed on auth after the manifest had already passed every check
|
||||
# (v1.7.121-alpha, 2026-08-04). The scheme is carried through to the API URL
|
||||
# rather than assumed.
|
||||
case "$remote_url" in
|
||||
http://*@*|https://*@*) ;;
|
||||
*) fail "$REMOTE must be an authenticated http(s):// Gitea remote URL for API uploads" ;;
|
||||
http://*@*) ;;
|
||||
*) fail "$REMOTE must be an authenticated http:// Gitea remote URL for API uploads" ;;
|
||||
esac
|
||||
|
||||
scheme=${remote_url%%://*}
|
||||
rest=${remote_url#*://}
|
||||
auth=${rest%%@*}
|
||||
host_path=${rest#*@}
|
||||
auth=${remote_url#http://}
|
||||
auth=${auth%@*}
|
||||
host_path=${remote_url#http://$auth@}
|
||||
host=${host_path%%/*}
|
||||
repo_path=${host_path#*/}
|
||||
repo_path=${repo_path%.git}
|
||||
api="$scheme://$host/api/v1/repos/$repo_path"
|
||||
api="http://$host/api/v1/repos/$repo_path"
|
||||
release_url="$api/releases/tags/v${VERSION}"
|
||||
|
||||
echo "Pushing main and v${VERSION} to $REMOTE..."
|
||||
|
||||
@@ -809,12 +809,7 @@ ensure_bitcoin_ui_nginx_conf() {
|
||||
tmp="${CONF_PATH}.tmp.$$"
|
||||
sudo tee "$tmp" >/dev/null << EOF
|
||||
server {
|
||||
# Loopback ONLY — this is the fourth copy of this declaration (the others
|
||||
# are the Rust template in container/bitcoin_ui_nginx.conf.template, the
|
||||
# image, and the manifest). Host networking means this nginx binds the
|
||||
# HOST's address, so \`listen 8334;\` served the Bitcoin screen on every
|
||||
# interface with no login. The app gate owns the external addresses now.
|
||||
listen 127.0.0.1:8334;
|
||||
listen 8334;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
+3
-23
@@ -321,17 +321,7 @@ fi
|
||||
UI_DOCKER_DEST="/opt/archipelago/docker"
|
||||
sudo mkdir -p "$UI_DOCKER_DEST"
|
||||
UI_REBUILD_LIST=""
|
||||
# fips-ui and fedimint-ui are synced but NOT added to UI_REBUILD_LIST below:
|
||||
# container-specs.sh has no spec for either (and their container names break
|
||||
# the archy-<ui> assumption — the FIPS one is plain `fips-ui`). Their rebuilds
|
||||
# come from elsewhere — the daemon's companion installer for fedimint-ui, the
|
||||
# orchestrator's build context for fips-ui — but BOTH read
|
||||
# /opt/archipelago/docker/<ui>, and nothing was ever updating that directory.
|
||||
# So source edits to those two trees reached nodes through no path at all:
|
||||
# their nginx kept listening on 0.0.0.0 and served the Guardian and FIPS
|
||||
# screens unauthenticated on every interface (found by scanning archi-dev-box
|
||||
# from outside, 2026-08-05 — the in-node audit could not see them).
|
||||
for ui in bitcoin-ui lnd-ui electrs-ui fips-ui fedimint-ui; do
|
||||
for ui in bitcoin-ui lnd-ui electrs-ui; do
|
||||
src="$REPO_DIR/docker/$ui"
|
||||
dst="$UI_DOCKER_DEST/$ui"
|
||||
[ -d "$src" ] || continue
|
||||
@@ -345,19 +335,9 @@ for ui in bitcoin-ui lnd-ui electrs-ui fips-ui fedimint-ui; do
|
||||
dst_hash=$( (cd "$dst" && find . -type f | LC_ALL=C sort | xargs sha256sum 2>/dev/null) | sha256sum | cut -d' ' -f1)
|
||||
fi
|
||||
if [ "$src_hash" != "$dst_hash" ]; then
|
||||
log "UI source changed for $ui; syncing"
|
||||
log "UI source changed for $ui; syncing and marking for rebuild"
|
||||
sudo rsync -a --delete "$src/" "$dst/"
|
||||
case "$ui" in
|
||||
# Rebuilt below from container-specs.sh.
|
||||
bitcoin-ui|lnd-ui|electrs-ui)
|
||||
UI_REBUILD_LIST="$UI_REBUILD_LIST $ui" ;;
|
||||
# Synced only — rebuilt by the daemon (companion installer /
|
||||
# orchestrator build context), which watches this directory.
|
||||
# Adding them to the rebuild list would fail: no spec exists and
|
||||
# the container names are not archy-<ui>.
|
||||
*)
|
||||
log " $ui synced; rebuild is owned by the daemon" ;;
|
||||
esac
|
||||
UI_REBUILD_LIST="$UI_REBUILD_LIST $ui"
|
||||
else
|
||||
ok "UI source unchanged for $ui"
|
||||
fi
|
||||
|
||||
@@ -11,10 +11,6 @@
|
||||
# Normally create-release.sh signs the manifest inline; this script exists for
|
||||
# re-signing (e.g. a manifest edited after creation) or signing on a box where
|
||||
# the release run was non-interactive.
|
||||
#
|
||||
# The release root was rotated 2026-08-05. From v1.7.123 this signs with the
|
||||
# NEW mnemonic and the signer's own anchor already pins that key, so no
|
||||
# ARCHY_RELEASE_ROOT_PUBKEY override is needed (it was, for .122 only).
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
Reference in New Issue
Block a user