diff --git a/Cargo.lock b/Cargo.lock index 3a7283a..f3de7df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1171,7 +1171,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openpxe" -version = "0.4.68" +version = "0.4.69" dependencies = [ "anyhow", "axum", @@ -1193,7 +1193,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.4.68" +version = "0.4.69" dependencies = [ "anyhow", "bcrypt", @@ -1212,7 +1212,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.4.68" +version = "0.4.69" dependencies = [ "anyhow", "bytes", @@ -1226,7 +1226,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.4.68" +version = "0.4.69" dependencies = [ "anyhow", "axum", @@ -1257,7 +1257,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.4.68" +version = "0.4.69" dependencies = [ "openpxe-core", "rust-embed", @@ -1267,7 +1267,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.4.68" +version = "0.4.69" dependencies = [ "anyhow", "bcrypt", @@ -1294,7 +1294,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.4.68" +version = "0.4.69" dependencies = [ "anyhow", "bytes", @@ -1308,7 +1308,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.4.68" +version = "0.4.69" [[package]] name = "parking_lot" diff --git a/Cargo.toml b/Cargo.toml index cbad640..c8ff2ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.4.68" +version = "0.4.69" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index c65489d..d536227 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -215,6 +215,7 @@ async fn index(State(state): State) -> Response { &state.public_base_url, env!("CARGO_PKG_VERSION"), state.branding.logo_rev(), + state.branding.has_logo(), ); ( [ @@ -318,74 +319,75 @@ async fn ui_logo(State(state): State) -> Response { .into_response() } -/// v0.4.61: PXE menu logo composed for the iPXE `console --picture` -/// call. The operator can upload any raster image (PNG / JPEG / WebP / -/// GIF) of any aspect ratio; this handler decodes it, draws it -/// centered-top onto a fixed 1024×768 canvas, and returns PNG bytes. -/// That gives the same look as iVentoy regardless of what the operator -/// uploaded — a portrait logo, a wide wordmark, a square monogram all -/// land in the same place on the boot screen. +/// PXE boot-menu background, composed for the iPXE `console --picture` +/// call. Returns a full-screen 1024×768 PNG: a dark field with the +/// operator's uploaded logo across the top, or — when no logo is set — +/// a default OpenPXE mark on the same dark field. Either way the +/// endpoint *always* returns a valid PNG so the menu's `console +/// --picture` paints a real background instead of falling through to +/// bare text (v0.4.69 — replaces the old ASCII wordmark). /// -/// SVG uploads still 404 here — iPXE can't rasterize SVG, and rather -/// than haul in `resvg` we ask the operator to provide a raster when -/// they want a custom PXE-side logo. (The WebUI keeps using the SVG.) +/// Any raster the operator uploads (PNG / JPEG / WebP / GIF) is decoded +/// and transcoded to PNG here, since iPXE only consumes PNG. SVG +/// uploads can't be rasterized without hauling in `resvg`, so an SVG +/// brand mark falls back to the *default* background for the PXE screen +/// (the WebUI still renders the SVG natively in the top-left). async fn ui_pxe_logo(State(state): State) -> Response { - let Some(path) = state.branding.logo_path() else { - return (StatusCode::NOT_FOUND, "no custom logo configured").into_response(); + // Resolve the operator's raster upload, if any and if it's a format + // iPXE/our compositor can consume. SVG (or a missing/unreadable + // file) yields `None`, which composes the default background. + let raster: Option> = match (state.branding.logo_path(), state.branding.logo_mime()) { + (Some(path), Some(mime)) if mime != "image/svg+xml" => { + tokio::fs::read(&path).await.ok() + } + _ => None, }; - let Some(mime) = state.branding.logo_mime() else { - return (StatusCode::NOT_FOUND, "no mime recorded").into_response(); - }; - if mime == "image/svg+xml" { - return ( - StatusCode::NOT_FOUND, - "operator-uploaded logo is SVG; PXE menu requires a raster (PNG / JPEG / WebP / GIF)", - ) - .into_response(); - } - let bytes = match tokio::fs::read(&path).await { - Ok(b) => b, + + let composed = match tokio::task::spawn_blocking(move || { + openpxe_iso_store::pxe_logo::compose_pxe_background(raster.as_deref()) + }) + .await + { + Ok(Ok(png)) => png, + Ok(Err(e)) => { + // A decode failure on the operator's upload shouldn't blank + // the boot screen — fall back to the default background. + tracing::warn!( + target: "openpxe::http::branding", + error = %e, "PXE background compose failed on upload; using default" + ); + match tokio::task::spawn_blocking(|| { + openpxe_iso_store::pxe_logo::compose_pxe_background(None) + }) + .await + { + Ok(Ok(png)) => png, + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to compose PXE background", + ) + .into_response(); + } + } + } Err(e) => { return ( - StatusCode::NOT_FOUND, - format!("custom logo unreadable: {e}"), + StatusCode::INTERNAL_SERVER_ERROR, + format!("pxe-background task failed: {e}"), ) .into_response(); } }; - // Compose to a fixed 1024×768 PNG so the PXE menu paints the logo - // centered-top regardless of the operator's source dimensions. The - // `image` crate is pure-Rust + sync; offload to a blocking task - // because Lanczos resampling on a 4K source can take tens of - // milliseconds and we don't want to block the executor. - let composed = - match tokio::task::spawn_blocking(move || openpxe_iso_store::pxe_logo::compose_pxe_logo(&bytes)) - .await - { - Ok(Ok(png)) => png, - Ok(Err(e)) => { - tracing::warn!( - target: "openpxe::http::branding", - error = %e, "failed to compose PXE logo PNG" - ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("failed to compose PXE logo: {e}"), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("pxe-logo task failed: {e}"), - ) - .into_response(); - } - }; ( [ (header::CONTENT_TYPE, HeaderValue::from_static("image/png")), - (header::CACHE_CONTROL, ASSET_CACHE_CONTROL), + // No-cache so a freshly uploaded logo paints on the next + // boot without a stale composite lingering. + ( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache, max-age=0"), + ), ], composed, ) diff --git a/crates/http-api/src/ipxe_script.rs b/crates/http-api/src/ipxe_script.rs index 7795d2b..b742027 100644 --- a/crates/http-api/src/ipxe_script.rs +++ b/crates/http-api/src/ipxe_script.rs @@ -30,14 +30,15 @@ use std::fmt::Write as _; /// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI /// clients because iPXE normalises the menu primitives across firmwares. /// -/// v0.4.6: rendered with an iVentoy-style polished frame — centered -/// OpenPXE wordmark banner at the top (ASCII so every iPXE build can -/// paint it), a footer carrying version + arch + firmware kind, and an -/// optional `console --picture` directive that paints the operator's -/// uploaded raster logo on top when the iPXE binary on the wire was -/// built with PNG support. The ASCII banner is always rendered so -/// even when the picture call no-ops the screen still reads as -/// "OpenPXE — here is the menu" rather than a featureless box. +/// v0.4.69: rendered with an iVentoy-style graphical frame — a +/// `console --picture` directive paints a full-screen PNG background +/// (the operator's uploaded logo on a dark field, or the default +/// OpenPXE mark) with the menu text overlaid below a reserved top +/// margin, plus a footer carrying version + arch + firmware kind. On +/// iPXE binaries built with `IMAGE_PNG` + `CONSOLE_FRAMEBUFFER` (our +/// x86_64 UEFI binaries, compiled from source) the background paints; +/// on binaries without PNG support the `|| console` fallback yields a +/// clean text menu. The old ASCII wordmark has been removed. #[must_use] pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String { let mut s = String::new(); @@ -56,14 +57,20 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set esc:hex 1b"); let _ = writeln!(s, "set cls ${{esc:string}}[2J"); - // v0.4.6: best-effort graphics console with the operator-uploaded - // raster logo. Falls back to plain text console on iPXE builds - // without PNG support — the `||` chain keeps a parse-clean - // single-statement form so even the strictest iPXE parsers accept - // it. The `console` reset at the end re-syncs the menu output. + // v0.4.69: graphical background. `/branding/pxe-logo` always + // returns a full-screen 1024×768 PNG now — the operator's logo on a + // dark field, or a default OpenPXE mark when none is uploaded. The + // `--top 290` reserves the top band (where the logo paints) so the + // menu text lands below it. On an iPXE build *with* `IMAGE_PNG` + + // `CONSOLE_FRAMEBUFFER` (our x86_64 UEFI binaries, built from source + // — see deploy/docker/Dockerfile) this paints the background and + // overlays the menu. On a build *without* PNG support (the fetched + // BIOS/i386/arm64 binaries) the whole `console --picture …` command + // fails and the `|| console` resets to a clean full-screen text + // menu. Either way there's no ASCII placeholder anymore. let _ = writeln!( s, - "console --picture {base}/branding/pxe-logo || console" + "console --picture {base}/branding/pxe-logo --top 290 || console" ); // Map iPXE's ${{buildarch}} + ${{platform}} into the human form the // user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI"). @@ -77,19 +84,8 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str ); let _ = writeln!(s, ":menu"); let _ = writeln!(s, "menu OpenPXE - network boot menu"); - // ASCII OpenPXE wordmark. Works on every iPXE build, including - // the boot.ipxe.org pre-builds we ship (which omit `IMAGE_PNG`, - // so `console --picture` paints nothing). When the queued iPXE - // source-build lands and the operator's uploaded raster actually - // paints via `console --picture`, this banner can be retired in - // favour of the real image. The compositor at - // /branding/pxe-logo is already wired and waiting. - let _ = writeln!(s, "item --gap"); - let _ = writeln!(s, "item --gap -- ___ ___ __ __ ___"); - let _ = writeln!(s, "item --gap -- / _ \\ _ __ ___ _ _ | _ \\ \\/ / | __|"); - let _ = writeln!(s, "item --gap -- | (_) | '_ \\/ -_) ' \\ | _/ \\ / | _|"); - let _ = writeln!(s, "item --gap -- \\___/| .__/\\___|_||_| |_| /_/\\_\\ |___|"); - let _ = writeln!(s, "item --gap -- |_|"); + // v0.4.69: the ASCII wordmark is gone — the graphical background + // (set via `console --picture` above) carries the branding now. let _ = writeln!(s, "item --gap"); let _ = writeln!( s, @@ -631,27 +627,29 @@ mod password_tests { #[test] fn top_menu_has_polished_branding_and_arch_footer() { - // v0.4.6 polish + v0.4.62 stability fixes: the menu emits a - // `console --picture` line that PNG-capable iPXE builds will - // honour (queued for a follow-up release once we can rebuild - // iPXE from source on native x86_64 hardware), an ASCII - // OpenPXE wordmark that works on every iPXE build (including - // the boot.ipxe.org pre-builds we currently ship), and a - // single-line footer carrying the OpenPXE version + arch. + // v0.4.69: the menu emits a `console --picture` line that paints + // a full-screen PNG background (the operator's logo, or the + // default OpenPXE mark) reserving a top margin for it, then + // falls back to a clean text console on iPXE builds without PNG + // support. The ASCII wordmark is gone — the graphical + // background carries the branding now. A single-line footer + // still carries the OpenPXE version + arch. let settings = Settings::default(); let s = render_menu(&[], &settings, "http://10.0.0.5"); assert!( s.contains("console --picture http://10.0.0.5/branding/pxe-logo"), "missing console --picture line:\n{s}" ); + // The picture call reserves a top margin for the logo band. + assert!(s.contains("--top 290"), "missing --top margin:\n{s}"); // Picture-or-text-console must be a single statement so older // iPXE parsers don't choke on the chain. assert!(s.contains("|| console"), "missing graceful fallback:\n{s}"); - // ASCII wordmark — paints on every iPXE build regardless of - // PNG support. + // The ASCII wordmark must be GONE — its removal is the whole + // point of v0.4.69's graphical background. assert!( - s.contains("___ ___ __ __ ___"), - "ASCII banner missing first row:\n{s}" + !s.contains("___ ___ __ __ ___"), + "ASCII banner should have been removed:\n{s}" ); // Footer with version + arch interpolation. The version comes // from CARGO_PKG_VERSION at compile time. diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index 6cec76c..6c17434 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -1832,13 +1832,39 @@ async fn docs_lists_new_v0_4_5_endpoints() { // ─── v0.4.6: PXE logo endpoint ──────────────────────────────────────────── #[tokio::test] -async fn pxe_logo_404_when_no_custom_logo_configured() { +async fn pxe_background_serves_default_when_no_custom_logo() { + // v0.4.69: the endpoint always returns a full-screen PNG background + // now — when no custom logo is configured it composes the default + // OpenPXE mark on a dark field rather than 404ing. This is what lets + // the PXE menu's `console --picture` paint a real background instead + // of falling back to the (now-removed) ASCII placeholder. let (state, _dir) = build_state().await; let app = build_router(state); - let (s, body) = get(&app, "/branding/pxe-logo").await; - assert_eq!(s, StatusCode::NOT_FOUND); - let text = std::str::from_utf8(&body).unwrap(); - assert!(text.contains("no custom logo"), "got: {text}"); + let res = app + .clone() + .oneshot( + Request::builder() + .uri("/branding/pxe-logo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers() + .get(axum::http::header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap(), + "image/png" + ); + let body = axum::body::to_bytes(res.into_body(), usize::MAX) + .await + .unwrap(); + assert!(body.starts_with(b"\x89PNG"), "default background not a PNG"); + let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]); + assert_eq!(width, 1024, "default background should be 1024 wide"); } /// Build a tiny valid PNG via the `image` crate. The v0.4.61 PXE-logo @@ -1857,10 +1883,11 @@ fn tiny_png() -> Vec { } #[tokio::test] -async fn pxe_logo_404_when_uploaded_logo_is_svg() { - // iPXE can't rasterize SVG, so an SVG upload deliberately doesn't - // light up the PXE menu's `console --picture` overlay — the menu - // simply paints without a logo. +async fn pxe_background_falls_back_to_default_for_svg_upload() { + // iPXE can't rasterize SVG, so an SVG upload doesn't paint as the + // PXE background — but v0.4.69 still returns the *default* OpenPXE + // background PNG (not a 404) so the boot screen stays graphical. + // The WebUI top-left continues to render the SVG natively. let (state, _dir) = build_state().await; state .branding @@ -1871,10 +1898,23 @@ async fn pxe_logo_404_when_uploaded_logo_is_svg() { ) .unwrap(); let app = build_router(state); - let (s, body) = get(&app, "/branding/pxe-logo").await; - assert_eq!(s, StatusCode::NOT_FOUND); - let text = std::str::from_utf8(&body).unwrap(); - assert!(text.contains("SVG"), "got: {text}"); + let res = app + .clone() + .oneshot( + Request::builder() + .uri("/branding/pxe-logo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = axum::body::to_bytes(res.into_body(), usize::MAX) + .await + .unwrap(); + assert!(body.starts_with(b"\x89PNG"), "should serve default PNG for SVG"); + let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]); + assert_eq!(width, 1024); } #[tokio::test] diff --git a/crates/iso-store/src/nfs_share.rs b/crates/iso-store/src/nfs_share.rs index 245ad81..422e439 100644 --- a/crates/iso-store/src/nfs_share.rs +++ b/crates/iso-store/src/nfs_share.rs @@ -66,6 +66,7 @@ use nfs3_types::nfs3::{ self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args, Nfs3Result, READ3args, READDIR3args, }; +use nfs3_types::rpc::{auth_unix, opaque_auth}; use nfs3_types::xdr_codec::Opaque; use openpxe_core::{Error, Result}; use parking_lot::Mutex; @@ -741,9 +742,28 @@ async fn connect_once( nfs3_client::Nfs3Connection>, NfsClientError, > { + // v0.4.69: present an AUTH_SYS (AUTH_UNIX) credential instead of + // the crate default (AUTH_NONE). This is the fix for the + // `NFS3ERR_ACCES` operators hit *after* the v0.4.68 privileged-port + // fix got them past the mount: nearly every NFS server exports + // `sec=sys` and rejects AUTH_NONE callers on the actual file ops + // (READDIR/LOOKUP/READ) even when MOUNT succeeded. We send uid 0 / + // gid 0 — a server with `no_root_squash` treats us as root (full + // read), and the far more common `root_squash` maps us to the + // anonymous user, which can still read any world-readable ISO + // share (the normal case). We deliberately keep this fixed rather + // than a UI knob: ISO libraries are read-only shared data, and a + // uid field is exactly the kind of thing that makes a "dead simple" + // tool confusing for an L1 tech. + let cred = opaque_auth::auth_unix(&auth_unix { + uid: 0, + gid: 0, + ..Default::default() + }); let fut = Nfs3ConnectionBuilder::new(TokioConnector, server, export) .connect_from_privileged_port(privileged) .nfs3_port(port) + .credential(cred) .mount(); match tokio::time::timeout(CONNECT_TIMEOUT, fut).await { Ok(Ok(conn)) => Ok(conn), @@ -839,10 +859,19 @@ fn hint_for(text: &str) -> Option { .into(), ) } else if s.contains("nfs3err_acces") || s.contains("permission denied") { + // The mount succeeded but a file op was denied. OpenPXE + // already presents an AUTH_SYS uid-0 credential, so this is a + // server-side permission/squash issue, not an IP or auth-flavor + // one. Point the operator at the share's filesystem permissions. Some( - "the NFS server rejected this client. Most likely your export \ - is restricted by client IP — add this OpenPXE host (or its \ - subnet) to the export's allowed-clients list on the server." + "the mount succeeded but the server denied reading the share \ + (NFS3ERR_ACCES). OpenPXE connects as AUTH_SYS uid 0, so this is \ + a server-side permission issue: make sure the export's \ + directory is readable (most ISO shares are world-readable / \ + 0755), and that the export isn't restricted to a specific \ + non-root user via all_squash/anonuid. On UniFi UNAS, confirm \ + the share's NFS permission for this host is Read-Write (or \ + Read-Only) and that the share itself grants read access." .into(), ) } else if s.contains("nfs3err_noent") diff --git a/crates/iso-store/src/pxe_logo.rs b/crates/iso-store/src/pxe_logo.rs index 92a6455..69c2559 100644 --- a/crates/iso-store/src/pxe_logo.rs +++ b/crates/iso-store/src/pxe_logo.rs @@ -1,86 +1,179 @@ -//! Operator-logo compositor for the iPXE menu. +//! PXE boot-menu background compositor. //! -//! The brief: match iVentoy's polished centered-logo PXE chrome with -//! whatever raster the operator drops onto Settings → Branding. A wide -//! wordmark, a portrait stack, a square monogram — all three should -//! land in roughly the same place on the boot screen. +//! The brief (v0.4.69): match iVentoy's polished graphical PXE screen. +//! iPXE built with `CONSOLE_FRAMEBUFFER` + `IMAGE_PNG` paints a PNG to +//! the framebuffer via `console --picture`, then draws the text menu on +//! top (the console's default background colour is rendered transparent +//! so the picture shows through the menu's blank cells). So what we +//! produce here is a **full-screen 1024×768 background**, not just a +//! floating logo: //! -//! Approach: decode the operator's upload, fit it into a fixed -//! 1024×768 canvas with the logo horizontally centered and pinned a -//! short margin from the top, re-encode as PNG, return the bytes. iPXE -//! built with `IMAGE_PNG` paints the result via `console --picture`. +//! - a solid dark field (matches the WebUI dark theme so the product +//! feels consistent from browser to bare metal), with +//! - the operator's uploaded logo composited across the top, leaving +//! the lower ~two-thirds clear for the iPXE menu text. //! -//! The 1024×768 size matches the default VESA framebuffer iPXE picks -//! on most BIOS/UEFI consoles. Operators uploading 4K logos get -//! correctly downscaled; tiny icons get drawn at their native size, -//! centered, with transparent margins. +//! When no custom logo is uploaded we still return a designed +//! background — a dark field with a centered "rainbow-horizon" disc +//! echoing the bundled OpenPXE mark — so the boot screen is graphical +//! out of the box. This replaces the old ASCII wordmark entirely. //! -//! We deliberately don't ship `resvg` for SVG support — keeping the -//! dependency surface narrow matters more than supporting SVG-only -//! brand assets. The WebUI's logo stays SVG-native (the browser -//! rasterizes it); the PXE menu wants a raster regardless. +//! iPXE does **not** scale pictures (confirmed against the decoder +//! source): the image is painted at native pixel size and the firmware +//! picks the smallest video mode that fits. 1024×768 is the universal +//! safe mode, so we pin the canvas there. Operators uploading a 4K logo +//! get it downscaled to fit the top band; tiny icons paint at native +//! size, centered. +//! +//! Input formats: anything the `image` crate decodes with our enabled +//! features — PNG, JPEG, WebP, GIF. iPXE itself only consumes PNG, so +//! we always *emit* PNG regardless of what the operator uploaded; a +//! WebP logo is transcoded here transparently. use image::imageops::FilterType; use image::{DynamicImage, ImageError, ImageFormat, Rgba, RgbaImage}; use std::io::Cursor; -/// Canvas dimensions used for the composed PXE logo. Picked to match -/// the framebuffer dimensions iPXE picks on most BIOS/UEFI consoles — -/// gives a 1:1 paint with no scaling at the firmware layer. +/// Canvas dimensions. Pinned to 1024×768 — the universal framebuffer +/// mode every BIOS/UEFI console supports, and iPXE doesn't scale. pub const CANVAS_W: u32 = 1024; pub const CANVAS_H: u32 = 768; -/// Maximum dimensions for the operator's logo inside the canvas. Any -/// upload larger than this in either axis is downscaled (preserving -/// aspect ratio) to fit. Smaller uploads paint at native size. -const LOGO_MAX_W: u32 = 600; +/// Bounding box for the operator's logo across the top band. Wider than +/// the old floating-logo box because the logo now anchors a full +/// background rather than sitting alone on transparency. +const LOGO_MAX_W: u32 = 760; const LOGO_MAX_H: u32 = 200; -/// Top margin in pixels from the canvas's top edge to the logo's top -/// edge. Matches the visual rhythm of iVentoy's screen (logo at top, -/// menu below). -const LOGO_TOP_MARGIN: u32 = 64; +/// Top margin from the canvas top to the logo's top edge. +const LOGO_TOP_MARGIN: u32 = 72; -/// Compose `src_bytes` (any PNG/JPEG/WebP/GIF) into a centered-top -/// 1024×768 PNG and return the encoded bytes. +/// Background fill — a near-black with a faint blue cast, matching the +/// WebUI's dark theme surface so the product reads as one piece from +/// browser to PXE screen. +const BG: Rgba = Rgba([11, 14, 22, 255]); + +/// Compose the operator's uploaded raster (`Some`) — or the default +/// OpenPXE mark (`None`) — into a full-screen 1024×768 PNG background +/// and return the encoded bytes. /// -/// Errors when the source can't be decoded or the encoded buffer can't -/// be written (only really fires on out-of-memory; the encoder itself -/// is infallible for well-formed inputs). -pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result, ImageError> { - let logo = image::load_from_memory(src_bytes)?; - // Resize-fit if the upload exceeds our bounding box. `Lanczos3` - // keeps the antialiasing crisp on the framebuffer console; it's a - // touch slower than `Triangle` but the operator hits this endpoint - // once per boot at most. - let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H); - let logo_rgba = logo.to_rgba8(); +/// Errors only when a provided `src_bytes` can't be decoded; the +/// `None` path and the PNG encode are infallible for our fixed canvas. +pub fn compose_pxe_background(src_bytes: Option<&[u8]>) -> Result, ImageError> { + let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, BG); - // Transparent canvas. iPXE 1.21+ honours alpha-channel transparency - // on framebuffer consoles; older builds simply draw the alpha as - // black, which still gives a sensible look. - let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, Rgba([0, 0, 0, 0])); - let logo_w = logo_rgba.width(); - let logo_h = logo_rgba.height(); - // Horizontal center, top-margin from the top. Saturating math - // means a logo wider than CANVAS_W (shouldn't happen after the - // downscale above, but defensive) just sits flush-left. - let off_x = CANVAS_W.saturating_sub(logo_w) / 2; - let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_h)); - image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into()); + match src_bytes { + Some(bytes) => { + let logo = image::load_from_memory(bytes)?; + let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H); + let logo_rgba = logo.to_rgba8(); + let off_x = CANVAS_W.saturating_sub(logo_rgba.width()) / 2; + let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_rgba.height())); + // `overlay` alpha-composites, so a transparent-background + // logo blends onto the dark field exactly as designed. + image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into()); + } + None => draw_default_mark(&mut canvas), + } - let mut out = Vec::with_capacity(64 * 1024); + let mut out = Vec::with_capacity(128 * 1024); DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?; Ok(out) } +/// Back-compat shim for the old name — callers that pass a raw logo and +/// want it composited get the same result as `compose_pxe_background` +/// with `Some`. +pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result, ImageError> { + compose_pxe_background(Some(src_bytes)) +} + +/// Paint a centered "rainbow-horizon" disc onto the dark canvas as the +/// default brand mark when no operator logo is set. Pure pixel math — +/// no font, no SVG rasterizer, no extra deps. A filled circle with a +/// left-to-right hue sweep echoes the bundled `logo.svg` motif. +// Casts here are all bounded small-range geometry (radius ≤ 90, canvas +// ≤ 1024) — precision loss / wrap is structurally impossible. +#[allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)] +fn draw_default_mark(canvas: &mut RgbaImage) { + let radius: i32 = 90; + let cx = (CANVAS_W / 2) as i32; + let cy = (LOGO_TOP_MARGIN + 100) as i32; + // Four-stop horizontal sweep across the disc (teal → blue → violet + // → magenta) — the OpenPXE palette. + let stops = [ + [0x22u8, 0xd3, 0xaa], + [0x3b, 0x82, 0xf6], + [0x8b, 0x5c, 0xf6], + [0xec, 0x48, 0x99], + ]; + let r2 = radius * radius; + for dy in -radius..=radius { + for dx in -radius..=radius { + if dx * dx + dy * dy > r2 { + continue; + } + // Position across the disc in [0,1] left→right. + let t = (f32::from(i16::try_from(dx + radius).unwrap_or(0))) + / (f32::from(i16::try_from(2 * radius).unwrap_or(1))); + let color = gradient_at(&stops, t); + // Soft edge: fade alpha in the outer 3px ring. + let dist = ((dx * dx + dy * dy) as f32).sqrt(); + let alpha = if dist > (radius as f32 - 3.0) { + let edge = (radius as f32 - dist).clamp(0.0, 3.0) / 3.0; + (edge * 255.0) as u8 + } else { + 255 + }; + let px = cx + dx; + let py = cy + dy; + if px >= 0 && py >= 0 && (px as u32) < CANVAS_W && (py as u32) < CANVAS_H { + blend_pixel(canvas, px as u32, py as u32, color, alpha); + } + } + } +} + +/// Linear interpolate across an N-stop palette at position `t` in [0,1]. +// `segments`/`idx` are ≤ palette length (4) — f32 cast is exact. +#[allow(clippy::cast_precision_loss)] +fn gradient_at(stops: &[[u8; 3]], t: f32) -> [u8; 3] { + let t = t.clamp(0.0, 1.0); + let segments = stops.len() - 1; + let scaled = t * segments as f32; + let idx = (scaled.floor() as usize).min(segments - 1); + let frac = scaled - idx as f32; + let a = stops[idx]; + let b = stops[idx + 1]; + [ + lerp(a[0], b[0], frac), + lerp(a[1], b[1], frac), + lerp(a[2], b[2], frac), + ] +} + +fn lerp(a: u8, b: u8, t: f32) -> u8 { + (f32::from(a) + (f32::from(b) - f32::from(a)) * t).round() as u8 +} + +/// Alpha-blend `color` at `alpha` over the existing canvas pixel. +fn blend_pixel(canvas: &mut RgbaImage, x: u32, y: u32, color: [u8; 3], alpha: u8) { + let bg = canvas.get_pixel(x, y).0; + let a = f32::from(alpha) / 255.0; + let out = Rgba([ + lerp(bg[0], color[0], a), + lerp(bg[1], color[1], a), + lerp(bg[2], color[2], a), + 255, + ]); + canvas.put_pixel(x, y, out); +} + fn downscale_to_fit(img: DynamicImage, max_w: u32, max_h: u32) -> DynamicImage { let (w, h) = (img.width(), img.height()); if w <= max_w && h <= max_h { return img; } - // Preserve aspect ratio. `resize` clamps to the smaller of the - // two scale factors so we never overshoot the bounding box. img.resize(max_w, max_h, FilterType::Lanczos3) } @@ -99,54 +192,73 @@ mod tests { } #[test] - fn compose_emits_canvas_sized_png() { + fn custom_logo_emits_canvas_sized_png_with_dark_field() { let src = solid_png(120, 60, [200, 50, 50]); - let out = compose_pxe_logo(&src).unwrap(); - // Round-trip the output and confirm dimensions. - let img = image::load_from_memory(&out).unwrap(); + let out = compose_pxe_background(Some(&src)).unwrap(); + let img = image::load_from_memory(&out).unwrap().to_rgba8(); assert_eq!(img.width(), CANVAS_W); assert_eq!(img.height(), CANVAS_H); + // A far corner should be the opaque dark background fill, not + // transparent — this is a full background now, not a floating + // logo on transparency. + let corner = img.get_pixel(CANVAS_W - 1, CANVAS_H - 1); + assert_eq!(corner.0, BG.0, "corner should be the dark fill"); } #[test] - fn small_logo_centered_at_top_margin() { + fn custom_logo_painted_in_top_band() { let src = solid_png(100, 40, [10, 200, 10]); - let out = compose_pxe_logo(&src).unwrap(); + let out = compose_pxe_background(Some(&src)).unwrap(); let canvas = image::load_from_memory(&out).unwrap().to_rgba8(); - // Pixel just inside the logo box should match the source color - // (alpha=255). Pixel near a far corner of the canvas should be - // the transparent background. let cx = (CANVAS_W - 100) / 2; let cy = LOGO_TOP_MARGIN; let inside = canvas.get_pixel(cx + 10, cy + 10); - assert_eq!(inside.0[3], 255, "logo pixel should be opaque"); - assert!(inside.0[0] < 100 && inside.0[1] > 100 && inside.0[2] < 100, "color mismatch: {inside:?}"); - let corner = canvas.get_pixel(CANVAS_W - 1, CANVAS_H - 1); - assert_eq!(corner.0[3], 0, "canvas corner should be transparent"); + assert!( + inside.0[1] > 100 && inside.0[0] < 100, + "logo pixel color mismatch: {inside:?}" + ); } #[test] - fn oversize_logo_is_downscaled_to_bounding_box() { - // 4000×800 image — bigger than LOGO_MAX_W and LOGO_MAX_H in - // both axes. After downscale the output must fit; we re-decode - // the canvas, count non-transparent pixels, and confirm none - // sit outside the expected band. - let src = solid_png(4000, 800, [50, 50, 200]); - let out = compose_pxe_logo(&src).unwrap(); + fn default_background_is_dark_with_a_painted_mark() { + let out = compose_pxe_background(None).unwrap(); let canvas = image::load_from_memory(&out).unwrap().to_rgba8(); - // Span row at the top margin should have non-transparent - // pixels somewhere; rows past the LOGO_TOP_MARGIN + LOGO_MAX_H - // should be entirely transparent. - let bottom_band_y = LOGO_TOP_MARGIN + LOGO_MAX_H + 10; - for x in 0..CANVAS_W { - let p = canvas.get_pixel(x, bottom_band_y); - assert_eq!(p.0[3], 0, "row {bottom_band_y} should be transparent at x={x}"); - } + assert_eq!(canvas.width(), CANVAS_W); + assert_eq!(canvas.height(), CANVAS_H); + // Corner is dark fill. + assert_eq!(canvas.get_pixel(2, CANVAS_H - 2).0, BG.0); + // Center of the disc is not the background fill (something was + // painted there). + let center = canvas.get_pixel(CANVAS_W / 2, LOGO_TOP_MARGIN + 100); + assert_ne!(center.0, BG.0, "default mark should paint over the field"); + } + + #[test] + fn webp_or_jpeg_input_is_accepted_and_transcoded_to_png() { + // Encode a JPEG and confirm the compositor decodes it and emits + // a valid PNG (iPXE only eats PNG, so transcoding is the point). + let img: ImageBuffer, Vec> = ImageBuffer::from_pixel(80, 80, Rgb([90, 90, 90])); + let mut jpeg = Vec::new(); + DynamicImage::ImageRgb8(img) + .write_to(&mut Cursor::new(&mut jpeg), ImageFormat::Jpeg) + .unwrap(); + let out = compose_pxe_background(Some(&jpeg)).unwrap(); + // Output must be a PNG (magic bytes) of canvas size. + assert_eq!(&out[..8], b"\x89PNG\r\n\x1a\n"); + let img = image::load_from_memory(&out).unwrap(); + assert_eq!(img.width(), CANVAS_W); } #[test] fn unsupported_bytes_returns_error_not_panic() { - let r = compose_pxe_logo(b"\xde\xad\xbe\xef not an image"); + let r = compose_pxe_background(Some(b"\xde\xad\xbe\xef not an image")); assert!(r.is_err()); } + + #[test] + fn gradient_endpoints_match_stops() { + let stops = [[0, 0, 0], [255, 255, 255]]; + assert_eq!(gradient_at(&stops, 0.0), [0, 0, 0]); + assert_eq!(gradient_at(&stops, 1.0), [255, 255, 255]); + } } diff --git a/crates/webui/src/app.css b/crates/webui/src/app.css index 62f20a1..1e04576 100644 --- a/crates/webui/src/app.css +++ b/crates/webui/src/app.css @@ -117,6 +117,20 @@ code, kbd { font-family: var(--mono); font-size: 12.5px; letter-spacing: 0.2px; color: var(--fg); } + +/* v0.4.69: FleetDM-style full-width custom logo. When the operator has + uploaded a custom brand mark, the sidebar header drops the bundled + 26px mark + "OpenPXE" wordmark and instead lets the uploaded image + span the header — left-aligned, capped at 200x50, scaled to fit + without distortion. The wordmark is hidden so the operator's logo is + the sole brand element (their logo presumably already contains their + name). The bundled-default case keeps the mark + wordmark. */ +.sidebar .brand.has-custom-logo { gap: 0; } +.sidebar .brand.has-custom-logo img { + width: auto; height: 50px; max-width: 200px; + object-fit: contain; object-position: left center; flex: none; +} +.sidebar .brand.has-custom-logo strong { display: none; } .sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; } .sidebar nav a { display: flex; align-items: center; gap: 10px; diff --git a/crates/webui/src/index.html b/crates/webui/src/index.html index abd245d..6b91256 100644 --- a/crates/webui/src/index.html +++ b/crates/webui/src/index.html @@ -32,7 +32,7 @@