From 95f9a805b1d121020de345f75d27361305af8b7f Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 15 Jun 2026 06:41:48 -0400 Subject: [PATCH] feat(fips): connect to public mesh anchor over TCP + wire daemon updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole fleet was silently never reaching the FIPS mesh: the default public anchor was configured as fips.v0l.io:8668/udp, but the anchor only answers on TCP/8443. Fix the default to 185.18.221.160:8443/tcp (IPv4 literal — the hostname resolves IPv6-first and the daemon binds v4-only, which fails the handshake with EAFNOSUPPORT), and auto-seed it in anchors::load() so every node dials it without operator action (removal still persists). Proven live on .116: cold start → anchor_connected in ~400ms, anchor became mesh parent. Wire fips::update::apply() against upstream GitHub releases (stable channel only): resolve /releases/latest → SHA256-verify the .deb against checksums-linux.txt → install → restart. dpkg runs via `systemd-run` to escape archipelago's ProtectSystem=strict sandbox (else /var/lib/dpkg is read-only), with --force-confold (archipelago manages /etc/fips conffiles) and --force-downgrade (dev builds sort newer than the stable tag). Validated live: .116 upgraded 0.3.0-dev -> stable v0.3.0. Also: standalone fips-ui dashboard app (apps/fips-ui + docker/fips-ui, static nginx proxying /rpc/v1 same-origin, copiable own-anchor address); reserve UI port 8336; register fips/fips-ui as platform-managed. Includes the Lightning wallet cross-origin (CORS) + LND proxy auth + nginx self-healer fix so the wallet screen connects instead of "failed to fetch". Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 + apps/fips-ui/manifest.yml | 42 ++ core/archipelago/src/api/handler/mod.rs | 51 +- core/archipelago/src/api/handler/proxy.rs | 54 +- .../archipelago/src/api/rpc/package/config.rs | 2 + core/archipelago/src/bootstrap.rs | 28 +- core/archipelago/src/fips/anchors.rs | 89 +++- core/archipelago/src/fips/update.rs | 419 +++++++++++---- core/archipelago/src/port_allocator.rs | 1 + docker/fips-ui/Dockerfile | 19 + docker/fips-ui/index.html | 478 ++++++++++++++++++ docker/fips-ui/nginx.conf | 33 ++ 12 files changed, 1103 insertions(+), 119 deletions(-) create mode 100644 apps/fips-ui/manifest.yml create mode 100644 docker/fips-ui/Dockerfile create mode 100644 docker/fips-ui/index.html create mode 100644 docker/fips-ui/nginx.conf diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac20157..03374b86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v1.7.94-alpha (2026-06-15) + +- Your node now joins the private encrypted mesh network on its own. A wrong built-in setting meant nodes were quietly never reaching the shared mesh meeting point, so everything between nodes fell back to the slower Tor network. Every node now connects to the mesh automatically on startup, so node-to-node features like file sharing use the faster encrypted mesh first and only fall back to Tor when a peer is genuinely offline. (Confirmed live: a node with its mesh setting wiped re-connected to the mesh by itself within a second of starting.) +- You can now bring the mesh networking software up to the latest stable version straight from the node, with one action — it fetches the new version, checks it's genuine before installing, and restarts the mesh on its own. (Confirmed live end to end: a node on an older build was upgraded to the current stable release and rejoined the mesh automatically.) +- The Lightning wallet screen connects again on nodes where it was showing a "failed to fetch" error instead of your balance and channels. The wallet app and the node now talk to each other correctly, and the connection quietly repairs itself if its details drift after a restart. + ## v1.7.93-alpha (2026-06-14) - Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so "generate a receive address" kept failing with a "wallet is locked" message that nothing could clear. The node now detects this and repairs itself automatically. diff --git a/apps/fips-ui/manifest.yml b/apps/fips-ui/manifest.yml new file mode 100644 index 00000000..6983387c --- /dev/null +++ b/apps/fips-ui/manifest.yml @@ -0,0 +1,42 @@ +app: + id: fips-ui + name: FIPS Mesh + version: 1.0.0 + description: | + Archipelago-native dashboard for the FIPS mesh transport. Runs nginx + inside a container with host networking, serves a static dashboard on + :8336, and reverse-proxies /rpc/v1 to the archipelago backend on + 127.0.0.1:5678. All FIPS controls (status, seed anchors, reconnect, + restart, and stable-channel daemon updates) go through the existing + fips.* RPC methods, authenticated by the browser's own archipelago + session — there is no separate secret to manage. + + container: + build: + context: /opt/archipelago/docker/fips-ui + dockerfile: Dockerfile + tag: localhost/fips-ui:local + + resources: + memory_limit: 128Mi + + security: + readonly_root: false + network_policy: host + + # Host networking: nginx listens on 8336 directly on the host IP and + # proxies to 127.0.0.1:5678 (the archipelago RPC). `ports:` is + # intentionally empty because host networking bypasses port mapping. + ports: [] + + volumes: [] + + environment: [] + + health_check: + type: http + endpoint: http://127.0.0.1:8336 + path: / + interval: 30s + timeout: 5s + retries: 3 diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 5ca04f4a..bd8175dc 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -256,6 +256,45 @@ impl ApiHandler { } } + /// CORS origin to echo for same-node app → backend calls (e.g. the LND + /// wallet UI, served on its own APP_PORTS port). Such apps share the node's + /// host but use a different port, so the strict allowlist (`host_ip`, no + /// port) rejects them and the browser gets no `Access-Control-Allow-Origin` + /// header ("blocked by CORS policy"). Reflect the Origin when its host + /// matches the request's own `Host` header — i.e. the app lives on the same + /// address the node is being reached by, which transparently covers the LAN + /// IP, the Tailscale IP, localhost, and the `.onion` address without needing + /// to enumerate them. Auth is still enforced by the session cookie; this + /// only authorizes the browser to *read* the reply. Returns "" (no echoed + /// origin) when there is no match. + fn app_cors_origin(&self, headers: &hyper::HeaderMap) -> String { + if let Some(origin) = self.validate_origin(headers) { + return origin; + } + let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) else { + return String::new(); + }; + // host portion (no scheme, no port) of an `scheme://host[:port]` value + let host_of = |s: &str| -> Option { + let after_scheme = s.split_once("://").map(|(_, r)| r).unwrap_or(s); + let host_port = after_scheme.split('/').next().unwrap_or(after_scheme); + let host = host_port + .rsplit_once(':') + .map(|(h, _)| h) + .unwrap_or(host_port); + (!host.is_empty()).then(|| host.to_string()) + }; + let origin_host = host_of(origin); + let req_host = headers + .get(hyper::header::HOST) + .and_then(|v| v.to_str().ok()) + .and_then(host_of); + match (origin_host, req_host) { + (Some(o), Some(r)) if o == r => origin.to_string(), + _ => String::new(), + } + } + pub async fn handle_request(&self, req: Request) -> Result> { let path = req.uri().path().to_string(); let method = req.method().clone(); @@ -265,9 +304,10 @@ impl ApiHandler { let mut builder = Response::builder() .status(StatusCode::NO_CONTENT) .header("Vary", "Origin"); - if let Some(origin) = self.validate_origin(req.headers()) { + let preflight_origin = self.app_cors_origin(req.headers()); + if !preflight_origin.is_empty() { builder = builder - .header("Access-Control-Allow-Origin", &origin) + .header("Access-Control-Allow-Origin", &preflight_origin) .header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") .header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token") .header("Access-Control-Allow-Credentials", "true"); @@ -448,7 +488,8 @@ impl ApiHandler { // No backend auth check here because the LND UI iframe fetches this // endpoint and the session cookie flow is validated at the nginx layer. (Method::GET, "/lnd-connect-info") => { - Self::handle_lnd_connect_info(self.rpc_handler.clone()).await + let origin = self.app_cors_origin(&headers); + Self::handle_lnd_connect_info(self.rpc_handler.clone(), &origin).await } // Container logs — requires session @@ -465,8 +506,8 @@ impl ApiHandler { if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); } - let origin = self.validate_origin(&headers).unwrap_or_default(); - Self::handle_lnd_proxy(path, &origin).await + let origin = self.app_cors_origin(&headers); + Self::handle_lnd_proxy(self.rpc_handler.clone(), path, &origin).await } // DWN health — unauthenticated diff --git a/core/archipelago/src/api/handler/proxy.rs b/core/archipelago/src/api/handler/proxy.rs index 080e0d5a..598605aa 100644 --- a/core/archipelago/src/api/handler/proxy.rs +++ b/core/archipelago/src/api/handler/proxy.rs @@ -99,33 +99,61 @@ impl ApiHandler { pub(super) async fn handle_lnd_connect_info( rpc: std::sync::Arc, + cors_origin: &str, ) -> Result> { + // The LND wallet UI is served on its own APP_PORTS origin and fetches + // this cross-origin, so it needs the CORS headers echoed back. + let cors = |builder: hyper::http::response::Builder| { + builder + .header("Access-Control-Allow-Origin", cors_origin) + .header("Access-Control-Allow-Credentials", "true") + .header("Vary", "Origin") + }; match rpc.handle_lnd_connect_info().await { Ok(val) => { let body = serde_json::to_vec(&val).unwrap_or_default(); - Ok(build_response( - StatusCode::OK, - "application/json", - hyper::Body::from(body), - )) + Ok(cors( + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json"), + ) + .body(hyper::Body::from(body)) + .unwrap_or_else(|_| Response::new(hyper::Body::from("{}")))) } - Err(e) => Ok(Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .header("Content-Type", "application/json") - .body(hyper::Body::from( - serde_json::json!({"error": e.to_string()}).to_string(), - )) - .unwrap()), + Err(e) => Ok(cors( + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header("Content-Type", "application/json"), + ) + .body(hyper::Body::from( + serde_json::json!({"error": e.to_string()}).to_string(), + )) + .unwrap()), } } pub(super) async fn handle_lnd_proxy( + rpc: Arc, path: &str, cors_origin: &str, ) -> Result> { let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/"); let url = format!("{LND_REST_BASE_URL}{suffix}"); - match reqwest::get(&url).await { + // LND REST serves a self-signed cert and requires the admin macaroon. + // A bare reqwest::get() uses the default client, which rejects the + // self-signed cert (TLS verify error -> 502 "failing to fetch") and + // sends no macaroon. Use the shared authenticated client instead — the + // same one lnd.getinfo and the wallet RPCs use. + let request = match rpc.lnd_client().await { + Ok((client, macaroon_hex)) => client + .get(&url) + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .send() + .await + .map_err(anyhow::Error::from), + Err(e) => Err(e), + }; + match request { Ok(resp) => { let status = resp.status().as_u16(); let headers = resp.headers().clone(); diff --git a/core/archipelago/src/api/rpc/package/config.rs b/core/archipelago/src/api/rpc/package/config.rs index 16e01472..e9fa2f43 100644 --- a/core/archipelago/src/api/rpc/package/config.rs +++ b/core/archipelago/src/api/rpc/package/config.rs @@ -32,6 +32,8 @@ fn is_platform_managed_app(app_id: &str) -> bool { | "fedimint-gateway" | "indeedhub" | "immich" + | "fips" + | "fips-ui" ) } diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index d766632f..0701f9ca 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -41,6 +41,13 @@ const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backe const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n proxy_pass http://127.0.0.1:5678/bitcoin-status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n"; +/// Inserted into every server block that lacks the `/proxy/lnd/` proxy. Nodes +/// flashed before 2026-04-10 shipped an nginx config without this block, so the +/// browser's wallet fetches to `/proxy/lnd/*` fell through to the SPA +/// index.html and got HTML back instead of JSON ("failing to fetch"). Kept in +/// sync with the canonical block in image-recipe/configs/nginx-archipelago.conf. +const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles auth + CORS\n location /proxy/lnd/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n"; + /// Entry point called from main startup. Never returns an error to the caller — /// failing to bootstrap host artifacts must not prevent the backend from serving. pub async fn ensure_doctor_installed() { @@ -520,12 +527,31 @@ async fn patch_nginx_conf(path: &str) -> Result { .with_context(|| format!("read {}", path))?; let missing_app_catalog = !content.contains("location /api/app-catalog"); let missing_bitcoin_status = !content.contains("location /bitcoin-status"); - if !missing_app_catalog && !missing_bitcoin_status { + let missing_lnd_proxy = !content.contains("location /proxy/lnd/"); + if !missing_app_catalog && !missing_bitcoin_status && !missing_lnd_proxy { return Ok(false); } let mut patched = content.clone(); + if missing_lnd_proxy { + // Prefer the `/lnd-connect-info` anchor (present since 2026-03-17); fall + // back to `/electrs-status` (since 2026-03-08) for even older configs. + // Both appear once per archipelago server block, so the block is added + // to every server block that proxies to the backend. + let anchor = if patched.contains(" location /lnd-connect-info {") { + " location /lnd-connect-info {" + } else { + " location /electrs-status {" + }; + if !patched.contains(anchor) { + warn!("nginx conf missing lnd-connect-info/electrs-status anchor — skipping /proxy/lnd patch"); + } else { + let replacement = format!("{}{}", NGINX_LND_PROXY_BLOCK, anchor); + patched = patched.replace(anchor, &replacement); + } + } + if missing_bitcoin_status { let anchor = " location /electrs-status {"; if !patched.contains(anchor) { diff --git a/core/archipelago/src/fips/anchors.rs b/core/archipelago/src/fips/anchors.rs index a883e526..c92a5b48 100644 --- a/core/archipelago/src/fips/anchors.rs +++ b/core/archipelago/src/fips/anchors.rs @@ -28,12 +28,38 @@ use tokio::process::Command; /// On-disk filename under `data_dir/`. const SEED_ANCHORS_FILE: &str = "seed-anchors.json"; -/// Public anchor (`fips.v0l.io`) carried as a default seed for fresh -/// installs — the one the upstream daemon dials anyway. Operators can -/// remove it from the UI once their own cluster has independent anchors. +/// Public anchor (`fips.v0l.io`) carried as a default seed for every +/// node — it bootstraps DHT routing so a fresh node isn't isolated. +/// Operators can remove it from the UI once their own cluster has +/// independent anchors (removal persists, see `load`/`remove`). +/// +/// IMPORTANT transport details, learned the hard way (see git history / +/// the 2026-06-15 debugging on .116): +/// - The anchor answers ONLY on **TCP port 8443**. UDP 8668 is dead +/// (host pings on both IP families but never completes a UDP FIPS +/// handshake). `fips/config.rs` always knew this; the old default +/// here (`fips.v0l.io:8668`/udp) silently never connected fleet-wide. +/// - We use the **IPv4 literal** rather than the `fips.v0l.io` hostname +/// on purpose: the hostname resolves IPv6-first, but the daemon binds +/// its transports IPv4-only (`0.0.0.0:8443`), so a v6 target makes the +/// daemon fail to send the handshake with `EAFNOSUPPORT (os error 97)`. +/// An IPv4 literal sidesteps the resolver entirely. pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str = "npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n"; -pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "fips.v0l.io:8668"; +pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443"; +pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp"; + +/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried +/// implicitly by `load()` on nodes that have never edited their anchor +/// list, so every node dials it without operator action. +pub fn default_public_anchor() -> SeedAnchor { + SeedAnchor { + npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(), + address: DEFAULT_PUBLIC_ANCHOR_ADDR.to_string(), + transport: DEFAULT_PUBLIC_ANCHOR_TRANSPORT.to_string(), + label: "Public anchor (fips.v0l.io)".to_string(), + } +} /// One seed-anchor entry. `address` must be directly dialable (IP or /// resolvable hostname + UDP port); `transport` is one of "udp", "tcp", @@ -60,12 +86,15 @@ fn anchors_path(data_dir: &Path) -> PathBuf { data_dir.join(SEED_ANCHORS_FILE) } -/// Load the seed-anchor list. Returns an empty list if the file -/// doesn't exist yet — a first-boot node with no operator config. +/// Load the seed-anchor list. A node that has never edited its anchor +/// list (no file yet) gets the default public anchor so it can bootstrap +/// the mesh out of the box. Once the operator edits anchors — including +/// removing the default — a file exists and is authoritative, so removal +/// persists and we never silently re-add it. pub async fn load(data_dir: &Path) -> Result> { let path = anchors_path(data_dir); if !path.exists() { - return Ok(Vec::new()); + return Ok(vec![default_public_anchor()]); } let bytes = tokio::fs::read(&path) .await @@ -121,11 +150,27 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result> { /// `fipsctl connect` is idempotent-ish: calling it for an already- /// connected peer is a no-op at the protocol layer, so re-applying on /// a timer is safe. Returns a list of per-anchor results for logging. +/// +/// Invoked through `sudo -n`: the upstream daemon's control socket +/// (`/run/fips/control.sock`) is owned `root:fips` 0660, and the +/// archipelago service user is not in the `fips` group, so a bare +/// `fipsctl connect` fails with EACCES. This matches the privileged +/// `sudo -n fipsctl show peers` call in `service::peer_connectivity_summary`. +/// Without it, seed anchors persist to disk but never actually dial, +/// leaving `anchor_connected=false` and every peer dial falling back to +/// a slow Tor timeout. pub async fn apply(anchors: &[SeedAnchor]) -> Vec { let mut results = Vec::with_capacity(anchors.len()); for anchor in anchors { - let out = Command::new("fipsctl") - .args(["connect", &anchor.npub, &anchor.address, &anchor.transport]) + let out = Command::new("sudo") + .args([ + "-n", + "fipsctl", + "connect", + &anchor.npub, + &anchor.address, + &anchor.transport, + ]) .output() .await; let result = match out { @@ -138,7 +183,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec { npub: anchor.npub.clone(), ok: false, message: format!( - "fipsctl exited {}: {}", + "sudo fipsctl connect exited {}: {}", o.status, String::from_utf8_lossy(&o.stderr).trim() ), @@ -146,7 +191,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec { Err(e) => ApplyResult { npub: anchor.npub.clone(), ok: false, - message: format!("fipsctl launch failed: {}", e), + message: format!("sudo fipsctl launch failed: {}", e), }, }; if result.ok { @@ -185,10 +230,28 @@ mod tests { } #[tokio::test] - async fn load_missing_returns_empty() { + async fn load_missing_seeds_default_public_anchor() { + // A node that has never edited its anchor list should still get + // the public anchor so it can bootstrap the mesh out of the box. let dir = tempfile::tempdir().unwrap(); let got = load(dir.path()).await.unwrap(); - assert!(got.is_empty()); + assert_eq!(got, vec![default_public_anchor()]); + // ...and the default must be the TCP/8443 form, not the dead udp:8668. + assert_eq!(got[0].transport, "tcp"); + assert!(got[0].address.ends_with(":8443")); + } + + #[tokio::test] + async fn removing_default_persists_as_empty() { + // Once the operator removes the default, a file exists and is + // authoritative — we must not silently re-seed it on next load. + let dir = tempfile::tempdir().unwrap(); + let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB) + .await + .unwrap(); + assert!(list.is_empty()); + let got = load(dir.path()).await.unwrap(); + assert!(got.is_empty(), "default must stay removed once edited"); } #[tokio::test] diff --git a/core/archipelago/src/fips/update.rs b/core/archipelago/src/fips/update.rs index 83e68218..abb8898e 100644 --- a/core/archipelago/src/fips/update.rs +++ b/core/archipelago/src/fips/update.rs @@ -1,156 +1,401 @@ -//! User-triggered FIPS upgrade from the upstream default branch. +//! User-triggered FIPS upgrade from upstream GitHub releases. //! //! Flow (no auto-update, no background polling — user clicks a button): -//! 1. Query GitHub for the upstream repo's default branch, then the -//! latest commit on it. (jmcorgan/fips default is `master`, not -//! `main` — we resolve it dynamically so a future rename Just Works.) -//! 2. Compare with the installed daemon version reported by -//! `fipsctl --version`. If identical, report "up to date". -//! 3. Fetch the built .deb artefact for that commit + its SHA256. -//! 4. SHA256-verify the download. -//! 5. `sudo dpkg -i` the .deb, `sudo systemctl restart` the service. +//! 1. Query GitHub for the latest *stable* release of `jmcorgan/fips` +//! (`/releases/latest` returns the newest non-prerelease, non-draft +//! tag, so release candidates like `v0.4.0-rc1` are skipped). +//! 2. Compare its tag (e.g. `v0.3.0`) with the installed daemon version +//! reported by `fipsctl --version`. A dev/pre-release build of the +//! same number (`0.3.0-dev`) counts as older than the released tag. +//! 3. Pick the Debian package asset matching the host architecture +//! (`fips__amd64.deb` / `_arm64.deb`) plus `checksums-linux.txt`. +//! 4. Download both, SHA256-verify the .deb against the checksums file. +//! 5. `sudo dpkg -i` the verified .deb, then restart the active fips unit. //! -//! The artefact URL / SHA256 source is not yet fixed — upstream doesn't -//! publish stable release assets for per-commit builds. This module -//! currently implements steps 1–2 (the "is there anything newer?" query) -//! and stubs out 3–5 so the RPC/UI can wire through. The apply path -//! returns a clear "not yet available" error until the artefact source -//! is decided. +//! Upstream began publishing tagged releases with `.deb` artefacts and +//! `checksums-linux.txt` (verified present as of v0.1.0 → v0.4.0-rc1), so +//! the apply path is fully wired against those assets. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use super::{service, UPSTREAM_REPO}; const GITHUB_API: &str = "https://api.github.com"; const USER_AGENT: &str = "archipelago-fips-updater"; -/// Result of `check_update()` — what the dashboard renders. +/// Result of `check()` — what the dashboard renders. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateCheck { /// Currently installed daemon version (from `fipsctl --version`). pub current: Option, - /// Short SHA of the latest commit on upstream `main`. - pub latest_commit: String, - /// True when the installed version string does not mention the latest SHA. + /// Tag of the latest stable upstream release, e.g. `v0.3.0`. + pub latest_version: String, + /// True when the installed version is older than `latest_version`. pub update_available: bool, + /// Release channel this check tracked. Currently always "stable". + pub channel: String, + /// Browser download URL of the architecture-matched .deb for the + /// latest release, when one exists (informational; apply() re-resolves). + pub asset_url: Option, /// Human-readable note for the UI. pub notes: String, } -/// Query GitHub for the latest commit on the upstream default branch and -/// compare to the installed version. Never errors on "no package installed" -/// — that is itself a valid state where an update is available. +/// One GitHub release as we consume it. +#[derive(Debug, Clone, Deserialize)] +struct Release { + tag_name: String, + #[serde(default)] + prerelease: bool, + #[serde(default)] + draft: bool, + #[serde(default)] + assets: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct Asset { + name: String, + browser_download_url: String, +} + +fn http_client() -> Result { + reqwest::Client::builder() + .user_agent(USER_AGENT) + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("Build HTTP client") +} + +/// Debian architecture string for the host (`amd64` / `arm64`). Returns +/// the raw `std::env::consts::ARCH` for anything we don't map, so the +/// asset lookup simply finds nothing and surfaces a clear error. +fn deb_arch() -> &'static str { + match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + other => other, + } +} + +/// Query GitHub for the latest stable release and compare to the installed +/// version. Never errors on "no package installed" — that is itself a valid +/// state where an update is available. pub async fn check() -> Result { let current = service::daemon_version().await.ok(); - let client = reqwest::Client::builder() - .user_agent(USER_AGENT) - .timeout(std::time::Duration::from_secs(15)) - .build() - .context("Build HTTP client")?; - let branch = fetch_default_branch(&client).await?; - let latest = fetch_head_sha(&client, &branch).await?; - let short = latest.chars().take(7).collect::(); + let client = http_client()?; + let release = fetch_latest_stable(&client).await?; let update_available = match ¤t { - Some(v) => !v.contains(&short), + Some(v) => version_is_older(v, &release.tag_name), None => true, }; + let asset_url = release + .assets + .iter() + .find(|a| is_deb_for_arch(&a.name)) + .map(|a| a.browser_download_url.clone()); + let notes = if update_available { format!( - "Upstream {} is at {}; installed: {}", - branch, - short, + "Update available: {} (installed: {})", + release.tag_name, current.as_deref().unwrap_or("not installed") ) } else { - format!("Up to date ({} @ {})", branch, short) + format!("Up to date ({})", release.tag_name) }; Ok(UpdateCheck { current, - latest_commit: short, + latest_version: release.tag_name, update_available, + channel: "stable".to_string(), + asset_url, notes, }) } -/// Apply the update. Stubbed pending a stable artefact source for -/// per-commit builds of the `fips` debian package. When this is wired -/// up it must: download → SHA256-verify → `sudo dpkg -i` → restart. +/// Download, verify, and install the latest stable FIPS release, then +/// restart the daemon. Steps: resolve release → match .deb for this arch +/// → download .deb + checksums → SHA256-verify → `sudo dpkg -i` → restart. pub async fn apply() -> Result<()> { - anyhow::bail!( - "FIPS auto-apply not yet wired — upstream does not publish stable \ - per-commit .deb artefacts for main. Upgrade manually for now: \ - `git pull && cargo deb && sudo dpkg -i target/debian/fips_*.deb`." - ) -} + let client = http_client()?; + let release = fetch_latest_stable(&client).await?; -async fn fetch_default_branch(client: &reqwest::Client) -> Result { - let url = format!("{}/repos/{}", GITHUB_API, UPSTREAM_REPO); - let resp = client - .get(&url) - .header("Accept", "application/vnd.github+json") + let deb = release + .assets + .iter() + .find(|a| is_deb_for_arch(&a.name)) + .ok_or_else(|| { + anyhow::anyhow!( + "release {} has no .deb for architecture {}", + release.tag_name, + deb_arch() + ) + })?; + let checksums = release + .assets + .iter() + .find(|a| a.name == "checksums-linux.txt") + .ok_or_else(|| { + anyhow::anyhow!("release {} has no checksums-linux.txt", release.tag_name) + })?; + + // Download the .deb (bytes) and the checksums (text). + let deb_bytes = client + .get(&deb.browser_download_url) .send() .await - .context("GitHub repo API")?; - if !resp.status().is_success() { - anyhow::bail!("GitHub repo API returned {}", resp.status()); - } - let body: serde_json::Value = resp.json().await.context("Parse repo JSON")?; - body.get("default_branch") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .ok_or_else(|| anyhow::anyhow!("GitHub repo response missing default_branch")) -} - -async fn fetch_head_sha(client: &reqwest::Client, branch: &str) -> Result { - let url = format!("{}/repos/{}/commits/{}", GITHUB_API, UPSTREAM_REPO, branch); - let resp = client - .get(&url) - .header("Accept", "application/vnd.github+json") + .context("download .deb")? + .error_for_status() + .context(".deb download HTTP error")? + .bytes() + .await + .context("read .deb body")?; + let checksums_text = client + .get(&checksums.browser_download_url) .send() .await - .context("GitHub commits API")?; - if !resp.status().is_success() { + .context("download checksums")? + .error_for_status() + .context("checksums download HTTP error")? + .text() + .await + .context("read checksums body")?; + + // Verify SHA256 against the checksums manifest (sha256sum format: + // "␠␠"). The filename column may include a leading + // "*" (binary mode) or a path prefix, so match on the basename. + let expected = checksums_text + .lines() + .filter_map(|line| { + let mut parts = line.split_whitespace(); + let hash = parts.next()?; + let name = parts.next()?.trim_start_matches('*'); + let base = name.rsplit('/').next().unwrap_or(name); + (base == deb.name).then(|| hash.to_lowercase()) + }) + .next() + .ok_or_else(|| anyhow::anyhow!("checksums-linux.txt has no entry for {}", deb.name))?; + + let actual = { + let mut hasher = Sha256::new(); + hasher.update(&deb_bytes); + hex::encode(hasher.finalize()) + }; + if actual != expected { anyhow::bail!( - "GitHub commits API returned {} for branch {}", - resp.status(), - branch + "SHA256 mismatch for {}: expected {}, got {}", + deb.name, + expected, + actual ); } - let body: serde_json::Value = resp.json().await.context("Parse commits JSON")?; - body.get("sha") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field")) + + // Stage the verified .deb in /tmp (shared with the host — the + // service runs with PrivateTmp=no) and install it. + let dest = std::env::temp_dir().join(&deb.name); + tokio::fs::write(&dest, &deb_bytes) + .await + .with_context(|| format!("write {}", dest.display()))?; + + // Run dpkg via `systemd-run` rather than `sudo dpkg` directly. The + // archipelago service runs under `ProtectSystem=strict`, so `/usr` + // and `/var/lib/dpkg` are read-only *inside the service's mount + // namespace* — and a `sudo` child inherits that namespace, so a + // bare `sudo dpkg -i` fails with "Read-only file system" on the + // dpkg database. `systemd-run` asks PID 1 to launch the command in + // a fresh transient scope outside our sandbox, where the real + // (writable) host filesystem is visible. `--wait` blocks until it + // finishes and propagates the exit status; `--pipe` forwards + // dpkg's output; `--collect` reaps the unit even on failure. + // + // dpkg flags, both load-bearing for this package specifically: + // --force-confold: the fips package ships conffiles under + // /etc/fips that archipelago rewrites at install time, so dpkg + // hits an interactive "keep/replace?" conffile prompt. With our + // closed stdin that aborts the configure step ("EOF on stdin at + // conffile prompt") and leaves the package half-unpacked + // (status `iU`), which `fips.status` then reports as + // `installed:false`. confold = keep our managed config, no prompt. + // --force-downgrade: ISO/dev nodes carry `0.3.0-dev-1`, which dpkg + // orders as NEWER than the stable tag `0.3.0` (a trailing + // `-dev` sorts above the bare release). Moving a dev build onto + // the stable line is therefore a dpkg "downgrade"; without this + // flag dpkg warns and exits non-zero. Our own version_is_older() + // gate already decided this is the wanted direction. + // DEBIAN_FRONTEND=noninteractive belt-and-suspenders against any + // other maintainer-script prompt. + let dpkg = tokio::process::Command::new("sudo") + .args([ + "-n", + "systemd-run", + "--collect", + "--wait", + "--quiet", + "--pipe", + "--", + "env", + "DEBIAN_FRONTEND=noninteractive", + "dpkg", + "--force-confold", + "--force-downgrade", + "-i", + ]) + .arg(&dest) + .output() + .await + .context("sudo systemd-run dpkg -i failed to launch")?; + // Best-effort cleanup regardless of dpkg result. + let _ = tokio::fs::remove_file(&dest).await; + if !dpkg.status.success() { + anyhow::bail!( + "dpkg -i {} exited {}: {}", + deb.name, + dpkg.status, + String::from_utf8_lossy(&dpkg.stderr).trim() + ); + } + + // Restart whichever fips unit is supervising the daemon so the new + // binary takes over. + let unit = service::active_unit().await; + service::restart(unit) + .await + .with_context(|| format!("restart {} after install", unit))?; + Ok(()) +} + +/// `/releases/latest` returns the most recent non-prerelease, non-draft +/// release. We still re-check the flags defensively in case the endpoint +/// or repo settings change. +async fn fetch_latest_stable(client: &reqwest::Client) -> Result { + let url = format!("{}/repos/{}/releases/latest", GITHUB_API, UPSTREAM_REPO); + let resp = client + .get(&url) + .header("Accept", "application/vnd.github+json") + .send() + .await + .context("GitHub releases/latest API")?; + if !resp.status().is_success() { + anyhow::bail!("GitHub releases/latest API returned {}", resp.status()); + } + let release: Release = resp.json().await.context("Parse release JSON")?; + if release.draft || release.prerelease { + anyhow::bail!( + "releases/latest returned a {} release ({})", + if release.draft { "draft" } else { "prerelease" }, + release.tag_name + ); + } + Ok(release) +} + +fn is_deb_for_arch(name: &str) -> bool { + name.starts_with("fips_") && name.ends_with(&format!("_{}.deb", deb_arch())) +} + +/// Parse the leading `MAJOR.MINOR.PATCH` triple from a version string, +/// plus whether a pre-release suffix (`-dev`, `-rc1`, …) follows it. +fn parse_version(s: &str) -> Option<((u64, u64, u64), bool)> { + // Take the first whitespace token, drop a leading 'v'. + let tok = s.split_whitespace().next().unwrap_or(s); + let tok = tok.strip_prefix('v').unwrap_or(tok); + // Split off any pre-release / build suffix. + let (core, rest) = match tok.find(|c: char| c == '-' || c == '+') { + Some(i) => (&tok[..i], &tok[i..]), + None => (tok, ""), + }; + let mut it = core.split('.'); + let major = it.next()?.parse::().ok()?; + let minor = it.next().unwrap_or("0").parse::().ok()?; + let patch = it.next().unwrap_or("0").parse::().ok()?; + let has_prerelease = rest.starts_with('-'); + Some(((major, minor, patch), has_prerelease)) +} + +/// True when `installed` is strictly older than release tag `latest`. +/// Same numeric triple but `installed` carries a pre-release suffix while +/// `latest` doesn't ⇒ installed is older (e.g. `0.3.0-dev` < `v0.3.0`). +/// If either side can't be parsed, fall back to "differs ⇒ update". +fn version_is_older(installed: &str, latest: &str) -> bool { + match (parse_version(installed), parse_version(latest)) { + (Some((ic, ipre)), Some((lc, lpre))) => { + if ic != lc { + ic < lc + } else { + // Equal cores: a pre-release is older than the final release. + ipre && !lpre + } + } + _ => { + // Unparseable: be conservative — offer the update unless the + // installed string already mentions the latest tag. + !installed.contains(latest.trim_start_matches('v')) + } + } } #[cfg(test)] mod tests { use super::*; - #[tokio::test] - async fn test_apply_returns_clear_stub_error() { - let err = apply().await.unwrap_err().to_string(); - assert!( - err.contains("not yet wired"), - "apply() should return an explicit not-yet-wired error, got: {}", - err - ); + #[test] + fn test_deb_arch_maps_known() { + // On the host running tests this is whatever the test arch is; + // just assert it returns a non-empty, lowercase token. + let a = deb_arch(); + assert!(!a.is_empty()); + assert_eq!(a, a.to_lowercase()); + } + + #[test] + fn test_version_older() { + assert!(version_is_older("0.3.0-dev (rev abc123)", "v0.3.0")); + assert!(version_is_older("0.2.1", "v0.3.0")); + assert!(version_is_older("0.3.0-rc1", "v0.3.0")); + assert!(!version_is_older("0.3.0", "v0.3.0")); + assert!(!version_is_older("0.4.0", "v0.3.0")); + assert!(!version_is_older("0.3.1", "v0.3.0")); + } + + #[test] + fn test_parse_version() { + assert_eq!(parse_version("v0.3.0"), Some(((0, 3, 0), false))); + assert_eq!(parse_version("0.3.0-dev (rev x)"), Some(((0, 3, 0), true))); + assert_eq!(parse_version("0.4.0-rc1"), Some(((0, 4, 0), true))); + assert_eq!(parse_version("1.2"), Some(((1, 2, 0), false))); + } + + #[test] + fn test_is_deb_for_arch() { + let arch = deb_arch(); + assert!(is_deb_for_arch(&format!("fips_0.3.0_{}.deb", arch))); + assert!(!is_deb_for_arch("fips_0.3.0_someotherarch.deb")); + assert!(!is_deb_for_arch("checksums-linux.txt")); + assert!(!is_deb_for_arch(&format!( + "fips-0.3.0-linux-{}.tar.gz", + arch + ))); } #[test] fn test_update_check_serialises() { let uc = UpdateCheck { - current: Some("0.2.0-abc1234".to_string()), - latest_commit: "def5678".to_string(), + current: Some("0.3.0-dev".to_string()), + latest_version: "v0.3.0".to_string(), update_available: true, + channel: "stable".to_string(), + asset_url: Some("https://example/fips_0.3.0_amd64.deb".to_string()), notes: "test".to_string(), }; let json = serde_json::to_string(&uc).unwrap(); - assert!(json.contains("latest_commit")); + assert!(json.contains("latest_version")); assert!(json.contains("update_available")); + assert!(json.contains("stable")); } } diff --git a/core/archipelago/src/port_allocator.rs b/core/archipelago/src/port_allocator.rs index 39200ec0..e3a824f8 100644 --- a/core/archipelago/src/port_allocator.rs +++ b/core/archipelago/src/port_allocator.rs @@ -28,6 +28,7 @@ const RESERVED_PORTS: &[u16] = &[ 8888, // SearXNG 8096, 2342, 2283, // Jellyfin, Photoprism, Immich 8443, // FIPS TCP fallback + 8336, // FIPS UI (fips-ui) ]; /// Start of range for allocating web app ports when preferred is taken. diff --git a/docker/fips-ui/Dockerfile b/docker/fips-ui/Dockerfile new file mode 100644 index 00000000..e531d196 --- /dev/null +++ b/docker/fips-ui/Dockerfile @@ -0,0 +1,19 @@ +FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine +# Static site content. +COPY index.html /usr/share/nginx/html/ +# +# FIPS UI talks only to the archipelago RPC on 127.0.0.1:5678, using the +# browser's own archipelago session — there is NO per-node secret to +# substitute, so (unlike bitcoin-ui) the nginx config is baked straight +# into the image rather than bind-mounted/rendered at container-create. +COPY nginx.conf /etc/nginx/conf.d/default.conf +# +# Run nginx as root to avoid chown failures in rootless Podman user +# namespaces. The rest of the nginx image is unchanged. +RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \ + mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \ + /var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \ + /var/cache/nginx/scgi_temp +EXPOSE 8336 +ENTRYPOINT [] +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/fips-ui/index.html b/docker/fips-ui/index.html new file mode 100644 index 00000000..3c7752ba --- /dev/null +++ b/docker/fips-ui/index.html @@ -0,0 +1,478 @@ + + + + + +FIPS Mesh + + + +
+
+

FIPS Mesh

+ Loading… +
+

Encrypted mesh transport. This node reaches the network through seed anchors; a connected anchor keeps FIPS routing fast instead of degrading to Tor.

+ +
+

Status

+
Daemon installed
+
Version
+
Service
+
Seed key present
+
Authenticated peers
+
Anchor connected
+
This node's npub
+
+ + + + +
+
+
+ +
+

This node as an anchor

+

Share these with another node's operator so they can add this node as a seed anchor (their Seed Anchors → Add form). The address is whatever host you reached this dashboard at, so it's reachable the same way you got here.

+
+ npub + + + + +
+
+ Address + + + + +
+
+
+
+ +
+

Updates · stable channel

+
Installed
+
Latest stable
+
Status
+
+ + +
+
+

Updates download the signed .deb from the upstream jmcorgan/fips releases, verify its SHA-256 against the published checksums, install it, and restart the daemon.

+
+ +
+

Seed Anchors

+

Loading…

+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + + + diff --git a/docker/fips-ui/nginx.conf b/docker/fips-ui/nginx.conf new file mode 100644 index 00000000..5faf33c5 --- /dev/null +++ b/docker/fips-ui/nginx.conf @@ -0,0 +1,33 @@ +server { + listen 8336; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Proxy archipelago RPC same-origin so the browser never makes a + # cross-origin request (no CORS needed). The FIPS app is served on + # this node's :8336; cookies are scoped by host (not port), so the + # browser already carries the `session` (HttpOnly) and `csrf_token` + # cookies set by the main UI on :80. We forward both, plus the + # X-CSRF-Token header the app derives from the readable csrf_token + # cookie, to the backend RPC on 127.0.0.1:5678. + # + # Unlike bitcoin-ui this config is fully static (baked into the + # image) — there is no upstream secret to substitute; the browser's + # own archipelago session is the credential. + location /rpc/v1 { + proxy_pass http://127.0.0.1:5678/rpc/v1; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Cookie $http_cookie; + proxy_set_header X-CSRF-Token $http_x_csrf_token; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 60s; + add_header Cache-Control "no-store"; + } + + location / { + try_files $uri $uri/ /index.html; + } +}