diff --git a/Cargo.lock b/Cargo.lock index acbf3e1..185a6ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2758,7 +2758,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openpxe" -version = "0.8.1" +version = "0.8.2" dependencies = [ "anyhow", "axum", @@ -2778,7 +2778,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.8.1" +version = "0.8.2" dependencies = [ "anyhow", "base64", @@ -2803,7 +2803,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.8.1" +version = "0.8.2" dependencies = [ "anyhow", "dhcproto", @@ -2818,7 +2818,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.8.1" +version = "0.8.2" dependencies = [ "anyhow", "axum", @@ -2852,7 +2852,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.8.1" +version = "0.8.2" dependencies = [ "openpxe-core", "rust-embed", @@ -2861,7 +2861,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.8.1" +version = "0.8.2" dependencies = [ "anyhow", "bcrypt", @@ -2887,7 +2887,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.8.1" +version = "0.8.2" dependencies = [ "anyhow", "openpxe-core", @@ -2899,7 +2899,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.8.1" +version = "0.8.2" [[package]] name = "p256" diff --git a/Cargo.toml b/Cargo.toml index 9081460..c04d620 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.8.1" +version = "0.8.2" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/http-api/src/ipxe_script.rs b/crates/http-api/src/ipxe_script.rs index 027e061..4460ee9 100644 --- a/crates/http-api/src/ipxe_script.rs +++ b/crates/http-api/src/ipxe_script.rs @@ -85,10 +85,25 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str // iPXE evaluates `iseq` lazily, so we only set whichever line // matches. Anything not on the allowlist falls through to a generic // ` ` display. + // + // v0.8.2: one probe per line, each terminated with `|| echo` (`echo` + // is the harmless rc-0 no-op; there is no `true` command in iPXE). + // The previous single line tried to chain the probes with `||` and + // ended in `|| true`, which is not an iPXE command: on any + // arch/platform pair outside the allowlist (e.g. i386 + efi) the + // command failed and aborted the script before the menu was drawn. 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" + "iseq ${{buildarch}} i386 && iseq ${{platform}} pcbios && set arch-label x86 BIOS || echo" + ); + let _ = writeln!( + s, + "iseq ${{buildarch}} x86_64 && iseq ${{platform}} efi && set arch-label x86_64 UEFI || echo" + ); + let _ = writeln!( + s, + "iseq ${{buildarch}} arm64 && iseq ${{platform}} efi && set arch-label arm64 UEFI || echo" ); let _ = writeln!(s, ":menu"); let _ = writeln!(s, "menu OpenPXE - network boot menu"); @@ -151,34 +166,44 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str ); } - // 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. + // v0.8.2: dispatch via `goto ` labels. This is the + // canonical iPXE menu pattern (ipxe.org examples) and, unlike + // `iseq ... && chain ... || goto menu`, it is not sensitive to the + // left-associative `&&`/`||` evaluation order that broke item + // selection in issue #2: for `A && B || C` iPXE runs C whenever A + // fails, so every item except the first used to skip its chain and + // bounce straight back to `goto menu` — redrawing the menu with the + // default (Queued Deployment) item highlighted, as if Enter did + // nothing. + // + // Menu-to-menu transitions use `chain --replace`: the replacement + // image becomes the top-most image, so `exit` from any submenu + // reaches the firmware and repeated Back/forward navigation does not + // nest script images (iPXE tail-recurses into replacements, so the + // stack stays flat). A failed fetch still returns an error, which + // the trailing `|| goto menu` turns into a menu redraw. + let _ = writeln!(s, "goto ${{target}} || goto menu"); + let _ = writeln!(s, ":local"); + let _ = writeln!(s, "chain --replace {base}/boot/_local.ipxe || goto menu"); + let _ = writeln!(s, ":linux"); let _ = writeln!( s, - "iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu" + "chain --replace {base}/boot/_linux_menu.ipxe || goto menu" ); + let _ = writeln!(s, ":windows"); let _ = writeln!( s, - "iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu" + "chain --replace {base}/boot/_windows_menu.ipxe || goto menu" ); + let _ = writeln!(s, ":tools"); let _ = writeln!( s, - "iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu" + "chain --replace {base}/boot/_tools_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"); + let _ = writeln!(s, ":queue"); + let _ = writeln!(s, "chain --replace {base}/boot/_queue.ipxe || goto menu"); + let _ = writeln!(s, ":exit"); + let _ = writeln!(s, "exit 0"); s } @@ -244,18 +269,21 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> 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" - ); + // v0.8.2: `back` is the only fixed item here; every other item value + // is a boot-entry id that can only be resolved at runtime, so dispatch + // on it directly. `echo` is iPXE's rc-0 no-op and keeps a + // non-matching test from aborting the script (issue #2). + let _ = writeln!(s, "iseq ${{target}} back && goto back || echo"); // 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" + "chain --replace {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu" ); + let _ = writeln!(s, ":back"); + let _ = writeln!(s, "chain --replace {base}/boot.ipxe || goto menu"); s } @@ -331,36 +359,34 @@ pub fn render_tools_menu(isos: &[IsoMeta], base_url: &str) -> String { 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" - ); + // v0.8.2: fixed item labels jump to their block; category-Tools ISO + // ids (only known at runtime) fall through to the entry chain. Every + // probe ends in `|| echo` so a non-matching test cannot abort the + // script (issue #2). + let _ = writeln!(s, "iseq ${{target}} util && goto util || echo"); + let _ = writeln!(s, "iseq ${{target}} shell && goto shell || echo"); + let _ = writeln!(s, "iseq ${{target}} nic && goto nic || echo"); + let _ = writeln!(s, "iseq ${{target}} reboot && goto reboot || echo"); + let _ = writeln!(s, "iseq ${{target}} firmware && goto firmware || echo"); + let _ = writeln!(s, "iseq ${{target}} back && goto back || echo"); // Fall-through for category-Tools ISO ids — same as the family // submenu, carrying `?mac=${mac}` for the boot log. let _ = writeln!( s, - "chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu" + "chain --replace {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu" ); + let _ = writeln!(s, ":util"); + let _ = writeln!(s, "chain --replace {base}/boot/_util.ipxe || goto menu"); + let _ = writeln!(s, ":shell"); + let _ = writeln!(s, "chain --replace {base}/boot/_shell.ipxe || goto menu"); + let _ = writeln!(s, ":nic"); + let _ = writeln!(s, "chain --replace {base}/boot/_nic.ipxe || goto menu"); + let _ = writeln!(s, ":reboot"); + let _ = writeln!(s, "reboot || goto menu"); + let _ = writeln!(s, ":firmware"); + let _ = writeln!(s, "exit 0"); + let _ = writeln!(s, ":back"); + let _ = writeln!(s, "chain --replace {base}/boot.ipxe || goto menu"); s } @@ -384,7 +410,7 @@ pub fn render_local_hdd(base_url: &str) -> String { ); 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"); + let _ = writeln!(s, "chain --replace {base}/boot.ipxe"); s } @@ -402,15 +428,18 @@ pub fn render_util(base_url: &str) -> String { let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item back < Back"); let _ = writeln!(s, "choose target || goto menu"); + // v0.8.2: dispatch to `:memtest`; anything else (only `back` exists + // today) returns to the Tools menu. The old `iseq back && chain + // _tools_menu || goto menu` pair never ran the Tools chain: the + // `back` line's own `iseq` failed first, so `|| goto menu` redrew + // Utilities and Back appeared dead (issue #2). + let _ = writeln!(s, "iseq ${{target}} memtest && goto memtest || echo"); let _ = writeln!( s, - "iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || goto menu" + "chain --replace {base}/boot/_tools_menu.ipxe || goto menu" ); - let _ = writeln!( - s, - "iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || goto menu" - ); - let _ = writeln!(s, "goto menu"); + let _ = writeln!(s, ":memtest"); + let _ = writeln!(s, "chain --replace {base}/ipxe/memtest.bin || goto menu"); s } @@ -425,7 +454,7 @@ pub fn render_shell(base_url: &str) -> String { 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"); + let _ = writeln!(s, "chain --replace {base}/boot.ipxe"); s } @@ -443,8 +472,11 @@ pub fn render_nic_info(base_url: &str) -> String { 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"); + // v0.8.2: `prompt` returns -ETIMEDOUT when nobody presses a key, and + // a bare failing line aborts the script (dropping the client back to + // firmware). `|| echo` treats the timeout as "return to menu now". + let _ = writeln!(s, "prompt --timeout 30000 || echo"); + let _ = writeln!(s, "chain --replace {base}/boot.ipxe"); s } @@ -464,7 +496,13 @@ pub fn render_queue_entry(base_url: &str) -> String { // 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}}"); + // v0.8.2: if the queue endpoint is unreachable, fall back to the main + // menu instead of aborting the whole boot chain into firmware. + let _ = writeln!( + s, + "chain --replace {base}/api/queue/join?mac=${{mac}} \ + || chain --replace {base}/boot.ipxe" + ); s } @@ -505,16 +543,21 @@ pub fn render_entry( cmdline.push_str(extra.trim()); } } - let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}"); + // v0.8.2: every fetch line carries its own failure branch. + // Menus now enter boot entries with `chain --replace`, so a + // failing line here can no longer be caught by the *menu's* + // `|| goto menu` — the entry script must return to the menu + // itself. + let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline} || goto failed"); for u in initrd_urls { - let _ = writeln!(s, "initrd {base}/{u}"); + let _ = writeln!(s, "initrd {base}/{u} || goto failed"); } let _ = writeln!(s, "boot || goto failed"); } BootKind::Wimboot { wimboot_url, files } => { - let _ = writeln!(s, "kernel {base}/{wimboot_url}"); + let _ = writeln!(s, "kernel {base}/{wimboot_url} || goto failed"); for (tag, url) in files { - let _ = writeln!(s, "initrd --name {tag} {base}/{url} {tag}"); + let _ = writeln!(s, "initrd --name {tag} {base}/{url} {tag} || goto failed"); } let _ = writeln!(s, "boot || goto failed"); } @@ -525,7 +568,7 @@ pub fn render_entry( 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"); + let _ = writeln!(s, "chain --replace {base}/boot.ipxe"); s } @@ -589,9 +632,14 @@ pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "set password "); let _ = writeln!(s, "read --secret password"); + // v0.8.2: `isset` (empty → nonzero rc) is the correct emptiness + // test. `iseq ${password} ""` compared against the *literal* token + // `""` — iPXE's tokenizer is whitespace-only and has no quoting, so + // an empty Enter never matched and fell through to the failed-login + // path instead of returning to the menu as the banner promises. let _ = writeln!( s, - "iseq ${{password}} \"\" && chain {base}/boot.ipxe || goto submit" + "isset ${{password}} && goto submit || chain --replace {base}/boot.ipxe" ); let _ = writeln!(s, ":submit"); let _ = writeln!(s, "echo Verifying..."); @@ -601,8 +649,8 @@ pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str // 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" + "chain --replace {base}/boot/{entry_id}.ipxe?token=${{password:uristring}}&mac=${{mac}} \ + || chain --replace {base}/boot.ipxe" ); s } @@ -621,7 +669,7 @@ pub fn render_password_failed(entry_id: &str, base_url: &str) -> String { let _ = writeln!(s, "sleep 2"); let _ = writeln!( s, - "chain {base}/boot/{entry_id}.ipxe || chain {base}/boot.ipxe" + "chain --replace {base}/boot/{entry_id}.ipxe || chain --replace {base}/boot.ipxe" ); s } @@ -640,7 +688,13 @@ mod password_tests { 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")); + assert!( + s.contains( + "isset ${password} && goto submit \ + || chain --replace http://10.0.0.5/boot.ipxe" + ), + "empty-password path missing:\n{s}" + ); // Never log/echo the value. assert!(!s.contains("echo ${password")); } @@ -650,7 +704,7 @@ mod password_tests { 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")); + assert!(s.contains("chain --replace http://10.0.0.5/boot/alpha-linux.ipxe")); } #[test] @@ -741,3 +795,489 @@ mod password_tests { } } } + +/// Control-flow regression tests for the generated iPXE menus. +/// +/// Issue #2 ("menu cannot select items") was a script-semantics bug: +/// iPXE evaluates `&&` / `||` strictly left to right, so +/// `iseq ${target} X && chain … || goto menu` runs `goto menu` whenever +/// the `iseq` fails — for every item except the first one. Text +/// assertions cannot catch that, so these tests run the generated +/// scripts through a small interpreter that mirrors the parts of iPXE's +/// `system()` (`src/core/exec.c`) and `process_script()` +/// (`src/image/script.c`) that the menus rely on, and check which URL +/// each menu item actually reaches. +#[cfg(test)] +mod menu_dispatch_tests { + use super::*; + use openpxe_iso_store::{IsoCategory, IsoSource, KernelArgs}; + use std::collections::HashMap; + + const BASE: &str = "http://10.0.0.5"; + + /// What a script did before it left the interpreter. + #[derive(Debug, Default)] + struct Run { + /// URLs passed to `chain`, in order (normally exactly one). + chains: Vec, + /// Exit status if the script reached `exit`. + exit: Option, + /// Whether the script reached `reboot`. + rebooted: bool, + } + + #[derive(Clone, Copy)] + enum Sep { + /// `&&` + And, + /// `||` + Or, + /// `;` + Always, + } + + enum LineOutcome { + Continue, + Jump(usize), + Done, + Abort(String), + } + + enum CmdOutcome { + Rc(i32), + Jump(usize), + Done, + Abort(String), + } + + /// Minimal interpreter for the subset of iPXE's script language used + /// by the generated menus. + struct IpxeSim { + vars: HashMap, + selection: String, + /// Value the simulated operator types at `read`. + input: String, + /// Model `prompt --timeout` expiring with no key pressed. + prompt_times_out: bool, + run: Run, + } + + impl IpxeSim { + fn new(selection: &str) -> Self { + let mut vars = HashMap::new(); + vars.insert("buildarch".to_string(), "x86_64".to_string()); + vars.insert("platform".to_string(), "efi".to_string()); + vars.insert("mac".to_string(), "aa:bb:cc:dd:ee:ff".to_string()); + vars.insert("password".to_string(), String::new()); + Self { + vars, + selection: selection.to_string(), + input: String::new(), + prompt_times_out: false, + run: Run::default(), + } + } + + fn with_var(mut self, name: &str, value: &str) -> Self { + self.vars.insert(name.to_string(), value.to_string()); + self + } + + fn with_input(mut self, value: &str) -> Self { + self.input = value.to_string(); + self + } + + /// Run a script to completion and report what it did. Panics if + /// the script aborts or never reaches a terminal command — which + /// is exactly how a bad dispatch line manifests on a real client. + fn run(mut self, script: &str) -> Run { + let lines: Vec<&str> = script.lines().collect(); + let mut labels: HashMap = HashMap::new(); + for (index, line) in lines.iter().enumerate() { + if let Some(name) = line.trim().strip_prefix(':') { + labels.insert(name.trim().to_string(), index); + } + } + let mut pc = 0; + let mut steps = 0; + while pc < lines.len() { + steps += 1; + assert!( + steps < 10_000, + "script looped without making progress\n--- script ---\n{script}" + ); + let line = lines[pc].trim().to_string(); + if line.is_empty() || line.starts_with('#') || line.starts_with(':') { + pc += 1; + continue; + } + match self.exec_line(&line, &labels) { + LineOutcome::Continue => pc += 1, + LineOutcome::Jump(target) => pc = target + 1, + LineOutcome::Done => return self.run, + LineOutcome::Abort(why) => { + panic!("script aborted at line {pc}: {why}\n--- script ---\n{script}"); + } + } + } + panic!("script ended without booting or exiting\n--- script ---\n{script}"); + } + + /// Expand `${name}` / `${name:modifier}` from the simulated + /// settings. iPXE expands *after* tokenising, so a value + /// containing spaces stays a single argument. + fn expand(&self, token: &str) -> String { + let mut out = String::new(); + let mut rest = token; + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + let Some(end) = after.find('}') else { + out.push_str(&rest[start..]); + return out; + }; + let spec = &after[..end]; + let name = spec.split(':').next().unwrap_or(spec); + if let Some(value) = self.vars.get(name) { + out.push_str(value); + } + rest = &after[end + 1..]; + } + out.push_str(rest); + out + } + + /// Execute one command line, mirroring `system()`: commands are + /// separated by `&&` / `||` / `;`, and each separator decides + /// whether the *next* command runs based on the last status. + fn exec_line(&mut self, line: &str, labels: &HashMap) -> LineOutcome { + let mut commands: Vec<(Vec, Option)> = Vec::new(); + let mut current: Vec = Vec::new(); + for token in tokenize(line) { + match token.as_str() { + "&&" => commands.push((std::mem::take(&mut current), Some(Sep::And))), + "||" => commands.push((std::mem::take(&mut current), Some(Sep::Or))), + ";" => commands.push((std::mem::take(&mut current), Some(Sep::Always))), + _ => current.push(token), + } + } + commands.push((current, None)); + + let mut rc = 0; + let mut process = true; + for (argv, sep) in &commands { + if process { + if argv.is_empty() { + return LineOutcome::Abort(format!("empty command in `{line}`")); + } + match self.exec_cmd(argv, labels) { + CmdOutcome::Rc(status) => rc = status, + CmdOutcome::Jump(target) => return LineOutcome::Jump(target), + CmdOutcome::Done => return LineOutcome::Done, + CmdOutcome::Abort(why) => return LineOutcome::Abort(why), + } + } + match sep { + Some(Sep::And) => process = rc == 0, + Some(Sep::Or) => process = rc != 0, + Some(Sep::Always) => process = true, + None => break, + } + } + if rc != 0 { + return LineOutcome::Abort(format!("`{line}` failed with status {rc}")); + } + LineOutcome::Continue + } + + fn exec_cmd(&mut self, argv: &[String], labels: &HashMap) -> CmdOutcome { + let cmd = self.expand(&argv[0]); + let args: Vec = argv[1..].iter().map(|a| self.expand(a)).collect(); + match cmd.as_str() { + "set" => { + if let Some(name) = args.first() { + let value = if args.len() > 1 { + args[1..].join(" ") + } else { + String::new() + }; + self.vars.insert(name.clone(), value); + } + CmdOutcome::Rc(0) + } + "iseq" => { + let equal = args.len() >= 2 && args[0] == args[1]; + CmdOutcome::Rc(i32::from(!equal)) + } + "isset" => { + let nonempty = args.first().is_some_and(|value| !value.is_empty()); + CmdOutcome::Rc(i32::from(!nonempty)) + } + "goto" => match args.first().and_then(|label| labels.get(label)) { + Some(target) => CmdOutcome::Jump(*target), + None => CmdOutcome::Abort(format!("goto to missing label in `{argv:?}`")), + }, + "exit" => { + let code = args + .first() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + self.run.exit = Some(code); + CmdOutcome::Done + } + // A boot takes over the machine (and `chain --replace` + // additionally replaces the current script image), so the + // interpreter records the URL and stops. + "chain" | "boot" | "imgexec" | "sanboot" => { + let url = args + .iter() + .find(|arg| !arg.starts_with('-')) + .cloned() + .unwrap_or_default(); + self.run.chains.push(url); + CmdOutcome::Done + } + "reboot" => { + self.run.rebooted = true; + CmdOutcome::Done + } + "choose" => { + let mut index = 0; + let mut setting = None; + while index < args.len() { + match args[index].as_str() { + "--default" | "--timeout" | "--key" => index += 2, + option if option.starts_with('-') => index += 1, + name => { + setting = Some(name.to_string()); + index += 1; + } + } + } + if let Some(name) = setting { + self.vars.insert(name, self.selection.clone()); + } + CmdOutcome::Rc(0) + } + "read" => { + if let Some(name) = args.iter().find(|arg| !arg.starts_with('-')) { + let typed = self.input.clone(); + self.vars.insert(name.clone(), typed); + } + CmdOutcome::Rc(0) + } + "prompt" => CmdOutcome::Rc(i32::from(self.prompt_times_out)), + "echo" | "console" | "menu" | "item" | "sleep" | "ifstat" | "route" | "shell" + | "kernel" | "initrd" | "imgfetch" => CmdOutcome::Rc(0), + other => CmdOutcome::Abort(format!("unknown iPXE command `{other}`")), + } + } + } + + /// iPXE splits on whitespace only — it has no shell quoting. + fn tokenize(line: &str) -> Vec { + line.split_whitespace().map(str::to_string).collect() + } + + // ---- fixtures ---- + + fn test_iso(id: &str, family: DistroFamily, entries: Vec) -> IsoMeta { + IsoMeta { + id: id.to_string(), + filename: format!("{id}.iso"), + size_bytes: 700 * 1024 * 1024, + sha256_hex: None, + uploaded_at: time::OffsetDateTime::UNIX_EPOCH, + introspection: openpxe_iso_store::IntrospectionReport { + family, + ..Default::default() + }, + boot_entries: entries, + source: IsoSource::Local, + password_hash: None, + category: IsoCategory::default(), + } + } + + fn linux_entry(id: &str) -> BootEntry { + BootEntry { + id: id.to_string(), + title: format!("Title {id}"), + kind: BootKind::LinuxKernel { + kernel_url: format!("iso/{id}/boot/vmlinuz"), + initrd_urls: vec![format!("iso/{id}/boot/initrd")], + args: KernelArgs::default(), + }, + } + } + + // ---- top-level menu ---- + + #[test] + fn every_top_menu_item_reaches_its_own_script() { + let settings = Settings::default(); + let menu = render_menu(&[], &settings, BASE); + for (item, expected) in [ + ("local", "/boot/_local.ipxe"), + ("linux", "/boot/_linux_menu.ipxe"), + ("windows", "/boot/_windows_menu.ipxe"), + ("tools", "/boot/_tools_menu.ipxe"), + ("queue", "/boot/_queue.ipxe"), + ] { + let run = IpxeSim::new(item).run(&menu); + assert_eq!( + run.chains, + vec![format!("{BASE}{expected}")], + "item `{item}` did not reach its script" + ); + assert_eq!(run.exit, None, "item `{item}` should not exit"); + } + } + + #[test] + fn exit_item_exits_the_firmware() { + let menu = render_menu(&[], &Settings::default(), BASE); + let run = IpxeSim::new("exit").run(&menu); + assert_eq!(run.exit, Some(0)); + assert!(run.chains.is_empty(), "{:?}", run.chains); + } + + #[test] + fn unknown_architecture_does_not_abort_the_menu() { + // The old arch-label line ended in `|| true`, which is not an + // iPXE command: any arch/platform pair outside the allowlist + // aborted the whole script. `|| echo` keeps the generic label. + for (arch, platform) in [("riscv64", "efi"), ("arm64", "pcbios"), ("i386", "efi")] { + let run = IpxeSim::new("local") + .with_var("buildarch", arch) + .with_var("platform", platform) + .run(&render_menu(&[], &Settings::default(), BASE)); + assert_eq!(run.chains.len(), 1, "menu aborted on {arch}/{platform}"); + } + } + + // ---- family (installer) menu ---- + + #[test] + fn family_menu_dispatches_each_entry_and_back() { + let isos = vec![ + test_iso( + "alpha", + DistroFamily::DebianUbuntu, + vec![linux_entry("alpha-linux")], + ), + test_iso( + "beta", + DistroFamily::Alpine, + vec![linux_entry("beta-linux")], + ), + ]; + let menu = render_family_menu(&isos, BASE, false); + for id in ["alpha-linux", "beta-linux"] { + let run = IpxeSim::new(id).run(&menu); + assert_eq!( + run.chains, + vec![format!("{BASE}/boot/{id}.ipxe?mac=aa:bb:cc:dd:ee:ff")], + "entry `{id}` fell through to the menu" + ); + } + let run = IpxeSim::new("back").run(&menu); + assert_eq!(run.chains, vec![format!("{BASE}/boot.ipxe")]); + } + + // ---- tools menu ---- + + #[test] + fn tools_menu_dispatches_every_item() { + let mut iso = test_iso( + "tool", + DistroFamily::Unknown, + vec![linux_entry("tool-disk")], + ); + iso.category = IsoCategory::Tools; + let menu = render_tools_menu(&[iso], BASE); + for (item, expected) in [ + ("util", format!("{BASE}/boot/_util.ipxe")), + ("shell", format!("{BASE}/boot/_shell.ipxe")), + ("nic", format!("{BASE}/boot/_nic.ipxe")), + ("back", format!("{BASE}/boot.ipxe")), + ( + "tool-disk", + format!("{BASE}/boot/tool-disk.ipxe?mac=aa:bb:cc:dd:ee:ff"), + ), + ] { + let run = IpxeSim::new(item).run(&menu); + assert_eq!(run.chains, vec![expected], "item `{item}`"); + } + let run = IpxeSim::new("reboot").run(&menu); + assert!(run.rebooted, "reboot item did not reboot"); + let run = IpxeSim::new("firmware").run(&menu); + assert_eq!(run.exit, Some(0), "firmware item did not exit"); + } + + // ---- utilities menu ---- + + #[test] + fn util_menu_dispatches_memtest_and_back() { + let menu = render_util(BASE); + let run = IpxeSim::new("memtest").run(&menu); + assert_eq!(run.chains, vec![format!("{BASE}/ipxe/memtest.bin")]); + let run = IpxeSim::new("back").run(&menu); + assert_eq!(run.chains, vec![format!("{BASE}/boot/_tools_menu.ipxe")]); + } + + // ---- small scripts reached from the menus ---- + + #[test] + fn shell_and_queue_scripts_end_in_their_chain() { + let run = IpxeSim::new("").run(&render_shell(BASE)); + assert_eq!(run.chains, vec![format!("{BASE}/boot.ipxe")]); + let run = IpxeSim::new("").run(&render_queue_entry(BASE)); + assert_eq!( + run.chains, + vec![format!("{BASE}/api/queue/join?mac=aa:bb:cc:dd:ee:ff")] + ); + } + + #[test] + fn nic_info_prompt_timeout_returns_to_menu() { + let mut sim = IpxeSim::new(""); + sim.prompt_times_out = true; + let run = sim.run(&render_nic_info(BASE)); + assert_eq!(run.chains, vec![format!("{BASE}/boot.ipxe")]); + } + + #[test] + fn password_prompt_empty_enter_returns_to_main_menu() { + let menu = render_password_prompt("alpha-linux", "Alpha.iso", BASE); + let run = IpxeSim::new("").run(&menu); + assert_eq!(run.chains, vec![format!("{BASE}/boot.ipxe")]); + let run = IpxeSim::new("").with_input("hunter2").run(&menu); + assert_eq!( + run.chains, + vec![format!( + "{BASE}/boot/alpha-linux.ipxe?token=hunter2&mac=aa:bb:cc:dd:ee:ff" + )] + ); + } + + #[test] + fn boot_entry_scripts_recover_to_the_menu_on_failed_fetches() { + // Menus now chain entries with `--replace`, so the entry script — + // not the caller — has to recover from a failed kernel/initrd + // fetch. Otherwise a flaky mirror drops the client to firmware. + let settings = Settings::default(); + let s = render_entry(&linux_entry("alpha-linux"), &settings, BASE, None); + for line in s + .lines() + .filter(|line| line.starts_with("kernel ") || line.starts_with("initrd ")) + { + assert!( + line.ends_with("|| goto failed"), + "missing failure branch on `{line}`" + ); + } + assert!(s.contains("chain --replace http://10.0.0.5/boot.ipxe")); + } +} diff --git a/crates/http-api/src/snapshots/openpxe_http_api__ipxe_script__password_tests__render_menu_snapshot.snap b/crates/http-api/src/snapshots/openpxe_http_api__ipxe_script__password_tests__render_menu_snapshot.snap index d4f90a1..5cf32e9 100644 --- a/crates/http-api/src/snapshots/openpxe_http_api__ipxe_script__password_tests__render_menu_snapshot.snap +++ b/crates/http-api/src/snapshots/openpxe_http_api__ipxe_script__password_tests__render_menu_snapshot.snap @@ -1,5 +1,6 @@ --- source: crates/http-api/src/ipxe_script.rs +assertion_line: 787 expression: rendered --- #!ipxe @@ -9,7 +10,9 @@ set esc:hex 1b set cls ${esc:string}[2J iseq ${platform} efi && console --picture http://10.0.0.5/branding/pxe-logo --top 290 || console set arch-label ${buildarch} ${platform} -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 +iseq ${buildarch} i386 && iseq ${platform} pcbios && set arch-label x86 BIOS || echo +iseq ${buildarch} x86_64 && iseq ${platform} efi && set arch-label x86_64 UEFI || echo +iseq ${buildarch} arm64 && iseq ${platform} efi && set arch-label arm64 UEFI || echo :menu menu OpenPXE - network boot menu item --gap @@ -27,10 +30,16 @@ item --key x exit Exit iPXE item --gap item --gap -- OpenPXE vX.Y.Z - ${arch-label} choose --default queue --timeout 600000 target || goto menu -iseq ${target} local && chain http://10.0.0.5/boot/_local.ipxe || goto menu -iseq ${target} linux && chain http://10.0.0.5/boot/_linux_menu.ipxe || goto menu -iseq ${target} windows && chain http://10.0.0.5/boot/_windows_menu.ipxe || goto menu -iseq ${target} tools && chain http://10.0.0.5/boot/_tools_menu.ipxe || goto menu -iseq ${target} queue && chain http://10.0.0.5/boot/_queue.ipxe || goto menu -iseq ${target} exit && exit || goto menu -goto menu +goto ${target} || goto menu +:local +chain --replace http://10.0.0.5/boot/_local.ipxe || goto menu +:linux +chain --replace http://10.0.0.5/boot/_linux_menu.ipxe || goto menu +:windows +chain --replace http://10.0.0.5/boot/_windows_menu.ipxe || goto menu +:tools +chain --replace http://10.0.0.5/boot/_tools_menu.ipxe || goto menu +:queue +chain --replace http://10.0.0.5/boot/_queue.ipxe || goto menu +:exit +exit 0 diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index cd129c3..a7f76ea 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -495,11 +495,11 @@ async fn reboot_and_firmware_exit_in_tools_menu() { "tools menu missing firmware-exit item:\n{tools}" ); assert!( - tools.contains("&& reboot"), + tools.contains(":reboot\nreboot || goto menu"), "reboot command not wired:\n{tools}" ); assert!( - tools.contains("&& exit 0"), + tools.contains(":firmware\nexit 0"), "firmware exit command not wired:\n{tools}" ); }