Compare commits
22
Commits
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ This document lists all port assignments for Archipelago apps.
|
||||
| did-wallet | 8083 | TCP | Web UI | 18083 |
|
||||
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
|
||||
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
|
||||
| podsteadr | 8095, 1935, 8889, 8189, 8890, 8098 | TCP/UDP | Web UI/API/RSS, RTMP ingest, WebRTC/WHIP ingest, WebRTC ICE (udp), HLS playback, Blossom blobs | 18095, 11935, 18889, 18189, 18890, 18098 |
|
||||
|
||||
## Development Ports (Offset: +10000)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
|
||||
- **did-wallet** — Web5 DID Wallet
|
||||
|
||||
### Self-Hosted Services
|
||||
- **podsteadr** — Nostr-native podcast publishing and livestreaming (RTMP/WebRTC ingest, HLS, RSS, Blossom media)
|
||||
- **nextcloud** (v28), **jellyfin** (v10.8.13), **immich** (release), **photoprism** (v240915)
|
||||
- **vaultwarden** (v1.30.0-alpine), **penpot** (v2.4)
|
||||
- **homeassistant** (v2024.1), **filebrowser** (v2.27.0), **searxng** (2024.11.17)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
app:
|
||||
id: podsteadr-blossom
|
||||
name: podsteadr Blossom
|
||||
version: "4"
|
||||
description: Blossom (BUD-02) sha256-addressed media blob server backing podsteadr's episode uploads and covers.
|
||||
category: media
|
||||
|
||||
# Hyphenated name matches the podsteadr repo's docker-compose container_name
|
||||
# (podsteadr-blossom); alias `blossom` is the short hostname podsteadr's
|
||||
# server reaches it by (BLOSSOM_URL_INTERNAL=http://blossom:3000).
|
||||
container_name: podsteadr-blossom
|
||||
|
||||
container:
|
||||
image: ghcr.io/hzrd149/blossom-server:4
|
||||
pull_policy: if-not-present
|
||||
network: podsteadr-net
|
||||
network_aliases: [blossom]
|
||||
# Image runs as container-root (no USER directive) writing to a
|
||||
# bind-mounted /app/data — CHOWN/DAC_OVERRIDE cover the fresh-bind-dir
|
||||
# ownership gap the same way apps/botfights and apps/immich document.
|
||||
# Unverified against a real install; check first-boot logs.
|
||||
data_uid: "0:0"
|
||||
|
||||
dependencies:
|
||||
- storage: 20Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 20Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, DAC_OVERRIDE, FOWNER]
|
||||
readonly_root: false
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8098
|
||||
container: 3000
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Media blobs (episode audio/video, covers) must be publicly fetchable
|
||||
by podcast clients as RSS enclosure URLs — that's the entire purpose
|
||||
of this port. Uploads are separately gated by blossom's own BUD-02
|
||||
signed-nostr-event auth (upload.requireAuth below), not a node
|
||||
session; reads are intentionally public per the config's own header
|
||||
comment.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/podsteadr-blossom/data
|
||||
target: /app/data
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/podsteadr-blossom/config/config.yml
|
||||
target: /app/config.yml
|
||||
options: [ro]
|
||||
|
||||
environment: []
|
||||
|
||||
files:
|
||||
- path: /var/lib/archipelago/podsteadr-blossom/config/config.yml
|
||||
overwrite: true
|
||||
content: |
|
||||
# blossom-server (v4.x) configuration for podsteadr.
|
||||
# Uploads require a signed nostr auth event (BUD-02, kind 24242);
|
||||
# reads are public so podcast apps can fetch enclosures.
|
||||
#
|
||||
# NOTE (blossom-server 4.4.1 gotcha, do not rediscover): `rules:` MUST
|
||||
# be nested under `storage:` — a top-level `rules:` key is silently
|
||||
# ignored, the ruleset ends up empty, and every upload fails 401
|
||||
# "Server dose not accept video/mp4 blobs" (typo is theirs). The
|
||||
# GitHub master branch is a Deno rewrite with a different schema
|
||||
# (storage.rules, BUD-11, range support); the `:4` image is the older
|
||||
# node/koa codebase this config targets.
|
||||
|
||||
publicDomain: ""
|
||||
|
||||
databasePath: data/sqlite.db
|
||||
|
||||
dashboard:
|
||||
enabled: false
|
||||
|
||||
discovery:
|
||||
nostr:
|
||||
enabled: false
|
||||
relays: []
|
||||
upstream:
|
||||
enabled: false
|
||||
domains: []
|
||||
|
||||
storage:
|
||||
backend: local
|
||||
local:
|
||||
dir: ./data/blobs
|
||||
removeWhenNoOwners: false
|
||||
# "expiration" is time since a blob was last accessed — unaccessed
|
||||
# blobs get pruned after this. Podcast media should effectively
|
||||
# never expire, so keep this long.
|
||||
rules:
|
||||
- type: "*"
|
||||
expiration: 10 years
|
||||
|
||||
upload:
|
||||
enabled: true
|
||||
requireAuth: true
|
||||
requirePubkeyInRule: false
|
||||
|
||||
list:
|
||||
requireAuth: false
|
||||
allowListOthers: true
|
||||
|
||||
tor:
|
||||
enabled: false
|
||||
proxy: ""
|
||||
|
||||
health_check:
|
||||
# No documented health endpoint; TCP liveness on the app port.
|
||||
type: tcp
|
||||
endpoint: localhost:3000
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,164 @@
|
||||
app:
|
||||
id: podsteadr-mediamtx
|
||||
name: podsteadr MediaMTX
|
||||
version: "1.20.0"
|
||||
description: MediaMTX ingest/output backend for podsteadr — RTMP + WebRTC/WHIP ingest, HLS playback, stream recording.
|
||||
category: media
|
||||
|
||||
# Hyphenated name matches the podsteadr repo's docker-compose container_name
|
||||
# (podsteadr-mediamtx); alias `mediamtx` is the short hostname podsteadr's
|
||||
# server reaches it by (MEDIAMTX_API_URL=http://mediamtx:9997) and the one
|
||||
# baked into mediamtx.yml's authHTTPAddress callback below.
|
||||
container_name: podsteadr-mediamtx
|
||||
|
||||
container:
|
||||
image: docker.io/bluenviron/mediamtx:1.20.0
|
||||
pull_policy: if-not-present
|
||||
network: podsteadr-net
|
||||
network_aliases: [mediamtx]
|
||||
derived_env:
|
||||
# Browsers need a reachable ICE host candidate for WebRTC/WHIP; without
|
||||
# this, the offer only advertises container-internal addresses and
|
||||
# publish/playback negotiation fails for anyone off-host.
|
||||
- key: MTX_WEBRTCADDITIONALHOSTS
|
||||
template: "{{HOST_MDNS}}"
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
# Stock mediamtx image runs as container-root (no USER directive) but
|
||||
# only ever writes to the bind-mounted /recordings — CHOWN/DAC_OVERRIDE
|
||||
# cover the fresh-bind-dir-ownership gap the same way apps/botfights and
|
||||
# apps/immich document (root uid inside the container does not
|
||||
# automatically bypass DAC checks once cap-drop ALL applies). Unverified
|
||||
# against a real install; check first-boot logs on initial deploy.
|
||||
capabilities: [CHOWN, DAC_OVERRIDE]
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 1935
|
||||
container: 1935
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
RTMP ingest (OBS). Not HTTP, so the node's session gate has no login
|
||||
page to serve here; publish auth is delegated to podsteadr's own
|
||||
HTTP auth webhook (authHTTPAddress below), which checks a per-stream
|
||||
secret key never exposed in this port mapping.
|
||||
- host: 8889
|
||||
container: 8889
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
WebRTC/WHIP ingest — browsers publish directly with a per-stream
|
||||
bearer secret checked by podsteadr's auth webhook, the same
|
||||
protocol-level auth as the RTMP port above.
|
||||
- host: 8189
|
||||
container: 8189
|
||||
protocol: udp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
WebRTC ICE/UDP media transport. Raw UDP has no HTTP session concept
|
||||
for the gate to enforce.
|
||||
- host: 8890
|
||||
container: 8888
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Public HLS playback URL, handed out to viewers and podcast/livestream
|
||||
clients outside the node (zap.stream, third-party players). A login
|
||||
page here would break every external viewer; playback is read-only.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
# Shared with apps/podsteadr (mounted read-only there) so the app can
|
||||
# list and remux finished recordings for one-click episode publishing.
|
||||
source: /var/lib/archipelago/podsteadr/recordings
|
||||
target: /recordings
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/podsteadr-mediamtx/config/mediamtx.yml
|
||||
target: /mediamtx.yml
|
||||
options: [ro]
|
||||
|
||||
environment: []
|
||||
|
||||
files:
|
||||
- path: /var/lib/archipelago/podsteadr-mediamtx/config/mediamtx.yml
|
||||
overwrite: true
|
||||
content: |
|
||||
# MediaMTX configuration for podsteadr.
|
||||
# Ingest: RTMP (OBS) + WebRTC/WHIP (browser). Output: HLS. Publish auth is
|
||||
# delegated to podsteadr via HTTP; stream status is polled from the API.
|
||||
|
||||
logLevel: info
|
||||
|
||||
api: yes
|
||||
apiAddress: :9997
|
||||
|
||||
# ---- authentication ------------------------------------------------------
|
||||
authMethod: http
|
||||
authHTTPAddress: http://podsteadr-app:8095/api/mediamtx/auth
|
||||
authHTTPExclude:
|
||||
- action: api
|
||||
- action: metrics
|
||||
- action: pprof
|
||||
|
||||
# ---- protocols -----------------------------------------------------------
|
||||
rtsp: no
|
||||
srt: no
|
||||
moq: no
|
||||
|
||||
rtmp: yes
|
||||
rtmpAddress: :1935
|
||||
|
||||
hls: yes
|
||||
hlsAddress: :8888
|
||||
# Standard HLS, not lowLatency: LL-HLS's small per-part buffering window has very little
|
||||
# tolerance for B-frame reordering (common in most OBS encoder presets), and a real test
|
||||
# stream crashed the muxer twice in ~2 minutes with "too many reordered frames" / "unable to
|
||||
# extract DTS" once frame timing got even slightly irregular. Standard HLS buffers a full
|
||||
# segment before finalizing, which absorbs that jitter — a few extra seconds of latency
|
||||
# instead of intermittent muxer crashes / viewer buffering.
|
||||
hlsVariant: mpegts
|
||||
hlsAlwaysRemux: yes
|
||||
hlsAllowOrigins: ["*"]
|
||||
|
||||
webrtc: yes
|
||||
webrtcAddress: :8889
|
||||
webrtcLocalUDPAddress: :8189
|
||||
webrtcAllowOrigins: ["*"]
|
||||
|
||||
# ---- recording -----------------------------------------------------------
|
||||
pathDefaults:
|
||||
record: yes
|
||||
recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S-%f
|
||||
recordFormat: fmp4
|
||||
recordPartDuration: 1s
|
||||
recordSegmentDuration: 1h
|
||||
recordDeleteAfter: 168h
|
||||
|
||||
paths:
|
||||
# Streams live at live/<streamId>; publish requires the stream secret,
|
||||
# which podsteadr checks in the auth webhook.
|
||||
"~^live/[A-Za-z0-9]+$": {}
|
||||
|
||||
health_check:
|
||||
# Stock mediamtx image has no shell, so an in-container HTTP probe of the
|
||||
# API isn't meaningfully cheaper than TCP; RTMP liveness is enough (same
|
||||
# polling-not-hooks rationale as podsteadr's own status poller, which
|
||||
# exists precisely because runOn*-style shell hooks aren't available on
|
||||
# this image).
|
||||
type: tcp
|
||||
endpoint: localhost:1935
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,44 @@
|
||||
# Vendored copy of the podsteadr repo's own Dockerfile (source lives outside
|
||||
# this tree — http://146.59.87.168:3000/ssmithx/podsteadr). Re-sync by hand if
|
||||
# the upstream Dockerfile changes; build with build-from-prototype.sh, which
|
||||
# passes the podsteadr repo root as build context (this Dockerfile expects
|
||||
# frontend/ and server/ subdirectories at the context root, not this apps/
|
||||
# directory).
|
||||
#
|
||||
# ---- frontend ----
|
||||
FROM node:22-bookworm-slim AS frontend-build
|
||||
WORKDIR /build/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ---- server ----
|
||||
FROM node:22-bookworm-slim AS server-build
|
||||
WORKDIR /build/server
|
||||
COPY server/package*.json ./
|
||||
RUN npm ci
|
||||
COPY server/ ./
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
|
||||
# ---- runtime ----
|
||||
FROM node:22-bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY --from=server-build /build/server/node_modules ./node_modules
|
||||
COPY --from=server-build /build/server/package.json ./package.json
|
||||
COPY --from=server-build /build/server/dist ./dist
|
||||
COPY --from=frontend-build /build/frontend/dist ./public
|
||||
# Named volumes inherit ownership from the image path: keep /data writable by node
|
||||
RUN mkdir -p /data && chown node:node /data
|
||||
USER node
|
||||
ENV NODE_ENV=production \
|
||||
PORT=8095 \
|
||||
DATA_DIR=/data \
|
||||
STATIC_DIR=/app/public
|
||||
EXPOSE 8095
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD curl -fsS http://localhost:8095/api/health || exit 1
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,85 @@
|
||||
# podsteadr — Nostr-native Podcasting & Livestreaming
|
||||
|
||||
Self-hosted, nostr-native podcast publishing and livestreaming. Log in with a
|
||||
NIP-07 nostr identity (no passwords, no email), upload an mp4 to publish an
|
||||
RSS 2.0 feed with Podcasting 2.0 lightning payment info, or go live via OBS
|
||||
(RTMP) or the browser (WebRTC/WHIP) — the stream is announced on nostr as a
|
||||
NIP-53 live event and viewers watch over HLS.
|
||||
|
||||
This is a three-container stack:
|
||||
|
||||
| App | Manifest | Role |
|
||||
|---|---|---|
|
||||
| `podsteadr` | `apps/podsteadr/manifest.yml` | Fastify API + built Vue UI + RSS feeds |
|
||||
| `podsteadr-mediamtx` | `apps/podsteadr-mediamtx/manifest.yml` | RTMP/WHIP ingest, HLS output, recording |
|
||||
| `podsteadr-blossom` | `apps/podsteadr-blossom/manifest.yml` | BUD-02 sha256-addressed media blobs |
|
||||
|
||||
All three join a dedicated `podsteadr-net` bridge network and resolve each
|
||||
other by short DNS aliases (`podsteadr-app`, `mediamtx`, `blossom`).
|
||||
|
||||
## Building the Image
|
||||
|
||||
The app image is built from the **podsteadr** repo, source of truth at
|
||||
`http://146.59.87.168:3000/ssmithx/podsteadr`.
|
||||
|
||||
### Option 1: Use the build script
|
||||
|
||||
```bash
|
||||
# From archy repo root
|
||||
./apps/podsteadr/build-from-prototype.sh
|
||||
```
|
||||
|
||||
### Option 2: Build from source directory
|
||||
|
||||
```bash
|
||||
cd ~/podsteadr
|
||||
podman build -t localhost/podsteadr:1.0.0 -f ~/archy/apps/podsteadr/Dockerfile .
|
||||
```
|
||||
|
||||
### Publishing to the shared registry
|
||||
|
||||
```bash
|
||||
./apps/podsteadr/push-to-registry.sh 1.0.0
|
||||
```
|
||||
|
||||
Then update `apps/podsteadr/manifest.yml`'s `container.image` to the pushed
|
||||
tag so other nodes pull instead of building locally.
|
||||
|
||||
## Ports
|
||||
|
||||
See `apps/PORTS.md`. Summary: 8095 (web UI/API/RSS), 1935 (RTMP), 8889
|
||||
(WebRTC/WHIP), 8189/udp (WebRTC ICE), 8890 (HLS), 8098 (Blossom).
|
||||
|
||||
All of podsteadr's ports are `auth: none` — this is a public podcast/livestream
|
||||
server, not a private personal app; RSS feeds, HLS playback, and blob reads
|
||||
must stay reachable by third-party clients with no Archipelago session, and
|
||||
the app enforces its own NIP-98 signed-request auth for sensitive routes and
|
||||
per-stream secret keys for RTMP/WHIP publish. See the `auth_rationale` on each
|
||||
port mapping.
|
||||
|
||||
## Nostr Identity
|
||||
|
||||
podsteadr's frontend vendors a copy of Archipelago's `nostr-provider.js` shim
|
||||
and references it directly from `index.html` (its Fastify server isn't the
|
||||
nginx-served SPA shape the platform auto-patches — see "Nostr Signer Bridge"
|
||||
in `docs/app-developer-guide.md`). `apps/podsteadr/manifest.yml` declares a
|
||||
`post_install` hook that re-copies the canonical
|
||||
`/opt/archipelago/web-ui/nostr-provider.js` over the vendored copy on every
|
||||
install/reinstall, so it doesn't go stale across OTA releases.
|
||||
|
||||
## Data
|
||||
|
||||
- `/var/lib/archipelago/podsteadr` — SQLite DB, server's own nostr key,
|
||||
covers, and (read-only here) shared stream recordings.
|
||||
- `/var/lib/archipelago/podsteadr/recordings` — stream recordings (writable
|
||||
by `podsteadr-mediamtx`, read-only for `podsteadr`), 7-day retention.
|
||||
- `/var/lib/archipelago/podsteadr-blossom/data` — media blobs.
|
||||
|
||||
## Known gotchas
|
||||
|
||||
See the podsteadr repo's `docs/STATUS.md` for the full list (blossom v4
|
||||
config `rules:` nesting, no HTTP range support in blossom 4.x, split-horizon
|
||||
blossom URL, MediaMTX has no shell so status is polled not hooked, standard
|
||||
vs. low-latency HLS). The blossom and mediamtx config files embedded in
|
||||
`apps/podsteadr-blossom/manifest.yml` / `apps/podsteadr-mediamtx/manifest.yml`
|
||||
already carry the load-bearing ones inline as comments.
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# Build the podsteadr container image from the podsteadr repo.
|
||||
# Usage: ./build-from-prototype.sh [path-to-podsteadr-repo]
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEFAULT_REPO="$HOME/podsteadr"
|
||||
REPO_DIR="${1:-$DEFAULT_REPO}"
|
||||
IMAGE_TAG="localhost/podsteadr:1.0.0"
|
||||
|
||||
if [ ! -d "$REPO_DIR" ]; then
|
||||
echo "podsteadr repo not found at: $REPO_DIR"
|
||||
echo " Set path: $0 /path/to/podsteadr"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$REPO_DIR/server/package.json" ] || [ ! -f "$REPO_DIR/frontend/package.json" ]; then
|
||||
echo "No server/package.json or frontend/package.json found in $REPO_DIR — is this the right directory?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine container runtime
|
||||
RUNTIME="podman"
|
||||
if ! command -v podman >/dev/null 2>&1; then
|
||||
RUNTIME="docker"
|
||||
fi
|
||||
|
||||
echo "Building podsteadr from $REPO_DIR using $SCRIPT_DIR/Dockerfile"
|
||||
$RUNTIME build -t "$IMAGE_TAG" -f "$SCRIPT_DIR/Dockerfile" "$REPO_DIR"
|
||||
|
||||
echo "Built $IMAGE_TAG"
|
||||
echo ""
|
||||
echo "You can now install podsteadr from the App Store in Archipelago."
|
||||
echo "Or run directly: $RUNTIME run -d --name podsteadr-app -p 8095:8095 $IMAGE_TAG"
|
||||
@@ -0,0 +1,143 @@
|
||||
app:
|
||||
id: podsteadr
|
||||
name: podsteadr
|
||||
version: "1.0.0"
|
||||
description: Self-hosted, nostr-native podcast publishing and livestreaming. Log in with Nostr, upload episodes or go live via OBS/WebRTC, publish to RSS with Podcasting 2.0 lightning payments.
|
||||
category: media
|
||||
|
||||
# Container/DNS-alias name deliberately NOT "podsteadr" — on a host whose own
|
||||
# hostname happens to be "podsteadr", the host's own /etc/hosts self-hostname
|
||||
# entry (127.0.1.1, e.g. from cloud-init) shadows the container network's DNS
|
||||
# alias for other containers looking up "podsteadr", and mediamtx's auth-webhook
|
||||
# callback resolves to the host's loopback instead of this container — every
|
||||
# RTMP publish gets rejected with "connection refused" (observed on
|
||||
# podsteadr.atobitcoin.io, 2026-07-30; see docker-compose.yml in the podsteadr
|
||||
# repo for the original writeup). Carried forward unchanged into the manifest.
|
||||
container_name: podsteadr-app
|
||||
|
||||
container:
|
||||
# Built locally from the podsteadr repo (source lives outside this tree —
|
||||
# see apps/podsteadr/README.md + build-from-prototype.sh), same pattern as
|
||||
# apps/indeedhub. Not yet pushed to the shared registry; push-to-registry.sh
|
||||
# is there for when fleet-wide install is needed.
|
||||
image: localhost/podsteadr:1.0.0
|
||||
pull_policy: if-not-present
|
||||
network: podsteadr-net
|
||||
network_aliases: [podsteadr-app]
|
||||
derived_env:
|
||||
- key: PUBLIC_URL
|
||||
template: "http://{{HOST_MDNS}}:8095"
|
||||
- key: MEDIAMTX_RTMP_PUBLIC
|
||||
template: "rtmp://{{HOST_MDNS}}:1935"
|
||||
- key: MEDIAMTX_WHIP_PUBLIC
|
||||
template: "http://{{HOST_MDNS}}:8889"
|
||||
- key: MEDIAMTX_HLS_PUBLIC
|
||||
template: "http://{{HOST_MDNS}}:8890"
|
||||
- key: BLOSSOM_URL_DEFAULT
|
||||
template: "http://{{HOST_MDNS}}:8098"
|
||||
# node:22-bookworm-slim's built-in `node` user is uid:gid 1000:1000. The
|
||||
# image's own Dockerfile chowns /data to node:node, but that only affects
|
||||
# the image layer — the actual runtime mount is the bind volume below, so
|
||||
# the host directory needs the same ownership or the read-only-root,
|
||||
# non-root `node` process can't open the SQLite DB (unverified against a
|
||||
# real node install; flagging per this repo's convention of documenting
|
||||
# bind-mount ownership assumptions, e.g. apps/botfights/manifest.yml).
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- app_id: podsteadr-mediamtx
|
||||
- app_id: podsteadr-blossom
|
||||
- storage: 2Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 2Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8095
|
||||
container: 8095
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
podsteadr is a public podcast/livestream server: RSS feeds and the
|
||||
marketplace/catalog API must stay fetchable by third-party podcast
|
||||
clients, crawlers, and other podsteadr instances with no Archipelago
|
||||
session, and the app already gates its own sensitive routes with
|
||||
NIP-98 signed-request auth (see server/src/plugins/nostr-auth.ts in
|
||||
the podsteadr repo). Putting the node's session gate in front would
|
||||
block every external RSS/API consumer without adding real protection.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/podsteadr
|
||||
target: /data
|
||||
options: [rw]
|
||||
# Shares podsteadr-mediamtx's recordings directory (rw there, ro here) so
|
||||
# the app can list/remux finished recordings for one-click episode
|
||||
# publishing without granting it write access to live segments.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/podsteadr/recordings
|
||||
target: /recordings
|
||||
options: [ro]
|
||||
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8095
|
||||
- DATA_DIR=/data
|
||||
- RECORDINGS_DIR=/recordings
|
||||
- MEDIAMTX_API_URL=http://mediamtx:9997
|
||||
- BLOSSOM_URL_INTERNAL=http://blossom:3000
|
||||
- NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
|
||||
- CASHU_MINT_URL_DEFAULT=https://mint.minibits.cash/Bitcoin
|
||||
|
||||
# podsteadr's Fastify server (fastify-static) isn't the nginx-served SPA
|
||||
# shape the platform auto-patches for NIP-07 injection (see "Nostr Signer
|
||||
# Bridge" in docs/app-developer-guide.md) — its frontend already
|
||||
# self-references /nostr-provider.js from index.html and vendors a copy at
|
||||
# build time (podsteadr commit 133558d). That vendored copy goes stale
|
||||
# across archy OTA releases, so re-copy the canonical host script over it
|
||||
# on every install/reinstall instead of trusting the baked-in one.
|
||||
hooks:
|
||||
post_install:
|
||||
- copy_from_host:
|
||||
src: "web-ui/nostr-provider.js"
|
||||
dest: /app/public/nostr-provider.js
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8095
|
||||
path: /api/health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: Podcast dashboard, upload/live wizard, and stream management
|
||||
type: ui
|
||||
port: 8095
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
author: podsteadr
|
||||
icon: /assets/img/app-icons/podsteadr.png
|
||||
repo: http://146.59.87.168:3000/ssmithx/podsteadr
|
||||
license: MIT
|
||||
tags:
|
||||
- nostr
|
||||
- podcast
|
||||
- livestream
|
||||
- media
|
||||
- rss
|
||||
- lightning
|
||||
launch:
|
||||
open_in_new_tab: false
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# Build and push the podsteadr container image to a registry.
|
||||
# Usage: ./push-to-registry.sh [version]
|
||||
#
|
||||
# Environment variables:
|
||||
# REGISTRY - Registry host (default: 146.59.87.168:3000, same as indeedhub/botfights)
|
||||
# NAMESPACE - Registry namespace (default: lfg2025)
|
||||
# RUNTIME - Container runtime (default: podman)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="${PODSTEADR_REPO:-$HOME/podsteadr}"
|
||||
VERSION="${1:-1.0.0}"
|
||||
REGISTRY="${REGISTRY:-146.59.87.168:3000}"
|
||||
NAMESPACE="${NAMESPACE:-lfg2025}"
|
||||
IMAGE_NAME="podsteadr"
|
||||
RUNTIME="${RUNTIME:-podman}"
|
||||
|
||||
FULL_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${VERSION}"
|
||||
|
||||
if [ ! -d "$REPO_DIR" ]; then
|
||||
echo "podsteadr repo not found at: $REPO_DIR"
|
||||
echo "Set PODSTEADR_REPO=/path/to/podsteadr"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== podsteadr Container Registry Push ==="
|
||||
echo "Source: $REPO_DIR"
|
||||
echo "Image: $FULL_TAG"
|
||||
echo "Runtime: $RUNTIME"
|
||||
echo ""
|
||||
|
||||
echo "[1/3] Building image..."
|
||||
$RUNTIME build --platform linux/amd64 \
|
||||
-t "$FULL_TAG" \
|
||||
-t "localhost/${IMAGE_NAME}:${VERSION}" \
|
||||
-f "$SCRIPT_DIR/Dockerfile" \
|
||||
"$REPO_DIR"
|
||||
|
||||
echo "[2/3] Pushing to registry..."
|
||||
if ! $RUNTIME login --get-login "$REGISTRY" >/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "Not logged in to $REGISTRY."
|
||||
echo "Run: $RUNTIME login $REGISTRY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$RUNTIME push "$FULL_TAG"
|
||||
|
||||
echo ""
|
||||
echo "[3/3] Done!"
|
||||
echo ""
|
||||
echo "Image pushed: $FULL_TAG"
|
||||
echo ""
|
||||
echo "Update apps/podsteadr/manifest.yml's container.image to $FULL_TAG so"
|
||||
echo "nodes pull it instead of building locally."
|
||||
Generated
+3
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -84,6 +84,33 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
// 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
|
||||
|
||||
@@ -147,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) {
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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");
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -6,7 +6,41 @@
|
||||
//! no listener, so allowing them is inert.
|
||||
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
|
||||
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
|
||||
18081, 18083, 23000, 32838, 50002,
|
||||
2283,
|
||||
2342,
|
||||
3000,
|
||||
3001,
|
||||
3002,
|
||||
4080,
|
||||
5180,
|
||||
7778,
|
||||
8080,
|
||||
8081,
|
||||
8082,
|
||||
8083,
|
||||
8084,
|
||||
8085,
|
||||
8087,
|
||||
8088,
|
||||
8089,
|
||||
8090,
|
||||
8095,
|
||||
8096,
|
||||
8123,
|
||||
8175,
|
||||
8176,
|
||||
8240,
|
||||
8334,
|
||||
8336,
|
||||
8888,
|
||||
8999,
|
||||
9000,
|
||||
9100,
|
||||
10380,
|
||||
11434,
|
||||
18081,
|
||||
18083,
|
||||
23000,
|
||||
32838,
|
||||
50002,
|
||||
];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1717,17 +1717,25 @@ 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.
|
||||
// 31 as of the podsteadr app-package round: the prior 25 (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) plus 6 new ones: podsteadr 8095 (web UI/API/RSS —
|
||||
// third-party podcast clients and other podsteadr instances must
|
||||
// fetch feeds/marketplace data with no node session; the app gates
|
||||
// its own sensitive routes with NIP-98), podsteadr-blossom 8098
|
||||
// (public blob reads for RSS enclosures; uploads are BUD-02
|
||||
// signed-auth gated by blossom itself), podsteadr-mediamtx
|
||||
// 1935/8189/8889/8890 (RTMP/ICE/WHIP ingest + HLS playback — none of
|
||||
// these are HTTP-session-shaped, and publish is protocol-gated by a
|
||||
// per-stream secret checked via podsteadr's own auth webhook). Every
|
||||
// one is a deliberate, rationale-carrying exemption.
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
25,
|
||||
31,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) })
|
||||
})()
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -28,6 +28,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
"nostr-rs-relay": 18081,
|
||||
"photoprism": 2342,
|
||||
"pine": 10380,
|
||||
"podsteadr": 8095,
|
||||
"portainer": 9000,
|
||||
"router": 8084,
|
||||
"searxng": 8888,
|
||||
@@ -87,6 +88,9 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||
"pine-openwakeword": "Pine Wake Word (openWakeWord)",
|
||||
"pine-piper": "Pine Piper (TTS)",
|
||||
"pine-whisper": "Pine Whisper (STT)",
|
||||
"podsteadr": "podsteadr",
|
||||
"podsteadr-blossom": "podsteadr Blossom",
|
||||
"podsteadr-mediamtx": "podsteadr MediaMTX",
|
||||
"portainer": "Portainer",
|
||||
"router": "Mesh Router",
|
||||
"searxng": "SearXNG",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
+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)"
|
||||
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
|
||||
Reference in New Issue
Block a user