From 3a32d65fb74df42bf4a4a6a2aec49b5f1f5aaa11 Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Tue, 9 Jun 2026 20:17:18 -0400 Subject: [PATCH] v0.7.0: Secure Boot chain, boot rules + decision webhook, tokenized answer files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three features, all zero-toggle and principle-clean (single static musl binary, container-first, no test certs, no client trust-store changes). Secure Boot via signed shim+GRUB (automatic): - The v0.6.1 escalation ladder gains a third rung: Firmware -> Builtin -> Shim. Secure-Boot firmware downloads our unsigned iPXE but refuses to execute it — indistinguishable from a failed chainload — so after two unconfirmed attempts the MAC is offered Fedora's Microsoft-signed shimx64.efi, which loads the signed GRUB, which fetches a server-rendered grub.cfg. Fully signed chain, SB stays on. - scripts/fetch-shim.sh pulls shim-x64/grub2-efi-x64 (+aa64 best-effort) from the official Fedora 43 packages and ships the EFI binaries byte-for-byte unmodified; Dockerfile fetch stage gained rpm2cpio/cpio. - New grub_script renderer (Linux kernel entries only — signed GRUB only boots signed kernels; sanboot/wimboot have no signed equivalent and are omitted with an explanatory menu line). - TFTP server gains a DynamicAsset hook for server-rendered names (grub.cfg); HTTP serves the same config under /ipxe/grub.cfg for native UEFI HTTP Boot chains. Arch-aware fallback walks back down the ladder where no shim exists (BIOS, IA32). Boot rules + decision webhook (open 'Matrix Boot'): - Ordered first-match-wins rules over MAC prefix + client arch (the DHCP proxy now bakes arch into the boot.ipxe chain URL), generalizing per-MAC pins. Persisted to boot_rules.json; GET/PUT /api/boot-rules; rules editor + webhook field on the Hosts tab. - Optional pixiecore-style webhook: unmatched boots GET ?mac=&arch= and 200 {"target":"id"} chains to it. Fail-open with a 2s budget — a dead endpoint can never block PXE. - Decision order: exact pin -> rules -> webhook -> menu. Empty config is byte-for-byte the previous behavior. Tokenized answer files (the post-WDS/CVE-2026-0386 hardening): - Every generated unattended URL (inst.ks / preseed url / autoinstall seed) now carries a 4h boot-scoped token; /unattended/{id} and the cloud-init seed routes require it (or an operator session) once an admin exists. Stops answer-file credential harvesting by anything else on the network. No toggle; setup-mode installs stay open. Validation: clippy clean, fmt clean, 290 workspace tests green (+18 new across boot_tokens, boot_rules, arch ladder, escalation, grub renderer, and four new full-flow integration tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 16 +- Cargo.toml | 2 +- crates/core/src/arch.rs | 91 ++++++++- crates/core/src/boot_rules.rs | 284 ++++++++++++++++++++++++++ crates/core/src/boot_tokens.rs | 138 +++++++++++++ crates/core/src/lib.rs | 4 + crates/dhcp-proxy/src/escalation.rs | 38 +++- crates/dhcp-proxy/src/reply.rs | 16 +- crates/http-api/src/app.rs | 296 ++++++++++++++++++++++++---- crates/http-api/src/auth.rs | 7 + crates/http-api/src/grub_script.rs | 156 +++++++++++++++ crates/http-api/src/lib.rs | 1 + crates/http-api/src/state.rs | 12 +- crates/http-api/tests/full_flow.rs | 141 +++++++++++++ crates/ipxe-assets/src/lib.rs | 10 +- crates/openpxe/src/main.rs | 19 ++ crates/tftp/src/lib.rs | 2 +- crates/tftp/src/server.rs | 26 ++- crates/webui/src/app.js | 99 +++++++++- deploy/docker/Dockerfile | 10 +- scripts/fetch-shim.sh | 96 +++++++++ 21 files changed, 1396 insertions(+), 68 deletions(-) create mode 100644 crates/core/src/boot_rules.rs create mode 100644 crates/core/src/boot_tokens.rs create mode 100644 crates/http-api/src/grub_script.rs create mode 100755 scripts/fetch-shim.sh diff --git a/Cargo.lock b/Cargo.lock index 23d6182..8f1a6c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2836,7 +2836,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openpxe" -version = "0.6.3" +version = "0.7.0" dependencies = [ "anyhow", "axum", @@ -2858,7 +2858,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.6.3" +version = "0.7.0" dependencies = [ "anyhow", "base64", @@ -2885,7 +2885,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.6.3" +version = "0.7.0" dependencies = [ "anyhow", "bytes", @@ -2900,7 +2900,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.6.3" +version = "0.7.0" dependencies = [ "anyhow", "axum", @@ -2936,7 +2936,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.6.3" +version = "0.7.0" dependencies = [ "openpxe-core", "rust-embed", @@ -2946,7 +2946,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.6.3" +version = "0.7.0" dependencies = [ "anyhow", "bcrypt", @@ -2975,7 +2975,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.6.3" +version = "0.7.0" dependencies = [ "anyhow", "bytes", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.6.3" +version = "0.7.0" [[package]] name = "p256" diff --git a/Cargo.toml b/Cargo.toml index 12622f3..121746b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.6.3" +version = "0.7.0" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/core/src/arch.rs b/crates/core/src/arch.rs index f99e961..8012a11 100644 --- a/crates/core/src/arch.rs +++ b/crates/core/src/arch.rs @@ -23,13 +23,21 @@ pub enum ClientArch { Unknown(u16), } -/// Which iPXE network backend to advertise to a client (v0.6.1). +/// Which boot binary family to advertise to a client (v0.6.1, extended +/// v0.7.0). /// /// OpenPXE serves [`DriverMode::Firmware`] first (the firmware's own NIC -/// stack, via `snponly`/`undionly`) and only escalates a specific MAC to -/// [`DriverMode::Builtin`] (iPXE's bundled NIC drivers) automatically, when a -/// firmware-net boot fails to chainload. There is no operator toggle — the -/// DHCP proxy decides per client. +/// stack, via `snponly`/`undionly`) and escalates a specific MAC +/// automatically when a boot never completes its handoff: +/// `Firmware → Builtin → Shim`. There is no operator toggle — the DHCP +/// proxy decides per client. +/// +/// The `Shim` rung (v0.7.0) covers Secure Boot: firmware with SB enabled +/// downloads our unsigned iPXE fine but refuses to *execute* it, which +/// looks exactly like a failed chainload. After both iPXE builds go +/// unconfirmed, the client is offered the Microsoft-signed shim, which +/// loads the signed GRUB, which fetches a server-rendered menu — a fully +/// signed chain that boots signed distro kernels with SB still on. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DriverMode { @@ -40,6 +48,9 @@ pub enum DriverMode { /// iPXE's own bundled NIC drivers (`ipxe.efi`, `ipxe.pxe`). Fallback for /// hardware whose firmware NIC stack is missing or buggy. Builtin, + /// Microsoft-signed shim + GRUB chain (`shimx64.efi`). Final fallback + /// for Secure-Boot-enabled UEFI clients that refuse unsigned iPXE. + Shim, } impl ClientArch { @@ -88,20 +99,43 @@ impl ClientArch { // IA32 UEFI. (Self::Ia32Uefi, DriverMode::Firmware) => "snponly-i386.efi", (Self::Ia32Uefi, DriverMode::Builtin) => "ipxe-i386.efi", + // No signed Shim chain for BIOS (no Secure Boot there) or + // IA32 UEFI (Fedora publishes no 32-bit shim; SB-on IA32 + // clients are vanishingly rare). Same outcome as the + // no-binary arches below, listed separately for the comment. + #[allow(clippy::match_same_arms)] + (Self::LegacyX86 | Self::Ia32Uefi, DriverMode::Shim) => return None, // x86_64 UEFI — the overwhelmingly common modern client. (Self::X64Uefi, DriverMode::Firmware) => "snponly.efi", (Self::X64Uefi, DriverMode::Builtin) => "ipxe.efi", + (Self::X64Uefi, DriverMode::Shim) => "shimx64.efi", // ARM64 UEFI. (Self::Arm64Uefi, DriverMode::Firmware) => "snponly-arm64.efi", (Self::Arm64Uefi, DriverMode::Builtin) => "ipxe-arm64.efi", + (Self::Arm64Uefi, DriverMode::Shim) => "shimaa64.efi", // ARM32 UEFI: upstream boot.ipxe.org publishes no prebuilt binary - // for this arch in either mode. Unknown arches likewise. Return + // for this arch in any mode. Unknown arches likewise. Return // None so the DHCP proxy declines rather than advertising a file // we can't serve. (Self::Arm32Uefi | Self::Unknown(_), _) => return None, }) } + /// Like [`Self::ipxe_bootfile_mode`], but walks back down the + /// escalation ladder (`Shim → Builtin → Firmware`) when the requested + /// mode has no binary for this arch — e.g. a BIOS client whose + /// escalation state reached `Shim` (BIOS has no Secure Boot) falls + /// back to the all-drivers build instead of being ignored. + #[must_use] + pub fn bootfile_with_fallback(self, mode: DriverMode) -> Option<&'static str> { + let ladder: &[DriverMode] = match mode { + DriverMode::Shim => &[DriverMode::Shim, DriverMode::Builtin, DriverMode::Firmware], + DriverMode::Builtin => &[DriverMode::Builtin, DriverMode::Firmware], + DriverMode::Firmware => &[DriverMode::Firmware], + }; + ladder.iter().find_map(|m| self.ipxe_bootfile_mode(*m)) + } + #[must_use] pub fn as_str(self) -> &'static str { match self { @@ -233,6 +267,51 @@ mod tests { assert_eq!(DriverMode::default(), DriverMode::Firmware); } + #[test] + fn shim_mode_maps_to_signed_chain_on_uefi_only() { + assert_eq!( + ClientArch::X64Uefi.ipxe_bootfile_mode(DriverMode::Shim), + Some("shimx64.efi") + ); + assert_eq!( + ClientArch::Arm64Uefi.ipxe_bootfile_mode(DriverMode::Shim), + Some("shimaa64.efi") + ); + // No Secure Boot on BIOS, no published 32-bit shim. + assert_eq!( + ClientArch::LegacyX86.ipxe_bootfile_mode(DriverMode::Shim), + None + ); + assert_eq!( + ClientArch::Ia32Uefi.ipxe_bootfile_mode(DriverMode::Shim), + None + ); + } + + #[test] + fn fallback_walks_down_the_ladder() { + // BIOS escalated to Shim → falls back to the all-drivers build. + assert_eq!( + ClientArch::LegacyX86.bootfile_with_fallback(DriverMode::Shim), + Some("ipxe.pxe") + ); + // UEFI x64 at Shim gets the real shim. + assert_eq!( + ClientArch::X64Uefi.bootfile_with_fallback(DriverMode::Shim), + Some("shimx64.efi") + ); + // Plain modes are unchanged. + assert_eq!( + ClientArch::X64Uefi.bootfile_with_fallback(DriverMode::Firmware), + Some("snponly.efi") + ); + // Arches with nothing stay None. + assert_eq!( + ClientArch::Arm32Uefi.bootfile_with_fallback(DriverMode::Shim), + None + ); + } + #[test] fn firmware_class_detects_ipxe_over_pxeclient() { let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE")); diff --git a/crates/core/src/boot_rules.rs b/crates/core/src/boot_rules.rs new file mode 100644 index 0000000..2949221 --- /dev/null +++ b/crates/core/src/boot_rules.rs @@ -0,0 +1,284 @@ +//! Label-based boot rules + boot-decision webhook (v0.7.0). +//! +//! Generalizes [`crate::host_bindings::HostBindings`] (exact-MAC pins) +//! into ordered, first-match-wins rules over what the boot chain knows +//! about a client — MAC prefix (OUI or longer) and firmware +//! architecture — plus an optional outbound webhook so external +//! automation (CMDB, netbox, a shell script) can decide the boot target +//! per machine, pixiecore-style. +//! +//! Decision order in the boot script handler, most-specific first: +//! 1. exact per-MAC host binding (operator pin) +//! 2. first matching enabled rule here +//! 3. webhook, if configured (fail-open: timeout/error → menu) +//! 4. interactive menu +//! +//! With no rules and no webhook configured the behavior is byte-for-byte +//! what it was before this feature existed — no toggles to flip. +//! +//! Persisted to `/boot_rules.json` with the same "in-memory +//! authoritative, disk is a crash cache, corruption falls back to empty" +//! policy as the host bindings — a bad rules file must never block PXE. + +use crate::host_bindings::normalize_mac; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; + +/// One ordered rule. All present (non-empty) selectors must match — +/// empty selector fields match anything, so a rule with only `arch` set +/// applies to every client of that architecture. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BootRule { + /// Case-insensitive MAC prefix, `:`-separated (e.g. `dc:a6:32` for + /// an OUI, or longer). Empty = any MAC. + #[serde(default)] + pub mac_prefix: String, + /// Client architecture selector — matches `ClientArch::as_str()` + /// (`bios`, `uefi-x64`, `uefi-ia32`, `uefi-arm64`). Empty = any. + #[serde(default)] + pub arch: String, + /// Boot entry id (a `BootEntry::id`) or reserved menu name + /// (`_local`, `_queue`, …) to chain to when this rule matches. + pub target: String, + /// Rules can be parked without deleting them. + #[serde(default = "default_true")] + pub enabled: bool, + /// Operator note shown in the UI (`"all Pi 4s"`, `"QA rack"`). + #[serde(default)] + pub note: String, +} + +fn default_true() -> bool { + true +} + +/// The whole persisted config: ordered rules + optional webhook. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct BootRulesConfig { + pub rules: Vec, + /// Optional boot-decision webhook URL. When set, unmatched boots GET + /// `?mac=&arch=` and a `200 {"target": ""}` + /// reply chains to that target. Anything else (404, timeout, bad + /// JSON) falls through to the menu. Empty = disabled. + pub webhook_url: String, +} + +/// Store for the rules config. Cheap to clone; locks held briefly. +#[derive(Debug, Clone)] +pub struct BootRulesStore { + path: Arc, + inner: Arc>, +} + +impl BootRulesStore { + /// Load from `work_dir/boot_rules.json`, or start empty if absent / + /// unreadable. + #[must_use] + pub fn load_or_default(work_dir: &std::path::Path) -> Self { + let path = work_dir.join("boot_rules.json"); + let inner = match std::fs::read_to_string(&path) { + Ok(text) => match serde_json::from_str::(&text) { + Ok(cfg) => cfg, + Err(e) => { + tracing::warn!( + target: "openpxe::boot_rules", + "boot_rules.json present but unreadable ({e}); starting empty" + ); + BootRulesConfig::default() + } + }, + Err(_) => BootRulesConfig::default(), + }; + Self { + path: Arc::new(path), + inner: Arc::new(RwLock::new(inner)), + } + } + + /// Current config snapshot (for the API / UI). + #[must_use] + pub fn snapshot(&self) -> BootRulesConfig { + self.inner.read().clone() + } + + /// Replace the whole config (the UI saves the full table at once — + /// rules are ordered, so partial updates would be ambiguous). + pub fn replace(&self, mut cfg: BootRulesConfig) { + for r in &mut cfg.rules { + r.mac_prefix = normalize_mac(&r.mac_prefix); + r.arch = r.arch.trim().to_ascii_lowercase(); + r.target = r.target.trim().to_string(); + r.note = r.note.trim().to_string(); + } + cfg.webhook_url = cfg.webhook_url.trim().to_string(); + *self.inner.write() = cfg; + self.persist(); + } + + /// Webhook URL, when configured. + #[must_use] + pub fn webhook_url(&self) -> Option { + let g = self.inner.read(); + if g.webhook_url.is_empty() { + None + } else { + Some(g.webhook_url.clone()) + } + } + + /// First enabled rule matching `(mac, arch)`, in stored order. + /// `arch` is the `ClientArch::as_str()` form when the boot chain + /// passed one along, `None` otherwise (older chains). + #[must_use] + pub fn match_target(&self, mac: &str, arch: Option<&str>) -> Option { + let mac = normalize_mac(mac); + let g = self.inner.read(); + for r in &g.rules { + if !r.enabled || r.target.is_empty() { + continue; + } + if !r.mac_prefix.is_empty() && !mac.starts_with(r.mac_prefix.as_str()) { + continue; + } + if !r.arch.is_empty() { + // An arch-selective rule can only match when the chain + // told us the client's arch. + match arch { + Some(a) if a.eq_ignore_ascii_case(&r.arch) => {} + _ => continue, + } + } + return Some(r.target.clone()); + } + None + } + + fn persist(&self) { + let snap = self.inner.read().clone(); + let body = match serde_json::to_vec_pretty(&snap) { + Ok(b) => b, + Err(e) => { + tracing::warn!(target: "openpxe::boot_rules", "serialize boot_rules.json: {e}"); + return; + } + }; + if let Some(parent) = self.path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let tmp = self.path.with_extension("json.tmp"); + if let Err(e) = std::fs::write(&tmp, body) { + tracing::warn!(target: "openpxe::boot_rules", "write boot_rules.json tmp: {e}"); + return; + } + if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) { + tracing::warn!(target: "openpxe::boot_rules", "rename boot_rules.json: {e}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn rule(mac_prefix: &str, arch: &str, target: &str) -> BootRule { + BootRule { + mac_prefix: mac_prefix.into(), + arch: arch.into(), + target: target.into(), + enabled: true, + note: String::new(), + } + } + + #[test] + fn empty_config_matches_nothing() { + let dir = tempdir().unwrap(); + let s = BootRulesStore::load_or_default(dir.path()); + assert!(s + .match_target("aa:bb:cc:dd:ee:ff", Some("uefi-x64")) + .is_none()); + assert!(s.webhook_url().is_none()); + } + + #[test] + fn first_match_wins_in_order() { + let dir = tempdir().unwrap(); + let s = BootRulesStore::load_or_default(dir.path()); + s.replace(BootRulesConfig { + rules: vec![ + rule("aa:bb:cc", "", "rack-image"), + rule("", "", "catch-all"), + ], + webhook_url: String::new(), + }); + assert_eq!( + s.match_target("AA-BB-CC-00-00-01", None).as_deref(), + Some("rack-image") + ); + assert_eq!( + s.match_target("11:22:33:44:55:66", None).as_deref(), + Some("catch-all") + ); + } + + #[test] + fn arch_selector_requires_known_arch() { + let dir = tempdir().unwrap(); + let s = BootRulesStore::load_or_default(dir.path()); + s.replace(BootRulesConfig { + rules: vec![rule("", "uefi-arm64", "arm-image")], + webhook_url: String::new(), + }); + assert_eq!( + s.match_target("aa:bb:cc:00:00:01", Some("uefi-arm64")) + .as_deref(), + Some("arm-image") + ); + // Wrong arch, or arch unknown to the chain → no match. + assert!(s.match_target("aa:bb:cc:00:00:01", Some("bios")).is_none()); + assert!(s.match_target("aa:bb:cc:00:00:01", None).is_none()); + } + + #[test] + fn disabled_rules_are_skipped() { + let dir = tempdir().unwrap(); + let s = BootRulesStore::load_or_default(dir.path()); + let mut r = rule("", "", "x"); + r.enabled = false; + s.replace(BootRulesConfig { + rules: vec![r], + webhook_url: String::new(), + }); + assert!(s.match_target("aa:bb:cc:00:00:01", None).is_none()); + } + + #[test] + fn config_round_trips_to_disk() { + let dir = tempdir().unwrap(); + let s = BootRulesStore::load_or_default(dir.path()); + s.replace(BootRulesConfig { + rules: vec![rule("DC-A6-32", "", "pi-image")], + webhook_url: " http://automation/boot ".into(), + }); + drop(s); + let s2 = BootRulesStore::load_or_default(dir.path()); + // Prefix was normalized on replace, webhook trimmed. + assert_eq!( + s2.match_target("dc:a6:32:01:02:03", None).as_deref(), + Some("pi-image") + ); + assert_eq!(s2.webhook_url().as_deref(), Some("http://automation/boot")); + } + + #[test] + fn corrupt_file_falls_back_to_empty() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("boot_rules.json"), b"{nope").unwrap(); + let s = BootRulesStore::load_or_default(dir.path()); + assert!(s.snapshot().rules.is_empty()); + } +} diff --git a/crates/core/src/boot_tokens.rs b/crates/core/src/boot_tokens.rs new file mode 100644 index 0000000..e0a923d --- /dev/null +++ b/crates/core/src/boot_tokens.rs @@ -0,0 +1,138 @@ +//! One-time(ish) access tokens for unattended answer files (v0.7.0). +//! +//! Why: answer files routinely embed credentials (local admin passwords, +//! domain-join accounts, root hashes). Serving them to anyone who can +//! GET `/unattended/` is exactly the exposure that got WDS +//! hands-free deployment disabled upstream (CVE-2026-0386 hardening +//! guidance). OpenPXE generates every answer-file URL it injects into a +//! boot chain, so it can scope each URL to the boot that requested it: +//! when a boot script is rendered, a short-lived token is minted and +//! appended; the serving endpoint requires it (or a logged-in operator +//! session, so browser testing keeps working). +//! +//! Deliberately multi-use within the TTL rather than strictly one-shot: +//! real installers fetch the same file more than once (initramfs + +//! installer stage, cloud-init retries), and the token's job is to stop +//! *unrelated* hosts from harvesting credentials, not to count fetches. +//! +//! In-memory only. A server restart invalidates outstanding tokens — +//! acceptable because a restart also interrupts the ISO streaming an +//! in-flight install depends on, and the next boot mints fresh ones. + +use parking_lot::Mutex; +use std::collections::HashMap; +use std::time::{Duration, Instant}; +use uuid::Uuid; + +/// Long enough to cover a slow OS install end-to-end (the answer file is +/// fetched early, but cloud-init can re-read late), short enough that a +/// leaked URL goes stale the same afternoon. +const TOKEN_TTL: Duration = Duration::from_hours(4); + +/// Hard cap on outstanding tokens; past it the oldest is evicted. Tokens +/// are minted once per boot-script render, so this only matters under +/// abuse, and serving must never become a memory-growth vector. +const MAX_TOKENS: usize = 4096; + +#[derive(Debug, Clone)] +struct Grant { + file_id: String, + issued: Instant, +} + +/// In-memory token table. Cheap to clone (`Arc`-shared). +#[derive(Debug, Clone, Default)] +pub struct BootTokens { + inner: std::sync::Arc>>, +} + +impl BootTokens { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Mint a token granting access to unattended file `file_id` for the + /// next [`TOKEN_TTL`]. Returns the opaque token value to embed in the + /// generated URL. + #[must_use] + pub fn mint(&self, file_id: &str) -> String { + self.mint_at(file_id, Instant::now()) + } + + /// Is `token` a live grant for `file_id`? + #[must_use] + pub fn check(&self, token: &str, file_id: &str) -> bool { + self.check_at(token, file_id, Instant::now()) + } + + fn mint_at(&self, file_id: &str, now: Instant) -> String { + let token = Uuid::new_v4().simple().to_string(); + let mut g = self.inner.lock(); + g.retain(|_, gr| now.duration_since(gr.issued) < TOKEN_TTL); + if g.len() >= MAX_TOKENS { + if let Some(oldest) = g + .iter() + .min_by_key(|(_, gr)| gr.issued) + .map(|(k, _)| k.clone()) + { + g.remove(&oldest); + } + } + g.insert( + token.clone(), + Grant { + file_id: file_id.to_string(), + issued: now, + }, + ); + token + } + + fn check_at(&self, token: &str, file_id: &str, now: Instant) -> bool { + let g = self.inner.lock(); + g.get(token) + .is_some_and(|gr| gr.file_id == file_id && now.duration_since(gr.issued) < TOKEN_TTL) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mint_then_check_round_trip() { + let t = BootTokens::new(); + let tok = t.mint("ks-1"); + assert!(t.check(&tok, "ks-1")); + // Multi-use within TTL: a second fetch still passes. + assert!(t.check(&tok, "ks-1")); + // Wrong file id never passes, even with a live token. + assert!(!t.check(&tok, "ks-2")); + // Unknown token never passes. + assert!(!t.check("nope", "ks-1")); + } + + #[test] + fn token_expires_after_ttl() { + let t = BootTokens::new(); + let now = Instant::now(); + let tok = t.mint_at("ks-1", now); + let just_before = TOKEN_TTL.checked_sub(Duration::from_secs(1)).unwrap(); + assert!(t.check_at(&tok, "ks-1", now + just_before)); + assert!(!t.check_at(&tok, "ks-1", now + TOKEN_TTL + Duration::from_secs(1))); + } + + #[test] + fn table_is_capped() { + let t = BootTokens::new(); + let now = Instant::now(); + let first = t.mint_at("f", now); + for i in 0..MAX_TOKENS { + let _ = t.mint_at(&format!("f{i}"), now + Duration::from_secs(1)); + } + // The oldest grant was evicted to stay within the cap. + assert!(!t.check_at(&first, "f", now + Duration::from_secs(2))); + assert!(t.inner.lock().len() <= MAX_TOKENS); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 4f32e83..0426564 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -5,6 +5,8 @@ pub mod arch; pub mod auth; pub mod boot_log; +pub mod boot_rules; +pub mod boot_tokens; pub mod branding; pub mod client; pub mod config; @@ -24,6 +26,8 @@ pub mod wol; pub use arch::{ClientArch, DriverMode, FirmwareClass}; pub use auth::{AdminAccount, AdminPublic, AdminStore}; pub use boot_log::{BootEvent, BootLog}; +pub use boot_rules::{BootRule, BootRulesConfig, BootRulesStore}; +pub use boot_tokens::BootTokens; pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES}; pub use client::{ClientEvent, ClientRegistry, ClientSnapshot}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; diff --git a/crates/dhcp-proxy/src/escalation.rs b/crates/dhcp-proxy/src/escalation.rs index d531da2..710387e 100644 --- a/crates/dhcp-proxy/src/escalation.rs +++ b/crates/dhcp-proxy/src/escalation.rs @@ -132,12 +132,17 @@ impl DriverEscalation { let recent = now.duration_since(entry.last_seen) < SAME_BOOT_DEBOUNCE; if primary && !recent { // A genuinely new boot. If the previous attempt was never - // confirmed, the firmware-net build failed → escalate to - // the all-drivers build. Builtin is the most capable build - // we have, so it's the single escalation target (and a MAC - // already on Builtin simply stays there). + // confirmed, the build we served failed → climb one rung: + // Firmware (firmware NIC stack) → Builtin (iPXE's own + // drivers) → Shim (signed shim+GRUB, v0.7.0 — covers + // Secure Boot firmware that downloads our unsigned iPXE + // but refuses to execute it). Shim is terminal: a MAC + // there stays until its entry TTLs out and resets. if entry.awaiting_confirm { - entry.mode = DriverMode::Builtin; + entry.mode = match entry.mode { + DriverMode::Firmware => DriverMode::Builtin, + DriverMode::Builtin | DriverMode::Shim => DriverMode::Shim, + }; } entry.awaiting_confirm = true; } @@ -221,6 +226,29 @@ mod tests { ); } + #[test] + fn third_unconfirmed_attempt_escalates_to_shim_and_stays() { + // v0.7.0: a Secure-Boot client downloads-but-refuses both unsigned + // iPXE builds; the third boot gets the signed shim chain, and the + // MAC stays there for subsequent boots. + let e = DriverEscalation::new(); + let t0 = Instant::now(); + assert_eq!(e.decide_at("ee", true, t0), DriverMode::Firmware); + assert_eq!( + e.decide_at("ee", true, t0 + Duration::from_mins(1)), + DriverMode::Builtin + ); + assert_eq!( + e.decide_at("ee", true, t0 + Duration::from_mins(2)), + DriverMode::Shim + ); + // Shim is terminal — a fourth unconfirmed boot stays on Shim. + assert_eq!( + e.decide_at("ee", true, t0 + Duration::from_mins(3)), + DriverMode::Shim + ); + } + #[test] fn stale_entry_is_forgotten_and_resets_to_firmware() { let e = DriverEscalation::new(); diff --git a/crates/dhcp-proxy/src/reply.rs b/crates/dhcp-proxy/src/reply.rs index b8460f9..8d8a4d1 100644 --- a/crates/dhcp-proxy/src/reply.rs +++ b/crates/dhcp-proxy/src/reply.rs @@ -49,10 +49,13 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective { // Pass the client's MAC in the query string so the HTTP // layer can short-circuit to a per-MAC binding when one // exists. iPXE substitutes `${mac}` literally before issuing - // the GET, so this stays static across firmwares. + // the GET, so this stays static across firmwares. The arch is + // known *here* from option 93, so it's baked in literally + // (v0.7.0) — it lets boot rules select on architecture. url: format!( - "{}/boot.ipxe?mac=${{mac}}", - ctx.public_base_url.trim_end_matches('/') + "{}/boot.ipxe?mac=${{mac}}&arch={}", + ctx.public_base_url.trim_end_matches('/'), + ctx.arch.as_str() ), }, FirmwareClass::HttpClient => { @@ -60,9 +63,12 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective { // pointing at an EFI executable. We serve the iPXE EFI build for // the negotiated driver mode over HTTP; it'll then do the same // script-fetch the iPXE path does. + // `bootfile_with_fallback` (v0.7.0) walks back down the + // escalation ladder when the negotiated mode has no binary + // for this arch (e.g. Shim on an arch with no signed chain). let name = ctx .arch - .ipxe_bootfile_mode(ctx.driver_mode) + .bootfile_with_fallback(ctx.driver_mode) .unwrap_or("snponly.efi"); BootDirective::HttpScript { url: format!( @@ -72,7 +78,7 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective { ), } } - FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile_mode(ctx.driver_mode) { + FirmwareClass::PxeClient => match ctx.arch.bootfile_with_fallback(ctx.driver_mode) { Some(name) => BootDirective::TftpIpxe { filename: name.to_string(), }, diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index d0a3d17..4e7cd7f 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -212,6 +212,13 @@ pub fn build_router(state: AppState) -> Router { // v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the // limited broadcast + the server's own subnet broadcast. .route("/api/hosts/{mac}/wol", post(api_hosts_wol)) + // v0.7.0: ordered boot rules (MAC prefix / arch → target) + the + // boot-decision webhook. The UI saves the whole config at once + // because rule order is significant. + .route( + "/api/boot-rules", + get(api_boot_rules_get).put(api_boot_rules_put), + ) // Rolling "host log" of boot events: what image actually // started installing on what MAC/IP, and when. Persisted to disk. .route("/api/boot-log", get(api_boot_log)) @@ -570,17 +577,105 @@ async fn boot_top_menu( // `?mac=` so the per-entry handler can record the boot into // the Host log without depending on iPXE substitution at // this stage. - return text_plain(format!( - "#!ipxe\n\ - echo OpenPXE: per-MAC binding -> {target}\n\ - chain {base}/boot/{target}.ipxe?mac={bound_mac} || chain {base}/boot.ipxe\n" - )); + return text_plain(chain_script(base, &target, &bound_mac, "per-MAC binding")); + } + + // v0.7.0 step 2: ordered boot rules (MAC prefix / arch). + let mac_norm = openpxe_core::normalize_mac(mac); + if let Some(target) = state.boot_rules.match_target(&mac_norm, p.arch.as_deref()) { + tracing::info!( + target: "openpxe::http", + mac = %mac_norm, target = %target, "boot rule matched" + ); + record_pre_boot(&state, &isos, &mac_norm, peer_ip, &target); + return text_plain(chain_script(base, &target, &mac_norm, "boot rule")); + } + + // v0.7.0 step 3: boot-decision webhook (fail-open — any error, + // timeout, or non-200 falls through to the menu so a dead + // automation endpoint can never block PXE for the network). + if let Some(url) = state.boot_rules.webhook_url() { + if let Some(target) = webhook_decide(&url, &mac_norm, p.arch.as_deref()).await { + tracing::info!( + target: "openpxe::http", + mac = %mac_norm, target = %target, "boot webhook decided" + ); + record_pre_boot(&state, &isos, &mac_norm, peer_ip, &target); + return text_plain(chain_script(base, &target, &mac_norm, "boot webhook")); + } } } text_plain(render_menu(&isos, &settings, base)) } +/// The short-circuit script all three decision sources (binding, rule, +/// webhook) emit: chain to the target's boot script, falling back to the +/// interactive menu so a stale target can't lock a client out. +fn chain_script(base: &str, target: &str, mac: &str, source: &str) -> String { + format!( + "#!ipxe\n\ + echo OpenPXE: {source} -> {target}\n\ + chain {base}/boot/{target}.ipxe?mac={mac} || chain {base}/boot.ipxe\n" + ) +} + +/// Pre-record a decision-driven boot into the Host log, mirroring what +/// the per-MAC binding path does: reserved `_xxx` targets are operator +/// conveniences, not imaging events, so they're skipped. +fn record_pre_boot( + state: &AppState, + isos: &[openpxe_iso_store::IsoMeta], + mac: &str, + peer_ip: Option, + target: &str, +) { + if target.starts_with('_') { + return; + } + let title = lookup_entry_title(isos, target); + state.boot_log.record(&BootEvent { + timestamp: time::OffsetDateTime::now_utc(), + mac: Some(mac.to_string()), + ip: peer_ip, + target_id: target.to_string(), + target_title: title, + }); +} + +/// Ask the operator's boot-decision webhook for a target. `200` with +/// `{"target": ""}` chains to that target; anything else (including +/// an empty target) means "no opinion". Two-second budget — a booting +/// machine is sitting at a black screen while this runs. +async fn webhook_decide(url: &str, mac: &str, arch: Option<&str>) -> Option { + #[derive(Deserialize)] + struct Decision { + target: String, + } + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(2)) + .build() + .ok()?; + let resp = match client + .get(url) + .query(&[("mac", mac), ("arch", arch.unwrap_or(""))]) + .send() + .await + { + Ok(r) => r, + Err(e) => { + tracing::warn!(target: "openpxe::http", "boot webhook unreachable: {e}"); + return None; + } + }; + if !resp.status().is_success() { + return None; + } + let d: Decision = resp.json().await.ok()?; + let t = d.target.trim().to_string(); + (!t.is_empty()).then_some(t) +} + /// Best-effort human title for a boot entry id — falls back to the id /// itself if the ISO has been deleted between record-time and now. fn lookup_entry_title(isos: &[openpxe_iso_store::IsoMeta], target_id: &str) -> String { @@ -603,6 +698,11 @@ struct BootMenuParams { /// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we /// fall back to the menu unconditionally. mac: Option, + /// v0.7.0: client architecture (`ClientArch::as_str()` form), baked + /// literally into the chain URL by the DHCP proxy, which knows it + /// from option 93. Lets boot rules select on architecture. Absent on + /// chains rendered by older binaries — arch rules simply don't match. + arch: Option, } #[derive(Debug, Deserialize)] @@ -750,7 +850,13 @@ async fn boot_sub( .as_deref() .and_then(|fid| state.unattended.get(fid)) .and_then(|meta| { - build_unattended_args(base, &meta, Some(m), &p) + build_unattended_args( + base, + &meta, + Some(m), + &p, + &state.boot_tokens, + ) }) }) }); @@ -771,10 +877,19 @@ async fn boot_sub( // ─── bundled iPXE binaries (memtest lives here too) ─────────────────────── -async fn ipxe_binary(AxumPath(name): AxumPath) -> Response { +async fn ipxe_binary(State(state): State, AxumPath(name): AxumPath) -> Response { if name.contains('/') || name.contains('\\') { return (StatusCode::BAD_REQUEST, "invalid name").into_response(); } + // v0.7.0: when the whole Secure Boot chain rides HTTP (native UEFI + // HTTP Boot), GRUB resolves `$prefix` to this directory and fetches + // its config from here — rendered live, same as the TFTP path. + if name == "grub.cfg" || name.starts_with("grub.cfg-") { + return text_plain(crate::grub_script::render_grub_menu( + &state.iso_store.list(), + &state.public_base_url, + )); + } let Some(data) = asset_slice(&name) else { return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response(); }; @@ -1382,17 +1497,54 @@ struct UnattendedServeQuery { hostname: Option, #[serde(default)] ip: Option, + /// v0.7.0: short-lived access token minted into the generated URL. + #[serde(default)] + t: Option, } -/// Public: serve a Kickstart/Preseed/answer file with `{{HOSTNAME}}` / +/// v0.7.0: answer files routinely embed credentials, so once an admin +/// account exists they're only served to (a) the boot that the URL was +/// minted for — proven by the token OpenPXE put in that URL — or (b) a +/// logged-in operator (browser testing). Pre-setup installs stay open, +/// matching the auth middleware's bootstrap behavior. +fn unattended_access_allowed( + state: &AppState, + headers: &HeaderMap, + token: Option<&str>, + file_id: &str, +) -> bool { + if !state.admin.is_configured() { + return true; + } + if token.is_some_and(|t| state.boot_tokens.check(t, file_id)) { + return true; + } + crate::auth::session_authenticated(state, headers) +} + +fn unattended_denied() -> Response { + ( + StatusCode::UNAUTHORIZED, + "answer files require the boot-scoped token OpenPXE mints into \ + generated URLs (or an operator session)", + ) + .into_response() +} + +/// Serve a Kickstart/Preseed/answer file with `{{HOSTNAME}}` / /// `{{IP}}` / `{{MAC}}` substituted from the query string. Returns /// `text/plain` so installers (anaconda, debian-installer, Windows setup -/// fetching over HTTP) read it verbatim. +/// fetching over HTTP) read it verbatim. Token-gated since v0.7.0 — see +/// [`unattended_access_allowed`]. async fn serve_unattended( State(state): State, AxumPath(id): AxumPath, + headers: HeaderMap, Query(q): Query, ) -> Response { + if !unattended_access_allowed(&state, &headers, q.t.as_deref(), &id) { + return unattended_denied(); + } let Ok(bytes) = state.unattended.read(&id).await else { return (StatusCode::NOT_FOUND, "no such unattended file").into_response(); }; @@ -1414,8 +1566,15 @@ async fn serve_unattended( async fn serve_unattended_seed( State(state): State, AxumPath((id, ctx, sub)): AxumPath<(String, String, String)>, + headers: HeaderMap, ) -> Response { - let (mac, hostname, ip) = decode_seed_ctx(&ctx); + let (mac, hostname, ip, token) = decode_seed_ctx(&ctx); + // v0.7.0: the seed ctx carries the access token (the seedfrom URL + // can't take a query string). Same gate as the flat answer-file + // route — see `unattended_access_allowed`. + if !unattended_access_allowed(&state, &headers, token.as_deref(), &id) { + return unattended_denied(); + } match sub.as_str() { "user-data" => { let Ok(bytes) = state.unattended.read(&id).await else { @@ -1439,6 +1598,22 @@ async fn serve_unattended_seed( } } +// ─── Boot rules (v0.7.0) ─────────────────────────────────────────────────── + +async fn api_boot_rules_get(State(state): State) -> Json { + Json(state.boot_rules.snapshot()) +} + +async fn api_boot_rules_put( + State(state): State, + Json(cfg): Json, +) -> StatusCode { + let n = cfg.rules.len(); + state.boot_rules.replace(cfg); + tracing::info!(target: "openpxe::http", rules = n, "boot rules replaced"); + StatusCode::NO_CONTENT +} + /// Resolve the deployment profile for a booting MAC: a host pin wins, else /// a queued device's Profile. `None` when neither carries one. fn resolve_profile(state: &AppState, mac: &str) -> Option { @@ -1458,12 +1633,23 @@ fn build_unattended_args( meta: &UnattendedMeta, mac: Option<&str>, profile: &DeployProfile, + tokens: &openpxe_core::BootTokens, ) -> Option { let base = base.trim_end_matches('/'); let id = &meta.id; let host = profile.auto_hostname.as_deref(); let ip = profile.auto_ip.as_deref(); - let query = build_query(&[("mac", mac), ("hostname", host), ("ip", ip)]); + // v0.7.0: every generated answer-file URL carries a fresh boot-scoped + // token; the serving endpoint requires it. See `crate::auth` and + // `openpxe_core::boot_tokens` for the threat model (CVE-2026-0386- + // style credential harvesting from openly-served answer files). + let token = tokens.mint(id); + let query = build_query(&[ + ("mac", mac), + ("hostname", host), + ("ip", ip), + ("t", Some(&token)), + ]); match meta.kind { UnattendedKind::Kickstart => Some(format!("inst.ks={base}/unattended/{id}{query}")), UnattendedKind::Preseed => { @@ -1475,7 +1661,7 @@ fn build_unattended_args( Some(s) } UnattendedKind::Autoinstall => { - let ctx = encode_seed_ctx(mac, host, ip); + let ctx = encode_seed_ctx(mac, host, ip, &token); Some(format!( "autoinstall ds=nocloud-net;s={base}/unattended/{id}/{ctx}/" )) @@ -1500,12 +1686,19 @@ fn build_query(pairs: &[(&str, Option<&str>)]) -> String { // `pct_encode` lives in `openpxe_core::encoding` (v0.5.4) — imported above. -/// Encode `(hostname, ip, mac)` into a single base64url path segment for -/// the cloud-init seed directory. Empty values become empty fields. -fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>) -> String { +/// Encode `(hostname, ip, mac, token)` into a single base64url path +/// segment for the cloud-init seed directory. Empty values become empty +/// fields. The access token rides in here (v0.7.0) because the +/// `seedfrom` URL can't carry a query string. +fn encode_seed_ctx( + mac: Option<&str>, + hostname: Option<&str>, + ip: Option<&str>, + token: &str, +) -> String { use base64::Engine as _; let raw = format!( - "{}\n{}\n{}", + "{}\n{}\n{}\n{token}", hostname.unwrap_or(""), ip.unwrap_or(""), mac.unwrap_or("") @@ -1513,21 +1706,30 @@ fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>) base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes()) } -/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip)`. A bad -/// or empty segment yields all-`None` so the seed still serves (just -/// without per-host substitution). -fn decode_seed_ctx(ctx: &str) -> (Option, Option, Option) { +/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip, token)`. +/// A bad or empty segment yields all-`None`; a pre-v0.7.0 three-field +/// ctx decodes with `token: None` (and the gate then rejects it once an +/// admin exists — stale URLs are exactly what tokens invalidate). +fn decode_seed_ctx( + ctx: &str, +) -> ( + Option, + Option, + Option, + Option, +) { use base64::Engine as _; let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(ctx.as_bytes()) else { - return (None, None, None); + return (None, None, None, None); }; let s = String::from_utf8_lossy(&bytes).into_owned(); - let mut it = s.splitn(3, '\n'); + let mut it = s.splitn(4, '\n'); let clean = |v: Option<&str>| v.map(str::to_string).filter(|x| !x.is_empty()); let hostname = clean(it.next()); let ip = clean(it.next()); let mac = clean(it.next()); - (mac, hostname, ip) + let token = clean(it.next()); + (mac, hostname, ip, token) } // ─── API reference (Settings → bottom) ──────────────────────────────────── @@ -1709,6 +1911,10 @@ async fn api_docs() -> Json { "summary": "Pin a MAC to a boot target. Body: { mac, target, label, auto_hostname?, auto_ip?, unattended_file? }."}, {"method": "DELETE", "path": "/api/hosts/{mac}", "summary": "Remove a binding."}, + {"method": "GET", "path": "/api/boot-rules", + "summary": "Boot rules + decision-webhook config (v0.7.0)."}, + {"method": "PUT", "path": "/api/boot-rules", + "summary": "Replace the whole boot-rules config (rules are ordered)."}, {"method": "POST", "path": "/api/hosts/{mac}/wol", "summary": "Send a Wake-on-LAN magic packet to a bound MAC (limited + subnet broadcast)."}, {"method": "GET", "path": "/api/boot-log", @@ -2942,11 +3148,13 @@ mod tests { auto_ip: Some("10.0.0.7".into()), unattended_file: Some("ks1".into()), }; + let tokens = openpxe_core::BootTokens::new(); let a = build_unattended_args( "http://h", &meta(UnattendedKind::Kickstart), Some("aa:bb:cc:dd:ee:ff"), &p, + &tokens, ) .unwrap(); assert!(a.starts_with("inst.ks=http://h/unattended/ks1?"), "{a}"); @@ -2954,6 +3162,9 @@ mod tests { assert!(a.contains("ip=10.0.0.7"), "{a}"); // MAC colons are percent-encoded. assert!(a.contains("mac=aa%3Abb%3Acc%3Add%3Aee%3Aff"), "{a}"); + // v0.7.0: a live access token rides in the generated URL. + let tok = a.rsplit("t=").next().unwrap(); + assert!(tokens.check(tok, "ks1"), "minted token must be live: {a}"); } #[test] @@ -2962,8 +3173,15 @@ mod tests { auto_hostname: Some("deb1".into()), ..Default::default() }; - let a = - build_unattended_args("http://h/", &meta(UnattendedKind::Preseed), None, &p).unwrap(); + let tokens = openpxe_core::BootTokens::new(); + let a = build_unattended_args( + "http://h/", + &meta(UnattendedKind::Preseed), + None, + &p, + &tokens, + ) + .unwrap(); assert!( a.starts_with("auto=true priority=critical url=http://h/unattended/ks1"), "{a}" @@ -2978,11 +3196,13 @@ mod tests { auto_ip: Some("10.1.1.5".into()), unattended_file: Some("ks1".into()), }; + let tokens = openpxe_core::BootTokens::new(); let a = build_unattended_args( "http://h", &meta(UnattendedKind::Autoinstall), Some("aa:bb"), &p, + &tokens, ) .unwrap(); assert!( @@ -2992,10 +3212,12 @@ mod tests { assert!(a.ends_with('/'), "seed URL must end with '/': {a}"); // The ctx segment round-trips back to the per-host values. let ctx = a.trim_end_matches('/').rsplit('/').next().unwrap(); - let (mac, host, ip) = decode_seed_ctx(ctx); + let (mac, host, ip, token) = decode_seed_ctx(ctx); assert_eq!(mac.as_deref(), Some("aa:bb")); assert_eq!(host.as_deref(), Some("u1")); assert_eq!(ip.as_deref(), Some("10.1.1.5")); + // v0.7.0: the ctx carries a live access token for the file. + assert!(tokens.check(token.as_deref().unwrap(), "ks1")); } #[test] @@ -3004,20 +3226,26 @@ mod tests { unattended_file: Some("ks1".into()), ..Default::default() }; - assert!( - build_unattended_args("http://h", &meta(UnattendedKind::AnswerFile), None, &p) - .is_none() - ); + let tokens = openpxe_core::BootTokens::new(); + assert!(build_unattended_args( + "http://h", + &meta(UnattendedKind::AnswerFile), + None, + &p, + &tokens + ) + .is_none()); } #[test] fn seed_ctx_empty_segment_decodes_to_none() { - let ctx = encode_seed_ctx(None, None, None); - let (m, h, i) = decode_seed_ctx(&ctx); + let ctx = encode_seed_ctx(None, None, None, "tok"); + let (m, h, i, t) = decode_seed_ctx(&ctx); assert!(m.is_none() && h.is_none() && i.is_none()); + assert_eq!(t.as_deref(), Some("tok")); // Garbage decodes safely to all-None. - let (m2, h2, i2) = decode_seed_ctx("!!!not-base64!!!"); - assert!(m2.is_none() && h2.is_none() && i2.is_none()); + let (m2, h2, i2, t2) = decode_seed_ctx("!!!not-base64!!!"); + assert!(m2.is_none() && h2.is_none() && i2.is_none() && t2.is_none()); } #[test] diff --git a/crates/http-api/src/auth.rs b/crates/http-api/src/auth.rs index f008f1e..287098d 100644 --- a/crates/http-api/src/auth.rs +++ b/crates/http-api/src/auth.rs @@ -169,6 +169,13 @@ fn parse_cookie(headers: &axum::http::HeaderMap) -> Option { None } +/// Does this request carry a live operator session? Used by endpoints +/// outside the `/api/*` middleware that still want to honor a logged-in +/// operator (e.g. browser-testing a token-gated answer file, v0.7.0). +pub(crate) fn session_authenticated(state: &AppState, headers: &axum::http::HeaderMap) -> bool { + parse_cookie(headers).is_some_and(|t| state.sessions.touch(&t).is_some()) +} + // ── Middleware ──────────────────────────────────────────────────────────── /// Return `true` if `path` is on the allowlist and should bypass the diff --git a/crates/http-api/src/grub_script.rs b/crates/http-api/src/grub_script.rs new file mode 100644 index 0000000..421a817 --- /dev/null +++ b/crates/http-api/src/grub_script.rs @@ -0,0 +1,156 @@ +//! 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)"); + 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 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}"); + } +} diff --git a/crates/http-api/src/lib.rs b/crates/http-api/src/lib.rs index 25ed67d..a9b6e83 100644 --- a/crates/http-api/src/lib.rs +++ b/crates/http-api/src/lib.rs @@ -16,6 +16,7 @@ pub mod app; pub mod auth; pub mod error; +pub mod grub_script; pub mod ipxe_script; pub mod iso_fs; pub mod log_stream; diff --git a/crates/http-api/src/state.rs b/crates/http-api/src/state.rs index da78df3..3f4661a 100644 --- a/crates/http-api/src/state.rs +++ b/crates/http-api/src/state.rs @@ -2,8 +2,8 @@ use crate::auth::SessionStore; use crate::saml_routes::SamlRuntime; use crate::uploads::UploadSessions; use openpxe_core::{ - AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, - Metrics, NotifyStore, SettingsStore, SsoStore, + AdminStore, BootLog, BootRulesStore, BootTokens, BrandingStore, ClientRegistry, + DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore, }; use openpxe_iso_store::{ IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager, UnattendedStore, @@ -29,6 +29,14 @@ pub struct AppState { /// every `/boot/.ipxe` chain that goes on to serve a script /// (i.e. an image actually starting to install on a machine). pub boot_log: BootLog, + /// v0.7.0: ordered label-based boot rules (MAC prefix / arch → + /// target) plus the optional boot-decision webhook. Consulted by the + /// top-level boot script after exact host bindings, before the menu. + pub boot_rules: BootRulesStore, + /// v0.7.0: short-lived access tokens for unattended answer files. + /// Minted into every generated answer-file URL; the serving endpoint + /// requires one (or an operator session) once an admin exists. + pub boot_tokens: BootTokens, /// Operator-controlled UI overrides (custom logo). When the /// operator hasn't uploaded anything, the WebUI serves the bundled /// rainbow-horizon mark. diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index 9dafee3..a11059b 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -115,6 +115,8 @@ async fn build_state() -> (AppState, tempfile::TempDir) { settings, hosts, boot_log, + boot_rules: openpxe_core::BootRulesStore::load_or_default(dir.path()), + boot_tokens: openpxe_core::BootTokens::new(), branding, pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(), admin, @@ -2610,3 +2612,142 @@ async fn acs_garbage_is_rejected_without_500() { assert!(location(&resp).contains("sso_error")); assert!(!has_session_cookie(&resp)); } + +// ─── v0.7.0: tokenized answer files + boot rules ──────────────────────────── + +#[tokio::test] +async fn unattended_requires_token_once_admin_exists() { + let (state, _dir) = build_state().await; + let app = build_router(state.clone()); + // Upload an answer file while in setup mode (everything open). + let (ct, body) = + multipart_iso_body("ks.ks", b"install\nrootpw s3cret\n%packages\n@core\n%end\n"); + let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await; + assert_eq!(s, StatusCode::CREATED); + let id = serde_json::from_slice::(&b).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + // Pre-setup, the file serves openly (bootstrap parity with the + // auth middleware). + let (s, _) = get(&app, &format!("/unattended/{id}")).await; + assert_eq!(s, StatusCode::OK); + + // Create the admin → the gate arms. + let (s, _, cookies) = post_collect( + &app, + "/api/setup", + r#"{"username":"admin","password":"hunter2hunter2"}"#, + ) + .await; + assert_eq!(s, StatusCode::CREATED); + let session = session_value(&cookies).unwrap(); + + // Bare fetch (the CVE-2026-0386 harvesting pattern) is refused. + let (s, _) = get(&app, &format!("/unattended/{id}")).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + // Garbage token is refused. + let (s, _) = get(&app, &format!("/unattended/{id}?t=bogus")).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + // A token minted for a *different* file is refused. + let other = state.boot_tokens.mint("some-other-file"); + let (s, _) = get(&app, &format!("/unattended/{id}?t={other}")).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + // The boot-scoped token OpenPXE mints into generated URLs passes. + let tok = state.boot_tokens.mint(&id); + let (s, b) = get(&app, &format!("/unattended/{id}?t={tok}")).await; + assert_eq!(s, StatusCode::OK); + assert!(String::from_utf8_lossy(&b).contains("rootpw")); + // A logged-in operator (browser testing) passes too. + let (s, _) = get_with_cookie(&app, &format!("/unattended/{id}"), &session).await; + assert_eq!(s, StatusCode::OK); +} + +#[tokio::test] +async fn boot_script_for_pinned_unattended_carries_live_token() { + let (state, _dir) = build_state().await; + let app = build_router(state.clone()); + let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); + let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await; + assert_eq!(s, StatusCode::CREATED); + let (ct, body) = multipart_iso_body("ks.ks", b"install\n%packages\n@core\n%end\n"); + let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await; + assert_eq!(s, StatusCode::CREATED); + let ks_id = serde_json::from_slice::(&b).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let mac = "aa:bb:cc:dd:ee:71"; + let pin = + format!(r#"{{"mac":"{mac}","target":"fake-alpine-linux","unattended_file":"{ks_id}"}}"#); + let (s, _) = post_json(&app, "/api/hosts", &pin).await; + assert_eq!(s, StatusCode::CREATED); + let (s, b) = get(&app, &format!("/boot/fake-alpine-linux.ipxe?mac={mac}")).await; + assert_eq!(s, StatusCode::OK); + let script = String::from_utf8_lossy(&b).into_owned(); + // The injected inst.ks URL ends with a token that is live for the file. + let tok = script + .split("t=") + .nth(1) + .and_then(|rest| rest.split_whitespace().next()) + .expect("kernel arg should carry t="); + assert!( + state.boot_tokens.check(tok, &ks_id), + "token in boot script must be live:\n{script}" + ); +} + +#[tokio::test] +async fn boot_rules_match_and_persist_via_api() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); + let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await; + assert_eq!(s, StatusCode::CREATED); + + // Save a rule: any MAC under aa:bb:cc, any arch → the Linux entry. + let cfg = r#"{"rules":[{"mac_prefix":"AA-BB-CC","arch":"","target":"fake-alpine-linux","enabled":true,"note":"rack"}],"webhook_url":""}"#; + let (s, _) = put_json(&app, "/api/boot-rules", cfg).await; + assert_eq!(s, StatusCode::NO_CONTENT); + // The config reads back (prefix normalized to colons). + let (s, b) = get(&app, "/api/boot-rules").await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["rules"][0]["mac_prefix"], "aa:bb:cc"); + + // A matching client short-circuits to the target... + let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:09&arch=uefi-x64").await; + assert_eq!(s, StatusCode::OK); + let script = String::from_utf8_lossy(&b); + assert!( + script.contains("boot rule -> fake-alpine-linux"), + "rule did not chain:\n{script}" + ); + // ...while a non-matching one still gets the menu. + let (s, b) = get(&app, "/boot.ipxe?mac=11:22:33:00:00:09&arch=uefi-x64").await; + assert_eq!(s, StatusCode::OK); + assert!( + String::from_utf8_lossy(&b).contains("menu"), + "non-matching client should see the menu" + ); +} + +#[tokio::test] +async fn arch_selective_rule_ignores_other_arches() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); + let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await; + assert_eq!(s, StatusCode::CREATED); + let cfg = r#"{"rules":[{"mac_prefix":"","arch":"uefi-arm64","target":"fake-alpine-linux","enabled":true,"note":""}],"webhook_url":""}"#; + let (s, _) = put_json(&app, "/api/boot-rules", cfg).await; + assert_eq!(s, StatusCode::NO_CONTENT); + // x64 client: no match → menu. + let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01&arch=uefi-x64").await; + assert_eq!(s, StatusCode::OK); + assert!(!String::from_utf8_lossy(&b).contains("boot rule ->")); + // arm64 client: match. + let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01&arch=uefi-arm64").await; + assert_eq!(s, StatusCode::OK); + assert!(String::from_utf8_lossy(&b).contains("boot rule -> fake-alpine-linux")); +} diff --git a/crates/ipxe-assets/src/lib.rs b/crates/ipxe-assets/src/lib.rs index 9670990..05d89de 100644 --- a/crates/ipxe-assets/src/lib.rs +++ b/crates/ipxe-assets/src/lib.rs @@ -67,7 +67,7 @@ pub fn log_availability() { ClientArch::Arm64Uefi, ]; for arch in arches { - for mode in [DriverMode::Firmware, DriverMode::Builtin] { + for mode in [DriverMode::Firmware, DriverMode::Builtin, DriverMode::Shim] { let Some(name) = arch.ipxe_bootfile_mode(mode) else { continue; }; @@ -82,12 +82,18 @@ pub fn log_availability() { "MISSING iPXE binary for {} [{mode:?}]: {name} — clients of this arch will not PXE boot", arch.as_str() ); - } else { + } else if mode == DriverMode::Builtin { tracing::info!( target: "openpxe::ipxe", "no built-in-driver fallback for {} [{mode:?}]: {name} — auto NIC driver escalation unavailable for this arch", arch.as_str() ); + } else { + tracing::info!( + target: "openpxe::ipxe", + "no signed shim chain for {} [{mode:?}]: {name} — Secure Boot clients of this arch can't be served", + arch.as_str() + ); } } } diff --git a/crates/openpxe/src/main.rs b/crates/openpxe/src/main.rs index e92c784..fd0ee25 100644 --- a/crates/openpxe/src/main.rs +++ b/crates/openpxe/src/main.rs @@ -191,6 +191,8 @@ async fn main() -> anyhow::Result<()> { queue: queue.clone(), hosts: hosts.clone(), boot_log: boot_log.clone(), + boot_rules: openpxe_core::BootRulesStore::load_or_default(&config.paths.work_dir), + boot_tokens: openpxe_core::BootTokens::new(), branding: branding.clone(), pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(), admin: admin.clone(), @@ -230,11 +232,28 @@ async fn main() -> anyhow::Result<()> { Ok::<_, anyhow::Error>(()) }); + // v0.7.0: TFTP names that aren't embedded assets get a dynamic + // renderer — `grub.cfg` for the Secure Boot shim+GRUB chain is + // generated from the live boot-entry list on every fetch, so menu + // changes apply without restart. + let grub_isos = iso_store.clone(); + let grub_base = public_base_url.clone(); + let tftp_dynamic: openpxe_tftp::DynamicAsset = std::sync::Arc::new(move |name: &str| { + if name == "grub.cfg" || name.starts_with("grub.cfg-") { + Some( + openpxe_http_api::grub_script::render_grub_menu(&grub_isos.list(), &grub_base) + .into_bytes(), + ) + } else { + None + } + }); let tftp = TftpServer::new( config.server.tftp_bind, config.server.tftp_port, clients.clone(), metrics.clone(), + Some(tftp_dynamic), ); let tftp_task = tokio::spawn(tftp.run()); diff --git a/crates/tftp/src/lib.rs b/crates/tftp/src/lib.rs index 53f2c91..8933a65 100644 --- a/crates/tftp/src/lib.rs +++ b/crates/tftp/src/lib.rs @@ -14,4 +14,4 @@ pub mod server; -pub use server::TftpServer; +pub use server::{DynamicAsset, TftpServer}; diff --git a/crates/tftp/src/server.rs b/crates/tftp/src/server.rs index 1deb9e9..bfd395b 100644 --- a/crates/tftp/src/server.rs +++ b/crates/tftp/src/server.rs @@ -32,11 +32,19 @@ const ERR_NOT_DEFINED: u16 = 0; const ERR_FILE_NOT_FOUND: u16 = 1; const ERR_ILLEGAL_OP: u16 = 4; +/// Server-rendered TFTP content for names that aren't embedded assets — +/// e.g. `grub.cfg` for the signed shim+GRUB Secure Boot chain (v0.7.0), +/// which is generated from the live boot-entry list per fetch. Kept as a +/// closure so this crate stays decoupled from the ISO store; the binary +/// wires it up in `main`. +pub type DynamicAsset = Arc Option> + Send + Sync>; + pub struct TftpServer { bind: IpAddr, port: u16, clients: Arc, metrics: openpxe_core::Metrics, + dynamic: Option, } impl TftpServer { @@ -45,12 +53,14 @@ impl TftpServer { port: u16, clients: Arc, metrics: openpxe_core::Metrics, + dynamic: Option, ) -> Self { Self { bind, port, clients, metrics, + dynamic, } } @@ -72,8 +82,11 @@ impl TftpServer { let clients = clients.clone(); let metrics = metrics.clone(); let bind_ip = self.bind; + let dynamic = self.dynamic.clone(); tokio::spawn(async move { - if let Err(e) = handle_rrq(data, from, bind_ip, clients, metrics.clone()).await { + if let Err(e) = + handle_rrq(data, from, bind_ip, clients, metrics.clone(), dynamic).await + { metrics.record_tftp_err(); tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}"); } @@ -88,6 +101,7 @@ async fn handle_rrq( bind_ip: IpAddr, clients: Arc, metrics: openpxe_core::Metrics, + dynamic: Option, ) -> anyhow::Result<()> { let Some(req) = parse_rrq(&packet) else { // Not a well-formed RRQ. A WRQ deserves an explicit refusal — @@ -117,7 +131,15 @@ async fn handle_rrq( return Ok(()); } - let Some(file_bytes) = asset_slice(&filename) else { + // Embedded assets first; otherwise the dynamic renderer (server- + // generated content like the Secure Boot chain's grub.cfg, v0.7.0). + let resolved = asset_slice(&filename).or_else(|| { + dynamic + .as_ref() + .and_then(|f| f(&filename)) + .map(std::borrow::Cow::Owned) + }); + let Some(file_bytes) = resolved else { let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await; tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404"); clients.record( diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index 667b92e..392204d 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -199,6 +199,101 @@ })[k] || (k || 'Unknown'); } + // v0.7.0: the Boot rules card — ordered first-match-wins rules + // (MAC prefix / architecture → target) plus the optional + // boot-decision webhook. Saved as one config because rule order + // matters. With no rules and no webhook, behavior is identical to + // before the feature existed. + function bootRulesCard(cfg, targetOptions) { + const archChoices = [ + ['', 'any arch'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'], + ['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'], + ]; + const rules = (cfg.rules || []).map(r => Object.assign({}, r)); + const tbody = el('tbody', {}); + const msg = el('div', {class:'msg'}); + const webhookInput = el('input', {type:'text', spellcheck:'false', + placeholder:'http://automation.example/boot-decision (optional)', + value: cfg.webhook_url || ''}); + + const targetSelect = (val) => el('select', {}, + [el('option', {value:''}, '— target —')] + .concat(targetOptions.map(t => + el('option', Object.assign({value: t.id}, t.id === val ? {selected:''} : {}), t.title)))); + + const redraw = () => { + tbody.innerHTML = ''; + if (!rules.length) { + tbody.appendChild(el('tr', {}, el('td', {colspan:'6', class:'empty', style:'padding:14px'}, + 'No rules. Add one to route whole groups of machines (an OUI, an architecture) to a target.'))); + } + rules.forEach((r, i) => { + const macIn = el('input', {type:'text', spellcheck:'false', placeholder:'aa:bb:cc (prefix)', + value: r.mac_prefix || '', oninput: e => { r.mac_prefix = e.target.value; }}); + const archSel = el('select', {onchange: e => { r.arch = e.target.value; }}, + archChoices.map(([v, label]) => + el('option', Object.assign({value: v}, v === (r.arch || '') ? {selected:''} : {}), label))); + const tgtSel = targetSelect(r.target || ''); + tgtSel.onchange = e => { r.target = e.target.value; }; + const noteIn = el('input', {type:'text', placeholder:'note', + value: r.note || '', oninput: e => { r.note = e.target.value; }}); + const enabled = el('input', {type:'checkbox', onchange: e => { r.enabled = e.target.checked; }}); + enabled.checked = r.enabled !== false; + tbody.appendChild(el('tr', {}, [ + el('td', {}, macIn), + el('td', {}, archSel), + el('td', {}, tgtSel), + el('td', {}, noteIn), + el('td', {style:'text-align:center'}, enabled), + el('td', {style:'text-align:right'}, + el('button', {class:'danger', onclick: () => { rules.splice(i, 1); redraw(); }}, '✕')), + ])); + }); + }; + redraw(); + + const addBtn = el('button', {class:'ghost', onclick: () => { + rules.push({mac_prefix:'', arch:'', target:'', enabled:true, note:''}); + redraw(); + }}, '+ Add rule'); + const saveBtn = el('button', {onclick: async () => { + const bad = rules.find(r => r.enabled !== false && !r.target); + if (bad) { msg.textContent = 'Every enabled rule needs a target.'; msg.className = 'msg err'; return; } + const r = await putJSON('/api/boot-rules', {rules, webhook_url: webhookInput.value.trim()}); + if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; } + else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; } + }}, 'Save rules'); + + return el('div', {class:'card'}, [ + el('header', {}, [ + el('h2', {}, 'Boot rules'), + el('span', {class:'sub'}, 'first match wins · checked top to bottom'), + ]), + el('div', {class:'body'}, [ + el('table', {}, [ + el('thead', {}, el('tr', {}, [ + el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'), + el('th',{},'Note'), el('th',{},'On'), el('th',{},''), + ])), + tbody, + ]), + el('div', {style:'margin-top:12px'}, [addBtn, saveBtn]), + el('label', {class:'field', style:'margin-top:16px;display:block'}, [ + el('span', {class:'name'}, 'Boot-decision webhook (optional)'), + webhookInput, + el('span', {class:'hint'}, + 'When no pin or rule matches, OpenPXE GETs this URL with ?mac=…&arch=… ' + + 'A 200 reply of {"target": ""} chains to that target; anything ' + + 'else (404, timeout, error) falls through to the menu — a dead endpoint ' + + 'can never block PXE.'), + ]), + msg, + el('p', {class:'msg', style:'margin-top:10px'}, + 'Decision order per boot: exact MAC pin → first matching rule → webhook → interactive menu.'), + ]), + ]); + } + // v0.5.2: build the shared "deployment profile" field group — auto // hostname, auto IP, and an unattended-file picker — reused by the // Hosts pin form and the Queue "Profile" modal. `files` is the @@ -1252,10 +1347,11 @@ }, hosts: async () => { - const [{ hosts = [] }, isos, bootLogRes, unattRes] = await Promise.all([ + const [{ hosts = [] }, isos, bootLogRes, unattRes, rulesCfg] = await Promise.all([ getJSON('/api/hosts'), getJSON('/api/isos'), getJSON('/api/boot-log').catch(() => ({ events: [] })), getJSON('/api/unattended').catch(() => ({ files: [] })), + getJSON('/api/boot-rules').catch(() => ({ rules: [], webhook_url: '' })), ]); const bootEvents = bootLogRes.events || []; const unattendedFiles = unattRes.files || []; @@ -1381,6 +1477,7 @@ ]), table, ]), + bootRulesCard(rulesCfg, reserved.concat(targets)), el('div', {class:'card'}, [ el('header', {}, [ el('h2', {}, 'Host log'), diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 89e6258..aad0047 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -23,11 +23,19 @@ ARG RUST_VERSION=1.95 # and serve as the baseline that the PNG-enabled x86_64/arm64 UEFI # binaries from the `ipxe-build` stage overlay on top of. FROM debian:12-slim AS fetch -RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ +# rpm2cpio + cpio: extract Fedora's Microsoft-signed shim/GRUB RPMs for +# the Secure Boot chain (v0.7.0, scripts/fetch-shim.sh). +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates rpm2cpio cpio \ && rm -rf /var/lib/apt/lists/* WORKDIR /src COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh +COPY scripts/fetch-shim.sh scripts/fetch-shim.sh RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh +# Signed shim+GRUB (Secure Boot escalation rung). Redistributed +# unmodified from the official Fedora packages — see fetch-shim.sh for +# the trust model. +RUN bash scripts/fetch-shim.sh /src/assets/ipxe ########## build PNG-enabled iPXE from source ########## # v0.4.69: THE graphical-boot-menu unlock. iVentoy paints a PNG diff --git a/scripts/fetch-shim.sh b/scripts/fetch-shim.sh new file mode 100755 index 0000000..3c96c67 --- /dev/null +++ b/scripts/fetch-shim.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Fetch Fedora's Microsoft-signed Secure Boot chain — shim + GRUB — and +# place the EFI binaries under assets/ipxe/ with the filenames OpenPXE's +# DriverMode::Shim mapping expects: +# +# shimx64.efi x86_64: Microsoft-signed shim (first stage) +# grubx64.efi x86_64: Fedora-signed GRUB (loaded by shim, fetches +# the server-rendered grub.cfg over TFTP/HTTP) +# shimaa64.efi arm64 equivalents (best-effort — see below) +# grubaa64.efi +# +# Why Fedora: a supply-chain decision made deliberately (v0.7.0) — one +# vendor, fast security turnaround, and the same chain most netboot +# projects redistribute. The binaries are extracted from the official +# distro RPMs and shipped BYTE-FOR-BYTE UNMODIFIED; their signatures are +# what make the chain work, and modifying them would break it. This is +# the standard documented netboot path for Secure Boot (Red Hat +# Satellite, SUSE HTTPBoot) and involves no test certificates and no +# client trust-store changes. +# +# Trust model matches fetch-ipxe.sh: HTTPS to the official distribution +# point, no sha pinning because we track the latest signed build (which +# rotates on SBAT revocations — pinning would mean shipping revoked +# shims). Mirror to your own artifact store for deterministic builds. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DEST="${1:-$ROOT/assets/ipxe}" +mkdir -p "$DEST" + +FEDORA_RELEASE="${FEDORA_RELEASE:-43}" +BASE="${FEDORA_MIRROR:-https://dl.fedoraproject.org/pub/fedora/linux/releases/$FEDORA_RELEASE/Everything}" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# Find the newest RPM in a repo directory whose name starts with +# `$pattern` followed by a version digit (anchoring on the digit keeps +# `grub2-efi-x64` from matching `grub2-efi-x64-cdboot`). +latest_rpm() { + local dir_url="$1" pattern="$2" + curl -fsSL "$dir_url/" \ + | grep -oE "href=\"${pattern}-[0-9][^\"]*\.rpm\"" \ + | sed 's/^href="//; s/"$//' \ + | sort -V | tail -1 +} + +# fetch_chain +fetch_chain() { + local arch="$1" shim_pkg="$2" grub_pkg="$3" shim_out="$4" grub_out="$5" mode="$6" + local pkg_base="$BASE/$arch/os/Packages" + local sdir="$pkg_base/${shim_pkg:0:1}" gdir="$pkg_base/${grub_pkg:0:1}" + + local shim_rpm grub_rpm + shim_rpm="$(latest_rpm "$sdir" "$shim_pkg" || true)" + grub_rpm="$(latest_rpm "$gdir" "$grub_pkg" || true)" + if [ -z "$shim_rpm" ] || [ -z "$grub_rpm" ]; then + echo "!! could not locate $shim_pkg/$grub_pkg RPMs under $pkg_base" + [ "$mode" = "hard" ] && exit 2 + echo " skipping $arch Secure Boot chain (best-effort)" + return 0 + fi + + echo ">> $arch: $shim_rpm + $grub_rpm" + local exdir="$WORK/$arch" + mkdir -p "$exdir" + curl -fsSL -o "$exdir/shim.rpm" "$sdir/$shim_rpm" + curl -fsSL -o "$exdir/grub.rpm" "$gdir/$grub_rpm" + ( cd "$exdir" \ + && rpm2cpio shim.rpm | cpio -idm --quiet "./boot/efi/EFI/*/$shim_out" \ + && rpm2cpio grub.rpm | cpio -idm --quiet "./boot/efi/EFI/*/$grub_out" ) + + local shim_path grub_path + shim_path="$(find "$exdir/boot" -name "$shim_out" | head -1)" + grub_path="$(find "$exdir/boot" -name "$grub_out" | head -1)" + if [ -z "$shim_path" ] || [ -z "$grub_path" ]; then + echo "!! RPM layout changed — $shim_out/$grub_out not found inside the packages" + [ "$mode" = "hard" ] && exit 2 + return 0 + fi + cp "$shim_path" "$DEST/$shim_out" + cp "$grub_path" "$DEST/$grub_out" + echo " installed $shim_out + $grub_out" +} + +# x86_64 is the headline Secure Boot audience — fail the build if it +# can't be assembled so a regression is loud, not silent. +fetch_chain x86_64 shim-x64 grub2-efi-x64 shimx64.efi grubx64.efi hard +# arm64 is best-effort: skipping just means no Shim escalation rung for +# that arch (logged at startup by ipxe-assets::log_availability). +fetch_chain aarch64 shim-aa64 grub2-efi-aa64 shimaa64.efi grubaa64.efi soft + +echo +echo "Secure Boot chain assets now in $DEST:" +ls -lh "$DEST"/shim*.efi "$DEST"/grub*.efi 2>/dev/null || true