v0.4.6: iVentoy-style PXE menu, top-right user menu, Settings touchups
PXE boot menu polish (iVentoy-inspired):
- render_menu now opens with a best-effort `console --picture
<base>/branding/pxe-logo || console` line so iPXE builds with PNG
support paint the operator's uploaded raster logo as the background.
- ASCII OpenPXE wordmark banner sits at the top of the menu in
`item --gap` lines — always visible on every iPXE build, including
the snponly/undionly variants without graphics console.
- New footer line above `choose`: "OpenPXE v0.4.6 - <arch label>",
where <arch label> is mapped from iPXE's ${buildarch}/${platform}
to "x86 BIOS", "x86_64 UEFI", or "arm64 UEFI". No URL, per brief.
- New GET /branding/pxe-logo route serves the operator's PNG / JPEG /
WebP / GIF as-is for iPXE to consume. SVG uploads 404 here (iPXE
can't rasterize SVG) — the always-visible ASCII wordmark stands in.
Route stays public after admin setup so iPXE clients (no cookies)
can fetch it.
UI:
- Removed the bottom-left "signed in as / Sign out" row.
- Added a person-icon button next to the theme toggle in the topbar.
Click opens a small popover with: Name (display only), Edit account
(jumps to Settings), Sign out. Esc + click-outside close it.
- Settings → Account card form chrome made consistent. The previous
`label.field` selector only styled type=text/number, leaving
password inputs with default browser chrome. Switched to a
negation-list selector that covers every typed input we use, plus
-webkit-appearance:none + a 1px focus ring. Light + dark mode both
show the same border/padding/focus state across all four account
fields.
- Settings → SSO card now renders display name, IdP logo URL (new),
and metadata source on one 3-column row. The metadata <select>
inherits the same chrome as the text inputs so it baseline-aligns
with them. SsoConfig grew an idp_logo_url field, persisted to
sso.json, length-capped and validated to http(s) only.
Quality:
- 138 tests passing (was 132 in v0.4.5). +1 IdP-logo-URL validation,
+1 PXE menu polish regression guard, +4 /branding/pxe-logo
integration tests covering missing-config / SVG-fallback / raster-
serve / post-auth public-allowlist cases.
- cargo clippy --workspace --all-targets clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
4a354a8664
commit
f8bfab3823
@@ -51,6 +51,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/assets/app.css", get(ui_css))
|
||||
.route("/assets/logo.svg", get(ui_logo))
|
||||
.route("/assets/loader.svg", get(ui_loader))
|
||||
// v0.4.6: PXE menu logo — the raster form of the operator's
|
||||
// uploaded mark, served so iPXE's `console --picture` can
|
||||
// overlay it on the boot menu. SVG uploads 404 here (iPXE
|
||||
// can't rasterize SVG); we deliberately don't bundle a
|
||||
// pre-rendered PNG fallback because iPXE's ASCII wordmark
|
||||
// banner already provides the always-visible branding.
|
||||
.route("/branding/pxe-logo", get(ui_pxe_logo))
|
||||
// iPXE script endpoints.
|
||||
.route("/boot.ipxe", get(boot_top_menu))
|
||||
.route("/boot/:filename", get(boot_sub))
|
||||
@@ -263,6 +270,49 @@ async fn ui_logo(State(state): State<AppState>) -> Response {
|
||||
.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.
|
||||
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();
|
||||
};
|
||||
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; iPXE menu falls back to the bundled ASCII wordmark",
|
||||
)
|
||||
.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,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("custom logo unreadable: {e}"),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ui_loader() -> Response {
|
||||
(
|
||||
[(
|
||||
@@ -945,6 +995,8 @@ async fn api_docs() -> Json<serde_json::Value> {
|
||||
"summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."},
|
||||
{"method": "DELETE", "path": "/api/branding/logo",
|
||||
"summary": "Remove the custom logo and revert to the bundled mark."},
|
||||
{"method": "GET", "path": "/branding/pxe-logo",
|
||||
"summary": "Raster form of the operator's logo for the iPXE menu's `console --picture`. SVG uploads 404 here."},
|
||||
{"method": "GET", "path": "/api/sso",
|
||||
"summary": "Current SAML SSO configuration."},
|
||||
{"method": "PUT", "path": "/api/sso",
|
||||
|
||||
@@ -440,6 +440,9 @@ mod tests {
|
||||
"/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe",
|
||||
"/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz",
|
||||
"/metrics",
|
||||
// v0.4.6: iPXE fetches this for `console --picture` before
|
||||
// it can possibly have a session cookie.
|
||||
"/branding/pxe-logo",
|
||||
] {
|
||||
assert!(is_public_path(p), "expected {p} to be public");
|
||||
}
|
||||
|
||||
@@ -29,6 +29,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.
|
||||
#[must_use]
|
||||
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
|
||||
let mut s = String::new();
|
||||
@@ -47,8 +56,38 @@ 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.
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"console --picture {base}/branding/pxe-logo || console"
|
||||
);
|
||||
// Map iPXE's ${{buildarch}} + ${{platform}} into the human form the
|
||||
// user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI").
|
||||
// iPXE evaluates `iseq` lazily, so we only set whichever line
|
||||
// matches. Anything not on the allowlist falls through to a generic
|
||||
// `<buildarch> <platform>` display.
|
||||
let _ = writeln!(s, "set arch-label ${{buildarch}} ${{platform}}");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"iseq ${{buildarch}} i386 && iseq ${{platform}} pcbios && set arch-label x86 BIOS || iseq ${{buildarch}} x86_64 && iseq ${{platform}} efi && set arch-label x86_64 UEFI || iseq ${{buildarch}} arm64 && iseq ${{platform}} efi && set arch-label arm64 UEFI || true"
|
||||
);
|
||||
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 -- <text>` 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");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"item --gap -- ------------------------- Default -------------------------"
|
||||
@@ -82,6 +121,18 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
|
||||
let _ = writeln!(s, "item queue Queued Deployment (join queue)");
|
||||
let _ = writeln!(s, "item --gap");
|
||||
let _ = writeln!(s, "item --key x exit Exit iPXE");
|
||||
// v0.4.6 footer line. Sits just above the `choose` line so it's
|
||||
// always visible regardless of how the menu paginates. iPXE
|
||||
// interpolates `${arch-label}` (set near the top of this script)
|
||||
// and `${version}` is the binary-baked iPXE version — *not* the
|
||||
// OpenPXE version — so we hard-code the OpenPXE version string
|
||||
// here.
|
||||
let openpxe_version = env!("CARGO_PKG_VERSION");
|
||||
let _ = writeln!(s, "item --gap");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"item --gap -- OpenPXE v{openpxe_version} - ${{arch-label}}"
|
||||
);
|
||||
|
||||
if matches!(settings.timeout_action, TimeoutAction::Stay) {
|
||||
let _ = writeln!(s, "choose --default {default_item} target || goto menu");
|
||||
@@ -575,6 +626,50 @@ mod password_tests {
|
||||
assert!(s.contains("chain http://10.0.0.5/boot/alpha-linux.ipxe"));
|
||||
}
|
||||
|
||||
#[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.
|
||||
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}"
|
||||
);
|
||||
// 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.
|
||||
assert!(
|
||||
s.contains("___ ___ __ __ ___"),
|
||||
"ascii banner missing first row:\n{s}"
|
||||
);
|
||||
// Footer with version + arch interpolation. The version comes
|
||||
// from CARGO_PKG_VERSION at compile time.
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
assert!(
|
||||
s.contains(&format!("OpenPXE v{version}")),
|
||||
"footer missing OpenPXE version:\n{s}"
|
||||
);
|
||||
assert!(
|
||||
s.contains("${arch-label}"),
|
||||
"footer missing arch-label interpolation:\n{s}"
|
||||
);
|
||||
// No website URL — the design brief calls that out as tacky.
|
||||
assert!(
|
||||
!s.to_ascii_lowercase().contains("openpxe.com"),
|
||||
"footer should not advertise the website:\n{s}"
|
||||
);
|
||||
// Arch-label mapping covers the three labels from the brief:
|
||||
// "x86 BIOS", "x86_64 UEFI", "arm64 UEFI".
|
||||
assert!(s.contains("x86 BIOS"), "{s}");
|
||||
assert!(s.contains("x86_64 UEFI"), "{s}");
|
||||
assert!(s.contains("arm64 UEFI"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
|
||||
let settings = Settings::default();
|
||||
|
||||
@@ -1742,3 +1742,92 @@ async fn docs_lists_new_v0_4_5_endpoints() {
|
||||
assert!(paths.iter().any(|p| p == needle), "{needle} missing");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── v0.4.6: PXE logo endpoint ────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pxe_logo_404_when_no_custom_logo_configured() {
|
||||
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}");
|
||||
}
|
||||
|
||||
#[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.
|
||||
let (state, _dir) = build_state().await;
|
||||
state
|
||||
.branding
|
||||
.set_logo(
|
||||
"image/svg+xml",
|
||||
"svg",
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"/>"#,
|
||||
)
|
||||
.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}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pxe_logo_serves_raster_with_correct_mime() {
|
||||
let (state, _dir) = build_state().await;
|
||||
state
|
||||
.branding
|
||||
.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake-png-bytes")
|
||||
.unwrap();
|
||||
let app = build_router(state);
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/branding/pxe-logo")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(axum::http::header::CONTENT_TYPE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert_eq!(ct, "image/png");
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(body.starts_with(b"\x89PNG"), "PNG header missing");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pxe_logo_endpoint_is_public_after_admin_setup() {
|
||||
// iPXE clients can't send a session cookie, so /branding/pxe-logo
|
||||
// must stay reachable once the admin has been bootstrapped. The
|
||||
// auth allowlist gates `/api/*` only.
|
||||
let (state, _dir) = build_state().await;
|
||||
state
|
||||
.branding
|
||||
.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake")
|
||||
.unwrap();
|
||||
let app = build_router(state);
|
||||
// Configure an admin so the middleware kicks in.
|
||||
let (s, _, _) = post_collect(
|
||||
&app,
|
||||
"/api/setup",
|
||||
r#"{"username":"admin","password":"hunter2hunter2"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CREATED);
|
||||
// Still public without a cookie.
|
||||
let (s, _) = get(&app, "/branding/pxe-logo").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user