Answers the operational question 'can a machine try all three boot binaries in one go?' The protocol can't carry three NBPs in one cycle (one boot file per DHCP round, the Secure-Boot refusal happens after handoff with no error report, and the broken-NIC case specifically needs the firmware itself to load builtin-driver iPXE — GRUB's network rides the same broken firmware stack). What we CAN do is make the walk a once-per-machine-ever event and give operators a way to skip it: - Learned driver modes persist (<work_dir>/driver_modes.json). A MAC that reaches the Shim rung, or confirms an iPXE handoff at Builtin, is pinned to disk: immune to the 30-min TTL, reloaded at startup. The file only carries exceptions — a healthy fleet never writes it. Corrupt file starts empty (standard crash-cache policy). - Boot rules gain an optional driver_mode pin (auto/firmware/builtin/ shim), consulted by the DHCP proxy BEFORE the escalation ladder: 'this OUI is a Secure Boot rack -> serve shim immediately' = zero failed cycles. Mode-only rules coexist with target rules (a pin doesn't shadow a later target match). Editor column on Hosts tab. - grub.cfg now tries to chainload all-drivers iPXE before showing the signed menu: with SB off the chainload succeeds and the client gets the full iPXE feature set back in the SAME boot (self-healing for mis-escalations, and the handoff then pins the working mode); with SB on, shim's verifier refuses it inline — no reboot — and the signed menu appears. DhcpProxyServer now takes the escalation table + rules store from main (persistence path comes from the configured work dir). Validation: clippy clean, fmt clean, 299 workspace tests green (+9: persistence round-trip across restart, Shim pin survives TTL, learned Builtin survives TTL, corrupt-file recovery, default-mode-never- persisted, rule-pin matching incl. unknown-mode tolerance and pin/target coexistence, GRUB chainload-before-menu ordering, API round-trip of the driver_mode field). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
193 lines
7.6 KiB
Rust
193 lines
7.6 KiB
Rust
//! GRUB menu rendering for the Secure Boot chain (v0.7.0).
|
|
//!
|
|
//! Secure-Boot-enabled firmware refuses our unsigned iPXE, so those
|
|
//! clients are automatically escalated (see `openpxe_dhcp_proxy::
|
|
//! escalation`) to the Microsoft-signed Fedora `shim` → signed `grub`
|
|
//! chain. GRUB then fetches `grub.cfg` from this server (TFTP `$prefix`
|
|
//! resolution, or HTTP when the whole chain came over HTTP Boot) — and
|
|
//! this module renders that config from the same boot-entry model that
|
|
//! renders `boot.ipxe`.
|
|
//!
|
|
//! Scope: **Linux kernel entries only.** A signed GRUB will only execute
|
|
//! kernels that pass shim verification — i.e. distro-signed kernels —
|
|
//! which is exactly what `LinuxKernel` boot entries point at. `sanboot`
|
|
//! ISO emulation and `wimboot` are iPXE mechanisms with no signed
|
|
//! equivalent; those entries are omitted here, and the menu says so.
|
|
//! (Windows deployment under Secure Boot has no legitimate unsigned
|
|
//! path — per project policy we never ship test-signed binaries or touch
|
|
//! client trust stores.)
|
|
//!
|
|
//! The kernel/initrd lines use GRUB's `(http,host:port)` device syntax;
|
|
//! Fedora's signed netboot GRUB carries the `http`, `tftp` and `efinet`
|
|
//! modules built in, so no unsigned module loading is required.
|
|
|
|
use openpxe_iso_store::{BootKind, IsoMeta};
|
|
use std::fmt::Write as _;
|
|
|
|
/// Render the full `grub.cfg` for the signed-GRUB menu.
|
|
///
|
|
/// `base_url` is the public HTTP base (`http://10.0.0.5` or
|
|
/// `http://10.0.0.5:8080`) — converted to GRUB's `(http,host:port)`
|
|
/// device prefix for kernel/initrd fetches.
|
|
#[must_use]
|
|
pub fn render_grub_menu(isos: &[IsoMeta], base_url: &str) -> String {
|
|
let base = base_url.trim_end_matches('/');
|
|
let dev = grub_http_device(base);
|
|
let mut s = String::new();
|
|
let _ = writeln!(s, "# OpenPXE — Secure Boot menu (signed shim+GRUB chain)");
|
|
// v0.7.1: before showing the limited signed menu, try to hand the
|
|
// boot back to full iPXE *in this same boot cycle*. With Secure Boot
|
|
// OFF the chainload succeeds and the client gets the complete iPXE
|
|
// feature set (sanboot, wimboot, the full menu) despite having been
|
|
// escalated here. With Secure Boot ON, shim's verifier refuses the
|
|
// unsigned image INLINE — no reboot, no failed cycle — and execution
|
|
// falls through to the signed menu below. The all-drivers build is
|
|
// used because a MAC only lands here after the firmware-net build
|
|
// already failed once.
|
|
let _ = writeln!(s, "if [ \"$grub_cpu\" = \"arm64\" ]; then");
|
|
let _ = writeln!(s, " set openpxe_ipxe=ipxe-arm64.efi");
|
|
let _ = writeln!(s, "else");
|
|
let _ = writeln!(s, " set openpxe_ipxe=ipxe.efi");
|
|
let _ = writeln!(s, "fi");
|
|
let _ = writeln!(s, "if chainloader {dev}/ipxe/$openpxe_ipxe ; then");
|
|
let _ = writeln!(s, " boot");
|
|
let _ = writeln!(s, "fi");
|
|
let _ = writeln!(s);
|
|
let _ = writeln!(s, "set timeout=30");
|
|
let _ = writeln!(s, "set default=0");
|
|
let _ = writeln!(s);
|
|
|
|
let mut entries = 0usize;
|
|
for iso in isos {
|
|
for entry in &iso.boot_entries {
|
|
let BootKind::LinuxKernel {
|
|
kernel_url,
|
|
initrd_urls,
|
|
args,
|
|
} = &entry.kind
|
|
else {
|
|
continue;
|
|
};
|
|
// GRUB menu titles: keep quotes out of the label.
|
|
let title = entry.title.replace('"', "'");
|
|
let cmdline = args.cmdline.replace("${base-url}", base);
|
|
let _ = writeln!(s, "menuentry \"{} — {title}\" {{", iso.filename);
|
|
let _ = writeln!(s, " linux {dev}/{kernel_url} {cmdline}");
|
|
if !initrd_urls.is_empty() {
|
|
let _ = write!(s, " initrd");
|
|
for u in initrd_urls {
|
|
let _ = write!(s, " {dev}/{u}");
|
|
}
|
|
let _ = writeln!(s);
|
|
}
|
|
let _ = writeln!(s, "}}");
|
|
let _ = writeln!(s);
|
|
entries += 1;
|
|
}
|
|
}
|
|
|
|
if entries == 0 {
|
|
let _ = writeln!(
|
|
s,
|
|
"menuentry \"No Secure-Boot-bootable images on this server yet\" {{ true }}"
|
|
);
|
|
let _ = writeln!(s);
|
|
}
|
|
// Always give the operator a way off this screen.
|
|
let _ = writeln!(s, "menuentry \"Boot from local disk\" {{");
|
|
let _ = writeln!(s, " exit");
|
|
let _ = writeln!(s, "}}");
|
|
s
|
|
}
|
|
|
|
/// `http://10.0.0.5:8080` → `(http,10.0.0.5:8080)`. GRUB wants the
|
|
/// scheme as the device type and host[:port] as the device address.
|
|
fn grub_http_device(base: &str) -> String {
|
|
let host = base
|
|
.trim_start_matches("http://")
|
|
.trim_start_matches("https://");
|
|
format!("(http,{host})")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use openpxe_iso_store::{BootEntry, IsoSource, KernelArgs};
|
|
|
|
fn linux_iso() -> IsoMeta {
|
|
IsoMeta {
|
|
id: "alp".into(),
|
|
filename: "alpine.iso".into(),
|
|
size_bytes: 1,
|
|
sha256_hex: None,
|
|
uploaded_at: time::OffsetDateTime::UNIX_EPOCH,
|
|
source: IsoSource::Local,
|
|
introspection: openpxe_iso_store::IntrospectionReport::default(),
|
|
boot_entries: vec![BootEntry {
|
|
id: "alp-linux".into(),
|
|
title: "Linux installer".into(),
|
|
kind: BootKind::LinuxKernel {
|
|
kernel_url: "iso/alp/boot/vmlinuz".into(),
|
|
initrd_urls: vec!["iso/alp/boot/initrd".into()],
|
|
args: KernelArgs {
|
|
cmdline: "quiet repo=${base-url}/iso/alp.iso".into(),
|
|
},
|
|
},
|
|
}],
|
|
category: openpxe_iso_store::IsoCategory::default(),
|
|
password_hash: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn renders_linux_entries_with_http_device_urls() {
|
|
let cfg = render_grub_menu(&[linux_iso()], "http://10.0.0.5:8080/");
|
|
assert!(
|
|
cfg.contains("menuentry \"alpine.iso — Linux installer\""),
|
|
"{cfg}"
|
|
);
|
|
assert!(
|
|
cfg.contains("linux (http,10.0.0.5:8080)/iso/alp/boot/vmlinuz quiet repo=http://10.0.0.5:8080/iso/alp.iso"),
|
|
"{cfg}"
|
|
);
|
|
assert!(
|
|
cfg.contains("initrd (http,10.0.0.5:8080)/iso/alp/boot/initrd"),
|
|
"{cfg}"
|
|
);
|
|
assert!(cfg.contains("Boot from local disk"), "{cfg}");
|
|
}
|
|
|
|
#[test]
|
|
fn config_tries_ipxe_chainload_before_menu() {
|
|
// v0.7.1: SB-off machines recover full iPXE in the same boot;
|
|
// SB-on machines fail the chainload inline and reach the menu.
|
|
let cfg = render_grub_menu(&[linux_iso()], "http://10.0.0.5:8080");
|
|
let chain_pos = cfg
|
|
.find("if chainloader (http,10.0.0.5:8080)/ipxe/$openpxe_ipxe ; then")
|
|
.expect("chainload attempt missing");
|
|
let menu_pos = cfg.find("menuentry").expect("menu missing");
|
|
assert!(
|
|
chain_pos < menu_pos,
|
|
"chainload must precede the menu:\n{cfg}"
|
|
);
|
|
// Arch-conditional binary selection via GRUB's $grub_cpu.
|
|
assert!(cfg.contains("set openpxe_ipxe=ipxe-arm64.efi"), "{cfg}");
|
|
assert!(cfg.contains("set openpxe_ipxe=ipxe.efi"), "{cfg}");
|
|
}
|
|
|
|
#[test]
|
|
fn sanboot_and_wimboot_entries_are_omitted() {
|
|
let mut iso = linux_iso();
|
|
iso.boot_entries = vec![BootEntry {
|
|
id: "win".into(),
|
|
title: "Windows".into(),
|
|
kind: BootKind::SanBootIso {
|
|
iso_url: "iso/win.iso".into(),
|
|
},
|
|
}];
|
|
let cfg = render_grub_menu(&[iso], "http://10.0.0.5");
|
|
assert!(!cfg.contains("Windows"), "{cfg}");
|
|
assert!(cfg.contains("No Secure-Boot-bootable images"), "{cfg}");
|
|
}
|
|
}
|