v0.4.69: PNG boot-menu background (iPXE built from source), NFS AUTH_SYS, FleetDM logo

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]>
This commit is contained in:
Miles Ward
2026-05-29 03:11:35 -04:00
co-authored by Claude Opus 4.8
parent 2f12a2ae84
commit 1eb41288c3
14 changed files with 569 additions and 214 deletions
+58 -56
View File
@@ -215,6 +215,7 @@ async fn index(State(state): State<AppState>) -> 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<AppState>) -> 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<AppState>) -> 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<Vec<u8>> = 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,
)
+36 -38
View File
@@ -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.
+53 -13
View File
@@ -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<u8> {
}
#[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]