From 7b972dc04998d26981176d5be683d2a72827a060 Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Mon, 25 May 2026 17:46:52 -0400 Subject: [PATCH] v0.4.4: Settings tab, API reference, ISO category, branding, disk space Settings: - New top-level Settings tab. Carries a placeholder for the planned LDAP / OIDC / user-management work, the new branding controls, and the API reference at the bottom. - Custom logo upload (PNG/SVG/JPEG/WebP/GIF up to 2 MB) replaces the bundled brand mark via /assets/logo.svg; bytes live at /branding/ and survive restart. The original "OpenPXE v" pins to the sidebar footer for support. - API reference rendered from a new GET /api/docs into a per-method coloured pill list grouped by area. ISO category (Storage): - New IsoCategory { Os, Tools } on IsoMeta with PUT /api/isos/:id/category. Storage table's Type cell becomes a dropdown; selecting Tools moves the ISO into the Tools submenu next to memtest / shell / NIC info and removes it from the OS Installers family submenu. Family detection still drives BIOS/UEFI / kernel args; only the menu placement changes. Storage telemetry: - New IsoStore::disk_usage (libc::statvfs, lives in iso-store so the http-api crate stays #![forbid(unsafe_code)]) and GET /api/storage/disk. The Storage tab now shows free/used/total for the volume hosting the ISO directory with an 80%/95% colour ramp. UI polish: - Brand block in the sidebar now matches the topbar height exactly, so the divider runs straight across the top of the app rather than stepping; version label moved out of the brand and pinned to the sidebar footer ("OpenPXE v0.4.4"). - Light-mode terminal: --terminal-bg + per-level text colours track the active theme rather than being hard-coded dark. - About: lead paragraph spans the full content width; new Docs row links to https://openpxe.com/. 106 tests passing (was 89 in v0.4.1, +17 across branding unit tests and new integration coverage for category / disk / docs / branding). cargo clippy --workspace --all-targets clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 16 +- Cargo.toml | 2 +- crates/core/src/branding.rs | 339 +++++++++++++++++++++++++++++ crates/core/src/lib.rs | 2 + crates/http-api/src/app.rs | 337 +++++++++++++++++++++++++++- crates/http-api/src/ipxe_script.rs | 57 ++++- crates/http-api/src/state.rs | 7 +- crates/http-api/tests/full_flow.rs | 205 +++++++++++++++++ crates/iso-store/src/lib.rs | 3 +- crates/iso-store/src/store.rs | 99 +++++++++ crates/openpxe/src/main.rs | 2 + crates/webui/src/app.css | 131 +++++++++-- crates/webui/src/app.js | 218 ++++++++++++++++++- crates/webui/src/index.html | 12 +- 14 files changed, 1385 insertions(+), 45 deletions(-) create mode 100644 crates/core/src/branding.rs diff --git a/Cargo.lock b/Cargo.lock index 767260e..f38d712 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1015,7 +1015,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openpxe" -version = "0.4.1" +version = "0.4.4" dependencies = [ "anyhow", "axum", @@ -1037,7 +1037,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.4.1" +version = "0.4.4" dependencies = [ "anyhow", "parking_lot", @@ -1055,7 +1055,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.4.1" +version = "0.4.4" dependencies = [ "anyhow", "bytes", @@ -1069,7 +1069,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.4.1" +version = "0.4.4" dependencies = [ "anyhow", "axum", @@ -1098,7 +1098,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.4.1" +version = "0.4.4" dependencies = [ "openpxe-core", "rust-embed", @@ -1108,7 +1108,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.4.1" +version = "0.4.4" dependencies = [ "anyhow", "bcrypt", @@ -1131,7 +1131,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.4.1" +version = "0.4.4" dependencies = [ "anyhow", "bytes", @@ -1145,7 +1145,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.4.1" +version = "0.4.4" [[package]] name = "parking_lot" diff --git a/Cargo.toml b/Cargo.toml index 64b813f..c94d7c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.4.1" +version = "0.4.4" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/core/src/branding.rs b/crates/core/src/branding.rs new file mode 100644 index 0000000..68041e0 --- /dev/null +++ b/crates/core/src/branding.rs @@ -0,0 +1,339 @@ +//! Operator-controlled branding overrides. +//! +//! The browser tab's logo (`/assets/logo.svg`) defaults to the bundled +//! rainbow-horizon mark. Operators who deploy OpenPXE behind their own +//! branding can upload a replacement that lives at +//! `/branding/logo.` and is served in preference to the +//! bundled SVG when present. Borrowed-from-FleetDM: tenant chrome, same +//! product. +//! +//! Storage policy mirrors `HostBindings` / `BootLog`: in-memory cache is +//! authoritative for the current process, disk is the source of truth on +//! restart, and a corrupt cache file falls back to the bundled default +//! rather than blocking startup. + +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Allowed MIME types for an uploaded logo. We deliberately keep this +/// narrow — anything that can be ``'d into the brand +/// block, no scripts. SVG carries the obvious XSS risk for raw inline +/// HTML; we always serve the bytes as a separate asset with a strict +/// content-type rather than inlining, so SVG is safe. +pub const ALLOWED_LOGO_MIMES: &[&str] = &[ + "image/svg+xml", + "image/png", + "image/jpeg", + "image/webp", + "image/gif", +]; + +/// Disk cap for an uploaded logo. PXE WebUIs are operator-facing — even +/// a generous 2 MB cap is comfortable for any reasonable brand mark and +/// puts a clear bound on memory + serialization cost. +pub const MAX_LOGO_BYTES: usize = 2 * 1024 * 1024; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct Inner { + /// File name (relative to the branding dir) for the active logo, if + /// any. Always under `/branding/`; never an absolute path + /// from the operator. + logo_filename: Option, + /// MIME of the active logo, mirroring `logo_filename`. Cached here + /// so the HTTP layer can set Content-Type without re-sniffing. + logo_mime: Option, +} + +/// In-memory + on-disk override registry. Cheap to clone; locks are +/// brief. The `branding.json` cache lives alongside the active asset +/// inside `/branding/`. +#[derive(Debug, Clone)] +pub struct BrandingStore { + /// Root directory: `/branding/`. Created on first write. + dir: Arc, + inner: Arc>, +} + +impl BrandingStore { + /// Load (or initialise empty) from `/branding/`. Tolerates + /// missing directories, partial state, and corrupt JSON — a bad + /// cache should never block PXE for the network. + #[must_use] + pub fn load_or_default(work_dir: &Path) -> Self { + let dir = work_dir.join("branding"); + let path = dir.join("branding.json"); + let mut inner = Inner::default(); + if let Ok(text) = std::fs::read_to_string(&path) { + match serde_json::from_str::(&text) { + Ok(parsed) => { + // Sanity: if the JSON says we have a logo but the + // file is gone, clear the in-memory pointer so + // /assets/logo.svg falls back to the bundled SVG + // rather than 500ing on a missing file. + if let Some(name) = parsed.logo_filename.as_deref() { + if dir.join(name).is_file() { + inner = parsed; + } else { + tracing::warn!( + target: "openpxe::branding", + file = %name, + "branding.json points at missing file; clearing" + ); + } + } else { + inner = parsed; + } + } + Err(e) => { + tracing::warn!( + target: "openpxe::branding", + "branding.json present but unreadable ({e}); starting empty" + ); + } + } + } + Self { + dir: Arc::new(dir), + inner: Arc::new(RwLock::new(inner)), + } + } + + /// Absolute path to the active logo, if one is set and present on + /// disk. `None` means the HTTP layer should serve the bundled SVG. + #[must_use] + pub fn logo_path(&self) -> Option { + let g = self.inner.read(); + g.logo_filename.as_deref().map(|n| self.dir.join(n)) + } + + /// MIME of the active logo, if any. The HTTP layer pairs this with + /// the bytes returned by [`Self::logo_path`]. + #[must_use] + pub fn logo_mime(&self) -> Option { + self.inner.read().logo_mime.clone() + } + + /// Replace the active logo. Returns the chosen on-disk filename so + /// the caller can echo it back in the API response. Old logos are + /// removed best-effort. + pub fn set_logo(&self, mime: &str, ext: &str, bytes: &[u8]) -> std::io::Result { + std::fs::create_dir_all(self.dir.as_path())?; + // Single canonical filename per upload — overwriting the old one + // (after clearing it) keeps the directory tidy and avoids any + // path-traversal concern: the operator never supplies the name. + let safe_ext = sanitize_ext(ext); + let filename = format!("logo.{safe_ext}"); + let final_path = self.dir.join(&filename); + // Atomic write: tmp -> rename. Guarantees the file is either + // entirely the old logo or entirely the new one. + let tmp = final_path.with_extension(format!("{safe_ext}.tmp")); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, &final_path)?; + // Clean up any sibling logo. so there's exactly one + // canonical file at any time. + if let Ok(entries) = std::fs::read_dir(self.dir.as_path()) { + for e in entries.flatten() { + let p = e.path(); + let name = p + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(""); + if name.starts_with("logo.") && name != filename { + let _ = std::fs::remove_file(&p); + } + } + } + + { + let mut g = self.inner.write(); + g.logo_filename = Some(filename.clone()); + g.logo_mime = Some(mime.to_string()); + } + self.persist(); + tracing::info!( + target: "openpxe::branding", + file = %filename, mime = %mime, size = bytes.len(), + "custom logo installed" + ); + Ok(filename) + } + + /// Drop the override and return to the bundled SVG. + pub fn clear_logo(&self) -> std::io::Result<()> { + let removed = { + let mut g = self.inner.write(); + let removed = g.logo_filename.take(); + g.logo_mime = None; + removed + }; + if let Some(name) = removed { + let p = self.dir.join(&name); + let _ = std::fs::remove_file(&p); + tracing::info!(target: "openpxe::branding", file = %name, "custom logo cleared"); + } + self.persist(); + Ok(()) + } + + /// Convenience: true if a custom logo is configured. Surfaces on + /// `/api/status` so the WebUI can show "Custom logo: yes" without + /// fetching the asset itself. + #[must_use] + pub fn has_logo(&self) -> bool { + self.inner.read().logo_filename.is_some() + } + + fn persist(&self) { + let snap = self.inner.read().clone(); + let body = match serde_json::to_vec_pretty(&snap) { + Ok(b) => b, + Err(e) => { + tracing::warn!(target: "openpxe::branding", "serialize branding.json: {e}"); + return; + } + }; + if let Err(e) = std::fs::create_dir_all(self.dir.as_path()) { + tracing::warn!(target: "openpxe::branding", "mkdir branding/: {e}"); + return; + } + let path = self.dir.join("branding.json"); + let tmp = path.with_extension("json.tmp"); + if let Err(e) = std::fs::write(&tmp, body) { + tracing::warn!(target: "openpxe::branding", "write branding.json tmp: {e}"); + return; + } + if let Err(e) = std::fs::rename(&tmp, &path) { + tracing::warn!(target: "openpxe::branding", "rename branding.json: {e}"); + } + } +} + +/// Trim arbitrary operator-supplied extension strings to a small, safe +/// alphanumeric form. Anything weird collapses to `bin`. We never let +/// the extension affect the path beyond the final segment of `logo.`. +fn sanitize_ext(ext: &str) -> String { + let lc: String = ext + .chars() + .filter(char::is_ascii_alphanumeric) + .map(|c| c.to_ascii_lowercase()) + .collect(); + if lc.is_empty() || lc.len() > 5 { + "bin".into() + } else { + lc + } +} + +/// Pick a safe filesystem extension from a MIME type. Returns `None` +/// if the MIME isn't on the [`ALLOWED_LOGO_MIMES`] allowlist. +#[must_use] +pub fn ext_for_mime(mime: &str) -> Option<&'static str> { + match mime { + "image/svg+xml" => Some("svg"), + "image/png" => Some("png"), + "image/jpeg" => Some("jpg"), + "image/webp" => Some("webp"), + "image/gif" => Some("gif"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn empty_after_load_when_no_branding_dir() { + let dir = tempdir().unwrap(); + let b = BrandingStore::load_or_default(dir.path()); + assert!(!b.has_logo()); + assert!(b.logo_path().is_none()); + assert!(b.logo_mime().is_none()); + } + + #[test] + fn set_clear_round_trip_persists() { + let dir = tempdir().unwrap(); + let b = BrandingStore::load_or_default(dir.path()); + let name = b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap(); + assert_eq!(name, "logo.png"); + assert!(b.has_logo()); + assert_eq!(b.logo_mime().as_deref(), Some("image/png")); + let p = b.logo_path().unwrap(); + assert!(p.is_file()); + + // Re-open and confirm the override survives a restart. + drop(b); + let b2 = BrandingStore::load_or_default(dir.path()); + assert!(b2.has_logo()); + assert_eq!(b2.logo_mime().as_deref(), Some("image/png")); + + // Clear; the file goes away and has_logo flips off. + b2.clear_logo().unwrap(); + assert!(!b2.has_logo()); + assert!(!p.exists()); + } + + #[test] + fn replacing_logo_removes_old_extension_sibling() { + // PNG then SVG; only the SVG should remain on disk. + let dir = tempdir().unwrap(); + let b = BrandingStore::load_or_default(dir.path()); + b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap(); + b.set_logo("image/svg+xml", "svg", br#""#).unwrap(); + let entries: Vec<_> = std::fs::read_dir(dir.path().join("branding")) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned())) + .collect(); + assert!(entries.iter().any(|n| n == "logo.svg"), "got {entries:?}"); + assert!(!entries.iter().any(|n| n == "logo.png"), "stale PNG left over: {entries:?}"); + } + + #[test] + fn sanitize_ext_strips_separators_and_path_chars() { + assert_eq!(sanitize_ext("svg"), "svg"); + // Path separators and non-alphanumerics filter out, leaving just + // letters. The remaining "etcpasswd" exceeds the 5-char cap so + // it collapses to `bin` rather than producing `etcpa`. + assert_eq!(sanitize_ext("../etc/passwd"), "bin"); + // Short alphanumeric strip-through stays itself. + assert_eq!(sanitize_ext("../svg"), "svg"); + assert_eq!(sanitize_ext(""), "bin"); + assert_eq!(sanitize_ext("PNG"), "png"); + // Anything past five chars is suspicious — collapse to `bin`. + assert_eq!(sanitize_ext("svgvvvv"), "bin"); + } + + #[test] + fn missing_file_referenced_by_json_resolves_to_empty() { + // If the operator nukes the file out from under the JSON cache, + // we should silently fall back to no-override rather than + // hanging on to a bogus path. + let dir = tempdir().unwrap(); + let brand_dir = dir.path().join("branding"); + std::fs::create_dir_all(&brand_dir).unwrap(); + // Hand-write a branding.json claiming logo.png exists. + let inner = Inner { + logo_filename: Some("logo.png".into()), + logo_mime: Some("image/png".into()), + }; + std::fs::write( + brand_dir.join("branding.json"), + serde_json::to_vec_pretty(&inner).unwrap(), + ) + .unwrap(); + let b = BrandingStore::load_or_default(dir.path()); + assert!(!b.has_logo(), "should fall back when referenced file is missing"); + } + + #[test] + fn ext_for_mime_only_accepts_known_types() { + assert_eq!(ext_for_mime("image/png"), Some("png")); + assert_eq!(ext_for_mime("image/svg+xml"), Some("svg")); + assert_eq!(ext_for_mime("application/octet-stream"), None); + assert_eq!(ext_for_mime("text/html"), None); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index f821663..dc11f17 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,6 +4,7 @@ pub mod arch; pub mod boot_log; +pub mod branding; pub mod client; pub mod config; pub mod error; @@ -15,6 +16,7 @@ pub mod settings; pub use arch::{ClientArch, FirmwareClass}; pub use boot_log::{BootEvent, BootLog}; +pub use branding::{ext_for_mime, BrandingStore, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES}; pub use client::{ClientEvent, ClientRegistry, ClientSnapshot}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; pub use error::{Error, Result}; diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index 52c5d1e..baa4643 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -29,9 +29,11 @@ use axum::{ routing::{delete, get, post, put}, Json, Router, }; -use openpxe_core::{BootEvent, ClientEvent, Error, Settings}; +use openpxe_core::{ + ext_for_mime, BootEvent, ClientEvent, Error, Settings, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES, +}; use openpxe_ipxe_assets::asset_bytes; -use openpxe_iso_store::{IsoMeta, NfsAddRequest}; +use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest}; use serde::Deserialize; use serde_json::json; use std::net::SocketAddr; @@ -73,6 +75,28 @@ pub fn build_router(state: AppState) -> Router { "/api/isos/:id/password", axum::routing::put(api_set_iso_password).delete(api_clear_iso_password), ) + // v0.4.4: per-ISO menu category (Os / Tools). Drives whether the + // image appears under Linux/Windows Installers (default) or in + // the Tools submenu next to memtest / shell / NIC info. + .route( + "/api/isos/:id/category", + axum::routing::put(api_set_iso_category), + ) + // v0.4.4: filesystem free-space telemetry for the ISO directory's + // volume — surfaced as a small card on the Storage tab so the + // operator knows when they're about to run out of room. + .route("/api/storage/disk", get(api_storage_disk)) + // v0.4.4: operator-controlled WebUI branding overrides + // (custom logo). Multipart upload to POST; DELETE clears. + .route( + "/api/branding/logo", + post(api_branding_upload).delete(api_branding_clear), + ) + // v0.4.4: self-rendered API reference, served as JSON so the UI + // can format it consistently with the rest of the chrome. Lives + // under the Settings tab — operators chasing an integration get + // it in-product instead of having to fetch the OpenAPI YAML. + .route("/api/docs", get(api_docs)) .route("/api/clients", get(api_list_clients)) .route("/api/status", get(api_status)) .route("/api/settings", get(api_get_settings).put(api_put_settings)) @@ -143,7 +167,43 @@ async fn ui_css() -> Response { .into_response() } -async fn ui_logo() -> Response { +async fn ui_logo(State(state): State) -> Response { + // Custom override first; fall back to the bundled rainbow-horizon + // SVG. We resolve the override on each request rather than caching + // because operators may upload/clear from the Settings tab while the + // server is live, and we want them to see their change immediately + // without bouncing the binary. + if let Some(path) = state.branding.logo_path() { + let mime = state + .branding + .logo_mime() + .unwrap_or_else(|| "image/svg+xml".to_string()); + match tokio::fs::read(&path).await { + Ok(bytes) => { + let ct = match HeaderValue::from_str(&mime) { + Ok(v) => v, + Err(_) => HeaderValue::from_static("application/octet-stream"), + }; + return ( + [ + (header::CONTENT_TYPE, ct), + ( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache, max-age=0"), + ), + ], + bytes, + ) + .into_response(); + } + Err(e) => { + tracing::warn!( + target: "openpxe::http::branding", + error = %e, "failed to read custom logo; falling back to bundled" + ); + } + } + } ( [( header::CONTENT_TYPE, @@ -298,7 +358,7 @@ async fn boot_sub( "_local" => render_local_hdd(base), "_linux_menu" => render_family_menu(&isos, base, false), "_windows_menu" => render_family_menu(&isos, base, true), - "_tools_menu" => render_tools_menu(base), + "_tools_menu" => render_tools_menu(&isos, base), "_util" => render_util(base), "_shell" => render_shell(base), "_nic" => render_nic_info(base), @@ -620,6 +680,274 @@ async fn api_clear_iso_password( } } +// ─── ISO category (OS / Tools) ──────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct SetCategoryBody { + /// `"os"` or `"tools"` — matches `IsoCategory`'s snake_case serde + /// repr. Anything else returns 400 with the allowed set spelled out. + category: String, +} + +async fn api_set_iso_category( + State(state): State, + AxumPath(id): AxumPath, + Json(body): Json, +) -> Response { + let cat = match body.category.as_str() { + "os" => IsoCategory::Os, + "tools" => IsoCategory::Tools, + other => { + return ( + StatusCode::BAD_REQUEST, + format!("unknown category '{other}'; expected one of: os, tools"), + ) + .into_response(); + } + }; + match state.iso_store.set_category(&id, cat).await { + Ok(meta) => { + tracing::info!( + target: "openpxe::http::iso", + iso = %id, category = ?cat, + "iso category updated" + ); + (StatusCode::OK, Json(meta)).into_response() + } + Err(Error::Invalid(msg)) => (StatusCode::NOT_FOUND, msg).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), + } +} + +// ─── Disk space (Storage tab) ───────────────────────────────────────────── + +async fn api_storage_disk(State(state): State) -> Json { + // statvfs on the directory that holds the ISO store. We deliberately + // don't walk the directory ourselves — the kernel already tracks + // free/total at the volume level and that's the only number the + // operator actually cares about for "do I have room for one more + // 5 GB ISO?". `statvfs` itself lives in iso-store to keep the + // http-api crate free of `unsafe`. + let dir = state.iso_store.iso_dir(); + let (total, available) = state.iso_store.disk_usage().unwrap_or((0, 0)); + let used = total.saturating_sub(available); + Json(json!({ + "path": dir.to_string_lossy(), + "total_bytes": total, + "available_bytes": available, + "used_bytes": used, + })) +} + +// ─── Branding (custom logo) ─────────────────────────────────────────────── + +async fn api_branding_upload( + State(state): State, + mut multipart: Multipart, +) -> Response { + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + if name != "file" && name != "logo" { + continue; + } + let mime = field.content_type().unwrap_or("").to_string(); + if !ALLOWED_LOGO_MIMES.iter().any(|m| *m == mime) { + return ( + StatusCode::BAD_REQUEST, + format!( + "unsupported MIME '{mime}'. Allowed: {}", + ALLOWED_LOGO_MIMES.join(", ") + ), + ) + .into_response(); + } + // Pre-read into memory so we can enforce the size cap before + // hitting disk. Logos are tiny by definition. + let bytes = match field.bytes().await { + Ok(b) => b, + Err(e) => { + return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response() + } + }; + if bytes.len() > MAX_LOGO_BYTES { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + format!( + "logo too large ({} bytes, max {})", + bytes.len(), + MAX_LOGO_BYTES + ), + ) + .into_response(); + } + let Some(ext) = ext_for_mime(&mime) else { + return (StatusCode::BAD_REQUEST, "unsupported MIME").into_response(); + }; + match state.branding.set_logo(&mime, ext, &bytes) { + Ok(filename) => { + return ( + StatusCode::OK, + Json(json!({ + "filename": filename, + "mime": mime, + "size_bytes": bytes.len(), + })), + ) + .into_response() + } + Err(e) => { + return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response() + } + } + } + (StatusCode::BAD_REQUEST, "no 'file' part").into_response() +} + +async fn api_branding_clear(State(state): State) -> Response { + match state.branding.clear_logo() { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), + } +} + +// ─── API reference (Settings → bottom) ──────────────────────────────────── + +async fn api_docs() -> Json { + // Hand-curated rather than introspected from axum because: + // 1. axum's runtime route table doesn't carry parameter docs; + // 2. the WebUI surfaces this as a readable list, not as an OpenAPI + // spec — readers are operators chasing an integration, not + // machines. + // Keep this in lockstep with `build_router` when adding endpoints. + Json(json!({ + "version": env!("CARGO_PKG_VERSION"), + "groups": [ + { + "name": "Status & health", + "endpoints": [ + {"method": "GET", "path": "/healthz", + "summary": "Liveness — always 200 OK while the HTTP task is alive."}, + {"method": "GET", "path": "/readyz", + "summary": "Readiness — 200 only when iPXE binaries are bundled and the ISO directory is readable."}, + {"method": "GET", "path": "/api/status", + "summary": "Dashboard JSON — versions, counts, settings snapshot, uptime."}, + {"method": "GET", "path": "/metrics", + "summary": "Prometheus text exposition (counters + gauges)."}, + ], + }, + { + "name": "ISO images", + "endpoints": [ + {"method": "GET", "path": "/api/isos", + "summary": "List ISOs (local + NFS) with size, family, boot entries, category."}, + {"method": "POST", "path": "/api/isos", + "summary": "Legacy single-shot multipart upload. Prefer /api/uploads for big files."}, + {"method": "DELETE", "path": "/api/isos/:id", + "summary": "Delete a local ISO and its sidecar metadata."}, + {"method": "PUT", "path": "/api/isos/:id/password", + "summary": "Set or update an ISO's boot password (bcrypt-hashed; plaintext never stored)."}, + {"method": "DELETE", "path": "/api/isos/:id/password", + "summary": "Clear an ISO's boot password."}, + {"method": "PUT", "path": "/api/isos/:id/category", + "summary": "Set the menu category. Body: { \"category\": \"os\" | \"tools\" }."}, + ], + }, + { + "name": "Chunked uploads", + "endpoints": [ + {"method": "POST", "path": "/api/uploads", + "summary": "Begin a chunked upload session. Body: { \"filename\", \"size_bytes\" }."}, + {"method": "PUT", "path": "/api/uploads/:upload_id", + "summary": "Append a chunk. Headers: x-openpxe-upload-offset, x-openpxe-upload-complete."}, + {"method": "DELETE", "path": "/api/uploads/:upload_id", + "summary": "Abort a chunked upload session and remove the .partial file."}, + ], + }, + { + "name": "NFS shares", + "endpoints": [ + {"method": "GET", "path": "/api/nfs", + "summary": "List configured NFS shares with mount state and iso counts."}, + {"method": "POST", "path": "/api/nfs", + "summary": "Mount an NFS share. Body: { server, export, version, read_only }."}, + {"method": "DELETE", "path": "/api/nfs/:id", + "summary": "Unmount a share and drop its entries from the ISO store."}, + {"method": "POST", "path": "/api/nfs/:id/scan", + "summary": "Re-walk a mounted share for ISOs."}, + ], + }, + { + "name": "Network", + "endpoints": [ + {"method": "GET", "path": "/api/network", + "summary": "Detected NIC, server IP, subnet, gateway, advertised base URL."}, + {"method": "PUT", "path": "/api/network", + "summary": "Update the informational DNS server hint (does not run DNS)."}, + ], + }, + { + "name": "Settings", + "endpoints": [ + {"method": "GET", "path": "/api/settings", + "summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."}, + {"method": "PUT", "path": "/api/settings", + "summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."}, + {"method": "POST", "path": "/api/branding/logo", + "summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."}, + {"method": "DELETE", "path": "/api/branding/logo", + "summary": "Remove the custom logo and revert to the bundled mark."}, + {"method": "GET", "path": "/api/docs", + "summary": "This API reference."}, + ], + }, + { + "name": "Storage telemetry", + "endpoints": [ + {"method": "GET", "path": "/api/storage/disk", + "summary": "Free / used / total bytes for the volume hosting the ISO directory."}, + ], + }, + { + "name": "Queued Deployment", + "endpoints": [ + {"method": "GET", "path": "/api/queue", + "summary": "List queue entries (waiting + assigned)."}, + {"method": "POST", "path": "/api/queue/assign", + "summary": "Assign a target image to queued clients. Body: { target, entry_ids }."}, + {"method": "DELETE", "path": "/api/queue/:entry_id", + "summary": "Release a queue entry without assigning."}, + ], + }, + { + "name": "Hosts & boot log", + "endpoints": [ + {"method": "GET", "path": "/api/hosts", + "summary": "List per-MAC boot bindings."}, + {"method": "POST", "path": "/api/hosts", + "summary": "Pin a MAC to a boot target. Body: { mac, target, label }."}, + {"method": "DELETE", "path": "/api/hosts/:mac", + "summary": "Remove a binding."}, + {"method": "GET", "path": "/api/boot-log", + "summary": "Ring of recent boot events (timestamp, mac, ip, target)."}, + ], + }, + { + "name": "Operator console", + "endpoints": [ + {"method": "GET", "path": "/api/clients", + "summary": "Live PXE-client registry — MAC, last IP, arch, events."}, + {"method": "GET", "path": "/api/log/recent", + "summary": "Ring of recent server log lines for the Terminal tab."}, + {"method": "GET", "path": "/api/log/stream", + "summary": "Server-Sent Events stream of log lines."}, + {"method": "POST", "path": "/api/terminal", + "summary": "Run a whitelisted operator command. Body: { command }."}, + ], + }, + ], + })) +} + async fn api_upload_iso(State(state): State, mut multipart: Multipart) -> Response { // Walk multipart parts until we find the file. Each branch logs so an // operator chasing a "stuck" upload in the Terminal tab can see @@ -979,6 +1307,7 @@ async fn api_status(State(state): State) -> Json { "nfs_count": nfs.len(), "nfs_active": nfs_active, "host_bindings": state.hosts.len(), + "custom_logo": state.branding.has_logo(), "uptime_secs": uptime_secs, "started_at": state.started_at, "nic_name": state.nic_name, diff --git a/crates/http-api/src/ipxe_script.rs b/crates/http-api/src/ipxe_script.rs index 77ce2b6..b5c0c26 100644 --- a/crates/http-api/src/ipxe_script.rs +++ b/crates/http-api/src/ipxe_script.rs @@ -149,6 +149,13 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> if !filter(iso.introspection.family) { continue; } + // v0.4.4: ISOs the operator flipped to the Tools category move + // out of the OS installer submenus entirely — they only appear + // under Tools. Without this filter the operator would see the + // same ISO in both menus. + if matches!(iso.category, openpxe_iso_store::IsoCategory::Tools) { + continue; + } for entry in &iso.boot_entries { let size_label = fmt_size_mib(iso.size_bytes); let key = hotkey_for_index(count); @@ -210,15 +217,52 @@ fn hotkey_for_index(i: usize) -> String { } } -/// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware. +/// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware, +/// plus any ISOs the operator flipped to [`IsoCategory::Tools`] in the +/// Storage tab. The category-Tools ISOs render first so frequently used +/// recovery / hardware tools are reachable with a single number key +/// before the built-in shortcuts. #[must_use] -pub fn render_tools_menu(base_url: &str) -> String { +pub fn render_tools_menu(isos: &[IsoMeta], base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, ":menu"); let _ = writeln!(s, "menu OpenPXE - Tools"); + + // Operator-categorized tool ISOs (hotkeys 1..9), each chained the + // same way as a per-family menu pick — through the boot-entry id + // route, carrying `?mac=${mac}` for Host log attribution. + let mut count = 0; + for iso in isos { + if !matches!(iso.category, openpxe_iso_store::IsoCategory::Tools) { + continue; + } + for entry in &iso.boot_entries { + let size_label = fmt_size_mib(iso.size_bytes); + let key = hotkey_for_index(count); + let lock = if iso.is_password_protected() { + "*" + } else { + " " + }; + let _ = writeln!( + s, + "item {}{} {}[{:>6}] {}", + key, + entry.id, + lock, + size_label, + escape_label(&entry.title), + ); + count += 1; + } + } + if count > 0 { + let _ = writeln!(s, "item --gap"); + } + let _ = writeln!(s, "item --key u util Utilities (memtest, ...)"); let _ = writeln!(s, "item --key s shell OpenPXE Shell"); let _ = writeln!(s, "item --key n nic Network Card Info"); @@ -252,7 +296,12 @@ pub fn render_tools_menu(base_url: &str) -> String { s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu" ); - let _ = writeln!(s, "goto menu"); + // Fall-through for category-Tools ISO ids — same as the family + // submenu, carrying `?mac=${mac}` for the boot log. + let _ = writeln!( + s, + "chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu" + ); s } @@ -531,7 +580,7 @@ mod password_tests { let settings = Settings::default(); let scripts = [ render_menu(&[], &settings, "http://10.0.0.5"), - render_tools_menu("http://10.0.0.5"), + render_tools_menu(&[], "http://10.0.0.5"), render_local_hdd("http://10.0.0.5"), render_util("http://10.0.0.5"), render_shell("http://10.0.0.5"), diff --git a/crates/http-api/src/state.rs b/crates/http-api/src/state.rs index 79b362c..a9ff754 100644 --- a/crates/http-api/src/state.rs +++ b/crates/http-api/src/state.rs @@ -1,6 +1,7 @@ use crate::uploads::UploadSessions; use openpxe_core::{ - BootLog, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore, + BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, + SettingsStore, }; use openpxe_iso_store::{IsoStore, NfsManager, SmbManager}; use std::sync::Arc; @@ -20,6 +21,10 @@ pub struct AppState { /// every `/boot/.ipxe` chain that goes on to serve a script /// (i.e. an image actually starting to install on a machine). pub boot_log: BootLog, + /// Operator-controlled UI overrides (custom logo). When the + /// operator hasn't uploaded anything, the WebUI serves the bundled + /// rainbow-horizon mark. + pub branding: BrandingStore, /// Lock-free metrics counters surfaced at `/metrics` in Prometheus /// text format. Cheap to clone (handles to atomics). pub metrics: Metrics, diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index c06be51..b71bd78 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -99,6 +99,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { let log_bus = LogBus::new(64); let hosts = HostBindings::load_or_default(dir.path()); let boot_log = openpxe_core::BootLog::load_or_default(dir.path()); + let branding = openpxe_core::BrandingStore::load_or_default(dir.path()); let metrics = Metrics::new(); let state = AppState { iso_store, @@ -107,6 +108,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { settings, hosts, boot_log, + branding, metrics, smb: None, nfs, @@ -1201,3 +1203,206 @@ async fn chunked_upload_rejects_offset_mismatch_without_advancing() { let text = String::from_utf8(body.to_vec()).unwrap(); assert!(text.contains("expected offset 0"), "got: {text}"); } + +#[tokio::test] +async fn iso_category_switch_moves_entry_between_menus() { + // OS (default): appears in Linux Installers submenu and not in Tools. + // After flipping to Tools: gone from Linux, present under Tools. + let (state, _dir) = build_state().await; + let app = build_router(state); + + let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); + let upload = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/isos") + .header("content-type", ct) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(upload.status(), StatusCode::CREATED); + + let (_, linux_before) = get(&app, "/boot/_linux_menu.ipxe").await; + let linux_before = String::from_utf8(linux_before).unwrap(); + assert!( + linux_before.contains("fake-alpine-linux"), + "expected entry in Linux submenu (default OS):\n{linux_before}" + ); + let (_, tools_before) = get(&app, "/boot/_tools_menu.ipxe").await; + let tools_before = String::from_utf8(tools_before).unwrap(); + assert!( + !tools_before.contains("fake-alpine-linux"), + "OS-category ISO shouldn't appear in Tools yet:\n{tools_before}" + ); + + // Flip to Tools. + let (s, _) = put_json( + &app, + "/api/isos/fake-alpine/category", + r#"{"category":"tools"}"#, + ) + .await; + assert_eq!(s, StatusCode::OK); + + let (_, linux_after) = get(&app, "/boot/_linux_menu.ipxe").await; + let linux_after = String::from_utf8(linux_after).unwrap(); + assert!( + !linux_after.contains("fake-alpine-linux"), + "Tools-category ISO should NOT appear in Linux submenu:\n{linux_after}" + ); + let (_, tools_after) = get(&app, "/boot/_tools_menu.ipxe").await; + let tools_after = String::from_utf8(tools_after).unwrap(); + assert!( + tools_after.contains("fake-alpine-linux"), + "Tools-category ISO should appear in Tools submenu:\n{tools_after}" + ); +} + +#[tokio::test] +async fn iso_category_unknown_value_returns_400() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/isos") + .header("content-type", ct) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + let (s, body) = put_json( + &app, + "/api/isos/fake-alpine/category", + r#"{"category":"gibberish"}"#, + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let text = std::str::from_utf8(&body).unwrap(); + assert!(text.contains("unknown category"), "got: {text}"); +} + +#[tokio::test] +async fn iso_category_unknown_id_returns_404() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, _) = put_json( + &app, + "/api/isos/does-not-exist/category", + r#"{"category":"tools"}"#, + ) + .await; + assert_eq!(s, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn storage_disk_endpoint_reports_volume_stats() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, body) = get(&app, "/api/storage/disk").await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + // statvfs always returns something on the tempdir filesystem; check + // that the shape is sane (total >= used >= 0 and total >= available). + assert!(v["total_bytes"].as_u64().unwrap() > 0, "got {v}"); + let total = v["total_bytes"].as_u64().unwrap(); + let avail = v["available_bytes"].as_u64().unwrap(); + let used = v["used_bytes"].as_u64().unwrap(); + assert!(total >= avail, "{v}"); + assert!(total >= used, "{v}"); + assert!(v["path"].as_str().unwrap().contains("isos"), "got {v}"); +} + +#[tokio::test] +async fn api_docs_lists_known_endpoints() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, body) = get(&app, "/api/docs").await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let groups = v["groups"].as_array().expect("groups array"); + assert!(!groups.is_empty()); + // Flatten the paths and confirm a handful of the routes that real + // operators will look up are documented. + let mut paths: Vec = Vec::new(); + for g in groups { + for ep in g["endpoints"].as_array().unwrap() { + paths.push(ep["path"].as_str().unwrap().into()); + } + } + for needle in [ + "/api/isos", + "/api/isos/:id/category", + "/api/storage/disk", + "/api/branding/logo", + "/api/boot-log", + "/metrics", + ] { + assert!( + paths.iter().any(|p| p == needle), + "expected {needle} in docs; got {paths:?}" + ); + } +} + +#[tokio::test] +async fn branding_clear_when_no_logo_is_no_content() { + // No-op clear should still 204 — it's not an error to revert to + // the default when there's nothing to revert from. + let (state, _dir) = build_state().await; + let app = build_router(state); + let res = app + .oneshot( + Request::builder() + .method("DELETE") + .uri("/api/branding/logo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn status_exposes_custom_logo_flag() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, body) = get(&app, "/api/status").await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + v["custom_logo"].as_bool(), + Some(false), + "fresh state has no custom logo: {v}" + ); +} + +async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec) { + let res = router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(path) + .header("content-type", "application/json") + .body(Body::from(body.to_owned())) + .unwrap(), + ) + .await + .unwrap(); + let status = res.status(); + let bytes = axum::body::to_bytes(res.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(); + (status, bytes) +} diff --git a/crates/iso-store/src/lib.rs b/crates/iso-store/src/lib.rs index ad8965d..051959b 100644 --- a/crates/iso-store/src/lib.rs +++ b/crates/iso-store/src/lib.rs @@ -28,6 +28,7 @@ pub use introspect::{DistroFamily, IntrospectionReport}; pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion}; pub use smb::{extract_windows_iso, SmbManager, SmbState}; pub use store::{ - generate_boot_entries_for, slugify_str, IsoMeta, IsoSource, IsoStore, UploadHandle, + generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, + UploadHandle, }; pub use windows::{WimPatcher, WinPatchState}; diff --git a/crates/iso-store/src/store.rs b/crates/iso-store/src/store.rs index 6899b65..29a68b6 100644 --- a/crates/iso-store/src/store.rs +++ b/crates/iso-store/src/store.rs @@ -31,6 +31,30 @@ pub enum IsoSource { }, } +/// Where the ISO lands in the PXE menu hierarchy. +/// +/// Auto-detected family (Debian, Windows, …) still drives BIOS/UEFI +/// behaviour and per-entry boot args, but the *menu placement* is +/// operator-controlled — an operator who's uploaded a TinyCore live ISO +/// to use as a recovery shim, or a SystemRescue image, can flip its +/// category to `Tools` so it lands next to memtest/shell instead of +/// under Linux Installers. +/// +/// Old `meta.json` files without this field deserialize as `Os`, which +/// matches v0.4.1 behaviour (everything goes under OS Installers). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IsoCategory { + /// "OS Installer" — routed via the auto-detected family into the + /// Linux / Windows installer submenus. + #[default] + Os, + /// "Tool" — surfaced under the Tools menu next to memtest, shell, + /// NIC info, etc. Family detection still decides BIOS/UEFI vs + /// wimboot vs sanboot at boot time. + Tools, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IsoMeta { /// Stable slug used in URLs (derived from the uploaded filename). @@ -56,6 +80,10 @@ pub struct IsoMeta { /// the common no-password case. #[serde(default, skip_serializing_if = "Option::is_none")] pub password_hash: Option, + /// Where the ISO sits in the PXE menu hierarchy — operator-controlled, + /// not driven by family detection. Defaults to [`IsoCategory::Os`]. + #[serde(default)] + pub category: IsoCategory, } impl IsoMeta { @@ -114,6 +142,7 @@ impl UploadHandle { boot_entries, source: IsoSource::Local, password_hash: None, + category: IsoCategory::default(), }; store.persist_meta(&meta).await?; store.insert(meta.clone()); @@ -318,6 +347,7 @@ impl IsoStore { boot_entries, source, password_hash: None, + category: IsoCategory::default(), }; self.inner.write().isos.insert(id, meta); } @@ -377,6 +407,47 @@ impl IsoStore { Ok(()) } + /// Flip an ISO's menu category. Persists to `meta.json` for local + /// ISOs; NFS-sourced ISOs keep the change in memory only (the next + /// re-scan would overwrite it anyway). + pub async fn set_category(&self, id: &str, category: IsoCategory) -> Result { + let updated = { + let mut g = self.inner.write(); + let m = g + .isos + .get_mut(id) + .ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?; + m.category = category; + m.clone() + }; + if matches!(updated.source, IsoSource::Local) { + self.persist_meta(&updated).await?; + } + Ok(updated) + } + + /// Absolute path to the directory holding local ISO uploads. Used + /// by the HTTP layer for the disk-space endpoint — the volume that + /// hosts this directory is what runs out of room first. + #[must_use] + pub fn iso_dir(&self) -> PathBuf { + self.iso_dir.as_path().to_path_buf() + } + + /// `(total_bytes, available_bytes)` for the filesystem hosting the + /// ISO directory. Returns `None` if `statvfs` fails (read-only + /// filesystem with no quota, mount disappeared, …) — callers + /// should treat that as "unknown" rather than zero. + /// + /// Lives here rather than the HTTP crate because `http-api`'s + /// `#![forbid(unsafe_code)]` rules out the libc FFI directly, and + /// because this is naturally an `IsoStore` question — the volume + /// of interest is whatever's hosting the iso dir. + #[must_use] + pub fn disk_usage(&self) -> Option<(u64, u64)> { + disk_usage_for(self.iso_dir.as_path()) + } + /// Verify a candidate password against the stored bcrypt hash. /// Returns: /// - `Ok(true)` — match (or the ISO has no password set; boot is open) @@ -393,6 +464,33 @@ impl IsoStore { } } +/// Resolve `(total, available)` bytes for the filesystem hosting `path`. +/// Returns `None` if `statvfs` fails. +#[allow(unsafe_code)] +fn disk_usage_for(path: &std::path::Path) -> Option<(u64, u64)> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + let c = CString::new(path.as_os_str().as_bytes()).ok()?; + // SAFETY: `statvfs` is repr(C); a zeroed value is a valid initial + // state per POSIX. The FFI call writes every field we then read. + let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; + // SAFETY: `c` is a NUL-terminated C string pointing into a stack + // CString that outlives this call; `&mut stat` is a unique aligned + // pointer to a stack-local `statvfs`. The kernel writes through + // it but does not retain the pointer past return. + let rc = unsafe { libc::statvfs(c.as_ptr(), &raw mut stat) }; + if rc != 0 { + return None; + } + // Use f_frsize (fundamental block size). f_bsize is "preferred I/O + // block" and doesn't always match the unit f_blocks is denominated + // in — on some BSDs it would over-report by a factor of 8. + let frsize = stat.f_frsize as u64; + let total = stat.f_blocks as u64 * frsize; + let avail = stat.f_bavail as u64 * frsize; + Some((total, avail)) +} + fn slugify(filename: &str) -> String { let stem = Path::new(filename) .file_stem() @@ -551,6 +649,7 @@ mod tests { boot_entries: vec![], source: IsoSource::Local, password_hash: None, + category: IsoCategory::default(), } } diff --git a/crates/openpxe/src/main.rs b/crates/openpxe/src/main.rs index 20b6ac9..b61ac23 100644 --- a/crates/openpxe/src/main.rs +++ b/crates/openpxe/src/main.rs @@ -104,6 +104,7 @@ async fn main() -> anyhow::Result<()> { let settings = SettingsStore::load_or_default(&config.paths.work_dir); let hosts = HostBindings::load_or_default(&config.paths.work_dir); let boot_log = openpxe_core::BootLog::load_or_default(&config.paths.work_dir); + let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir); let metrics = Metrics::new(); // Build the SMB manager unconditionally — it starts/stops on the @@ -142,6 +143,7 @@ async fn main() -> anyhow::Result<()> { queue: queue.clone(), hosts: hosts.clone(), boot_log: boot_log.clone(), + branding: branding.clone(), metrics: metrics.clone(), smb: Some(smb.clone()), nfs: nfs.clone(), diff --git a/crates/webui/src/app.css b/crates/webui/src/app.css index 82b0dea..b4a36d9 100644 --- a/crates/webui/src/app.css +++ b/crates/webui/src/app.css @@ -54,7 +54,11 @@ --ok: #1f9b54; --border: #d8dde6; --border-soft: #e7eaf0; - --terminal-bg: #0d1219; /* terminal stays dark even in light mode */ + /* Light-mode terminal: the pane background and chrome track the rest + of the light theme. Per-level text colours below recolour-on-light + so log lines stay readable on a pale background — previously the + terminal was locked to dark and looked like a stuck panel. */ + --terminal-bg: #ffffff; --shadow-card: 0 1px 0 rgba(0,0,0,0.02), 0 6px 18px rgba(20,28,52,0.06); } @@ -87,14 +91,23 @@ code, kbd { font-family: var(--mono); font-size: 12.5px; border-right: 1px solid var(--border); display: flex; flex-direction: column; } +/* The brand block sits flush with the topbar so the sidebar+topbar reads + as one continuous bar across the top of the app, rather than a chunky + 2-line logo block plus a separate (smaller-typeface) page title. The + height/border-bottom match the topbar exactly so the divider runs + straight across without a step. */ .sidebar .brand { display: flex; align-items: center; gap: 12px; - padding: 14px 18px; + padding: 0 18px; + height: var(--topbar-h); border-bottom: 1px solid var(--border); } -.sidebar .brand img { width: 40px; height: auto; } -.sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; } -.sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; } +.sidebar .brand img { width: 26px; height: 26px; flex: none; } +.sidebar .brand strong { + font-size: 15px; font-weight: 600; + letter-spacing: 0.2px; + color: var(--fg); +} .sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; } .sidebar nav a { display: flex; align-items: center; gap: 10px; @@ -140,6 +153,16 @@ code, kbd { font-family: var(--mono); font-size: 12.5px; .sidebar .footer .status-value.err { color: var(--err); } .sidebar .footer .status-value.warn { color: var(--warn); } .sidebar .footer .footer-sub { color: var(--fg-dimmer); margin-top: 2px; } +/* Persistent backend identity. Sits below the advertised URL so even + when an operator has uploaded their own logo, "what is this" stays + answerable from the bottom-left of every page. */ +.sidebar .footer .footer-version { + margin-top: 8px; padding-top: 8px; + border-top: 1px dashed var(--border-soft); + color: var(--fg-dim); + font-variant-numeric: tabular-nums; + letter-spacing: 0.2px; +} /* ── Top bar ───────────────────────────────────────────────────────── */ @@ -436,26 +459,29 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); } min-height: 480px; box-shadow: var(--shadow-card); } +/* Terminal pane colours follow the active theme. Hard-coded hexes + (#050505, #181818, #cfd6e2 etc.) were leaving the light-mode pane + looking dark; we keep palette-aware vars instead so the toggle works. */ .terminal .pane { flex: 1; overflow: auto; padding: 10px 14px; font-family: var(--mono); font-size: 12.5px; line-height: 1.5; - color: #cfd6e2; + color: var(--fg); white-space: pre-wrap; word-break: break-word; } .terminal .pane .lvl-error { color: var(--err); } .terminal .pane .lvl-warn { color: var(--warn); } -.terminal .pane .lvl-info { color: #cfd6e2; } -.terminal .pane .lvl-debug { color: #8b94a8; } -.terminal .pane .lvl-trace { color: #5a6379; } -.terminal .pane .ts { color: #5a6379; } -.terminal .pane .tg { color: #7cd3ff; } +.terminal .pane .lvl-info { color: var(--fg); } +.terminal .pane .lvl-debug { color: var(--fg-dim); } +.terminal .pane .lvl-trace { color: var(--fg-dimmer); } +.terminal .pane .ts { color: var(--fg-dimmer); } +.terminal .pane .tg { color: var(--accent); } .terminal .pane .echo { color: var(--accent); } .terminal .input-row { display: flex; align-items: center; gap: 8px; padding: 8px 14px; - background: #050505; - border-top: 1px solid #181818; + background: var(--bg-panel-2); + border-top: 1px solid var(--border); } .terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); } .terminal .input-row input { @@ -466,22 +492,91 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); } .terminal .toolbar { display: flex; gap: 8px; align-items: center; padding: 8px 14px; - background: #050505; - border-bottom: 1px solid #181818; + background: var(--bg-panel-2); + border-bottom: 1px solid var(--border); font-size: 12px; color: var(--fg-dim); } .terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; } .terminal .toolbar button { padding: 3px 9px; font-size: 11px; - background: transparent; color: var(--fg-dim); border: 1px solid #181818; + background: transparent; color: var(--fg-dim); border: 1px solid var(--border); font-weight: 500; } -.terminal .toolbar button:hover { color: var(--fg); background: #181818; } +.terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); } /* ── About card ─────────────────────────────────────────────────── */ .about-hero { padding: 20px 24px; } .about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); } -.about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: 60ch; } +/* Span the full main column rather than capping at 60ch — the page is + read at typical desktop widths and the cap was leaving the right two + thirds of the panel awkwardly empty. */ +.about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: none; } .about-hero .who { margin-top: 18px; font-size: 13px; } .about-hero .who span { color: var(--fg-dim); } .about-hero .who strong { color: var(--accent); } +.about-hero a { color: var(--accent); } + +/* ── API reference (Settings → bottom) ─────────────────────────── */ +.api-ref { display: grid; gap: 18px; padding: 16px; } +.api-ref .group h3 { + margin: 0 0 8px; font-size: 13px; color: var(--fg-dim); + text-transform: uppercase; letter-spacing: 0.8px; +} +.api-ref .ep { + display: grid; grid-template-columns: 64px minmax(200px, 1fr) 2fr; + gap: 12px; align-items: baseline; + padding: 6px 0; border-top: 1px solid var(--border-soft); + font-size: 13px; +} +.api-ref .ep:first-child { border-top: 0; } +.api-ref .ep .method { + font-family: var(--mono); font-weight: 600; font-size: 11px; + padding: 2px 6px; border-radius: 4px; + text-align: center; letter-spacing: 0.6px; +} +.api-ref .ep .method.get { background: color-mix(in srgb, var(--ok) 22%, transparent); color: var(--ok); } +.api-ref .ep .method.post { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); } +.api-ref .ep .method.put { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); } +.api-ref .ep .method.delete { background: color-mix(in srgb, var(--err) 22%, transparent); color: var(--err); } +.api-ref .ep .path { font-family: var(--mono); color: var(--fg); word-break: break-all; } +.api-ref .ep .desc { color: var(--fg-dim); } +@media (max-width: 900px) { + .api-ref .ep { grid-template-columns: 1fr; gap: 4px; } + .api-ref .ep .method { justify-self: start; } +} + +/* ── Disk space card ───────────────────────────────────────────── */ +.diskbar { + height: 10px; border-radius: 5px; + background: var(--bg-elev); + overflow: hidden; margin-top: 8px; +} +.diskbar .fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-dim), var(--accent)); + transition: width 0.4s ease; +} +.diskbar.warn .fill { background: var(--warn); } +.diskbar.full .fill { background: var(--err); } +.disk-meta { display: flex; gap: 14px; font-size: 12px; color: var(--fg-dim); margin-top: 8px; flex-wrap: wrap; } +.disk-meta strong { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; } + +/* ── Logo upload (Settings) ────────────────────────────────────── */ +.logo-preview { + display: flex; align-items: center; gap: 14px; + padding: 12px; + background: var(--bg-panel-2); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.logo-preview .swatch { + width: 56px; height: 56px; + display: flex; align-items: center; justify-content: center; + background: var(--bg); border: 1px solid var(--border); + border-radius: var(--radius); + flex: none; +} +.logo-preview .swatch img { max-width: 48px; max-height: 48px; } +.logo-preview .info { flex: 1; min-width: 0; } +.logo-preview .info .name { color: var(--fg); font-weight: 600; } +.logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; } diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index 46ab90d..1e4c867 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -321,8 +321,11 @@ }, storage: async () => { - const [isos, settings, nfsRes] = await Promise.all([ + const [isos, settings, nfsRes, disk] = await Promise.all([ getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'), + getJSON('/api/storage/disk').catch(() => ({ + total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?', + })), ]); const mounts = nfsRes.mounts || []; @@ -511,6 +514,30 @@ const editorRow = el('tr', {style:'display:none'}, editorCells); refreshFieldVisibility(); + // Type cell becomes an OS/Tools