Three things, headlined by the long-blocked graphical PXE menu.
## 1. Graphical PXE boot background — the iVentoy feature, finally
iVentoy paints a PNG background on the PXE screen using stock iPXE
built with CONSOLE_FRAMEBUFFER + IMAGE_PNG + CONSOLE_CMD; the public
iPXE binaries omit those, so `console --picture` is a no-op on them.
We now build our own iPXE from upstream with that thin config delta
(deploy/ipxe/local/{general,console}.h).
The 8-release blocker was cc1 segfaulting when an amd64 gcc ran under
QEMU emulation on the arm64 build host. Fix: a new `ipxe-build`
Dockerfile stage pinned to $BUILDPLATFORM (native arch — no emulation)
that cross-compiles x86_64 iPXE with CROSS_COMPILE=x86_64-linux-gnu-.
The compiler runs native and emits x86_64. Validated end-to-end:
png.o + fbcon.o + pixbuf.o all compile and link (confirmed via the
linked-ELF symbol table, not just strings), ~112s, no segfault. Host
tools needed libc6-dev (dropped by --no-install-recommends; without
it the native host compile falls through to iPXE's freestanding
headers and dies on bits/stdint.h — fixed).
Server side:
- pxe_logo.rs is now a full-screen background compositor: a dark field
(matching the WebUI theme) with the operator's uploaded logo across
the top, or — with no upload — a default OpenPXE rainbow disc drawn
with pure pixel math (no font/SVG deps). Always 1024x768 (iPXE
doesn't scale; this is the universal mode). WebP/JPEG/GIF/PNG in,
PNG out (iPXE only eats PNG).
- /branding/pxe-logo always returns a PNG now (default when no logo,
default when SVG) so the menu always has a background.
- render_menu uses `console --picture … --top 290 || console`: paints
the background and reserves the logo band on PNG-capable binaries
(x86_64 UEFI), cleanly falls back to text on the others. The ASCII
wordmark is GONE.
Only x86_64 UEFI is built from source (host-arch-agnostic cross build);
BIOS/i386/arm64 keep upstream-fetched no-PNG binaries + text fallback.
Modern clients are overwhelmingly x86_64 UEFI.
## 2. NFS AUTH_SYS credential — fixes NFS3ERR_ACCES
v0.4.68's privileged-port fix got past MNT3ERR_ACCES (mount); operators
then hit NFS3ERR_ACCES on READDIR because nfs3_client defaults to
AUTH_NONE and virtually every server exports sec=sys. We now present an
AUTH_UNIX credential (uid 0 / gid 0): no_root_squash servers treat us
as root, root_squash servers map us to anon which reads any
world-readable ISO share. Kept fixed (no UI knob) to stay dead-simple.
Hint updated: a remaining NFS3ERR_ACCES is now a server-side
permission/squash issue, not IP/auth-flavor.
## 3. FleetDM-style full-width logo (top-left)
When a custom logo is uploaded the sidebar header drops the bundled
mark + "OpenPXE" wordmark and lets the logo span the header
(left-aligned, capped 200x50, contain). Rendered server-side via a
brand-class in index_html (has_custom_logo) so there's no flash of the
default. The bundled-default case is unchanged.
Tests: 164 passing. clippy -D warnings clean. iPXE build stage
validated in isolation before the full image build.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
265 lines
11 KiB
Rust
265 lines
11 KiB
Rust
//! PXE boot-menu background compositor.
|
||
//!
|
||
//! 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:
|
||
//!
|
||
//! - 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.
|
||
//!
|
||
//! 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.
|
||
//!
|
||
//! 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. 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;
|
||
|
||
/// 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 from the canvas top to the logo's top edge.
|
||
const LOGO_TOP_MARGIN: u32 = 72;
|
||
|
||
/// 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<u8> = 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 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<Vec<u8>, ImageError> {
|
||
let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, BG);
|
||
|
||
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(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<Vec<u8>, 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;
|
||
}
|
||
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<u8> {
|
||
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = 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 custom_logo_emits_canvas_sized_png_with_dark_field() {
|
||
let src = solid_png(120, 60, [200, 50, 50]);
|
||
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 custom_logo_painted_in_top_band() {
|
||
let src = solid_png(100, 40, [10, 200, 10]);
|
||
let out = compose_pxe_background(Some(&src)).unwrap();
|
||
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
|
||
let cx = (CANVAS_W - 100) / 2;
|
||
let cy = LOGO_TOP_MARGIN;
|
||
let inside = canvas.get_pixel(cx + 10, cy + 10);
|
||
assert!(
|
||
inside.0[1] > 100 && inside.0[0] < 100,
|
||
"logo pixel color mismatch: {inside:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
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();
|
||
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<Rgb<u8>, Vec<u8>> = 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_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]);
|
||
}
|
||
}
|