Merge branch 'main' into gsd/phase-13-aiui-functional-conversational-node-control-and-content-surf
# Conflicts: # .planning/config.json
This commit is contained in:
@@ -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.
|
||||
Generated
+3
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -117,7 +117,10 @@ impl RNodeRfSettings {
|
||||
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);
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.4–869.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 & 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>
|
||||
|
||||
Executable
+149
@@ -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
|
||||
Reference in New Issue
Block a user