Merge branch 'main' into gsd/phase-13-aiui-functional-conversational-node-control-and-content-surf

# Conflicts:
#	.planning/config.json
This commit is contained in:
archipelago
2026-08-06 16:02:47 -04:00
26 changed files with 1438 additions and 67 deletions
+106
View File
@@ -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.
+3
View File
@@ -148,6 +148,8 @@ dependencies = [
"reed-solomon-erasure",
"regex",
"reqwest 0.11.27",
"rustls-pemfile",
"rustls-webpki 0.101.7",
"sd-notify",
"serde",
"serde_bytes",
@@ -160,6 +162,7 @@ dependencies = [
"tempfile",
"thiserror 1.0.69",
"tokio",
"tokio-rustls 0.24.1",
"tokio-test",
"tokio-tungstenite 0.20.1",
"toml",
+7
View File
@@ -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)
+81 -17
View File
@@ -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::*;
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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-----
+393
View File
@@ -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");
}
}
+44 -15
View File
@@ -2155,22 +2155,51 @@ impl MeshService {
/// 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> {
let status = self.state.status.read().await;
if !status.device_connected {
anyhow::bail!("No mesh device connected. Check USB connection.");
// 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")
}
}
}
drop(status);
let (tx, rx) = tokio::sync::oneshot::channel();
self.state
.send_cmd(listener::MeshCommand::QueryRadioState { reply: tx })
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
let state = tokio::time::timeout(std::time::Duration::from_secs(10), rx)
.await
.map_err(|_| anyhow::anyhow!("The radio daemon did not answer the state query"))?
.map_err(|_| anyhow::anyhow!("Mesh session ended before the state query completed"))?;
state.map_err(|e| anyhow::anyhow!(e))
Err(last_err)
}
/// Current mesh-AI assistant settings (issue #50).
+39 -12
View File
@@ -117,7 +117,10 @@ impl RNodeRfSettings {
bail!("coding rate {} is outside 58", self.coding_rate);
}
if self.txpower > 22 {
bail!("tx power {} dBm is above the 22 dBm RNode maximum", self.txpower);
bail!(
"tx power {} dBm is above the 22 dBm RNode maximum",
self.txpower
);
}
for (label, v) in [
("airtime_limit_short", self.airtime_limit_short),
@@ -133,9 +136,9 @@ impl RNodeRfSettings {
// 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 == '.'))
|| port.chars().any(|c| {
!(c.is_ascii_alphanumeric() || c == '/' || c == '_' || c == '-' || c == '.')
})
{
bail!("port must be an absolute /dev device path");
}
@@ -345,14 +348,38 @@ mod tests {
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() },
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");
}
@@ -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;
@@ -964,6 +975,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;
@@ -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) {
+11
View File
@@ -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…
@@ -1148,6 +1158,7 @@ export const useMeshStore = defineStore('mesh', () => {
latestBlockHeight,
fetchStatus,
undismissedDetectedDevices,
suppressDeviceDetect,
dismissDetectedDevice,
flashFlowPath,
openFlashFlow,
+24
View File
@@ -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.4869.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 },
}
+22 -1
View File
@@ -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())
+6 -14
View File
@@ -1,7 +1,7 @@
<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()
@@ -13,6 +13,8 @@ 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 {
const res = await mesh.rebootRadio()
// The backend now waits for the device's acknowledgement and says what
@@ -26,19 +28,6 @@ async function handleReboot() {
}
// RNode (Reticulum) RF settings full round-trip with device read-back
// Recommended plans per region for Reticulum RNode radios. EU868 is the
// operator-validated Portugal plan (869.4625 MHz keeps clear of the default
// community channel while staying in the 10%-duty 869.4869.65 sub-band;
// airtime locks match EU duty-cycle law). Others use the RNS community
// conventions for the band with the region's legal power cap.
const RNODE_REGION_PLANS: Record<string, { frequency: number; bandwidth: number; spreading_factor: number; coding_rate: number; txpower: number; airtime_limit_short: number | null; airtime_limit_long: number | null }> = {
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 },
}
const rnodeForm = ref({
enabled: true,
@@ -101,6 +90,9 @@ async function loadRnodeConfig() {
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,
@@ -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 &amp; 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 &amp; 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 &amp; 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>
+149
View File
@@ -0,0 +1,149 @@
#!/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 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