feat(fips): connect to public mesh anchor over TCP + wire daemon updates

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) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-15 06:41:48 -04:00
parent 640dc87a5f
commit 95f9a805b1
12 changed files with 1103 additions and 119 deletions

View File

@ -1,5 +1,11 @@
# Changelog # 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) ## 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. - 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.

42
apps/fips-ui/manifest.yml Normal file
View File

@ -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

View File

@ -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<String> {
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<hyper::Body>) -> Result<Response<hyper::Body>> { pub async fn handle_request(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
let path = req.uri().path().to_string(); let path = req.uri().path().to_string();
let method = req.method().clone(); let method = req.method().clone();
@ -265,9 +304,10 @@ impl ApiHandler {
let mut builder = Response::builder() let mut builder = Response::builder()
.status(StatusCode::NO_CONTENT) .status(StatusCode::NO_CONTENT)
.header("Vary", "Origin"); .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 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-Methods", "GET, POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token") .header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
.header("Access-Control-Allow-Credentials", "true"); .header("Access-Control-Allow-Credentials", "true");
@ -448,7 +488,8 @@ impl ApiHandler {
// No backend auth check here because the LND UI iframe fetches this // No backend auth check here because the LND UI iframe fetches this
// endpoint and the session cookie flow is validated at the nginx layer. // endpoint and the session cookie flow is validated at the nginx layer.
(Method::GET, "/lnd-connect-info") => { (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 // Container logs — requires session
@ -465,8 +506,8 @@ impl ApiHandler {
if !self.is_authenticated(&headers).await { if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized()); return Ok(Self::unauthorized());
} }
let origin = self.validate_origin(&headers).unwrap_or_default(); let origin = self.app_cors_origin(&headers);
Self::handle_lnd_proxy(path, &origin).await Self::handle_lnd_proxy(self.rpc_handler.clone(), path, &origin).await
} }
// DWN health — unauthenticated // DWN health — unauthenticated

View File

@ -99,33 +99,61 @@ impl ApiHandler {
pub(super) async fn handle_lnd_connect_info( pub(super) async fn handle_lnd_connect_info(
rpc: std::sync::Arc<super::super::rpc::RpcHandler>, rpc: std::sync::Arc<super::super::rpc::RpcHandler>,
cors_origin: &str,
) -> Result<Response<hyper::Body>> { ) -> Result<Response<hyper::Body>> {
// 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 { match rpc.handle_lnd_connect_info().await {
Ok(val) => { Ok(val) => {
let body = serde_json::to_vec(&val).unwrap_or_default(); let body = serde_json::to_vec(&val).unwrap_or_default();
Ok(build_response( Ok(cors(
StatusCode::OK, Response::builder()
"application/json", .status(StatusCode::OK)
hyper::Body::from(body), .header("Content-Type", "application/json"),
)) )
.body(hyper::Body::from(body))
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
} }
Err(e) => Ok(Response::builder() Err(e) => Ok(cors(
.status(StatusCode::INTERNAL_SERVER_ERROR) Response::builder()
.header("Content-Type", "application/json") .status(StatusCode::INTERNAL_SERVER_ERROR)
.body(hyper::Body::from( .header("Content-Type", "application/json"),
serde_json::json!({"error": e.to_string()}).to_string(), )
)) .body(hyper::Body::from(
.unwrap()), serde_json::json!({"error": e.to_string()}).to_string(),
))
.unwrap()),
} }
} }
pub(super) async fn handle_lnd_proxy( pub(super) async fn handle_lnd_proxy(
rpc: Arc<RpcHandler>,
path: &str, path: &str,
cors_origin: &str, cors_origin: &str,
) -> Result<Response<hyper::Body>> { ) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/"); let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
let url = format!("{LND_REST_BASE_URL}{suffix}"); 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) => { Ok(resp) => {
let status = resp.status().as_u16(); let status = resp.status().as_u16();
let headers = resp.headers().clone(); let headers = resp.headers().clone();

View File

@ -32,6 +32,8 @@ fn is_platform_managed_app(app_id: &str) -> bool {
| "fedimint-gateway" | "fedimint-gateway"
| "indeedhub" | "indeedhub"
| "immich" | "immich"
| "fips"
| "fips-ui"
) )
} }

View File

@ -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"; 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 — /// 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. /// failing to bootstrap host artifacts must not prevent the backend from serving.
pub async fn ensure_doctor_installed() { pub async fn ensure_doctor_installed() {
@ -520,12 +527,31 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
.with_context(|| format!("read {}", path))?; .with_context(|| format!("read {}", path))?;
let missing_app_catalog = !content.contains("location /api/app-catalog"); let missing_app_catalog = !content.contains("location /api/app-catalog");
let missing_bitcoin_status = !content.contains("location /bitcoin-status"); 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); return Ok(false);
} }
let mut patched = content.clone(); 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 { if missing_bitcoin_status {
let anchor = " location /electrs-status {"; let anchor = " location /electrs-status {";
if !patched.contains(anchor) { if !patched.contains(anchor) {

View File

@ -28,12 +28,38 @@ use tokio::process::Command;
/// On-disk filename under `data_dir/`. /// On-disk filename under `data_dir/`.
const SEED_ANCHORS_FILE: &str = "seed-anchors.json"; const SEED_ANCHORS_FILE: &str = "seed-anchors.json";
/// Public anchor (`fips.v0l.io`) carried as a default seed for fresh /// Public anchor (`fips.v0l.io`) carried as a default seed for every
/// installs — the one the upstream daemon dials anyway. Operators can /// node — it bootstraps DHT routing so a fresh node isn't isolated.
/// remove it from the UI once their own cluster has independent anchors. /// 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 = pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n"; "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 /// One seed-anchor entry. `address` must be directly dialable (IP or
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp", /// 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) data_dir.join(SEED_ANCHORS_FILE)
} }
/// Load the seed-anchor list. Returns an empty list if the file /// Load the seed-anchor list. A node that has never edited its anchor
/// doesn't exist yet — a first-boot node with no operator config. /// 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<Vec<SeedAnchor>> { pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
let path = anchors_path(data_dir); let path = anchors_path(data_dir);
if !path.exists() { if !path.exists() {
return Ok(Vec::new()); return Ok(vec![default_public_anchor()]);
} }
let bytes = tokio::fs::read(&path) let bytes = tokio::fs::read(&path)
.await .await
@ -121,11 +150,27 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
/// `fipsctl connect` is idempotent-ish: calling it for an already- /// `fipsctl connect` is idempotent-ish: calling it for an already-
/// connected peer is a no-op at the protocol layer, so re-applying on /// 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. /// 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<ApplyResult> { pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
let mut results = Vec::with_capacity(anchors.len()); let mut results = Vec::with_capacity(anchors.len());
for anchor in anchors { for anchor in anchors {
let out = Command::new("fipsctl") let out = Command::new("sudo")
.args(["connect", &anchor.npub, &anchor.address, &anchor.transport]) .args([
"-n",
"fipsctl",
"connect",
&anchor.npub,
&anchor.address,
&anchor.transport,
])
.output() .output()
.await; .await;
let result = match out { let result = match out {
@ -138,7 +183,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
npub: anchor.npub.clone(), npub: anchor.npub.clone(),
ok: false, ok: false,
message: format!( message: format!(
"fipsctl exited {}: {}", "sudo fipsctl connect exited {}: {}",
o.status, o.status,
String::from_utf8_lossy(&o.stderr).trim() String::from_utf8_lossy(&o.stderr).trim()
), ),
@ -146,7 +191,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
Err(e) => ApplyResult { Err(e) => ApplyResult {
npub: anchor.npub.clone(), npub: anchor.npub.clone(),
ok: false, ok: false,
message: format!("fipsctl launch failed: {}", e), message: format!("sudo fipsctl launch failed: {}", e),
}, },
}; };
if result.ok { if result.ok {
@ -185,10 +230,28 @@ mod tests {
} }
#[tokio::test] #[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 dir = tempfile::tempdir().unwrap();
let got = load(dir.path()).await.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] #[tokio::test]

View File

@ -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): //! Flow (no auto-update, no background polling — user clicks a button):
//! 1. Query GitHub for the upstream repo's default branch, then the //! 1. Query GitHub for the latest *stable* release of `jmcorgan/fips`
//! latest commit on it. (jmcorgan/fips default is `master`, not //! (`/releases/latest` returns the newest non-prerelease, non-draft
//! `main` — we resolve it dynamically so a future rename Just Works.) //! tag, so release candidates like `v0.4.0-rc1` are skipped).
//! 2. Compare with the installed daemon version reported by //! 2. Compare its tag (e.g. `v0.3.0`) with the installed daemon version
//! `fipsctl --version`. If identical, report "up to date". //! reported by `fipsctl --version`. A dev/pre-release build of the
//! 3. Fetch the built .deb artefact for that commit + its SHA256. //! same number (`0.3.0-dev`) counts as older than the released tag.
//! 4. SHA256-verify the download. //! 3. Pick the Debian package asset matching the host architecture
//! 5. `sudo dpkg -i` the .deb, `sudo systemctl restart` the service. //! (`fips_<ver>_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 //! Upstream began publishing tagged releases with `.deb` artefacts and
//! publish stable release assets for per-commit builds. This module //! `checksums-linux.txt` (verified present as of v0.1.0 → v0.4.0-rc1), so
//! currently implements steps 12 (the "is there anything newer?" query) //! the apply path is fully wired against those assets.
//! and stubs out 35 so the RPC/UI can wire through. The apply path
//! returns a clear "not yet available" error until the artefact source
//! is decided.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::{service, UPSTREAM_REPO}; use super::{service, UPSTREAM_REPO};
const GITHUB_API: &str = "https://api.github.com"; const GITHUB_API: &str = "https://api.github.com";
const USER_AGENT: &str = "archipelago-fips-updater"; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateCheck { pub struct UpdateCheck {
/// Currently installed daemon version (from `fipsctl --version`). /// Currently installed daemon version (from `fipsctl --version`).
pub current: Option<String>, pub current: Option<String>,
/// Short SHA of the latest commit on upstream `main`. /// Tag of the latest stable upstream release, e.g. `v0.3.0`.
pub latest_commit: String, pub latest_version: String,
/// True when the installed version string does not mention the latest SHA. /// True when the installed version is older than `latest_version`.
pub update_available: bool, 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<String>,
/// Human-readable note for the UI. /// Human-readable note for the UI.
pub notes: String, pub notes: String,
} }
/// Query GitHub for the latest commit on the upstream default branch and /// One GitHub release as we consume it.
/// compare to the installed version. Never errors on "no package installed" #[derive(Debug, Clone, Deserialize)]
/// — that is itself a valid state where an update is available. struct Release {
tag_name: String,
#[serde(default)]
prerelease: bool,
#[serde(default)]
draft: bool,
#[serde(default)]
assets: Vec<Asset>,
}
#[derive(Debug, Clone, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
fn http_client() -> Result<reqwest::Client> {
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<UpdateCheck> { pub async fn check() -> Result<UpdateCheck> {
let current = service::daemon_version().await.ok(); let current = service::daemon_version().await.ok();
let client = reqwest::Client::builder() let client = http_client()?;
.user_agent(USER_AGENT) let release = fetch_latest_stable(&client).await?;
.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::<String>();
let update_available = match &current { let update_available = match &current {
Some(v) => !v.contains(&short), Some(v) => version_is_older(v, &release.tag_name),
None => true, 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 { let notes = if update_available {
format!( format!(
"Upstream {} is at {}; installed: {}", "Update available: {} (installed: {})",
branch, release.tag_name,
short,
current.as_deref().unwrap_or("not installed") current.as_deref().unwrap_or("not installed")
) )
} else { } else {
format!("Up to date ({} @ {})", branch, short) format!("Up to date ({})", release.tag_name)
}; };
Ok(UpdateCheck { Ok(UpdateCheck {
current, current,
latest_commit: short, latest_version: release.tag_name,
update_available, update_available,
channel: "stable".to_string(),
asset_url,
notes, notes,
}) })
} }
/// Apply the update. Stubbed pending a stable artefact source for /// Download, verify, and install the latest stable FIPS release, then
/// per-commit builds of the `fips` debian package. When this is wired /// restart the daemon. Steps: resolve release → match .deb for this arch
/// up it must: download → SHA256-verify → `sudo dpkg -i` → restart. /// → download .deb + checksums → SHA256-verify → `sudo dpkg -i` → restart.
pub async fn apply() -> Result<()> { pub async fn apply() -> Result<()> {
anyhow::bail!( let client = http_client()?;
"FIPS auto-apply not yet wired — upstream does not publish stable \ let release = fetch_latest_stable(&client).await?;
per-commit .deb artefacts for main. Upgrade manually for now: \
`git pull && cargo deb && sudo dpkg -i target/debian/fips_*.deb`."
)
}
async fn fetch_default_branch(client: &reqwest::Client) -> Result<String> { let deb = release
let url = format!("{}/repos/{}", GITHUB_API, UPSTREAM_REPO); .assets
let resp = client .iter()
.get(&url) .find(|a| is_deb_for_arch(&a.name))
.header("Accept", "application/vnd.github+json") .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() .send()
.await .await
.context("GitHub repo API")?; .context("download .deb")?
if !resp.status().is_success() { .error_for_status()
anyhow::bail!("GitHub repo API returned {}", resp.status()); .context(".deb download HTTP error")?
} .bytes()
let body: serde_json::Value = resp.json().await.context("Parse repo JSON")?; .await
body.get("default_branch") .context("read .deb body")?;
.and_then(|v| v.as_str()) let checksums_text = client
.map(|s| s.to_string()) .get(&checksums.browser_download_url)
.ok_or_else(|| anyhow::anyhow!("GitHub repo response missing default_branch"))
}
async fn fetch_head_sha(client: &reqwest::Client, branch: &str) -> Result<String> {
let url = format!("{}/repos/{}/commits/{}", GITHUB_API, UPSTREAM_REPO, branch);
let resp = client
.get(&url)
.header("Accept", "application/vnd.github+json")
.send() .send()
.await .await
.context("GitHub commits API")?; .context("download checksums")?
if !resp.status().is_success() { .error_for_status()
.context("checksums download HTTP error")?
.text()
.await
.context("read checksums body")?;
// Verify SHA256 against the checksums manifest (sha256sum format:
// "<hex>␠␠<filename>"). 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!( anyhow::bail!(
"GitHub commits API returned {} for branch {}", "SHA256 mismatch for {}: expected {}, got {}",
resp.status(), deb.name,
branch expected,
actual
); );
} }
let body: serde_json::Value = resp.json().await.context("Parse commits JSON")?;
body.get("sha") // Stage the verified .deb in /tmp (shared with the host — the
.and_then(|v| v.as_str()) // service runs with PrivateTmp=no) and install it.
.map(|s| s.to_string()) let dest = std::env::temp_dir().join(&deb.name);
.ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field")) 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<Release> {
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::<u64>().ok()?;
let minor = it.next().unwrap_or("0").parse::<u64>().ok()?;
let patch = it.next().unwrap_or("0").parse::<u64>().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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test] #[test]
async fn test_apply_returns_clear_stub_error() { fn test_deb_arch_maps_known() {
let err = apply().await.unwrap_err().to_string(); // On the host running tests this is whatever the test arch is;
assert!( // just assert it returns a non-empty, lowercase token.
err.contains("not yet wired"), let a = deb_arch();
"apply() should return an explicit not-yet-wired error, got: {}", assert!(!a.is_empty());
err 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] #[test]
fn test_update_check_serialises() { fn test_update_check_serialises() {
let uc = UpdateCheck { let uc = UpdateCheck {
current: Some("0.2.0-abc1234".to_string()), current: Some("0.3.0-dev".to_string()),
latest_commit: "def5678".to_string(), latest_version: "v0.3.0".to_string(),
update_available: true, update_available: true,
channel: "stable".to_string(),
asset_url: Some("https://example/fips_0.3.0_amd64.deb".to_string()),
notes: "test".to_string(), notes: "test".to_string(),
}; };
let json = serde_json::to_string(&uc).unwrap(); 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("update_available"));
assert!(json.contains("stable"));
} }
} }

View File

@ -28,6 +28,7 @@ const RESERVED_PORTS: &[u16] = &[
8888, // SearXNG 8888, // SearXNG
8096, 2342, 2283, // Jellyfin, Photoprism, Immich 8096, 2342, 2283, // Jellyfin, Photoprism, Immich
8443, // FIPS TCP fallback 8443, // FIPS TCP fallback
8336, // FIPS UI (fips-ui)
]; ];
/// Start of range for allocating web app ports when preferred is taken. /// Start of range for allocating web app ports when preferred is taken.

19
docker/fips-ui/Dockerfile Normal file
View File

@ -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;"]

478
docker/fips-ui/index.html Normal file
View File

@ -0,0 +1,478 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FIPS Mesh</title>
<style>
:root {
--bg: #0e1116;
--panel: #161b22;
--panel-2: #1c232c;
--border: #2a323d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #2f81f7;
--ok: #2ea043;
--warn: #d29922;
--bad: #f85149;
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.wrap { max-width: 860px; margin: 0 auto; padding: 24px 18px 64px; }
header { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
header h1 { font-size: 20px; margin: 0; font-weight: 600; }
.sub { color: var(--muted); margin: 0 0 20px; font-size: 13px; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
margin-bottom: 16px;
}
.card h2 {
font-size: 13px; text-transform: uppercase; letter-spacing: .04em;
color: var(--muted); margin: 0 0 14px; font-weight: 600;
}
.row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; gap: 12px; }
.row + .row { border-top: 1px solid var(--border); }
.row .k { color: var(--muted); }
.row .v { font-variant-numeric: tabular-nums; text-align: right; word-break: break-all; }
.pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
border: 1px solid transparent;
}
.pill::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.pill.ok { color: var(--ok); background: rgba(46,160,67,.12); }
.pill.warn { color: var(--warn); background: rgba(210,153,34,.12); }
.pill.bad { color: var(--bad); background: rgba(248,81,73,.12); }
.pill.muted { color: var(--muted); background: rgba(139,148,158,.12); }
.btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
button {
font: inherit; font-weight: 600; cursor: pointer;
background: var(--panel-2); color: var(--text);
border: 1px solid var(--border); border-radius: 8px; padding: 8px 14px;
}
button:hover:not(:disabled) { border-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.danger { color: var(--bad); }
button:disabled { opacity: .5; cursor: default; }
input, select {
font: inherit; background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; width: 100%;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.grid .full { grid-column: 1 / -1; }
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.anchor-item { padding: 10px 0; }
.anchor-item + .anchor-item { border-top: 1px solid var(--border); }
.anchor-item .top { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
.anchor-item .addr { color: var(--muted); font-size: 12px; word-break: break-all; }
.anchor-item .npub { font-size: 12px; color: var(--muted); word-break: break-all; }
.notice { padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-top: 10px; display: none; }
.notice.show { display: block; }
.notice.info { background: rgba(47,129,247,.12); color: var(--accent); }
.notice.good { background: rgba(46,160,67,.12); color: var(--ok); }
.notice.error { background: rgba(248,81,73,.12); color: var(--bad); }
.spin { display: inline-block; width: 13px; height: 13px; border: 2px solid currentColor;
border-right-color: transparent; border-radius: 50%; animation: r .7s linear infinite; vertical-align: -2px; }
@keyframes r { to { transform: rotate(360deg); } }
.muted-note { color: var(--muted); font-size: 12px; margin-top: 8px; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>FIPS Mesh</h1>
<span id="anchorPill" class="pill muted">Loading…</span>
</header>
<p class="sub">Encrypted mesh transport. This node reaches the network through seed anchors; a connected anchor keeps FIPS routing fast instead of degrading to Tor.</p>
<div class="card">
<h2>Status</h2>
<div class="row"><span class="k">Daemon installed</span><span class="v" id="sInstalled"></span></div>
<div class="row"><span class="k">Version</span><span class="v" id="sVersion"></span></div>
<div class="row"><span class="k">Service</span><span class="v" id="sService"></span></div>
<div class="row"><span class="k">Seed key present</span><span class="v" id="sKey"></span></div>
<div class="row"><span class="k">Authenticated peers</span><span class="v" id="sPeers"></span></div>
<div class="row"><span class="k">Anchor connected</span><span class="v" id="sAnchor"></span></div>
<div class="row"><span class="k">This node's npub</span><span class="v" id="sNpub"></span></div>
<div class="btns">
<button id="btnRefresh">Refresh</button>
<button id="btnReconnect">Reconnect</button>
<button id="btnRestart">Restart daemon</button>
<button id="btnInstall">Install / repair</button>
</div>
<div id="actionNotice" class="notice"></div>
</div>
<div class="card">
<h2>This node as an anchor</h2>
<p class="muted-note" style="margin-top:0">Share these with another node's operator so they can add this node as a seed anchor (their <em>Seed Anchors → Add</em> form). The address is whatever host you reached this dashboard at, so it's reachable the same way you got here.</p>
<div class="row">
<span class="k">npub</span>
<span class="v" style="display:flex;gap:8px;align-items:center">
<code id="ownNpub" style="font-size:12px"></code>
<button data-copy="ownNpub">Copy</button>
</span>
</div>
<div class="row">
<span class="k">Address</span>
<span class="v" style="display:flex;gap:8px;align-items:center">
<code id="ownAddr" style="font-size:12px"></code>
<button data-copy="ownAddr">Copy</button>
</span>
</div>
<div id="ownReach" class="muted-note"></div>
<div id="copyNotice" class="notice"></div>
</div>
<div class="card">
<h2>Updates · stable channel</h2>
<div class="row"><span class="k">Installed</span><span class="v" id="uCurrent"></span></div>
<div class="row"><span class="k">Latest stable</span><span class="v" id="uLatest"></span></div>
<div class="row"><span class="k">Status</span><span class="v" id="uStatus"></span></div>
<div class="btns">
<button id="btnCheck">Check for updates</button>
<button id="btnApply" class="primary" disabled>Apply update</button>
</div>
<div id="updateNotice" class="notice"></div>
<p class="muted-note">Updates download the signed <code>.deb</code> from the upstream <code>jmcorgan/fips</code> releases, verify its SHA-256 against the published checksums, install it, and restart the daemon.</p>
</div>
<div class="card">
<h2>Seed Anchors</h2>
<div id="anchorList"><p class="muted-note">Loading…</p></div>
<div style="margin-top:14px">
<div class="grid">
<div class="full"><label>npub</label><input id="aNpub" placeholder="npub1…" autocomplete="off" spellcheck="false" /></div>
<div><label>Address (host:port)</label><input id="aAddr" placeholder="192.168.1.116:8668" autocomplete="off" spellcheck="false" /></div>
<div><label>Transport</label><select id="aTransport"><option value="udp">udp</option><option value="tcp">tcp</option></select></div>
<div class="full"><label>Label (optional)</label><input id="aLabel" placeholder="Home anchor" autocomplete="off" /></div>
</div>
<div class="btns">
<button id="btnAddAnchor" class="primary">Add anchor</button>
<button id="btnApplyAnchors">Re-apply all</button>
</div>
<div id="anchorNotice" class="notice"></div>
</div>
</div>
</div>
<script>
const RPC_ENDPOINT = '/rpc/v1';
function cookieValue(name) {
return document.cookie
.split('; ')
.find(row => row.startsWith(`${name}=`))
?.split('=').slice(1).join('=') || '';
}
async function rpc(method, params = {}) {
const headers = { 'Content-Type': 'application/json' };
const csrf = cookieValue('csrf_token');
if (csrf) headers['X-CSRF-Token'] = decodeURIComponent(csrf);
const res = await fetch(RPC_ENDPOINT, {
method: 'POST',
headers,
credentials: 'include',
cache: 'no-store',
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body.error) {
throw new Error(body.error?.message || `RPC ${method} failed (${res.status})`);
}
return body.result;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function setText(id, v, fallback = '—') {
const el = document.getElementById(id);
if (el) el.textContent = (v === null || v === undefined || v === '') ? fallback : v;
}
function pill(state, text) {
return `<span class="pill ${state}">${escapeHtml(text)}</span>`;
}
function setHtml(id, html) {
const el = document.getElementById(id);
if (el) el.innerHTML = html;
}
function notice(id, kind, msg) {
const el = document.getElementById(id);
if (!el) return;
if (!msg) { el.className = 'notice'; el.textContent = ''; return; }
el.className = `notice show ${kind}`;
el.innerHTML = msg;
}
function busy(btn, on, label) {
if (!btn) return;
if (on) {
btn.dataset.label = btn.dataset.label || btn.textContent;
btn.disabled = true;
btn.innerHTML = `<span class="spin"></span> ${escapeHtml(label || btn.dataset.label)}`;
} else {
btn.disabled = false;
btn.textContent = btn.dataset.label || btn.textContent;
}
}
function renderStatus(s) {
setText('sInstalled', s.installed ? 'Yes' : 'No');
setText('sVersion', s.version);
const active = s.service_active;
setHtml('sService', active
? pill('ok', s.service_state || 'active')
: pill('bad', s.service_state || 'inactive'));
setHtml('sKey', s.key_present ? pill('ok', 'present') : pill('warn', 'missing'));
const peers = s.authenticated_peer_count || 0;
setHtml('sPeers', peers > 0 ? pill('ok', String(peers)) : pill('warn', '0 — isolated'));
setHtml('sAnchor', s.anchor_connected ? pill('ok', 'connected') : pill('bad', 'unreachable'));
setText('sNpub', s.npub);
if (s.npub) document.getElementById('ownNpub').textContent = s.npub;
const ap = document.getElementById('anchorPill');
if (s.anchor_connected) { ap.className = 'pill ok'; ap.textContent = 'Anchor connected'; }
else if (peers > 0) { ap.className = 'pill warn'; ap.textContent = 'Peers, no anchor'; }
else if (s.service_active) { ap.className = 'pill bad'; ap.textContent = 'Isolated'; }
else { ap.className = 'pill muted'; ap.textContent = 'Daemon down'; }
}
async function loadStatus() {
try {
const s = await rpc('fips.status');
renderStatus(s);
setText('uCurrent', s.version, 'not installed');
} catch (e) {
notice('actionNotice', 'error', escapeHtml(e.message));
}
}
function renderAnchors(list) {
if (!list || list.length === 0) {
setHtml('anchorList', '<p class="muted-note">No seed anchors configured. Add one below so this node can reach the mesh.</p>');
return;
}
const html = list.map(a => `
<div class="anchor-item">
<div class="top">
<strong>${escapeHtml(a.label || a.address)}</strong>
<button class="danger" data-remove="${escapeHtml(a.npub)}">Remove</button>
</div>
<div class="addr">${escapeHtml(a.address)} · ${escapeHtml(a.transport || 'udp')}</div>
<div class="npub">${escapeHtml(a.npub)}</div>
</div>`).join('');
setHtml('anchorList', html);
document.querySelectorAll('[data-remove]').forEach(btn => {
btn.addEventListener('click', () => removeAnchor(btn.dataset.remove, btn));
});
}
async function loadAnchors() {
try {
const r = await rpc('fips.list-seed-anchors');
renderAnchors(r.seed_anchors);
} catch (e) {
setHtml('anchorList', `<p class="muted-note">${escapeHtml(e.message)}</p>`);
}
}
async function removeAnchor(npub, btn) {
busy(btn, true, 'Removing');
notice('anchorNotice', '', '');
try {
const r = await rpc('fips.remove-seed-anchor', { npub });
renderAnchors(r.seed_anchors);
notice('anchorNotice', 'good', 'Anchor removed.');
} catch (e) {
notice('anchorNotice', 'error', escapeHtml(e.message));
busy(btn, false);
}
}
// --- wire up buttons ---
document.getElementById('btnRefresh').addEventListener('click', loadStatus);
document.getElementById('btnReconnect').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Reconnecting…');
notice('actionNotice', 'info', 'Restarting the daemon and waiting for an anchor — this takes about 20 seconds…');
try {
const r = await rpc('fips.reconnect');
renderStatus(r.after);
const kind = r.after.anchor_connected ? 'good' : 'error';
notice('actionNotice', kind, `${escapeHtml(r.hint || r.likely_cause)}`);
} catch (err) {
notice('actionNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnRestart').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Restarting…');
notice('actionNotice', '', '');
try {
const r = await rpc('fips.restart');
notice('actionNotice', 'good', `Restarted ${escapeHtml(r.unit || 'fips service')}.`);
await loadStatus();
} catch (err) {
notice('actionNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnInstall').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Installing…');
notice('actionNotice', '', '');
try {
const s = await rpc('fips.install');
renderStatus(s);
notice('actionNotice', 'good', 'Config and key re-materialised; service activated.');
} catch (err) {
notice('actionNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnCheck').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Checking…');
notice('updateNotice', '', '');
const applyBtn = document.getElementById('btnApply');
try {
const c = await rpc('fips.check-update');
setText('uCurrent', c.current, 'not installed');
setText('uLatest', c.latest_version);
setHtml('uStatus', c.update_available ? pill('warn', 'update available') : pill('ok', 'up to date'));
applyBtn.disabled = !c.update_available;
notice('updateNotice', c.update_available ? 'info' : 'good', escapeHtml(c.notes || ''));
} catch (err) {
notice('updateNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnApply').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Updating…');
notice('updateNotice', 'info', 'Downloading, verifying, and installing the new FIPS daemon, then restarting it…');
try {
await rpc('fips.apply-update');
notice('updateNotice', 'good', 'Update installed and daemon restarted.');
btn.disabled = true;
await loadStatus();
await rpc('fips.check-update').then(c => {
setText('uLatest', c.latest_version);
setHtml('uStatus', c.update_available ? pill('warn', 'update available') : pill('ok', 'up to date'));
}).catch(() => {});
} catch (err) {
notice('updateNotice', 'error', escapeHtml(err.message));
busy(btn, false);
}
});
document.getElementById('btnAddAnchor').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const npub = document.getElementById('aNpub').value.trim();
const address = document.getElementById('aAddr').value.trim();
const transport = document.getElementById('aTransport').value;
const label = document.getElementById('aLabel').value.trim();
if (!npub.startsWith('npub1')) { notice('anchorNotice', 'error', 'npub must start with npub1…'); return; }
if (!address.includes(':')) { notice('anchorNotice', 'error', 'Address must be host:port (e.g. 192.168.1.116:8668).'); return; }
busy(btn, true, 'Adding…');
notice('anchorNotice', '', '');
try {
const r = await rpc('fips.add-seed-anchor', { npub, address, transport, label });
renderAnchors(r.seed_anchors);
const applied = (r.apply || []).find(x => x.npub === npub);
const ok = applied ? applied.ok : true;
notice('anchorNotice', ok ? 'good' : 'info',
ok ? 'Anchor added and pushed to the running daemon.' : `Anchor saved. Daemon push: ${escapeHtml(applied?.message || 'pending')}`);
['aNpub','aAddr','aLabel'].forEach(id => document.getElementById(id).value = '');
await loadStatus();
} catch (err) {
notice('anchorNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnApplyAnchors').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Applying…');
notice('anchorNotice', '', '');
try {
const r = await rpc('fips.apply-seed-anchors');
const okCount = (r.results || []).filter(x => x.ok).length;
notice('anchorNotice', 'good', `Re-applied ${okCount}/${r.applied || 0} anchors to the daemon.`);
await loadStatus();
} catch (err) {
notice('anchorNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
// This node's own anchor address: the host the operator reached the
// dashboard at is, by definition, reachable for them — so it's the
// right dial hint. FIPS listens on UDP 8668.
const FIPS_PORT = 8668;
function isPrivateLan(host) {
return /^10\./.test(host)
|| /^192\.168\./.test(host)
|| /^172\.(1[6-9]|2\d|3[01])\./.test(host)
|| host === 'localhost' || host === '127.0.0.1';
}
function populateOwnAnchor() {
const host = window.location.hostname;
const addr = `${host}:${FIPS_PORT}`;
document.getElementById('ownAddr').textContent = addr;
const reach = document.getElementById('ownReach');
if (/^100\./.test(host)) {
reach.innerHTML = 'This is a Tailscale address — reachable by any node on your tailnet, including over the internet.';
} else if (isPrivateLan(host)) {
reach.innerHTML = '⚠ This is a private LAN address — it only works for nodes on the same local network. For a node across the internet, share this nodes Tailscale (100.x) or public IP with UDP 8668 reachable, or have both nodes use a common public anchor instead.';
} else {
reach.innerHTML = 'This looks like a public address — reachable over the internet if UDP 8668 is open/forwarded to this node.';
}
}
document.querySelectorAll('[data-copy]').forEach(btn => {
btn.addEventListener('click', async () => {
const text = document.getElementById(btn.dataset.copy)?.textContent || '';
if (!text || text === '—') return;
try {
await navigator.clipboard.writeText(text);
notice('copyNotice', 'good', 'Copied.');
setTimeout(() => notice('copyNotice', '', ''), 1500);
} catch {
notice('copyNotice', 'error', `Copy failed — select manually: ${escapeHtml(text)}`);
}
});
});
// initial load
populateOwnAnchor();
loadStatus();
loadAnchors();
</script>
</body>
</html>

33
docker/fips-ui/nginx.conf Normal file
View File

@ -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;
}
}