//! iPXE script generator. //! //! ## Menu hierarchy (per Phase 2 spec) //! //! ```text //! Top level: //! Default //! > Boot from Local HDD //! Installers //! > Linux Installers -> submenu of Linux ISOs //! > Windows Installers -> submenu of Windows ISOs (controlled by Settings::windows_enabled) //! Tools //! > Utilities -> memtest, etc. (embedded assets only) //! > OpenPXE Shell -> drop to iPXE shell with branded prompt //! > Network Card Info -> ifstat / config / route dump //! Queued Deployment -> join the deployment queue //! ``` //! //! ## iPXE is entirely backend — users do not see or write iPXE //! //! All user-facing knobs live in `Settings`. Script generation translates //! those knobs into iPXE primitives (chain, menu, item, choose, etc.). //! There is intentionally no UI path to upload a custom `.ipxe` script. use openpxe_core::{Settings, TimeoutAction}; use openpxe_iso_store::introspect::DistroFamily; use openpxe_iso_store::{BootEntry, BootKind, IsoMeta}; 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. #[must_use] pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String { let mut s = String::new(); let base = base_url.trim_end_matches('/'); let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000); let default_item = match settings.timeout_action { TimeoutAction::QueuedDeployment => "queue", // Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default // label; the client waits for keypress. We use the same label as // LocalHdd to keep the menu's pre-highlight stable. TimeoutAction::LocalHdd | TimeoutAction::Stay => "local", }; let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "# OpenPXE top-level menu - auto-generated, do not edit"); let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set esc:hex 1b"); let _ = writeln!(s, "set cls ${{esc:string}}[2J"); let _ = writeln!(s, ":menu"); let _ = writeln!(s, "menu OpenPXE - network boot menu"); let _ = writeln!( s, "item --gap -- ------------------------- Default -------------------------" ); let _ = writeln!(s, "item local Boot from Local HDD"); let _ = writeln!( s, "item --gap -- ----------------------- Installers -----------------------" ); if has_family(isos, is_linux_family) { let _ = writeln!(s, "item linux Linux Installers >"); } else { let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)"); } if settings.windows_enabled && has_family(isos, is_windows_family) { let _ = writeln!(s, "item windows Windows Installers >"); } else if settings.windows_enabled { let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)"); } else { let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)"); } let _ = writeln!( s, "item --gap -- -------------------------- Tools --------------------------" ); let _ = writeln!(s, "item tools Tools >"); let _ = writeln!( s, "item --gap -- ---------------------- Queued Deployment ---------------------" ); let _ = writeln!(s, "item queue Queued Deployment (join queue)"); let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --key x exit Exit iPXE"); if matches!(settings.timeout_action, TimeoutAction::Stay) { let _ = writeln!(s, "choose --default {default_item} target || goto menu"); } else { let _ = writeln!( s, "choose --default {default_item} --timeout {timeout_ms} target || goto menu" ); } // iPXE's `||` is strict about what follows. Each test uses `goto menu` // as the fallthrough target so the parser never sees a bare `||` with // trailing whitespace — some iPXE builds reject that. let _ = writeln!( s, "iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} tools && chain {base}/boot/_tools_menu.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} queue && chain {base}/boot/_queue.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} exit && exit || goto menu" ); let _ = writeln!(s, "goto menu"); s } /// Per-family submenu (Linux or Windows). Each item shows the ISO size /// in MiB, iVentoy-style (`[ 4376 MB] ubuntu-22.04-desktop-amd64`). #[must_use] pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> String { let base = base_url.trim_end_matches('/'); let title = if is_windows { "Windows Installers" } else { "Linux Installers" }; let label = if is_windows { "windows" } else { "linux" }; let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, ":menu"); let _ = writeln!(s, "menu OpenPXE - {title}"); let filter: fn(DistroFamily) -> bool = if is_windows { is_windows_family } else { is_linux_family }; let mut count = 0; for iso in isos { if !filter(iso.introspection.family) { continue; } for entry in &iso.boot_entries { let size_label = fmt_size_mib(iso.size_bytes); let key = hotkey_for_index(count); // Visual hint: a leading `*` marks password-protected entries. // ASCII only — iPXE's menu console mangles non-ASCII on some // firmwares. let lock = if iso.is_password_protected() { "*" } else { " " }; let _ = writeln!( s, "item {}{} {}[{:>6}] {}", key, entry.id, lock, size_label, escape_label(&entry.title), ); count += 1; } } if count == 0 { let _ = writeln!(s, "item --gap -- (no {label} images uploaded yet)"); } let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --key b back < Back to main menu"); let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!( s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu" ); // Pass `?mac=${mac}` so the per-entry handler can record the booting // client into the Host log. iPXE substitutes `${mac}` before // the HTTP fetch; if the firmware can't resolve it the literal // `${mac}` is sent and the server treats it as "unknown". let _ = writeln!( s, "chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu" ); s } /// Format a byte count as `NNNN MB` (iVentoy-style — MB not MiB, to match /// operator expectations from the original tool). fn fmt_size_mib(bytes: u64) -> String { let mib = bytes / (1024 * 1024); format!("{mib} MB") } /// Assign `--key N ` hotkeys 1..9, then nothing for positions >=9. /// iPXE's menu needs the --key prefix as a separate token before the id. fn hotkey_for_index(i: usize) -> String { if i < 9 { format!("--key {} ", i + 1) } else { String::new() } } /// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware. #[must_use] pub fn render_tools_menu(base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, ":menu"); let _ = writeln!(s, "menu OpenPXE - Tools"); let _ = writeln!(s, "item --key u util Utilities (memtest, ...)"); let _ = writeln!(s, "item --key s shell OpenPXE Shell"); let _ = writeln!(s, "item --key n nic Network Card Info"); let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --key r reboot Reboot Computer"); let _ = writeln!(s, "item --key e firmware Exit and continue BIOS boot"); let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --key b back < Back to main menu"); let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!( s, "iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} shell && chain {base}/boot/_shell.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} nic && chain {base}/boot/_nic.ipxe || goto menu" ); let _ = writeln!( s, "iseq ${{target}} reboot && reboot || goto menu" ); let _ = writeln!( s, "iseq ${{target}} firmware && exit 0 || goto menu" ); let _ = writeln!( s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu" ); let _ = writeln!(s, "goto menu"); s } /// "Boot from Local HDD". On BIOS, we sanboot the first local drive; on /// UEFI we `exit` so the firmware moves to the next boot entry (normally /// the internal disk). #[must_use] pub fn render_local_hdd(base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "# Boot from Local HDD - platform-sensitive"); let _ = writeln!( s, "iseq ${{platform}} pcbios && sanboot --no-describe --drive 0x80 || goto uefi" ); let _ = writeln!(s, ":uefi"); let _ = writeln!( s, "# UEFI path: fall through to the firmware's next boot entry" ); let _ = writeln!(s, "exit 0"); let _ = writeln!(s, "# If the above exit returns, loop back to the main menu"); let _ = writeln!(s, "chain {base}/boot.ipxe"); s } /// Utilities submenu. For Phase 2 we bundle memtest86+ as an optional /// asset (if absent, the item is listed but errors gracefully). No third- /// party tools are fetched at runtime. #[must_use] pub fn render_util(base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, ":menu"); let _ = writeln!(s, "menu OpenPXE - Utilities"); let _ = writeln!(s, "item memtest MemTest86+ (RAM diagnostic)"); let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item back < Back"); let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!( s, "iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || goto menu" ); let _ = writeln!( s, "iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || goto menu" ); let _ = writeln!(s, "goto menu"); s } /// "OpenPXE Shell" — iPXE shell, branded. #[must_use] pub fn render_shell(base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "echo OpenPXE Shell"); let _ = writeln!(s, "echo 'exit' returns to the main menu"); let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "shell"); let _ = writeln!(s, "chain {base}/boot.ipxe"); s } /// "Network Card Info" — print ifstat + route + config. #[must_use] pub fn render_nic_info(base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "echo Network Card Info"); let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "ifstat"); let _ = writeln!(s, "echo"); let _ = writeln!(s, "route"); let _ = writeln!(s, "echo"); let _ = writeln!(s, "echo 'Press any key to return to menu'"); let _ = writeln!(s, "prompt --timeout 30000"); let _ = writeln!(s, "chain {base}/boot.ipxe"); s } /// Queued Deployment entry point. Joins the queue, then enters a long-poll /// loop (iPXE repeats the chain on 3xx redirects / HTTP errors until a /// real script comes back). #[must_use] pub fn render_queue_entry(base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!( s, "# Queued Deployment - join the queue and wait for operator" ); let _ = writeln!(s, "echo Joining deployment queue..."); // imgfetch writes the body to a file in iPXE's transient FS; we read // the queue entry id out of the Location-style header by asking the server // to put it in the response body as a single token. let _ = writeln!(s, "chain --replace {base}/api/queue/join?mac=${{mac}}"); s } /// Per-entry boot script (same as Phase 1, with extra_kernel_args appended). #[must_use] pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> String { let mut s = String::new(); let base = base_url.trim_end_matches('/'); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "set base-url {base}"); match &entry.kind { BootKind::LinuxKernel { kernel_url, initrd_urls, args, } => { let mut cmdline = args.cmdline.replace("${base-url}", base); if !settings.extra_kernel_args.trim().is_empty() { cmdline.push(' '); cmdline.push_str(settings.extra_kernel_args.trim()); } let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}"); for u in initrd_urls { let _ = writeln!(s, "initrd {base}/{u}"); } let _ = writeln!(s, "boot || goto failed"); } BootKind::Wimboot { wimboot_url, files } => { let _ = writeln!(s, "kernel {base}/{wimboot_url}"); for (tag, url) in files { let _ = writeln!(s, "initrd --name {tag} {base}/{url} {tag}"); } let _ = writeln!(s, "boot || goto failed"); } BootKind::SanBootIso { iso_url } => { let _ = writeln!(s, "sanboot --no-describe {base}/{iso_url} || goto failed"); } } let _ = writeln!(s, ":failed"); let _ = writeln!(s, "echo Boot failed - returning to menu in 5s"); let _ = writeln!(s, "sleep 5"); let _ = writeln!(s, "chain {base}/boot.ipxe"); s } fn is_linux_family(f: DistroFamily) -> bool { matches!( f, DistroFamily::DebianUbuntu | DistroFamily::RhelFedora | DistroFamily::OpenSuse | DistroFamily::Arch | DistroFamily::Alpine | DistroFamily::Unknown ) } fn is_windows_family(f: DistroFamily) -> bool { matches!(f, DistroFamily::WindowsPe) } fn has_family(isos: &[IsoMeta], pred: fn(DistroFamily) -> bool) -> bool { isos.iter().any(|i| pred(i.introspection.family)) } fn escape_label(s: &str) -> String { s.chars() .map(|c| match c { '\n' | '\r' => ' ', c => c, }) .collect() } /// Render the password-prompt script for a protected boot entry. /// /// Flow on the client: /// 1. iPXE clears any leftover ${password}, prints a banner naming the /// ISO so the operator knows what they're being asked for. /// 2. `read --secret password` accepts input without echoing it to /// the screen. /// 3. An empty input bails back to the main menu (lets the operator /// back out of a misclick). /// 4. Otherwise the script chains the same /boot/.ipxe URL but /// with `?token=${password:uristring}`. iPXE's `:uristring` /// modifier URL-encodes the value so `&`, `?`, `=`, spaces, etc. /// survive transport. /// 5. The server replies with either the boot script (correct /// password) or [`render_password_failed`] (wrong password). On /// transport failure we fall back to the main menu. #[must_use] pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let label = escape_label(iso_filename); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "# OpenPXE password prompt for {label}"); let _ = writeln!(s, "echo"); let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "echo This image requires a password"); let _ = writeln!(s, "echo {label}"); let _ = writeln!(s, "echo (enter alone returns to main menu)"); let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "set password "); let _ = writeln!(s, "read --secret password"); let _ = writeln!( s, "iseq ${{password}} \"\" && chain {base}/boot.ipxe || goto submit" ); let _ = writeln!(s, ":submit"); let _ = writeln!(s, "echo Verifying..."); // Carry `mac=${mac}` alongside the token so a successful unlock // records the actual client MAC into the Host log. On // older iPXE that can't resolve `${mac}` the server just stores it // as "unknown" rather than refusing to boot. let _ = writeln!( s, "chain {base}/boot/{entry_id}.ipxe?token=${{password:uristring}}&mac=${{mac}} \ || chain {base}/boot.ipxe" ); s } /// Render the "wrong password" script. Tells the operator, sleeps for /// two seconds (gives the eye time to register the message and dampens /// brute-force rate without help from the server), and chains back to /// the same entry — which sends them through the prompt flow again. #[must_use] pub fn render_password_failed(entry_id: &str, base_url: &str) -> String { let base = base_url.trim_end_matches('/'); let mut s = String::new(); let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "echo"); let _ = writeln!(s, "echo Wrong password."); let _ = writeln!(s, "sleep 2"); let _ = writeln!( s, "chain {base}/boot/{entry_id}.ipxe || chain {base}/boot.ipxe" ); s } #[cfg(test)] mod password_tests { use super::*; #[test] fn prompt_uses_secret_read_and_uri_escape() { let s = render_password_prompt("alpha-linux", "Alpha Test.iso", "http://10.0.0.5"); assert!(s.starts_with("#!ipxe\n")); assert!(s.contains("read --secret password")); assert!(s.contains("Alpha Test.iso")); // URI-string modifier on the var so passwords with `&`/spaces survive. assert!(s.contains("token=${password:uristring}")); // Empty enter sends back to the main menu, not back into the prompt // (avoids a wedged client if the operator chose by mistake). assert!(s.contains("&& chain http://10.0.0.5/boot.ipxe || goto submit")); // Never log/echo the value. assert!(!s.contains("echo ${password")); } #[test] fn failed_chains_back_to_entry() { let s = render_password_failed("alpha-linux", "http://10.0.0.5"); assert!(s.contains("Wrong password.")); // Re-target the entry so the prompt flow runs again. assert!(s.contains("chain http://10.0.0.5/boot/alpha-linux.ipxe")); } #[test] fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() { let settings = Settings::default(); let scripts = [ render_menu(&[], &settings, "http://10.0.0.5"), render_tools_menu("http://10.0.0.5"), render_local_hdd("http://10.0.0.5"), render_util("http://10.0.0.5"), render_shell("http://10.0.0.5"), render_nic_info("http://10.0.0.5"), render_queue_entry("http://10.0.0.5"), render_password_failed("alpha-linux", "http://10.0.0.5"), ]; for script in scripts { for line in script.lines() { assert!( !line.trim_end().ends_with("||"), "bare iPXE fallback operator in line: {line}\nscript:\n{script}" ); } } } }