Compare commits
53
Commits
v1.7.123-alpha
...
main
@@ -0,0 +1,106 @@
|
||||
# App-port authentication gate — design
|
||||
|
||||
Item 1 of `RELEASE-1.7.121-TASKS.md`. Opened 2026-08-04.
|
||||
|
||||
> "if I'm logged out I can reach every app port on tailscale and LAN, this can not be
|
||||
> allowed… it must present the login to access the app with an app icon of what you're
|
||||
> accessing to confirm, and 2FA if present" — operator, 2026-08-03
|
||||
>
|
||||
> "make sure we fix FIPS, Tor, everything … umbrel definitely shows a port when you go to
|
||||
> tailscale IP or other + port but demands the node login and 2FA if activated"
|
||||
> — operator, 2026-08-04
|
||||
|
||||
---
|
||||
|
||||
## What we already built, and why it did not close this
|
||||
|
||||
The operator's recollection that FIPS and Tor were "done" is correct — but that work was
|
||||
about **reachability**, and about restricting the **daemon's own** API. Neither one ever
|
||||
authenticated an app port. Read together, each transport got a door and none got a lock:
|
||||
|
||||
| Layer | What exists today | What it protects |
|
||||
| --- | --- | --- |
|
||||
| `server.rs:1271` `is_peer_allowed_path` | Federated peers hitting the **daemon** port may only reach `/health`, `/rpc/v1`, `/content`, `/blob/`, `/dwn/`, `/transport/inbox`, `/archipelago/*` | The daemon's API surface. **Not app ports.** |
|
||||
| `fips/app_ports.rs` `APP_LAUNCH_PORTS` | 35 app ports **allowed through** the fips0 firewall | Nothing — it *opens* them |
|
||||
| `server.rs:1130` `app_port_v6_relay_loop` | Daemon relays mesh v6 → v4 loopback for those same ports | Nothing — it *bridges* them |
|
||||
| `api/rpc/tor/mod.rs:243` | Per-app `HiddenServicePort 80 → 127.0.0.1:<app port>` | Nothing — it *publishes* them to an onion |
|
||||
| `container/quadlet.rs:261` | `PublishPort=0.0.0.0:{host}:{container}` | Nothing — it binds every interface |
|
||||
|
||||
So the app ports are reachable, by construction, over LAN, Tailscale, FIPS mesh and Tor,
|
||||
and nothing on any of those paths checks a session. This is the same bug class as the
|
||||
v1.7.120 `/lnd-connect-info` + `/bitcoin-rpc/` leaks, but structural rather than
|
||||
per-endpoint.
|
||||
|
||||
## The rule this design is built on
|
||||
|
||||
**You cannot gate a socket you do not own.** Every previous fix added a check *beside* the
|
||||
listener, which is why each one only covered the transport it was written for. The gate
|
||||
has to *be* the listener.
|
||||
|
||||
## Design
|
||||
|
||||
Port numbers do not change. For an app whose UI port is `P`:
|
||||
|
||||
- **The app binds `127.0.0.1:P` only** (`PublishPort=127.0.0.1:P:<container>`), so it is
|
||||
no longer reachable from any interface.
|
||||
- **The gate binds `P` on every external address** — LAN IP, Tailscale IP, fips0 ULA —
|
||||
and on **`127.0.0.2:P`** for Tor. `127.0.0.2` is a distinct loopback address, so it does
|
||||
not collide with the app on `127.0.0.1:P`, and it means **no app needs a second port
|
||||
number**. `torrc` changes to `HiddenServicePort 80 127.0.0.2:P`.
|
||||
- Upstream for the gate is always `127.0.0.1:P`.
|
||||
|
||||
Because the gate owns the socket, LAN / Tailscale / FIPS / Tor are one code path. There is
|
||||
no per-transport work, and therefore no transport to forget.
|
||||
|
||||
### Request handling
|
||||
|
||||
1. Read the `session` cookie. Cookies are **host-scoped and port-agnostic**, so the
|
||||
session minted on the dashboard is presented to `<host>:P` automatically — this is the
|
||||
same mechanism umbrel's "proxy token" relies on. (Scheme still matters: a `Secure`
|
||||
cookie will not travel to a plain-HTTP app port. See open questions.)
|
||||
2. **Valid session** → proxy to `127.0.0.1:P`, passing through `Upgrade` so WebSockets work.
|
||||
3. **No/invalid session** → serve the login page **on the app port itself**, naming the app
|
||||
and showing its icon, POSTing back to the same origin. The gate verifies the password,
|
||||
enforces TOTP when enabled, and sets the session cookie — so logging in at
|
||||
`<tailscale-ip>:P` also logs you into the dashboard, exactly as umbrel behaves.
|
||||
4. Non-browser clients get `401` with a JSON body rather than an HTML page.
|
||||
|
||||
### What must NOT be gated
|
||||
|
||||
Non-HTTP ports cannot carry a cookie and must be declared, not discovered:
|
||||
electrum `50002`, bitcoin p2p `8333`, LND gRPC `10009`/`9735`. These need an explicit
|
||||
manifest field (`auth: none` + rationale) so the exception list is a `grep`, and they are
|
||||
a firewall/allowlist question, tracked separately.
|
||||
|
||||
Note `api/rpc/tor/mod.rs:238-240` already special-cases lnd's `9735`/`10009` as
|
||||
`is_protocol_service` — that distinction is the seed of the manifest field.
|
||||
|
||||
## Deploy traps this walks into
|
||||
|
||||
- **Three copies of every container spec** — `apps/<id>/manifest.yml`,
|
||||
`scripts/container-specs.sh`, `scripts/first-boot-containers.sh`. Changing `PublishPort`
|
||||
in one leaves fresh installs broken while the node looks fixed. This is exactly what bit
|
||||
lnd-ui (item 4). **Deduplicating these is arguably a prerequisite, not a follow-up.**
|
||||
- Changing `PublishPort` drifts every app → one-time recreate fleet-wide.
|
||||
- The gate must rebind when addresses change (Tailscale up/down, DHCP, fips0 re-key).
|
||||
Precedent exists: `peer_late_bind_loop` in `server.rs` already does this for fips0.
|
||||
- Verify **on the node**, not from source. v1.7.120's headline bug was a fix that shipped
|
||||
in the binary and never reached the running container.
|
||||
|
||||
## Open questions for the operator
|
||||
|
||||
1. **Machine clients.** Umbrel's real-world failure mode: Home Assistant (or any API
|
||||
client) hitting an app's API has no cookie and breaks. Browser-only, or do we mint
|
||||
per-app long-lived tokens?
|
||||
2. **TLS/scheme.** The daemon serves plain HTTP with nginx terminating TLS in front. If the
|
||||
dashboard is HTTPS and app ports are HTTP, a `Secure` session cookie will not be sent —
|
||||
the gate would prompt for login every time. Either the gate serves TLS on app ports too,
|
||||
or app ports are HTTP-only on such nodes.
|
||||
|
||||
## Sequencing
|
||||
|
||||
1. Gate module + login page + proxy, behind an env opt-in.
|
||||
2. Prove on **one** HTTP app on .228, across all four transports.
|
||||
3. Dedupe the container-spec declarations.
|
||||
4. Roll to all HTTP apps; declare the non-HTTP exceptions.
|
||||
5. Repoint `torrc` at `127.0.0.2`.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Resume — 2026-08-05 (app gate, releases .122–.125)
|
||||
|
||||
Paste the block at the bottom into a new session.
|
||||
|
||||
## Where things stand
|
||||
|
||||
- **v1.7.124-alpha is SHIPPED** (signed with the NEW root, published, verified).
|
||||
- **Signed catalog is LIVE** carrying two hotfixes made after .124:
|
||||
the repaired bitcoin start script and the fedimint 8175 removal.
|
||||
Last commit: `4ace62fa`.
|
||||
- **Release-root rotation is COMPLETE.** .122 was the last release signed with
|
||||
the old key; .123/.124 and all catalogs use the new one. No override needed.
|
||||
|
||||
## Two bugs I introduced in .124 (both fixed, both instructive)
|
||||
|
||||
1. **Bitcoin vanished from every node.** I put a `#` comment INSIDE the
|
||||
manifest's folded YAML scalar (`>-`), where `#` is not a comment — it
|
||||
reaches the shell, and folding joins lines with spaces so it commented out
|
||||
the `if ... then` while the more-indented `echo` survived, leaving an orphan
|
||||
`fi`. Container exited instantly; app detection is container-based so the
|
||||
app disappeared. **Guard added:** `scripts/check-manifest-shell.py` runs
|
||||
`sh -n` over every embedded manifest script and rejects `#` in these
|
||||
scalars; wired into `tests/release/run.sh`.
|
||||
2. **Fedimint crash-looped.** I declared port 8175 on the `fedimint` app so the
|
||||
gate could name it — but 8175 is served by the separate `archy-fedimint-ui`
|
||||
companion. The orchestrator then tried to publish 8175 from fedimintd,
|
||||
collided, and `start_container` failed forever. Removed. **Rule: never
|
||||
declare a port on an app whose container does not actually serve it.**
|
||||
|
||||
Also: I published an UNSIGNED catalog at one point, which nodes correctly
|
||||
reject — they silently keep their old cached copy. **Always verify
|
||||
`'signature' in catalog` on the live URL after publishing.**
|
||||
|
||||
## OPEN TASKS
|
||||
|
||||
1. **indeedhub crash-loop — NOT mine, needs a real fix.** `indeedhub-minio` is
|
||||
**absent** on `.38` and `.88`, so nginx fails with
|
||||
`host not found in upstream "minio"` and both `indeedhub` and
|
||||
`indeedhub-api` exit(1). The stack member never gets created. Look at
|
||||
`api/rpc/package/stacks.rs` + `dependencies.rs`.
|
||||
2. **Verify `.38` refetched the signed catalog** and bitcoin-knots starts.
|
||||
`.88` already did (signed: True, script fixed).
|
||||
3. **Deploy the .125 build to archi-dev-box for operator confirmation.**
|
||||
Binary is built at `core/target/release/archipelago` with: app-login page
|
||||
using the sidebar **A mark** (`favico-black-v2.svg`) not the wordmark;
|
||||
page pinned to `100svh` + `position:fixed` so mobile stays centred and the
|
||||
keyboard overlays instead of scrolling; install-version modal icon uses
|
||||
`object-contain` so non-square icons are not cropped. **Operator has not
|
||||
seen these yet.**
|
||||
4. **Cut v1.7.125-alpha** once confirmed. Sign with the **NEW** mnemonic.
|
||||
|
||||
## Traps that cost time today
|
||||
|
||||
- `create-release.sh` says "sign, then re-run" — **re-running regenerates the
|
||||
manifest and DESTROYS the signature**, and its clean-tree check blocks
|
||||
anyway. Do steps 7/8 by hand: `git add` version+changelog+manifest →
|
||||
commit `chore: release vX` → `git tag -a vX` → push main → **push the tag
|
||||
explicitly** → `git ls-remote --tags` to prove it → `publish-release-assets.sh`.
|
||||
- The release gate's `cargo-test-weekly` times out on the **compile** after any
|
||||
version bump. Pre-warm: `CARGO_INCREMENTAL=0 cargo test --manifest-path
|
||||
core/Cargo.toml -p archipelago --no-run`.
|
||||
- The frontend version check fails until the in-app **What's New** block for
|
||||
that version exists (`neode-ui/src/views/settings/AccountInfoSection.vue`) —
|
||||
that string is what it greps for.
|
||||
- `generate-app-catalog.py` writes `APP_LAUNCH_PORTS` one-per-line; rustfmt
|
||||
packs it, so run `cargo fmt` after any catalog sync or the gate fails.
|
||||
- **Manifest changes reach nodes via the SIGNED CATALOG, not the binary.** A
|
||||
manifest hotfix needs only a catalog re-sign — no release.
|
||||
|
||||
## Fleet
|
||||
|
||||
SSH: `sshpass -p 'ThisIsWeb54321!' ssh archipelago@<ip>` (note the `!`; `@`
|
||||
is older and still works on some). RPC/node password differs per node — the
|
||||
`!` one failed RPC login on `.38`.
|
||||
|
||||
- `100.69.68.39` archi-dev-box — dev target
|
||||
- `100.82.34.38` archipelago-1
|
||||
- `100.70.96.88` austin-sapien
|
||||
- `100.64.204.114` .228 shorty-s — **in real use, treat carefully**
|
||||
|
||||
**Force a catalog refresh on a node:** Settings → App Updates → Check for
|
||||
updates, or `sudo rm -f /var/lib/archipelago/app-catalog.json && sudo
|
||||
systemctl restart archipelago`.
|
||||
|
||||
**All fleet nodes were repaired** from `Restart=on-failure` →
|
||||
`Restart=always`; a node with the old value stays DEAD after an in-process
|
||||
update (the updater exits cleanly and systemd reads that as success).
|
||||
`bootstrap::ensure_restart_policy()` now self-heals it.
|
||||
|
||||
---
|
||||
|
||||
## PASTE THIS INTO THE NEW SESSION
|
||||
|
||||
Resume the archy work from 2026-08-05. Read
|
||||
`.planning/RESUME-2026-08-05-appgate-fixes.md` and the memory notes
|
||||
`project_fleet_ota_restart_policy_incident` and
|
||||
`project_v1_7_121_shipped_appgate` first.
|
||||
|
||||
v1.7.124-alpha is shipped and the signed catalog is live with two hotfixes
|
||||
(bitcoin start script, fedimint 8175). Four things are open, in order:
|
||||
|
||||
1. Fix the indeedhub crash-loop: `indeedhub-minio` is absent on .38 and .88 so
|
||||
nginx fails on upstream "minio" and indeedhub + indeedhub-api exit(1). This
|
||||
one is pre-existing, not from the port work.
|
||||
2. Verify .38 refetched the signed catalog and bitcoin-knots starts (.88
|
||||
already did).
|
||||
3. Deploy the built .125 binary + frontend to archi-dev-box (100.69.68.39) so
|
||||
I can confirm the app-login page (A mark, mobile centring, keyboard
|
||||
behaviour) and the install-modal icon.
|
||||
4. Then cut v1.7.125-alpha — I sign with the new mnemonic.
|
||||
|
||||
Do not re-run create-release.sh after signing; it destroys the signature —
|
||||
do the commit/tag/publish steps by hand as the resume doc describes.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"workflow": {
|
||||
"_auto_chain_active": false
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.125-alpha (2026-08-06)
|
||||
|
||||
- **The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.
|
||||
- **IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app.
|
||||
- **A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled.
|
||||
- **Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed "send all funds" toggle — no longer quietly carries over into the next payment. Choosing "send all funds" also shows the amount being swept instead of a confusing 0.
|
||||
- **A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with "check server logs"; it now explains that no transaction can be built from those coins.
|
||||
- **The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as "the scanner is gone". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.
|
||||
- **App data folders can no longer be "repaired" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied.
|
||||
- Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.
|
||||
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
|
||||
|
||||
## v1.7.124-alpha (2026-08-05)
|
||||
|
||||
- **The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update.
|
||||
- **Portainer opens again.** Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1, their stored data was written by that version, and the app list pinned a version from two years earlier — so when the container was rebuilt it landed on the old one, which will not read newer data. The correct version is now pinned, older installs upgrade cleanly, and no data was touched.
|
||||
- **Bitcoin starts reliably again.** A leftover settings file in the Bitcoin folder — one the node itself kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes that file, removes stale copies, and treats any that remain as harmless.
|
||||
- **Every app screen opens from My Apps again.** The login gate refused to be displayed inside another page at all, which is exactly how My Apps opens an app, so protected apps appeared broken. It now allows only your own node to display it, and refuses everyone else — a distinction the old setting could not express.
|
||||
- **The app login screen now looks like the node's own.** Same rotating artwork, the same panel, the Archipelago mark, and the app's real icon shown as a tile the way My Apps shows it, instead of a plain box with a letter.
|
||||
- **The Mesh screen uses wide displays properly.** On very large screens it stacked all five panels on top of each other, clipping three of the headings to a sliver and squeezing the map into a letterbox — more screen producing a worse view. It now shows one panel at a time, filling the space, with the map running edge to edge.
|
||||
- **You can choose how long you stay signed in.** Settings → Account now offers an inactivity timeout and a hard limit, plus an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle, because there is nobody there to sign them back in.
|
||||
- Updates now come from `source.archipelago-foundation.org` rather than a bare address, with the old one kept as an automatic fallback for nodes whose clock or name lookup is off. Also included: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.
|
||||
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
|
||||
|
||||
## v1.7.123-alpha (2026-08-05)
|
||||
|
||||
- **Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself.
|
||||
|
||||
@@ -442,7 +442,7 @@
|
||||
"author": "Portainer",
|
||||
"category": "development",
|
||||
"tier": "optional",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
|
||||
"repoUrl": "https://github.com/portainer/portainer",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
|
||||
@@ -38,6 +38,9 @@ app:
|
||||
RPC_CONF="/tmp/rpc.conf";
|
||||
umask 077;
|
||||
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
|
||||
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
|
||||
echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2;
|
||||
fi;
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||
@@ -46,9 +49,9 @@ app:
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
|
||||
@@ -38,6 +38,9 @@ app:
|
||||
RPC_CONF="/tmp/rpc.conf";
|
||||
umask 077;
|
||||
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
|
||||
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
|
||||
echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2;
|
||||
fi;
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||
@@ -46,9 +49,9 @@ app:
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
|
||||
@@ -43,6 +43,10 @@ app:
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes:
|
||||
# Bind-mount the rendered nginx.conf read-only. The prod orchestrator
|
||||
|
||||
@@ -35,6 +35,10 @@ app:
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes: []
|
||||
|
||||
|
||||
@@ -63,16 +63,13 @@ app:
|
||||
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
|
||||
# 8175 is NOT declared here. It is served by the archy-fedimint-ui
|
||||
# companion, a different container, and declaring it on this app made the
|
||||
# orchestrator try to publish 8175 from fedimintd — colliding with the
|
||||
# companion that already holds it, so start_container failed forever and
|
||||
# fedimint crash-looped (100.82.34.38, 2026-08-05). The companion's nginx
|
||||
# is pinned to 127.0.0.1, which is what actually closes that port; the
|
||||
# gate reports it rather than fronting it.
|
||||
- host: 8177
|
||||
container: 8175
|
||||
protocol: tcp
|
||||
|
||||
@@ -39,6 +39,10 @@ app:
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes: []
|
||||
|
||||
|
||||
@@ -22,7 +22,13 @@ app:
|
||||
memory_limit: 256Mi
|
||||
|
||||
security:
|
||||
capabilities: [SETGID, SETUID]
|
||||
# The alpine entrypoint runs as container-root, `find`s /data to chown
|
||||
# anything not owned by the redis user, then su-execs to it. Under the
|
||||
# orchestrator's --cap-drop=ALL, root cannot traverse the 0700
|
||||
# appendonlydir owned by uid 999 without DAC_OVERRIDE (observed
|
||||
# crash-looping ~4k restarts on archi-dev-box) — CHOWN is what the find's
|
||||
# -exec chown needs on adopted legacy data.
|
||||
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ app:
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes: []
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ app:
|
||||
category: development
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/portainer:2.19.4
|
||||
image: 146.59.87.168:3000/lfg2025/portainer:2.39.1
|
||||
pull_policy: if-not-present
|
||||
data_uid: "1000:1000"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.123-alpha"
|
||||
version = "1.7.125-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
@@ -147,6 +147,8 @@ dependencies = [
|
||||
"reed-solomon-erasure",
|
||||
"regex",
|
||||
"reqwest 0.11.27",
|
||||
"rustls-pemfile",
|
||||
"rustls-webpki 0.101.7",
|
||||
"sd-notify",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
@@ -159,6 +161,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-rustls 0.24.1",
|
||||
"tokio-test",
|
||||
"tokio-tungstenite 0.20.1",
|
||||
"toml",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.123-alpha"
|
||||
version = "1.7.125-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
@@ -80,6 +80,13 @@ serde_yaml = "0.9"
|
||||
|
||||
# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging)
|
||||
# Uses rustls-tls for cross-compilation (no OpenSSL dependency)
|
||||
# App-gate TLS. Pinned to the rustls 0.21 line that reqwest already resolves,
|
||||
# so this adds no new vendor and no second rustls major to the tree.
|
||||
tokio-rustls = "0.24"
|
||||
rustls-pemfile = "1.0"
|
||||
# Verifying that the gate's key actually pairs with its certificate; rustls
|
||||
# does not check this itself. Same version rustls 0.21 already resolves.
|
||||
webpki = { package = "rustls-webpki", version = "0.101" }
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
|
||||
|
||||
# Nostr (node discovery + NIP-44 encrypted peer handshake)
|
||||
|
||||
@@ -405,6 +405,8 @@ impl RpcHandler {
|
||||
"mesh.send-channel" => self.handle_mesh_send_channel(params).await,
|
||||
"mesh.broadcast" => self.handle_mesh_broadcast().await,
|
||||
"mesh.reboot-radio" => self.handle_mesh_reboot_radio(params).await,
|
||||
"mesh.rnode-config" => self.handle_mesh_rnode_config().await,
|
||||
"mesh.rnode-config-apply" => self.handle_mesh_rnode_config_apply(params).await,
|
||||
"mesh.configure" => self.handle_mesh_configure(params).await,
|
||||
"mesh.send-invoice" => self.handle_mesh_send_invoice(params).await,
|
||||
"mesh.send-coordinate" => self.handle_mesh_send_coordinate(params).await,
|
||||
@@ -472,6 +474,8 @@ impl RpcHandler {
|
||||
"system.disk-cleanup" => self.handle_system_disk_cleanup().await,
|
||||
"system.reboot" => self.handle_system_reboot(params).await,
|
||||
"system.factory-reset" => self.handle_system_factory_reset(params).await,
|
||||
"auth.session-policy.get" => self.handle_session_policy_get().await,
|
||||
"auth.session-policy.set" => self.handle_session_policy_set(params).await,
|
||||
"system.settings.get" => self.handle_system_settings_get(params).await,
|
||||
"system.settings.set" => self.handle_system_settings_set(params).await,
|
||||
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
|
||||
|
||||
@@ -192,6 +192,19 @@ impl RpcHandler {
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// LND's sweep refusal reads like a debug dump ("insufficient
|
||||
// input to create sweep tx: input_sum=0 BTC, output_sum=…").
|
||||
// input_sum=0 with a tiny output means the wallet's coins are
|
||||
// unconfirmed or below Bitcoin's dust minimum — say that
|
||||
// (framework-pt sweep of 92 sats, 2026-08-06).
|
||||
if msg.contains("insufficient input to create sweep tx") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to send: your on-chain balance is too small or still \
|
||||
unconfirmed to sweep. Bitcoin cannot build a transaction from \
|
||||
coins below the dust minimum (~546 sats) or from funds that \
|
||||
have not confirmed yet. (LND: {msg})"
|
||||
));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to send: {}", msg));
|
||||
}
|
||||
|
||||
|
||||
@@ -104,10 +104,115 @@ impl RpcHandler {
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||
|
||||
svc.reboot_radio(seconds).await?;
|
||||
let message = svc.reboot_radio(seconds).await?;
|
||||
info!(seconds, "Mesh radio reboot requested via RPC");
|
||||
|
||||
Ok(serde_json::json!({ "reboot": true, "seconds": seconds }))
|
||||
Ok(serde_json::json!({ "reboot": true, "seconds": seconds, "message": message }))
|
||||
}
|
||||
|
||||
/// mesh.rnode-config — persisted RF settings + the live radio state
|
||||
/// (radio-confirmed values) for the LoRa settings panel. `live` is best-
|
||||
/// effort: null with `live_error` when no Reticulum radio is connected.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_rnode_config(&self) -> Result<serde_json::Value> {
|
||||
let settings = mesh::rnode_settings::RNodeRfSettings::load(&self.config.data_dir).await;
|
||||
let (live, live_error) = match self.mesh_service.read().await.as_ref() {
|
||||
Some(svc) => match svc.radio_state().await {
|
||||
Ok(state) => (Some(state), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
},
|
||||
None => (None, Some("Mesh service not running".to_string())),
|
||||
};
|
||||
Ok(serde_json::json!({
|
||||
"settings": settings,
|
||||
"live": live,
|
||||
"live_error": live_error,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.rnode-config-apply — validate + persist the RF settings, restart
|
||||
/// the radio daemon so they take effect, then read back the radio-
|
||||
/// confirmed values as proof. Returns { applied, live, message }; a
|
||||
/// failed read-back still reports the persisted settings with a clear
|
||||
/// message instead of pretending success.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_rnode_config_apply(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let settings: mesh::rnode_settings::RNodeRfSettings = serde_json::from_value(
|
||||
params
|
||||
.get("settings")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'settings'"))?,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid settings: {e}"))?;
|
||||
settings.validate()?;
|
||||
settings.save(&self.config.data_dir).await?;
|
||||
info!(?settings, "RNode RF settings persisted");
|
||||
|
||||
// Restart the radio daemon so the new args apply. No radio connected
|
||||
// is fine — the settings apply on the next connect.
|
||||
let service = self.mesh_service.read().await;
|
||||
let Some(svc) = service.as_ref() else {
|
||||
return Ok(serde_json::json!({
|
||||
"applied": false,
|
||||
"message": "Settings saved. They apply when the mesh service next connects to the radio.",
|
||||
}));
|
||||
};
|
||||
if let Err(e) = svc.reboot_radio(2).await {
|
||||
return Ok(serde_json::json!({
|
||||
"applied": false,
|
||||
"message": format!(
|
||||
"Settings saved, but the radio daemon restart failed: {e:#}. \
|
||||
They apply on the next reconnect."
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
// Read-back: poll until the respawned daemon reports the radio online
|
||||
// with our applied values (the respawn re-detects the RNode, ~15s).
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(45);
|
||||
let mut last_live = None;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
if let Ok(state) = svc.radio_state().await {
|
||||
let online = state
|
||||
.get("online")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
last_live = Some(state);
|
||||
if online {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match last_live {
|
||||
Some(live) => {
|
||||
let confirmed = live
|
||||
.get("r_frequency")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|f| f == settings.frequency)
|
||||
.unwrap_or(false);
|
||||
Ok(serde_json::json!({
|
||||
"applied": true,
|
||||
"confirmed": confirmed,
|
||||
"live": live,
|
||||
"message": if confirmed {
|
||||
"The radio confirmed it is now using the applied settings."
|
||||
} else {
|
||||
"Settings applied and the daemon restarted; the radio has not \
|
||||
confirmed the new values yet — recheck in a few seconds."
|
||||
},
|
||||
}))
|
||||
}
|
||||
None => Ok(serde_json::json!({
|
||||
"applied": true,
|
||||
"confirmed": false,
|
||||
"live": null,
|
||||
"message": "Settings applied and the daemon restarted, but it has not \
|
||||
reported the radio state yet — recheck in a few seconds.",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.configure — Enable/disable mesh and set device path.
|
||||
|
||||
@@ -79,6 +79,38 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
// them in the first place (ecash send, 2026-07-22).
|
||||
"Insufficient balance",
|
||||
"Insufficient funds",
|
||||
// On-chain send/sweep refusals from LND ("Failed to send: your
|
||||
// on-chain balance is too small or still unconfirmed to sweep…").
|
||||
// Masking sent the operator to journalctl again (framework-pt
|
||||
// sweep, 2026-08-06) — same lesson as the two above.
|
||||
"Failed to send",
|
||||
// A frontend newer than the daemon calls methods it doesn't have.
|
||||
// Masked, this reads as "the feature is broken" instead of "this
|
||||
// node needs its update" — hit live the moment the .126 LoRa panel
|
||||
// was deployed ahead of its binary (2026-08-06).
|
||||
"Unknown method",
|
||||
// RNode RF settings validation (mesh::rnode_settings::validate) —
|
||||
// every one names the offending field and its legal range, which is
|
||||
// the entire point of validating before touching the radio.
|
||||
"frequency ",
|
||||
"bandwidth ",
|
||||
"spreading factor ",
|
||||
"coding rate ",
|
||||
"tx power ",
|
||||
"airtime_limit_short",
|
||||
"airtime_limit_long",
|
||||
"port must be an absolute",
|
||||
"Invalid settings",
|
||||
"Missing 'settings'",
|
||||
// Mesh preconditions the operator can act on directly.
|
||||
"Mesh service not running",
|
||||
"No mesh device connected",
|
||||
"Mesh listener not running",
|
||||
"MeshCore radios have no remote reboot",
|
||||
"Radio state read-back",
|
||||
"The radio daemon did not answer",
|
||||
"The radio did not acknowledge",
|
||||
"RNode interface is disabled",
|
||||
// Lightning payment failures carry LND's reason ("invoice expired.
|
||||
// Valid until …", "no route", …) — the user can act on every one of
|
||||
// them, and masking sent the operator to journalctl (invoice-expired
|
||||
|
||||
@@ -307,19 +307,24 @@ impl RpcHandler {
|
||||
let deps = self.gate_install_deps(package_id).await?;
|
||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
let repaired_bitcoin_conf =
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
// Materialise the RPC password file before any install path
|
||||
// runs. The orchestrator path resolves secret_env from
|
||||
// /var/lib/archipelago/secrets/bitcoin-rpc-password at start
|
||||
// time; if the file is missing, bitcoind exits within ms.
|
||||
// bitcoin_rpc_credentials() generates + persists on first
|
||||
// call (OnceCell-cached), so this is idempotent.
|
||||
let _ = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
ensure_bitcoin_rpc_config().await?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
// Materialise the RPC password file before any install path
|
||||
// runs. The orchestrator path resolves secret_env from
|
||||
// /var/lib/archipelago/secrets/bitcoin-rpc-password at start
|
||||
// time; if the file is missing, bitcoind exits within ms.
|
||||
// bitcoin_rpc_credentials() generates + persists on first
|
||||
// call (OnceCell-cached), so this is idempotent.
|
||||
let _ = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
// A stale datadir bitcoin.conf from an older install conflicts
|
||||
// with the container's -conf=/tmp/rpc.conf launch (see
|
||||
// apps/bitcoin-core & bitcoin-knots manifest.yml) and makes
|
||||
// Bitcoin Core refuse to start at all. Clear it before
|
||||
// (re)install. Unlike the old bind-setting "repair" this was
|
||||
// replacing, it never requires restarting an already-running
|
||||
// container — bitcoind doesn't read this file, so removing it
|
||||
// changes nothing at runtime.
|
||||
remove_stale_bitcoin_conf().await?;
|
||||
}
|
||||
|
||||
// For orchestrator-managed apps, skip the legacy "container exists →
|
||||
// adopt + return" probe entirely. The orchestrator's own install path
|
||||
@@ -389,37 +394,7 @@ impl RpcHandler {
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if state == "running" && repaired_bitcoin_conf {
|
||||
info!(
|
||||
"Restarting existing container {} after bitcoin.conf RPC repair",
|
||||
package_id
|
||||
);
|
||||
let restart_output = tokio::process::Command::new("podman")
|
||||
.args(["restart", package_id])
|
||||
.output()
|
||||
.await
|
||||
.context(
|
||||
"Failed to restart existing container after bitcoin.conf repair",
|
||||
)?;
|
||||
if !restart_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&restart_output.stderr);
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT FAIL: {} - restart after RPC repair failed: {}",
|
||||
package_id, stderr
|
||||
))
|
||||
.await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"Container {} exists but failed to restart after RPC repair: {}",
|
||||
package_id,
|
||||
stderr
|
||||
));
|
||||
}
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", "archy-bitcoin-ui"])
|
||||
.output()
|
||||
.await;
|
||||
wait_for_adopted_container(package_id, package_id).await?;
|
||||
} else if state != "running" {
|
||||
if state != "running" {
|
||||
// Start the stopped/exited container
|
||||
info!("Starting existing container {} (was {})", package_id, state);
|
||||
let start_output = tokio::process::Command::new("podman")
|
||||
@@ -715,9 +690,13 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-install: write config files BEFORE chown (dir is still owned by archipelago user)
|
||||
// Pre-install: clear a stale datadir bitcoin.conf BEFORE chown (dir is
|
||||
// still owned by archipelago user). bitcoind is launched with
|
||||
// -conf=/tmp/rpc.conf (see apps/bitcoin-core & bitcoin-knots
|
||||
// manifest.yml) and never reads a datadir bitcoin.conf — if one
|
||||
// exists, Bitcoin Core's own safety check refuses to start at all.
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await?;
|
||||
remove_stale_bitcoin_conf().await?;
|
||||
}
|
||||
|
||||
if package_id == "lnd" {
|
||||
@@ -1435,101 +1414,13 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write bitcoin.conf with rpcauth (salted HMAC hash, no plaintext password).
|
||||
async fn write_bitcoin_conf(&self, rpc_user: &str, rpc_pass: &str) -> Result<()> {
|
||||
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
|
||||
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
|
||||
|
||||
// Idempotent: once bitcoin-knots (or a prior install) has started,
|
||||
// the data dir is chowned into the container's user namespace
|
||||
// (e.g. UID 100100 on the host) with 700 perms — the archipelago
|
||||
// daemon can no longer stat or write there. Treat any non-NotFound
|
||||
// error on the conf as "conf already provisioned by the container
|
||||
// user" and skip. Matches the lnd.conf behavior below.
|
||||
match tokio::fs::metadata(&conf_path).await {
|
||||
Ok(_) => {
|
||||
ensure_bitcoin_rpc_config().await?;
|
||||
info!("bitcoin.conf already exists, ensured Bitcoin RPC config");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(_) => {
|
||||
ensure_bitcoin_rpc_config().await?;
|
||||
info!("bitcoin.conf path inaccessible, ensured Bitcoin RPC config via host helper");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
// KEY-05: the salt is half of the stored `rpcauth=` credential line, so
|
||||
// source named and draw guarded.
|
||||
let mut salt_bytes = [0u8; 16];
|
||||
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut salt_bytes).map_err(|e| {
|
||||
anyhow::anyhow!("Refusing to build an rpcauth line from degenerate salt entropy: {e}")
|
||||
})?;
|
||||
let salt_hex = hex::encode(salt_bytes);
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(rpc_pass.as_bytes());
|
||||
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||
let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex);
|
||||
|
||||
// Default to full archive — operators with 2TB+ drives shouldn't be
|
||||
// silently pruned down to 550 MB. Users who want a pruned node can
|
||||
// set `prune=N` in bitcoin.conf themselves after install.
|
||||
//
|
||||
// printtoconsole=0: bitcoind already writes debug.log in the datadir
|
||||
// (self-shrunk on restart); duplicating it to stdout pushed every IBD
|
||||
// "UpdateTip" line through conmon into journald (>1 GB/day). Deep
|
||||
// debugging uses /var/lib/archipelago/bitcoin/debug.log.
|
||||
// rpcbind=0.0.0.0 is REQUIRED inside a container: with rpcallowip set
|
||||
// but no rpcbind, bitcoind binds RPC to 127.0.0.1 in the container
|
||||
// netns only — LND / the Bitcoin UI dialing bitcoin-knots:8332 over
|
||||
// the bridge get connection refused (fresh-install LND crash-loop +
|
||||
// bitcoin-rpc 502, seen on the 1.7.99 ISO). The port publish stays
|
||||
// 127.0.0.1-only on the host, so exposure is unchanged.
|
||||
// Prune sized to the data volume. A full archive needs ~810 GB and
|
||||
// grows; silently writing an unpruned config onto a small disk fills
|
||||
// it mid-IBD (framework node 2026-07-14: unpruned mainnet on a 205 GB
|
||||
// volume). Volumes with real archival headroom (≥1.2 TB) stay full
|
||||
// archive; smaller ones get prune = 25% of the volume, clamped to
|
||||
// [550 MB, 100 GB], leaving room for LND/apps sharing the disk.
|
||||
let prune_line = match bitcoin_data_volume_gb().await {
|
||||
Some(total_gb) if total_gb > 0 && total_gb < 1200 => {
|
||||
let prune_mb = ((total_gb as f64 * 0.25 * 1024.0) as u64).clamp(550, 100_000);
|
||||
info!(
|
||||
volume_gb = total_gb,
|
||||
prune_mb, "Data volume below archival size — enabling sized bitcoin prune"
|
||||
);
|
||||
format!("prune={}\n", prune_mb)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let bitcoin_conf = format!(
|
||||
"\
|
||||
# rpcauth: salted hash only - no plaintext password in config or CLI\n\
|
||||
{}\n\
|
||||
server=1\n\
|
||||
rpcbind=0.0.0.0\n\
|
||||
rpcallowip=0.0.0.0/0\n\
|
||||
listen=1\n\
|
||||
rpcthreads=16\n\
|
||||
rpcworkqueue=256\n\
|
||||
printtoconsole=0\n\
|
||||
{}",
|
||||
rpcauth_line, prune_line
|
||||
);
|
||||
tokio::fs::create_dir_all(bitcoin_dir)
|
||||
.await
|
||||
.context("Failed to create bitcoin data directory")?;
|
||||
tokio::fs::write(&conf_path, bitcoin_conf)
|
||||
.await
|
||||
.context("Failed to write bitcoin.conf")?;
|
||||
info!("Created bitcoin.conf with rpcauth (no plaintext credentials)");
|
||||
Ok(())
|
||||
}
|
||||
// write_bitcoin_conf removed: bitcoind is launched with -conf=/tmp/rpc.conf
|
||||
// (see apps/bitcoin-core & bitcoin-knots manifest.yml, commit a597c1d9)
|
||||
// and never reads a datadir bitcoin.conf. Writing one here created a
|
||||
// fatal "-conf vs default bitcoin.conf" conflict on every subsequent
|
||||
// start (Bitcoin Core's own datadir-conflict safety check). See
|
||||
// `remove_stale_bitcoin_conf` below, which replaces both this and
|
||||
// `ensure_bitcoin_rpc_config`.
|
||||
|
||||
/// Write LND config file with Bitcoin RPC credentials.
|
||||
async fn write_lnd_conf(&self, rpc_user: &str, rpc_pass: &str) -> Result<()> {
|
||||
@@ -2624,28 +2515,12 @@ async fn wait_for_adopted_container(package_id: &str, container_name: &str) -> R
|
||||
))
|
||||
}
|
||||
|
||||
/// Total size (GB) of the filesystem holding the bitcoin data dir, via
|
||||
/// `df -k`. None when df fails (containers, exotic mounts) — callers treat
|
||||
/// unknown as "don't prune" to preserve archival defaults on big iron.
|
||||
async fn bitcoin_data_volume_gb() -> Option<u64> {
|
||||
let target = if std::path::Path::new("/var/lib/archipelago").exists() {
|
||||
"/var/lib/archipelago"
|
||||
} else {
|
||||
"/"
|
||||
};
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["-k", target])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let line = stdout.lines().nth(1)?;
|
||||
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
|
||||
Some(kb / 1024 / 1024)
|
||||
}
|
||||
// bitcoin_data_volume_gb removed with write_bitcoin_conf: it only fed that
|
||||
// function's volume-aware `prune=` line, which bitcoind never read either
|
||||
// (see remove_stale_bitcoin_conf). The manifest's shell entrypoint already
|
||||
// computes DISK_GB_VALUE and hardcodes -prune=550 on small volumes — a
|
||||
// real volume-aware prune fix belongs there, not in a conf file nothing
|
||||
// reads. Tracked as follow-up in bitcoin-conf-crash-patch.md.
|
||||
|
||||
/// One-shot probe: does bitcoind answer an authenticated getblockchaininfo?
|
||||
/// Works during IBD (the call answers with progress while syncing). Goes via
|
||||
@@ -2723,52 +2598,36 @@ async fn wait_for_bitcoin_rpc_gate(package_id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_bitcoin_rpc_config() -> Result<bool> {
|
||||
/// bitcoind reads only `/tmp/rpc.conf` + CLI args at container start (see
|
||||
/// apps/bitcoin-core & bitcoin-knots manifest.yml, commit a597c1d9) — it
|
||||
/// never reads a datadir bitcoin.conf. A leftover file from an older install
|
||||
/// (or a manual edit) makes Bitcoin Core's own datadir-conflict safety check
|
||||
/// refuse to start ("-conf=... vs default bitcoin.conf"). Remove it — via
|
||||
/// the same host-privileged path the old writer/repairer used, since the
|
||||
/// dir may already be chowned into the container's UID namespace by a
|
||||
/// previous start — instead of "repairing" it into existence.
|
||||
async fn remove_stale_bitcoin_conf() -> Result<bool> {
|
||||
let script = r#"
|
||||
set -eu
|
||||
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
|
||||
[ -f "$conf" ] || exit 0
|
||||
changed=0
|
||||
tmp=$(mktemp)
|
||||
awk -F= '
|
||||
/^(server|txindex|rpcbind|rpcallowip|rpcport|listen|bind|dbcache|rpcthreads|rpcworkqueue)=/ {
|
||||
if (seen[$1]++) next
|
||||
}
|
||||
{ print }
|
||||
' "$conf" > "$tmp"
|
||||
if ! cmp -s "$conf" "$tmp"; then
|
||||
cat "$tmp" > "$conf"
|
||||
changed=1
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
ensure_line() {
|
||||
line="$1"
|
||||
key="${line%%=*}"
|
||||
if ! grep -q "^${key}=" "$conf"; then
|
||||
printf '%s\n' "$line" >> "$conf"
|
||||
changed=1
|
||||
fi
|
||||
}
|
||||
ensure_line server=1
|
||||
ensure_line rpcbind=0.0.0.0
|
||||
ensure_line rpcallowip=0.0.0.0/0
|
||||
ensure_line listen=1
|
||||
ensure_line rpcthreads=16
|
||||
ensure_line rpcworkqueue=256
|
||||
[ "$changed" -eq 0 ] && exit 0
|
||||
mv "$conf" "$conf.disabled-$(date +%s)"
|
||||
exit 2
|
||||
"#;
|
||||
let status = host_sudo(&["sh", "-lc", script])
|
||||
.await
|
||||
.context("ensure bitcoin.conf RPC bind settings")?;
|
||||
.context("remove stale bitcoin.conf")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => {
|
||||
install_log("INSTALL REPAIR: bitcoin.conf RPC bind settings added").await;
|
||||
install_log(
|
||||
"INSTALL REPAIR: removed stale bitcoin.conf (conflicts with -conf=/tmp/rpc.conf launch)",
|
||||
)
|
||||
.await;
|
||||
Ok(true)
|
||||
}
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"bitcoin.conf RPC repair helper exited with {}",
|
||||
"bitcoin.conf removal helper exited with {}",
|
||||
status
|
||||
)),
|
||||
}
|
||||
|
||||
@@ -1011,6 +1011,59 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// auth.session-policy.get — how long a login lasts on this node.
|
||||
pub(in crate::api::rpc) async fn handle_session_policy_get(&self) -> Result<serde_json::Value> {
|
||||
let policy = crate::settings::session_policy::load(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!({
|
||||
"idle_timeout_secs": policy.idle_timeout_secs,
|
||||
"absolute_timeout_secs": policy.absolute_timeout_secs,
|
||||
"reauth_for_funds": policy.reauth_for_funds,
|
||||
}))
|
||||
}
|
||||
|
||||
/// auth.session-policy.set — change it.
|
||||
///
|
||||
/// Values are clamped rather than rejected: the caller learns what was
|
||||
/// actually stored from the reply, which is friendlier than an error and
|
||||
/// makes the bounds discoverable. Fields are individually optional so the
|
||||
/// UI can change one control without having to send the others back.
|
||||
pub(in crate::api::rpc) async fn handle_session_policy_set(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or(serde_json::json!({}));
|
||||
let current = crate::settings::session_policy::load(&self.config.data_dir).await;
|
||||
let policy = crate::settings::session_policy::SessionPolicy {
|
||||
idle_timeout_secs: params
|
||||
.get("idle_timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(current.idle_timeout_secs),
|
||||
absolute_timeout_secs: match params.get("absolute_timeout_secs") {
|
||||
// Explicit null means "no absolute cap", which is different
|
||||
// from the field being absent (leave it as it is).
|
||||
Some(serde_json::Value::Null) => None,
|
||||
Some(v) => v.as_u64().or(current.absolute_timeout_secs),
|
||||
None => current.absolute_timeout_secs,
|
||||
},
|
||||
reauth_for_funds: params
|
||||
.get("reauth_for_funds")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(current.reauth_for_funds),
|
||||
};
|
||||
let saved = crate::settings::session_policy::save(&self.config.data_dir, policy).await?;
|
||||
tracing::info!(
|
||||
idle = saved.idle_timeout_secs,
|
||||
absolute = ?saved.absolute_timeout_secs,
|
||||
reauth_for_funds = saved.reauth_for_funds,
|
||||
"session policy updated"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"idle_timeout_secs": saved.idle_timeout_secs,
|
||||
"absolute_timeout_secs": saved.absolute_timeout_secs,
|
||||
"reauth_for_funds": saved.reauth_for_funds,
|
||||
}))
|
||||
}
|
||||
|
||||
/// system.settings.set — Write a settings value
|
||||
pub(in crate::api::rpc) async fn handle_system_settings_set(
|
||||
&self,
|
||||
|
||||
@@ -75,7 +75,8 @@ pub fn address_caching_dependents(package_id: &str) -> &'static [&'static str] {
|
||||
/// The package whose lifecycle lock covers `app_id`: the stack package when
|
||||
/// `app_id` is a member (RPC ops on "mempool" hold the "mempool" lock while
|
||||
/// they drive archy-mempool-web), otherwise the app itself.
|
||||
fn owning_package(app_id: &str) -> &str {
|
||||
/// Also consulted by the reconciler's absent-stack-member recovery.
|
||||
pub fn owning_package(app_id: &str) -> &str {
|
||||
const STACKS: &[&str] = &[
|
||||
"immich",
|
||||
"indeedhub",
|
||||
|
||||
@@ -35,6 +35,11 @@ pub struct GatedPort {
|
||||
/// Tor-upstream bind — must key on this flag: acting on an undeclared
|
||||
/// port is the v1.7.121 incident class, whatever the action.
|
||||
pub declared: bool,
|
||||
/// Manifest opt-in (`session_passthrough: true` on the port): forward the
|
||||
/// node session cookie to the app on authorised requests. First-party
|
||||
/// companion UIs proxy that cookie to the daemon's authenticated
|
||||
/// endpoints; for every other app the gate strips its own credential.
|
||||
pub session_passthrough: bool,
|
||||
}
|
||||
|
||||
/// A port deliberately left unauthenticated, and the manifest's stated reason.
|
||||
@@ -142,11 +147,18 @@ pub fn build_port_map() -> PortMap {
|
||||
let mut seen_apps: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
for (app_id, value) in crate::container::app_catalog::catalog_manifest_values() {
|
||||
// Ports-only overlay: unlike the install path, classification also
|
||||
// accepts BUILD-SOURCE manifests. The on-node-built companion UIs
|
||||
// are exactly the apps whose gate policy (session_passthrough,
|
||||
// auth: gated) must arrive reliably, and their disk manifests
|
||||
// proved stale or absent fleet-wide in the v1.7.125 rollout. The
|
||||
// gate's binds fail safely on conflict with a differently-published
|
||||
// container, so a fresher catalog can only tighten, never expose.
|
||||
let Some(manifest) =
|
||||
crate::container::app_catalog::catalog_manifest_overlay(&app_id, value)
|
||||
crate::container::app_catalog::catalog_manifest_ports_overlay(&app_id, value)
|
||||
else {
|
||||
// Unparseable/invalid/build-source → the orchestrator falls back
|
||||
// to disk for this app, so classification must too.
|
||||
// Unparseable/invalid → the orchestrator falls back to disk for
|
||||
// this app, so classification must too.
|
||||
continue;
|
||||
};
|
||||
if seen_apps.insert(app_id) {
|
||||
@@ -226,6 +238,7 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
|
||||
app_name: app_name.clone(),
|
||||
icon: icon.clone(),
|
||||
declared: true,
|
||||
session_passthrough: port.session_passthrough,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -273,6 +286,9 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
|
||||
app_name: app_name.clone(),
|
||||
icon: icon.clone(),
|
||||
declared: false,
|
||||
// An undeclared port never gets the node session —
|
||||
// passthrough is an explicit manifest opt-in only.
|
||||
session_passthrough: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -331,23 +331,7 @@ fn spawn_accept_loop(
|
||||
let gate = gate.clone();
|
||||
let app = app.clone();
|
||||
tokio::spawn(async move {
|
||||
let service = hyper::service::service_fn(move |req| {
|
||||
let gate = gate.clone();
|
||||
let app = app.clone();
|
||||
async move {
|
||||
Ok::<_, std::convert::Infallible>(
|
||||
gate.handle(req, &app, peer.ip()).await,
|
||||
)
|
||||
}
|
||||
});
|
||||
let _ = hyper::server::conn::Http::new()
|
||||
// Same slowloris guard as the main listener: an
|
||||
// unauthenticated caller must not be able to hold
|
||||
// a connection open by never sending headers.
|
||||
.http1_header_read_timeout(std::time::Duration::from_secs(30))
|
||||
.serve_connection(stream, service)
|
||||
.with_upgrades()
|
||||
.await;
|
||||
serve_connection(stream, peer, gate, app).await;
|
||||
});
|
||||
}
|
||||
_ = shutdown_rx.changed() => break,
|
||||
@@ -356,6 +340,86 @@ fn spawn_accept_loop(
|
||||
})
|
||||
}
|
||||
|
||||
/// How long a freshly-accepted connection has to send its first byte.
|
||||
///
|
||||
/// The peek below blocks until *something* arrives, so without this an
|
||||
/// unauthenticated caller could hold a task open indefinitely by connecting and
|
||||
/// saying nothing — the same slowloris shape the header-read timeout guards
|
||||
/// against, one step earlier in the handshake.
|
||||
const FIRST_BYTE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
|
||||
/// Serve one connection, as TLS or plain HTTP depending on what the client
|
||||
/// actually sent.
|
||||
///
|
||||
/// The first byte decides: `peek` inspects it *without consuming it*, so a TLS
|
||||
/// client's ClientHello reaches the acceptor whole. This is what lets one port
|
||||
/// serve an HTTP dashboard's frames and an HTTPS dashboard's frames on the same
|
||||
/// node without a second port number or a per-node build.
|
||||
async fn serve_connection(
|
||||
stream: tokio::net::TcpStream,
|
||||
peer: SocketAddr,
|
||||
gate: Arc<AppGate>,
|
||||
app: GatedPort,
|
||||
) {
|
||||
let mut first = [0u8; 1];
|
||||
let peeked = tokio::time::timeout(FIRST_BYTE_TIMEOUT, stream.peek(&mut first)).await;
|
||||
|
||||
let is_tls = match peeked {
|
||||
Ok(Ok(1)) => super::tls::looks_like_tls(first[0]),
|
||||
// 0 bytes is a clean close before any request; anything else is a
|
||||
// read error or the timeout. Nothing to serve either way.
|
||||
_ => {
|
||||
debug!(%peer, "app gate connection closed before sending anything");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if is_tls {
|
||||
match gate.tls.acceptor().await {
|
||||
Some(acceptor) => match acceptor.accept(stream).await {
|
||||
Ok(tls_stream) => serve_http(tls_stream, peer, gate, app).await,
|
||||
Err(e) => {
|
||||
// Routine: a browser probing a cert it does not trust, or a
|
||||
// scanner. Not operator-actionable, so debug.
|
||||
debug!(%peer, error = %e, "app gate TLS handshake failed");
|
||||
}
|
||||
},
|
||||
None => {
|
||||
// The client speaks TLS and this node has no certificate.
|
||||
// Replying in plain HTTP would be unreadable garbage to it, so
|
||||
// close and let the browser report the connection failure.
|
||||
debug!(
|
||||
%peer,
|
||||
"app gate got a TLS connection but has no certificate — closing"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
serve_http(stream, peer, gate, app).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// The HTTP half, generic over the transport so TLS and plain share one path —
|
||||
/// the gate's authentication, proxying and upgrade handling must not differ by
|
||||
/// scheme, and generics make that structural rather than a thing to remember.
|
||||
async fn serve_http<S>(stream: S, peer: SocketAddr, gate: Arc<AppGate>, app: GatedPort)
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let service = hyper::service::service_fn(move |req| {
|
||||
let gate = gate.clone();
|
||||
let app = app.clone();
|
||||
async move { Ok::<_, std::convert::Infallible>(gate.handle(req, &app, peer.ip()).await) }
|
||||
});
|
||||
let _ = hyper::server::conn::Http::new()
|
||||
// Same slowloris guard as the main listener: an unauthenticated caller
|
||||
// must not be able to hold a connection open by never sending headers.
|
||||
.http1_header_read_timeout(std::time::Duration::from_secs(30))
|
||||
.serve_connection(stream, service)
|
||||
.with_upgrades()
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
|
||||
pub mod identity;
|
||||
pub mod listener;
|
||||
pub mod tls;
|
||||
|
||||
use crate::auth::AuthManager;
|
||||
use crate::rate_limit::LoginRateLimiter;
|
||||
@@ -65,6 +66,10 @@ pub struct AppGate {
|
||||
limiter: LoginRateLimiter,
|
||||
data_dir: PathBuf,
|
||||
port_map: Arc<RwLock<PortMap>>,
|
||||
/// TLS for gated ports. Shared by every accept loop so one reissue is
|
||||
/// picked up by all of them, and so the parse happens once rather than
|
||||
/// per port.
|
||||
pub(crate) tls: Arc<tls::GateTls>,
|
||||
}
|
||||
|
||||
impl AppGate {
|
||||
@@ -80,6 +85,7 @@ impl AppGate {
|
||||
limiter,
|
||||
data_dir,
|
||||
port_map: Arc::new(RwLock::new(identity::build_port_map())),
|
||||
tls: Arc::new(tls::GateTls::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +142,7 @@ impl AppGate {
|
||||
}
|
||||
|
||||
match self.authorize(req.headers(), &app.app_id).await {
|
||||
Authorization::Allow => proxy_to_app(req, app.port).await,
|
||||
Authorization::Allow => proxy_to_app(req, app).await,
|
||||
// 401 rather than a redirect: a redirect to a login page is
|
||||
// indistinguishable from the app itself redirecting, and machine
|
||||
// clients would follow it and parse HTML as if it were their API
|
||||
@@ -154,6 +160,11 @@ impl AppGate {
|
||||
action: &str,
|
||||
client_ip: IpAddr,
|
||||
) -> Response<Body> {
|
||||
// Assets are GET and pre-auth by nature: the login page cannot
|
||||
// render its own background or logo without them.
|
||||
if let Some(name) = action.strip_prefix("asset/") {
|
||||
return self.serve_asset(name);
|
||||
}
|
||||
if req.method() != Method::POST {
|
||||
return login_page(app, None, StatusCode::OK);
|
||||
}
|
||||
@@ -187,6 +198,26 @@ impl AppGate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Static assets the login page needs, served from the gate's own origin.
|
||||
///
|
||||
/// The backgrounds are ~1 MB each, so inlining them as data URIs would
|
||||
/// bloat every challenge response. Serving them here keeps the page
|
||||
/// byte-identical to the dashboard's login while the CSP stays tight:
|
||||
/// `img-src 'self' data:` and nothing else.
|
||||
fn serve_asset(&self, name: &str) -> Response<Body> {
|
||||
let Some((bytes, mime)) = read_ui_asset(name) else {
|
||||
return not_found();
|
||||
};
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime)
|
||||
// Immutable art; caching it costs nothing and keeps the login
|
||||
// instant on a repeat challenge.
|
||||
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
||||
.body(Body::from(bytes))
|
||||
.expect("asset response builds")
|
||||
}
|
||||
|
||||
async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response<Body> {
|
||||
let password = field(form, "password").unwrap_or_default();
|
||||
|
||||
@@ -350,7 +381,8 @@ fn percent_decode(input: &str) -> String {
|
||||
}
|
||||
|
||||
/// Forward an authorised request to the app on loopback.
|
||||
async fn proxy_to_app(req: Request<Body>, port: u16) -> Response<Body> {
|
||||
async fn proxy_to_app(req: Request<Body>, app: &GatedPort) -> Response<Body> {
|
||||
let port = app.port;
|
||||
let path_and_query = req
|
||||
.uri()
|
||||
.path_and_query()
|
||||
@@ -364,10 +396,16 @@ async fn proxy_to_app(req: Request<Body>, port: u16) -> Response<Body> {
|
||||
|
||||
let (mut parts, body) = req.into_parts();
|
||||
parts.uri = uri;
|
||||
// Strip the gate's own credential before it reaches the app: the app has
|
||||
// no use for the node session and should never be in a position to log,
|
||||
// echo, or forward it.
|
||||
parts.headers.remove(header::COOKIE);
|
||||
// Strip the gate's own credential before it reaches the app — the app
|
||||
// should never be in a position to log, echo, or forward the node
|
||||
// session. But ONLY the gate's cookies: apps run their own cookie logins
|
||||
// (vaultwarden, nextcloud, gitea…), and removing the whole header logged
|
||||
// every one of them out on each request. Companion UIs that proxy the
|
||||
// daemon's authenticated endpoints opt in to keeping the session via
|
||||
// `session_passthrough: true` on their gated port.
|
||||
if !app.session_passthrough {
|
||||
strip_gate_cookies(&mut parts.headers);
|
||||
}
|
||||
parts.headers.remove(header::AUTHORIZATION);
|
||||
|
||||
let client = hyper::Client::new();
|
||||
@@ -377,6 +415,44 @@ async fn proxy_to_app(req: Request<Body>, port: u16) -> Response<Body> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cookie names owned by the gate/daemon, never the app's to see.
|
||||
const GATE_COOKIE_NAMES: &[&str] = &["session", "csrf_token"];
|
||||
|
||||
/// Remove the gate's own cookie pairs from the Cookie header, preserving the
|
||||
/// app's cookies (its login/session/prefs) untouched. Drops the header
|
||||
/// entirely when nothing remains.
|
||||
fn strip_gate_cookies(headers: &mut hyper::HeaderMap) {
|
||||
let Some(cookie) = headers.get(header::COOKIE) else {
|
||||
return;
|
||||
};
|
||||
let Ok(raw) = cookie.to_str() else {
|
||||
// Not valid UTF-8 — can't safely filter pairs, so fail closed.
|
||||
headers.remove(header::COOKIE);
|
||||
return;
|
||||
};
|
||||
let kept: Vec<&str> = raw
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|pair| {
|
||||
let name = pair.split('=').next().unwrap_or("").trim();
|
||||
!GATE_COOKIE_NAMES.contains(&name)
|
||||
})
|
||||
.filter(|pair| !pair.is_empty())
|
||||
.collect();
|
||||
if kept.is_empty() {
|
||||
headers.remove(header::COOKIE);
|
||||
return;
|
||||
}
|
||||
match header::HeaderValue::from_str(&kept.join("; ")) {
|
||||
Ok(v) => {
|
||||
headers.insert(header::COOKIE, v);
|
||||
}
|
||||
Err(_) => {
|
||||
headers.remove(header::COOKIE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
|
||||
// No Domain attribute, so the cookie is host-only. Cookies ignore port,
|
||||
// which is what makes one sign-in cover the dashboard and every app port
|
||||
@@ -428,43 +504,161 @@ fn esc(s: &str) -> String {
|
||||
/// none. Inlined as a data URI rather than linked: the gate is answering on
|
||||
/// the app's own port, so any asset URL would either hit the unauthenticated
|
||||
/// app behind it or a different origin the browser may not reach.
|
||||
/// One stacked layer per background, each delayed so they cross-fade in turn.
|
||||
fn background_layers() -> String {
|
||||
let step = LOGIN_BACKGROUNDS.len() as u32 * 9 / LOGIN_BACKGROUNDS.len() as u32;
|
||||
LOGIN_BACKGROUNDS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
format!(
|
||||
r#"<div class="bg" style="background-image:url('{prefix}asset/{name}');animation-delay:{delay}s"></div>"#,
|
||||
prefix = GATE_PREFIX,
|
||||
delay = i as u32 * step,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn icon_markup(app: &GatedPort) -> String {
|
||||
if let Some(path) = &app.icon {
|
||||
if let Some(data_uri) = read_icon_data_uri(path) {
|
||||
return format!(r#"<img class="icon" src="{}" alt="">"#, esc(&data_uri));
|
||||
}
|
||||
}
|
||||
let letter = app
|
||||
.app_name
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.to_uppercase().to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
format!(r#"<div class="icon lettermark">{}</div>"#, esc(&letter))
|
||||
let inner = app
|
||||
.icon
|
||||
.as_deref()
|
||||
.and_then(read_icon_data_uri)
|
||||
// A manifest that names no icon still gets one: the dashboard already
|
||||
// ships icons named after the app, so fall back to those before
|
||||
// giving up. Without this EVERY gated app showed a lettermark,
|
||||
// because no manifest declares metadata.icon (archi-dev-box,
|
||||
// 2026-08-05).
|
||||
.or_else(|| {
|
||||
icon_candidates(&app.app_id)
|
||||
.iter()
|
||||
.find_map(|c| read_icon_data_uri(c))
|
||||
})
|
||||
.map(|data_uri| format!(r#"<img class="icon" src="{}" alt="">"#, esc(&data_uri)))
|
||||
.unwrap_or_else(|| {
|
||||
let letter = app
|
||||
.app_name
|
||||
.chars()
|
||||
.find(|c| c.is_alphanumeric())
|
||||
.map(|c| c.to_uppercase().to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
format!(r#"<div class="icon lettermark">{}</div>"#, esc(&letter))
|
||||
});
|
||||
format!(r#"<div class="tile">{inner}</div>"#)
|
||||
}
|
||||
|
||||
/// Icons live with the web UI. Only files under the icon directory are read,
|
||||
/// and only known image extensions — the path comes from a manifest, which is
|
||||
/// signed, but treating it as untrusted costs nothing.
|
||||
fn read_icon_data_uri(icon_path: &str) -> Option<String> {
|
||||
let name = std::path::Path::new(icon_path).file_name()?.to_str()?;
|
||||
let mime = match name.rsplit_once('.')?.1.to_ascii_lowercase().as_str() {
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"webp" => "image/webp",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
_ => return None,
|
||||
/// Icon basenames to try for an app id, best first.
|
||||
///
|
||||
/// The shipped icon set is named for the *product*, while app ids carry
|
||||
/// packaging detail — `filebrowser` vs `file-browser`, `morphos-server` vs
|
||||
/// `morphos` — and the per-app screens (`lnd-ui`, `bitcoin-ui`, `electrs-ui`)
|
||||
/// have no icon of their own but obviously belong to the app they front.
|
||||
/// Resolving those here keeps the mapping in one readable place instead of
|
||||
/// adding a `metadata.icon` line to every manifest, which would have to be
|
||||
/// re-signed into the catalog to take effect.
|
||||
fn icon_candidates(app_id: &str) -> Vec<String> {
|
||||
let mut out = vec![app_id.to_string()];
|
||||
let alias = match app_id {
|
||||
"filebrowser" => Some("file-browser"),
|
||||
"home-assistant" => Some("homeassistant"),
|
||||
"morphos-server" => Some("morphos"),
|
||||
"barkd" => Some("bark"),
|
||||
"archy-mempool-web" | "mempool-api" => Some("mempool"),
|
||||
"lnd-ui" | "lightning-stack" => Some("lnd"),
|
||||
"bitcoin-ui" => Some("bitcoin-core"),
|
||||
"electrs-ui" => Some("electrumx"),
|
||||
"fips-ui" | "aiui" | "did-wallet" => Some("archipelago-a"),
|
||||
"fedimint-gateway" | "fedimint-clientd" => Some("fedimint"),
|
||||
_ => None,
|
||||
};
|
||||
out.extend(alias.map(str::to_string));
|
||||
// `<app>-ui` / `-server` / `-web` front an app whose icon is the bare name.
|
||||
for suffix in ["-ui", "-server", "-web"] {
|
||||
if let Some(base) = app_id.strip_suffix(suffix) {
|
||||
out.push(base.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Backgrounds the login cycles through, matching the dashboard's own
|
||||
/// `/login` art. Cross-faded by CSS alone — the CSP forbids script, and a
|
||||
/// rotation that needs JavaScript would not survive it.
|
||||
const LOGIN_BACKGROUNDS: [&str; 4] = [
|
||||
"bg-intro.jpg",
|
||||
"bg-intro-4.webp",
|
||||
"bg-intro-6.webp",
|
||||
"bg-intro-3.jpg",
|
||||
];
|
||||
|
||||
/// Assets the gate will serve, by exact name. An allowlist rather than a path
|
||||
/// join: the name arrives in a URL, and the gate answers before any
|
||||
/// authentication, so nothing here may be caller-controlled beyond this set.
|
||||
fn read_ui_asset(name: &str) -> Option<(Vec<u8>, &'static str)> {
|
||||
let allowed = LOGIN_BACKGROUNDS.contains(&name) || name == "favico-black-v2.svg";
|
||||
if !allowed {
|
||||
return None;
|
||||
}
|
||||
let mime = icon_mime(name.rsplit_once('.')?.1)?;
|
||||
for root in [
|
||||
"/opt/archipelago/web-ui/assets/img/app-icons",
|
||||
"web/dist/neode-ui/assets/img/app-icons",
|
||||
"/opt/archipelago/web-ui/assets/img",
|
||||
"web/dist/neode-ui/assets/img",
|
||||
"neode-ui/public/assets/img",
|
||||
"/opt/archipelago/web-ui/assets/icon",
|
||||
"web/dist/neode-ui/assets/icon",
|
||||
"neode-ui/public/assets/icon",
|
||||
] {
|
||||
let candidate = std::path::Path::new(root).join(name);
|
||||
if let Ok(bytes) = std::fs::read(&candidate) {
|
||||
if bytes.len() > 512 * 1024 {
|
||||
return None;
|
||||
if let Ok(bytes) = std::fs::read(std::path::Path::new(root).join(name)) {
|
||||
return Some((bytes, mime));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
const ICON_ROOTS: [&str; 2] = [
|
||||
"/opt/archipelago/web-ui/assets/img/app-icons",
|
||||
"web/dist/neode-ui/assets/img/app-icons",
|
||||
];
|
||||
|
||||
fn icon_mime(ext: &str) -> Option<&'static str> {
|
||||
match ext.to_ascii_lowercase().as_str() {
|
||||
"svg" => Some("image/svg+xml"),
|
||||
"png" => Some("image/png"),
|
||||
"webp" => Some("image/webp"),
|
||||
"jpg" | "jpeg" => Some("image/jpeg"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an app icon as a `data:` URI.
|
||||
///
|
||||
/// `icon_ref` may be a filename or path with an extension (a manifest's
|
||||
/// `metadata.icon`), or a bare name such as an app id — in which case the
|
||||
/// known extensions are tried in turn. Only the file name is used; the
|
||||
/// directories searched are fixed, so a manifest cannot point the gate at an
|
||||
/// arbitrary path.
|
||||
fn read_icon_data_uri(icon_ref: &str) -> Option<String> {
|
||||
let name = std::path::Path::new(icon_ref).file_name()?.to_str()?;
|
||||
let candidates: Vec<(String, &str)> = match name.rsplit_once('.') {
|
||||
Some((_, ext)) => vec![(name.to_string(), icon_mime(ext)?)],
|
||||
None => ["svg", "png", "webp", "jpg"]
|
||||
.iter()
|
||||
.filter_map(|ext| Some((format!("{name}.{ext}"), icon_mime(ext)?)))
|
||||
.collect(),
|
||||
};
|
||||
for (file, mime) in candidates {
|
||||
for root in ICON_ROOTS {
|
||||
let candidate = std::path::Path::new(root).join(&file);
|
||||
if let Ok(bytes) = std::fs::read(&candidate) {
|
||||
if bytes.len() > 512 * 1024 {
|
||||
continue;
|
||||
}
|
||||
return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
|
||||
}
|
||||
return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -484,29 +678,91 @@ fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Respons
|
||||
<meta name="robots" content="noindex">
|
||||
<title>{title} — {app_name}</title>
|
||||
<style>
|
||||
/* The dashboard's own /login, rebuilt in static CSS: the same rotating
|
||||
intro art, .glass-card panel, .glass-button action and transparent
|
||||
white-bordered inputs from neode-ui/src/style.css. Written longhand
|
||||
rather than shared with the SPA because the gate answers before any
|
||||
bundle exists, and the CSP forbids external stylesheets and script. */
|
||||
:root {{ color-scheme: dark; }}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{ margin:0; min-height:100vh; display:grid; place-items:center;
|
||||
background:#0b0f14; color:#e6edf3; font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif; }}
|
||||
.card {{ width:min(92vw,380px); padding:2rem; background:#121820;
|
||||
border:1px solid #223; border-radius:14px; text-align:center; }}
|
||||
.icon {{ width:64px; height:64px; border-radius:14px; margin:0 auto 1rem; display:block; object-fit:cover; }}
|
||||
.lettermark {{ display:grid; place-items:center; background:#1d2733; font-size:28px; font-weight:600; }}
|
||||
h1 {{ font-size:1.15rem; margin:0 0 .25rem; }}
|
||||
p.sub {{ margin:0 0 1.5rem; color:#8b98a5; font-size:.9rem; }}
|
||||
input {{ width:100%; padding:.7rem .8rem; margin-bottom:.75rem; border-radius:9px;
|
||||
border:1px solid #2b3947; background:#0d131a; color:#e6edf3; font-size:1rem; }}
|
||||
input:focus {{ outline:2px solid #3b82f6; outline-offset:1px; }}
|
||||
button {{ width:100%; padding:.7rem; border:0; border-radius:9px; background:#3b82f6;
|
||||
color:#fff; font-size:1rem; font-weight:600; cursor:pointer; }}
|
||||
button:hover {{ background:#2f6fd6; }}
|
||||
.err {{ background:#3b1519; border:1px solid #7f1d1d; color:#fca5a5;
|
||||
padding:.6rem .8rem; border-radius:9px; margin-bottom:1rem; font-size:.9rem; }}
|
||||
html {{ height:100%; }}
|
||||
body {{ margin:0; color:#fff; background:#05070a; overflow:hidden;
|
||||
font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||
/* Fixed to the viewport rather than a tall scrolling page: an on-screen
|
||||
keyboard then overlays the card instead of scrolling it away, and the
|
||||
card stays optically centred. min-height:100vh scrolled with the
|
||||
keyboard on mobile and left the card off-centre (reported 2026-08-05). */
|
||||
position:fixed; inset:0;
|
||||
display:grid; place-items:center; padding:1rem;
|
||||
height:100vh; height:100svh; }}
|
||||
/* Very short viewports (landscape phone, or a keyboard eating most of it):
|
||||
allow the card to scroll INSIDE the fixed frame rather than overflow. */
|
||||
@media (max-height:640px) {{
|
||||
body {{ align-items:start; overflow-y:auto; padding-top:3rem; }}
|
||||
}}
|
||||
/* Rotating backgrounds: each layer holds its image and cross-fades on a
|
||||
shared cycle, so the art moves the way /login does with no script. */
|
||||
.bg {{ position:fixed; inset:0; z-index:0; background-size:cover;
|
||||
background-position:center; opacity:0; animation:bg-cycle {cycle}s infinite; }}
|
||||
.bg::after {{ content:''; position:absolute; inset:0;
|
||||
background:linear-gradient(180deg, rgba(0,0,0,.35), rgba(0,0,0,.72)); }}
|
||||
@keyframes bg-cycle {{
|
||||
0% {{ opacity:0; }} 4% {{ opacity:1; }}
|
||||
{hold}% {{ opacity:1; }} {fade}% {{ opacity:0; }} 100% {{ opacity:0; }}
|
||||
}}
|
||||
main {{ position:relative; z-index:1; width:min(92vw,28rem); }}
|
||||
.card {{ padding:2rem; padding-top:3.5rem; position:relative;
|
||||
background:rgba(0,0,0,.65); backdrop-filter:blur(18px);
|
||||
-webkit-backdrop-filter:blur(18px); border:1px solid rgba(255,255,255,.18);
|
||||
border-radius:1rem; box-shadow:0 8px 24px rgba(0,0,0,.45); text-align:center; }}
|
||||
/* The Archipelago mark, half in and half out of the panel — same placement
|
||||
and gradient ring as Login.vue. */
|
||||
.logo {{ position:absolute; top:-2.5rem; left:50%; transform:translateX(-50%);
|
||||
width:5rem; height:5rem; border-radius:9999px; padding:3px;
|
||||
background:linear-gradient(135deg, rgba(255,255,255,.6) 0%, rgba(0,0,0,.8) 100%);
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.5); }}
|
||||
.logo img {{ width:100%; height:100%; border-radius:9999px; display:block;
|
||||
background:#000; padding:.5rem; }}
|
||||
/* The app's own tile, in the My Apps shape: 18px-rounded square on dark
|
||||
glass with the same inner highlight and drop shadow. */
|
||||
.tile {{ width:60px; height:60px; border-radius:18px; margin:0 auto .75rem;
|
||||
background:rgba(0,0,0,.72); box-shadow:0 8px 18px rgba(0,0,0,.38); }}
|
||||
.tile .icon {{ width:100%; height:100%; border-radius:18px; display:block;
|
||||
object-fit:cover; border:1px solid rgba(255,255,255,.18);
|
||||
background:radial-gradient(circle at 35% 28%, rgba(255,255,255,.1), rgba(255,255,255,0) 42%),
|
||||
linear-gradient(145deg, rgba(22,22,24,.96), rgba(0,0,0,.96));
|
||||
box-shadow:inset 0 1px 0 rgba(255,255,255,.12), inset 0 -10px 24px rgba(0,0,0,.34); }}
|
||||
.lettermark {{ display:grid; place-items:center; font-size:1.6rem; font-weight:600;
|
||||
color:rgba(255,255,255,.9); }}
|
||||
h1 {{ font-size:1.5rem; font-weight:600; margin:0 0 .4rem;
|
||||
color:rgba(255,255,255,.96); text-shadow:0 2px 6px rgba(0,0,0,.4); }}
|
||||
p.sub {{ margin:0 0 1.75rem; color:rgba(255,255,255,.6); font-size:.875rem; }}
|
||||
input {{ width:100%; padding:.75rem 1rem; margin-bottom:1rem; border-radius:.5rem;
|
||||
border:1px solid rgba(255,255,255,.2); background:transparent; color:#fff;
|
||||
font-size:1rem; transition:border-color .2s ease; }}
|
||||
input::placeholder {{ color:rgba(255,255,255,.4); }}
|
||||
input:focus {{ outline:none; border-color:rgba(255,255,255,.4);
|
||||
box-shadow:0 0 0 1px rgba(255,255,255,.2); }}
|
||||
button {{ width:100%; min-height:44px; padding:.75rem 1.25rem; border:none;
|
||||
border-radius:.75rem; background:rgba(0,0,0,.6);
|
||||
backdrop-filter:blur(24px); -webkit-backdrop-filter:blur(24px);
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.22);
|
||||
color:rgba(255,255,255,.9); font-size:1rem; font-weight:500; cursor:pointer;
|
||||
transition:background-color .2s ease, transform .3s cubic-bezier(.4,0,.2,1); }}
|
||||
button:hover {{ background:rgba(0,0,0,.7); }}
|
||||
button:active {{ transform:translateY(1px); }}
|
||||
.err {{ background:rgba(239,68,68,.2); border:1px solid rgba(239,68,68,.4);
|
||||
color:#fecaca; padding:.75rem; border-radius:.5rem; margin-bottom:1rem;
|
||||
font-size:.875rem; text-align:left; }}
|
||||
</style></head>
|
||||
<body><main class="card">{body}</main></body></html>"#,
|
||||
<body>{backgrounds}<main><div class="card">{body}</div></main></body></html>"#,
|
||||
title = esc(title),
|
||||
app_name = esc(&app.app_name),
|
||||
body = body,
|
||||
backgrounds = background_layers(),
|
||||
cycle = LOGIN_BACKGROUNDS.len() as u32 * 9,
|
||||
hold = 100 / LOGIN_BACKGROUNDS.len() as u32,
|
||||
fade = 100 / LOGIN_BACKGROUNDS.len() as u32 + 4,
|
||||
);
|
||||
Response::builder()
|
||||
.status(status)
|
||||
@@ -514,10 +770,18 @@ button:hover {{ background:#2f6fd6; }}
|
||||
// The gate answers on the app's own port for an unauthenticated
|
||||
// caller; nothing here should be cached or framed.
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header("X-Frame-Options", "DENY")
|
||||
// NOT X-Frame-Options: DENY. My Apps opens an app in an embedded
|
||||
// frame, so a blanket DENY made every gated app render as "app is
|
||||
// not responding" the moment the gate challenged it (reported on
|
||||
// 100.82.34.38, 2026-08-05). frame-ancestors is the modern control
|
||||
// and can be precise: only pages from this same node may frame the
|
||||
// login, on any port or scheme, which is exactly the dashboard.
|
||||
// Anything else — another site embedding it to harvest the node
|
||||
// password — is still refused.
|
||||
.header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'self'",
|
||||
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
|
||||
form-action 'self'; frame-ancestors 'self' http://*:* https://*:*",
|
||||
)
|
||||
.body(Body::from(html))
|
||||
.expect("static response builds")
|
||||
@@ -528,7 +792,8 @@ button:hover {{ background:#2f6fd6; }}
|
||||
/// password by an unexplained page.
|
||||
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
|
||||
let body = format!(
|
||||
r#"{icon}
|
||||
r#"<div class="logo"><img src="{prefix}asset/favico-black-v2.svg" alt="Archipelago"></div>
|
||||
{icon}
|
||||
<h1>Sign in to open {name}</h1>
|
||||
<p class="sub">This app is protected by your node password.</p>
|
||||
{err}
|
||||
@@ -579,6 +844,7 @@ mod tests {
|
||||
app_name: "Strfry Relay".to_string(),
|
||||
icon: None,
|
||||
declared: true,
|
||||
session_passthrough: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,11 +902,64 @@ mod tests {
|
||||
assert!(!html.contains("<img src=x"));
|
||||
}
|
||||
|
||||
/// The challenge must be framable by this node's own dashboard — My Apps
|
||||
/// opens apps in an embedded frame, and a blanket `X-Frame-Options: DENY`
|
||||
/// turned every gated app into "app is not responding" (100.82.34.38,
|
||||
/// 2026-08-05). It must still be uncacheable, and still refuse to be
|
||||
/// framed by a foreign origin, which `frame-ancestors` expresses and
|
||||
/// `X-Frame-Options` cannot.
|
||||
#[test]
|
||||
fn challenge_pages_are_not_cacheable_or_framable() {
|
||||
fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() {
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
|
||||
assert_eq!(resp.headers()["X-Frame-Options"], "DENY");
|
||||
assert!(
|
||||
!resp.headers().contains_key("X-Frame-Options"),
|
||||
"X-Frame-Options cannot express 'my own node on another port' — it \
|
||||
blocked the dashboard's own frame"
|
||||
);
|
||||
let csp = resp.headers()["Content-Security-Policy"].to_str().unwrap();
|
||||
assert!(csp.contains("frame-ancestors 'self'"));
|
||||
assert!(csp.contains("form-action 'self'"));
|
||||
}
|
||||
|
||||
/// The login page must render entirely from the gate's own origin: the
|
||||
/// CSP allows no external host, so a background or logo that 404s leaves
|
||||
/// a black page rather than the dashboard's art.
|
||||
#[tokio::test]
|
||||
async fn login_page_sources_its_art_from_the_gate() {
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body).to_string();
|
||||
assert!(html.contains(&format!("{GATE_PREFIX}asset/favico-black-v2.svg")));
|
||||
for name in LOGIN_BACKGROUNDS {
|
||||
assert!(
|
||||
html.contains(&format!("{GATE_PREFIX}asset/{name}")),
|
||||
"background {name} is not referenced"
|
||||
);
|
||||
}
|
||||
// Every referenced asset must be one the gate will actually serve.
|
||||
// The logo is the sidebar A mark (favico-black-v2.svg) since the
|
||||
// 2026-08-05 login-page rework — the old wordmark is off the
|
||||
// allowlist on purpose.
|
||||
assert!(read_ui_asset("favico-black-v2.svg").is_some() || cfg!(not(debug_assertions)));
|
||||
}
|
||||
|
||||
/// The allowlist is the whole security boundary for asset serving: the
|
||||
/// name arrives in a URL and is read before any authentication.
|
||||
#[test]
|
||||
fn asset_serving_refuses_anything_off_the_allowlist() {
|
||||
for name in [
|
||||
"../../../etc/passwd",
|
||||
"/etc/passwd",
|
||||
"db.sqlite3",
|
||||
"manifest.yml",
|
||||
"",
|
||||
] {
|
||||
assert!(
|
||||
read_ui_asset(name).is_none(),
|
||||
"{name} must not be servable by the gate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -682,6 +1001,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The gate must remove ONLY its own cookie pairs: an app's login cookie
|
||||
/// riding the same header has to survive, or every gated app with its
|
||||
/// own auth (vaultwarden, nextcloud, gitea) is logged out on each
|
||||
/// request — the 2026-08-05 companion-UI/"app logged me out" regression.
|
||||
#[test]
|
||||
fn strip_gate_cookies_keeps_app_cookies() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
"session=abc; vw_session=keepme; csrf_token=def; theme=dark"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
strip_gate_cookies(&mut headers);
|
||||
assert_eq!(
|
||||
headers.get(header::COOKIE).unwrap().to_str().unwrap(),
|
||||
"vw_session=keepme; theme=dark"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_gate_cookies_drops_header_when_only_gate_cookies() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
"session=abc; csrf_token=def".parse().unwrap(),
|
||||
);
|
||||
strip_gate_cookies(&mut headers);
|
||||
assert!(headers.get(header::COOKIE).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_gate_cookies_no_header_is_a_noop() {
|
||||
let mut headers = HeaderMap::new();
|
||||
strip_gate_cookies(&mut headers);
|
||||
assert!(headers.get(header::COOKIE).is_none());
|
||||
}
|
||||
|
||||
/// The load-bearing 2FA property: a session still awaiting its TOTP code
|
||||
/// fails `validate()`, so the gate rejects it without knowing anything
|
||||
/// about second factors.
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
Throwaway TLS fixtures for `appgate::tls` unit tests.
|
||||
|
||||
Generated by `openssl req -x509 -nodes` with SANs `localhost`/`127.0.0.1` only.
|
||||
They are **not** any node's identity: a real node's pair lives at
|
||||
`/etc/archipelago/ssl/` and is created by `scripts/setup-node-ca.sh`. Nothing
|
||||
here is trusted by anything, and `other.key` exists purely to prove a
|
||||
mismatched cert/key pair is rejected rather than silently served.
|
||||
|
||||
Regenerate with the command in this directory's git history if they ever
|
||||
expire — `-days 36500` means that should not happen.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDezCCAmOgAwIBAgIUT3u7aR6+q5j3ZITojvEaSt4mVWkwDQYJKoZIhvcNAQEL
|
||||
BQAwPjEZMBcGA1UEAwwQYXJjaGlwZWxhZ28tdGVzdDEhMB8GA1UECgwYQXJjaGlw
|
||||
ZWxhZ28gVGVzdCBGaXh0dXJlMCAXDTI2MDgwNjE4NDcyOFoYDzIxMjYwNzEzMTg0
|
||||
NzI4WjA+MRkwFwYDVQQDDBBhcmNoaXBlbGFnby10ZXN0MSEwHwYDVQQKDBhBcmNo
|
||||
aXBlbGFnbyBUZXN0IEZpeHR1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
|
||||
AoIBAQD6t1PeYAXxQVlLzfqn+6C1NFT609OiJmOx5d9uhKXIg7zu9KCqaRJWCDeJ
|
||||
FBX/UEmWIJjJvB8GzLCzBNYLbcRDcFVGOPvo1SKaBDpFGACiAvkpez7TaRxhm6zK
|
||||
qbUk2iuwm4BlGUGDCTtMxag6N94X/FPtQa2G8uD7D0MGi8lIYg4AGvPw8eKo2btl
|
||||
wzOpUuxT5+SWWtX/wlDA+/YqSUvgbdh1gH/E013dqKPLgwdYuXnQdZ/wBkRLR60T
|
||||
sjYXvCK/xfnZY0BSkMSAQEWkyesKr/nq2oJB8BYIns4npppmgmvaiTl0VMhHmrY5
|
||||
d1JYgHQ9Sgg41zLNtBR/RKU5L64/AgMBAAGjbzBtMB0GA1UdDgQWBBROknlP9RUU
|
||||
DWQLCnXh1bXJtFSfPjAfBgNVHSMEGDAWgBROknlP9RUUDWQLCnXh1bXJtFSfPjAP
|
||||
BgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkq
|
||||
hkiG9w0BAQsFAAOCAQEAzncb5ju1O8Rls4vYspITYPJn5G8Vcc+N1uOnUwQF8ySC
|
||||
MyaSd2TLYz+tyBCZ5JHuh9/gmhzReztarF/UDrDVQocqLn2G0xI7Q3ItYO7kqx0+
|
||||
qWXBa4Qd1ZIYL5Qi4kX8wJBWuym5Ib8XV9dvcFuwxOpXkFZfAH/hTFgs4csTs9Za
|
||||
PulDhQPtUemtcerWoG65C9WplLw1DyitMeWpx/36iyVXBA5T2FIQnKsTtNt1Py1j
|
||||
lsqrN5CTi1N9oZkTqkDjcbF9tqqx3NUCbFsBckMZ2lGizI12TlkGAeDqVPbZuyOj
|
||||
psnc1Nu/EQEzcTYvPHJpMUwUOsJgDb2HWx5FAxy02Q==
|
||||
-----END CERTIFICATE-----
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQD6t1PeYAXxQVlL
|
||||
zfqn+6C1NFT609OiJmOx5d9uhKXIg7zu9KCqaRJWCDeJFBX/UEmWIJjJvB8GzLCz
|
||||
BNYLbcRDcFVGOPvo1SKaBDpFGACiAvkpez7TaRxhm6zKqbUk2iuwm4BlGUGDCTtM
|
||||
xag6N94X/FPtQa2G8uD7D0MGi8lIYg4AGvPw8eKo2btlwzOpUuxT5+SWWtX/wlDA
|
||||
+/YqSUvgbdh1gH/E013dqKPLgwdYuXnQdZ/wBkRLR60TsjYXvCK/xfnZY0BSkMSA
|
||||
QEWkyesKr/nq2oJB8BYIns4npppmgmvaiTl0VMhHmrY5d1JYgHQ9Sgg41zLNtBR/
|
||||
RKU5L64/AgMBAAECggEAYi9ge3JscVZPw6WXd6jN/5jOfOpu844INfeZoDz3dcbN
|
||||
u2D2+LWsVh/iq96/XJzTLKV4YGy5U97ehkUrFA+5MFXyN02CreSmJ93m+f8T5F64
|
||||
uDuJV57O3BTsvvNmOtfsCz5isnUJGGmJnR+9KYuOgSMytPQnInXEkN2huJMO0Ta6
|
||||
5x/rVzKnP+NWfXaUtCmaNgY+uJLk7BlrT6jcL/munR7Llffhw1l1TApIKV61U7Te
|
||||
bGybB/thdXU1JfvkWHMMGBH9wF4FvRJ+WIE542aYuTi57HJ+jgJhL0y0Izvp42On
|
||||
16L3AgZ4E7J3cafb5s52wB1Hf8qtApo7PRWoJQltRQKBgQD/wDDYdvXGcRBQUWH+
|
||||
mCJm6OV82xvBp2mKGPAXwM8cz3VI0eqgeDN4VFzaRbyuoG8mvDIxLAKaSrXAhCF9
|
||||
eP1m6zh45MGVw6Hb7unJOP0Hs1/mT2Yg6OD+JftlW8DrhjU2rJzrAB4cvYM2Mlp+
|
||||
z2jZyTsH5gclqzwu34Hhz8PsqwKBgQD69eF0bsdOTKM3nP/cFRdr8SG1KP6UXNT2
|
||||
0okzHKj+QhYQRGtULEe3PWJtYHYo5elhmqOpcxy4djt4HefdauOIvB6RwQfiNwkq
|
||||
x0ERH9W5ZSw/LxuOuMUNAaAJ4osyymb1o5gLrMdwS1oVVaTF3SS78mZpVs5Ekez+
|
||||
c88t5HXcvQKBgBge4zx3M8TsgvJgSpK9fHkiPAqji6GfDXgl0/cZiy8XbeNZUPyj
|
||||
eY8+vackbqA1p2YK190FXpV4uF2Y2KPB1nxvcNsOECf01H4usUP2KP8h7siE8ofm
|
||||
DtpJcMVlevN7q+clLoOHdk+VnBtvclOFckkgDn43NrNZzApLsC9A7iSTAoGADlY9
|
||||
qwkpGbAHIwY1F72cuO3tnwvYf2FOSUt9yw24Gc5stEE0YHqnHjDDjrwUBAIecxUC
|
||||
hIuu+FrIyvPqaxvQI9+bX3hHmwTJ4UfAz9mhvBWrkXB/gofLuhJ9shLfIOevOhk+
|
||||
dmxIeIHVg6KA50za7GHMt/fdkM1FXMQA8f47PYECgYEAnT147gCKbCQlWyE1Q6Q3
|
||||
LgtGCNbmEW4gPpZnMIDiwBZBqfX2fdQUZhBbANEgx98Dy7fzL18y+ULhqQAHlZmv
|
||||
wj42J35Ni2CCVVh58j2OQBmjhRnuVtbeDkWfF6lrwpdiAS85MZgTSnSrnj3opgx1
|
||||
m+jMknsSIITKIhu6oa1PqvM=
|
||||
-----END PRIVATE KEY-----
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCy2KgVkOYSz0QO
|
||||
QxXA0ENomMr0Butuh4Yv5KT9RzrTxrsf/GfiJPX5fjtANwUXniojMNClxLGGep5v
|
||||
55Sy0wXgj1HHX00eeWfMIW3A7pYKy2geM3gY3/Xull7Ny2A1+aa4XzK9jIZXqkLj
|
||||
zSd+zdkkrxa0JTdv/kVFX198pvx5w79KBx706NLgY8T6YqAIerturvwclL3uWvYm
|
||||
kB2CirwYAR4XO+6RxAqe+msjxn877h5bUSxwtfL7OzdcuyilGBG2FeB9FSm7r7h2
|
||||
zLJcch3WCwHBWbLK6n5pprrYXLFgTJjC/VlNjGmv9ZAG7HPnAw9J0Ck+JT3s+7mf
|
||||
pWiNzPePAgMBAAECggEANcLsEAONLcVRY2omHV5djRE1HRMBbanenAIC2MIzPFsG
|
||||
gDB7N989c8DO5dhENxvL9eUkK1iLtu2gN+po6DKIFz9t6V1MDOeY3KOF3xO5Vchc
|
||||
ZYu6Q9v7DTv1hq5mnwMLa2vukE0wSyT604iloTgW2LCrRf7UAd3xC9AGH64Awkcl
|
||||
TxWeuXDf1Z9ndTXwTcyWJwxs69eDhxHJdNi8Pit0sowuQJMsmj+uxWsAXb5DvmHV
|
||||
HxihzZ8tQpq7ZCuJBcpqcYZ3/XYxfYcGez42+1nIUHtcIaywQCZUk3WmL3wxEMRA
|
||||
N5LoJuI1a6EYNRZdtwmD3aoNwOapPSIeIyf1AuVV8QKBgQDZABBmxMecLq1sYYjG
|
||||
2vaS2aHtg4qaeoQV97vkbOceNHX54gCi/Oj6ocm+jKDoNG0LRITBTMc0fivpccUu
|
||||
dNnW7niTQFUqQ3XS7ONMUbMZNUaiiYaQu2Pzsvq+FVDbLD0VVIqd4mQFNY8wOAMi
|
||||
VImPvFUuV2tBW9Od/bZTAIP4kQKBgQDS/SxRc7NJ7sb8D6LKQcUN3RQ6/Yi9caBN
|
||||
+PbC7rLALM8CIFStiSTVH0jO1aEwLoNSlOG7IBLOPaVxp3sauqs2VHHLrPS3ter0
|
||||
UQt5WDdsgNtJVAZ9GKw10pZ5EQJHTxDVIyFAyOpkLm1DdUsRCShheW5HaFRGrYhA
|
||||
XV3hYxL+HwKBgFGNepyE29fQmxCeXz8Mz5pE/Fw9EXwZC0cOQakJXJq3cJcm3sJi
|
||||
dlSrNRzN0TMzcL/JUnMrHbqWqH4lacuZ0ry6BsqgZOFrVP6eVJY8JikVIqS3NsFy
|
||||
C5Bs9Vs2u5qDN7mqeiX4DUr/4/5lLphaWRCR4Rl3dTGtBwzbawgqq25hAoGAEQOz
|
||||
oDnpWmv0Bf2ozhCxuGV8rSkm7sgL+l26YIvpRFAYvX4n9fqaSsmEEJHvtrf5hR5W
|
||||
ecWjXphgECNGbShiiDYVGyyua2YzNVKXz0hK5+gYRviMsWfc81YxJkA149Q/ckCr
|
||||
/NJ2/G82Bnud+xi29e1Z9E44hZ6W30HoQTXBIVcCgYApBXtQzue+jSRZXhpgw+ps
|
||||
9H7eTHsA6zsxtqk4O/tijkkcsv+LepJ81nJNN8G4aqbdAb132w5bHqh9ir0DFtKj
|
||||
2Eqae15OFYKfYV83TOAcc/IW3aZi8jkNyux08k43gIn3Lzo5T09jUSFFV5FazVNi
|
||||
RxnrHeKUcS43Z346QXYrsg==
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,393 @@
|
||||
//! TLS for gated app ports, alongside plain HTTP on the same socket.
|
||||
//!
|
||||
//! # Why both, on one port
|
||||
//!
|
||||
//! An app port has to serve whatever the browser asks for. A node whose
|
||||
//! dashboard is plain HTTP embeds `http://host:PORT`; a node with HTTPS embeds
|
||||
//! `https://host:PORT` — and an HTTPS page cannot embed an HTTP frame at all
|
||||
//! (mixed content), so the choice is genuinely per-node, not per-fleet. Giving
|
||||
//! TLS its own port number would mean every app declares a second port, every
|
||||
//! manifest changes, and torrc doubles. Instead the gate peeks the first byte:
|
||||
//! a TLS ClientHello starts with `0x16` (handshake) and no HTTP method does, so
|
||||
//! the two are distinguishable without consuming anything.
|
||||
//!
|
||||
//! `peek` is what makes this safe — it leaves the bytes in the socket buffer,
|
||||
//! so the TLS acceptor still sees a complete, untouched ClientHello.
|
||||
//!
|
||||
//! # Why reload, rather than load once
|
||||
//!
|
||||
//! `scripts/setup-node-ca.sh` reissues the leaf whenever the node gains an
|
||||
//! address (DHCP, Tailscale coming up, the fips0 ULA appearing late) — the same
|
||||
//! churn the bind sweep exists for. A config parsed once at startup would keep
|
||||
//! serving a certificate that omits the address the user is actually on, and
|
||||
//! the failure is a browser-side name mismatch that no node-side log would
|
||||
//! explain. So the mtime of both files is checked and the config rebuilt when
|
||||
//! either moves.
|
||||
//!
|
||||
//! # Absent certificates are not an error
|
||||
//!
|
||||
//! A node that has never run the CA script has no certificate. That node serves
|
||||
//! plain HTTP exactly as before and is fully functional — TLS is an upgrade,
|
||||
//! not a requirement — so a missing file is logged once at debug, not warn.
|
||||
//! What IS logged at warn is a certificate that exists but cannot be parsed:
|
||||
//! that is a misconfiguration the operator can act on, and silently falling
|
||||
//! back to plain HTTP would hide it.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_rustls::rustls::{Certificate, PrivateKey, ServerConfig};
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Where `setup-node-ca.sh` writes the node's leaf. Same pair nginx serves, so
|
||||
/// the dashboard and the app ports present one identity and a single trusted
|
||||
/// CA covers both.
|
||||
const DEFAULT_CERT: &str = "/etc/archipelago/ssl/archipelago.crt";
|
||||
const DEFAULT_KEY: &str = "/etc/archipelago/ssl/archipelago.key";
|
||||
|
||||
/// First byte of a TLS record of type `handshake` (22). No HTTP request can
|
||||
/// begin with it: methods are uppercase ASCII letters, so the two wire formats
|
||||
/// are unambiguous from a single byte.
|
||||
pub const TLS_HANDSHAKE_FIRST_BYTE: u8 = 0x16;
|
||||
|
||||
/// Does this look like the start of a TLS connection rather than plain HTTP?
|
||||
pub fn looks_like_tls(first: u8) -> bool {
|
||||
first == TLS_HANDSHAKE_FIRST_BYTE
|
||||
}
|
||||
|
||||
/// Lazily-built, mtime-invalidated TLS config for the gate.
|
||||
pub struct GateTls {
|
||||
cert_path: PathBuf,
|
||||
key_path: PathBuf,
|
||||
cached: RwLock<Option<Cached>>,
|
||||
}
|
||||
|
||||
struct Cached {
|
||||
acceptor: TlsAcceptor,
|
||||
stamp: Stamp,
|
||||
}
|
||||
|
||||
/// Modification times of both halves. Compared as a pair because reissuing
|
||||
/// writes the certificate and the key separately — keying on only one would
|
||||
/// serve a certificate that no longer matches its key.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
struct Stamp {
|
||||
cert: SystemTime,
|
||||
key: SystemTime,
|
||||
}
|
||||
|
||||
impl GateTls {
|
||||
pub fn new() -> Self {
|
||||
Self::with_paths(DEFAULT_CERT, DEFAULT_KEY)
|
||||
}
|
||||
|
||||
pub fn with_paths(cert: impl Into<PathBuf>, key: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
cert_path: cert.into(),
|
||||
key_path: key.into(),
|
||||
cached: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current acceptor, rebuilding it if the files changed underneath.
|
||||
///
|
||||
/// `None` means this node has no usable certificate and app ports stay
|
||||
/// plain HTTP. Callers must treat that as ordinary, not as a failure.
|
||||
pub async fn acceptor(&self) -> Option<TlsAcceptor> {
|
||||
let stamp = self.stamp().await?;
|
||||
|
||||
if let Some(c) = self.cached.read().await.as_ref() {
|
||||
if c.stamp == stamp {
|
||||
return Some(c.acceptor.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild. Re-check under the write lock so concurrent connections
|
||||
// during a reissue do not each parse the same files.
|
||||
let mut guard = self.cached.write().await;
|
||||
if let Some(c) = guard.as_ref() {
|
||||
if c.stamp == stamp {
|
||||
return Some(c.acceptor.clone());
|
||||
}
|
||||
}
|
||||
|
||||
match load_config(&self.cert_path, &self.key_path).await {
|
||||
Ok(config) => {
|
||||
let acceptor = TlsAcceptor::from(Arc::new(config));
|
||||
debug!(
|
||||
cert = %self.cert_path.display(),
|
||||
"app gate loaded its TLS certificate"
|
||||
);
|
||||
*guard = Some(Cached {
|
||||
acceptor: acceptor.clone(),
|
||||
stamp,
|
||||
});
|
||||
Some(acceptor)
|
||||
}
|
||||
Err(e) => {
|
||||
// A present-but-broken certificate is an operator-actionable
|
||||
// misconfiguration; do not let it pass quietly as "no TLS".
|
||||
warn!(
|
||||
cert = %self.cert_path.display(),
|
||||
error = %e,
|
||||
"app gate could not load its TLS certificate — app ports stay plain HTTP"
|
||||
);
|
||||
// Cache the failure against this stamp so a broken file is not
|
||||
// re-parsed on every single connection.
|
||||
*guard = None;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stamp(&self) -> Option<Stamp> {
|
||||
let cert = mtime(&self.cert_path).await?;
|
||||
let key = mtime(&self.key_path).await?;
|
||||
Some(Stamp { cert, key })
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GateTls {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
async fn mtime(path: &Path) -> Option<SystemTime> {
|
||||
tokio::fs::metadata(path).await.ok()?.modified().ok()
|
||||
}
|
||||
|
||||
async fn load_config(cert_path: &Path, key_path: &Path) -> io::Result<ServerConfig> {
|
||||
let cert_pem = tokio::fs::read(cert_path).await?;
|
||||
let key_pem = tokio::fs::read(key_path).await?;
|
||||
build_config(&cert_pem, &key_pem)
|
||||
}
|
||||
|
||||
/// Split out from the filesystem so it can be tested against bytes directly.
|
||||
pub(crate) fn build_config(cert_pem: &[u8], key_pem: &[u8]) -> io::Result<ServerConfig> {
|
||||
let certs: Vec<Certificate> = rustls_pemfile::certs(&mut &cert_pem[..])?
|
||||
.into_iter()
|
||||
.map(Certificate)
|
||||
.collect();
|
||||
if certs.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"no certificates in PEM",
|
||||
));
|
||||
}
|
||||
|
||||
let key = read_key(key_pem)?;
|
||||
|
||||
// rustls does NOT check that the key matches the certificate — verified by
|
||||
// test, not assumed: `with_single_cert` accepts a pair from two different
|
||||
// keys and only fails later, mid-handshake, in someone's browser. That is
|
||||
// precisely the silently-broken-security-control shape this module exists
|
||||
// to avoid, so prove the pairing here and refuse to serve otherwise.
|
||||
ensure_key_matches_cert(&certs[0], &key)?;
|
||||
|
||||
ServerConfig::builder()
|
||||
.with_safe_defaults()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
/// Sign a fixed message with the private key and verify it with the public key
|
||||
/// inside the certificate. They pair iff the verification succeeds.
|
||||
fn ensure_key_matches_cert(cert: &Certificate, key: &PrivateKey) -> io::Result<()> {
|
||||
use tokio_rustls::rustls::sign;
|
||||
|
||||
let signing_key = sign::any_supported_type(key)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "unsupported private key type"))?;
|
||||
|
||||
// Any scheme the key supports will do — this proves possession, it is not
|
||||
// negotiating anything. Offer the full set and let rustls pick.
|
||||
const ALL_SCHEMES: &[tokio_rustls::rustls::SignatureScheme] = {
|
||||
use tokio_rustls::rustls::SignatureScheme as S;
|
||||
&[
|
||||
S::ECDSA_NISTP256_SHA256,
|
||||
S::ECDSA_NISTP384_SHA384,
|
||||
S::ED25519,
|
||||
S::RSA_PSS_SHA256,
|
||||
S::RSA_PSS_SHA384,
|
||||
S::RSA_PSS_SHA512,
|
||||
S::RSA_PKCS1_SHA256,
|
||||
S::RSA_PKCS1_SHA384,
|
||||
S::RSA_PKCS1_SHA512,
|
||||
]
|
||||
};
|
||||
let signer = signing_key
|
||||
.choose_scheme(ALL_SCHEMES)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no usable signature scheme"))?;
|
||||
|
||||
const PROOF: &[u8] = b"archipelago app gate certificate pairing check";
|
||||
let signature = signer
|
||||
.sign(PROOF)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
let end_entity = webpki::EndEntityCert::try_from(cert.0.as_slice())
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("bad certificate: {e}")))?;
|
||||
|
||||
let alg: &webpki::SignatureAlgorithm = match signer.scheme() {
|
||||
tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA256 => {
|
||||
&webpki::RSA_PKCS1_2048_8192_SHA256
|
||||
}
|
||||
tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA384 => {
|
||||
&webpki::RSA_PKCS1_2048_8192_SHA384
|
||||
}
|
||||
tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA512 => {
|
||||
&webpki::RSA_PKCS1_2048_8192_SHA512
|
||||
}
|
||||
tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA256 => {
|
||||
&webpki::RSA_PSS_2048_8192_SHA256_LEGACY_KEY
|
||||
}
|
||||
tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA384 => {
|
||||
&webpki::RSA_PSS_2048_8192_SHA384_LEGACY_KEY
|
||||
}
|
||||
tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA512 => {
|
||||
&webpki::RSA_PSS_2048_8192_SHA512_LEGACY_KEY
|
||||
}
|
||||
tokio_rustls::rustls::SignatureScheme::ECDSA_NISTP256_SHA256 => &webpki::ECDSA_P256_SHA256,
|
||||
tokio_rustls::rustls::SignatureScheme::ECDSA_NISTP384_SHA384 => &webpki::ECDSA_P384_SHA384,
|
||||
tokio_rustls::rustls::SignatureScheme::ED25519 => &webpki::ED25519,
|
||||
// An unrecognised scheme must not silently skip the check.
|
||||
other => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("cannot verify key/certificate pairing for scheme {other:?}"),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
end_entity
|
||||
.verify_signature(alg, PROOF, &signature)
|
||||
.map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"private key does not match the certificate",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Accept PKCS#8 or PKCS#1. `setup-node-ca.sh` emits PKCS#8, but a key that
|
||||
/// predates it (or was generated by hand) may be PKCS#1, and refusing that
|
||||
/// would be a silent downgrade to plain HTTP on an already-working node.
|
||||
fn read_key(key_pem: &[u8]) -> io::Result<PrivateKey> {
|
||||
if let Some(k) = rustls_pemfile::pkcs8_private_keys(&mut &key_pem[..])?
|
||||
.into_iter()
|
||||
.next()
|
||||
{
|
||||
return Ok(PrivateKey(k));
|
||||
}
|
||||
if let Some(k) = rustls_pemfile::rsa_private_keys(&mut &key_pem[..])?
|
||||
.into_iter()
|
||||
.next()
|
||||
{
|
||||
return Ok(PrivateKey(k));
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"no PKCS#8 or PKCS#1 private key in PEM",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Generated by scripts/setup-node-ca.sh's own openssl invocation, so these
|
||||
// exercise the exact shape the node produces.
|
||||
const CERT: &[u8] = include_bytes!("testdata/leaf.crt");
|
||||
const KEY: &[u8] = include_bytes!("testdata/leaf.key");
|
||||
|
||||
#[test]
|
||||
fn a_tls_client_hello_is_distinguishable_from_every_http_method() {
|
||||
assert!(looks_like_tls(0x16));
|
||||
// Every HTTP method starts with an uppercase letter; none is 0x16.
|
||||
for m in ["GET", "POST", "PUT", "HEAD", "OPTIONS", "DELETE", "PATCH"] {
|
||||
assert!(
|
||||
!looks_like_tls(m.as_bytes()[0]),
|
||||
"{m} misread as a TLS handshake"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_a_config_from_the_nodes_own_cert_and_key() {
|
||||
assert!(build_config(CERT, KEY).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cert_without_its_matching_key_is_rejected_not_ignored() {
|
||||
// Key from a different pair: rustls must refuse rather than serve a
|
||||
// certificate it cannot prove ownership of.
|
||||
let other = build_config(CERT, OTHER_KEY);
|
||||
assert!(other.is_err(), "mismatched cert/key pair was accepted");
|
||||
}
|
||||
const OTHER_KEY: &[u8] = include_bytes!("testdata/other.key");
|
||||
|
||||
#[test]
|
||||
fn empty_pem_is_an_error_rather_than_an_empty_chain() {
|
||||
assert!(build_config(b"", KEY).is_err());
|
||||
assert!(build_config(CERT, b"").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_node_without_certificates_reports_no_acceptor() {
|
||||
let tls = GateTls::with_paths(
|
||||
"/nonexistent/archipelago.crt",
|
||||
"/nonexistent/archipelago.key",
|
||||
);
|
||||
assert!(tls.acceptor().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_acceptor_is_built_and_then_served_from_cache() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cert = dir.path().join("c.crt");
|
||||
let key = dir.path().join("c.key");
|
||||
tokio::fs::write(&cert, CERT).await.unwrap();
|
||||
tokio::fs::write(&key, KEY).await.unwrap();
|
||||
|
||||
let tls = GateTls::with_paths(&cert, &key);
|
||||
assert!(tls.acceptor().await.is_some());
|
||||
// Second call hits the cache; the observable contract is simply that it
|
||||
// still yields an acceptor.
|
||||
assert!(tls.acceptor().await.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reissued_certificate_is_picked_up_without_a_restart() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cert = dir.path().join("c.crt");
|
||||
let key = dir.path().join("c.key");
|
||||
tokio::fs::write(&cert, CERT).await.unwrap();
|
||||
tokio::fs::write(&key, KEY).await.unwrap();
|
||||
|
||||
let tls = GateTls::with_paths(&cert, &key);
|
||||
assert!(tls.acceptor().await.is_some());
|
||||
let first = *tls.cached.read().await.as_ref().map(|c| &c.stamp).unwrap();
|
||||
|
||||
// Reissue with a distinctly later mtime, the way the CA script does
|
||||
// when the node gains an address. Set explicitly rather than relying on
|
||||
// wall-clock advancing, because a same-second rewrite can land on an
|
||||
// identical mtime on coarse-granularity filesystems and make this pass
|
||||
// or fail by luck.
|
||||
tokio::fs::write(&cert, CERT).await.unwrap();
|
||||
let later = SystemTime::now() + std::time::Duration::from_secs(5);
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&cert)
|
||||
.unwrap()
|
||||
.set_modified(later)
|
||||
.unwrap();
|
||||
|
||||
assert!(tls.acceptor().await.is_some());
|
||||
let second = *tls.cached.read().await.as_ref().map(|c| &c.stamp).unwrap();
|
||||
assert_ne!(first, second, "reissued certificate was not reloaded");
|
||||
}
|
||||
}
|
||||
@@ -154,9 +154,9 @@ pub async fn ensure_doctor_installed() {
|
||||
}
|
||||
match run_bitcoin_rpc_repair().await {
|
||||
Ok(true) => {
|
||||
info!("Repaired Bitcoin RPC bind settings; running Bitcoin containers left untouched")
|
||||
info!("Removed stale bitcoin.conf; running Bitcoin containers left untouched")
|
||||
}
|
||||
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
|
||||
Ok(false) => debug!("No stale bitcoin.conf found"),
|
||||
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_apps_dir_repair().await {
|
||||
@@ -621,52 +621,30 @@ exit 2
|
||||
}
|
||||
|
||||
async fn run_bitcoin_rpc_repair() -> Result<bool> {
|
||||
// Older installs can have a container-owned bitcoin.conf with only rpcauth
|
||||
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
|
||||
// without a manual uninstall/reinstall. Bind/port stay in the container
|
||||
// command line to avoid duplicate RPC endpoint definitions.
|
||||
// bitcoind is launched with -conf=/tmp/rpc.conf and never reads a
|
||||
// datadir bitcoin.conf (apps/bitcoin-core & bitcoin-knots manifest.yml,
|
||||
// commit a597c1d9 — bind/port live only on the container command line).
|
||||
// A leftover file from an older install makes Bitcoin Core's own
|
||||
// datadir-conflict safety check refuse to start on every subsequent
|
||||
// start. Remove it instead of "repairing" it into existence — this
|
||||
// previously wrote server=/rpcbind=/rpcallowip=/listen= into the file,
|
||||
// which is exactly what caused the conflict.
|
||||
let script = r#"
|
||||
set -eu
|
||||
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
|
||||
[ -f "$conf" ] || exit 0
|
||||
changed=0
|
||||
ensure_line() {
|
||||
line="$1"
|
||||
key="${line%%=*}"
|
||||
if ! grep -q "^${key}=" "$conf"; then
|
||||
printf '%s\n' "$line" >> "$conf"
|
||||
changed=1
|
||||
fi
|
||||
}
|
||||
ensure_line server=1
|
||||
# rpcbind=0.0.0.0 is required inside the container: with rpcallowip set but
|
||||
# no rpcbind, bitcoind binds RPC to the container's loopback only and every
|
||||
# dial over the container network (LND, bitcoin-ui) is refused — the fresh-
|
||||
# install "LND took 5 attempts" / bitcoin-rpc 502 failure (host publish stays
|
||||
# 127.0.0.1-only, so exposure is unchanged).
|
||||
ensure_line rpcbind=0.0.0.0
|
||||
ensure_line rpcallowip=0.0.0.0/0
|
||||
ensure_line listen=1
|
||||
# Log-volume fix: printtoconsole=1 duplicated every log line (incl. per-block
|
||||
# IBD "UpdateTip" spam) into journald via conmon on top of the datadir
|
||||
# debug.log bitcoind already writes. Console off; debug.log stays (bitcoind
|
||||
# self-shrinks it on restart).
|
||||
if grep -q '^printtoconsole=1' "$conf"; then
|
||||
sed -i 's/^printtoconsole=1$/printtoconsole=0/' "$conf"
|
||||
changed=1
|
||||
fi
|
||||
[ "$changed" -eq 0 ] && exit 0
|
||||
mv "$conf" "$conf.disabled-$(date +%s)"
|
||||
exit 2
|
||||
"#;
|
||||
let status = host_sudo(&["sh", "-lc", script])
|
||||
.await
|
||||
.context("repair bitcoin.conf RPC bind settings")?;
|
||||
.context("remove stale bitcoin.conf RPC bind settings")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
// Do not restart Bitcoin from bootstrap. During IBD, an automatic
|
||||
// restart can cost hours of progress. The repaired file is only a
|
||||
// fallback for future starts; current containers keep their command-line
|
||||
// RPC args until an operator or update intentionally restarts them.
|
||||
// restart can cost hours of progress. Removing the stale file is
|
||||
// only a fallback for future starts; current containers keep their
|
||||
// command-line RPC args regardless.
|
||||
Some(2) => Ok(true),
|
||||
_ => {
|
||||
warn!("Bitcoin RPC repair helper exited with {}", status);
|
||||
@@ -1293,3 +1271,54 @@ mod tests {
|
||||
assert_ne!(outcome, PodmanHealOutcome::Healthy);
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair this node's own systemd restart policy.
|
||||
///
|
||||
/// The in-process updater replaces the binary and then asks systemd to
|
||||
/// restart the service, treating `Restart=always` on the unit as its second
|
||||
/// net if that request is ever lost. On austin-sapien (2026-08-05) the unit
|
||||
/// was an old one carrying `Restart=on-failure`: the daemon exited cleanly
|
||||
/// (status 0), systemd read that as success, and the node sat dead for over
|
||||
/// two hours after a routine update — "server starting" in the UI, with
|
||||
/// nothing to start it.
|
||||
///
|
||||
/// A node cannot be relied on to fix this via `self-update.sh` (which does
|
||||
/// refresh units) because the in-process update path never runs it. So the
|
||||
/// daemon checks its own unit at boot: any node that starts even once ends
|
||||
/// up with a policy that survives the next update. Deliberately narrow —
|
||||
/// only the `Restart=` line is touched, so local edits elsewhere in the unit
|
||||
/// are preserved.
|
||||
pub async fn ensure_restart_policy() {
|
||||
const UNIT: &str = "/etc/systemd/system/archipelago.service";
|
||||
let Ok(body) = fs::read_to_string(UNIT).await else {
|
||||
return; // not a systemd install (container, dev box) — nothing to do
|
||||
};
|
||||
if !body.lines().any(|l| {
|
||||
let l = l.trim();
|
||||
l.starts_with("Restart=") && l != "Restart=always"
|
||||
}) {
|
||||
return; // already correct, or no Restart= line to repair
|
||||
}
|
||||
let patched: String = body
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.trim().starts_with("Restart=") && l.trim() != "Restart=always" {
|
||||
"Restart=always"
|
||||
} else {
|
||||
l
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
match write_root_if_needed(UNIT, &patched).await {
|
||||
Ok(true) => {
|
||||
tracing::warn!(
|
||||
"repaired archipelago.service Restart= policy to always — this node would \
|
||||
have stayed dead after an in-process update"
|
||||
);
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(error = %e, "could not repair archipelago.service restart policy"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +256,33 @@ pub fn catalog_manifest_overlay(
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Like [`catalog_manifest_overlay`] but WITHOUT the build-source refusal —
|
||||
/// for PORT CLASSIFICATION only, never for install/orchestration.
|
||||
///
|
||||
/// The on-node-built companion UIs (lnd-ui, bitcoin-ui, electrs-ui, fips-ui)
|
||||
/// are exactly the apps whose port policy (auth/bind/session_passthrough)
|
||||
/// must reach the gate reliably, yet their build sources made the overlay
|
||||
/// defer to DISK manifests — whose only delivery paths (frontend runtime
|
||||
/// payload, per-node repo copies) proved stale or absent across the fleet in
|
||||
/// the v1.7.125 rollout: nodes served ungated UIs or 401-dead panels until
|
||||
/// hand-fixed. The signed catalog is fresher and operator-signed; and the
|
||||
/// gate's address binds fail safely on conflict with a container that
|
||||
/// publishes differently (logged as CANNOT PROTECT), so classifying from the
|
||||
/// catalog cannot open anything the running container hasn't already opened.
|
||||
pub fn catalog_manifest_ports_overlay(
|
||||
app_id: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Option<archipelago_container::manifest::AppManifest> {
|
||||
let m: archipelago_container::manifest::AppManifest = serde_json::from_value(value).ok()?;
|
||||
if m.app.id != app_id {
|
||||
return None;
|
||||
}
|
||||
if m.validate().is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// The catalog's default/latest version string for an app (the top-level
|
||||
/// `version` field), if covered. Used to decide whether an install-time
|
||||
/// selection should pin (older) or track-latest (default).
|
||||
|
||||
@@ -214,10 +214,59 @@ pub async fn install_one(spec: &CompanionSpec) -> Result<()> {
|
||||
}
|
||||
// Start is idempotent — if already running, systemctl returns 0.
|
||||
quadlet::enable_now(&unit.service_name()).await?;
|
||||
|
||||
// A rebuilt image does NOT reach a container that is already running.
|
||||
// `ensure_image_present` rebuilds in place under the same tag, so the unit
|
||||
// body is byte-identical, `write_if_changed` reports no change, and
|
||||
// `enable_now` is a no-op on a running service — the container keeps the
|
||||
// old layers indefinitely. That is exactly how archi-dev-box kept serving
|
||||
// the LND, FIPS, Electrs and Guardian screens on 0.0.0.0 after v1.7.123
|
||||
// rebuilt every one of those images to bind loopback: the images were
|
||||
// correct on disk and the running containers were three days old
|
||||
// (2026-08-05). Compare image IDs and restart when they diverge.
|
||||
if let Some(running) = container_image_id(spec.name).await {
|
||||
if let Some(built) = image_id(&image).await {
|
||||
if running != built {
|
||||
info!(
|
||||
companion = spec.name,
|
||||
"running container uses a stale image; restarting onto the rebuilt one"
|
||||
);
|
||||
quadlet::restart_service(&unit.service_name()).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(companion = spec.name, "companion started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Image ID a container is actually running, or `None` when it does not exist.
|
||||
async fn container_image_id(name: &str) -> Option<String> {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{.Image}}"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!id.is_empty()).then_some(id)
|
||||
}
|
||||
|
||||
/// Current ID behind an image reference, or `None` when absent.
|
||||
async fn image_id(image_ref: &str) -> Option<String> {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["image", "inspect", image_ref, "--format", "{{.Id}}"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!id.is_empty()).then_some(id)
|
||||
}
|
||||
|
||||
/// Build companion image locally if a Dockerfile exists, otherwise
|
||||
/// pull from the lfg2025 registry. Returns the image ref the quadlet
|
||||
/// should reference (`localhost/<base>:latest` for build, registry
|
||||
|
||||
@@ -104,6 +104,32 @@ fn dependency_manifests_required_by_active_apps<'a>(
|
||||
required
|
||||
}
|
||||
|
||||
/// Whether `app_id` is a member of a known multi-container stack that has at
|
||||
/// least one OTHER member with a live container (any state). A live sibling
|
||||
/// proves the stack is installed on this node, so an absent member is a hole
|
||||
/// to repair — while a stack with no containers at all stays untouched
|
||||
/// (uninstalled, or never installed here). Sibling app ids resolve to
|
||||
/// container names through the loaded-manifest map when available (immich's
|
||||
/// `immich-postgres` app id runs as container `immich_postgres`), falling
|
||||
/// back to the id itself.
|
||||
fn absent_stack_member_with_live_sibling(
|
||||
app_id: &str,
|
||||
present_containers: &HashSet<String>,
|
||||
container_name_by_app_id: &std::collections::HashMap<String, String>,
|
||||
) -> bool {
|
||||
let stack = crate::app_ops::owning_package(app_id);
|
||||
let members = crate::app_ops::stack_member_app_ids(stack);
|
||||
members.iter().any(|member| {
|
||||
*member != app_id
|
||||
&& present_containers.contains(
|
||||
container_name_by_app_id
|
||||
.get(*member)
|
||||
.map(String::as_str)
|
||||
.unwrap_or(member),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn manifest_dependency_app_ids(manifest: &AppManifest) -> Vec<String> {
|
||||
manifest
|
||||
.app
|
||||
@@ -246,10 +272,10 @@ fn build_fingerprint_stamp_path(data_dir: &Path, tag: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||
let uid = uid_gid
|
||||
let (uid, gid) = uid_gid
|
||||
.split_once(':')
|
||||
.and_then(|(uid, _)| uid.parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
.map(|(u, g)| (u.parse::<u32>().unwrap_or(0), g.parse::<u32>().unwrap_or(0)))
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
if uid > 0 && uid < 100_000 {
|
||||
let output = tokio::process::Command::new("podman")
|
||||
@@ -262,9 +288,22 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
let status = host_sudo(&["chown", "-R", uid_gid, path])
|
||||
// Host-side fallback. A CONTAINER-namespace id must be translated into
|
||||
// the subuid range first: `sudo chown 999` writes literal host uid 999,
|
||||
// which maps to nobody inside the userns — the app then can't open its
|
||||
// own files while the chown reported success (botfights SQLITE_CANTOPEN
|
||||
// crash-loop, framework-pt 2026-08-06). Container uid N (N>=1) lives at
|
||||
// subuid_base + N - 1; the fleet provisions base 100000. uid 0 and
|
||||
// already-mapped ids (>=100000) pass through untouched.
|
||||
let host_uid_gid = if uid > 0 && uid < 100_000 {
|
||||
let map = |id: u32| if id == 0 { 1000 } else { 100_000 + id - 1 };
|
||||
format!("{}:{}", map(uid), map(gid))
|
||||
} else {
|
||||
uid_gid.to_string()
|
||||
};
|
||||
let status = host_sudo(&["chown", "-R", &host_uid_gid, path])
|
||||
.await
|
||||
.with_context(|| format!("sudo chown -R {uid_gid} {path}"))?;
|
||||
.with_context(|| format!("sudo chown -R {host_uid_gid} {path}"))?;
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1654,13 +1693,16 @@ impl ProdContainerOrchestrator {
|
||||
// app whose container vanished (e.g. a wedged teardown cleared by a
|
||||
// reboot) instead of leaving it down. See the immich .198 incident.
|
||||
let was_running = crate::crash_recovery::load_last_running_names(&self.data_dir).await;
|
||||
let manifests: Vec<LoadedManifest> = {
|
||||
let (manifests, container_name_by_app_id): (
|
||||
Vec<LoadedManifest>,
|
||||
std::collections::HashMap<String, String>,
|
||||
) = {
|
||||
let state = self.state.read().await;
|
||||
let dependency_required = dependency_manifests_required_by_active_apps(
|
||||
state.manifests.values().map(|lm| &lm.manifest),
|
||||
&user_stopped,
|
||||
);
|
||||
state
|
||||
let filtered = state
|
||||
.manifests
|
||||
.iter()
|
||||
.filter(|(app_id, _)| !state.disabled.contains(*app_id))
|
||||
@@ -1670,8 +1712,25 @@ impl ProdContainerOrchestrator {
|
||||
&& !user_stopped.contains(&compute_container_name(&lm.manifest)))
|
||||
})
|
||||
.map(|(_, lm)| lm.clone())
|
||||
.collect()
|
||||
.collect();
|
||||
// Unfiltered id→container-name map for the absent-stack-member
|
||||
// recovery below: a sibling may be excluded from this pass (e.g.
|
||||
// user-stopped) yet its live container still proves the stack is
|
||||
// installed.
|
||||
let names = state
|
||||
.manifests
|
||||
.iter()
|
||||
.map(|(id, lm)| (id.clone(), compute_container_name(&lm.manifest)))
|
||||
.collect();
|
||||
(filtered, names)
|
||||
};
|
||||
// Live container names (any state), for the same recovery check.
|
||||
let present_containers: std::collections::HashSet<String> = self
|
||||
.runtime
|
||||
.list_containers()
|
||||
.await
|
||||
.map(|cs| cs.into_iter().map(|c| c.name).collect())
|
||||
.unwrap_or_default();
|
||||
let mut report = ReconcileReport::default();
|
||||
let disk_gb = self.disk_gb().await;
|
||||
// Register every candidate before the (sequential, possibly slow)
|
||||
@@ -1738,7 +1797,20 @@ impl ProdContainerOrchestrator {
|
||||
Ok(ReconcileAction::Left(reason))
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
&& reason == "absent"
|
||||
&& was_running.contains(&compute_container_name(&lm.manifest)) =>
|
||||
&& (was_running.contains(&compute_container_name(&lm.manifest))
|
||||
// Absent STACK MEMBER whose siblings have live
|
||||
// containers: the stack is installed, so the
|
||||
// missing member is a hole, not a choice. The
|
||||
// was_running snapshot ages out after a few daemon
|
||||
// restarts, which left indeedhub-minio/-postgres
|
||||
// permanently absent on .38 (2026-08-06) — nginx
|
||||
// down on `host not found in upstream "minio"`
|
||||
// with nothing ever recreating the members.
|
||||
|| absent_stack_member_with_live_sibling(
|
||||
&app_id,
|
||||
&present_containers,
|
||||
&container_name_by_app_id,
|
||||
)) =>
|
||||
{
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
@@ -1754,7 +1826,10 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
Ok(action) => report.record(&app_id, action),
|
||||
Err(e) => {
|
||||
tracing::error!(app_id = %app_id, error = %e, "reconcile failed");
|
||||
// `{:#}` prints the whole anyhow chain — `%e` alone showed
|
||||
// only the outer context ("create_container X") and hid
|
||||
// the actual libpod error for days.
|
||||
tracing::error!(app_id = %app_id, error = %format!("{e:#}"), "reconcile failed");
|
||||
report.failures.push((app_id, e.to_string()));
|
||||
}
|
||||
}
|
||||
@@ -4440,6 +4515,7 @@ mod tests {
|
||||
bind: String::new(),
|
||||
auth: None,
|
||||
auth_rationale: None,
|
||||
session_passthrough: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4447,6 +4523,61 @@ mod tests {
|
||||
items.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// The .38 indeedhub incident class: an absent stack member must be
|
||||
/// recovered when its siblings have live containers (the stack is
|
||||
/// installed), and left alone when the whole stack is gone or the app
|
||||
/// is not a stack member at all.
|
||||
#[test]
|
||||
fn absent_stack_member_recovery_requires_a_live_sibling() {
|
||||
let present: HashSet<String> = ["indeedhub-redis", "indeedhub-relay", "indeedhub"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let names = std::collections::HashMap::new();
|
||||
// Missing members of a stack with live siblings → recover.
|
||||
assert!(absent_stack_member_with_live_sibling(
|
||||
"indeedhub-minio",
|
||||
&present,
|
||||
&names
|
||||
));
|
||||
assert!(absent_stack_member_with_live_sibling(
|
||||
"indeedhub-postgres",
|
||||
&present,
|
||||
&names
|
||||
));
|
||||
// Whole stack absent → NOT recovered (uninstalled stays uninstalled).
|
||||
let empty = HashSet::new();
|
||||
assert!(!absent_stack_member_with_live_sibling(
|
||||
"indeedhub-minio",
|
||||
&empty,
|
||||
&names
|
||||
));
|
||||
// Non-stack app → never.
|
||||
assert!(!absent_stack_member_with_live_sibling(
|
||||
"vaultwarden",
|
||||
&present,
|
||||
&names
|
||||
));
|
||||
// An app's OWN container being present proves nothing about siblings.
|
||||
let only_self: HashSet<String> = std::iter::once("indeedhub-minio".to_string()).collect();
|
||||
assert!(!absent_stack_member_with_live_sibling(
|
||||
"indeedhub-minio",
|
||||
&only_self,
|
||||
&names
|
||||
));
|
||||
// App-id → container-name mapping is honoured (immich_postgres runs
|
||||
// under an underscore name while its app id is hyphenated).
|
||||
let mut mapped = std::collections::HashMap::new();
|
||||
mapped.insert("immich-postgres".to_string(), "immich_postgres".to_string());
|
||||
let immich_present: HashSet<String> =
|
||||
std::iter::once("immich_postgres".to_string()).collect();
|
||||
assert!(absent_stack_member_with_live_sibling(
|
||||
"immich-redis",
|
||||
&immich_present,
|
||||
&mapped
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_drift_tolerates_quadlet_entrypoint_split() {
|
||||
// Quadlet writes Entrypoint=sh + Exec=-lc "<script>", so podman
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
|
||||
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8888, 8999, 9000, 9100, 10380, 11434, 18081,
|
||||
18083, 23000, 32838, 50002,
|
||||
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
|
||||
18081, 18083, 23000, 32838, 50002,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Last-known-good FIPS peer endpoints (A3.10).
|
||||
//!
|
||||
//! The LAN direct-peering tick (`anchors::lan_fips_anchors`) only helps peers
|
||||
//! we can currently see on the LAN. When a federation peer's LAN path is gone
|
||||
//! (renumbered network, remote site, mDNS blackout) the only route left is the
|
||||
//! anchor spanning tree — the exact hairpin RC2 calls out. But if we were EVER
|
||||
//! connected to that peer directly, the daemon knew a working endpoint for it
|
||||
//! (`fipsctl show peers` → `transport_addr`/`transport_type`, which covers
|
||||
//! LAN, Tailscale, and WAN endpoints alike). This module persists those
|
||||
//! npub-keyed endpoints and re-offers them as dial candidates when the live
|
||||
//! paths disappear: LAN → last-known-good → anchor tree.
|
||||
//!
|
||||
//! Persisted at `<data_dir>/fips-endpoints.json`. Entries are refreshed every
|
||||
//! time the peer is seen connected and dropped after `RETENTION` without a
|
||||
//! sighting, so a peer that genuinely moved doesn't get dialed at a stale
|
||||
//! address forever ( `fipsctl connect` to a dead address is harmless but not
|
||||
//! free).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::fs;
|
||||
|
||||
use super::anchors::SeedAnchor;
|
||||
|
||||
const FILE_NAME: &str = "fips-endpoints.json";
|
||||
/// Forget endpoints not seen connected for this long (seconds) — 30 days.
|
||||
const RETENTION_SECS: u64 = 30 * 24 * 60 * 60;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct KnownEndpoint {
|
||||
/// "ip:port" as reported by the daemon (`transport_addr`).
|
||||
pub address: String,
|
||||
/// "udp" | "tcp" (`transport_type`).
|
||||
pub transport: String,
|
||||
/// Unix seconds of the last time this peer was seen connected here.
|
||||
pub last_ok_unix: u64,
|
||||
}
|
||||
|
||||
/// A currently-connected peer as parsed from `fipsctl show peers`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectedPeer {
|
||||
pub npub: String,
|
||||
pub address: String,
|
||||
pub transport: String,
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> HashMap<String, KnownEndpoint> {
|
||||
let path = data_dir.join(FILE_NAME);
|
||||
match fs::read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
Err(_) => HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save(data_dir: &Path, map: &HashMap<String, KnownEndpoint>) -> Result<()> {
|
||||
let path = data_dir.join(FILE_NAME);
|
||||
let tmp = data_dir.join(format!("{FILE_NAME}.tmp"));
|
||||
fs::write(&tmp, serde_json::to_vec_pretty(map)?).await?;
|
||||
fs::rename(&tmp, &path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge the currently-connected peers into the store (refreshing their
|
||||
/// timestamps), prune expired entries, persist, and return the updated map.
|
||||
/// Persistence failures are non-fatal — the in-memory result is still
|
||||
/// returned so this tick's fallback logic works.
|
||||
pub async fn record_connected(
|
||||
data_dir: &Path,
|
||||
connected: &[ConnectedPeer],
|
||||
) -> HashMap<String, KnownEndpoint> {
|
||||
let mut map = load(data_dir).await;
|
||||
let now = now_unix();
|
||||
let before = map.clone();
|
||||
for p in connected {
|
||||
if p.npub.is_empty() || p.address.is_empty() {
|
||||
continue;
|
||||
}
|
||||
map.insert(
|
||||
p.npub.clone(),
|
||||
KnownEndpoint {
|
||||
address: p.address.clone(),
|
||||
transport: p.transport.clone(),
|
||||
last_ok_unix: now,
|
||||
},
|
||||
);
|
||||
}
|
||||
map.retain(|_, e| now.saturating_sub(e.last_ok_unix) <= RETENTION_SECS);
|
||||
if map != before {
|
||||
if let Err(e) = save(data_dir, &map).await {
|
||||
tracing::debug!("fips endpoint store save failed (non-fatal): {e}");
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Build fallback anchors for federation peers whose live paths are gone:
|
||||
/// every `wanted_npub` that is neither currently connected nor covered by a
|
||||
/// live LAN direct entry, but has a last-known-good endpoint, becomes a dial
|
||||
/// candidate. `fipsctl connect` is idempotent and failure-tolerant, so a
|
||||
/// stale candidate costs one failed dial, bounded by apply()'s per-connect
|
||||
/// timeout.
|
||||
pub fn fallback_anchors(
|
||||
known: &HashMap<String, KnownEndpoint>,
|
||||
wanted_npubs: &[String],
|
||||
connected_npubs: &[String],
|
||||
lan_direct: &[SeedAnchor],
|
||||
) -> Vec<SeedAnchor> {
|
||||
let mut out = Vec::new();
|
||||
for npub in wanted_npubs {
|
||||
if connected_npubs.iter().any(|c| c == npub) {
|
||||
continue;
|
||||
}
|
||||
if lan_direct.iter().any(|a| &a.npub == npub) {
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = known.get(npub) {
|
||||
out.push(SeedAnchor {
|
||||
npub: npub.clone(),
|
||||
address: e.address.clone(),
|
||||
transport: e.transport.clone(),
|
||||
label: "last-known-good endpoint (direct FIPS)".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ep(addr: &str) -> KnownEndpoint {
|
||||
KnownEndpoint {
|
||||
address: addr.to_string(),
|
||||
transport: "udp".to_string(),
|
||||
last_ok_unix: now_unix(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_and_reload_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let connected = vec![ConnectedPeer {
|
||||
npub: "npub1aaa".into(),
|
||||
address: "100.114.134.21:2121".into(),
|
||||
transport: "udp".into(),
|
||||
}];
|
||||
let map = record_connected(dir.path(), &connected).await;
|
||||
assert_eq!(map["npub1aaa"].address, "100.114.134.21:2121");
|
||||
let reloaded = load(dir.path()).await;
|
||||
assert_eq!(reloaded, map);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_entries_are_pruned_on_record() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut stale = HashMap::new();
|
||||
stale.insert(
|
||||
"npub1old".to_string(),
|
||||
KnownEndpoint {
|
||||
address: "10.0.0.1:2121".into(),
|
||||
transport: "udp".into(),
|
||||
last_ok_unix: now_unix() - RETENTION_SECS - 60,
|
||||
},
|
||||
);
|
||||
save(dir.path(), &stale).await.unwrap();
|
||||
let map = record_connected(dir.path(), &[]).await;
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_skips_connected_and_lan_covered_peers() {
|
||||
let mut known = HashMap::new();
|
||||
known.insert("npub1gone".to_string(), ep("100.1.2.3:2121"));
|
||||
known.insert("npub1conn".to_string(), ep("100.1.2.4:2121"));
|
||||
known.insert("npub1lan".to_string(), ep("100.1.2.5:2121"));
|
||||
let wanted: Vec<String> = ["npub1gone", "npub1conn", "npub1lan", "npub1never"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let connected = vec!["npub1conn".to_string()];
|
||||
let lan = vec![SeedAnchor {
|
||||
npub: "npub1lan".into(),
|
||||
address: "192.168.63.198:2121".into(),
|
||||
transport: "udp".into(),
|
||||
label: "LAN".into(),
|
||||
}];
|
||||
let out = fallback_anchors(&known, &wanted, &connected, &lan);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].npub, "npub1gone");
|
||||
assert_eq!(out[0].address, "100.1.2.3:2121");
|
||||
// npub1never has no stored endpoint → nothing to dial.
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ pub mod anchors;
|
||||
pub mod app_ports;
|
||||
pub mod config;
|
||||
pub mod dial;
|
||||
pub mod endpoints;
|
||||
pub mod iface;
|
||||
pub mod service;
|
||||
pub mod telemetry;
|
||||
|
||||
@@ -227,6 +227,52 @@ pub async fn peer_connectivity_summary(anchor_candidates: &[String]) -> (u32, bo
|
||||
(authenticated_peer_count, anchor_connected)
|
||||
}
|
||||
|
||||
/// Currently-connected peers with their live endpoints, from
|
||||
/// `fipsctl show peers` (`transport_addr`/`transport_type`). Feeds the
|
||||
/// last-known-good endpoint store (A3.10); empty on any failure.
|
||||
pub async fn connected_peer_endpoints() -> Vec<crate::fips::endpoints::ConnectedPeer> {
|
||||
let peers_json = match Command::new("sudo")
|
||||
.args(["-n", "fipsctl", "show", "peers"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(o) if o.status.success() => o.stdout,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
let parsed: serde_json::Value = match serde_json::from_slice(&peers_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
parsed
|
||||
.get("peers")
|
||||
.and_then(|p| p.as_array())
|
||||
.map(|peers| {
|
||||
peers
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.get("connectivity")
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s == "connected")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter_map(|p| {
|
||||
let npub = p.get("npub").and_then(|n| n.as_str())?;
|
||||
let address = p.get("transport_addr").and_then(|a| a.as_str())?;
|
||||
let transport = p
|
||||
.get("transport_type")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("udp");
|
||||
Some(crate::fips::endpoints::ConnectedPeer {
|
||||
npub: npub.to_string(),
|
||||
address: address.to_string(),
|
||||
transport: transport.to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the upstream daemon's public key at `/etc/fips/fips.pub` and return
|
||||
/// it as a bech32 npub. Returns `Ok(None)` if the file doesn't exist — used
|
||||
/// as a fallback on legacy/dev nodes where no seed-derived key exists.
|
||||
|
||||
@@ -409,6 +409,11 @@ async fn main() -> Result<()> {
|
||||
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
|
||||
tokio::spawn(bootstrap::ensure_kiosk_hardened());
|
||||
|
||||
// Repair our own restart policy before anything else can need it: a node
|
||||
// whose unit still says Restart=on-failure stays dead after the next
|
||||
// in-process update, because the daemon exits cleanly to be restarted.
|
||||
tokio::spawn(bootstrap::ensure_restart_policy());
|
||||
|
||||
// HDMI audio: install the PipeWire stack + audio-router daemon on kiosk
|
||||
// nodes (older ISOs shipped no audio stack; the router also heals the
|
||||
// boot-time ELD race that leaves HDMI silently unavailable).
|
||||
|
||||
@@ -148,9 +148,21 @@ pub enum MeshCommand {
|
||||
},
|
||||
SendAdvert,
|
||||
/// Reboot the locally-connected radio firmware to recover a wedged /
|
||||
/// RX-deaf radio. Meshtastic-only; meshcore ignores it.
|
||||
/// RX-deaf radio. Meshtastic: firmware reboot command. Reticulum: the
|
||||
/// sidecar daemon is restarted (radio re-detected + reconfigured).
|
||||
/// MeshCore: unsupported, and says so. `reply` (when present) carries
|
||||
/// the real outcome to the RPC caller — the buttons used to be
|
||||
/// fire-and-forget `warn!`s, i.e. no feedback ever reached the UI
|
||||
/// (operator, 2026-08-06).
|
||||
RebootRadio {
|
||||
seconds: i64,
|
||||
reply: Option<tokio::sync::oneshot::Sender<Result<String, String>>>,
|
||||
},
|
||||
/// Query the live RNode radio state (Reticulum-only): the sidecar's
|
||||
/// radio-confirmed parameters, for the LoRa settings panel's current
|
||||
/// values + apply read-back.
|
||||
QueryRadioState {
|
||||
reply: tokio::sync::oneshot::Sender<Result<serde_json::Value, String>>,
|
||||
},
|
||||
/// Re-fetch contact list from the radio device.
|
||||
RefreshContacts,
|
||||
|
||||
@@ -165,13 +165,41 @@ impl MeshRadioDevice {
|
||||
}
|
||||
}
|
||||
|
||||
async fn reboot(&mut self, seconds: i64) -> Result<()> {
|
||||
async fn reboot(&mut self, seconds: i64) -> Result<String> {
|
||||
match self {
|
||||
// Meshcore/Reticulum have no equivalent local-admin reboot in our
|
||||
// driver; the RX-deaf recovery this targets is Meshtastic-specific.
|
||||
Self::Meshcore(_) => Ok(()),
|
||||
Self::Meshtastic(device) => device.reboot(seconds).await,
|
||||
Self::Reticulum(_) => Ok(()),
|
||||
// No remote reboot in the MeshCore serial protocol — say so
|
||||
// instead of silently reporting success (the old `Ok(())` here
|
||||
// is why the button "did nothing" for the operator).
|
||||
Self::Meshcore(_) => {
|
||||
anyhow::bail!("MeshCore radios have no remote reboot — power-cycle the device")
|
||||
}
|
||||
Self::Meshtastic(device) => {
|
||||
device.reboot(seconds).await?;
|
||||
Ok(format!(
|
||||
"Radio firmware reboots in {seconds}s and reconnects automatically"
|
||||
))
|
||||
}
|
||||
// Restarting the sidecar drops the serial port, re-detects the
|
||||
// RNode and reapplies the RF config — the closest thing to a
|
||||
// reboot the RNS stack has, and exactly what an operator wants
|
||||
// after changing settings or on a wedged radio.
|
||||
Self::Reticulum(device) => {
|
||||
device.restart_daemon().await?;
|
||||
Ok("Radio daemon restarting — the RNode re-detects and reconnects in about 15 seconds".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live RNode radio state — Reticulum-only (see ReticulumLink::query_radio_state).
|
||||
async fn radio_state(&mut self) -> Result<serde_json::Value> {
|
||||
match self {
|
||||
Self::Meshcore(_) | Self::Meshtastic(_) => {
|
||||
anyhow::bail!("Radio state read-back is only available for Reticulum RNode devices")
|
||||
}
|
||||
Self::Reticulum(device) => device
|
||||
.query_radio_state(std::time::Duration::from_secs(5))
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("The radio daemon did not answer the state query")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1549,12 +1577,18 @@ async fn handle_send_command(
|
||||
warn!("Failed to send NodeInfo advert: {}", e);
|
||||
}
|
||||
}
|
||||
MeshCommand::RebootRadio { seconds } => {
|
||||
if let Err(e) = device.reboot(seconds).await {
|
||||
warn!("Failed to reboot radio: {}", e);
|
||||
} else {
|
||||
info!(seconds, "Radio reboot command sent to device");
|
||||
MeshCommand::RebootRadio { seconds, reply } => {
|
||||
let outcome = device.reboot(seconds).await;
|
||||
match &outcome {
|
||||
Err(e) => warn!("Failed to reboot radio: {}", e),
|
||||
Ok(_) => info!(seconds, "Radio reboot command sent to device"),
|
||||
}
|
||||
if let Some(reply) = reply {
|
||||
let _ = reply.send(outcome.map_err(|e| format!("{e:#}")));
|
||||
}
|
||||
}
|
||||
MeshCommand::QueryRadioState { reply } => {
|
||||
let _ = reply.send(device.radio_state().await.map_err(|e| format!("{e:#}")));
|
||||
}
|
||||
MeshCommand::RefreshContacts => {
|
||||
refresh_contacts(device, state).await;
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod outbox;
|
||||
pub mod protocol;
|
||||
pub mod ratchet;
|
||||
pub mod reticulum;
|
||||
pub mod rnode_settings;
|
||||
pub mod scheduler;
|
||||
pub mod serial;
|
||||
pub mod session;
|
||||
@@ -2123,20 +2124,82 @@ impl MeshService {
|
||||
/// RX-deaf radio (one that has stopped hearing the mesh while still able to
|
||||
/// transmit). The device reconnects via the listener's reboot→reconnect
|
||||
/// loop. `seconds` is the firmware reboot delay.
|
||||
pub async fn reboot_radio(&self, seconds: i64) -> Result<()> {
|
||||
pub async fn reboot_radio(&self, seconds: i64) -> Result<String> {
|
||||
let status = self.state.status.read().await;
|
||||
if !status.device_connected {
|
||||
anyhow::bail!("No mesh device connected. Check USB connection.");
|
||||
}
|
||||
drop(status);
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
self.state
|
||||
.send_cmd(listener::MeshCommand::RebootRadio { seconds })
|
||||
.send_cmd(listener::MeshCommand::RebootRadio {
|
||||
seconds,
|
||||
reply: Some(tx),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
||||
|
||||
// The real outcome, not fire-and-forget: the UI shows this string
|
||||
// (or the error) instead of pretending success.
|
||||
let outcome = tokio::time::timeout(std::time::Duration::from_secs(15), rx)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("The radio did not acknowledge the reboot in time"))?
|
||||
.map_err(|_| anyhow::anyhow!("Mesh session ended before the reboot completed"))?;
|
||||
let message = outcome.map_err(|e| anyhow::anyhow!(e))?;
|
||||
info!(seconds, "Mesh radio reboot triggered");
|
||||
Ok(())
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
/// Live RNode radio state (Reticulum-only): the sidecar's view of the
|
||||
/// interface including the radio-confirmed r_* parameters. The LoRa
|
||||
/// settings panel's source for "what is the device actually running".
|
||||
pub async fn radio_state(&self) -> Result<serde_json::Value> {
|
||||
// Retry across a reconnect window. Applying settings deliberately
|
||||
// restarts the radio daemon (~15s), and the session is legitimately
|
||||
// absent while it comes back — a single-shot query inside that window
|
||||
// reported "the daemon did not answer" for what is a healthy,
|
||||
// in-progress restart (operator, 2026-08-06).
|
||||
const ATTEMPTS: u32 = 6;
|
||||
let mut last_err = anyhow::anyhow!("No mesh device connected. Check USB connection.");
|
||||
for attempt in 0..ATTEMPTS {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
|
||||
}
|
||||
if !self.state.status.read().await.device_connected {
|
||||
last_err = anyhow::anyhow!(
|
||||
"The radio is not connected right now — if settings were just applied it \
|
||||
is restarting and comes back within about 20 seconds."
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
if self
|
||||
.state
|
||||
.send_cmd(listener::MeshCommand::QueryRadioState { reply: tx })
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
last_err = anyhow::anyhow!("Mesh listener not running");
|
||||
continue;
|
||||
}
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(10), rx).await {
|
||||
Ok(Ok(Ok(state))) => return Ok(state),
|
||||
Ok(Ok(Err(e))) => {
|
||||
// A real device-level refusal (e.g. not an RNode radio) —
|
||||
// retrying cannot change it.
|
||||
return Err(anyhow::anyhow!(e));
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
last_err =
|
||||
anyhow::anyhow!("Mesh session ended before the state query completed")
|
||||
}
|
||||
Err(_) => {
|
||||
last_err = anyhow::anyhow!("The radio daemon did not answer the state query")
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err)
|
||||
}
|
||||
|
||||
/// Current mesh-AI assistant settings (issue #50).
|
||||
|
||||
@@ -176,6 +176,7 @@ fn daemon_command(
|
||||
archy_x25519_pubkey_hex: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
enable_transport: bool,
|
||||
rf: Option<&super::rnode_settings::RNodeRfSettings>,
|
||||
) -> Command {
|
||||
let (program, script) = daemon_program();
|
||||
let mut cmd = Command::new(program);
|
||||
@@ -189,6 +190,24 @@ fn daemon_command(
|
||||
match iface {
|
||||
ReticulumInterface::Serial(path) => {
|
||||
cmd.arg("--serial-port").arg(path);
|
||||
// Operator-editable RF parameters (.126 LoRa panel). Passed
|
||||
// explicitly on every spawn so the sidecar's argparse defaults
|
||||
// stop being the silent source of truth. `rf` is None only for
|
||||
// non-serial interfaces, where these have no meaning.
|
||||
if let Some(rf) = rf {
|
||||
cmd.arg("--frequency").arg(rf.frequency.to_string());
|
||||
cmd.arg("--bandwidth").arg(rf.bandwidth.to_string());
|
||||
cmd.arg("--txpower").arg(rf.txpower.to_string());
|
||||
cmd.arg("--spreadingfactor")
|
||||
.arg(rf.spreading_factor.to_string());
|
||||
cmd.arg("--codingrate").arg(rf.coding_rate.to_string());
|
||||
if let Some(pct) = rf.airtime_limit_short {
|
||||
cmd.arg("--airtime-limit-short").arg(pct.to_string());
|
||||
}
|
||||
if let Some(pct) = rf.airtime_limit_long {
|
||||
cmd.arg("--airtime-limit-long").arg(pct.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ReticulumInterface::TcpServer(bind) => {
|
||||
cmd.arg("--tcp-listen").arg(bind);
|
||||
@@ -318,6 +337,10 @@ pub struct ReticulumLink {
|
||||
/// down and the outer reconnect loop respawns the daemon — without this
|
||||
/// a dead daemon was invisible until the 30-minute RX-stall watchdog.
|
||||
daemon_gone: bool,
|
||||
/// Latest `radio_state` event from the sidecar (the live RNodeInterface
|
||||
/// values, radio-confirmed `r_*` included). Refreshed by
|
||||
/// [`Self::query_radio_state`]; the .126 LoRa panel's read-back source.
|
||||
last_radio_state: Option<Value>,
|
||||
}
|
||||
|
||||
impl ReticulumLink {
|
||||
@@ -344,6 +367,16 @@ impl ReticulumLink {
|
||||
our_x25519_pubkey_hex: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
let rf = super::rnode_settings::RNodeRfSettings::load(data_dir).await;
|
||||
if !rf.enabled {
|
||||
anyhow::bail!(
|
||||
"RNode interface is disabled in the LoRa settings — enable it to connect"
|
||||
);
|
||||
}
|
||||
// Operator port override wins over the auto-detected path (.126 LoRa
|
||||
// panel). The probe below still gates: a wrong override fails with
|
||||
// the detect error instead of a silent dead transport.
|
||||
let path = rf.port.as_deref().unwrap_or(path);
|
||||
probe_rnode(path)
|
||||
.await
|
||||
.context("RNode KISS detect failed")?;
|
||||
@@ -454,6 +487,15 @@ impl ReticulumLink {
|
||||
}
|
||||
|
||||
let enable_transport = daemon_supports_enable_transport().await;
|
||||
// Operator RF settings ride every serial spawn; loaded here (not by
|
||||
// callers) so a settings apply only needs a transport restart to take
|
||||
// effect. Non-serial interfaces carry no RF.
|
||||
let rf = match iface {
|
||||
ReticulumInterface::Serial(_) => {
|
||||
Some(super::rnode_settings::RNodeRfSettings::load(data_dir).await)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let mut cmd = daemon_command(
|
||||
&socket_path,
|
||||
&iface,
|
||||
@@ -462,6 +504,7 @@ impl ReticulumLink {
|
||||
our_x25519_pubkey_hex,
|
||||
display_name,
|
||||
enable_transport,
|
||||
rf.as_ref(),
|
||||
);
|
||||
cmd.env("TMPDIR", &tmp_dir);
|
||||
let child = cmd
|
||||
@@ -534,6 +577,7 @@ impl ReticulumLink {
|
||||
inbound: std::collections::VecDeque::new(),
|
||||
resource_id_counter: 0,
|
||||
daemon_gone: false,
|
||||
last_radio_state: None,
|
||||
};
|
||||
link.load_persisted_peers();
|
||||
Ok(link)
|
||||
@@ -896,8 +940,50 @@ impl ReticulumLink {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the sidecar daemon: ask it to shut down cleanly and mark the
|
||||
/// link dead so the session loop tears down and the outer reconnect loop
|
||||
/// respawns it — re-detecting the RNode and reapplying the RF config
|
||||
/// from the (possibly just-edited) persisted settings. This IS the
|
||||
/// "reboot device" semantic for Reticulum radios, and the apply step of
|
||||
/// the .126 LoRa settings panel.
|
||||
pub async fn restart_daemon(&mut self) -> Result<()> {
|
||||
// Best-effort clean shutdown (lets PyInstaller clear its _MEI dir);
|
||||
// the SIGTERM path in Drop/terminate covers an already-dead socket.
|
||||
let _ = self.send_rpc(serde_json::json!({"cmd": "shutdown"})).await;
|
||||
self.daemon_gone = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ask the sidecar for the live RNode state and wait briefly for the
|
||||
/// reply event. Returns the freshest `radio_state` payload, or `None`
|
||||
/// when the daemon didn't answer in time (dead daemon, no radio build).
|
||||
pub async fn query_radio_state(&mut self, timeout: Duration) -> Option<Value> {
|
||||
self.last_radio_state = None;
|
||||
if self
|
||||
.send_rpc(serde_json::json!({"cmd": "radio_state"}))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
self.drain_events().await;
|
||||
if let Some(state) = &self.last_radio_state {
|
||||
return Some(state.clone());
|
||||
}
|
||||
if self.daemon_gone || tokio::time::Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, ev: Value) {
|
||||
match ev.get("event").and_then(Value::as_str) {
|
||||
Some("radio_state") => {
|
||||
self.last_radio_state = Some(ev);
|
||||
}
|
||||
Some("announce") => {
|
||||
let Some(hash) = ev
|
||||
.get("dest_hash")
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
//! Persisted RNode LoRa RF settings — the operator-editable half of the
|
||||
//! Reticulum transport (.126 LoRa settings panel).
|
||||
//!
|
||||
//! The reticulum sidecar (reticulum-daemon) writes the RNS config from its
|
||||
//! CLI args at every spawn; before this module those args were never passed,
|
||||
//! so every node ran the sidecar's argparse defaults and nothing was
|
||||
//! operator-editable. These settings persist at
|
||||
//! `<data_dir>/rnode-rf-settings.json`, feed `daemon_command` as explicit
|
||||
//! args, and the panel confirms application via the sidecar's `radio_state`
|
||||
//! read-back (the radio-confirmed `r_*` values, not the requested ones).
|
||||
//!
|
||||
//! An absent file yields [`RNodeRfSettings::default`], which matches the
|
||||
//! sidecar's historical argparse defaults exactly — deploying this changes
|
||||
//! nothing until the operator edits something.
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
const SETTINGS_FILE: &str = "rnode-rf-settings.json";
|
||||
|
||||
/// Validation bounds mirror RNS `RNodeInterface.py` (`validate_firmware` /
|
||||
/// the constructor checks) — NOT guessed: frequency 137–1020 MHz, sf 5–12,
|
||||
/// cr 5–8, txpower 0–22 dBm, airtime locks 0–100 %.
|
||||
const FREQ_MIN_HZ: u64 = 137_000_000;
|
||||
const FREQ_MAX_HZ: u64 = 1_020_000_000;
|
||||
/// The discrete bandwidths RNode firmware accepts (Hz).
|
||||
const VALID_BANDWIDTHS: &[u64] = &[
|
||||
7_800, 10_400, 15_600, 20_800, 31_250, 41_700, 62_500, 125_000, 250_000, 500_000,
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RNodeRfSettings {
|
||||
/// Interface on/off. `false` keeps the daemon from opening the radio at
|
||||
/// all (the mesh service skips the serial transport).
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Serial device override (e.g. `/dev/ttyACM0`). `None` = auto-detect,
|
||||
/// which is what every node did before this existed.
|
||||
#[serde(default)]
|
||||
pub port: Option<String>,
|
||||
#[serde(default = "default_frequency")]
|
||||
pub frequency: u64,
|
||||
#[serde(default = "default_bandwidth")]
|
||||
pub bandwidth: u64,
|
||||
#[serde(default = "default_spreading_factor")]
|
||||
pub spreading_factor: u8,
|
||||
#[serde(default = "default_coding_rate")]
|
||||
pub coding_rate: u8,
|
||||
#[serde(default = "default_txpower")]
|
||||
pub txpower: u8,
|
||||
/// Short-window airtime duty-cycle lock, percent (EU868: 25). `None` =
|
||||
/// no software lock (RNS default).
|
||||
#[serde(default)]
|
||||
pub airtime_limit_short: Option<f64>,
|
||||
/// Long-window airtime duty-cycle lock, percent (EU868: 10).
|
||||
#[serde(default)]
|
||||
pub airtime_limit_long: Option<f64>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_frequency() -> u64 {
|
||||
869_525_000
|
||||
}
|
||||
fn default_bandwidth() -> u64 {
|
||||
125_000
|
||||
}
|
||||
fn default_spreading_factor() -> u8 {
|
||||
8
|
||||
}
|
||||
fn default_coding_rate() -> u8 {
|
||||
5
|
||||
}
|
||||
fn default_txpower() -> u8 {
|
||||
17
|
||||
}
|
||||
|
||||
impl Default for RNodeRfSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
port: None,
|
||||
frequency: default_frequency(),
|
||||
bandwidth: default_bandwidth(),
|
||||
spreading_factor: default_spreading_factor(),
|
||||
coding_rate: default_coding_rate(),
|
||||
txpower: default_txpower(),
|
||||
airtime_limit_short: None,
|
||||
airtime_limit_long: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RNodeRfSettings {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if !(FREQ_MIN_HZ..=FREQ_MAX_HZ).contains(&self.frequency) {
|
||||
bail!(
|
||||
"frequency {} Hz is outside the RNode range ({}–{} Hz)",
|
||||
self.frequency,
|
||||
FREQ_MIN_HZ,
|
||||
FREQ_MAX_HZ
|
||||
);
|
||||
}
|
||||
if !VALID_BANDWIDTHS.contains(&self.bandwidth) {
|
||||
bail!(
|
||||
"bandwidth {} Hz is not an RNode bandwidth (valid: {:?})",
|
||||
self.bandwidth,
|
||||
VALID_BANDWIDTHS
|
||||
);
|
||||
}
|
||||
if !(5..=12).contains(&self.spreading_factor) {
|
||||
bail!("spreading factor {} is outside 5–12", self.spreading_factor);
|
||||
}
|
||||
if !(5..=8).contains(&self.coding_rate) {
|
||||
bail!("coding rate {} is outside 5–8", self.coding_rate);
|
||||
}
|
||||
if self.txpower > 22 {
|
||||
bail!(
|
||||
"tx power {} dBm is above the 22 dBm RNode maximum",
|
||||
self.txpower
|
||||
);
|
||||
}
|
||||
for (label, v) in [
|
||||
("airtime_limit_short", self.airtime_limit_short),
|
||||
("airtime_limit_long", self.airtime_limit_long),
|
||||
] {
|
||||
if let Some(pct) = v {
|
||||
if !(0.0..=100.0).contains(&pct) || !pct.is_finite() {
|
||||
bail!("{label} {pct} is not a percentage (0–100)");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(port) = &self.port {
|
||||
// Same shape the flasher accepts: an absolute device node. Keeps
|
||||
// shell-metacharacter garbage out of the sidecar's argv.
|
||||
if !port.starts_with("/dev/")
|
||||
|| port.chars().any(|c| {
|
||||
!(c.is_ascii_alphanumeric() || c == '/' || c == '_' || c == '-' || c == '.')
|
||||
})
|
||||
{
|
||||
bail!("port must be an absolute /dev device path");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> Self {
|
||||
let path = data_dir.join(SETTINGS_FILE);
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(raw) => match serde_json::from_str::<Self>(&raw) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "rnode-rf-settings.json unparseable — using defaults");
|
||||
Self::default()
|
||||
}
|
||||
},
|
||||
// First run after the update: no settings file yet. ADOPT the
|
||||
// node's existing effective RF config rather than imposing
|
||||
// defaults — the operator's standing requirement is that the
|
||||
// update changes NO device's applied settings. For archy-managed
|
||||
// radios the sidecar config equals our defaults anyway; this
|
||||
// covers any node whose RNS config diverged (hand edits,
|
||||
// hand-run rnsd).
|
||||
Err(_) => {
|
||||
let adopted = Self::adopt_existing_rns_config().await;
|
||||
if let Some(adopted) = adopted {
|
||||
tracing::info!(
|
||||
settings = ?adopted,
|
||||
"adopted existing RNS RNode config as initial RF settings"
|
||||
);
|
||||
if let Err(e) = adopted.save(data_dir).await {
|
||||
tracing::warn!(error = %e, "could not persist adopted RF settings");
|
||||
}
|
||||
adopted
|
||||
} else {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the RNodeInterface section out of an existing RNS config file
|
||||
/// (the sidecar's `~/.archy-reticulum/config`, else a hand-run rnsd's
|
||||
/// `~/.reticulum/config`). Returns `None` when neither exists or no
|
||||
/// RNodeInterface section is found. Unparseable/absent fields keep the
|
||||
/// default (which equals the sidecar's historical argparse default).
|
||||
async fn adopt_existing_rns_config() -> Option<Self> {
|
||||
let home = std::env::var("HOME").ok()?;
|
||||
for candidate in [
|
||||
format!("{home}/.archy-reticulum/config"),
|
||||
format!("{home}/.reticulum/config"),
|
||||
] {
|
||||
let Ok(raw) = tokio::fs::read_to_string(&candidate).await else {
|
||||
continue;
|
||||
};
|
||||
if let Some(s) = Self::parse_rnode_section(&raw) {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract RNode parameters from RNS config text. Scoped to the block
|
||||
/// after a `type = RNodeInterface` line so TCP interface options can
|
||||
/// never bleed in; stops at the next `[[...]]` section header.
|
||||
fn parse_rnode_section(raw: &str) -> Option<Self> {
|
||||
let mut in_rnode = false;
|
||||
let mut seen_any = false;
|
||||
let mut s = Self::default();
|
||||
for line in raw.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("[[") {
|
||||
if in_rnode {
|
||||
break; // next interface section — RNode block ended
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let (key, value) = (key.trim(), value.trim());
|
||||
if key == "type" {
|
||||
in_rnode = value == "RNodeInterface";
|
||||
continue;
|
||||
}
|
||||
if !in_rnode {
|
||||
continue;
|
||||
}
|
||||
seen_any = true;
|
||||
match key {
|
||||
"enabled" | "interface_enabled" => {
|
||||
s.enabled = matches!(value.to_ascii_lowercase().as_str(), "yes" | "true" | "on")
|
||||
}
|
||||
"port" => s.port = Some(value.to_string()),
|
||||
"frequency" => s.frequency = value.parse().unwrap_or(s.frequency),
|
||||
"bandwidth" => s.bandwidth = value.parse().unwrap_or(s.bandwidth),
|
||||
"txpower" => s.txpower = value.parse().unwrap_or(s.txpower),
|
||||
"spreadingfactor" => {
|
||||
s.spreading_factor = value.parse().unwrap_or(s.spreading_factor)
|
||||
}
|
||||
"codingrate" => s.coding_rate = value.parse().unwrap_or(s.coding_rate),
|
||||
"airtime_limit_short" => s.airtime_limit_short = value.parse().ok(),
|
||||
"airtime_limit_long" => s.airtime_limit_long = value.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(in_rnode || seen_any).then_some(s)
|
||||
}
|
||||
|
||||
pub async fn save(&self, data_dir: &Path) -> Result<()> {
|
||||
self.validate()?;
|
||||
let path = data_dir.join(SETTINGS_FILE);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let raw = serde_json::to_string_pretty(self)?;
|
||||
tokio::fs::write(&tmp, raw).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_sidecar_argparse_defaults() {
|
||||
// reticulum_daemon.py: --frequency 869525000 --bandwidth 125000
|
||||
// --txpower 17 --spreadingfactor 8 --codingrate 5, no airtime locks.
|
||||
let d = RNodeRfSettings::default();
|
||||
assert_eq!(d.frequency, 869_525_000);
|
||||
assert_eq!(d.bandwidth, 125_000);
|
||||
assert_eq!(d.txpower, 17);
|
||||
assert_eq!(d.spreading_factor, 8);
|
||||
assert_eq!(d.coding_rate, 5);
|
||||
assert!(d.airtime_limit_short.is_none() && d.airtime_limit_long.is_none());
|
||||
assert!(d.enabled && d.port.is_none());
|
||||
d.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_portugal_config_validates() {
|
||||
// The operator's real device config (2026-08-06).
|
||||
let s = RNodeRfSettings {
|
||||
enabled: true,
|
||||
port: Some("/dev/ttyACM0".into()),
|
||||
frequency: 869_462_500,
|
||||
bandwidth: 125_000,
|
||||
spreading_factor: 8,
|
||||
coding_rate: 5,
|
||||
txpower: 14,
|
||||
airtime_limit_short: Some(25.0),
|
||||
airtime_limit_long: Some(10.0),
|
||||
};
|
||||
s.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adoption_preserves_the_operator_portugal_config_exactly() {
|
||||
// The operator's literal RNS config (2026-08-06). The update must
|
||||
// adopt these values verbatim — changing a node's applied RF
|
||||
// settings is forbidden.
|
||||
let raw = "\
|
||||
[reticulum]
|
||||
enable_transport = yes
|
||||
|
||||
[interfaces]
|
||||
[[RNode LoRa Portugal]]
|
||||
type = RNodeInterface
|
||||
interface_enabled = true
|
||||
port = /dev/ttyACM0
|
||||
frequency = 869462500
|
||||
bandwidth = 125000
|
||||
spreadingfactor = 8
|
||||
codingrate = 5
|
||||
txpower = 14
|
||||
airtime_limit_short = 25
|
||||
airtime_limit_long = 10
|
||||
";
|
||||
let s = RNodeRfSettings::parse_rnode_section(raw).expect("section found");
|
||||
assert!(s.enabled);
|
||||
assert_eq!(s.port.as_deref(), Some("/dev/ttyACM0"));
|
||||
assert_eq!(s.frequency, 869_462_500);
|
||||
assert_eq!(s.bandwidth, 125_000);
|
||||
assert_eq!(s.spreading_factor, 8);
|
||||
assert_eq!(s.coding_rate, 5);
|
||||
assert_eq!(s.txpower, 14);
|
||||
assert_eq!(s.airtime_limit_short, Some(25.0));
|
||||
assert_eq!(s.airtime_limit_long, Some(10.0));
|
||||
s.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adoption_ignores_non_rnode_sections_and_absent_config() {
|
||||
let tcp_only = "\
|
||||
[interfaces]
|
||||
[[Reticulum TCP Server]]
|
||||
type = TCPServerInterface
|
||||
listen_ip = 127.0.0.1
|
||||
listen_port = 4242
|
||||
";
|
||||
assert!(RNodeRfSettings::parse_rnode_section(tcp_only).is_none());
|
||||
assert!(RNodeRfSettings::parse_rnode_section("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_values_are_rejected() {
|
||||
let base = RNodeRfSettings::default();
|
||||
for bad in [
|
||||
RNodeRfSettings {
|
||||
frequency: 100,
|
||||
..base.clone()
|
||||
},
|
||||
RNodeRfSettings {
|
||||
bandwidth: 123_456,
|
||||
..base.clone()
|
||||
},
|
||||
RNodeRfSettings {
|
||||
spreading_factor: 4,
|
||||
..base.clone()
|
||||
},
|
||||
RNodeRfSettings {
|
||||
coding_rate: 9,
|
||||
..base.clone()
|
||||
},
|
||||
RNodeRfSettings {
|
||||
txpower: 23,
|
||||
..base.clone()
|
||||
},
|
||||
RNodeRfSettings {
|
||||
airtime_limit_short: Some(180.0),
|
||||
..base.clone()
|
||||
},
|
||||
RNodeRfSettings {
|
||||
port: Some("ttyACM0".into()),
|
||||
..base.clone()
|
||||
},
|
||||
RNodeRfSettings {
|
||||
port: Some("/dev/tty; rm -rf /".into()),
|
||||
..base.clone()
|
||||
},
|
||||
] {
|
||||
assert!(bad.validate().is_err(), "{bad:?} should fail validation");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -847,6 +847,39 @@ impl Server {
|
||||
if !direct.is_empty() {
|
||||
let _ = crate::fips::anchors::apply(&direct).await;
|
||||
}
|
||||
|
||||
// A3.10 — endpoint fallback for direct peering. Record
|
||||
// where currently-connected peers actually are (their
|
||||
// transport_addr covers LAN, Tailscale, and WAN alike),
|
||||
// then re-dial the last-known-good endpoint of every
|
||||
// federation peer whose live paths are gone: not
|
||||
// connected now, no LAN direct entry this tick. Escala-
|
||||
// tion order is LAN → last-known-good → anchor tree;
|
||||
// a stale candidate costs one bounded failed dial.
|
||||
let connected = crate::fips::service::connected_peer_endpoints().await;
|
||||
let known =
|
||||
crate::fips::endpoints::record_connected(&data_dir, &connected).await;
|
||||
let wanted: Vec<String> = reg
|
||||
.all_peers()
|
||||
.await
|
||||
.iter()
|
||||
.filter_map(|p| p.fips_npub.clone())
|
||||
.collect();
|
||||
let connected_npubs: Vec<String> =
|
||||
connected.iter().map(|c| c.npub.clone()).collect();
|
||||
let fallback = crate::fips::endpoints::fallback_anchors(
|
||||
&known,
|
||||
&wanted,
|
||||
&connected_npubs,
|
||||
&direct,
|
||||
);
|
||||
if !fallback.is_empty() {
|
||||
tracing::info!(
|
||||
count = fallback.len(),
|
||||
"dialing last-known-good endpoints for disconnected federation peers"
|
||||
);
|
||||
let _ = crate::fips::anchors::apply(&fallback).await;
|
||||
}
|
||||
}
|
||||
|
||||
let next = if daemon_restarting && fast_retries < MAX_FAST_RETRIES {
|
||||
|
||||
@@ -40,12 +40,19 @@ struct Session {
|
||||
created_at: SystemTime,
|
||||
last_activity: SystemTime,
|
||||
session_type: SessionType,
|
||||
/// What kind of screen this login came from. A TV on the wall must not
|
||||
/// be signed out for sitting still — nobody is there to type a password
|
||||
/// back in — while a browser must be.
|
||||
device_class: crate::settings::session_policy::DeviceClass,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionStore {
|
||||
sessions: Arc<RwLock<HashMap<[u8; 32], Session>>>,
|
||||
persist_path: PathBuf,
|
||||
/// Where the session policy lives. Held rather than looked up globally
|
||||
/// so tests can point at a temp dir.
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// On-disk representation of a persisted session (only Full sessions, no TOTP secrets).
|
||||
@@ -67,6 +74,7 @@ impl SessionStore {
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(sessions)),
|
||||
persist_path,
|
||||
data_dir: PathBuf::from("/var/lib/archipelago"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,9 +83,17 @@ impl SessionStore {
|
||||
/// machine's real /var/lib/archipelago/sessions.json.
|
||||
#[cfg(test)]
|
||||
pub fn new_for_tests(persist_path: PathBuf) -> Self {
|
||||
// data_dir shares the temp path's parent so a test that writes a
|
||||
// policy file is honoured, and one that doesn't gets the defaults
|
||||
// rather than the dev machine's real configuration.
|
||||
let data_dir = persist_path
|
||||
.parent()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
persist_path,
|
||||
data_dir,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +136,7 @@ impl SessionStore {
|
||||
created_at,
|
||||
last_activity,
|
||||
session_type: SessionType::Full,
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -160,6 +177,7 @@ impl SessionStore {
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
session_type: SessionType::Full,
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
};
|
||||
|
||||
let mut sessions = self.sessions.write().await;
|
||||
@@ -184,6 +202,10 @@ impl SessionStore {
|
||||
totp_secret,
|
||||
attempts: 0,
|
||||
},
|
||||
// A half-finished login is always treated as a browser: it lives
|
||||
// for PENDING_SESSION_TTL either way, and a kiosk exemption on a
|
||||
// session that has not passed 2FA yet would be the wrong default.
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
};
|
||||
self.sessions.write().await.insert(hash, session);
|
||||
token
|
||||
@@ -192,19 +214,23 @@ impl SessionStore {
|
||||
/// Validate a full session token. Returns true if the session exists and hasn't expired.
|
||||
/// Updates last_activity on successful validation (inactivity-based expiry).
|
||||
pub async fn validate(&self, token: &str) -> bool {
|
||||
let policy = self.policy().await;
|
||||
let hash = hash_token(token);
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(session) = sessions.get_mut(&hash) {
|
||||
if !matches!(session.session_type, SessionType::Full) {
|
||||
return false;
|
||||
}
|
||||
if session
|
||||
let idle = session
|
||||
.last_activity
|
||||
.elapsed()
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
>= FULL_SESSION_TTL
|
||||
{
|
||||
.as_secs();
|
||||
let age = session.created_at.elapsed().unwrap_or_default().as_secs();
|
||||
// Both limits, not just idleness: the dashboard polls, so an
|
||||
// idle timeout alone would never fire on an open tab. The
|
||||
// absolute cap is what actually guarantees a login ends.
|
||||
if policy.is_expired(session.device_class, age, idle) {
|
||||
sessions.remove(&hash);
|
||||
return false;
|
||||
}
|
||||
@@ -215,6 +241,13 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// The operator's session policy, re-read from disk rather than cached
|
||||
/// for the process lifetime so a change in Settings takes effect on the
|
||||
/// next request instead of the next restart.
|
||||
pub async fn policy(&self) -> crate::settings::session_policy::SessionPolicy {
|
||||
crate::settings::session_policy::load(&self.data_dir).await
|
||||
}
|
||||
|
||||
/// Get the TOTP secret from a pending session. Returns None if not a valid pending session.
|
||||
/// Increments the attempt counter.
|
||||
pub async fn get_pending_secret(&self, token: &str) -> Option<Vec<u8>> {
|
||||
@@ -259,6 +292,7 @@ impl SessionStore {
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
session_type: SessionType::Full,
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
},
|
||||
);
|
||||
Self::save_to_disk(&sessions, &self.persist_path).await;
|
||||
@@ -300,6 +334,7 @@ impl SessionStore {
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
session_type: SessionType::Full,
|
||||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||||
},
|
||||
);
|
||||
Self::save_to_disk(&sessions, &self.persist_path).await;
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
//! call sites (deep in the transport / RPC / ingest stacks) don't need
|
||||
//! to thread a data_dir or Arc through the entire call graph.
|
||||
|
||||
pub mod session_policy;
|
||||
pub mod transport;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! How long a login lasts, and who gets to say so.
|
||||
//!
|
||||
//! # Why this is configurable rather than a constant
|
||||
//!
|
||||
//! There is no single correct session lifetime. The same node can be a
|
||||
//! wall-mounted TV in a living room that must never ask for a password
|
||||
//! mid-film, and a wallet holding real funds where PCI DSS-style guidance
|
||||
//! says fifteen minutes. Both are legitimate; the operator knows which one
|
||||
//! this node is and we do not.
|
||||
//!
|
||||
//! # The two tokens
|
||||
//!
|
||||
//! * **Session token** — short-lived, refreshed silently on every
|
||||
//! authenticated request. This is what the browser sends; if it leaks, it
|
||||
//! is useful only until [`SessionPolicy::idle_timeout_secs`] of silence.
|
||||
//! * **Login (remember) token** — long-lived, and its *only* power is to
|
||||
//! mint a fresh session token. Kept separate so raising the convenience
|
||||
//! knob does not put a 30-day bearer credential on every request.
|
||||
//!
|
||||
//! Raising the idle timeout therefore does not weaken the credential that
|
||||
//! actually travels; it only changes how long a quiet tab stays usable.
|
||||
//!
|
||||
//! # Why an absolute cap exists at all
|
||||
//!
|
||||
//! Idle timeout alone can be defeated by any page that polls — the
|
||||
//! dashboard polls constantly, so an idle timeout would never fire while a
|
||||
//! tab is open. The absolute cap is what guarantees a login eventually
|
||||
//! ends, which is the property an auditor actually asks about.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
const FILE_PATH: &str = "settings/session_policy.json";
|
||||
|
||||
/// Bounds. A setting that can be made meaningless is not a setting, and one
|
||||
/// that can lock the operator out of their own node is a footgun.
|
||||
const MIN_IDLE_SECS: u64 = 60;
|
||||
const MAX_IDLE_SECS: u64 = 90 * 24 * 3600;
|
||||
const MIN_ABSOLUTE_SECS: u64 = 300;
|
||||
const MAX_ABSOLUTE_SECS: u64 = 365 * 24 * 3600;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DeviceClass {
|
||||
/// Ordinary browser on a phone or laptop. Policy applies as configured.
|
||||
Browser,
|
||||
/// A screen nobody logs into — a wall-mounted dashboard or TV. Being
|
||||
/// signed out mid-view is the failure mode here, not a stale session:
|
||||
/// the device is physically in the home, and there is no keyboard to
|
||||
/// re-authenticate with. Exempt from the idle timeout, still subject to
|
||||
/// the absolute cap so a stolen box does not stay authenticated forever.
|
||||
Kiosk,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionPolicy {
|
||||
/// Silence after which a session token stops validating.
|
||||
pub idle_timeout_secs: u64,
|
||||
/// Hard ceiling from login, regardless of activity. `None` = no cap.
|
||||
pub absolute_timeout_secs: Option<u64>,
|
||||
/// Re-prompt for the password before actions that move money, however
|
||||
/// fresh the session is. Independent of the timeouts on purpose: it is
|
||||
/// the control that matters when funds are involved, and it costs the
|
||||
/// operator nothing the rest of the time.
|
||||
pub reauth_for_funds: bool,
|
||||
}
|
||||
|
||||
impl Default for SessionPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// A day of silence, matching the previous hard-coded constant so
|
||||
// existing nodes see no behaviour change until someone chooses.
|
||||
idle_timeout_secs: 86_400,
|
||||
// 30 days, aligned with the login token's own lifetime: a
|
||||
// session that outlived the token which could refresh it would
|
||||
// be an oddity.
|
||||
absolute_timeout_secs: Some(30 * 24 * 3600),
|
||||
reauth_for_funds: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionPolicy {
|
||||
/// Clamp to the supported range. Applied on load as well as on save, so
|
||||
/// a hand-edited file cannot disable expiry by writing `0`.
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
self.idle_timeout_secs = self.idle_timeout_secs.clamp(MIN_IDLE_SECS, MAX_IDLE_SECS);
|
||||
self.absolute_timeout_secs = self
|
||||
.absolute_timeout_secs
|
||||
.map(|v| v.clamp(MIN_ABSOLUTE_SECS, MAX_ABSOLUTE_SECS))
|
||||
// An absolute cap below the idle timeout would expire sessions
|
||||
// while they are still active, which reads as random logouts.
|
||||
.map(|v| v.max(self.idle_timeout_secs));
|
||||
self
|
||||
}
|
||||
|
||||
/// Idle timeout for a given device, or `None` when idleness is not a
|
||||
/// reason to expire (kiosk screens).
|
||||
pub fn idle_timeout_for(&self, class: DeviceClass) -> Option<u64> {
|
||||
match class {
|
||||
DeviceClass::Browser => Some(self.idle_timeout_secs),
|
||||
DeviceClass::Kiosk => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Has a session expired? `age` is time since login, `idle` since last
|
||||
/// use. Both are checked because either alone is insufficient: idle
|
||||
/// never fires on a polling dashboard, and absolute alone leaves a
|
||||
/// forgotten tab usable for a month.
|
||||
pub fn is_expired(&self, class: DeviceClass, age_secs: u64, idle_secs: u64) -> bool {
|
||||
if let Some(limit) = self.absolute_timeout_secs {
|
||||
if age_secs >= limit {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
match self.idle_timeout_for(class) {
|
||||
Some(limit) => idle_secs >= limit,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> SessionPolicy {
|
||||
let path = data_dir.join(FILE_PATH);
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice::<SessionPolicy>(&bytes)
|
||||
.map(SessionPolicy::sanitized)
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "session policy unreadable; using defaults");
|
||||
SessionPolicy::default()
|
||||
}),
|
||||
Err(_) => SessionPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save(data_dir: &Path, policy: SessionPolicy) -> anyhow::Result<SessionPolicy> {
|
||||
let policy = policy.sanitized();
|
||||
let path = data_dir.join(FILE_PATH);
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, serde_json::to_vec_pretty(&policy)?).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_previous_hardcoded_behaviour() {
|
||||
let p = SessionPolicy::default();
|
||||
assert_eq!(p.idle_timeout_secs, 86_400);
|
||||
assert!(p.reauth_for_funds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_cannot_be_disabled_by_hand_editing_the_file() {
|
||||
let p = SessionPolicy {
|
||||
idle_timeout_secs: 0,
|
||||
absolute_timeout_secs: Some(0),
|
||||
reauth_for_funds: false,
|
||||
}
|
||||
.sanitized();
|
||||
assert!(p.idle_timeout_secs >= MIN_IDLE_SECS);
|
||||
assert!(p.absolute_timeout_secs.unwrap() >= MIN_ABSOLUTE_SECS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_cap_is_never_shorter_than_idle() {
|
||||
// Otherwise a session dies while actively in use, which the operator
|
||||
// experiences as being logged out at random.
|
||||
let p = SessionPolicy {
|
||||
idle_timeout_secs: 7 * 24 * 3600,
|
||||
absolute_timeout_secs: Some(3600),
|
||||
reauth_for_funds: true,
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(p.absolute_timeout_secs.unwrap(), p.idle_timeout_secs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kiosk_never_expires_from_idleness_but_still_has_a_ceiling() {
|
||||
let p = SessionPolicy::default();
|
||||
let a_week = 7 * 24 * 3600;
|
||||
assert!(!p.is_expired(DeviceClass::Kiosk, 60, a_week));
|
||||
assert!(p.is_expired(DeviceClass::Browser, 60, a_week));
|
||||
// The absolute cap still applies to the TV.
|
||||
assert!(p.is_expired(DeviceClass::Kiosk, 31 * 24 * 3600, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polling_dashboard_still_eventually_expires() {
|
||||
// idle never grows because the page polls; only the cap saves us.
|
||||
let p = SessionPolicy::default();
|
||||
assert!(p.is_expired(DeviceClass::Browser, 30 * 24 * 3600, 0));
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,20 @@ fn is_newer(candidate: &str, current: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary OTA origin. Named host over TLS rather than the bare IP it used
|
||||
/// to be: the IP pinned the fleet to one machine and one plaintext port, so
|
||||
/// moving or fronting the origin meant an OTA to change where OTAs come
|
||||
/// from — the one update you cannot ship if the origin is unreachable. The
|
||||
/// signature is what establishes trust (see `trust::anchor`), not the
|
||||
/// transport, but HTTPS also stops a network observer seeing which version
|
||||
/// a node runs.
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||
"https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
|
||||
/// The previous IP-based origin, kept as an automatic fallback so a node
|
||||
/// whose DNS or TLS is broken still updates. Dropped from the mirror list
|
||||
/// once the fleet has moved.
|
||||
const LEGACY_UPDATE_MANIFEST_URL: &str =
|
||||
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
const UPDATE_STATE_FILE: &str = "update_state.json";
|
||||
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
|
||||
@@ -113,10 +126,19 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
fn default_mirrors() -> Vec<UpdateMirror> {
|
||||
vec![UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Server 1 (OVH)".to_string(),
|
||||
}]
|
||||
vec![
|
||||
UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Archipelago Foundation".to_string(),
|
||||
},
|
||||
// Fallback, tried only if the named origin fails: a node whose DNS
|
||||
// or clock is wrong (both break TLS) must still be able to update
|
||||
// itself, and the signature check is what makes either source safe.
|
||||
UpdateMirror {
|
||||
url: LEGACY_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Direct (fallback)".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Load the operator-configured mirror list. Returns defaults if the
|
||||
@@ -186,15 +208,18 @@ fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
|
||||
}
|
||||
for mirror in list.iter_mut() {
|
||||
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
mirror.label = "Server 1 (OVH)".to_string();
|
||||
mirror.label = "Archipelago Foundation".to_string();
|
||||
} else if mirror.url == LEGACY_UPDATE_MANIFEST_URL {
|
||||
mirror.label = "Direct (fallback)".to_string();
|
||||
}
|
||||
}
|
||||
list.sort_by_key(|m| {
|
||||
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
// Named origin first, its IP fallback second, anything the operator
|
||||
// added after that. Ordering matters: the list is tried in order, so a
|
||||
// stale entry sitting first costs a timeout on every check.
|
||||
list.sort_by_key(|m| match m.url.as_str() {
|
||||
u if u == DEFAULT_UPDATE_MANIFEST_URL => 0,
|
||||
u if u == LEGACY_UPDATE_MANIFEST_URL => 1,
|
||||
_ => 2,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2373,8 +2398,18 @@ mod tests {
|
||||
async fn test_load_mirrors_returns_defaults_when_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = load_mirrors(dir.path()).await.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert!(list[0].url.contains("146.59.87.168"));
|
||||
// The named origin leads, its IP fallback follows. A node with broken
|
||||
// DNS or a wrong clock (both break TLS) must still have a way to
|
||||
// update; the signature is what makes either source trustworthy.
|
||||
assert_eq!(list.len(), 2);
|
||||
assert!(
|
||||
list[0]
|
||||
.url
|
||||
.starts_with("https://source.archipelago-foundation.org/"),
|
||||
"the named origin must be primary, got {}",
|
||||
list[0].url
|
||||
);
|
||||
assert!(list[1].url.contains("146.59.87.168"));
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
|
||||
"tx1138 was retired as a release server and must not be a default mirror"
|
||||
|
||||
@@ -1040,6 +1040,12 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mut received_total = 0u64;
|
||||
// MintClient translates the mint's NUT error code into plain language and
|
||||
// puts it at the top of the error chain (see `mint_error` in
|
||||
// mint_client.rs); `{}` surfaces that, `{:#}` keeps the raw status/body
|
||||
// for the log. Remember the last one so a total failure can tell the user
|
||||
// *why* instead of just "nothing was received".
|
||||
let mut last_reason: Option<String> = None;
|
||||
|
||||
// Swap proofs at each mint
|
||||
for entry in &token.token {
|
||||
@@ -1051,14 +1057,18 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
received_total += amount;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
|
||||
warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e);
|
||||
last_reason = Some(e.to_string());
|
||||
// Continue with other mints if any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if received_total == 0 {
|
||||
anyhow::bail!("Failed to receive any proofs from token");
|
||||
match last_reason {
|
||||
Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason),
|
||||
None => anyhow::bail!("Failed to receive any proofs from token"),
|
||||
}
|
||||
}
|
||||
|
||||
wallet.record_tx(
|
||||
|
||||
@@ -59,6 +59,72 @@ pub struct MintResult {
|
||||
pub proofs: Vec<Proof>,
|
||||
}
|
||||
|
||||
/// Translate a Cashu NUT "transaction validation" error code into plain
|
||||
/// language a wallet user can act on. Mints respond to a rejected request
|
||||
/// with `{"code": N, "detail": "..."}`; `detail` is implementation-defined
|
||||
/// free text, but `code` is the stable identifier from the spec
|
||||
/// (https://github.com/cashubtc/nuts/blob/main/error_codes.md). Covers the
|
||||
/// 10001-11017 "proof/transaction validation" range plus the 12001-12003
|
||||
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call
|
||||
/// can actually hit. Returns `None` for anything else (e.g. Lightning/quote
|
||||
/// codes in the 20000s) so the caller falls back to the mint's own `detail`.
|
||||
fn describe_mint_error_code(code: i64) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
10001 => "The mint rejected these coins as invalid.",
|
||||
11001 => "This ecash has already been redeemed — it can't be claimed twice.",
|
||||
11002 => "This ecash is already being redeemed elsewhere — try again in a moment.",
|
||||
11003 => "The mint already issued new coins for this exact request — there's nothing left to redeem.",
|
||||
11004 => "This request is still being processed by the mint — try again in a moment.",
|
||||
11005 => "The token's amounts don't add up (inputs don't match outputs) — it may be corrupt.",
|
||||
11006 => "That amount is outside the range this mint allows.",
|
||||
11007 => "This token contains duplicate coins — it may be corrupt or already used.",
|
||||
11008 => "The mint rejected this as a duplicate request.",
|
||||
11009 | 11010 => "This token mixes incompatible currency units — the mint rejected it.",
|
||||
11011 => "That Lightning invoice has no amount, which isn't supported here.",
|
||||
11012 => "The amount requested doesn't match the Lightning invoice.",
|
||||
11013 => "The mint doesn't support this currency unit.",
|
||||
11014 | 11015 => "This token has too many coins for the mint to process in one request.",
|
||||
11016 => "Duplicate quote IDs were sent in this request.",
|
||||
11017 => "Too many items were sent in a single request.",
|
||||
12001 => "The mint no longer recognizes the keyset that signed this token.",
|
||||
12002 => "The mint's signing key for this token is inactive.",
|
||||
12003 => "The mint's signing key for this token has expired.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a mint's error body (`{"code": N, "detail": "..."}`) and pick the
|
||||
/// best user-facing message: the plain-language translation when we know the
|
||||
/// code, otherwise the mint's own `detail` text, otherwise the raw body.
|
||||
fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
|
||||
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
|
||||
let code = parsed
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("code"))
|
||||
.and_then(|c| c.as_i64());
|
||||
let detail = parsed
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("detail"))
|
||||
.and_then(|d| d.as_str());
|
||||
|
||||
if let Some(friendly) = code.and_then(describe_mint_error_code) {
|
||||
return friendly.to_string();
|
||||
}
|
||||
match detail {
|
||||
Some(d) if !d.is_empty() => d.to_string(),
|
||||
_ => format!("mint returned {} with no further detail", status),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the error for a failed mint HTTP call: `op` + status + raw body as
|
||||
/// the technical cause (visible via `{:#}` in logs), with the plain-language
|
||||
/// translation layered on top via `.context()` so `{}` — what reaches the
|
||||
/// wallet user — shows something actionable instead of raw mint JSON.
|
||||
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
|
||||
let friendly = describe_mint_error_body(status, body);
|
||||
anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly)
|
||||
}
|
||||
|
||||
/// HTTP client for a single Cashu mint.
|
||||
pub struct MintClient {
|
||||
url: String,
|
||||
@@ -146,7 +212,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Mint quote failed ({}): {}", status, body);
|
||||
return Err(mint_error("Mint quote", status, &body));
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse mint quote")
|
||||
@@ -212,7 +278,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Mint tokens failed ({}): {}", status, body);
|
||||
return Err(mint_error("Minting tokens", status, &body));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse mint response")?;
|
||||
@@ -266,7 +332,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Melt quote failed ({}): {}", status, body);
|
||||
return Err(mint_error("Melt quote", status, &body));
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse melt quote")
|
||||
@@ -293,7 +359,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Melt failed ({}): {}", status, body);
|
||||
return Err(mint_error("Melt", status, &body));
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse melt response")
|
||||
@@ -337,7 +403,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Swap failed ({}): {}", status, body);
|
||||
return Err(mint_error("Swap", status, &body));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse swap response")?;
|
||||
|
||||
@@ -599,6 +599,19 @@ pub struct PortMapping {
|
||||
/// means the author expected an exemption they did not get.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_rationale: Option<String>,
|
||||
/// Forward the node session cookie to the app on authorised requests.
|
||||
///
|
||||
/// The gate normally strips its own credential before proxying — an app
|
||||
/// must never be in a position to log or replay the node session. The
|
||||
/// first-party companion UIs (lnd-ui, bitcoin-ui, electrs-ui, fips-ui)
|
||||
/// are the exception their design requires: their nginx forwards the
|
||||
/// browser's session cookie to the daemon's authenticated endpoints
|
||||
/// (`/proxy/lnd/*`, `/rpc/v1`, `/lnd-connect-info`), so stripping it
|
||||
/// breaks every data call behind the gate with a 401 while the page
|
||||
/// shell still renders (observed as "LND UI unreachable", 2026-08-05).
|
||||
/// Only meaningful on a `auth: gated` port.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub session_passthrough: bool,
|
||||
}
|
||||
|
||||
impl PortMapping {
|
||||
@@ -626,6 +639,7 @@ impl From<(u16, u16)> for PortMapping {
|
||||
bind: String::new(),
|
||||
auth: None,
|
||||
auth_rationale: None,
|
||||
session_passthrough: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1703,9 +1717,17 @@ app:
|
||||
}
|
||||
}
|
||||
exempt.sort();
|
||||
// 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2),
|
||||
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
|
||||
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack
|
||||
// 8091/9738/10010, lnd 9735/10009/18080, netbird 3478/8086/8087,
|
||||
// pine TLS 10381 + the three voice ports (10200/10300/10400 — the
|
||||
// disclosed known gap), router SSDP/mDNS 1900/5353. Every one is a
|
||||
// deliberate, rationale-carrying exemption; the release-gate test
|
||||
// stage timed out that cycle, so the count here lagged at 17.
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
17,
|
||||
25,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -366,6 +366,7 @@ impl PodmanClient {
|
||||
}
|
||||
|
||||
let mut mounts = Vec::new();
|
||||
let mut named_volumes = Vec::new();
|
||||
for volume in &manifest.app.volumes {
|
||||
if volume.volume_type == "tmpfs" {
|
||||
let options: Vec<String> = volume
|
||||
@@ -382,6 +383,19 @@ impl PodmanClient {
|
||||
"type": "tmpfs",
|
||||
"options": options,
|
||||
}));
|
||||
} else if volume.volume_type == "volume" {
|
||||
// Named podman volume. The libpod create spec carries these in
|
||||
// the separate `volumes` field ({Name, Dest, Options}), NOT in
|
||||
// `mounts`: sending one as a bind mount makes the API treat
|
||||
// the bare volume name as a host path and the create fails —
|
||||
// which left indeedhub-postgres/-minio permanently absent on
|
||||
// legacy-path nodes (the reconciler removed the old container
|
||||
// for drift, then could never create its replacement).
|
||||
named_volumes.push(serde_json::json!({
|
||||
"Name": volume.source,
|
||||
"Dest": volume.target,
|
||||
"Options": volume.options,
|
||||
}));
|
||||
} else {
|
||||
mounts.push(serde_json::json!({
|
||||
"destination": volume.target,
|
||||
@@ -464,6 +478,7 @@ impl PodmanClient {
|
||||
"image": image_ref,
|
||||
"portmappings": port_mappings,
|
||||
"mounts": mounts,
|
||||
"volumes": named_volumes,
|
||||
"env": env_map,
|
||||
"secret_env": secret_env_map,
|
||||
"labels": labels_map,
|
||||
|
||||
@@ -18,6 +18,17 @@ server {
|
||||
root /opt/archipelago/web-ui;
|
||||
index index.html;
|
||||
|
||||
# This node's CA, for devices that have not trusted it yet. Deliberately
|
||||
# unauthenticated and served over plain HTTP: a device fetches this BEFORE
|
||||
# it can validate the node's own certificate, so requiring HTTPS or a login
|
||||
# here would be a chicken-and-egg. It is a public certificate — never a key
|
||||
# — and the dashboard shows its fingerprint so it can be checked on sight.
|
||||
location = /ca.crt {
|
||||
alias /etc/archipelago/ssl/ca-download.crt;
|
||||
default_type application/x-x509-ca-cert;
|
||||
add_header Content-Disposition 'attachment; filename="archipelago-node-ca.crt"';
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
@@ -934,6 +945,13 @@ server {
|
||||
index index.html;
|
||||
include snippets/archipelago-pwa.conf;
|
||||
|
||||
# Same CA download over HTTPS — see the note in the HTTP block above.
|
||||
location = /ca.crt {
|
||||
alias /etc/archipelago/ssl/ca-download.crt;
|
||||
default_type application/x-x509-ca-cert;
|
||||
add_header Content-Disposition 'attachment; filename="archipelago-node-ca.crt"';
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.123-alpha",
|
||||
"version": "1.7.125-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.123-alpha",
|
||||
"version": "1.7.125-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.123-alpha",
|
||||
"version": "1.7.125-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.19.4",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
|
||||
"repoUrl": "https://github.com/portainer/portainer",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
|
||||
@@ -0,0 +1,927 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Archipelago — Seed & Entropy</title>
|
||||
<link rel="icon" href="/favicon-v2.ico">
|
||||
<style>
|
||||
/* Everything below is lifted from the app's own stylesheets
|
||||
(src/style.css + views/dashboard/dashboard-styles.css) so this page is
|
||||
the dashboard, not a lookalike. No new container styles: .glass-card is
|
||||
the only box. */
|
||||
|
||||
:root { color-scheme: dark; }
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-Bold.ttf') format('truetype');
|
||||
font-weight: 700; font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-ExtraBold.ttf') format('truetype');
|
||||
font-weight: 800; font-style: normal;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: 'Avenir Next', system-ui, -apple-system, sans-serif;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1.7;
|
||||
font-size: 16px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Dashboard background layer — the Settings wallpaper, as the app uses it */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed; inset: 0; z-index: -2;
|
||||
background: #000 url('/assets/img/bg-settings.webp') center center / cover no-repeat;
|
||||
}
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed; inset: 0; z-index: -1;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.45), rgba(0,0,0,0.62));
|
||||
}
|
||||
|
||||
.dashboard-view { display: flex; min-height: 100vh; }
|
||||
|
||||
/* ---- Sidebar (dashboard-styles.css) ---- */
|
||||
aside {
|
||||
width: 256px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
z-index: 10;
|
||||
}
|
||||
.sidebar-shell {
|
||||
width: 100%; height: 100%; min-height: 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-inner { display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden; }
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
margin-bottom: 2rem; padding: 1.5rem 1.5rem 0; flex-shrink: 0;
|
||||
}
|
||||
.sidebar-logo h2 {
|
||||
font-size: 1.125rem; font-weight: 600; color: #fff;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.sidebar-logo p { font-size: 0.75rem; color: rgba(255, 255, 255, 0.6); }
|
||||
|
||||
/* AnimatedLogo.vue — gradient ring + staggered square reveal */
|
||||
.logo-gradient-border {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
width: 3.5rem; height: 3.5rem;
|
||||
border-radius: 9999px;
|
||||
padding: 3px;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.6) 0%, rgba(0,0,0,0.8) 100%);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.logo-gradient-border::after {
|
||||
content: '';
|
||||
position: absolute; inset: 3px;
|
||||
border-radius: 9999px;
|
||||
background: #000;
|
||||
z-index: 0;
|
||||
}
|
||||
.logo-gradient-border svg {
|
||||
border-radius: 9999px;
|
||||
display: block; position: relative; z-index: 1;
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
.logo-square {
|
||||
opacity: 0;
|
||||
animation: logo-square-in 3s ease-out infinite;
|
||||
animation-delay: var(--delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
@keyframes logo-square-in {
|
||||
0% { opacity: 0; }
|
||||
15% { opacity: 1; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1; min-height: 0;
|
||||
overflow-y: auto; overscroll-behavior: contain;
|
||||
padding: 1rem 1.5rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
|
||||
}
|
||||
.sidebar-nav::-webkit-scrollbar { width: 6px; }
|
||||
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.22); border-radius: 999px; }
|
||||
.sidebar-nav > * + * { margin-top: 0.5rem; }
|
||||
|
||||
.sidebar-nav-item {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-decoration: none;
|
||||
font-size: 0.9375rem;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
.sidebar-nav-item:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||
.sidebar-nav-item svg { width: 1.25rem; height: 1.25rem; flex-shrink: 0; }
|
||||
|
||||
/* nav-tab-active (style.css) — the app's current-section treatment */
|
||||
.nav-tab-active {
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.25);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-tab-active::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.3), transparent);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-bottom {
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.18), transparent 100%);
|
||||
}
|
||||
|
||||
/* Entrance animation, mirroring the dashboard's staggered sidebar reveal */
|
||||
.sidebar-logo { opacity: 0; animation: sidebar-logo-in 0.5s cubic-bezier(0.25,0.46,0.45,0.94) 0.05s forwards; }
|
||||
@keyframes sidebar-logo-in {
|
||||
0% { opacity: 0; transform: translateY(-8px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.sidebar-nav-item {
|
||||
opacity: 0;
|
||||
animation: sidebar-nav-item-in 0.4s cubic-bezier(0.25,0.46,0.45,0.94) forwards;
|
||||
animation-delay: calc(0.22s + var(--nav-stagger, 0) * 0.06s);
|
||||
}
|
||||
@keyframes sidebar-nav-item-in {
|
||||
0% { opacity: 0; transform: translateX(-12px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* ---- Main content ---- */
|
||||
main {
|
||||
flex: 1; min-width: 0;
|
||||
padding: 2.5rem 2rem 6rem;
|
||||
}
|
||||
.content { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
h1 {
|
||||
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||
font-size: 2rem; font-weight: 800;
|
||||
color: #fff;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
h2 {
|
||||
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||
font-size: 1.5rem; font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 3rem 0 0.25rem;
|
||||
scroll-margin-top: 1.5rem;
|
||||
}
|
||||
h3 { font-size: 1.125rem; font-weight: 600; color: #fff; margin: 1.75rem 0 0.5rem; }
|
||||
h4 { font-size: 0.9375rem; font-weight: 600; color: #fff; margin: 0 0 0.35rem; }
|
||||
p { margin: 0.5rem 0 1rem; }
|
||||
.glass-card > p:last-child, .glass-card > ul:last-child { margin-bottom: 0; }
|
||||
ul, ol { margin: 0.5rem 0 1rem 1.25rem; }
|
||||
li { margin: 0.25rem 0; }
|
||||
.lede { color: rgba(255,255,255,0.6); font-size: 0.9375rem; margin-bottom: 1rem; }
|
||||
a { color: #fb923c; }
|
||||
|
||||
/* The section label the app uses above grouped content */
|
||||
.section-label {
|
||||
display: block;
|
||||
font-size: 0.6875rem; font-weight: 700;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.section-label.accent { color: #fb923c; }
|
||||
|
||||
/* glass-card (style.css) — the ONLY container on this page */
|
||||
.glass-card {
|
||||
background-color: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
border-radius: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin: 1rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Layout only — no new box chrome */
|
||||
.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); margin: 1rem 0; }
|
||||
.grid .glass-card { margin: 0; }
|
||||
|
||||
/* Orange count badge, as used on sidebar nav items */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 1.25rem; height: 1.25rem; padding: 0 0.4rem;
|
||||
border-radius: 9999px;
|
||||
background: #f97316; color: #fff;
|
||||
font-size: 10px; font-weight: 700;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.badge.muted { background: rgba(255,255,255,0.14); color: rgba(255,255,255,0.85); }
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.pills { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
|
||||
th {
|
||||
text-align: left; padding: 0.5rem 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.6875rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
|
||||
white-space: nowrap;
|
||||
}
|
||||
td { padding: 0.625rem 0.75rem; border-bottom: 1px solid rgba(255, 255, 255, 0.06); vertical-align: top; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
code {
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.8125rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
color: #fb923c;
|
||||
}
|
||||
pre {
|
||||
font-family: 'Menlo', 'Monaco', monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
overflow-x: auto;
|
||||
}
|
||||
pre code { background: none; padding: 0; color: rgba(255,255,255,0.85); }
|
||||
pre .a { color: #fb923c; font-weight: 600; }
|
||||
pre .g { color: #4ade80; }
|
||||
pre .b { color: #60a5fa; }
|
||||
pre .r { color: #f87171; }
|
||||
pre .p { color: #a78bfa; }
|
||||
pre .y { color: #facc15; }
|
||||
|
||||
.ok { color: #4ade80; }
|
||||
.warn { color: #facc15; }
|
||||
.bad { color: #f87171; }
|
||||
|
||||
ol.steps { list-style: none; margin-left: 0; counter-reset: s; }
|
||||
ol.steps li {
|
||||
counter-increment: s;
|
||||
position: relative;
|
||||
padding-left: 2.25rem;
|
||||
margin: 0.85rem 0;
|
||||
}
|
||||
ol.steps li::before {
|
||||
content: counter(s);
|
||||
position: absolute; left: 0; top: 0.15rem;
|
||||
width: 1.5rem; height: 1.5rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(249, 115, 22, 0.18);
|
||||
color: #fb923c;
|
||||
font-size: 0.75rem; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
aside { display: none; }
|
||||
main { padding: 1.5rem 1rem 4rem; }
|
||||
h1 { font-size: 1.5rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="dashboard-view">
|
||||
|
||||
<aside>
|
||||
<div class="sidebar-shell">
|
||||
<div class="sidebar-inner">
|
||||
|
||||
<div class="sidebar-logo">
|
||||
<div class="logo-gradient-border">
|
||||
<svg viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="Neode">
|
||||
<rect width="1024" height="1024" fill="#030202"/>
|
||||
<rect class="logo-square" style="--delay:0ms" x="357.614" y="318" width="71.007" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:100ms" x="436.152" y="318" width="72.082" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:200ms" x="515.766" y="318" width="72.082" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:300ms" x="595.379" y="318" width="71.007" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:400ms" x="595.379" y="396.46" width="71.007" height="72.011" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:500ms" x="673.917" y="396.46" width="72.083" height="72.011" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:600ms" x="278" y="475.994" width="72.083" height="72.012" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:700ms" x="357.614" y="475.994" width="71.007" height="72.012" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:800ms" x="436.152" y="475.994" width="72.082" height="72.012" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:900ms" x="515.766" y="475.994" width="72.082" height="72.012" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1000ms" x="595.379" y="475.994" width="71.007" height="72.012" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1100ms" x="673.917" y="475.994" width="72.083" height="72.012" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1200ms" x="278" y="555.529" width="72.083" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1300ms" x="357.614" y="555.529" width="71.007" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1400ms" x="595.379" y="555.529" width="71.007" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1500ms" x="673.917" y="555.529" width="72.083" height="70.936" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1600ms" x="357.614" y="633.989" width="71.007" height="72.011" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1700ms" x="436.152" y="633.989" width="72.082" height="72.011" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1800ms" x="515.766" y="633.989" width="72.082" height="72.011" fill="white"/>
|
||||
<rect class="logo-square" style="--delay:1900ms" x="595.379" y="633.989" width="71.007" height="72.011" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="min-width:0;flex:1">
|
||||
<h2>Seed & Entropy</h2>
|
||||
<p>Node security guide</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav" aria-label="Guide sections">
|
||||
<a class="sidebar-nav-item nav-tab-active" href="#overview" style="--nav-stagger:0">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
|
||||
<span>Overview</span>
|
||||
</a>
|
||||
<a class="sidebar-nav-item" href="#creation" style="--nav-stagger:1">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span>How it's created</span>
|
||||
</a>
|
||||
<a class="sidebar-nav-item" href="#guardrails" style="--nav-stagger:2">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
|
||||
<span>Guardrails</span>
|
||||
<span class="badge">5</span>
|
||||
</a>
|
||||
<a class="sidebar-nav-item" href="#storage" style="--nav-stagger:3">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
|
||||
<span>Stored on disk</span>
|
||||
</a>
|
||||
<a class="sidebar-nav-item" href="#derivation" style="--nav-stagger:4">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/></svg>
|
||||
<span>Derivation tree</span>
|
||||
</a>
|
||||
<a class="sidebar-nav-item" href="#failures" style="--nav-stagger:5">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
<span>Failures</span>
|
||||
</a>
|
||||
<a class="sidebar-nav-item" href="#restore" style="--nav-stagger:6">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
<span>Restore</span>
|
||||
</a>
|
||||
<a class="sidebar-nav-item" href="#verify" style="--nav-stagger:7">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span>Verify it yourself</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-bottom">
|
||||
<a class="sidebar-nav-item" href="/dashboard/settings" style="--nav-stagger:8">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
|
||||
<span>Back to Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="content">
|
||||
|
||||
<h1>Your node's seed & entropy</h1>
|
||||
<p class="lede">How 32 random bytes become every key this node owns — where the randomness comes from, what protects it, and exactly what your 24 words can and cannot bring back.</p>
|
||||
<div class="pills">
|
||||
<span class="pill">256-bit entropy</span>
|
||||
<span class="pill">BIP-39 · 24 words</span>
|
||||
<span class="pill">HKDF-SHA256</span>
|
||||
<span class="pill">Kernel CSPRNG only</span>
|
||||
<span class="pill">KEY-05 hardened</span>
|
||||
</div>
|
||||
|
||||
<h2 id="overview">Overview</h2>
|
||||
<p class="lede">One master secret, many keys — by design.</p>
|
||||
|
||||
<p>
|
||||
Almost everything cryptographic on this node — its identity, its Nostr keys, its mesh
|
||||
transport keys, its Lightning wallet — grows from a <strong>single master seed</strong>:
|
||||
32 bytes of randomness drawn once, shown to you once as a 24-word recovery phrase, and
|
||||
never stored in raw form anywhere.
|
||||
</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<span class="section-label accent">In plain words</span>
|
||||
<p>
|
||||
Think of the seed as an acorn. Every branch of the tree — your identity, your wallet,
|
||||
your mesh radio's name — grows from it in a fixed, repeatable pattern. Plant the same
|
||||
acorn on new hardware by typing your 24 words and the <em>same tree</em> grows back,
|
||||
branch for branch. That is why those words are the most valuable thing your node ever
|
||||
shows you, and why anyone who copies them owns your tree.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<pre><span class="b">Linux kernel CSPRNG</span> (interrupt timing, jitter, CPU RNG)
|
||||
│
|
||||
│ getrandom(2) — via an explicitly named <span class="a">OsRng</span>, nothing else allowed
|
||||
▼
|
||||
<span class="a">32 bytes raw entropy</span> ──▶ degenerate-draw check ──▶ <span class="r">refuse & wipe if suspicious</span>
|
||||
│
|
||||
│ BIP-39 encoding
|
||||
▼
|
||||
<span class="g">24-word recovery phrase</span> ←── the only form you ever see or back up
|
||||
│
|
||||
│ PBKDF2-HMAC-SHA512 × 2048
|
||||
▼
|
||||
<span class="a">64-byte master seed</span> ←── lives only in RAM, never written to disk
|
||||
│
|
||||
├─ HKDF "archipelago/node/ed25519/v1" ──▶ <span class="g">Node identity key + DID</span>
|
||||
├─ HKDF "archipelago/nostr-node/…/v1" ──▶ <span class="p">Node Nostr key (npub)</span>
|
||||
├─ HKDF "archipelago/fips/secp256k1/v1" ──▶ <span class="b">FIPS mesh transport key</span>
|
||||
├─ HKDF "archipelago/identity/{i}/…/v1" ──▶ <span class="g">Personal identities</span>
|
||||
├─ BIP-32 m/44'/1237'/0'/0/{i} (NIP-06) ──▶ <span class="p">Personal Nostr keys</span>
|
||||
├─ HKDF "archipelago/lnd/entropy/v1" ──▶ <span class="y">Lightning entropy → aezeed</span>
|
||||
└─ BIP-32 m/84'/0'/0' ──▶ <span class="y">Bitcoin xprv (dormant)</span>
|
||||
│
|
||||
│ and from the node key, second-order:
|
||||
▼
|
||||
├─ <span class="b">Reticulum / LXMF mesh identity</span>
|
||||
├─ <span class="g">Message-store + contacts encryption</span>
|
||||
└─ <span class="g">Credential-store key</span>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<h4>One rule to remember</h4>
|
||||
<p>
|
||||
If it is on the diagram above, your 24 words rebuild it from scratch, on any hardware,
|
||||
forever. If it is not on the diagram — session tokens, app passwords, WireGuard keys,
|
||||
Lightning channel state — it is independent randomness, protected by other backups.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 id="creation">How it's created</h2>
|
||||
<p class="lede">One named source. No mixing. No silent defaults.</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<span class="section-label accent">In plain words</span>
|
||||
<p>
|
||||
Computers cannot invent randomness — they collect it. The Linux kernel constantly
|
||||
harvests unpredictable physical noise (the exact nanosecond a network card interrupts,
|
||||
timing jitter between CPU cores, the CPU's hardware random generator) into a
|
||||
cryptographic pool. Archipelago rolls its dice by asking that pool directly, and only
|
||||
that pool. There is deliberately no blending of other sources: a single, named,
|
||||
well-studied source is auditable, whereas a blend is a place for bugs to hide.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>Technically</h3>
|
||||
<p>
|
||||
The master seed is generated by <code>MasterSeed::generate()</code> in
|
||||
<code>core/archipelago/src/seed.rs</code>. It fills a 32-byte buffer using
|
||||
<code>rand::rngs::OsRng</code> — a thin wrapper around the <code>getrandom(2)</code>
|
||||
system call, which reads the kernel CSPRNG (same source as <code>/dev/urandom</code>,
|
||||
but immune to file-descriptor exhaustion and chroot tricks).
|
||||
</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<pre><code>let mut entropy = [0u8; 32];
|
||||
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut entropy)?; // guarded draw
|
||||
let mnemonic = bip39::Mnemonic::from_entropy(&entropy)?; // → 24 words
|
||||
entropy.zeroize(); // wipe raw bytes</code></pre>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
<li><strong>Exactly 32 bytes / 256 bits</strong> — the maximum BIP-39 strength, encoding to 24 words.</li>
|
||||
<li><strong>The RNG is named at the call site.</strong> No function anywhere generates key material with a default or implicit RNG.</li>
|
||||
<li><strong>The RNG type is compiler-enforced.</strong> Key generation only accepts RNGs on a sealed allowlist (<code>KeyGenRng</code>) whose single production member is <code>OsRng</code>.</li>
|
||||
<li><strong>The buffer is zeroized</strong> on every path, success or failure.</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
The words are then stretched into the 64-byte master seed by standard BIP-39:
|
||||
PBKDF2-HMAC-SHA512, 2048 rounds, empty passphrase. That 64-byte value is a 512-bit
|
||||
expansion of the same 256 bits of entropy — not extra randomness. It exists only in
|
||||
memory, is recomputed from the words when needed, and never touches disk.
|
||||
</p>
|
||||
|
||||
<h3>When the seed is born</h3>
|
||||
<p>At onboarding — not at first boot.</p>
|
||||
<div class="glass-card">
|
||||
<ol class="steps">
|
||||
<li><strong>First boot: a placeholder.</strong> A freshly flashed node boots with a random <em>temporary</em> identity key so services can start. It is not seed-derived and is about to be thrown away.</li>
|
||||
<li><strong>Onboarding: the real draw.</strong> At the "Recovery phrase" step, the <code>seed.generate</code> RPC performs the guarded 32-byte draw and shows you the 24 words.</li>
|
||||
<li><strong>Derivation.</strong> Node key, DID, Nostr key, FIPS mesh key and your first identity are derived and written to <code>/var/lib/archipelago/identity/</code> at mode 0600, overwriting the placeholder.</li>
|
||||
<li><strong>Password setup: the backup is sealed.</strong> The words are encrypted under your login password and stored as <code>master_seed.enc</code>, so you can reveal them again later.</li>
|
||||
</ol>
|
||||
<p>
|
||||
Generation is idempotent for 10 minutes and serialised behind a lock: a browser refresh
|
||||
returns the <em>same</em> words rather than minting a second seed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 id="guardrails">Guardrails</h2>
|
||||
<p class="lede">Defence in depth around a single random draw.</p>
|
||||
|
||||
<div class="grid">
|
||||
<div class="glass-card">
|
||||
<h4><span class="badge muted">1</span> Sealed RNG allowlist</h4>
|
||||
<p>Key draws only compile against RNG types on a closed, private allowlist. A refactor that swaps in a weak or deterministic RNG becomes a <em>compile error</em>, not a silent disaster.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4><span class="badge muted">2</span> Degenerate-draw refusal</h4>
|
||||
<p>Every draw is checked for three broken-RNG shapes: all zeros, all bytes identical, or a counting pattern. A match is refused and wiped — <strong>never retried</strong>, because retrying would mask a broken RNG instead of exposing it.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4><span class="badge muted">3</span> CSPRNG readiness ledger</h4>
|
||||
<p>Before generating, the node probes whether the kernel pool is fully initialised and appends the verdict to an append-only log at <code>security/csprng-readiness.jsonl</code> (0600). You can audit the entropy conditions your seed was born under, forever.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4><span class="badge muted">4</span> Build-time lint bans</h4>
|
||||
<p>CI bans <code>rand::random()</code> and <code>rand::thread_rng()</code> across the workspace — the two convenient entry points behind real-world wallet disasters. Using either fails the build.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4><span class="badge muted">5</span> Zeroization everywhere</h4>
|
||||
<p>Raw entropy, mnemonics and derived secrets are wiped from memory on every code path, including error paths, so key material does not linger in freed RAM or crash dumps.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<span class="section-label accent">In plain words</span>
|
||||
<h4>Why so paranoid about one function?</h4>
|
||||
<p>
|
||||
In 2026 a well-known hardware wallet shipped a bug where a refactor quietly switched
|
||||
seed generation to a <em>predictable</em> random source — no error, no warning, and seeds
|
||||
that looked perfectly normal. Predictable randomness is invisible: the words look random,
|
||||
the wallet works, and months later someone who can predict the generator drains it.
|
||||
Archipelago's answer is to make that entire class of bug impossible to compile, and to
|
||||
log the health of the random pool at the moment your seed was created.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>What the degenerate check does and doesn't do</h3>
|
||||
<p>
|
||||
It is deliberately closed-form: it recognises exactly three catastrophic shapes
|
||||
(all-zero, all-identical, ±1 counter). It is <em>not</em> a statistical entropy estimator —
|
||||
those cannot distinguish good randomness from a cleverly broken RNG and add false
|
||||
positives. The security load is carried by guardrails 1, 3 and 4; this is a tripwire for
|
||||
total RNG failure, such as a buffer that was never filled.
|
||||
</p>
|
||||
|
||||
<h3>Recent hardening</h3>
|
||||
<p>
|
||||
This system was audited and rebuilt in early August 2026. The headline finding: the
|
||||
mnemonic library was silently choosing its own RNG via a transitive default. It happened
|
||||
to be a secure one, but nothing guaranteed that, and a dependency update could have
|
||||
changed it with no diff in Archipelago's own code.
|
||||
</p>
|
||||
<div class="glass-card">
|
||||
<table>
|
||||
<tr><th>Date</th><th>Change</th></tr>
|
||||
<tr><td>Jul 30</td><td>Kernel CSPRNG readiness probe; non-determinism regression test (64 consecutive mnemonics must be unique).</td></tr>
|
||||
<tr><td>Jul 31</td><td>Full entropy audit published (findings F-01…F-13).</td></tr>
|
||||
<tr><td>Aug 1</td><td><strong>The pivotal fix:</strong> master-seed RNG made explicit — <code>OsRng</code> named at the call site, injected through a testable seam, pinned by a known-answer test.</td></tr>
|
||||
<tr><td>Aug 2</td><td>Audit widened: 43 defaulted-RNG call sites across 15 files migrated to explicit <code>OsRng</code>, including AEAD nonces and ecash key material.</td></tr>
|
||||
<tr><td>Aug 2</td><td>Onboarding RPCs gated — <code>seed.restore</code> now refuses on a provisioned node (previously an unauthenticated restore could hijack a live node; fixed before any release shipped it).</td></tr>
|
||||
<tr><td>Aug 2</td><td>KEY-05 layer landed: sealed allowlist, guarded draws, readiness ledger, clippy bans, supply-chain pinning of the <code>rand</code> crate.</td></tr>
|
||||
<tr><td>Aug 2</td><td>Legacy Bitcoin Core wallet-import path deleted — the master xprv is no longer handed to any external wallet process.</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 id="storage">Stored on disk</h2>
|
||||
<p class="lede">The words, encrypted — and the derived keys. Never the raw seed.</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<table>
|
||||
<tr><th>File</th><th>Contents</th><th>Protection</th></tr>
|
||||
<tr><td><code>identity/master_seed.enc</code></td><td>Your 24 words, encrypted</td><td>Argon2(login password) + ChaCha20-Poly1305, 0600</td></tr>
|
||||
<tr><td><code>identity/node_key</code></td><td>Node Ed25519 identity key</td><td>0600, seed-derived</td></tr>
|
||||
<tr><td><code>identity/nostr_secret</code></td><td>Node Nostr keypair</td><td>0600, seed-derived</td></tr>
|
||||
<tr><td><code>identity/fips_key</code></td><td>FIPS mesh transport key (bech32 nsec)</td><td>0600, seed-derived</td></tr>
|
||||
<tr><td><code>identity/identity_index</code></td><td>Next unused derivation index</td><td>Plain integer, not secret</td></tr>
|
||||
<tr><td><code>identities/<uuid>.json</code></td><td>Identity records: keys + metadata</td><td>0600; keys seed-derived, <em>metadata is not</em></td></tr>
|
||||
<tr><td><code>identity/lnd_aezeed.enc</code></td><td>Lightning wallet's own seed</td><td>Encrypted under the LND wallet password</td></tr>
|
||||
<tr><td><code>security/csprng-readiness.jsonl</code></td><td>Append-only entropy audit trail</td><td>0600; outside <code>identity/</code> so restores never touch it</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<h4 class="ok">The raw seed never touches disk</h4>
|
||||
<p>
|
||||
What is stored is the <em>encrypted words</em> and the <em>derived keys</em>. The 64-byte
|
||||
master seed is recomputed in RAM from the words when needed and wiped afterwards.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>The encrypted envelope</h3>
|
||||
<div class="glass-card">
|
||||
<pre>login password ──▶ <span class="a">Argon2id</span> (memory-hard) ──▶ 256-bit file key
|
||||
▲
|
||||
16-byte random salt
|
||||
|
||||
24 words ──▶ <span class="a">ChaCha20-Poly1305</span> (authenticated, 12-byte random nonce)
|
||||
│
|
||||
▼
|
||||
┌───────────┬────────────┬───────────────────────────┐
|
||||
│ salt (16) │ nonce (12) │ ciphertext + auth tag │ = master_seed.enc
|
||||
└───────────┴────────────┴───────────────────────────┘
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<span class="section-label accent">In plain words</span>
|
||||
<p>
|
||||
Your words are locked in a digital safe whose combination is your login password, run
|
||||
through a deliberately slow, memory-hungry grinder (Argon2) so guessing billions of
|
||||
passwords per second is impractical even for someone who steals the file. The
|
||||
authentication tag means the safe also notices tampering: a modified file fails loudly
|
||||
rather than yielding wrong words.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Revealing the words later (Settings → Backup → Reveal) requires an authenticated session,
|
||||
re-entering your password, and your 2FA code if enabled. It is rate-limited, and the words
|
||||
go only to your browser — never to logs.
|
||||
</p>
|
||||
|
||||
<h2 id="derivation">Derivation tree</h2>
|
||||
<p class="lede">Every key, its exact derivation, and where it lands.</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<span class="section-label accent">In plain words</span>
|
||||
<p>
|
||||
The node never uses the master seed directly as a key. It uses HKDF — think of a
|
||||
locksmith who, given one master blank and a <em>label</em> ("node key", "mesh key",
|
||||
"Lightning entropy"), cuts a completely different, unrelated key for each label. Knowing
|
||||
one cut key tells you nothing about the others or about the blank. The labels are fixed
|
||||
strings baked into the code, which is what lets the identical tree regrow on new hardware.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<table>
|
||||
<tr><th>Key</th><th>Method</th><th>Label / path</th></tr>
|
||||
<tr><td><strong>Node identity (Ed25519)</strong> — signs everything, forms your DID</td><td>HKDF-SHA256</td><td><code>archipelago/node/ed25519/v1</code></td></tr>
|
||||
<tr><td><strong>Node Nostr key</strong> — the node's npub</td><td>HKDF-SHA256</td><td><code>archipelago/nostr-node/secp256k1/v1</code></td></tr>
|
||||
<tr><td><strong>FIPS mesh transport key</strong></td><td>HKDF-SHA256</td><td><code>archipelago/fips/secp256k1/v1</code></td></tr>
|
||||
<tr><td><strong>Personal identity #i (Ed25519)</strong></td><td>HKDF-SHA256</td><td><code>archipelago/identity/{i}/ed25519/v1</code></td></tr>
|
||||
<tr><td><strong>Personal Nostr key #i</strong> — NIP-06 standard, portable to other Nostr apps</td><td>BIP-32</td><td><code>m/44'/1237'/0'/0/{i}</code></td></tr>
|
||||
<tr><td><strong>Lightning wallet entropy</strong> — 16 bytes</td><td>HKDF-SHA256</td><td><code>archipelago/lnd/entropy/v1</code></td></tr>
|
||||
<tr><td><strong>Bitcoin BIP-84 xprv</strong> — dormant, reserved for a future cold vault</td><td>BIP-32</td><td><code>m/84'/0'/0'</code></td></tr>
|
||||
<tr><td><strong>Release-root signing key</strong> — never on a node; derived offline by the publisher</td><td>HKDF-SHA256</td><td><code>archipelago/release/root/ed25519/v1</code></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
All HKDF derivations are HKDF-SHA256 with a distinct, versioned label — the <code>/v1</code>
|
||||
suffix means a future migration can introduce <code>/v2</code> without ambiguity. Personal
|
||||
Nostr keys deliberately use the NIP-06 standard path instead of HKDF, so the same 24 words
|
||||
typed into any NIP-06 Nostr client reproduce the same npub: your social identity is portable
|
||||
beyond Archipelago.
|
||||
</p>
|
||||
|
||||
<h3>The Lightning special case</h3>
|
||||
<div class="glass-card">
|
||||
<pre>master seed ──HKDF──▶ 16 bytes ──▶ <span class="y">LND generates its own "aezeed"</span> ──▶ wallet
|
||||
│
|
||||
│ ⚠ one-way: the aezeed cannot be
|
||||
│ recomputed from your 24 words
|
||||
▼
|
||||
captured ONCE at init, stored encrypted as
|
||||
<span class="a">identity/lnd_aezeed.enc</span>
|
||||
</pre>
|
||||
</div>
|
||||
<p>
|
||||
LND uses its own seed format, <em>aezeed</em>, which is not BIP-39. Archipelago derives
|
||||
deterministic entropy from your master seed and hands it to LND at wallet creation — but LND
|
||||
wraps it with its own internal salt, so the resulting aezeed cannot be re-derived from your
|
||||
24 words afterwards. The node captures it once and stores it encrypted alongside your other
|
||||
identity files.
|
||||
</p>
|
||||
<div class="glass-card">
|
||||
<h4 class="warn">Back up the Lightning seed separately</h4>
|
||||
<p>
|
||||
Your 24 words restore your node identity and on-chain derivations, but <em>not</em> an
|
||||
already-initialised Lightning wallet, and never off-chain channel balances (those need
|
||||
channel backups, as Lightning requires by design). Treat the aezeed in the Lightning
|
||||
backup screen as a second phrase worth writing down. It restores into LND-based wallets
|
||||
such as Zeus, Blixt or another Archipelago node — hardware wallets cannot import it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>Second-order keys</h3>
|
||||
<p>
|
||||
Some subsystems derive from the <em>node identity key</em> rather than the master seed
|
||||
directly. Since the node key is itself seed-derived, these still regrow from your words:
|
||||
<code>words → master seed → node key → subsystem key</code>. Each prefixes a unique fixed
|
||||
string before hashing (domain separation), so compromising one never exposes another.
|
||||
</p>
|
||||
<div class="glass-card">
|
||||
<table>
|
||||
<tr><th>Subsystem</th><th>Derivation from <code>node_key</code></th></tr>
|
||||
<tr><td><strong>Reticulum / LXMF mesh identity</strong> (LoRa long-range mesh)</td><td>HKDF-SHA256, salt <code>archipelago-reticulum-identity-v1</code>, separate X25519 + Ed25519 labels — a stable address that survives reinstalls</td></tr>
|
||||
<tr><td><strong>Message store</strong> (chats at rest)</td><td><code>SHA-256("archipelago-message-store-v1" ‖ node_key)</code></td></tr>
|
||||
<tr><td><strong>Mesh contacts</strong></td><td><code>SHA-256("archipelago-mesh-contacts-v1" ‖ node_key)</code></td></tr>
|
||||
<tr><td><strong>Credential store</strong> (saved app credentials)</td><td>Same domain-separated SHA-256 pattern</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>What is <em>not</em> derived from the seed</h3>
|
||||
<p>
|
||||
Plenty of secrets are freshly random instead. That is intentional: things that should die
|
||||
with a session, rotate freely, or belong to a third-party app must not be recoverable from
|
||||
your words.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<div class="glass-card">
|
||||
<h4>Ephemeral by design</h4>
|
||||
<p>Session tokens, device pairing tokens, federation invites, TOTP secrets and backup codes, all encryption nonces, X3DH ephemeral mesh keys, anonymous marketplace and discovery Nostr keys.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4>App-owned secrets</h4>
|
||||
<p>Every manifest-declared <code>generated_secret</code> (app database passwords, API keys), Bitcoin RPC credentials, the LND wallet <em>password</em> (distinct from its seed), Home Assistant tokens.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4>Host-level material</h4>
|
||||
<p>WireGuard keypairs (via <code>wg genkey</code>), SSH host keys and the TLS certificate (created by the installer image at first boot), the machine-id.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4>Opt-outs from derivability</h4>
|
||||
<p>Identities created with "new random key" instead of seed derivation, and a node key after an explicit <code>rotate-key</code> — rotation deliberately breaks the link to your words, and says so.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 id="failures">Failures</h2>
|
||||
<p class="lede">What happens when something goes wrong, at every stage.</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<table>
|
||||
<tr><th>Scenario</th><th>Behaviour</th><th>Outcome</th></tr>
|
||||
<tr>
|
||||
<td>RNG returns a degenerate pattern</td>
|
||||
<td>Draw refused and wiped, <strong>never retried</strong>; error logged; onboarding fails loudly</td>
|
||||
<td class="bad">No seed created</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Kernel pool not yet initialised</td>
|
||||
<td><code>getrandom(2)</code> blocks until seeded — an unseeded pool cannot produce a seed. The probe logs a warning and records the verdict</td>
|
||||
<td class="warn">Waits, then proceeds</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Onboarding page refreshed mid-generation</td>
|
||||
<td>Same words returned for 10 minutes, mutex-serialised; no second seed can be minted</td>
|
||||
<td class="ok">Idempotent</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>master_seed.enc</code> missing</td>
|
||||
<td>Node runs normally — derived keys are already on disk. Only Reveal and future re-derivation are unavailable, and the UI says so</td>
|
||||
<td class="warn">Degraded, functional</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Seed file corrupt, or wrong password</td>
|
||||
<td>Authenticated decryption fails closed with an explicit error — no fallback, no partial output, no auto-regeneration</td>
|
||||
<td class="bad">Fails loudly</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Restore attempted on a provisioned node</td>
|
||||
<td>The onboarding gate refuses identity-mutating RPCs once set up — a live node cannot be hijacked or accidentally re-seeded</td>
|
||||
<td class="ok">Refused</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Legacy or corrupt FIPS key format</td>
|
||||
<td>Self-heals: the legacy raw-byte format is detected and migrated in place to bech32</td>
|
||||
<td class="ok">Auto-migrated</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Readiness-ledger write fails</td>
|
||||
<td>Warns and continues — the audit trail is best-effort and can never block key generation</td>
|
||||
<td class="warn">Non-blocking</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<h4 class="bad">The one true single point of failure is you</h4>
|
||||
<p>
|
||||
Every software failure above fails <em>safe</em>. The only unrecoverable scenario is
|
||||
losing the 24 words <em>and</em> the node's disk together. Write the words down, store
|
||||
them offline, and never type them into anything except a node you are restoring. Anyone
|
||||
holding them can rebuild your entire identity tree — which is exactly what makes them a
|
||||
perfect backup and a perfect target.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 id="restore">Restore</h2>
|
||||
<p class="lede">Typing 24 words into a fresh node, step by step.</p>
|
||||
|
||||
<div class="glass-card">
|
||||
<ol class="steps">
|
||||
<li><strong>Gate check.</strong> Restore only proceeds on an un-onboarded node. This gate is load-bearing and runs before anything else.</li>
|
||||
<li><strong>Validation.</strong> Exactly 24 words, checked against the BIP-39 wordlist and its checksum — a typo is caught here, before anything is written.</li>
|
||||
<li><strong>Identity regrowth.</strong> Node key, DID, node Nostr key and FIPS mesh key are re-derived byte-identically, because the HKDF labels are fixed.</li>
|
||||
<li><strong>Personal identity #0.</strong> The index resets to 0 and your default identity (Ed25519 + NIP-06 Nostr key) is recreated. Further seed-derived identities re-derive as the index walks forward, but their names and avatars were metadata, not key material.</li>
|
||||
<li><strong>Mesh reactivation.</strong> FIPS auto-activation starts in the background; the Reticulum identity re-derives from the restored node key, so your LXMF address returns too.</li>
|
||||
<li><strong>Password and re-seal.</strong> Setting the new login password re-encrypts the words into a fresh <code>master_seed.enc</code>, so Reveal works on the restored node.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h3>What comes back — and what doesn't</h3>
|
||||
<div class="grid">
|
||||
<div class="glass-card">
|
||||
<h4 class="ok">Restored by the words</h4>
|
||||
<p>Node identity and DID · node npub · FIPS mesh key · Reticulum/LXMF address · personal identity keys and npubs · message-store, contacts and credential encryption keys · the dormant Bitcoin xprv · the ability to reveal the phrase again.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4 class="warn">Needs its own backup</h4>
|
||||
<p>Lightning wallet (aezeed — one-way gate) and channel state · chat history and app data (node backup) · identity names and avatars · app secrets, which regenerate on reinstall.</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h4 class="bad">Gone by design</h4>
|
||||
<p>Sessions and device pairings (log in, re-pair) · 2FA secret (re-enrol) · WireGuard peers (re-pair) · rotated-away node keys · anonymous throwaway Nostr keys.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>SeedQR</h3>
|
||||
<p>
|
||||
Wherever the phrase is shown, a QR tab sits beside the words. For the BIP-39 phrase the
|
||||
default is <strong>SeedQR</strong>: each word becomes its 4-digit position in the official
|
||||
wordlist (24 words → 96 digits) as a compact numeric QR. Passport, SeedSigner and Keystone
|
||||
import this directly, so you can move your on-chain identity to cold storage without typing.
|
||||
A plain-text QR fallback exists for wallets that read the phrase as text.
|
||||
</p>
|
||||
<ul>
|
||||
<li>The QR holds <em>exactly the same secret</em> as the words — treat a printout or screenshot identically.</li>
|
||||
<li>The Lightning aezeed is never SeedQR-encoded: it is not BIP-39, hardware wallets cannot import it, and pretending otherwise would be dishonest. It gets a plain-text QR with an explanation.</li>
|
||||
<li>Restore is by typed or pasted words; there is no camera-based SeedQR scanner on the restore path today.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="verify">Verify it yourself</h2>
|
||||
<p class="lede">Don't trust — recompute.</p>
|
||||
|
||||
<p>Because every derivation is deterministic and label-fixed, you can independently confirm that this node's keys really do come from your words:</p>
|
||||
<ul>
|
||||
<li><strong>Independent re-derivation:</strong> <code>scripts/verify-seed-derivation.py</code> in the Archipelago source — pure standard-library Python, no Archipelago code. On a trusted offline machine it recomputes <code>node_key</code>, <code>nostr_secret</code> and <code>fips_key</code> from your mnemonic and byte-compares them against <code>/var/lib/archipelago/identity/</code>.</li>
|
||||
<li><strong>Known-answer tests:</strong> the test suite pins the exact expected keys for a fixed test mnemonic, so any change to the derivation math turns the build red.</li>
|
||||
<li><strong>Non-determinism test:</strong> 64 consecutive generated mnemonics are asserted unique — a canary against the predictable-RNG failure class.</li>
|
||||
<li><strong>Your own audit trail:</strong> <code>/var/lib/archipelago/security/csprng-readiness.jsonl</code> records, append-only, the kernel randomness verdict at every key-generation event on this node — including the moment your seed was born.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Honest edges</h3>
|
||||
<p>The audit that produced this system also tracked what it did not fix. Naming the edges is part of the point:</p>
|
||||
<ul>
|
||||
<li><strong>The words cross the RPC boundary.</strong> During onboarding the phrase travels to your browser to be displayed, sits in session storage for the wizard's duration, and is held in server memory for the 10-minute idempotence window — the price of a refresh-proof, display-once flow.</li>
|
||||
<li><strong>Argon2 uses library defaults</strong> (≈19 MiB, 2 passes) rather than the heavier profile the design doc specifies. Still memory-hard; scheduled for tightening.</li>
|
||||
<li><strong>2FA backup codes carry slight modulo bias</strong> — cosmetically imperfect, cryptographically irrelevant at their length, queued for cleanup.</li>
|
||||
<li><strong>The lint ban covers the main workspace</strong>, but one small helper crate outside it is not reached yet.</li>
|
||||
<li><strong>Best-effort sealing:</strong> if writing <code>master_seed.enc</code> fails during setup, the node continues (keys exist, only Reveal is lost). Whether that should fail loudly instead is under review.</li>
|
||||
</ul>
|
||||
|
||||
<div class="glass-card">
|
||||
<h4 class="ok">The whole story in one paragraph</h4>
|
||||
<p>
|
||||
Your node asked the Linux kernel for 32 bytes of hardware-grade randomness through a
|
||||
single, named, compiler-enforced channel; refused to proceed unless the bytes looked
|
||||
alive; wrote down the health of the random pool as evidence; turned the bytes into 24
|
||||
words it showed you exactly once; locked an encrypted copy behind your password; and then
|
||||
grew every identity and key it owns from those words along fixed, versioned,
|
||||
independently verifiable paths — so the words in your drawer are, and will remain, a
|
||||
complete blueprint of who your node is.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="./nav.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
// Scroll-spy for the sidebar, mirroring the dashboard's nav-tab-active state.
|
||||
// External file (not inline) because the node's CSP is script-src 'self'.
|
||||
(function () {
|
||||
var links = Array.prototype.slice.call(
|
||||
document.querySelectorAll('.sidebar-nav .sidebar-nav-item[href^="#"]')
|
||||
)
|
||||
if (!links.length || !('IntersectionObserver' in window)) return
|
||||
|
||||
var sections = links
|
||||
.map(function (a) { return document.getElementById(a.getAttribute('href').slice(1)) })
|
||||
.filter(Boolean)
|
||||
|
||||
function activate(id) {
|
||||
links.forEach(function (a) {
|
||||
a.classList.toggle('nav-tab-active', a.getAttribute('href') === '#' + id)
|
||||
})
|
||||
}
|
||||
|
||||
var visible = {}
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) { visible[e.target.id] = e.isIntersecting })
|
||||
// Topmost section currently on screen wins, so the highlight tracks reading position.
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
if (visible[sections[i].id]) { activate(sections[i].id); return }
|
||||
}
|
||||
}, { rootMargin: '-10% 0px -70% 0px', threshold: 0 })
|
||||
|
||||
sections.forEach(function (s) { observer.observe(s) })
|
||||
})()
|
||||
@@ -2,11 +2,15 @@
|
||||
<BaseModal :show="show" title="" max-width="max-w-lg" @close="emit('close')">
|
||||
<!-- Header: app icon + "Install Bitcoin Knots/Core" -->
|
||||
<div class="flex items-center gap-4 mb-5 -mt-2">
|
||||
<!-- object-contain, not the default fill: app icons are not all square
|
||||
(bitcoin-knots is not), so a fixed 56x56 box distorted or cropped the
|
||||
mark against the rounded corners. Contain plus a dark plate shows the
|
||||
whole icon whatever its aspect ratio. -->
|
||||
<img
|
||||
v-if="app?.icon"
|
||||
:src="app.icon"
|
||||
:alt="app?.title || ''"
|
||||
class="w-14 h-14 rounded-xl shadow-lg shrink-0"
|
||||
class="w-14 h-14 rounded-xl shadow-lg shrink-0 object-contain bg-black/40 p-1"
|
||||
/>
|
||||
<div v-else class="w-14 h-14 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
|
||||
<svg class="w-7 h-7 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -107,7 +107,21 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{ close: []; received: []; scan: [] }>()
|
||||
|
||||
watch(() => props.show, (open) => {
|
||||
if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) {
|
||||
if (!open) return
|
||||
// Blank slate on every open: a leftover amount/memo/token or a previous
|
||||
// invoice quietly carrying into a new receive flow is exactly the stale-
|
||||
// state class the operator flagged on the send modal (2026-08-05).
|
||||
receiveMethod.value = 'onchain'
|
||||
invoiceAmount.value = 0
|
||||
invoiceMemo.value = ''
|
||||
invoiceResult.value = ''
|
||||
onchainAddress.value = ''
|
||||
arkAddress.value = ''
|
||||
ecashToken.value = ''
|
||||
ecashResult.value = ''
|
||||
error.value = ''
|
||||
processing.value = false
|
||||
if (props.autoGenerate && receiveMethod.value === 'onchain') {
|
||||
void receive()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -327,15 +327,57 @@ const isSweep = computed(() => sendMethod.value === 'onchain' && sendAll.value)
|
||||
|
||||
function toggleSendAll() {
|
||||
sendAll.value = !sendAll.value
|
||||
if (sendAll.value && onchainBalance.value === null) {
|
||||
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
|
||||
.then((res) => { onchainBalance.value = res.balance_sats || 0 })
|
||||
.catch(() => { /* balance hint is best-effort */ })
|
||||
if (!sendAll.value) {
|
||||
// Disarming clears the field — a swept-balance figure left behind reads
|
||||
// as a typed amount.
|
||||
amount.value = 0
|
||||
return
|
||||
}
|
||||
// Arming shows the swept balance IN the (disabled) amount field — a field
|
||||
// stuck at 0 while "send all" is lit read as "sending nothing" (operator
|
||||
// feedback 2026-08-05). Refresh the figure on every arm.
|
||||
const applyBalance = () => {
|
||||
if (sendAll.value && onchainBalance.value !== null) amount.value = onchainBalance.value
|
||||
}
|
||||
applyBalance()
|
||||
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
|
||||
.then((res) => { onchainBalance.value = res.balance_sats || 0; applyBalance() })
|
||||
.catch(() => { /* balance hint is best-effort */ })
|
||||
}
|
||||
|
||||
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
|
||||
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
|
||||
// (and drops the swept-balance figure it wrote into the amount field).
|
||||
watch(sendMethod, (m) => {
|
||||
if (m !== 'onchain' && sendAll.value) {
|
||||
sendAll.value = false
|
||||
amount.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
// Every open starts from a blank slate. Stale state from the previous send —
|
||||
// destination, amount, and above all an armed "send all funds" toggle — is
|
||||
// dangerous to inherit invisibly (operator feedback 2026-08-05).
|
||||
watch(() => props.show, (shown) => {
|
||||
if (!shown) return
|
||||
sendMethod.value = 'lightning'
|
||||
amountUnit.value = 'sats'
|
||||
amountEntry.value = 0
|
||||
dest.value = ''
|
||||
error.value = ''
|
||||
successInfo.value = null
|
||||
ecashToken.value = ''
|
||||
sendAll.value = false
|
||||
onchainBalance.value = null
|
||||
feePreset.value = 'standard'
|
||||
customConfTarget.value = null
|
||||
customSatPerVbyte.value = null
|
||||
resolvedFeeParams.value = {}
|
||||
feeEstimate.value = null
|
||||
confirming.value = false
|
||||
confirmBalance.value = null
|
||||
invoiceAmountSats.value = null
|
||||
processing.value = false
|
||||
})
|
||||
|
||||
// --- On-chain network fee: presets map to LND confirmation targets; custom
|
||||
// --- takes a block target or an explicit sat/vB rate (rate wins).
|
||||
|
||||
@@ -51,10 +51,16 @@
|
||||
</svg>
|
||||
<p class="text-sm text-white/60 text-center">How do you want to read the QR?</p>
|
||||
<!-- hasNativeQr: on the companion (plain http, no getUserMedia)
|
||||
the native bridge still provides a live camera -->
|
||||
<button v-if="!liveCameraUnavailable || hasNativeQr" @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||
the native bridge still provides a live camera. On plain-http
|
||||
desktop the button stays VISIBLE — hiding it read as "the
|
||||
scanner is gone" (operator, 2026-08-05); choosing it surfaces
|
||||
the browser's HTTPS requirement with the fallbacks instead. -->
|
||||
<button @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||
Scan with camera
|
||||
</button>
|
||||
<p v-if="liveCameraUnavailable && !hasNativeQr" class="text-[11px] text-white/40 text-center -mt-1">
|
||||
Your browser only allows live camera on HTTPS pages — the photo and paste options below always work.
|
||||
</p>
|
||||
<button @click="photoInput?.click()" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||
Upload / take a photo of the QR
|
||||
</button>
|
||||
|
||||
@@ -256,8 +256,11 @@
|
||||
<p v-if="effectiveKind === 'meshcore' && rfPreset" class="text-[11px] text-sky-300/80 mt-1">
|
||||
MeshCore RF params for {{ selectedRegion?.code }} are applied to the radio automatically on connect.
|
||||
</p>
|
||||
<p v-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
|
||||
RNode radio parameters (frequency / bandwidth / SF / CR) are managed by the Reticulum daemon's interface config on this node.
|
||||
<p v-if="effectiveKind === 'reticulum' && rnodePlan" class="text-[11px] text-sky-300/80 mt-1">
|
||||
RNode plan for {{ form.region }}: {{ (rnodePlan.frequency / 1e6).toFixed(4) }} MHz, {{ rnodePlan.bandwidth / 1000 }} kHz, SF{{ rnodePlan.spreading_factor }}, CR4/{{ rnodePlan.coding_rate }}, {{ rnodePlan.txpower }} dBm — applied on connect, and the radio confirms it.
|
||||
</p>
|
||||
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
|
||||
Pick a region to apply its recommended RNode RF plan on connect — editable any time in Mesh → Device settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -302,7 +305,7 @@ import { useRouter } from 'vue-router'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type FlashFirmwareFamily, type FlashBoard, type FlashJobStatus } from '@/stores/mesh'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
|
||||
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor, RNODE_REGION_PLANS } from '@/utils/loraRegions'
|
||||
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
@@ -373,6 +376,10 @@ const form = ref({
|
||||
})
|
||||
|
||||
const selectedRegion = computed(() => regionByCode(form.value.region))
|
||||
/** The chosen region's recommended RNode RF plan (undefined = none chosen). */
|
||||
const rnodePlan = computed(() =>
|
||||
form.value.region ? RNODE_REGION_PLANS[form.value.region] : undefined,
|
||||
)
|
||||
// The firmware whose options we surface: the probe result wins, else the
|
||||
// last connected type, else meshtastic-style (where presets apply).
|
||||
const effectiveKind = computed(() => {
|
||||
@@ -501,6 +508,17 @@ async function applySetup() {
|
||||
}
|
||||
}
|
||||
await mesh.configure(params)
|
||||
// RNode radios: the region's recommended RF plan is applied through the
|
||||
// Reticulum daemon's persisted settings (mesh.rnode-config-apply), the
|
||||
// same round-trip the Device panel uses — the radio confirms the values
|
||||
// itself after the daemon restarts with them. Best-effort here: a
|
||||
// failure must not abort the connect the user just asked for.
|
||||
if (effectiveKind.value === 'reticulum' && rnodePlan.value) {
|
||||
mesh.suppressDeviceDetect()
|
||||
void mesh
|
||||
.applyRnodeConfig({ enabled: true, port: null, ...rnodePlan.value })
|
||||
.catch(() => {})
|
||||
}
|
||||
mesh.dismissDetectedDevice(path)
|
||||
void router.push('/dashboard/mesh')
|
||||
} catch (e) {
|
||||
|
||||
@@ -356,9 +356,19 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
// The modal waits for 2 sightings so it doesn't flash during the couple of
|
||||
// seconds an ordinary reconnect (same radio, transient blip) needs.
|
||||
const detectSightings = ref<Record<string, number>>({})
|
||||
/** Epoch-ms until which the device-setup modal must NOT auto-open: an
|
||||
* operator-initiated radio restart (settings apply, Reboot Radio) takes
|
||||
* the radio down for ~15-20s, and the modal treated that healthy,
|
||||
* expected gap as "a new stick was plugged in" and interrupted the flow
|
||||
* (operator, 2026-08-06). */
|
||||
const suppressDetectUntil = ref(0)
|
||||
function suppressDeviceDetect(ms = 90_000) {
|
||||
suppressDetectUntil.value = Date.now() + ms
|
||||
}
|
||||
const undismissedDetectedDevices = computed(() => {
|
||||
const s = status.value
|
||||
if (!s) return []
|
||||
if (Date.now() < suppressDetectUntil.value) return []
|
||||
return (s.detected_devices || []).filter(p =>
|
||||
dismissedDetected.value[p] !== pluggedAt(s, p) &&
|
||||
// The port the live session occupies is not a candidate…
|
||||
@@ -863,12 +873,35 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
}
|
||||
|
||||
async function rebootRadio(seconds = 2) {
|
||||
return rpcClient.call<{ reboot: boolean; seconds: number }>({
|
||||
// Long timeout: Reticulum reboots restart the sidecar daemon and the
|
||||
// backend waits for the acknowledgement instead of fire-and-forgetting.
|
||||
return rpcClient.call<{ reboot: boolean; seconds: number; message?: string }>({
|
||||
method: 'mesh.reboot-radio',
|
||||
params: { seconds },
|
||||
timeout: 30000,
|
||||
})
|
||||
}
|
||||
|
||||
/** Persisted RNode RF settings + live radio-confirmed state (Reticulum). */
|
||||
async function getRnodeConfig() {
|
||||
return rpcClient.call<{
|
||||
settings: Record<string, unknown>
|
||||
live: Record<string, unknown> | null
|
||||
live_error: string | null
|
||||
}>({ method: 'mesh.rnode-config', timeout: 20000 })
|
||||
}
|
||||
|
||||
/** Apply RNode RF settings: persists, restarts the radio daemon, waits for
|
||||
* the radio's own read-back confirmation (up to ~50s). */
|
||||
async function applyRnodeConfig(settings: Record<string, unknown>) {
|
||||
return rpcClient.call<{
|
||||
applied: boolean
|
||||
confirmed?: boolean
|
||||
live?: Record<string, unknown> | null
|
||||
message: string
|
||||
}>({ method: 'mesh.rnode-config-apply', params: { settings }, timeout: 70000 })
|
||||
}
|
||||
|
||||
async function getOutbox() {
|
||||
try {
|
||||
return await rpcClient.call<{ count: number; messages?: unknown[] }>({ method: 'mesh.outbox' })
|
||||
@@ -1125,6 +1158,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
latestBlockHeight,
|
||||
fetchStatus,
|
||||
undismissedDetectedDevices,
|
||||
suppressDeviceDetect,
|
||||
dismissDetectedDevice,
|
||||
flashFlowPath,
|
||||
openFlashFlow,
|
||||
@@ -1155,6 +1189,8 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
sendReply,
|
||||
sendReaction,
|
||||
rebootRadio,
|
||||
getRnodeConfig,
|
||||
applyRnodeConfig,
|
||||
getOutbox,
|
||||
sendReadReceipt,
|
||||
forwardMessage,
|
||||
|
||||
@@ -126,3 +126,27 @@ export const MESHCORE_RF_PRESETS: MeshcoreRfPreset[] = [
|
||||
{ id: 'us_anz_915', label: 'US / Canada / ANZ — 915.0 MHz, 250 kHz, SF10, CR4/5', freqMhz: 915.0, bwKhz: 250, sf: 10, cr: 5 },
|
||||
{ id: 'eu_433', label: 'Europe (433 MHz) — 433.65 MHz, 250 kHz, SF11, CR4/5', freqMhz: 433.65, bwKhz: 250, sf: 11, cr: 5 },
|
||||
]
|
||||
|
||||
/** Recommended Reticulum RNode RF plan per region. Applied via
|
||||
* mesh.rnode-config-apply (the daemon restarts the radio with these and the
|
||||
* radio confirms them back). EU868 is the operator-validated Portugal plan:
|
||||
* 869.4625 MHz sits in the 10%-duty 869.4–869.65 sub-band clear of the
|
||||
* default community channel, with the EU airtime locks written in. Others
|
||||
* follow RNS community conventions with the region's legal power cap. */
|
||||
export interface RnodeRegionPlan {
|
||||
frequency: number
|
||||
bandwidth: number
|
||||
spreading_factor: number
|
||||
coding_rate: number
|
||||
txpower: number
|
||||
airtime_limit_short: number | null
|
||||
airtime_limit_long: number | null
|
||||
}
|
||||
export const RNODE_REGION_PLANS: Record<string, RnodeRegionPlan> = {
|
||||
EU868: { frequency: 869462500, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 14, airtime_limit_short: 25, airtime_limit_long: 10 },
|
||||
US915: { frequency: 914875000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
|
||||
AU915: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
|
||||
ANZ: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
|
||||
AS923: { frequency: 923200000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 13, airtime_limit_short: null, airtime_limit_long: null },
|
||||
IN865: { frequency: 866000000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
:refresh-key="refreshKey"
|
||||
:blocked-reason="blockedReason"
|
||||
:blocked-title="blockedTitle"
|
||||
:warming-up="warmingUp"
|
||||
:electrs-sync="electrsSync"
|
||||
@iframe-load="onLoad"
|
||||
@iframe-error="onError"
|
||||
@@ -112,6 +113,7 @@ import {
|
||||
initialDisplayMode, resolveAppUrl, resolveAppTitle,
|
||||
} from './appSession/appSessionConfig'
|
||||
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
|
||||
import { PackageState } from '@/types/api'
|
||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
|
||||
@@ -168,6 +170,25 @@ const appIcon = computed(() =>
|
||||
: `/assets/img/app-icons/${appId.value}.png`
|
||||
)
|
||||
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
|
||||
|
||||
// A container that is up but not yet answering its probe is STARTING, not
|
||||
// broken — bitcoind serves RPC error -28 for its whole warm-up and lnd is
|
||||
// unreachable until the wallet unlocks, so both spent that window reading as
|
||||
// a hard "App not reachable" failure. The retry machinery below already
|
||||
// tolerates it (6 × 10s); this only makes the headline tell the truth while
|
||||
// those retries are still in flight. Once they are exhausted, the failure is
|
||||
// real again and the copy reverts.
|
||||
const MAX_AUTO_RETRIES = 6
|
||||
const warmingUp = computed(() =>
|
||||
iframeBlocked.value &&
|
||||
!mustOpenNewTab.value &&
|
||||
!blockedReason.value &&
|
||||
autoRetryCount.value < MAX_AUTO_RETRIES &&
|
||||
(packageEntry.value?.state === PackageState.Running ||
|
||||
packageEntry.value?.state === PackageState.Starting ||
|
||||
packageEntry.value?.state === PackageState.Restarting ||
|
||||
packageEntry.value?.health === 'starting')
|
||||
)
|
||||
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
|
||||
// Reactive so the overlay/teleport/footer/animation decisions track the live
|
||||
// viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read.
|
||||
@@ -350,7 +371,7 @@ function onError() {
|
||||
isRefreshing.value = false
|
||||
iframeBlocked.value = true
|
||||
// Auto-retry up to 6 times (60s total) for apps that are still starting
|
||||
if (!mustOpenNewTab.value && autoRetryCount.value < 6) {
|
||||
if (!mustOpenNewTab.value && autoRetryCount.value < MAX_AUTO_RETRIES) {
|
||||
autoRetryId = setTimeout(() => {
|
||||
autoRetryCount.value++
|
||||
refresh()
|
||||
|
||||
@@ -311,31 +311,26 @@ const showChatPanel = computed(() =>
|
||||
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
|
||||
)
|
||||
const showBitcoinPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'bitcoin'
|
||||
if (isMobile.value) return mobileTab.value === 'bitcoin'
|
||||
return activeTab.value === 'bitcoin'
|
||||
})
|
||||
const showDeadmanPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'deadman'
|
||||
if (isMobile.value) return mobileTab.value === 'deadman'
|
||||
return activeTab.value === 'deadman'
|
||||
})
|
||||
const showAssistantPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'assistant'
|
||||
if (isMobile.value) return mobileTab.value === 'assistant'
|
||||
return activeTab.value === 'assistant'
|
||||
})
|
||||
const showMapPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'map'
|
||||
if (isMobile.value) return mobileTab.value === 'map'
|
||||
return activeTab.value === 'map'
|
||||
})
|
||||
const showDevicePanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'device'
|
||||
if (isMobile.value) return mobileTab.value === 'device'
|
||||
return activeTab.value === 'device'
|
||||
@@ -2683,7 +2678,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
|
||||
<!-- Tools panels (3rd column on wide screens) -->
|
||||
<div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
|
||||
<div v-if="isWideDesktop && !isVeryWideDesktop" class="mesh-tools-tab-bar">
|
||||
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">Bitcoin</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
|
||||
Dead Man
|
||||
|
||||
@@ -68,14 +68,18 @@
|
||||
<Transition name="content-fade">
|
||||
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<!-- Warm-up uses the app's own icon, pulsing, rather than the padlock:
|
||||
the padlock reads as "blocked/denied" and this state is neither. -->
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden" :class="{ 'animate-pulse': warmingUp }">
|
||||
<img v-if="warmingUp" :src="appIcon" :alt="appTitle" class="w-full h-full object-cover" @error="handleImageError" />
|
||||
<svg v-else class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ warmingUp ? `${appTitle} is starting…` : blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
|
||||
<p class="text-white/50 text-sm mb-6">
|
||||
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
|
||||
<template v-else-if="warmingUp">The container is running but hasn't finished warming up yet.<br>This screen opens on its own as soon as it answers.<span v-if="autoRetryCount > 0" class="block text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
||||
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
||||
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
|
||||
</p>
|
||||
@@ -131,6 +135,9 @@ const props = defineProps<{
|
||||
refreshKey: number
|
||||
blockedReason?: string
|
||||
blockedTitle?: string
|
||||
// True while the container is up but its probe hasn't answered yet and the
|
||||
// auto-retries are still in flight — a warm-up, not a failure.
|
||||
warmingUp?: boolean
|
||||
// Non-null only for ElectrumX while its index is still building — shows the
|
||||
// sync screen and gates the iframe until status flips to "synced".
|
||||
electrsSync?: ElectrsSyncStatus | null
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AppSessionFrame from '../AppSessionFrame.vue'
|
||||
|
||||
// Regression cover for the operator-reported defect: a container that is up
|
||||
// but has not finished warming up (bitcoind serving RPC -28, lnd before the
|
||||
// wallet unlocks) rendered the hard "App not reachable" failure copy for the
|
||||
// whole warm-up window. The retry machinery already tolerated it — only the
|
||||
// headline lied.
|
||||
|
||||
function mountFrame(props: Record<string, unknown> = {}) {
|
||||
return mount(AppSessionFrame, {
|
||||
props: {
|
||||
appUrl: 'http://localhost:8332/',
|
||||
appId: 'bitcoin-knots',
|
||||
appTitle: 'Bitcoin',
|
||||
appIcon: '/icons/bitcoin.png',
|
||||
loading: false,
|
||||
iframeBlocked: true,
|
||||
mustOpenNewTab: false,
|
||||
autoRetryCount: 1,
|
||||
refreshKey: 0,
|
||||
...props,
|
||||
},
|
||||
global: { stubs: { AppLoadingScreen: true, Transition: false } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppSessionFrame warm-up state', () => {
|
||||
it('reads as starting, not unreachable, while the container is warming up', () => {
|
||||
const text = mountFrame({ warmingUp: true }).text()
|
||||
expect(text).toContain('Bitcoin is starting…')
|
||||
expect(text).not.toContain('App not reachable')
|
||||
})
|
||||
|
||||
it('says the container is running so the copy does not imply it is stopped', () => {
|
||||
const text = mountFrame({ warmingUp: true }).text()
|
||||
expect(text).toContain("container is running but hasn't finished warming up")
|
||||
expect(text).not.toContain('the container is stopped')
|
||||
})
|
||||
|
||||
it('still surfaces the automatic re-check while warming up', () => {
|
||||
expect(mountFrame({ warmingUp: true, autoRetryCount: 3 }).text()).toContain(
|
||||
'Checking again automatically (3)',
|
||||
)
|
||||
})
|
||||
|
||||
it('reverts to the real failure once warm-up is over (retries exhausted)', () => {
|
||||
const text = mountFrame({ warmingUp: false, autoRetryCount: 6 }).text()
|
||||
expect(text).toContain('App not reachable')
|
||||
expect(text).not.toContain('is starting…')
|
||||
})
|
||||
|
||||
it('leaves the explicit blocked-reason path untouched', () => {
|
||||
const text = mountFrame({
|
||||
warmingUp: false,
|
||||
blockedReason: 'Waiting for Bitcoin to finish syncing.',
|
||||
blockedTitle: 'Waiting for Bitcoin sync',
|
||||
}).text()
|
||||
expect(text).toContain('Waiting for Bitcoin sync')
|
||||
expect(text).not.toContain('App not reachable')
|
||||
})
|
||||
|
||||
it('leaves the new-tab path untouched', () => {
|
||||
const text = mountFrame({ warmingUp: false, mustOpenNewTab: true }).text()
|
||||
expect(text).toContain('This app opens in a new tab')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { appOrigin, matchPageScheme } from '../appSessionConfig'
|
||||
|
||||
// An HTTPS dashboard cannot embed an HTTP app frame — browsers block it as
|
||||
// mixed content — so the app origin has to follow the page's scheme. Plain-HTTP
|
||||
// nodes must be completely unaffected, which is what most of these pin.
|
||||
|
||||
function setLocation(protocol: string, hostname: string) {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol, hostname },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('appOrigin', () => {
|
||||
it('stays on http for an http dashboard', () => {
|
||||
setLocation('http:', 'archi-dev-box')
|
||||
expect(appOrigin(8334)).toBe('http://archi-dev-box:8334')
|
||||
})
|
||||
|
||||
it('follows an https dashboard onto the app port', () => {
|
||||
setLocation('https:', 'archi-dev-box')
|
||||
expect(appOrigin(8334)).toBe('https://archi-dev-box:8334')
|
||||
})
|
||||
|
||||
it('keeps the hostname the user actually typed, not a fixed name', () => {
|
||||
setLocation('https:', '100.69.68.39')
|
||||
expect(appOrigin(3000)).toBe('https://100.69.68.39:3000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchPageScheme', () => {
|
||||
it('leaves backend-reported http URLs alone on an http page', () => {
|
||||
setLocation('http:', 'node')
|
||||
expect(matchPageScheme('http://node:8080/app')).toBe('http://node:8080/app')
|
||||
})
|
||||
|
||||
it('upgrades a backend-reported http URL on an https page', () => {
|
||||
setLocation('https:', 'node')
|
||||
expect(matchPageScheme('http://node:8080/app')).toBe('https://node:8080/app')
|
||||
})
|
||||
|
||||
it('does not touch anything but the scheme', () => {
|
||||
setLocation('https:', 'node')
|
||||
expect(matchPageScheme('http://node:8080/a/b?c=1#d')).toBe('https://node:8080/a/b?c=1#d')
|
||||
})
|
||||
|
||||
it('leaves an already-https URL untouched', () => {
|
||||
setLocation('https:', 'node')
|
||||
expect(matchPageScheme('https://node:8080/app')).toBe('https://node:8080/app')
|
||||
})
|
||||
})
|
||||
@@ -107,11 +107,15 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
||||
// shell when proxied under a path prefix on some nodes.
|
||||
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
|
||||
if (import.meta.env.DEV) return '/app/bitcoin-ui/'
|
||||
return 'http://' + window.location.hostname + ':8334'
|
||||
return appOrigin(8334)
|
||||
}
|
||||
|
||||
if (runtimeUrl && id !== 'netbird') {
|
||||
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
|
||||
// The backend reports runtime URLs as http:// because that is how the app
|
||||
// binds locally. Sent to a browser on an HTTPS dashboard that is mixed
|
||||
// content and the frame is blocked outright, so follow the page instead.
|
||||
base = matchPageScheme(base)
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
return base
|
||||
}
|
||||
@@ -120,11 +124,48 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
||||
const port = APP_PORTS[id]
|
||||
if (!port) return ''
|
||||
|
||||
let base = 'http://' + window.location.hostname + ':' + String(port)
|
||||
let base = appOrigin(port)
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* An app's origin on this host, on the SAME scheme as the page.
|
||||
*
|
||||
* An HTTPS dashboard cannot embed an HTTP frame at all — browsers block it as
|
||||
* mixed content before any cookie question arises — and it is also what makes
|
||||
* the two origins schemefully cross-site, so the session cookie is withheld.
|
||||
* Following the page's scheme fixes both at once and keeps plain HTTP working
|
||||
* exactly as before on nodes that serve the dashboard over HTTP.
|
||||
*
|
||||
* On HTTPS this requires the app port to actually serve TLS with a certificate
|
||||
* the browser trusts — see scripts/setup-node-ca.sh and Settings → System →
|
||||
* Node certificate. A certificate warning cannot be accepted inside an iframe,
|
||||
* so an untrusted app port renders nothing rather than prompting.
|
||||
*/
|
||||
export function appOrigin(port: number): string {
|
||||
return `${pageScheme()}//${window.location.hostname}:${port}`
|
||||
}
|
||||
|
||||
/** Rewrite a URL's scheme to the page's, leaving everything else alone. */
|
||||
export function matchPageScheme(url: string): string {
|
||||
if (pageScheme() !== 'https:') return url
|
||||
return url.replace(/^http:\/\//i, 'https://')
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's scheme, defaulting to http.
|
||||
*
|
||||
* A real browser always has location.protocol; this defends the non-browser
|
||||
* cases (tests, SSR-ish contexts) where it can be absent. Defaulting to http
|
||||
* is the safe direction — it preserves today's behaviour rather than inventing
|
||||
* an https URL for a port that may not serve TLS.
|
||||
*/
|
||||
function pageScheme(): string {
|
||||
const p = window.location?.protocol
|
||||
return p === 'https:' || p === 'http:' ? p : 'http:'
|
||||
}
|
||||
|
||||
/** Resolve a human-readable title for an app */
|
||||
export function resolveAppTitle(id: string): string {
|
||||
return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
|
||||
@@ -4,12 +4,15 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
"aiui": 5180,
|
||||
"archy-mempool-web": 4080,
|
||||
"archy-nbxplorer": 32838,
|
||||
"bitcoin-ui": 8334,
|
||||
"botfights": 9100,
|
||||
"btcpay-server": 23000,
|
||||
"did-wallet": 8088,
|
||||
"electrs-ui": 50002,
|
||||
"electrumx": 50002,
|
||||
"fedimint": 8175,
|
||||
"filebrowser": 8083,
|
||||
"fips-ui": 8336,
|
||||
"gitea": 3001,
|
||||
"grafana": 3000,
|
||||
"homeassistant": 8123,
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import { LORA_REGIONS, regionByCode, meshcorePlanFor, MESHCORE_RF_PRESETS } from '@/utils/loraRegions'
|
||||
import { LORA_REGIONS, regionByCode, meshcorePlanFor, MESHCORE_RF_PRESETS, RNODE_REGION_PLANS } from '@/utils/loraRegions'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
|
||||
const rebooting = ref(false)
|
||||
const rebootError = ref<string | null>(null)
|
||||
const rebootMessage = ref<string | null>(null)
|
||||
|
||||
async function handleReboot() {
|
||||
rebooting.value = true
|
||||
rebootError.value = null
|
||||
rebootMessage.value = null
|
||||
// Same as apply: the radio goes away on purpose for ~15-20s.
|
||||
mesh.suppressDeviceDetect()
|
||||
try {
|
||||
await mesh.rebootRadio()
|
||||
const res = await mesh.rebootRadio()
|
||||
// The backend now waits for the device's acknowledgement and says what
|
||||
// actually happened — show it instead of silently going idle again.
|
||||
rebootMessage.value = res.message || 'Reboot command acknowledged by the radio.'
|
||||
} catch (e) {
|
||||
rebootError.value = e instanceof Error ? e.message : 'Failed to reboot radio'
|
||||
} finally {
|
||||
@@ -20,6 +27,111 @@ async function handleReboot() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── RNode (Reticulum) RF settings — full round-trip with device read-back ──
|
||||
|
||||
const rnodeForm = ref({
|
||||
enabled: true,
|
||||
port: '',
|
||||
frequency: '',
|
||||
bandwidth: '125000',
|
||||
spreading_factor: '8',
|
||||
coding_rate: '5',
|
||||
txpower: '17',
|
||||
airtime_limit_short: '',
|
||||
airtime_limit_long: '',
|
||||
})
|
||||
const rnodeLive = ref<Record<string, unknown> | null>(null)
|
||||
const rnodeLiveError = ref<string | null>(null)
|
||||
const rnodeLoading = ref(false)
|
||||
const rnodeApplying = ref(false)
|
||||
const rnodeResult = ref<{ ok: boolean; confirmed: boolean; message: string } | null>(null)
|
||||
let rnodeSeeded = false
|
||||
|
||||
const rnodeRegionPlan = computed(() => (form.value.region ? RNODE_REGION_PLANS[form.value.region] : undefined))
|
||||
|
||||
function setRnodeRecommendedForRegion() {
|
||||
const plan = rnodeRegionPlan.value
|
||||
if (!plan) return
|
||||
rnodeForm.value.frequency = String(plan.frequency)
|
||||
rnodeForm.value.bandwidth = String(plan.bandwidth)
|
||||
rnodeForm.value.spreading_factor = String(plan.spreading_factor)
|
||||
rnodeForm.value.coding_rate = String(plan.coding_rate)
|
||||
rnodeForm.value.txpower = String(plan.txpower)
|
||||
rnodeForm.value.airtime_limit_short = plan.airtime_limit_short != null ? String(plan.airtime_limit_short) : ''
|
||||
rnodeForm.value.airtime_limit_long = plan.airtime_limit_long != null ? String(plan.airtime_limit_long) : ''
|
||||
}
|
||||
|
||||
async function loadRnodeConfig() {
|
||||
rnodeLoading.value = true
|
||||
try {
|
||||
const res = await mesh.getRnodeConfig()
|
||||
rnodeLive.value = res.live
|
||||
rnodeLiveError.value = res.live_error
|
||||
const s = res.settings as Record<string, unknown>
|
||||
if (!rnodeSeeded && s) {
|
||||
rnodeSeeded = true
|
||||
rnodeForm.value.enabled = s.enabled !== false
|
||||
rnodeForm.value.port = (s.port as string) ?? ''
|
||||
rnodeForm.value.frequency = String(s.frequency ?? '')
|
||||
rnodeForm.value.bandwidth = String(s.bandwidth ?? '125000')
|
||||
rnodeForm.value.spreading_factor = String(s.spreading_factor ?? '8')
|
||||
rnodeForm.value.coding_rate = String(s.coding_rate ?? '5')
|
||||
rnodeForm.value.txpower = String(s.txpower ?? '17')
|
||||
rnodeForm.value.airtime_limit_short = s.airtime_limit_short != null ? String(s.airtime_limit_short) : ''
|
||||
rnodeForm.value.airtime_limit_long = s.airtime_limit_long != null ? String(s.airtime_limit_long) : ''
|
||||
}
|
||||
} catch (e) {
|
||||
rnodeLiveError.value = e instanceof Error ? e.message : 'Could not load RNode settings'
|
||||
} finally {
|
||||
rnodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyRnodeSettings() {
|
||||
rnodeApplying.value = true
|
||||
rnodeResult.value = null
|
||||
// Applying deliberately restarts the radio daemon; without this the
|
||||
// "new device detected" modal interrupts the flow mid-apply.
|
||||
mesh.suppressDeviceDetect()
|
||||
try {
|
||||
const res = await mesh.applyRnodeConfig({
|
||||
enabled: rnodeForm.value.enabled,
|
||||
port: rnodeForm.value.port.trim() || null,
|
||||
frequency: Number(rnodeForm.value.frequency),
|
||||
bandwidth: Number(rnodeForm.value.bandwidth),
|
||||
spreading_factor: Number(rnodeForm.value.spreading_factor),
|
||||
coding_rate: Number(rnodeForm.value.coding_rate),
|
||||
txpower: Number(rnodeForm.value.txpower),
|
||||
airtime_limit_short: rnodeForm.value.airtime_limit_short === '' ? null : Number(rnodeForm.value.airtime_limit_short),
|
||||
airtime_limit_long: rnodeForm.value.airtime_limit_long === '' ? null : Number(rnodeForm.value.airtime_limit_long),
|
||||
})
|
||||
rnodeResult.value = { ok: res.applied, confirmed: !!res.confirmed, message: res.message }
|
||||
if (res.live) rnodeLive.value = res.live
|
||||
} catch (e) {
|
||||
rnodeResult.value = { ok: false, confirmed: false, message: e instanceof Error ? e.message : 'Apply failed' }
|
||||
} finally {
|
||||
rnodeApplying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRnodeLive() {
|
||||
rnodeLoading.value = true
|
||||
try {
|
||||
const res = await mesh.getRnodeConfig()
|
||||
rnodeLive.value = res.live
|
||||
rnodeLiveError.value = res.live_error
|
||||
} catch (e) {
|
||||
rnodeLiveError.value = e instanceof Error ? e.message : 'Could not read the radio state'
|
||||
} finally {
|
||||
rnodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fmtMhz(v: unknown): string {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) && n > 0 ? `${(n / 1e6).toFixed(4)} MHz` : '—'
|
||||
}
|
||||
|
||||
// ── Editable settings (persisted via mesh.configure) ──
|
||||
const form = ref({
|
||||
region: '',
|
||||
@@ -157,6 +269,18 @@ async function saveSettings() {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load the RNode settings + live state as soon as the panel knows a
|
||||
// Reticulum radio is (or is pinned as) the device. Declared LAST: with
|
||||
// `immediate: true` the source getter runs at setup, and `effectiveKind`
|
||||
// must already exist (the SendBitcoinModal TDZ-crash lesson).
|
||||
watch(
|
||||
() => effectiveKind.value,
|
||||
(kind) => {
|
||||
if (kind === 'reticulum') void loadRnodeConfig()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -202,7 +326,7 @@ async function saveSettings() {
|
||||
Program the radio's RF settings with the fields below — every radio on your mesh must match{{ selectedRegion ? ` (${selectedRegion.band} MHz band)` : '' }}.
|
||||
</p>
|
||||
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
|
||||
RNode RF parameters are managed by the Reticulum daemon's interface config on this node.
|
||||
Pick your region, then use "Set recommended for region" in the RNode section below.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -271,6 +395,96 @@ async function saveSettings() {
|
||||
Saved settings program the radio on its next connect (it reboots once to apply). Leave all four empty to keep the radio's own settings.
|
||||
</p>
|
||||
</div>
|
||||
<!-- RNode (Reticulum) RF settings: the device's CURRENT values shown
|
||||
first (radio-confirmed read-back), then every parameter editable,
|
||||
with apply → device confirmation. Actions stack in a column. -->
|
||||
<div v-if="effectiveKind === 'reticulum'" class="mt-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h5 class="text-xs font-semibold text-white/80">RNode radio — current device settings</h5>
|
||||
<button class="text-[11px] text-sky-300/80 hover:text-sky-200 disabled:opacity-50" :disabled="rnodeLoading" @click="refreshRnodeLive">
|
||||
{{ rnodeLoading ? 'Reading…' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="rnodeLive" class="rounded-lg bg-white/[0.04] border border-white/10 p-3 mb-3 grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||
<div><span class="text-white/40 block">Status</span><span :class="rnodeLive.online ? 'text-green-400' : 'text-amber-400'">{{ rnodeLive.online ? 'Online' : 'Detected, not online' }}</span></div>
|
||||
<div><span class="text-white/40 block">Port</span><span class="text-white/80">{{ rnodeLive.port || '—' }}</span></div>
|
||||
<div><span class="text-white/40 block">Frequency</span><span class="text-white/80">{{ fmtMhz(rnodeLive.r_frequency ?? rnodeLive.frequency) }}</span></div>
|
||||
<div><span class="text-white/40 block">Bandwidth</span><span class="text-white/80">{{ rnodeLive.r_bandwidth ?? rnodeLive.bandwidth ?? '—' }} Hz</span></div>
|
||||
<div><span class="text-white/40 block">Spreading</span><span class="text-white/80">SF {{ rnodeLive.r_spreadingfactor ?? rnodeLive.spreadingfactor ?? '—' }}</span></div>
|
||||
<div><span class="text-white/40 block">Coding rate</span><span class="text-white/80">4/{{ rnodeLive.r_codingrate ?? rnodeLive.codingrate ?? '—' }}</span></div>
|
||||
<div><span class="text-white/40 block">TX power</span><span class="text-white/80">{{ rnodeLive.r_txpower ?? rnodeLive.txpower ?? '—' }} dBm</span></div>
|
||||
<div><span class="text-white/40 block">Airtime limits</span><span class="text-white/80">{{ rnodeLive.r_airtime_limit_short ?? rnodeLive.airtime_limit_short ?? '—' }}% / {{ rnodeLive.r_airtime_limit_long ?? rnodeLive.airtime_limit_long ?? '—' }}%</span></div>
|
||||
</div>
|
||||
<p v-else-if="rnodeLiveError" class="text-[11px] text-amber-400/80 mb-3">{{ rnodeLiveError }}</p>
|
||||
|
||||
<h5 class="text-xs font-semibold text-white/80 mb-2">RNode RF parameters</h5>
|
||||
<div class="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Frequency (Hz)</label>
|
||||
<input v-model="rnodeForm.frequency" inputmode="numeric" placeholder="869462500" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Bandwidth (Hz)</label>
|
||||
<select v-model="rnodeForm.bandwidth" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option v-for="bw in ['7800','10400','15600','20800','31250','41700','62500','125000','250000','500000']" :key="bw" :value="bw">{{ bw }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Spreading factor</label>
|
||||
<select v-model="rnodeForm.spreading_factor" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option v-for="sf in [5,6,7,8,9,10,11,12]" :key="sf" :value="String(sf)">SF {{ sf }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Coding rate</label>
|
||||
<select v-model="rnodeForm.coding_rate" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option v-for="cr in [5,6,7,8]" :key="cr" :value="String(cr)">4/{{ cr }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">TX power (dBm)</label>
|
||||
<input v-model="rnodeForm.txpower" inputmode="numeric" placeholder="14" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Airtime short (%)</label>
|
||||
<input v-model="rnodeForm.airtime_limit_short" inputmode="decimal" placeholder="25" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Airtime long (%)</label>
|
||||
<input v-model="rnodeForm.airtime_limit_long" inputmode="decimal" placeholder="10" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Serial port</label>
|
||||
<input v-model="rnodeForm.port" placeholder="auto-detect" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
|
||||
<input v-model="rnodeForm.enabled" type="checkbox" class="h-4 w-4 accent-orange-500" />
|
||||
RNode interface enabled
|
||||
</label>
|
||||
|
||||
<!-- Actions: stacked in a column on purpose (operator layout request) -->
|
||||
<div class="flex flex-col gap-2 mt-4 max-w-sm">
|
||||
<button
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
:disabled="!rnodeRegionPlan || rnodeApplying"
|
||||
@click="setRnodeRecommendedForRegion"
|
||||
>
|
||||
{{ rnodeRegionPlan ? `Set recommended for ${form.region}` : 'Pick a region above first' }}
|
||||
</button>
|
||||
<button
|
||||
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
:disabled="rnodeApplying"
|
||||
@click="applyRnodeSettings"
|
||||
>
|
||||
{{ rnodeApplying ? 'Applying — waiting for the radio to confirm…' : 'Apply & Confirm on Device' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="rnodeResult" class="text-xs mt-2" :class="rnodeResult.ok && rnodeResult.confirmed ? 'text-green-400' : rnodeResult.ok ? 'text-amber-400' : 'text-red-400'">
|
||||
<template v-if="rnodeResult.ok && rnodeResult.confirmed">✓ </template>{{ rnodeResult.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
|
||||
<input v-model="form.broadcastIdentity" type="checkbox" class="h-4 w-4 accent-orange-500" />
|
||||
Periodically broadcast this node's identity on the mesh
|
||||
@@ -304,6 +518,7 @@ async function saveSettings() {
|
||||
<template v-else>Reboot Radio</template>
|
||||
</button>
|
||||
<p class="mesh-device-reboot-hint">Use this if the device stops responding to sent messages or seems stuck.</p>
|
||||
<p v-if="rebootMessage" class="text-xs text-green-400 mt-1">{{ rebootMessage }}</p>
|
||||
<p v-if="rebootError" class="mesh-device-reboot-error">{{ rebootError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,17 +43,26 @@
|
||||
.mesh-columns-wide .mesh-chat-card { grid-column: 2; grid-row: 1; min-height: 0; overflow: hidden; }
|
||||
.mesh-columns-wide .mesh-tools-wrapper { grid-column: 3; grid-row: 1; display: flex; flex-direction: column; gap: 0; min-height: 0; overflow: hidden; }
|
||||
.mesh-columns-wide .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; margin-bottom: 12px; }
|
||||
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(420px, 0.95fr); }
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper { display: grid; grid-template-rows: minmax(0, 1fr) minmax(0, 0.85fr) minmax(0, 1fr); gap: 12px; overflow: hidden; }
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-deadman-panel,
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-assistant-panel,
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-map-panel { min-height: 0; height: 100%; overflow: hidden; }
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-bitcoin-panel,
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-deadman-panel,
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-assistant-panel,
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-map-panel { flex: 1 1 auto; min-height: 0; height: auto; }
|
||||
.mesh-columns-very-wide .mesh-tools-tab-bar { display: none; }
|
||||
/* A very wide screen gets a roomier third column — but the SAME tabbed
|
||||
panel as every other desktop width. It used to stack Bitcoin, Dead Man,
|
||||
AI, the map and Device on top of each other in fixed grid rows, which on
|
||||
a real 2560px display clipped the first three headings to a few pixels,
|
||||
letterboxed the map, and pushed Radio Settings into a scroll — more
|
||||
screen producing a worse view (reported with a screenshot 2026-08-05).
|
||||
One tab at a time, filling the column, is what makes the map edge to
|
||||
edge and every control reachable without scrolling. */
|
||||
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(460px, 1fr); }
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-deadman-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-assistant-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-device-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel {
|
||||
flex: 1 1 auto; min-height: 0; height: auto; overflow-y: auto;
|
||||
}
|
||||
/* The map is the one panel with nothing to scroll: let it consume the
|
||||
column edge to edge rather than sitting in a letterbox. */
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel { overflow: hidden; padding: 0; }
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel > * { height: 100%; width: 100%; }
|
||||
.mesh-columns-wide .mesh-mobile-back-btn,
|
||||
.mesh-columns-wide .mesh-tab-bar { display: none; }
|
||||
.mesh-status-card { padding: 16px; flex-shrink: 0; }
|
||||
|
||||
@@ -362,6 +362,38 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.125-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.125-alpha</span>
|
||||
<span class="text-xs text-white/40">August 6, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.</strong> Since the gate went up, those screens would load their frame and then show every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.</p>
|
||||
<p><strong>IndeeHub heals itself.</strong> Three separate faults: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted about ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — the node would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. All three are fixed, and the storage fault fixes the same trap for every future multi-part app.</p>
|
||||
<p><strong>Send and Receive open clean every time.</strong> Whatever you typed last — an address, an amount, and above all an armed "send all funds" toggle — no longer quietly carries over into the next payment. And choosing "send all funds" now shows the amount being swept instead of a confusing 0.</p>
|
||||
<p><strong>The camera scanner option no longer vanishes on desktop.</strong> Browsers only allow the live camera on secure (HTTPS) pages, and the scan window used to silently hide the camera choice on plain connections, which read as "the scanner is gone". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.</p>
|
||||
<p>Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.124-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.124-alpha</span>
|
||||
<span class="text-xs text-white/40">August 5, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>The most important fix here: some nodes were left switched off by their own update, and could not switch themselves back on.</strong> The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it <em>crashes</em>. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts.</p>
|
||||
<p><strong>Portainer opens again.</strong> Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1 and their stored data was written by that version, while the app list pinned a version from two years earlier — so a rebuilt container landed on the old one, which will not read newer data. The correct version is pinned now, older installs upgrade cleanly, and no data was touched.</p>
|
||||
<p><strong>Bitcoin starts reliably again.</strong> A leftover settings file in the Bitcoin folder — one the node kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes it, clears stale copies, and treats any that remain as harmless.</p>
|
||||
<p><strong>Every app screen opens from My Apps again.</strong> The login gate refused to be displayed inside another page at all — which is exactly how My Apps opens an app — so protected apps looked broken. It now allows only your own node to display it and refuses everyone else.</p>
|
||||
<p><strong>The app login screen looks like the node's own now:</strong> same rotating artwork, same panel, the Archipelago mark, and the app's real icon as a tile the way My Apps shows it, instead of a plain box with a letter.</p>
|
||||
<p><strong>The Mesh screen uses wide displays properly.</strong> On very large screens it stacked all five panels, clipping three headings to a sliver and squeezing the map into a letterbox. It now shows one panel at a time, filling the space, with the map edge to edge.</p>
|
||||
<p><strong>You can choose how long you stay signed in.</strong> Settings → Account offers an inactivity timeout, a hard limit, and an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle — there is nobody there to sign them back in.</p>
|
||||
<p>Updates now come from source.archipelago-foundation.org rather than a bare address, with the old one kept as an automatic fallback. Also: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.</p>
|
||||
<p>Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.123-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import AccountInfoSection from '@/views/settings/AccountInfoSection.vue'
|
||||
import ChangePasswordSection from '@/views/settings/ChangePasswordSection.vue'
|
||||
import TwoFactorSection from '@/views/settings/TwoFactorSection.vue'
|
||||
import SessionTimeoutSection from '@/views/settings/SessionTimeoutSection.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
@@ -24,6 +25,7 @@ async function handleLogout() {
|
||||
<AccountInfoSection />
|
||||
<ChangePasswordSection />
|
||||
<TwoFactorSection />
|
||||
<SessionTimeoutSection />
|
||||
|
||||
<!-- Logout Button -->
|
||||
<button
|
||||
|
||||
@@ -289,6 +289,15 @@ defineExpose({ loadBackups })
|
||||
(and 2FA code, if enabled). Only reveal it somewhere private — anyone with these
|
||||
words controls this node.
|
||||
</p>
|
||||
<a
|
||||
href="/entropy/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-1 mt-2 text-sm text-orange-300/90 hover:text-orange-200 transition-colors"
|
||||
>
|
||||
How your seed & keys work — the full guide
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
// This node signs its own certificates with a CA that never leaves it. Install
|
||||
// that CA once per device and every port on this node is trusted — which is what
|
||||
// lets a gated app load inside the dashboard's frame at all: a cert warning
|
||||
// cannot be clicked through inside an iframe, so an untrusted app port simply
|
||||
// fails to render.
|
||||
|
||||
const fingerprint = ref('')
|
||||
const fingerprintError = ref('')
|
||||
const loading = ref(true)
|
||||
const caAvailable = ref(false)
|
||||
|
||||
// SHA-256 over the DER bytes — the same number `openssl x509 -fingerprint
|
||||
// -sha256` prints, so the two can be compared character for character.
|
||||
async function computeFingerprint(pem: string): Promise<string> {
|
||||
const body = pem
|
||||
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||
.replace(/-----END CERTIFICATE-----/, '')
|
||||
.replace(/\s+/g, '')
|
||||
const der = Uint8Array.from(atob(body), (c) => c.charCodeAt(0))
|
||||
const digest = await crypto.subtle.digest('SHA-256', der)
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, '0').toUpperCase())
|
||||
.join(':')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/ca.crt', { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const pem = await res.text()
|
||||
if (!pem.includes('BEGIN CERTIFICATE')) throw new Error('not a certificate')
|
||||
caAvailable.value = true
|
||||
|
||||
// crypto.subtle only exists in a secure context. That is exactly the case
|
||||
// this feature is meant to fix, so an HTTP dashboard lands here — say so
|
||||
// and give the offline command rather than showing nothing.
|
||||
if (!window.crypto?.subtle) {
|
||||
fingerprintError.value =
|
||||
'The fingerprint cannot be computed over a plain HTTP connection. Verify it on the node instead: openssl x509 -in /etc/archipelago/ssl/ca.crt -noout -fingerprint -sha256'
|
||||
} else {
|
||||
fingerprint.value = await computeFingerprint(pem)
|
||||
}
|
||||
} catch {
|
||||
caAvailable.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-base font-medium text-white/90 mb-1">Node certificate</h3>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Install this node's certificate on a device and it stops warning you about
|
||||
this node — on every port, not just the dashboard. Apps that open inside
|
||||
the dashboard need this: a certificate warning cannot be accepted inside an
|
||||
embedded frame, so an untrusted app shows nothing at all.
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="text-sm text-white/50">Checking…</div>
|
||||
|
||||
<div
|
||||
v-else-if="!caAvailable"
|
||||
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
|
||||
>
|
||||
This node has not generated a certificate authority yet. Run
|
||||
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">scripts/setup-node-ca.sh</code>
|
||||
on the node, then reload this page.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div>
|
||||
<a
|
||||
href="/ca.crt"
|
||||
download="archipelago-node-ca.crt"
|
||||
class="inline-flex items-center gap-2 px-4 py-3 glass-button rounded-lg text-sm font-semibold"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download this node's certificate
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white/80 mb-1">Fingerprint (SHA-256)</p>
|
||||
<p v-if="fingerprint" class="font-mono text-xs text-white/70 break-all select-all">{{ fingerprint }}</p>
|
||||
<p v-else class="text-xs text-orange-300/80">{{ fingerprintError }}</p>
|
||||
<p class="text-xs text-white/50 mt-2">
|
||||
Check this matches the fingerprint the node itself prints before you trust
|
||||
it. If they differ, something is intercepting the connection — do not install it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-sm font-medium text-white/80 py-2">
|
||||
How to install it
|
||||
</summary>
|
||||
<div class="mt-2 space-y-3 text-sm text-white/60">
|
||||
<p><strong class="text-white/80">macOS</strong> — open the file, add it to the
|
||||
<em>login</em> keychain, then find it in Keychain Access, open it, expand Trust
|
||||
and set “When using this certificate” to <em>Always Trust</em>.</p>
|
||||
<p><strong class="text-white/80">iOS / iPadOS</strong> — download it in Safari and
|
||||
allow the profile, then Settings → General → VPN & Device Management to
|
||||
install it, and finally Settings → General → About → Certificate Trust Settings
|
||||
to switch it on. Both steps are required.</p>
|
||||
<p><strong class="text-white/80">Windows</strong> — right-click → Install
|
||||
Certificate → Local Machine → place it in <em>Trusted Root Certification
|
||||
Authorities</em>.</p>
|
||||
<p><strong class="text-white/80">Android</strong> — Settings → Security →
|
||||
Encryption & credentials → Install a certificate → CA certificate.</p>
|
||||
<p><strong class="text-white/80">Linux</strong> — copy to
|
||||
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">/usr/local/share/ca-certificates/</code>
|
||||
and run <code class="px-1 py-0.5 bg-black/30 rounded text-xs">sudo update-ca-certificates</code>.
|
||||
Firefox keeps its own store — add it under Settings → Privacy & Security →
|
||||
View Certificates → Authorities.</p>
|
||||
<p class="text-white/50">
|
||||
You are trusting this node, not a company. The signing key stays on the node
|
||||
and only ever signs this node's own address. Anyone who takes the node also
|
||||
takes that key — remove the certificate from your devices if you retire it.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* How long a login lasts on this node.
|
||||
*
|
||||
* Presented as two plain questions — "sign me out after quiet" and "always
|
||||
* sign me out after" — rather than as the two-token mechanism underneath.
|
||||
* The distinction that matters to the operator is that the second one is
|
||||
* what actually guarantees a login ends: this dashboard polls constantly,
|
||||
* so an idle timeout alone never fires on an open tab.
|
||||
*/
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
type Policy = {
|
||||
idle_timeout_secs: number
|
||||
absolute_timeout_secs: number | null
|
||||
reauth_for_funds: boolean
|
||||
}
|
||||
|
||||
const idle = ref<number>(86400)
|
||||
const absolute = ref<number | null>(30 * 24 * 3600)
|
||||
const reauthForFunds = ref(true)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Offered as presets rather than a free number field: the useful values are
|
||||
// few, and a box accepting "5" invites someone to lock themselves out.
|
||||
const idleChoices = [
|
||||
{ label: '15 minutes', value: 15 * 60 },
|
||||
{ label: '1 hour', value: 3600 },
|
||||
{ label: '1 day', value: 86400 },
|
||||
{ label: '1 week', value: 7 * 24 * 3600 },
|
||||
{ label: '30 days', value: 30 * 24 * 3600 },
|
||||
]
|
||||
const absoluteChoices = [
|
||||
{ label: '1 day', value: 86400 },
|
||||
{ label: '1 week', value: 7 * 24 * 3600 },
|
||||
{ label: '30 days', value: 30 * 24 * 3600 },
|
||||
{ label: '90 days', value: 90 * 24 * 3600 },
|
||||
{ label: 'Never', value: null },
|
||||
]
|
||||
|
||||
const shortIdleWarning = computed(() => idle.value <= 3600)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const p = await rpcClient.call<Policy>({ method: 'auth.session-policy.get', params: {} })
|
||||
idle.value = p.idle_timeout_secs
|
||||
absolute.value = p.absolute_timeout_secs
|
||||
reauthForFunds.value = p.reauth_for_funds
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
error.value = ''
|
||||
try {
|
||||
// The backend clamps and returns what it stored, so reflect that back
|
||||
// rather than assuming our values were taken verbatim.
|
||||
const p = await rpcClient.call<Policy>({
|
||||
method: 'auth.session-policy.set',
|
||||
params: {
|
||||
idle_timeout_secs: idle.value,
|
||||
absolute_timeout_secs: absolute.value,
|
||||
reauth_for_funds: reauthForFunds.value,
|
||||
},
|
||||
})
|
||||
idle.value = p.idle_timeout_secs
|
||||
absolute.value = p.absolute_timeout_secs
|
||||
reauthForFunds.value = p.reauth_for_funds
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2500)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-base font-medium text-white/90 mb-1">Session timeout</h3>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
How long this node keeps you signed in. TV and kiosk screens are never
|
||||
signed out for sitting idle — there is nobody there to sign them back in.
|
||||
</p>
|
||||
|
||||
<div v-if="error" role="alert" class="mb-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg text-red-200 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="!loading" class="space-y-4">
|
||||
<div>
|
||||
<label for="idle-timeout" class="block text-sm font-medium text-white/80 mb-2">Sign me out after this much inactivity</label>
|
||||
<select
|
||||
id="idle-timeout"
|
||||
v-model.number="idle"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
|
||||
>
|
||||
<option v-for="c in idleChoices" :key="c.value" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
|
||||
</select>
|
||||
<p v-if="shortIdleWarning" class="text-xs text-orange-300/80 mt-2">
|
||||
Short timeouts are what payment-industry rules ask for when funds are involved — expect to sign in often.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="absolute-timeout" class="block text-sm font-medium text-white/80 mb-2">Always sign me out after</label>
|
||||
<select
|
||||
id="absolute-timeout"
|
||||
v-model="absolute"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
|
||||
>
|
||||
<option v-for="c in absoluteChoices" :key="String(c.value)" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
|
||||
</select>
|
||||
<p class="text-xs text-white/50 mt-2">
|
||||
Counts from when you signed in, whatever you are doing. This is the one that
|
||||
guarantees a session ends: an open dashboard is never idle, so the setting
|
||||
above would not fire on it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input v-model="reauthForFunds" type="checkbox" class="mt-1 accent-orange-500" />
|
||||
<span class="text-sm text-white/80">
|
||||
Ask for my password again before sending funds
|
||||
<span class="block text-xs text-white/50">Recommended. Applies however recently you signed in.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
:disabled="saving"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 disabled:opacity-50"
|
||||
@click="save"
|
||||
>
|
||||
{{ saving ? 'Saving…' : (saved ? 'Saved' : 'Save session settings') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
|
||||
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
|
||||
import WebhookSection from '@/views/settings/WebhookSection.vue'
|
||||
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
|
||||
import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue'
|
||||
import BackupSection from '@/views/settings/BackupSection.vue'
|
||||
import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
||||
</script>
|
||||
@@ -16,6 +17,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
||||
<AIDataAccessSection />
|
||||
<WebhookSection />
|
||||
<TelemetrySection />
|
||||
<NodeCertificateSection />
|
||||
<BackupSection />
|
||||
<SystemDangerZone />
|
||||
</template>
|
||||
|
||||
+23
-21
@@ -1,33 +1,35 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**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."
|
||||
"**The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.",
|
||||
"**IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app.",
|
||||
"**A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled.",
|
||||
"**Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed \"send all funds\" toggle — no longer quietly carries over into the next payment. Choosing \"send all funds\" also shows the amount being swept instead of a confusing 0.",
|
||||
"**A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with \"check server logs\"; it now explains that no transaction can be built from those coins.",
|
||||
"**The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as \"the scanner is gone\". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.",
|
||||
"**App data folders can no longer be \"repaired\" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied.",
|
||||
"Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.",
|
||||
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.123-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago",
|
||||
"current_version": "1.7.125-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.123-alpha",
|
||||
"sha256": "b7974a671f8f7987fab548d0a9cac1ad145e9a52f4099791c3d1879d065a9f47",
|
||||
"size_bytes": 54957360
|
||||
"new_version": "1.7.125-alpha",
|
||||
"sha256": "080bc83cb10b3ebe532497917d96b7af3766c36c30119cb0f348de36162d9e9d",
|
||||
"size_bytes": 55060216
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.123-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago-frontend-1.7.123-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.123-alpha.tar.gz",
|
||||
"new_version": "1.7.123-alpha",
|
||||
"sha256": "075370c761f07373e553db07eb5e019d96ceb591fa9c1067e1adc6701fa30752",
|
||||
"size_bytes": 210533854
|
||||
"current_version": "1.7.125-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago-frontend-1.7.125-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.125-alpha.tar.gz",
|
||||
"new_version": "1.7.125-alpha",
|
||||
"sha256": "0bff6f169767043928d9189ab53045c9e4f7c523697b42bf770b2b485c2902f1",
|
||||
"size_bytes": 210532813
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-05",
|
||||
"signature": "f64eaf9731277865c0d3f757f63dc2f4921fb7d90deb1615ca2b2b0f4c173282524b39a2d0b8f027c7532e918c349a86b14ed1b7992a96b0c6a80cd46b5d500d",
|
||||
"release_date": "2026-08-06",
|
||||
"signature": "975157cc59527679f3de846a4a93d11e27f6ad48eed215fd769574dfc6db36a687867a35aeb6e705d41667ea800b948f53ce7f80bfcdb5a6e1820e50613d4403",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.7.123-alpha"
|
||||
"version": "1.7.125-alpha"
|
||||
}
|
||||
|
||||
+23
-24
@@ -421,7 +421,7 @@
|
||||
},
|
||||
"container": {
|
||||
"custom_args": [
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
||||
],
|
||||
"data_uid": "100101:100101",
|
||||
"derived_env": [
|
||||
@@ -567,7 +567,7 @@
|
||||
},
|
||||
"container": {
|
||||
"custom_args": [
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
||||
],
|
||||
"data_uid": "100101:100101",
|
||||
"derived_env": [
|
||||
@@ -684,7 +684,7 @@
|
||||
]
|
||||
},
|
||||
"bitcoin-ui": {
|
||||
"image": "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.119-alpha",
|
||||
"image": "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.123-alpha",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
@@ -717,7 +717,8 @@
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8334,
|
||||
"host": 8334,
|
||||
"protocol": "tcp"
|
||||
"protocol": "tcp",
|
||||
"session_passthrough": true
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
@@ -740,7 +741,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "1.7.119-alpha"
|
||||
"version": "1.7.123-alpha"
|
||||
},
|
||||
"botfights": {
|
||||
"manifest": {
|
||||
@@ -1146,7 +1147,7 @@
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"electrs-ui": {
|
||||
"image": "146.59.87.168:3000/lfg2025/electrs-ui:latest",
|
||||
"image": "146.59.87.168:3000/lfg2025/electrs-ui:1.7.123-alpha",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
@@ -1175,7 +1176,8 @@
|
||||
"bind": "127.0.0.1",
|
||||
"container": 50002,
|
||||
"host": 50002,
|
||||
"protocol": "tcp"
|
||||
"protocol": "tcp",
|
||||
"session_passthrough": true
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
@@ -1189,7 +1191,7 @@
|
||||
"volumes": []
|
||||
}
|
||||
},
|
||||
"version": "latest"
|
||||
"version": "1.7.123-alpha"
|
||||
},
|
||||
"electrumx": {
|
||||
"image": "146.59.87.168:3000/lfg2025/electrumx:v1.18.0",
|
||||
@@ -1386,13 +1388,6 @@
|
||||
"host": 8174,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8175,
|
||||
"host": 8175,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
@@ -1739,7 +1734,8 @@
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8336,
|
||||
"host": 8336,
|
||||
"protocol": "tcp"
|
||||
"protocol": "tcp",
|
||||
"session_passthrough": true
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
@@ -2729,6 +2725,8 @@
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [
|
||||
"CHOWN",
|
||||
"DAC_OVERRIDE",
|
||||
"SETGID",
|
||||
"SETUID"
|
||||
],
|
||||
@@ -3094,7 +3092,7 @@
|
||||
"version": "v0.18.4-beta"
|
||||
},
|
||||
"lnd-ui": {
|
||||
"image": "146.59.87.168:3000/lfg2025/lnd-ui:1.7.119-alpha",
|
||||
"image": "146.59.87.168:3000/lfg2025/lnd-ui:1.7.123-alpha",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
@@ -3127,7 +3125,8 @@
|
||||
"bind": "127.0.0.1",
|
||||
"container": 18083,
|
||||
"host": 18083,
|
||||
"protocol": "tcp"
|
||||
"protocol": "tcp",
|
||||
"session_passthrough": true
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
@@ -3141,7 +3140,7 @@
|
||||
"volumes": []
|
||||
}
|
||||
},
|
||||
"version": "1.7.119-alpha"
|
||||
"version": "1.7.123-alpha"
|
||||
},
|
||||
"mempool": {
|
||||
"image": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1",
|
||||
@@ -4382,13 +4381,13 @@
|
||||
"version": "3.4.2"
|
||||
},
|
||||
"portainer": {
|
||||
"image": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
||||
"image": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"category": "development",
|
||||
"container": {
|
||||
"data_uid": "1000:1000",
|
||||
"image": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
||||
"image": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
@@ -4475,7 +4474,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "2.19.4"
|
||||
"version": "2.39.1"
|
||||
},
|
||||
"router": {
|
||||
"manifest": {
|
||||
@@ -4898,7 +4897,7 @@
|
||||
}
|
||||
},
|
||||
"schema": 1,
|
||||
"signature": "cc19214673b37c2d7d41edf996d5002abe41cf933ee9f33fb7182b05c6c40aa1a555ad596228c4a83dc4a11166f3345e7478399f9693f5f2da3818020980eb03",
|
||||
"signature": "a9a0bf60aa6c47ae970a6c7c3e19e9390ee7af157f425c2e38b1bbb194f0315b73ddf16bc46244b36390454787a1ddecc559be806098fdf569d3315f388b9006",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"updated": "2026-08-05"
|
||||
"updated": "2026-08-06"
|
||||
}
|
||||
|
||||
+23
-21
@@ -1,33 +1,35 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**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."
|
||||
"**The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.",
|
||||
"**IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app.",
|
||||
"**A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled.",
|
||||
"**Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed \"send all funds\" toggle — no longer quietly carries over into the next payment. Choosing \"send all funds\" also shows the amount being swept instead of a confusing 0.",
|
||||
"**A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with \"check server logs\"; it now explains that no transaction can be built from those coins.",
|
||||
"**The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as \"the scanner is gone\". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.",
|
||||
"**App data folders can no longer be \"repaired\" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied.",
|
||||
"Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.",
|
||||
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.123-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago",
|
||||
"current_version": "1.7.125-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.123-alpha",
|
||||
"sha256": "b7974a671f8f7987fab548d0a9cac1ad145e9a52f4099791c3d1879d065a9f47",
|
||||
"size_bytes": 54957360
|
||||
"new_version": "1.7.125-alpha",
|
||||
"sha256": "080bc83cb10b3ebe532497917d96b7af3766c36c30119cb0f348de36162d9e9d",
|
||||
"size_bytes": 55060216
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.123-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago-frontend-1.7.123-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.123-alpha.tar.gz",
|
||||
"new_version": "1.7.123-alpha",
|
||||
"sha256": "075370c761f07373e553db07eb5e019d96ceb591fa9c1067e1adc6701fa30752",
|
||||
"size_bytes": 210533854
|
||||
"current_version": "1.7.125-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago-frontend-1.7.125-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.125-alpha.tar.gz",
|
||||
"new_version": "1.7.125-alpha",
|
||||
"sha256": "0bff6f169767043928d9189ab53045c9e4f7c523697b42bf770b2b485c2902f1",
|
||||
"size_bytes": 210532813
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-05",
|
||||
"signature": "f64eaf9731277865c0d3f757f63dc2f4921fb7d90deb1615ca2b2b0f4c173282524b39a2d0b8f027c7532e918c349a86b14ed1b7992a96b0c6a80cd46b5d500d",
|
||||
"release_date": "2026-08-06",
|
||||
"signature": "975157cc59527679f3de846a4a93d11e27f6ad48eed215fd769574dfc6db36a687867a35aeb6e705d41667ea800b948f53ce7f80bfcdb5a6e1820e50613d4403",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.7.123-alpha"
|
||||
"version": "1.7.125-alpha"
|
||||
}
|
||||
|
||||
@@ -108,6 +108,13 @@ def _write_rns_config(
|
||||
f" spreadingfactor = {lora['spreadingfactor']}\n"
|
||||
f" codingrate = {lora['codingrate']}\n"
|
||||
)
|
||||
# Regulatory duty-cycle limits (percent). Only written when set —
|
||||
# absent keys keep RNS's own default (no software airtime lock),
|
||||
# matching every daemon built before these args existed.
|
||||
if lora.get("airtime_limit_short") is not None:
|
||||
interfaces += f" airtime_limit_short = {lora['airtime_limit_short']}\n"
|
||||
if lora.get("airtime_limit_long") is not None:
|
||||
interfaces += f" airtime_limit_long = {lora['airtime_limit_long']}\n"
|
||||
elif tcp_listen or tcp_connect:
|
||||
parts = []
|
||||
if tcp_listen:
|
||||
@@ -188,6 +195,8 @@ class ReticulumDaemon:
|
||||
"txpower": self.args.txpower,
|
||||
"spreadingfactor": self.args.spreadingfactor,
|
||||
"codingrate": self.args.codingrate,
|
||||
"airtime_limit_short": self.args.airtime_limit_short,
|
||||
"airtime_limit_long": self.args.airtime_limit_long,
|
||||
},
|
||||
no_radio=self.args.no_radio,
|
||||
tcp_listen=self.args.tcp_listen,
|
||||
@@ -358,6 +367,8 @@ class ReticulumDaemon:
|
||||
self.announce()
|
||||
elif cmd == "status":
|
||||
self._broadcast(self._status())
|
||||
elif cmd == "radio_state":
|
||||
self._broadcast(self._radio_state())
|
||||
elif cmd == "send_resource":
|
||||
self._send_resource(req)
|
||||
elif cmd == "shutdown":
|
||||
@@ -374,6 +385,48 @@ class ReticulumDaemon:
|
||||
return {"event": "status", "connected": self.router is not None,
|
||||
"dest_hash": self.dest_hash_hex, "interfaces": ifaces}
|
||||
|
||||
def _radio_state(self) -> dict:
|
||||
"""Radio-confirmed RNode parameters, straight from the live
|
||||
RNodeInterface object. The r_* attributes are what the RADIO reported
|
||||
after detect/configure (RNS/Interfaces/RNodeInterface.py) — this is
|
||||
the read-back the settings panel shows as proof the device is
|
||||
actually using the applied values, as opposed to what the config
|
||||
asked for. Absent radio (TCP/no-radio builds) → configured=False."""
|
||||
state = {"event": "radio_state", "configured": False, "online": False}
|
||||
try:
|
||||
import RNS
|
||||
for iface in list(RNS.Transport.interfaces):
|
||||
if type(iface).__name__ != "RNodeInterface":
|
||||
continue
|
||||
state.update({
|
||||
"configured": True,
|
||||
"online": bool(getattr(iface, "online", False)),
|
||||
"port": getattr(iface, "port", None),
|
||||
# Requested (config) values…
|
||||
"frequency": getattr(iface, "frequency", None),
|
||||
"bandwidth": getattr(iface, "bandwidth", None),
|
||||
"txpower": getattr(iface, "txpower", None),
|
||||
"spreadingfactor": getattr(iface, "sf", None),
|
||||
"codingrate": getattr(iface, "cr", None),
|
||||
"airtime_limit_short": getattr(iface, "st_alock", None),
|
||||
"airtime_limit_long": getattr(iface, "lt_alock", None),
|
||||
# …and what the radio itself confirmed it is running.
|
||||
"r_frequency": getattr(iface, "r_frequency", None),
|
||||
"r_bandwidth": getattr(iface, "r_bandwidth", None),
|
||||
"r_txpower": getattr(iface, "r_txpower", None),
|
||||
"r_spreadingfactor": getattr(iface, "r_sf", None),
|
||||
"r_codingrate": getattr(iface, "r_cr", None),
|
||||
"r_airtime_limit_short": getattr(iface, "r_st_alock", None),
|
||||
"r_airtime_limit_long": getattr(iface, "r_lt_alock", None),
|
||||
# Live utilisation, when the interface tracks it.
|
||||
"airtime_short": getattr(iface, "airtime_short", None),
|
||||
"airtime_long": getattr(iface, "airtime_long", None),
|
||||
})
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
return state
|
||||
|
||||
def _send(self, req: dict):
|
||||
import RNS
|
||||
import LXMF
|
||||
@@ -643,6 +696,11 @@ def _parse_args(argv):
|
||||
p.add_argument("--txpower", type=int, default=17)
|
||||
p.add_argument("--spreadingfactor", type=int, default=8)
|
||||
p.add_argument("--codingrate", type=int, default=5)
|
||||
# Regulatory duty-cycle locks (percent of airtime, e.g. EU868 short=25
|
||||
# long=10). None (the default) writes no config line, so RNS applies no
|
||||
# software airtime lock — identical to daemons built before these existed.
|
||||
p.add_argument("--airtime-limit-short", type=float, default=None)
|
||||
p.add_argument("--airtime-limit-long", type=float, default=None)
|
||||
p.add_argument("--enable-transport", action="store_true",
|
||||
help="run as an RNS transport node: relay traffic and rebroadcast "
|
||||
"announces so nodes beyond direct RF range discover each other "
|
||||
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Syntax-check the shell embedded in apps/*/manifest.yml.
|
||||
|
||||
A manifest can carry a whole startup script in `container.custom_args` /
|
||||
`entrypoint`. Nothing validated it, so a broken one shipped through the
|
||||
signed catalog and only failed on the node — as a container that exits
|
||||
instantly and an app that vanishes from the UI.
|
||||
|
||||
Two checks, both learned from v1.7.124 (bitcoin-knots / bitcoin-core):
|
||||
|
||||
1. `sh -n` the snippet. The break was `sh: Syntax error: "fi" unexpected`,
|
||||
which no YAML parse and no Rust test could have caught.
|
||||
|
||||
2. Reject `#` inside the snippet. These are YAML **folded** scalars (`>-`),
|
||||
where `#` is NOT a comment — it is literal text that reaches the shell,
|
||||
and because folding joins lines with spaces it comments out the rest of
|
||||
the folded line. That is exactly how an `if ... then` was swallowed while
|
||||
its more-indented body survived, leaving an orphan `fi`. Put explanations
|
||||
above the `- >-` line, where YAML really does treat them as comments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import yaml
|
||||
|
||||
# Long enough to be a script rather than a flag.
|
||||
MIN_SCRIPT_LEN = 60
|
||||
|
||||
|
||||
def snippets(path: str):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
container = ((data or {}).get("app") or {}).get("container") or {}
|
||||
for key in ("custom_args", "entrypoint"):
|
||||
value = container.get(key)
|
||||
if not isinstance(value, list):
|
||||
continue
|
||||
for i, part in enumerate(value):
|
||||
if isinstance(part, str) and len(part) >= MIN_SCRIPT_LEN:
|
||||
yield f"{key}[{i}]", part
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures = []
|
||||
checked = 0
|
||||
for path in sorted(glob.glob("apps/*/manifest.yml")):
|
||||
app = os.path.basename(os.path.dirname(path))
|
||||
try:
|
||||
found = list(snippets(path))
|
||||
except Exception as exc: # noqa: BLE001 — report, don't crash the gate
|
||||
failures.append(f"{app}: manifest does not parse: {exc}")
|
||||
continue
|
||||
for where, script in found:
|
||||
checked += 1
|
||||
if "#" in script:
|
||||
failures.append(
|
||||
f"{app} {where}: contains '#'. In a folded YAML scalar that is not a "
|
||||
f"comment — it reaches the shell and comments out the rest of the "
|
||||
f"folded line. Move the explanation above the '- >-' line."
|
||||
)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as tmp:
|
||||
tmp.write(script)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["sh", "-n", tmp_path], capture_output=True, text=True, check=False
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
if proc.returncode != 0:
|
||||
failures.append(f"{app} {where}: {proc.stderr.strip()}")
|
||||
|
||||
for f in failures:
|
||||
print(f"MANIFEST-SHELL {f}", file=sys.stderr)
|
||||
print(f'{{"snippets_checked": {checked}, "failures": {len(failures)}}}')
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot node-side repair: pull the current companion-UI manifests
|
||||
# (session_passthrough on the gated ports) from the public repo, install
|
||||
# them into every location the daemon reads, restart, and report.
|
||||
#
|
||||
# Run on a node:
|
||||
# curl -sf https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/scripts/fix-companion-manifests.sh | bash
|
||||
#
|
||||
# Idempotent and safe to re-run. Needs passwordless sudo (fleet default).
|
||||
set -u
|
||||
|
||||
BASE="https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/apps"
|
||||
RUNTIME="/opt/archipelago/web-ui/archipelago-runtime/apps"
|
||||
updated=0
|
||||
|
||||
for app in lnd-ui bitcoin-ui electrs-ui fips-ui; do
|
||||
tmp="/tmp/${app}-manifest.yml"
|
||||
if ! curl -sf --max-time 30 "$BASE/$app/manifest.yml" -o "$tmp"; then
|
||||
echo "✗ $app: download failed"; continue
|
||||
fi
|
||||
if ! grep -q session_passthrough "$tmp"; then
|
||||
echo "✗ $app: fetched file missing session_passthrough — refusing"; continue
|
||||
fi
|
||||
sudo cp "$tmp" "/opt/archipelago/apps/$app/manifest.yml" || { echo "✗ $app: install failed"; continue; }
|
||||
# The frontend's runtime payload is restored over /opt/archipelago/apps at
|
||||
# every daemon boot on nodes that carry it — update it too or the fix
|
||||
# reverts on the next restart.
|
||||
if [ -d "$RUNTIME/$app" ]; then
|
||||
sudo cp "$tmp" "$RUNTIME/$app/manifest.yml"
|
||||
fi
|
||||
echo "✓ $app updated"
|
||||
updated=$((updated + 1))
|
||||
done
|
||||
|
||||
if [ "$updated" -eq 0 ]; then
|
||||
echo "Nothing updated — not restarting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo systemctl restart archipelago
|
||||
echo "Daemon restarted; waiting for the gate…"
|
||||
sleep 15
|
||||
ip=$(hostname -I | tr ' ' '\n' | grep '^100\.' | head -1)
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://$ip:18083/" 2>/dev/null)
|
||||
echo "ext :18083 -> $code (401 = gate holds the port: CORRECT)"
|
||||
@@ -45,7 +45,13 @@ SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
|
||||
CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0"
|
||||
FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0"
|
||||
NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
|
||||
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.19.4"
|
||||
# 2.39.1 is what the fleet has actually been running via the moving :latest
|
||||
# tag, and it is the version that wrote their databases. Pinning back to
|
||||
# 2.19.4 (2 years older) made Portainer refuse to start the moment a
|
||||
# container was recreated: "database schema version does not align with the
|
||||
# server version" — it migrates a DB forward, never backward. Pinned
|
||||
# forward and published as a concrete tag so this is reproducible.
|
||||
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.39.1"
|
||||
|
||||
# Networking
|
||||
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"
|
||||
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
# Per-node certificate authority.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# The node used to serve a bare self-signed leaf (setup-https-dev.sh). A browser
|
||||
# can be told to trust that, but the exception is granted per ORIGIN — scheme +
|
||||
# host + PORT. The dashboard on :443 and an app on :8334 are different origins,
|
||||
# so each app port needed its own click-through, and a cert interstitial CANNOT
|
||||
# be accepted inside an iframe: the embedded app just fails.
|
||||
#
|
||||
# A CA fixes that structurally. The user installs ONE certificate; every leaf it
|
||||
# signs is then trusted, on every port, with no further prompts. Ports are not
|
||||
# part of a certificate's identity — one leaf with the right SANs covers every
|
||||
# port on the host — so this is what makes gated apps embeddable over HTTPS.
|
||||
#
|
||||
# The CA private key never leaves the node and signs nothing but this node's own
|
||||
# leaf. Installing it means trusting THIS node, not a third party.
|
||||
#
|
||||
# Idempotent: re-running reuses an existing CA and only reissues the leaf (which
|
||||
# is what you want when the node gains an address). Pass --force-ca to start over
|
||||
# — that invalidates every copy users have already installed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SSL_DIR="${ARCHY_SSL_DIR:-/etc/archipelago/ssl}"
|
||||
CA_CRT="$SSL_DIR/ca.crt"
|
||||
CA_KEY="$SSL_DIR/ca.key"
|
||||
CA_SRL="$SSL_DIR/ca.srl"
|
||||
LEAF_CRT="$SSL_DIR/archipelago.crt"
|
||||
LEAF_KEY="$SSL_DIR/archipelago.key"
|
||||
|
||||
CA_DAYS="${ARCHY_CA_DAYS:-3650}"
|
||||
# Public CAs cap leaves at 398 days and browsers enforce it. That limit applies
|
||||
# to publicly-trusted roots, not a privately-installed one, but a shorter leaf
|
||||
# still bounds the damage from a key leak — and reissuing costs nothing here
|
||||
# because this script is re-run on address changes anyway.
|
||||
LEAF_DAYS="${ARCHY_LEAF_DAYS:-397}"
|
||||
|
||||
FORCE_CA=false
|
||||
[ "${1:-}" = "--force-ca" ] && FORCE_CA=true
|
||||
|
||||
NODE_NAME="$(hostname -s 2>/dev/null || echo archipelago)"
|
||||
|
||||
log() { echo " $*"; }
|
||||
|
||||
mkdir -p "$SSL_DIR"
|
||||
chmod 755 "$SSL_DIR"
|
||||
|
||||
# --- Subject alternative names -----------------------------------------------
|
||||
# Every name/address the node can be reached by must be in the leaf, because a
|
||||
# certificate is scoped to names, not ports. Missing one here means that access
|
||||
# path still throws a warning even after the CA is installed.
|
||||
collect_sans() {
|
||||
local -a dns=() ips=()
|
||||
|
||||
dns+=("archipelago.local" "$NODE_NAME" "$NODE_NAME.local" "localhost")
|
||||
|
||||
# Tailscale gives a stable MagicDNS name; include it so tailnet access is clean.
|
||||
if command -v tailscale >/dev/null 2>&1; then
|
||||
local ts_name
|
||||
ts_name="$(tailscale status --json 2>/dev/null \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); print((d.get("Self") or {}).get("DNSName","").rstrip("."))' 2>/dev/null || true)"
|
||||
[ -n "$ts_name" ] && dns+=("$ts_name")
|
||||
fi
|
||||
|
||||
# Every non-loopback address the host currently holds, plus loopback itself.
|
||||
ips+=("127.0.0.1" "::1")
|
||||
while read -r addr; do
|
||||
[ -n "$addr" ] && ips+=("$addr")
|
||||
done < <(ip -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | sort -u)
|
||||
|
||||
local out="" i=1 j=1
|
||||
for d in $(printf '%s\n' "${dns[@]}" | awk 'NF' | sort -u); do
|
||||
out="${out}DNS.$i:$d,"; i=$((i+1))
|
||||
done
|
||||
for a in $(printf '%s\n' "${ips[@]}" | awk 'NF' | sort -u); do
|
||||
out="${out}IP.$j:$a,"; j=$((j+1))
|
||||
done
|
||||
echo "${out%,}"
|
||||
}
|
||||
|
||||
SAN="$(collect_sans)"
|
||||
[ -z "$SAN" ] && { echo "ERROR: no SANs resolved — refusing to issue a useless cert" >&2; exit 1; }
|
||||
|
||||
# --- CA ----------------------------------------------------------------------
|
||||
if [ "$FORCE_CA" = true ] && [ -f "$CA_CRT" ]; then
|
||||
log "--force-ca: replacing the existing CA (previously installed copies stop working)"
|
||||
rm -f "$CA_CRT" "$CA_KEY" "$CA_SRL"
|
||||
fi
|
||||
|
||||
if [ -f "$CA_CRT" ] && [ -f "$CA_KEY" ]; then
|
||||
log "Reusing the existing node CA (installed copies keep working)"
|
||||
else
|
||||
log "Creating this node's certificate authority…"
|
||||
openssl req -x509 -nodes -newkey rsa:4096 -sha256 -days "$CA_DAYS" \
|
||||
-keyout "$CA_KEY" -out "$CA_CRT" \
|
||||
-subj "/CN=Archipelago Node CA ($NODE_NAME)/O=Archipelago/OU=Node CA" \
|
||||
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign" 2>/dev/null
|
||||
chmod 600 "$CA_KEY"
|
||||
chmod 644 "$CA_CRT"
|
||||
fi
|
||||
|
||||
# --- Leaf --------------------------------------------------------------------
|
||||
log "Issuing the server certificate for: $SAN"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
openssl req -nodes -newkey rsa:2048 -sha256 \
|
||||
-keyout "$TMP/leaf.key" -out "$TMP/leaf.csr" \
|
||||
-subj "/CN=$NODE_NAME/O=Archipelago" 2>/dev/null
|
||||
|
||||
cat >"$TMP/leaf.ext" <<EOF
|
||||
basicConstraints=CA:FALSE
|
||||
keyUsage=critical,digitalSignature,keyEncipherment
|
||||
extendedKeyUsage=serverAuth
|
||||
subjectAltName=$SAN
|
||||
EOF
|
||||
|
||||
openssl x509 -req -in "$TMP/leaf.csr" -CA "$CA_CRT" -CAkey "$CA_KEY" \
|
||||
-CAcreateserial -CAserial "$CA_SRL" \
|
||||
-out "$TMP/leaf.crt" -days "$LEAF_DAYS" -sha256 -extfile "$TMP/leaf.ext" 2>/dev/null
|
||||
|
||||
# Swap in place only once both halves exist, so a failure mid-run cannot leave
|
||||
# nginx pointing at a cert whose key is gone.
|
||||
install -m 644 "$TMP/leaf.crt" "$LEAF_CRT"
|
||||
install -m 600 "$TMP/leaf.key" "$LEAF_KEY"
|
||||
|
||||
# The leaf key has TWO readers with different privileges: nginx's master
|
||||
# process (root) and the archipelago daemon (User=archipelago), which needs it
|
||||
# to terminate TLS on gated app ports. Root-only 0600 silently costs the daemon
|
||||
# its TLS — it logs "Permission denied" and every app port quietly stays plain
|
||||
# HTTP, which is exactly the fail-open shape the gate is built to avoid. So the
|
||||
# key is group-readable by the service user and nothing wider.
|
||||
SERVICE_USER="${ARCHY_SERVICE_USER:-archipelago}"
|
||||
if getent group "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
chgrp "$SERVICE_USER" "$LEAF_KEY" && chmod 640 "$LEAF_KEY"
|
||||
log "Key readable by group $SERVICE_USER (0640) — the daemon needs it for app-port TLS"
|
||||
elif getent passwd "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
# User exists without an eponymous group — fall back to its primary group.
|
||||
PRIMARY="$(id -gn "$SERVICE_USER" 2>/dev/null || true)"
|
||||
if [ -n "$PRIMARY" ]; then
|
||||
chgrp "$PRIMARY" "$LEAF_KEY" && chmod 640 "$LEAF_KEY"
|
||||
log "Key readable by group $PRIMARY (0640)"
|
||||
fi
|
||||
else
|
||||
log "No '$SERVICE_USER' user on this host — key left root-only (0600)"
|
||||
fi
|
||||
|
||||
# The dashboard serves this for download; it is a public certificate, never the key.
|
||||
install -m 644 "$CA_CRT" "$SSL_DIR/ca-download.crt"
|
||||
|
||||
FP="$(openssl x509 -in "$CA_CRT" -noout -fingerprint -sha256 | cut -d= -f2)"
|
||||
log "CA fingerprint (SHA-256): $FP"
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet nginx; then
|
||||
if nginx -t >/dev/null 2>&1; then
|
||||
systemctl reload nginx && log "nginx reloaded"
|
||||
else
|
||||
echo "WARNING: nginx config test failed — NOT reloading. Certs are in place; fix nginx and reload." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Done. Install $CA_CRT on each device that should reach this node without warnings.
|
||||
The dashboard serves it at /ca.crt (Settings → Node certificate).
|
||||
Verify the fingerprint above matches what the dashboard shows before trusting it.
|
||||
EOF
|
||||
@@ -62,6 +62,7 @@ summary() {
|
||||
# ── Stage 1: static ──────────────────────────────────────────────────
|
||||
stage "git-diff-check" git diff --check
|
||||
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
|
||||
stage "manifest-shell" python3 scripts/check-manifest-shell.py
|
||||
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
# Every release must surface its CHANGELOG entry in the Settings "What's New"
|
||||
# modal. The modal hardcodes a block per version and has drifted behind before
|
||||
|
||||
Reference in New Issue
Block a user