From 4f193cac0584401a75c33e45e10e1cbde313fd7a Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Tue, 9 Jun 2026 11:14:00 -0400 Subject: [PATCH] v0.6.1: latest iPXE + automatic NIC driver fallback (more devices, zero toggle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the worthwhile device-support wins from iVentoy 1.0.24→1.0.35 onto our (very different) proxy-DHCP + iPXE-chainload architecture. iVentoy's other changes are inapplicable (arm64-server / distro-display fixes live in its injected Linux, which we don't have), niche (iSCSI), or closed-source (Matrix Boot). iPXE refreshed (mirrors 1.0.35 "Update iPXE") - Pin the from-source build to ipxe/ipxe master @ 2026-06-09 (95ffbf4745553e8a207922389929e1943c0237c0) — newer NIC drivers + EFI fixes. The pin also busts the cached ipxe-build Docker layer so the release actually recompiles iPXE; build-ipxe.sh now shallow-fetches an exact SHA. Automatic NIC driver fallback (mirrors 1.0.34 "driver/boot-file mode" — but no operator toggle, per request) - New DriverMode {Firmware, Builtin} in core; ClientArch::ipxe_bootfile_mode maps each arch to either the firmware-net build (snponly/undionly, default) or the all-drivers build (ipxe.efi/ipxe.pxe/ipxe-i386.efi/ipxe-arm64.efi). - The DHCP proxy serves Firmware by default — byte-for-byte unchanged, so hardware that boots today never regresses. A new DriverEscalation state machine watches for the tell-tale failure: a MAC re-PXE-boots (fresh firmware DISCOVER) without ever completing the iPXE-user-class handoff that proves the firmware NIC stack worked. That MAC is automatically escalated to iPXE's own NIC drivers, and the choice is sticky after a confirmed handoff (debounced for the :67/:4011 same-boot pair, TTL-pruned, capped). It just works — no settings, no UI. - All-drivers binaries fetched per arch (ipxe.pxe + i386/arm64 native EFI; x86_64 ipxe.efi already built from source with PNG); ipxe-assets embeds *.pxe and logs availability per (arch, mode). Core principles intact: DHCP-proxy-only, container-first, Rust-focused (the logic is all Rust; only the iPXE fetch/build stays shell), Windows hard-rules untouched (this never goes near Windows boot). Validation: clippy clean; full workspace test suite green (core 99 incl. new DriverMode tests, dhcp-proxy +4 escalation tests, http-api 31+68, iso-store 61, tftp 6, bin 2); fmt-clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 17 +-- Cargo.toml | 2 +- crates/core/src/arch.rs | 120 +++++++++++++++-- crates/core/src/lib.rs | 2 +- crates/dhcp-proxy/Cargo.toml | 1 + crates/dhcp-proxy/src/escalation.rs | 202 ++++++++++++++++++++++++++++ crates/dhcp-proxy/src/lib.rs | 2 + crates/dhcp-proxy/src/reply.rs | 19 ++- crates/dhcp-proxy/src/server.rs | 28 +++- crates/ipxe-assets/src/lib.rs | 66 ++++++--- scripts/build-ipxe.sh | 30 ++++- scripts/fetch-ipxe.sh | 10 +- 12 files changed, 444 insertions(+), 55 deletions(-) create mode 100644 crates/dhcp-proxy/src/escalation.rs diff --git a/Cargo.lock b/Cargo.lock index 45e34a7..cb29359 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2669,7 +2669,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openpxe" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "axum", @@ -2691,7 +2691,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "base64", @@ -2718,12 +2718,13 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "bytes", "dhcproto", "openpxe-core", + "parking_lot", "socket2 0.5.10", "thiserror 2.0.18", "tokio", @@ -2732,7 +2733,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "axum", @@ -2768,7 +2769,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.6.0" +version = "0.6.1" dependencies = [ "openpxe-core", "rust-embed", @@ -2778,7 +2779,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "bcrypt", @@ -2807,7 +2808,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "bytes", @@ -2821,7 +2822,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.6.0" +version = "0.6.1" [[package]] name = "p256" diff --git a/Cargo.toml b/Cargo.toml index 7fcc93d..64f2e99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.6.0" +version = "0.6.1" 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 7f8b65b..f99e961 100644 --- a/crates/core/src/arch.rs +++ b/crates/core/src/arch.rs @@ -23,6 +23,25 @@ pub enum ClientArch { Unknown(u16), } +/// Which iPXE network backend to advertise to a client (v0.6.1). +/// +/// 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. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DriverMode { + /// Reuse the firmware UNDI/SNP NIC stack (`snponly.efi`, `undionly.kpxe`). + /// Default, smallest, most reliable for chainloading. + #[default] + Firmware, + /// iPXE's own bundled NIC drivers (`ipxe.efi`, `ipxe.pxe`). Fallback for + /// hardware whose firmware NIC stack is missing or buggy. + Builtin, +} + impl ClientArch { #[must_use] pub fn from_option_93(value: u16) -> Self { @@ -39,18 +58,47 @@ impl ClientArch { /// Default iPXE binary filename to return via TFTP for this architecture. /// Uses `snponly` variants which reuse the firmware's UNDI/SNP network /// stack — smaller binaries and broader hardware compatibility than the - /// all-drivers-included `ipxe.efi`. + /// all-drivers-included `ipxe.efi`. Equivalent to + /// [`Self::ipxe_bootfile_mode`] with [`DriverMode::Firmware`]; kept as a + /// convenience for the common firmware-net path. #[must_use] pub fn ipxe_bootfile(self) -> Option<&'static str> { - Some(match self { - Self::LegacyX86 => "undionly.kpxe", - Self::Ia32Uefi => "snponly-i386.efi", - Self::X64Uefi => "snponly.efi", - // ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt - // snponly variant for this arch. We return None so the DHCP - // proxy declines rather than advertising a file we can't serve. - Self::Arm32Uefi | Self::Unknown(_) => return None, - Self::Arm64Uefi => "snponly-arm64.efi", + self.ipxe_bootfile_mode(DriverMode::Firmware) + } + + /// iPXE binary filename for this architecture under a given network + /// [`DriverMode`]. + /// + /// * [`DriverMode::Firmware`] — the `snponly`/`undionly` builds that reuse + /// the firmware's UNDI/SNP NIC stack. Smallest, and the most reliable + /// choice for chainloading because the firmware just proved its network + /// works by downloading the NBP. This is the default first attempt. + /// * [`DriverMode::Builtin`] — the all-drivers `ipxe.efi`/`ipxe.pxe` + /// builds that carry iPXE's *own* NIC drivers. The automatic fallback + /// for clients whose firmware NIC stack is missing or buggy (v0.6.1): + /// the DHCP proxy escalates a MAC to this mode when a firmware-net boot + /// never completes the iPXE handoff. iPXE still includes the `snp` + /// driver here too, so it degrades gracefully. + #[must_use] + pub fn ipxe_bootfile_mode(self, mode: DriverMode) -> Option<&'static str> { + Some(match (self, mode) { + // Legacy x86 BIOS: UNDI (firmware) vs full native-driver build. + (Self::LegacyX86, DriverMode::Firmware) => "undionly.kpxe", + (Self::LegacyX86, DriverMode::Builtin) => "ipxe.pxe", + // IA32 UEFI. + (Self::Ia32Uefi, DriverMode::Firmware) => "snponly-i386.efi", + (Self::Ia32Uefi, DriverMode::Builtin) => "ipxe-i386.efi", + // x86_64 UEFI — the overwhelmingly common modern client. + (Self::X64Uefi, DriverMode::Firmware) => "snponly.efi", + (Self::X64Uefi, DriverMode::Builtin) => "ipxe.efi", + // ARM64 UEFI. + (Self::Arm64Uefi, DriverMode::Firmware) => "snponly-arm64.efi", + (Self::Arm64Uefi, DriverMode::Builtin) => "ipxe-arm64.efi", + // ARM32 UEFI: upstream boot.ipxe.org publishes no prebuilt binary + // for this arch in either 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, }) } @@ -133,6 +181,58 @@ mod tests { assert_eq!(ClientArch::Unknown(0xFFFF).ipxe_bootfile(), None); } + #[test] + fn bootfile_default_is_firmware_mode() { + // The convenience method must equal the explicit Firmware mode. + for a in [ + ClientArch::LegacyX86, + ClientArch::Ia32Uefi, + ClientArch::X64Uefi, + ClientArch::Arm64Uefi, + ClientArch::Arm32Uefi, + ClientArch::Unknown(0x99), + ] { + assert_eq!( + a.ipxe_bootfile(), + a.ipxe_bootfile_mode(DriverMode::Firmware) + ); + } + } + + #[test] + fn builtin_mode_maps_to_all_drivers_binaries() { + assert_eq!( + ClientArch::LegacyX86.ipxe_bootfile_mode(DriverMode::Builtin), + Some("ipxe.pxe") + ); + assert_eq!( + ClientArch::X64Uefi.ipxe_bootfile_mode(DriverMode::Builtin), + Some("ipxe.efi") + ); + assert_eq!( + ClientArch::Ia32Uefi.ipxe_bootfile_mode(DriverMode::Builtin), + Some("ipxe-i386.efi") + ); + assert_eq!( + ClientArch::Arm64Uefi.ipxe_bootfile_mode(DriverMode::Builtin), + Some("ipxe-arm64.efi") + ); + // No binary for ARM32 / unknown in either mode. + assert_eq!( + ClientArch::Arm32Uefi.ipxe_bootfile_mode(DriverMode::Builtin), + None + ); + assert_eq!( + ClientArch::Unknown(0x99).ipxe_bootfile_mode(DriverMode::Builtin), + None + ); + } + + #[test] + fn driver_mode_default_is_firmware() { + assert_eq!(DriverMode::default(), DriverMode::Firmware); + } + #[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/lib.rs b/crates/core/src/lib.rs index 527bd71..4f32e83 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -21,7 +21,7 @@ pub mod settings; pub mod sso; pub mod wol; -pub use arch::{ClientArch, FirmwareClass}; +pub use arch::{ClientArch, DriverMode, FirmwareClass}; pub use auth::{AdminAccount, AdminPublic, AdminStore}; pub use boot_log::{BootEvent, BootLog}; pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES}; diff --git a/crates/dhcp-proxy/Cargo.toml b/crates/dhcp-proxy/Cargo.toml index 8460233..5d27799 100644 --- a/crates/dhcp-proxy/Cargo.toml +++ b/crates/dhcp-proxy/Cargo.toml @@ -18,3 +18,4 @@ tracing.workspace = true thiserror.workspace = true anyhow.workspace = true bytes.workspace = true +parking_lot.workspace = true diff --git a/crates/dhcp-proxy/src/escalation.rs b/crates/dhcp-proxy/src/escalation.rs new file mode 100644 index 0000000..bcf0f0a --- /dev/null +++ b/crates/dhcp-proxy/src/escalation.rs @@ -0,0 +1,202 @@ +//! Automatic per-MAC NIC driver-mode escalation (v0.6.1). +//! +//! OpenPXE serves the firmware-net iPXE build (`snponly`/`undionly`) by +//! default — it's the most reliable choice for chainloading because the +//! firmware just proved its network works by downloading the NBP. A minority +//! of NICs have a missing or buggy firmware UNDI/SNP stack; those clients +//! TFTP the binary fine, but then iPXE can't bring the link up, so the +//! tell-tale second DHCP DISCOVER carrying the `iPXE` user-class never arrives +//! and the machine eventually re-PXE-boots. +//! +//! We detect exactly that: a *fresh* firmware DISCOVER from a MAC whose +//! previous firmware attempt was never confirmed by an iPXE handoff means the +//! firmware-net build failed → escalate that MAC to [`DriverMode::Builtin`] +//! (iPXE's own NIC drivers). The decision is sticky — once a MAC settles on a +//! mode that completes the handoff, later boots go straight to it. There is no +//! operator toggle; it just works, and the default (firmware) path is +//! unchanged so hardware that already boots never regresses. + +use openpxe_core::DriverMode; +use parking_lot::Mutex; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +/// Multiple DISCOVERs within this window belong to the *same* boot (DHCP +/// retransmits, plus the :4011 PXE Boot Server query that follows the :67 +/// DISCOVER). They must not be mistaken for a failed-and-retried boot. +const SAME_BOOT_DEBOUNCE: Duration = Duration::from_secs(8); + +/// Forget a MAC's state after this long with no activity, so a transient +/// escalation doesn't pin a client to Builtin forever and the map stays +/// bounded over a long-running deployment. +const ENTRY_TTL: Duration = Duration::from_mins(30); + +/// Hard cap on tracked MACs. Past this we evict the least-recently-seen +/// entry — escalation is best-effort, never a memory-growth vector. +const MAX_ENTRIES: usize = 4096; + +#[derive(Debug, Clone, Copy)] +struct Entry { + mode: DriverMode, + /// True once we've served `mode` and are waiting for the iPXE handoff to + /// confirm it worked. A *new* boot arriving while this is still true means + /// the previous attempt failed and we should escalate. + awaiting_confirm: bool, + last_seen: Instant, +} + +/// Tracks per-MAC driver-mode escalation. Cheap to share via `Arc`. +#[derive(Debug, Default)] +pub struct DriverEscalation { + inner: Mutex>, +} + +impl DriverEscalation { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Decide the driver mode for a firmware (PXEClient/HTTPClient) boot from + /// `mac`. `primary` is true for the main DHCP DISCOVER (:67) and false for + /// the PXE Boot Server query (:4011); only the primary path drives + /// escalation, and only when it's clearly a *new* boot (outside the + /// same-boot debounce). The :4011 path just echoes the current mode. + pub fn mode_for_firmware_attempt(&self, mac: &str, primary: bool) -> DriverMode { + self.decide_at(mac, primary, Instant::now()) + } + + /// Record that `mac` completed the iPXE handoff (a DISCOVER carrying the + /// `iPXE` user-class). The mode we last served worked, so stop awaiting + /// confirmation and keep it sticky for next time. + pub fn mark_ipxe_success(&self, mac: &str) { + self.confirm_at(mac, Instant::now()); + } + + fn decide_at(&self, mac: &str, primary: bool, now: Instant) -> DriverMode { + let mut g = self.inner.lock(); + g.retain(|_, e| now.duration_since(e.last_seen) < ENTRY_TTL); + + match g.get_mut(mac) { + None => { + g.insert( + mac.to_owned(), + Entry { + mode: DriverMode::Firmware, + // Only the primary DISCOVER opens a confirmation window. + awaiting_confirm: primary, + last_seen: now, + }, + ); + if g.len() > MAX_ENTRIES { + evict_oldest(&mut g); + } + DriverMode::Firmware + } + Some(entry) => { + 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). + if entry.awaiting_confirm { + entry.mode = DriverMode::Builtin; + } + entry.awaiting_confirm = true; + } + entry.last_seen = now; + entry.mode + } + } + } + + fn confirm_at(&self, mac: &str, now: Instant) { + let mut g = self.inner.lock(); + if let Some(e) = g.get_mut(mac) { + e.awaiting_confirm = false; + e.last_seen = now; + } + } +} + +fn evict_oldest(map: &mut HashMap) { + if let Some(oldest) = map + .iter() + .min_by_key(|(_, e)| e.last_seen) + .map(|(k, _)| k.clone()) + { + map.remove(&oldest); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn firmware_first_then_escalates_on_unconfirmed_retry() { + let e = DriverEscalation::new(); + let t0 = Instant::now(); + // Boot 1, primary DISCOVER: firmware. + assert_eq!(e.decide_at("aa", true, t0), DriverMode::Firmware); + // Same boot's :4011 query (+1s, within debounce): still firmware, no escalation. + assert_eq!( + e.decide_at("aa", false, t0 + Duration::from_secs(1)), + DriverMode::Firmware + ); + // Firmware net failed → no iPXE handoff → machine re-PXE-boots much + // later: escalate to builtin drivers. + assert_eq!( + e.decide_at("aa", true, t0 + Duration::from_mins(1)), + DriverMode::Builtin + ); + } + + #[test] + fn builtin_is_sticky_after_success() { + let e = DriverEscalation::new(); + let t0 = Instant::now(); + assert_eq!(e.decide_at("bb", true, t0), DriverMode::Firmware); + assert_eq!( + e.decide_at("bb", true, t0 + Duration::from_mins(1)), + DriverMode::Builtin + ); + // Builtin worked this time — confirm the handoff. + e.confirm_at("bb", t0 + Duration::from_secs(61)); + // Next cold boot goes straight to builtin (no wasted firmware attempt). + assert_eq!( + e.decide_at("bb", true, t0 + Duration::from_mins(2)), + DriverMode::Builtin + ); + } + + #[test] + fn confirmed_firmware_never_escalates() { + let e = DriverEscalation::new(); + let t0 = Instant::now(); + assert_eq!(e.decide_at("cc", true, t0), DriverMode::Firmware); + // snponly worked: handoff confirmed. + e.confirm_at("cc", t0 + Duration::from_secs(2)); + // A later boot stays on firmware — no spurious escalation. + assert_eq!( + e.decide_at("cc", true, t0 + Duration::from_mins(5)), + DriverMode::Firmware + ); + } + + #[test] + fn stale_entry_is_forgotten_and_resets_to_firmware() { + let e = DriverEscalation::new(); + let t0 = Instant::now(); + assert_eq!(e.decide_at("dd", true, t0), DriverMode::Firmware); + assert_eq!( + e.decide_at("dd", true, t0 + Duration::from_mins(1)), + DriverMode::Builtin + ); + // After the TTL with no activity the entry is pruned → fresh firmware. + let later = t0 + Duration::from_mins(1) + ENTRY_TTL + Duration::from_secs(1); + assert_eq!(e.decide_at("dd", true, later), DriverMode::Firmware); + } +} diff --git a/crates/dhcp-proxy/src/lib.rs b/crates/dhcp-proxy/src/lib.rs index 21f83dc..784a35b 100644 --- a/crates/dhcp-proxy/src/lib.rs +++ b/crates/dhcp-proxy/src/lib.rs @@ -17,7 +17,9 @@ //! clients silently drop them. #![forbid(unsafe_code)] +pub mod escalation; pub mod reply; pub mod server; +pub use escalation::DriverEscalation; pub use server::DhcpProxyServer; diff --git a/crates/dhcp-proxy/src/reply.rs b/crates/dhcp-proxy/src/reply.rs index a0386fc..b8460f9 100644 --- a/crates/dhcp-proxy/src/reply.rs +++ b/crates/dhcp-proxy/src/reply.rs @@ -9,7 +9,7 @@ //! pass, or the HTTP URL of the boot script once iPXE has chained. use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode}; -use openpxe_core::{ClientArch, FirmwareClass}; +use openpxe_core::{ClientArch, DriverMode, FirmwareClass}; use std::net::Ipv4Addr; /// Where the reply directs the client next. @@ -31,6 +31,11 @@ pub struct ReplyContext<'a> { pub our_ip: Ipv4Addr, pub arch: ClientArch, pub class: FirmwareClass, + /// Which iPXE network backend to advertise for this client. The DHCP + /// proxy fills this from the automatic per-MAC escalation state: normally + /// [`DriverMode::Firmware`], escalated to [`DriverMode::Builtin`] for a + /// MAC whose firmware-net boot failed to chainload (v0.6.1). + pub driver_mode: DriverMode, /// Public base URL (scheme://host[:port]) used in HTTP directives. pub public_base_url: &'a str, } @@ -52,9 +57,13 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective { }, FirmwareClass::HttpClient => { // UEFI HTTP boot: client wants an http:// URL in option 67 - // pointing at an EFI executable. We serve ipxe.efi over HTTP; - // it'll then do the same script-fetch the iPXE path does. - let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi"); + // 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. + let name = ctx + .arch + .ipxe_bootfile_mode(ctx.driver_mode) + .unwrap_or("snponly.efi"); BootDirective::HttpScript { url: format!( "{}/ipxe/{}", @@ -63,7 +72,7 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective { ), } } - FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile() { + FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile_mode(ctx.driver_mode) { Some(name) => BootDirective::TftpIpxe { filename: name.to_string(), }, diff --git a/crates/dhcp-proxy/src/server.rs b/crates/dhcp-proxy/src/server.rs index 9994cc5..7b65cec 100644 --- a/crates/dhcp-proxy/src/server.rs +++ b/crates/dhcp-proxy/src/server.rs @@ -1,10 +1,11 @@ //! UDP listener loop for the DHCP proxy. Accepts on :67 (and :4011 on a //! second socket) and dispatches each datagram through the pure reply logic. +use crate::escalation::DriverEscalation; use crate::reply::{build_reply, decide, BootDirective, ReplyContext}; use dhcproto::v4::{DhcpOption, Message, OptionCode}; use dhcproto::{Decodable, Decoder, Encodable, Encoder}; -use openpxe_core::{ClientArch, ClientEvent, ClientRegistry, FirmwareClass}; +use openpxe_core::{ClientArch, ClientEvent, ClientRegistry, DriverMode, FirmwareClass}; use socket2::{Domain, Protocol, Socket, Type}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; use std::sync::Arc; @@ -18,6 +19,9 @@ pub struct DhcpProxyServer { public_base_url: String, clients: Arc, metrics: openpxe_core::Metrics, + /// Automatic per-MAC NIC driver-mode escalation (v0.6.1). Shared across + /// the :67 and :4011 listener tasks via the server `Arc`. + escalation: DriverEscalation, } impl DhcpProxyServer { @@ -38,6 +42,7 @@ impl DhcpProxyServer { public_base_url, clients, metrics, + escalation: DriverEscalation::new(), } } @@ -126,11 +131,30 @@ impl DhcpProxyServer { }, ); + // Automatic NIC driver-mode selection (v0.6.1). The default is + // firmware-net (snponly/undionly). A successful iPXE handoff confirms + // the current mode works for this MAC; a fresh firmware boot whose + // predecessor never handed off escalates the MAC to iPXE's built-in + // NIC drivers. No operator toggle — the firmware path is unchanged so + // hardware that already boots never regresses. + let driver_mode = match class { + FirmwareClass::IpxeUserClass => { + self.escalation.mark_ipxe_success(&mac); + DriverMode::Firmware // unused: this path serves the HTTP script + } + FirmwareClass::PxeClient | FirmwareClass::HttpClient => self + .escalation + .mode_for_firmware_attempt(&mac, label == "67"), + // Unreachable: FirmwareClass::Other returned above. + FirmwareClass::Other => DriverMode::Firmware, + }; + let ctx = ReplyContext { request: &request, our_ip: self.our_ip, arch, class, + driver_mode, public_base_url: &self.public_base_url, }; let directive = decide(&ctx); @@ -154,7 +178,7 @@ impl DhcpProxyServer { sock.send_to(&out, dest).await?; tracing::info!( target: "openpxe::dhcp", - mac=%mac, arch=arch.as_str(), class=?class, dest=%dest, directive=?directive, + mac=%mac, arch=arch.as_str(), class=?class, driver=?driver_mode, dest=%dest, directive=?directive, "PXE reply sent" ); Ok(()) diff --git a/crates/ipxe-assets/src/lib.rs b/crates/ipxe-assets/src/lib.rs index 1227c6d..0080147 100644 --- a/crates/ipxe-assets/src/lib.rs +++ b/crates/ipxe-assets/src/lib.rs @@ -6,23 +6,32 @@ //! missing, that architecture simply won't have PXE support — we log at //! startup and serve what we have. //! -//! Filename convention (matches `ClientArch::ipxe_bootfile`): +//! Filename convention (matches `ClientArch::ipxe_bootfile_mode`): +//! +//! DriverMode::Firmware (default — reuse the firmware UNDI/SNP NIC stack): //! - `undionly.kpxe` — Legacy x86 BIOS //! - `snponly-i386.efi` — IA32 UEFI //! - `snponly.efi` — x86_64 UEFI -//! - `snponly-arm32.efi` — ARM32 UEFI //! - `snponly-arm64.efi` — ARM64 UEFI -//! - `ipxe.efi` (fallback) — UEFI with bundled drivers, if snponly fails on a NIC +//! +//! DriverMode::Builtin (v0.6.1 automatic fallback — iPXE's own NIC drivers, +//! advertised when a firmware-net boot fails to chainload): +//! - `ipxe.pxe` — Legacy x86 BIOS +//! - `ipxe-i386.efi` — IA32 UEFI +//! - `ipxe.efi` — x86_64 UEFI (built from source with PNG) +//! - `ipxe-arm64.efi` — ARM64 UEFI +//! //! - `wimboot` — Windows boot shim (fetched separately for WIM chains) #![forbid(unsafe_code)] -use openpxe_core::ClientArch; +use openpxe_core::{ClientArch, DriverMode}; use rust_embed::Embed; #[derive(Embed)] #[folder = "../../assets/ipxe/"] #[include = "*.kpxe"] #[include = "*.efi"] +#[include = "*.pxe"] #[include = "wimboot"] pub struct IpxeAssets; @@ -56,25 +65,42 @@ pub fn list_assets() -> Vec { .collect() } -/// Log at startup which iPXE binaries are present and which are missing. +/// Log at startup which iPXE binaries are present and which are missing, for +/// both driver modes. The Firmware-mode binaries are required for PXE on each +/// arch; the Builtin-mode binaries are the optional automatic NIC-driver +/// fallback (v0.6.1) — without one, escalation simply can't help that arch. pub fn log_availability() { let have: std::collections::HashSet = list_assets().into_iter().collect(); - let needed = [ - (ClientArch::LegacyX86, "undionly.kpxe"), - (ClientArch::Ia32Uefi, "snponly-i386.efi"), - (ClientArch::X64Uefi, "snponly.efi"), - // ARM32 UEFI deferred — no upstream snponly binary published. - (ClientArch::Arm64Uefi, "snponly-arm64.efi"), + let arches = [ + ClientArch::LegacyX86, + ClientArch::Ia32Uefi, + ClientArch::X64Uefi, + // ARM32 UEFI deferred — no upstream binary published in either mode. + ClientArch::Arm64Uefi, ]; - for (arch, name) in needed { - if have.contains(name) { - tracing::info!(target: "openpxe::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name); - } else { - tracing::warn!( - target: "openpxe::ipxe", - "MISSING iPXE binary for {}: {} — clients of this arch will not PXE boot", - arch.as_str(), name - ); + for arch in arches { + for mode in [DriverMode::Firmware, DriverMode::Builtin] { + let Some(name) = arch.ipxe_bootfile_mode(mode) else { + continue; + }; + if have.contains(name) { + tracing::info!( + target: "openpxe::ipxe", + "bundled iPXE for {} [{mode:?}]: {name}", arch.as_str() + ); + } else if mode == DriverMode::Firmware { + tracing::warn!( + target: "openpxe::ipxe", + "MISSING iPXE binary for {} [{mode:?}]: {name} — clients of this arch will not PXE boot", + arch.as_str() + ); + } else { + tracing::info!( + target: "openpxe::ipxe", + "no built-in-driver fallback for {} [{mode:?}]: {name} — auto NIC driver escalation unavailable for this arch", + arch.as_str() + ); + } } } } diff --git a/scripts/build-ipxe.sh b/scripts/build-ipxe.sh index deca1a2..8bea9ea 100755 --- a/scripts/build-ipxe.sh +++ b/scripts/build-ipxe.sh @@ -41,15 +41,31 @@ DEST="${1:-$ROOT/assets/ipxe}" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT -# Pinned upstream iPXE. Rolling master is fine functionally, but a pin -# keeps builds reproducible and protects against a transient master -# breakage. Bump deliberately. +# Pinned upstream iPXE. Rolling master is fine functionally, but a pin keeps +# builds reproducible, protects against a transient master breakage, and — +# crucially for the Docker image — busting this value invalidates the cached +# ipxe-build layer so an "update iPXE" release actually recompiles from the +# new upstream. Bump deliberately to a recent master commit. +# +# v0.6.1: ipxe/ipxe master @ 2026-06-09 (newer NIC drivers + EFI fixes; +# mirrors iVentoy 1.0.35 "Update iPXE"). IPXE_REPO="https://github.com/ipxe/ipxe.git" -IPXE_REF="${IPXE_REF:-master}" +IPXE_REF="${IPXE_REF:-95ffbf4745553e8a207922389929e1943c0237c0}" -echo ">> cloning iPXE ($IPXE_REF)" -git clone --depth 1 --branch "$IPXE_REF" "$IPXE_REPO" "$WORK/ipxe" 2>/dev/null \ - || git clone "$IPXE_REPO" "$WORK/ipxe" +echo ">> fetching iPXE ($IPXE_REF)" +# Shallow-fetch the exact ref: works for a full commit SHA (GitHub allows +# reachable-SHA1-in-want) and for branch/tag names. Fall back to a full +# clone + checkout if the server refuses a direct fetch of this ref. +git init -q "$WORK/ipxe" +git -C "$WORK/ipxe" remote add origin "$IPXE_REPO" +if git -C "$WORK/ipxe" fetch -q --depth 1 origin "$IPXE_REF"; then + git -C "$WORK/ipxe" checkout -q FETCH_HEAD +else + echo " direct fetch failed; falling back to full clone + checkout" + rm -rf "$WORK/ipxe" + git clone -q "$IPXE_REPO" "$WORK/ipxe" + git -C "$WORK/ipxe" checkout -q "$IPXE_REF" +fi SRC="$WORK/ipxe/src" echo ">> applying OpenPXE config overrides (PNG + framebuffer + console cmd)" diff --git a/scripts/fetch-ipxe.sh b/scripts/fetch-ipxe.sh index c548a86..4c17e79 100755 --- a/scripts/fetch-ipxe.sh +++ b/scripts/fetch-ipxe.sh @@ -29,11 +29,19 @@ mkdir -p "$DEST" # Upstream uses arch-scoped subdirectories; we flatten to the names our # ClientArch::ipxe_bootfile() expects. declare -a MAP=( + # DriverMode::Firmware (default) — reuse the firmware UNDI/SNP NIC stack. "undionly.kpxe=undionly.kpxe" "snponly.efi=x86_64-efi/snponly.efi" "snponly-i386.efi=i386-efi/snponly.efi" "snponly-arm64.efi=arm64-efi/snponly.efi" - "ipxe.efi=x86_64-efi/ipxe.efi" # fallback with bundled drivers + # DriverMode::Builtin (v0.6.1 automatic fallback) — iPXE's own all-drivers + # builds, advertised by the DHCP proxy to a MAC whose firmware NIC stack + # failed to chainload. (x86_64 ipxe.efi is rebuilt from source with PNG in + # build-ipxe.sh and overlaid on top of this fetched baseline.) + "ipxe.efi=x86_64-efi/ipxe.efi" + "ipxe.pxe=ipxe.pxe" + "ipxe-i386.efi=i386-efi/ipxe.efi" + "ipxe-arm64.efi=arm64-efi/ipxe.efi" ) BASE="https://boot.ipxe.org"