Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a71057fce6 | ||
|
|
29040e8a5a | ||
|
|
3a32d65fb7 |
Generated
+10
-8
@@ -2836,7 +2836,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe"
|
name = "openpxe"
|
||||||
version = "0.6.3"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -2858,7 +2858,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-core"
|
name = "openpxe-core"
|
||||||
version = "0.6.3"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -2885,14 +2885,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-dhcp-proxy"
|
name = "openpxe-dhcp-proxy"
|
||||||
version = "0.6.3"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"dhcproto",
|
"dhcproto",
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
"serde_json",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2900,7 +2902,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-http-api"
|
name = "openpxe-http-api"
|
||||||
version = "0.6.3"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -2936,7 +2938,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-ipxe-assets"
|
name = "openpxe-ipxe-assets"
|
||||||
version = "0.6.3"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
@@ -2946,7 +2948,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-iso-store"
|
name = "openpxe-iso-store"
|
||||||
version = "0.6.3"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
@@ -2975,7 +2977,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-tftp"
|
name = "openpxe-tftp"
|
||||||
version = "0.6.3"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2989,7 +2991,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-webui"
|
name = "openpxe-webui"
|
||||||
version = "0.6.3"
|
version = "0.7.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "p256"
|
name = "p256"
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.6.3"
|
version = "0.7.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+85
-6
@@ -23,13 +23,21 @@ pub enum ClientArch {
|
|||||||
Unknown(u16),
|
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
|
/// OpenPXE serves [`DriverMode::Firmware`] first (the firmware's own NIC
|
||||||
/// stack, via `snponly`/`undionly`) and only escalates a specific MAC to
|
/// stack, via `snponly`/`undionly`) and escalates a specific MAC
|
||||||
/// [`DriverMode::Builtin`] (iPXE's bundled NIC drivers) automatically, when a
|
/// automatically when a boot never completes its handoff:
|
||||||
/// firmware-net boot fails to chainload. There is no operator toggle — the
|
/// `Firmware → Builtin → Shim`. There is no operator toggle — the DHCP
|
||||||
/// DHCP proxy decides per client.
|
/// 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum DriverMode {
|
pub enum DriverMode {
|
||||||
@@ -40,6 +48,9 @@ pub enum DriverMode {
|
|||||||
/// iPXE's own bundled NIC drivers (`ipxe.efi`, `ipxe.pxe`). Fallback for
|
/// iPXE's own bundled NIC drivers (`ipxe.efi`, `ipxe.pxe`). Fallback for
|
||||||
/// hardware whose firmware NIC stack is missing or buggy.
|
/// hardware whose firmware NIC stack is missing or buggy.
|
||||||
Builtin,
|
Builtin,
|
||||||
|
/// Microsoft-signed shim + GRUB chain (`shimx64.efi`). Final fallback
|
||||||
|
/// for Secure-Boot-enabled UEFI clients that refuse unsigned iPXE.
|
||||||
|
Shim,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientArch {
|
impl ClientArch {
|
||||||
@@ -88,20 +99,43 @@ impl ClientArch {
|
|||||||
// IA32 UEFI.
|
// IA32 UEFI.
|
||||||
(Self::Ia32Uefi, DriverMode::Firmware) => "snponly-i386.efi",
|
(Self::Ia32Uefi, DriverMode::Firmware) => "snponly-i386.efi",
|
||||||
(Self::Ia32Uefi, DriverMode::Builtin) => "ipxe-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.
|
// x86_64 UEFI — the overwhelmingly common modern client.
|
||||||
(Self::X64Uefi, DriverMode::Firmware) => "snponly.efi",
|
(Self::X64Uefi, DriverMode::Firmware) => "snponly.efi",
|
||||||
(Self::X64Uefi, DriverMode::Builtin) => "ipxe.efi",
|
(Self::X64Uefi, DriverMode::Builtin) => "ipxe.efi",
|
||||||
|
(Self::X64Uefi, DriverMode::Shim) => "shimx64.efi",
|
||||||
// ARM64 UEFI.
|
// ARM64 UEFI.
|
||||||
(Self::Arm64Uefi, DriverMode::Firmware) => "snponly-arm64.efi",
|
(Self::Arm64Uefi, DriverMode::Firmware) => "snponly-arm64.efi",
|
||||||
(Self::Arm64Uefi, DriverMode::Builtin) => "ipxe-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
|
// 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
|
// None so the DHCP proxy declines rather than advertising a file
|
||||||
// we can't serve.
|
// we can't serve.
|
||||||
(Self::Arm32Uefi | Self::Unknown(_), _) => return None,
|
(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]
|
#[must_use]
|
||||||
pub fn as_str(self) -> &'static str {
|
pub fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -233,6 +267,51 @@ mod tests {
|
|||||||
assert_eq!(DriverMode::default(), DriverMode::Firmware);
|
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]
|
#[test]
|
||||||
fn firmware_class_detects_ipxe_over_pxeclient() {
|
fn firmware_class_detects_ipxe_over_pxeclient() {
|
||||||
let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE"));
|
let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE"));
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
//! 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 `<work_dir>/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.
|
||||||
|
/// May be empty for a rule that only pins a driver mode.
|
||||||
|
#[serde(default)]
|
||||||
|
pub target: String,
|
||||||
|
/// v0.7.1: optional first-boot binary pin — `""` (auto: let the
|
||||||
|
/// escalation ladder decide), `"firmware"`, `"builtin"`, or
|
||||||
|
/// `"shim"`. Lets an operator declare "this rack is all Secure
|
||||||
|
/// Boot → serve the signed chain immediately", skipping the
|
||||||
|
/// learn-by-failing walk entirely for known fleets.
|
||||||
|
#[serde(default)]
|
||||||
|
pub driver_mode: 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<BootRule>,
|
||||||
|
/// Optional boot-decision webhook URL. When set, unmatched boots GET
|
||||||
|
/// `<url>?mac=<mac>&arch=<arch>` and a `200 {"target": "<id>"}`
|
||||||
|
/// 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<PathBuf>,
|
||||||
|
inner: Arc<RwLock<BootRulesConfig>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<BootRulesConfig>(&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.driver_mode = r.driver_mode.trim().to_ascii_lowercase();
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
self.first_match(mac, arch, |r| {
|
||||||
|
(!r.target.is_empty()).then(|| r.target.clone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v0.7.1: first enabled rule that pins a driver mode for `(mac,
|
||||||
|
/// arch)`. Consulted by the DHCP proxy *before* the automatic
|
||||||
|
/// escalation ladder — an operator who knows a rack is all Secure
|
||||||
|
/// Boot pins it to `shim` and those machines never walk the ladder.
|
||||||
|
/// Unknown mode strings are ignored (forward compatibility).
|
||||||
|
#[must_use]
|
||||||
|
pub fn driver_mode_hint(&self, mac: &str, arch: Option<&str>) -> Option<crate::DriverMode> {
|
||||||
|
self.first_match(mac, arch, |r| match r.driver_mode.as_str() {
|
||||||
|
"firmware" => Some(crate::DriverMode::Firmware),
|
||||||
|
"builtin" => Some(crate::DriverMode::Builtin),
|
||||||
|
"shim" => Some(crate::DriverMode::Shim),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared rule-matching walk: returns the first `extract` result from
|
||||||
|
/// an enabled rule whose selectors match. Rules that match but yield
|
||||||
|
/// `None` from `extract` (e.g. no target set, or no driver mode set)
|
||||||
|
/// don't stop the walk — target rules and mode-pin rules coexist.
|
||||||
|
fn first_match<T>(
|
||||||
|
&self,
|
||||||
|
mac: &str,
|
||||||
|
arch: Option<&str>,
|
||||||
|
extract: impl Fn(&BootRule) -> Option<T>,
|
||||||
|
) -> Option<T> {
|
||||||
|
let mac = normalize_mac(mac);
|
||||||
|
let g = self.inner.read();
|
||||||
|
for r in &g.rules {
|
||||||
|
if !r.enabled {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(v) = extract(r) {
|
||||||
|
return Some(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(),
|
||||||
|
driver_mode: String::new(),
|
||||||
|
enabled: true,
|
||||||
|
note: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn driver_mode_hint_pins_known_modes_and_ignores_unknown() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let s = BootRulesStore::load_or_default(dir.path());
|
||||||
|
let mut sb_rack = rule("aa:bb:cc", "", "");
|
||||||
|
sb_rack.driver_mode = "SHIM".into(); // normalized on replace
|
||||||
|
let mut weird = rule("11:22:33", "", "");
|
||||||
|
weird.driver_mode = "quantum".into(); // unknown → ignored
|
||||||
|
s.replace(BootRulesConfig {
|
||||||
|
rules: vec![sb_rack, weird],
|
||||||
|
webhook_url: String::new(),
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
s.driver_mode_hint("aa:bb:cc:00:00:01", None),
|
||||||
|
Some(crate::DriverMode::Shim)
|
||||||
|
);
|
||||||
|
assert_eq!(s.driver_mode_hint("11:22:33:00:00:01", None), None);
|
||||||
|
assert_eq!(s.driver_mode_hint("99:99:99:00:00:01", None), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mode_pin_rule_does_not_shadow_later_target_rule() {
|
||||||
|
// A mode-only rule and a target rule can both apply to the same
|
||||||
|
// client: the mode pin must not consume the target walk.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let s = BootRulesStore::load_or_default(dir.path());
|
||||||
|
let mut pin = rule("aa:bb", "", "");
|
||||||
|
pin.driver_mode = "builtin".into();
|
||||||
|
s.replace(BootRulesConfig {
|
||||||
|
rules: vec![pin, rule("aa:bb", "", "rack-image")],
|
||||||
|
webhook_url: String::new(),
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
s.driver_mode_hint("aa:bb:00:00:00:01", None),
|
||||||
|
Some(crate::DriverMode::Builtin)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.match_target("aa:bb:00:00:00:01", None).as_deref(),
|
||||||
|
Some("rack-image")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/<id>` 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<Mutex<HashMap<String, Grant>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
pub mod arch;
|
pub mod arch;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod boot_log;
|
pub mod boot_log;
|
||||||
|
pub mod boot_rules;
|
||||||
|
pub mod boot_tokens;
|
||||||
pub mod branding;
|
pub mod branding;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
@@ -24,6 +26,8 @@ pub mod wol;
|
|||||||
pub use arch::{ClientArch, DriverMode, FirmwareClass};
|
pub use arch::{ClientArch, DriverMode, FirmwareClass};
|
||||||
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
||||||
pub use boot_log::{BootEvent, BootLog};
|
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 branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
|
||||||
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
|
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
|
||||||
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
|
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
|
||||||
|
|||||||
@@ -19,3 +19,8 @@ thiserror.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
|
# v0.7.1: learned driver modes persist to <work_dir>/driver_modes.json.
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.12"
|
||||||
|
|||||||
@@ -1,24 +1,43 @@
|
|||||||
//! Automatic per-MAC NIC driver-mode escalation (v0.6.1).
|
//! Automatic per-MAC boot-binary escalation (v0.6.1, extended v0.7.x).
|
||||||
//!
|
//!
|
||||||
//! OpenPXE serves the firmware-net iPXE build (`snponly`/`undionly`) by
|
//! OpenPXE serves the firmware-net iPXE build (`snponly`/`undionly`) by
|
||||||
//! default — it's the most reliable choice for chainloading because the
|
//! default — it's the most reliable choice for chainloading because the
|
||||||
//! firmware just proved its network works by downloading the NBP. A minority
|
//! firmware just proved its network works by downloading the NBP. Two
|
||||||
//! of NICs have a missing or buggy firmware UNDI/SNP stack; those clients
|
//! classes of machine can't run it:
|
||||||
//! 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
|
//! * a minority of NICs have a missing or buggy firmware UNDI/SNP stack —
|
||||||
//! previous firmware attempt was never confirmed by an iPXE handoff means the
|
//! they TFTP the binary fine but iPXE can't bring the link up;
|
||||||
//! firmware-net build failed → escalate that MAC to [`DriverMode::Builtin`]
|
//! * Secure-Boot firmware downloads it fine but refuses to *execute* an
|
||||||
//! (iPXE's own NIC drivers). The decision is sticky — once a MAC settles on a
|
//! unsigned image.
|
||||||
//! 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
|
//! Both look identical from here: the tell-tale second DHCP DISCOVER
|
||||||
//! unchanged so hardware that already boots never regresses.
|
//! carrying the `iPXE` user-class never arrives and the machine
|
||||||
|
//! re-PXE-boots. So a fresh firmware DISCOVER from a MAC whose previous
|
||||||
|
//! attempt was never confirmed climbs one rung:
|
||||||
|
//! `Firmware → Builtin → Shim` (the signed shim+GRUB chain). The decision
|
||||||
|
//! is sticky; there is no operator toggle; the default path is unchanged
|
||||||
|
//! so hardware that already boots never regresses.
|
||||||
|
//!
|
||||||
|
//! v0.7.1 — **learned modes persist**. Walking the ladder costs one or
|
||||||
|
//! two failed boot cycles, so a machine should pay it once *ever*, not
|
||||||
|
//! once per idle window or server restart. Two events pin a MAC's mode
|
||||||
|
//! to disk (`<work_dir>/driver_modes.json`):
|
||||||
|
//!
|
||||||
|
//! * a confirmed iPXE handoff at a non-default mode (Builtin proved to
|
||||||
|
//! work — also Shim, via the GRUB→iPXE same-boot chainload);
|
||||||
|
//! * reaching the terminal Shim rung (Secure-Boot machines never produce
|
||||||
|
//! an iPXE handoff from the signed menu, so escalation itself is the
|
||||||
|
//! best knowledge we'll ever have).
|
||||||
|
//!
|
||||||
|
//! Pinned entries are immune to the TTL and reload at startup. The
|
||||||
|
//! operator escape hatch is a rules-level driver-mode pin (which
|
||||||
|
//! overrides this table entirely) or deleting `driver_modes.json`.
|
||||||
|
|
||||||
use openpxe_core::DriverMode;
|
use openpxe_core::DriverMode;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// Multiple DISCOVERs within this window belong to the *same* boot (DHCP
|
/// Multiple DISCOVERs within this window belong to the *same* boot (DHCP
|
||||||
@@ -26,13 +45,14 @@ use std::time::{Duration, Instant};
|
|||||||
/// DISCOVER). They must not be mistaken for a failed-and-retried boot.
|
/// DISCOVER). They must not be mistaken for a failed-and-retried boot.
|
||||||
const SAME_BOOT_DEBOUNCE: Duration = Duration::from_secs(8);
|
const SAME_BOOT_DEBOUNCE: Duration = Duration::from_secs(8);
|
||||||
|
|
||||||
/// Forget a MAC's state after this long with no activity, so a transient
|
/// Forget an *unpinned* MAC's state after this long with no activity, so
|
||||||
/// escalation doesn't pin a client to Builtin forever and the map stays
|
/// a transient mid-walk state doesn't linger and the map stays bounded.
|
||||||
/// bounded over a long-running deployment.
|
/// Pinned (learned) entries are exempt — that's their whole point.
|
||||||
const ENTRY_TTL: Duration = Duration::from_mins(30);
|
const ENTRY_TTL: Duration = Duration::from_mins(30);
|
||||||
|
|
||||||
/// Hard cap on tracked MACs. Past this we evict the least-recently-seen
|
/// Hard cap on tracked MACs. Past this we evict the least-recently-seen
|
||||||
/// entry — escalation is best-effort, never a memory-growth vector.
|
/// entry (unpinned first) — escalation is best-effort, never a
|
||||||
|
/// memory-growth vector.
|
||||||
const MAX_ENTRIES: usize = 4096;
|
const MAX_ENTRIES: usize = 4096;
|
||||||
|
|
||||||
/// How often (at most) the whole map is swept for expired entries.
|
/// How often (at most) the whole map is swept for expired entries.
|
||||||
@@ -49,6 +69,8 @@ struct Entry {
|
|||||||
/// confirm it worked. A *new* boot arriving while this is still true means
|
/// confirm it worked. A *new* boot arriving while this is still true means
|
||||||
/// the previous attempt failed and we should escalate.
|
/// the previous attempt failed and we should escalate.
|
||||||
awaiting_confirm: bool,
|
awaiting_confirm: bool,
|
||||||
|
/// Learned mode (v0.7.1): persisted to disk, exempt from the TTL.
|
||||||
|
pinned: bool,
|
||||||
last_seen: Instant,
|
last_seen: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,14 +94,68 @@ impl Default for Inner {
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct DriverEscalation {
|
pub struct DriverEscalation {
|
||||||
inner: Mutex<Inner>,
|
inner: Mutex<Inner>,
|
||||||
|
/// Persistence target for learned modes; `None` = ephemeral (tests).
|
||||||
|
path: Option<Arc<PathBuf>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DriverEscalation {
|
impl DriverEscalation {
|
||||||
|
/// Ephemeral instance (no persistence) — used by tests.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Instance backed by `<work_dir>/driver_modes.json`. Learned modes
|
||||||
|
/// from previous runs are reloaded as pinned entries; a missing or
|
||||||
|
/// corrupt file starts empty (same crash-cache policy as every other
|
||||||
|
/// store — a bad file must never block PXE).
|
||||||
|
#[must_use]
|
||||||
|
pub fn load_or_default(work_dir: &Path) -> Self {
|
||||||
|
let path = work_dir.join("driver_modes.json");
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
if let Ok(text) = std::fs::read_to_string(&path) {
|
||||||
|
match serde_json::from_str::<HashMap<String, DriverMode>>(&text) {
|
||||||
|
Ok(loaded) => {
|
||||||
|
let now = Instant::now();
|
||||||
|
for (mac, mode) in loaded {
|
||||||
|
// Firmware is the default — persisting it would be
|
||||||
|
// noise; tolerate it in the file but don't track it.
|
||||||
|
if mode == DriverMode::Firmware {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map.insert(
|
||||||
|
mac,
|
||||||
|
Entry {
|
||||||
|
mode,
|
||||||
|
awaiting_confirm: false,
|
||||||
|
pinned: true,
|
||||||
|
last_seen: now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
target: "openpxe::dhcp",
|
||||||
|
learned = map.len(),
|
||||||
|
"loaded learned driver modes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "openpxe::dhcp",
|
||||||
|
"driver_modes.json present but unreadable ({e}); starting empty"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
inner: Mutex::new(Inner {
|
||||||
|
map,
|
||||||
|
last_prune: Instant::now(),
|
||||||
|
}),
|
||||||
|
path: Some(Arc::new(path)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Decide the driver mode for a firmware (PXEClient/HTTPClient) boot from
|
/// Decide the driver mode for a firmware (PXEClient/HTTPClient) boot from
|
||||||
/// `mac`. `primary` is true for the main DHCP DISCOVER (:67) and false for
|
/// `mac`. `primary` is true for the main DHCP DISCOVER (:67) and false for
|
||||||
/// the PXE Boot Server query (:4011); only the primary path drives
|
/// the PXE Boot Server query (:4011); only the primary path drives
|
||||||
@@ -91,77 +167,150 @@ impl DriverEscalation {
|
|||||||
|
|
||||||
/// Record that `mac` completed the iPXE handoff (a DISCOVER carrying the
|
/// Record that `mac` completed the iPXE handoff (a DISCOVER carrying the
|
||||||
/// `iPXE` user-class). The mode we last served worked, so stop awaiting
|
/// `iPXE` user-class). The mode we last served worked, so stop awaiting
|
||||||
/// confirmation and keep it sticky for next time.
|
/// confirmation, keep it sticky, and — for non-default modes — pin it to
|
||||||
|
/// disk so the machine never re-walks the ladder (v0.7.1).
|
||||||
pub fn mark_ipxe_success(&self, mac: &str) {
|
pub fn mark_ipxe_success(&self, mac: &str) {
|
||||||
self.confirm_at(mac, Instant::now());
|
self.confirm_at(mac, Instant::now());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decide_at(&self, mac: &str, primary: bool, now: Instant) -> DriverMode {
|
fn decide_at(&self, mac: &str, primary: bool, now: Instant) -> DriverMode {
|
||||||
let mut g = self.inner.lock();
|
let (mode, snapshot) = {
|
||||||
if now.duration_since(g.last_prune) >= PRUNE_INTERVAL {
|
let mut g = self.inner.lock();
|
||||||
g.map
|
if now.duration_since(g.last_prune) >= PRUNE_INTERVAL {
|
||||||
.retain(|_, e| now.duration_since(e.last_seen) < ENTRY_TTL);
|
g.map
|
||||||
g.last_prune = now;
|
.retain(|_, e| e.pinned || now.duration_since(e.last_seen) < ENTRY_TTL);
|
||||||
}
|
g.last_prune = now;
|
||||||
// Inline staleness check: a MAC whose entry outlived the TTL starts
|
}
|
||||||
// fresh even when the amortized sweep above hasn't caught it yet.
|
// Inline staleness check: an unpinned MAC whose entry outlived
|
||||||
if g.map
|
// the TTL starts fresh even when the amortized sweep above
|
||||||
.get(mac)
|
// hasn't caught it yet. Pinned entries never go stale.
|
||||||
.is_some_and(|e| now.duration_since(e.last_seen) >= ENTRY_TTL)
|
if g.map
|
||||||
{
|
.get(mac)
|
||||||
g.map.remove(mac);
|
.is_some_and(|e| !e.pinned && now.duration_since(e.last_seen) >= ENTRY_TTL)
|
||||||
}
|
{
|
||||||
|
g.map.remove(mac);
|
||||||
|
}
|
||||||
|
|
||||||
match g.map.get_mut(mac) {
|
let mut newly_pinned = false;
|
||||||
None => {
|
let mode = match g.map.get_mut(mac) {
|
||||||
g.map.insert(
|
None => {
|
||||||
mac.to_owned(),
|
g.map.insert(
|
||||||
Entry {
|
mac.to_owned(),
|
||||||
mode: DriverMode::Firmware,
|
Entry {
|
||||||
// Only the primary DISCOVER opens a confirmation window.
|
mode: DriverMode::Firmware,
|
||||||
awaiting_confirm: primary,
|
// Only the primary DISCOVER opens a confirmation window.
|
||||||
last_seen: now,
|
awaiting_confirm: primary,
|
||||||
},
|
pinned: false,
|
||||||
);
|
last_seen: now,
|
||||||
if g.map.len() > MAX_ENTRIES {
|
},
|
||||||
evict_oldest(&mut g.map);
|
);
|
||||||
}
|
if g.map.len() > MAX_ENTRIES {
|
||||||
DriverMode::Firmware
|
evict_oldest(&mut g.map);
|
||||||
}
|
|
||||||
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;
|
DriverMode::Firmware
|
||||||
}
|
}
|
||||||
entry.last_seen = now;
|
Some(entry) => {
|
||||||
entry.mode
|
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 build we served failed → climb
|
||||||
|
// one rung: Firmware (firmware NIC stack) → Builtin
|
||||||
|
// (iPXE's own drivers) → Shim (signed shim+GRUB —
|
||||||
|
// covers Secure Boot firmware that downloads our
|
||||||
|
// unsigned iPXE but refuses to execute it). Shim is
|
||||||
|
// terminal and pins to disk: SB machines never emit
|
||||||
|
// an iPXE handoff from the signed menu, so reaching
|
||||||
|
// the rung *is* the durable knowledge.
|
||||||
|
if entry.awaiting_confirm {
|
||||||
|
entry.mode = match entry.mode {
|
||||||
|
DriverMode::Firmware => DriverMode::Builtin,
|
||||||
|
DriverMode::Builtin | DriverMode::Shim => DriverMode::Shim,
|
||||||
|
};
|
||||||
|
if entry.mode == DriverMode::Shim && !entry.pinned {
|
||||||
|
entry.pinned = true;
|
||||||
|
newly_pinned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.awaiting_confirm = true;
|
||||||
|
}
|
||||||
|
entry.last_seen = now;
|
||||||
|
entry.mode
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(mode, newly_pinned.then(|| pinned_snapshot(&g.map)))
|
||||||
|
};
|
||||||
|
if let Some(s) = snapshot {
|
||||||
|
self.persist(&s);
|
||||||
}
|
}
|
||||||
|
mode
|
||||||
}
|
}
|
||||||
|
|
||||||
fn confirm_at(&self, mac: &str, now: Instant) {
|
fn confirm_at(&self, mac: &str, now: Instant) {
|
||||||
let mut g = self.inner.lock();
|
let snapshot = {
|
||||||
if let Some(e) = g.map.get_mut(mac) {
|
let mut g = self.inner.lock();
|
||||||
|
let Some(e) = g.map.get_mut(mac) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
e.awaiting_confirm = false;
|
e.awaiting_confirm = false;
|
||||||
e.last_seen = now;
|
e.last_seen = now;
|
||||||
|
// A proven non-default mode is worth remembering forever —
|
||||||
|
// the machine demonstrably can't use the default path.
|
||||||
|
if e.mode != DriverMode::Firmware && !e.pinned {
|
||||||
|
e.pinned = true;
|
||||||
|
Some(pinned_snapshot(&g.map))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(s) = snapshot {
|
||||||
|
self.persist(&s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort atomic write of the learned-mode table. No-op for
|
||||||
|
/// ephemeral instances. Failure logs and moves on — persistence is an
|
||||||
|
/// optimization, never a correctness requirement.
|
||||||
|
fn persist(&self, snapshot: &HashMap<String, DriverMode>) {
|
||||||
|
let Some(path) = &self.path else { return };
|
||||||
|
let body = match serde_json::to_vec_pretty(snapshot) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(target: "openpxe::dhcp", "serialize driver_modes.json: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
if let Err(e) = std::fs::write(&tmp, body) {
|
||||||
|
tracing::warn!(target: "openpxe::dhcp", "write driver_modes.json tmp: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::rename(&tmp, path.as_path()) {
|
||||||
|
tracing::warn!(target: "openpxe::dhcp", "rename driver_modes.json: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pinned_snapshot(map: &HashMap<String, Entry>) -> HashMap<String, DriverMode> {
|
||||||
|
map.iter()
|
||||||
|
.filter(|(_, e)| e.pinned)
|
||||||
|
.map(|(k, e)| (k.clone(), e.mode))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn evict_oldest(map: &mut HashMap<String, Entry>) {
|
fn evict_oldest(map: &mut HashMap<String, Entry>) {
|
||||||
if let Some(oldest) = map
|
// Prefer evicting an unpinned entry; only touch learned modes when
|
||||||
.iter()
|
// the whole table is pinned (4096 learned machines — at that point
|
||||||
.min_by_key(|(_, e)| e.last_seen)
|
// the operator has bigger questions than our memory bound).
|
||||||
.map(|(k, _)| k.clone())
|
let pick = |pinned: bool| {
|
||||||
{
|
map.iter()
|
||||||
|
.filter(|(_, e)| e.pinned == pinned)
|
||||||
|
.min_by_key(|(_, e)| e.last_seen)
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
};
|
||||||
|
if let Some(oldest) = pick(false).or_else(|| pick(true)) {
|
||||||
map.remove(&oldest);
|
map.remove(&oldest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,6 +318,7 @@ fn evict_oldest(map: &mut HashMap<String, Entry>) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn firmware_first_then_escalates_on_unconfirmed_retry() {
|
fn firmware_first_then_escalates_on_unconfirmed_retry() {
|
||||||
@@ -222,7 +372,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stale_entry_is_forgotten_and_resets_to_firmware() {
|
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_unpinned_entry_is_forgotten_and_resets_to_firmware() {
|
||||||
let e = DriverEscalation::new();
|
let e = DriverEscalation::new();
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
assert_eq!(e.decide_at("dd", true, t0), DriverMode::Firmware);
|
assert_eq!(e.decide_at("dd", true, t0), DriverMode::Firmware);
|
||||||
@@ -230,8 +403,92 @@ mod tests {
|
|||||||
e.decide_at("dd", true, t0 + Duration::from_mins(1)),
|
e.decide_at("dd", true, t0 + Duration::from_mins(1)),
|
||||||
DriverMode::Builtin
|
DriverMode::Builtin
|
||||||
);
|
);
|
||||||
// After the TTL with no activity the entry is pruned → fresh firmware.
|
// After the TTL with no activity the (unpinned) Builtin walk is
|
||||||
|
// pruned → fresh firmware. (A *confirmed* Builtin would be pinned
|
||||||
|
// and survive — see learned_builtin_survives_ttl.)
|
||||||
let later = t0 + Duration::from_mins(1) + ENTRY_TTL + Duration::from_secs(1);
|
let later = t0 + Duration::from_mins(1) + ENTRY_TTL + Duration::from_secs(1);
|
||||||
assert_eq!(e.decide_at("dd", true, later), DriverMode::Firmware);
|
assert_eq!(e.decide_at("dd", true, later), DriverMode::Firmware);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shim_pin_survives_ttl() {
|
||||||
|
// v0.7.1: reaching the Shim rung is durable knowledge — the
|
||||||
|
// machine must NOT re-walk the ladder after an idle period.
|
||||||
|
let e = DriverEscalation::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let _ = e.decide_at("ff", true, t0);
|
||||||
|
let _ = e.decide_at("ff", true, t0 + Duration::from_mins(1));
|
||||||
|
assert_eq!(
|
||||||
|
e.decide_at("ff", true, t0 + Duration::from_mins(2)),
|
||||||
|
DriverMode::Shim
|
||||||
|
);
|
||||||
|
let much_later = t0 + Duration::from_mins(2) + ENTRY_TTL + Duration::from_mins(5);
|
||||||
|
assert_eq!(e.decide_at("ff", true, much_later), DriverMode::Shim);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn learned_builtin_survives_ttl() {
|
||||||
|
let e = DriverEscalation::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let _ = e.decide_at("gg", true, t0);
|
||||||
|
assert_eq!(
|
||||||
|
e.decide_at("gg", true, t0 + Duration::from_mins(1)),
|
||||||
|
DriverMode::Builtin
|
||||||
|
);
|
||||||
|
// The handoff confirms Builtin → pinned.
|
||||||
|
e.confirm_at("gg", t0 + Duration::from_secs(61));
|
||||||
|
let much_later = t0 + ENTRY_TTL + Duration::from_mins(10);
|
||||||
|
assert_eq!(e.decide_at("gg", true, much_later), DriverMode::Builtin);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn learned_modes_persist_across_restart() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
{
|
||||||
|
let e = DriverEscalation::load_or_default(dir.path());
|
||||||
|
// Walk one MAC to Shim (pins on escalation)...
|
||||||
|
let _ = e.decide_at("aa:01", true, t0);
|
||||||
|
let _ = e.decide_at("aa:01", true, t0 + Duration::from_mins(1));
|
||||||
|
assert_eq!(
|
||||||
|
e.decide_at("aa:01", true, t0 + Duration::from_mins(2)),
|
||||||
|
DriverMode::Shim
|
||||||
|
);
|
||||||
|
// ...and another to a confirmed Builtin (pins on handoff).
|
||||||
|
let _ = e.decide_at("aa:02", true, t0);
|
||||||
|
let _ = e.decide_at("aa:02", true, t0 + Duration::from_mins(1));
|
||||||
|
e.confirm_at("aa:02", t0 + Duration::from_secs(61));
|
||||||
|
}
|
||||||
|
// "Restart": a fresh instance from the same work_dir knows both.
|
||||||
|
let e2 = DriverEscalation::load_or_default(dir.path());
|
||||||
|
assert_eq!(e2.decide_at("aa:01", true, t0), DriverMode::Shim);
|
||||||
|
assert_eq!(e2.decide_at("aa:02", true, t0), DriverMode::Builtin);
|
||||||
|
// Unlearned MACs still start at the default.
|
||||||
|
assert_eq!(e2.decide_at("aa:03", true, t0), DriverMode::Firmware);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_persistence_file_starts_empty() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("driver_modes.json"), b"{broken").unwrap();
|
||||||
|
let e = DriverEscalation::load_or_default(dir.path());
|
||||||
|
assert_eq!(
|
||||||
|
e.decide_at("aa:bb", true, Instant::now()),
|
||||||
|
DriverMode::Firmware
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirmed_firmware_is_not_persisted() {
|
||||||
|
// The default mode is never written — the file only carries
|
||||||
|
// exceptions, so a healthy fleet leaves it absent/empty.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
{
|
||||||
|
let e = DriverEscalation::load_or_default(dir.path());
|
||||||
|
let _ = e.decide_at("aa:09", true, t0);
|
||||||
|
e.confirm_at("aa:09", t0 + Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
assert!(!dir.path().join("driver_modes.json").exists());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,10 +49,13 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
|
|||||||
// Pass the client's MAC in the query string so the HTTP
|
// Pass the client's MAC in the query string so the HTTP
|
||||||
// layer can short-circuit to a per-MAC binding when one
|
// layer can short-circuit to a per-MAC binding when one
|
||||||
// exists. iPXE substitutes `${mac}` literally before issuing
|
// 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!(
|
url: format!(
|
||||||
"{}/boot.ipxe?mac=${{mac}}",
|
"{}/boot.ipxe?mac=${{mac}}&arch={}",
|
||||||
ctx.public_base_url.trim_end_matches('/')
|
ctx.public_base_url.trim_end_matches('/'),
|
||||||
|
ctx.arch.as_str()
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
FirmwareClass::HttpClient => {
|
FirmwareClass::HttpClient => {
|
||||||
@@ -60,9 +63,12 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
|
|||||||
// pointing at an EFI executable. We serve the iPXE EFI build for
|
// pointing at an EFI executable. We serve the iPXE EFI build for
|
||||||
// the negotiated driver mode over HTTP; it'll then do the same
|
// the negotiated driver mode over HTTP; it'll then do the same
|
||||||
// script-fetch the iPXE path does.
|
// 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
|
let name = ctx
|
||||||
.arch
|
.arch
|
||||||
.ipxe_bootfile_mode(ctx.driver_mode)
|
.bootfile_with_fallback(ctx.driver_mode)
|
||||||
.unwrap_or("snponly.efi");
|
.unwrap_or("snponly.efi");
|
||||||
BootDirective::HttpScript {
|
BootDirective::HttpScript {
|
||||||
url: format!(
|
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 {
|
Some(name) => BootDirective::TftpIpxe {
|
||||||
filename: name.to_string(),
|
filename: name.to_string(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ use crate::escalation::DriverEscalation;
|
|||||||
use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
|
use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
|
||||||
use dhcproto::v4::{DhcpOption, Message, OptionCode};
|
use dhcproto::v4::{DhcpOption, Message, OptionCode};
|
||||||
use dhcproto::{Decodable, Decoder, Encodable, Encoder};
|
use dhcproto::{Decodable, Decoder, Encodable, Encoder};
|
||||||
use openpxe_core::{ClientArch, ClientEvent, ClientRegistry, DriverMode, FirmwareClass};
|
use openpxe_core::{
|
||||||
|
BootRulesStore, ClientArch, ClientEvent, ClientRegistry, DriverMode, FirmwareClass,
|
||||||
|
};
|
||||||
use socket2::{Domain, Protocol, Socket, Type};
|
use socket2::{Domain, Protocol, Socket, Type};
|
||||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -19,12 +21,19 @@ pub struct DhcpProxyServer {
|
|||||||
public_base_url: String,
|
public_base_url: String,
|
||||||
clients: Arc<ClientRegistry>,
|
clients: Arc<ClientRegistry>,
|
||||||
metrics: openpxe_core::Metrics,
|
metrics: openpxe_core::Metrics,
|
||||||
/// Automatic per-MAC NIC driver-mode escalation (v0.6.1). Shared across
|
/// Automatic per-MAC NIC driver-mode escalation (v0.6.1; persistent
|
||||||
/// the :67 and :4011 listener tasks via the server `Arc`.
|
/// learned modes since v0.7.1). Shared across the :67 and :4011
|
||||||
|
/// listener tasks via the server `Arc`. Built by the caller so the
|
||||||
|
/// persistence path comes from the configured work dir.
|
||||||
escalation: DriverEscalation,
|
escalation: DriverEscalation,
|
||||||
|
/// v0.7.1: boot rules — consulted for an operator driver-mode pin
|
||||||
|
/// (e.g. "this OUI is all Secure Boot → serve shim immediately")
|
||||||
|
/// before the automatic escalation ladder.
|
||||||
|
rules: BootRulesStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DhcpProxyServer {
|
impl DhcpProxyServer {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
bind: IpAddr,
|
bind: IpAddr,
|
||||||
dhcp_port: u16,
|
dhcp_port: u16,
|
||||||
@@ -33,6 +42,8 @@ impl DhcpProxyServer {
|
|||||||
public_base_url: String,
|
public_base_url: String,
|
||||||
clients: Arc<ClientRegistry>,
|
clients: Arc<ClientRegistry>,
|
||||||
metrics: openpxe_core::Metrics,
|
metrics: openpxe_core::Metrics,
|
||||||
|
escalation: DriverEscalation,
|
||||||
|
rules: BootRulesStore,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
bind,
|
bind,
|
||||||
@@ -42,7 +53,8 @@ impl DhcpProxyServer {
|
|||||||
public_base_url,
|
public_base_url,
|
||||||
clients,
|
clients,
|
||||||
metrics,
|
metrics,
|
||||||
escalation: DriverEscalation::new(),
|
escalation,
|
||||||
|
rules,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,9 +154,17 @@ impl DhcpProxyServer {
|
|||||||
self.escalation.mark_ipxe_success(&mac);
|
self.escalation.mark_ipxe_success(&mac);
|
||||||
DriverMode::Firmware // unused: this path serves the HTTP script
|
DriverMode::Firmware // unused: this path serves the HTTP script
|
||||||
}
|
}
|
||||||
FirmwareClass::PxeClient | FirmwareClass::HttpClient => self
|
FirmwareClass::PxeClient | FirmwareClass::HttpClient => {
|
||||||
.escalation
|
// v0.7.1: an operator rule pin wins over (and bypasses)
|
||||||
.mode_for_firmware_attempt(&mac, label == "67"),
|
// the automatic escalation ladder — known Secure-Boot
|
||||||
|
// fleets boot the signed chain on the very first cycle.
|
||||||
|
if let Some(pinned) = self.rules.driver_mode_hint(&mac, Some(arch.as_str())) {
|
||||||
|
pinned
|
||||||
|
} else {
|
||||||
|
self.escalation
|
||||||
|
.mode_for_firmware_attempt(&mac, label == "67")
|
||||||
|
}
|
||||||
|
}
|
||||||
// Unreachable: FirmwareClass::Other returned above.
|
// Unreachable: FirmwareClass::Other returned above.
|
||||||
FirmwareClass::Other => DriverMode::Firmware,
|
FirmwareClass::Other => DriverMode::Firmware,
|
||||||
};
|
};
|
||||||
|
|||||||
+263
-34
@@ -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
|
// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the
|
||||||
// limited broadcast + the server's own subnet broadcast.
|
// limited broadcast + the server's own subnet broadcast.
|
||||||
.route("/api/hosts/{mac}/wol", post(api_hosts_wol))
|
.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
|
// Rolling "host log" of boot events: what image actually
|
||||||
// started installing on what MAC/IP, and when. Persisted to disk.
|
// started installing on what MAC/IP, and when. Persisted to disk.
|
||||||
.route("/api/boot-log", get(api_boot_log))
|
.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
|
// `?mac=` so the per-entry handler can record the boot into
|
||||||
// the Host log without depending on iPXE substitution at
|
// the Host log without depending on iPXE substitution at
|
||||||
// this stage.
|
// this stage.
|
||||||
return text_plain(format!(
|
return text_plain(chain_script(base, &target, &bound_mac, "per-MAC binding"));
|
||||||
"#!ipxe\n\
|
}
|
||||||
echo OpenPXE: per-MAC binding -> {target}\n\
|
|
||||||
chain {base}/boot/{target}.ipxe?mac={bound_mac} || chain {base}/boot.ipxe\n"
|
// 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))
|
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<std::net::IpAddr>,
|
||||||
|
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": "<id>"}` 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<String> {
|
||||||
|
#[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
|
/// 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.
|
/// 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 {
|
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
|
/// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we
|
||||||
/// fall back to the menu unconditionally.
|
/// fall back to the menu unconditionally.
|
||||||
mac: Option<String>,
|
mac: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -750,7 +850,13 @@ async fn boot_sub(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|fid| state.unattended.get(fid))
|
.and_then(|fid| state.unattended.get(fid))
|
||||||
.and_then(|meta| {
|
.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) ───────────────────────
|
// ─── bundled iPXE binaries (memtest lives here too) ───────────────────────
|
||||||
|
|
||||||
async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response {
|
async fn ipxe_binary(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
||||||
if name.contains('/') || name.contains('\\') {
|
if name.contains('/') || name.contains('\\') {
|
||||||
return (StatusCode::BAD_REQUEST, "invalid name").into_response();
|
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 {
|
let Some(data) = asset_slice(&name) else {
|
||||||
return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
|
return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
|
||||||
};
|
};
|
||||||
@@ -1382,17 +1497,54 @@ struct UnattendedServeQuery {
|
|||||||
hostname: Option<String>,
|
hostname: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
ip: Option<String>,
|
ip: Option<String>,
|
||||||
|
/// v0.7.0: short-lived access token minted into the generated URL.
|
||||||
|
#[serde(default)]
|
||||||
|
t: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
/// `{{IP}}` / `{{MAC}}` substituted from the query string. Returns
|
||||||
/// `text/plain` so installers (anaconda, debian-installer, Windows setup
|
/// `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(
|
async fn serve_unattended(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath(id): AxumPath<String>,
|
AxumPath(id): AxumPath<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
Query(q): Query<UnattendedServeQuery>,
|
Query(q): Query<UnattendedServeQuery>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
if !unattended_access_allowed(&state, &headers, q.t.as_deref(), &id) {
|
||||||
|
return unattended_denied();
|
||||||
|
}
|
||||||
let Ok(bytes) = state.unattended.read(&id).await else {
|
let Ok(bytes) = state.unattended.read(&id).await else {
|
||||||
return (StatusCode::NOT_FOUND, "no such unattended file").into_response();
|
return (StatusCode::NOT_FOUND, "no such unattended file").into_response();
|
||||||
};
|
};
|
||||||
@@ -1414,8 +1566,15 @@ async fn serve_unattended(
|
|||||||
async fn serve_unattended_seed(
|
async fn serve_unattended_seed(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath((id, ctx, sub)): AxumPath<(String, String, String)>,
|
AxumPath((id, ctx, sub)): AxumPath<(String, String, String)>,
|
||||||
|
headers: HeaderMap,
|
||||||
) -> Response {
|
) -> 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() {
|
match sub.as_str() {
|
||||||
"user-data" => {
|
"user-data" => {
|
||||||
let Ok(bytes) = state.unattended.read(&id).await else {
|
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<AppState>) -> Json<openpxe_core::BootRulesConfig> {
|
||||||
|
Json(state.boot_rules.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_boot_rules_put(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(cfg): Json<openpxe_core::BootRulesConfig>,
|
||||||
|
) -> 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
|
/// Resolve the deployment profile for a booting MAC: a host pin wins, else
|
||||||
/// a queued device's Profile. `None` when neither carries one.
|
/// a queued device's Profile. `None` when neither carries one.
|
||||||
fn resolve_profile(state: &AppState, mac: &str) -> Option<DeployProfile> {
|
fn resolve_profile(state: &AppState, mac: &str) -> Option<DeployProfile> {
|
||||||
@@ -1458,12 +1633,23 @@ fn build_unattended_args(
|
|||||||
meta: &UnattendedMeta,
|
meta: &UnattendedMeta,
|
||||||
mac: Option<&str>,
|
mac: Option<&str>,
|
||||||
profile: &DeployProfile,
|
profile: &DeployProfile,
|
||||||
|
tokens: &openpxe_core::BootTokens,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let base = base.trim_end_matches('/');
|
let base = base.trim_end_matches('/');
|
||||||
let id = &meta.id;
|
let id = &meta.id;
|
||||||
let host = profile.auto_hostname.as_deref();
|
let host = profile.auto_hostname.as_deref();
|
||||||
let ip = profile.auto_ip.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 {
|
match meta.kind {
|
||||||
UnattendedKind::Kickstart => Some(format!("inst.ks={base}/unattended/{id}{query}")),
|
UnattendedKind::Kickstart => Some(format!("inst.ks={base}/unattended/{id}{query}")),
|
||||||
UnattendedKind::Preseed => {
|
UnattendedKind::Preseed => {
|
||||||
@@ -1475,7 +1661,7 @@ fn build_unattended_args(
|
|||||||
Some(s)
|
Some(s)
|
||||||
}
|
}
|
||||||
UnattendedKind::Autoinstall => {
|
UnattendedKind::Autoinstall => {
|
||||||
let ctx = encode_seed_ctx(mac, host, ip);
|
let ctx = encode_seed_ctx(mac, host, ip, &token);
|
||||||
Some(format!(
|
Some(format!(
|
||||||
"autoinstall ds=nocloud-net;s={base}/unattended/{id}/{ctx}/"
|
"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.
|
// `pct_encode` lives in `openpxe_core::encoding` (v0.5.4) — imported above.
|
||||||
|
|
||||||
/// Encode `(hostname, ip, mac)` into a single base64url path segment for
|
/// Encode `(hostname, ip, mac, token)` into a single base64url path
|
||||||
/// the cloud-init seed directory. Empty values become empty fields.
|
/// segment for the cloud-init seed directory. Empty values become empty
|
||||||
fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>) -> String {
|
/// 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 _;
|
use base64::Engine as _;
|
||||||
let raw = format!(
|
let raw = format!(
|
||||||
"{}\n{}\n{}",
|
"{}\n{}\n{}\n{token}",
|
||||||
hostname.unwrap_or(""),
|
hostname.unwrap_or(""),
|
||||||
ip.unwrap_or(""),
|
ip.unwrap_or(""),
|
||||||
mac.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())
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip)`. A bad
|
/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip, token)`.
|
||||||
/// or empty segment yields all-`None` so the seed still serves (just
|
/// A bad or empty segment yields all-`None`; a pre-v0.7.0 three-field
|
||||||
/// without per-host substitution).
|
/// ctx decodes with `token: None` (and the gate then rejects it once an
|
||||||
fn decode_seed_ctx(ctx: &str) -> (Option<String>, Option<String>, Option<String>) {
|
/// admin exists — stale URLs are exactly what tokens invalidate).
|
||||||
|
fn decode_seed_ctx(
|
||||||
|
ctx: &str,
|
||||||
|
) -> (
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
) {
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(ctx.as_bytes()) else {
|
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 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 clean = |v: Option<&str>| v.map(str::to_string).filter(|x| !x.is_empty());
|
||||||
let hostname = clean(it.next());
|
let hostname = clean(it.next());
|
||||||
let ip = clean(it.next());
|
let ip = clean(it.next());
|
||||||
let mac = clean(it.next());
|
let mac = clean(it.next());
|
||||||
(mac, hostname, ip)
|
let token = clean(it.next());
|
||||||
|
(mac, hostname, ip, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── API reference (Settings → bottom) ────────────────────────────────────
|
// ─── API reference (Settings → bottom) ────────────────────────────────────
|
||||||
@@ -1709,6 +1911,10 @@ async fn api_docs() -> Json<serde_json::Value> {
|
|||||||
"summary": "Pin a MAC to a boot target. Body: { mac, target, label, auto_hostname?, auto_ip?, unattended_file? }."},
|
"summary": "Pin a MAC to a boot target. Body: { mac, target, label, auto_hostname?, auto_ip?, unattended_file? }."},
|
||||||
{"method": "DELETE", "path": "/api/hosts/{mac}",
|
{"method": "DELETE", "path": "/api/hosts/{mac}",
|
||||||
"summary": "Remove a binding."},
|
"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",
|
{"method": "POST", "path": "/api/hosts/{mac}/wol",
|
||||||
"summary": "Send a Wake-on-LAN magic packet to a bound MAC (limited + subnet broadcast)."},
|
"summary": "Send a Wake-on-LAN magic packet to a bound MAC (limited + subnet broadcast)."},
|
||||||
{"method": "GET", "path": "/api/boot-log",
|
{"method": "GET", "path": "/api/boot-log",
|
||||||
@@ -2538,6 +2744,7 @@ async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|||||||
.strip_prefix("http://")
|
.strip_prefix("http://")
|
||||||
.unwrap_or(&state.public_base_url),
|
.unwrap_or(&state.public_base_url),
|
||||||
"nic_name": state.nic_name,
|
"nic_name": state.nic_name,
|
||||||
|
"nic_link": state.nic_link,
|
||||||
"subnet_mask": state.subnet_mask,
|
"subnet_mask": state.subnet_mask,
|
||||||
"gateway": state.gateway,
|
"gateway": state.gateway,
|
||||||
"dns_server": state.settings.snapshot().dns_server,
|
"dns_server": state.settings.snapshot().dns_server,
|
||||||
@@ -2942,11 +3149,13 @@ mod tests {
|
|||||||
auto_ip: Some("10.0.0.7".into()),
|
auto_ip: Some("10.0.0.7".into()),
|
||||||
unattended_file: Some("ks1".into()),
|
unattended_file: Some("ks1".into()),
|
||||||
};
|
};
|
||||||
|
let tokens = openpxe_core::BootTokens::new();
|
||||||
let a = build_unattended_args(
|
let a = build_unattended_args(
|
||||||
"http://h",
|
"http://h",
|
||||||
&meta(UnattendedKind::Kickstart),
|
&meta(UnattendedKind::Kickstart),
|
||||||
Some("aa:bb:cc:dd:ee:ff"),
|
Some("aa:bb:cc:dd:ee:ff"),
|
||||||
&p,
|
&p,
|
||||||
|
&tokens,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(a.starts_with("inst.ks=http://h/unattended/ks1?"), "{a}");
|
assert!(a.starts_with("inst.ks=http://h/unattended/ks1?"), "{a}");
|
||||||
@@ -2954,6 +3163,9 @@ mod tests {
|
|||||||
assert!(a.contains("ip=10.0.0.7"), "{a}");
|
assert!(a.contains("ip=10.0.0.7"), "{a}");
|
||||||
// MAC colons are percent-encoded.
|
// MAC colons are percent-encoded.
|
||||||
assert!(a.contains("mac=aa%3Abb%3Acc%3Add%3Aee%3Aff"), "{a}");
|
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]
|
#[test]
|
||||||
@@ -2962,8 +3174,15 @@ mod tests {
|
|||||||
auto_hostname: Some("deb1".into()),
|
auto_hostname: Some("deb1".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let a =
|
let tokens = openpxe_core::BootTokens::new();
|
||||||
build_unattended_args("http://h/", &meta(UnattendedKind::Preseed), None, &p).unwrap();
|
let a = build_unattended_args(
|
||||||
|
"http://h/",
|
||||||
|
&meta(UnattendedKind::Preseed),
|
||||||
|
None,
|
||||||
|
&p,
|
||||||
|
&tokens,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
a.starts_with("auto=true priority=critical url=http://h/unattended/ks1"),
|
a.starts_with("auto=true priority=critical url=http://h/unattended/ks1"),
|
||||||
"{a}"
|
"{a}"
|
||||||
@@ -2978,11 +3197,13 @@ mod tests {
|
|||||||
auto_ip: Some("10.1.1.5".into()),
|
auto_ip: Some("10.1.1.5".into()),
|
||||||
unattended_file: Some("ks1".into()),
|
unattended_file: Some("ks1".into()),
|
||||||
};
|
};
|
||||||
|
let tokens = openpxe_core::BootTokens::new();
|
||||||
let a = build_unattended_args(
|
let a = build_unattended_args(
|
||||||
"http://h",
|
"http://h",
|
||||||
&meta(UnattendedKind::Autoinstall),
|
&meta(UnattendedKind::Autoinstall),
|
||||||
Some("aa:bb"),
|
Some("aa:bb"),
|
||||||
&p,
|
&p,
|
||||||
|
&tokens,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2992,10 +3213,12 @@ mod tests {
|
|||||||
assert!(a.ends_with('/'), "seed URL must end with '/': {a}");
|
assert!(a.ends_with('/'), "seed URL must end with '/': {a}");
|
||||||
// The ctx segment round-trips back to the per-host values.
|
// The ctx segment round-trips back to the per-host values.
|
||||||
let ctx = a.trim_end_matches('/').rsplit('/').next().unwrap();
|
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!(mac.as_deref(), Some("aa:bb"));
|
||||||
assert_eq!(host.as_deref(), Some("u1"));
|
assert_eq!(host.as_deref(), Some("u1"));
|
||||||
assert_eq!(ip.as_deref(), Some("10.1.1.5"));
|
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]
|
#[test]
|
||||||
@@ -3004,20 +3227,26 @@ mod tests {
|
|||||||
unattended_file: Some("ks1".into()),
|
unattended_file: Some("ks1".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert!(
|
let tokens = openpxe_core::BootTokens::new();
|
||||||
build_unattended_args("http://h", &meta(UnattendedKind::AnswerFile), None, &p)
|
assert!(build_unattended_args(
|
||||||
.is_none()
|
"http://h",
|
||||||
);
|
&meta(UnattendedKind::AnswerFile),
|
||||||
|
None,
|
||||||
|
&p,
|
||||||
|
&tokens
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn seed_ctx_empty_segment_decodes_to_none() {
|
fn seed_ctx_empty_segment_decodes_to_none() {
|
||||||
let ctx = encode_seed_ctx(None, None, None);
|
let ctx = encode_seed_ctx(None, None, None, "tok");
|
||||||
let (m, h, i) = decode_seed_ctx(&ctx);
|
let (m, h, i, t) = decode_seed_ctx(&ctx);
|
||||||
assert!(m.is_none() && h.is_none() && i.is_none());
|
assert!(m.is_none() && h.is_none() && i.is_none());
|
||||||
|
assert_eq!(t.as_deref(), Some("tok"));
|
||||||
// Garbage decodes safely to all-None.
|
// Garbage decodes safely to all-None.
|
||||||
let (m2, h2, i2) = decode_seed_ctx("!!!not-base64!!!");
|
let (m2, h2, i2, t2) = decode_seed_ctx("!!!not-base64!!!");
|
||||||
assert!(m2.is_none() && h2.is_none() && i2.is_none());
|
assert!(m2.is_none() && h2.is_none() && i2.is_none() && t2.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -169,6 +169,13 @@ fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
|||||||
None
|
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 ────────────────────────────────────────────────────────────
|
// ── Middleware ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Return `true` if `path` is on the allowlist and should bypass the
|
/// Return `true` if `path` is on the allowlist and should bypass the
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! GRUB menu rendering for the Secure Boot chain (v0.7.0).
|
||||||
|
//!
|
||||||
|
//! Secure-Boot-enabled firmware refuses our unsigned iPXE, so those
|
||||||
|
//! clients are automatically escalated (see `openpxe_dhcp_proxy::
|
||||||
|
//! escalation`) to the Microsoft-signed Fedora `shim` → signed `grub`
|
||||||
|
//! chain. GRUB then fetches `grub.cfg` from this server (TFTP `$prefix`
|
||||||
|
//! resolution, or HTTP when the whole chain came over HTTP Boot) — and
|
||||||
|
//! this module renders that config from the same boot-entry model that
|
||||||
|
//! renders `boot.ipxe`.
|
||||||
|
//!
|
||||||
|
//! Scope: **Linux kernel entries only.** A signed GRUB will only execute
|
||||||
|
//! kernels that pass shim verification — i.e. distro-signed kernels —
|
||||||
|
//! which is exactly what `LinuxKernel` boot entries point at. `sanboot`
|
||||||
|
//! ISO emulation and `wimboot` are iPXE mechanisms with no signed
|
||||||
|
//! equivalent; those entries are omitted here, and the menu says so.
|
||||||
|
//! (Windows deployment under Secure Boot has no legitimate unsigned
|
||||||
|
//! path — per project policy we never ship test-signed binaries or touch
|
||||||
|
//! client trust stores.)
|
||||||
|
//!
|
||||||
|
//! The kernel/initrd lines use GRUB's `(http,host:port)` device syntax;
|
||||||
|
//! Fedora's signed netboot GRUB carries the `http`, `tftp` and `efinet`
|
||||||
|
//! modules built in, so no unsigned module loading is required.
|
||||||
|
|
||||||
|
use openpxe_iso_store::{BootKind, IsoMeta};
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
/// Render the full `grub.cfg` for the signed-GRUB menu.
|
||||||
|
///
|
||||||
|
/// `base_url` is the public HTTP base (`http://10.0.0.5` or
|
||||||
|
/// `http://10.0.0.5:8080`) — converted to GRUB's `(http,host:port)`
|
||||||
|
/// device prefix for kernel/initrd fetches.
|
||||||
|
#[must_use]
|
||||||
|
pub fn render_grub_menu(isos: &[IsoMeta], base_url: &str) -> String {
|
||||||
|
let base = base_url.trim_end_matches('/');
|
||||||
|
let dev = grub_http_device(base);
|
||||||
|
let mut s = String::new();
|
||||||
|
let _ = writeln!(s, "# OpenPXE — Secure Boot menu (signed shim+GRUB chain)");
|
||||||
|
// v0.7.1: before showing the limited signed menu, try to hand the
|
||||||
|
// boot back to full iPXE *in this same boot cycle*. With Secure Boot
|
||||||
|
// OFF the chainload succeeds and the client gets the complete iPXE
|
||||||
|
// feature set (sanboot, wimboot, the full menu) despite having been
|
||||||
|
// escalated here. With Secure Boot ON, shim's verifier refuses the
|
||||||
|
// unsigned image INLINE — no reboot, no failed cycle — and execution
|
||||||
|
// falls through to the signed menu below. The all-drivers build is
|
||||||
|
// used because a MAC only lands here after the firmware-net build
|
||||||
|
// already failed once.
|
||||||
|
let _ = writeln!(s, "if [ \"$grub_cpu\" = \"arm64\" ]; then");
|
||||||
|
let _ = writeln!(s, " set openpxe_ipxe=ipxe-arm64.efi");
|
||||||
|
let _ = writeln!(s, "else");
|
||||||
|
let _ = writeln!(s, " set openpxe_ipxe=ipxe.efi");
|
||||||
|
let _ = writeln!(s, "fi");
|
||||||
|
let _ = writeln!(s, "if chainloader {dev}/ipxe/$openpxe_ipxe ; then");
|
||||||
|
let _ = writeln!(s, " boot");
|
||||||
|
let _ = writeln!(s, "fi");
|
||||||
|
let _ = writeln!(s);
|
||||||
|
let _ = writeln!(s, "set timeout=30");
|
||||||
|
let _ = writeln!(s, "set default=0");
|
||||||
|
let _ = writeln!(s);
|
||||||
|
|
||||||
|
let mut entries = 0usize;
|
||||||
|
for iso in isos {
|
||||||
|
for entry in &iso.boot_entries {
|
||||||
|
let BootKind::LinuxKernel {
|
||||||
|
kernel_url,
|
||||||
|
initrd_urls,
|
||||||
|
args,
|
||||||
|
} = &entry.kind
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// GRUB menu titles: keep quotes out of the label.
|
||||||
|
let title = entry.title.replace('"', "'");
|
||||||
|
let cmdline = args.cmdline.replace("${base-url}", base);
|
||||||
|
let _ = writeln!(s, "menuentry \"{} — {title}\" {{", iso.filename);
|
||||||
|
let _ = writeln!(s, " linux {dev}/{kernel_url} {cmdline}");
|
||||||
|
if !initrd_urls.is_empty() {
|
||||||
|
let _ = write!(s, " initrd");
|
||||||
|
for u in initrd_urls {
|
||||||
|
let _ = write!(s, " {dev}/{u}");
|
||||||
|
}
|
||||||
|
let _ = writeln!(s);
|
||||||
|
}
|
||||||
|
let _ = writeln!(s, "}}");
|
||||||
|
let _ = writeln!(s);
|
||||||
|
entries += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries == 0 {
|
||||||
|
let _ = writeln!(
|
||||||
|
s,
|
||||||
|
"menuentry \"No Secure-Boot-bootable images on this server yet\" {{ true }}"
|
||||||
|
);
|
||||||
|
let _ = writeln!(s);
|
||||||
|
}
|
||||||
|
// Always give the operator a way off this screen.
|
||||||
|
let _ = writeln!(s, "menuentry \"Boot from local disk\" {{");
|
||||||
|
let _ = writeln!(s, " exit");
|
||||||
|
let _ = writeln!(s, "}}");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `http://10.0.0.5:8080` → `(http,10.0.0.5:8080)`. GRUB wants the
|
||||||
|
/// scheme as the device type and host[:port] as the device address.
|
||||||
|
fn grub_http_device(base: &str) -> String {
|
||||||
|
let host = base
|
||||||
|
.trim_start_matches("http://")
|
||||||
|
.trim_start_matches("https://");
|
||||||
|
format!("(http,{host})")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use openpxe_iso_store::{BootEntry, IsoSource, KernelArgs};
|
||||||
|
|
||||||
|
fn linux_iso() -> IsoMeta {
|
||||||
|
IsoMeta {
|
||||||
|
id: "alp".into(),
|
||||||
|
filename: "alpine.iso".into(),
|
||||||
|
size_bytes: 1,
|
||||||
|
sha256_hex: None,
|
||||||
|
uploaded_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
source: IsoSource::Local,
|
||||||
|
introspection: openpxe_iso_store::IntrospectionReport::default(),
|
||||||
|
boot_entries: vec![BootEntry {
|
||||||
|
id: "alp-linux".into(),
|
||||||
|
title: "Linux installer".into(),
|
||||||
|
kind: BootKind::LinuxKernel {
|
||||||
|
kernel_url: "iso/alp/boot/vmlinuz".into(),
|
||||||
|
initrd_urls: vec!["iso/alp/boot/initrd".into()],
|
||||||
|
args: KernelArgs {
|
||||||
|
cmdline: "quiet repo=${base-url}/iso/alp.iso".into(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
category: openpxe_iso_store::IsoCategory::default(),
|
||||||
|
password_hash: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_linux_entries_with_http_device_urls() {
|
||||||
|
let cfg = render_grub_menu(&[linux_iso()], "http://10.0.0.5:8080/");
|
||||||
|
assert!(
|
||||||
|
cfg.contains("menuentry \"alpine.iso — Linux installer\""),
|
||||||
|
"{cfg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
cfg.contains("linux (http,10.0.0.5:8080)/iso/alp/boot/vmlinuz quiet repo=http://10.0.0.5:8080/iso/alp.iso"),
|
||||||
|
"{cfg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
cfg.contains("initrd (http,10.0.0.5:8080)/iso/alp/boot/initrd"),
|
||||||
|
"{cfg}"
|
||||||
|
);
|
||||||
|
assert!(cfg.contains("Boot from local disk"), "{cfg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_tries_ipxe_chainload_before_menu() {
|
||||||
|
// v0.7.1: SB-off machines recover full iPXE in the same boot;
|
||||||
|
// SB-on machines fail the chainload inline and reach the menu.
|
||||||
|
let cfg = render_grub_menu(&[linux_iso()], "http://10.0.0.5:8080");
|
||||||
|
let chain_pos = cfg
|
||||||
|
.find("if chainloader (http,10.0.0.5:8080)/ipxe/$openpxe_ipxe ; then")
|
||||||
|
.expect("chainload attempt missing");
|
||||||
|
let menu_pos = cfg.find("menuentry").expect("menu missing");
|
||||||
|
assert!(
|
||||||
|
chain_pos < menu_pos,
|
||||||
|
"chainload must precede the menu:\n{cfg}"
|
||||||
|
);
|
||||||
|
// Arch-conditional binary selection via GRUB's $grub_cpu.
|
||||||
|
assert!(cfg.contains("set openpxe_ipxe=ipxe-arm64.efi"), "{cfg}");
|
||||||
|
assert!(cfg.contains("set openpxe_ipxe=ipxe.efi"), "{cfg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanboot_and_wimboot_entries_are_omitted() {
|
||||||
|
let mut iso = linux_iso();
|
||||||
|
iso.boot_entries = vec![BootEntry {
|
||||||
|
id: "win".into(),
|
||||||
|
title: "Windows".into(),
|
||||||
|
kind: BootKind::SanBootIso {
|
||||||
|
iso_url: "iso/win.iso".into(),
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
let cfg = render_grub_menu(&[iso], "http://10.0.0.5");
|
||||||
|
assert!(!cfg.contains("Windows"), "{cfg}");
|
||||||
|
assert!(cfg.contains("No Secure-Boot-bootable images"), "{cfg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod grub_script;
|
||||||
pub mod ipxe_script;
|
pub mod ipxe_script;
|
||||||
pub mod iso_fs;
|
pub mod iso_fs;
|
||||||
pub mod log_stream;
|
pub mod log_stream;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use crate::auth::SessionStore;
|
|||||||
use crate::saml_routes::SamlRuntime;
|
use crate::saml_routes::SamlRuntime;
|
||||||
use crate::uploads::UploadSessions;
|
use crate::uploads::UploadSessions;
|
||||||
use openpxe_core::{
|
use openpxe_core::{
|
||||||
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
|
AdminStore, BootLog, BootRulesStore, BootTokens, BrandingStore, ClientRegistry,
|
||||||
Metrics, NotifyStore, SettingsStore, SsoStore,
|
DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore,
|
||||||
};
|
};
|
||||||
use openpxe_iso_store::{
|
use openpxe_iso_store::{
|
||||||
IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager, UnattendedStore,
|
IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager, UnattendedStore,
|
||||||
@@ -29,6 +29,14 @@ pub struct AppState {
|
|||||||
/// every `/boot/<entry>.ipxe` chain that goes on to serve a script
|
/// every `/boot/<entry>.ipxe` chain that goes on to serve a script
|
||||||
/// (i.e. an image actually starting to install on a machine).
|
/// (i.e. an image actually starting to install on a machine).
|
||||||
pub boot_log: BootLog,
|
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-controlled UI overrides (custom logo). When the
|
||||||
/// operator hasn't uploaded anything, the WebUI serves the bundled
|
/// operator hasn't uploaded anything, the WebUI serves the bundled
|
||||||
/// rainbow-horizon mark.
|
/// rainbow-horizon mark.
|
||||||
@@ -108,6 +116,10 @@ pub struct AppState {
|
|||||||
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
|
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
|
||||||
/// interface couldn't be identified.
|
/// interface couldn't be identified.
|
||||||
pub nic_name: String,
|
pub nic_name: String,
|
||||||
|
/// v0.7.2: physical link summary for that NIC (operstate, speed,
|
||||||
|
/// duplex, port MAC) — read from sysfs at startup; empty where
|
||||||
|
/// unavailable. Helps confirm which port answers PXE.
|
||||||
|
pub nic_link: String,
|
||||||
/// Subnet mask of the public interface in dotted-quad form.
|
/// Subnet mask of the public interface in dotted-quad form.
|
||||||
pub subnet_mask: String,
|
pub subnet_mask: String,
|
||||||
/// Default gateway IPv4 address.
|
/// Default gateway IPv4 address.
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
settings,
|
settings,
|
||||||
hosts,
|
hosts,
|
||||||
boot_log,
|
boot_log,
|
||||||
|
boot_rules: openpxe_core::BootRulesStore::load_or_default(dir.path()),
|
||||||
|
boot_tokens: openpxe_core::BootTokens::new(),
|
||||||
branding,
|
branding,
|
||||||
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
||||||
admin,
|
admin,
|
||||||
@@ -133,6 +135,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
started_at: time::OffsetDateTime::now_utc(),
|
started_at: time::OffsetDateTime::now_utc(),
|
||||||
public_base_url: "http://127.0.0.1".into(),
|
public_base_url: "http://127.0.0.1".into(),
|
||||||
nic_name: "lo".into(),
|
nic_name: "lo".into(),
|
||||||
|
nic_link: String::new(),
|
||||||
subnet_mask: "255.0.0.0".into(),
|
subnet_mask: "255.0.0.0".into(),
|
||||||
gateway: "127.0.0.1".into(),
|
gateway: "127.0.0.1".into(),
|
||||||
};
|
};
|
||||||
@@ -2610,3 +2613,162 @@ async fn acs_garbage_is_rejected_without_500() {
|
|||||||
assert!(location(&resp).contains("sso_error"));
|
assert!(location(&resp).contains("sso_error"));
|
||||||
assert!(!has_session_cookie(&resp));
|
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::<serde_json::Value>(&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::<serde_json::Value>(&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=<token>");
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn boot_rule_driver_mode_pin_round_trips_via_api() {
|
||||||
|
// v0.7.1: a rule may pin only a boot binary (no target) — the API
|
||||||
|
// must persist and return it for the DHCP proxy to consult.
|
||||||
|
let (state, _dir) = build_state().await;
|
||||||
|
let app = build_router(state.clone());
|
||||||
|
let cfg = r#"{"rules":[{"mac_prefix":"aa:bb:cc","arch":"","target":"","driver_mode":"shim","enabled":true,"note":"SB rack"}],"webhook_url":""}"#;
|
||||||
|
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
|
||||||
|
assert_eq!(s, StatusCode::NO_CONTENT);
|
||||||
|
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]["driver_mode"], "shim");
|
||||||
|
// And the store the DHCP proxy shares resolves the pin.
|
||||||
|
assert_eq!(
|
||||||
|
state.boot_rules.driver_mode_hint("aa:bb:cc:00:00:07", None),
|
||||||
|
Some(openpxe_core::DriverMode::Shim)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ pub fn log_availability() {
|
|||||||
ClientArch::Arm64Uefi,
|
ClientArch::Arm64Uefi,
|
||||||
];
|
];
|
||||||
for arch in arches {
|
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 {
|
let Some(name) = arch.ipxe_bootfile_mode(mode) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -82,12 +82,18 @@ pub fn log_availability() {
|
|||||||
"MISSING iPXE binary for {} [{mode:?}]: {name} — clients of this arch will not PXE boot",
|
"MISSING iPXE binary for {} [{mode:?}]: {name} — clients of this arch will not PXE boot",
|
||||||
arch.as_str()
|
arch.as_str()
|
||||||
);
|
);
|
||||||
} else {
|
} else if mode == DriverMode::Builtin {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "openpxe::ipxe",
|
target: "openpxe::ipxe",
|
||||||
"no built-in-driver fallback for {} [{mode:?}]: {name} — auto NIC driver escalation unavailable for this arch",
|
"no built-in-driver fallback for {} [{mode:?}]: {name} — auto NIC driver escalation unavailable for this arch",
|
||||||
arch.as_str()
|
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()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
"network info"
|
"network info"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// v0.7.1: boot rules are shared between the HTTP layer (target rules,
|
||||||
|
// webhook, the editor API) and the DHCP proxy (driver-mode pins).
|
||||||
|
let boot_rules = openpxe_core::BootRulesStore::load_or_default(&config.paths.work_dir);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
iso_store: iso_store.clone(),
|
iso_store: iso_store.clone(),
|
||||||
clients: clients.clone(),
|
clients: clients.clone(),
|
||||||
@@ -191,6 +195,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
queue: queue.clone(),
|
queue: queue.clone(),
|
||||||
hosts: hosts.clone(),
|
hosts: hosts.clone(),
|
||||||
boot_log: boot_log.clone(),
|
boot_log: boot_log.clone(),
|
||||||
|
boot_rules: boot_rules.clone(),
|
||||||
|
boot_tokens: openpxe_core::BootTokens::new(),
|
||||||
branding: branding.clone(),
|
branding: branding.clone(),
|
||||||
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
||||||
admin: admin.clone(),
|
admin: admin.clone(),
|
||||||
@@ -209,6 +215,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
started_at: time::OffsetDateTime::now_utc(),
|
started_at: time::OffsetDateTime::now_utc(),
|
||||||
public_base_url: public_base_url.clone(),
|
public_base_url: public_base_url.clone(),
|
||||||
nic_name: net.nic_name,
|
nic_name: net.nic_name,
|
||||||
|
nic_link: net.nic_link,
|
||||||
subnet_mask: net.subnet_mask,
|
subnet_mask: net.subnet_mask,
|
||||||
gateway: net.gateway,
|
gateway: net.gateway,
|
||||||
};
|
};
|
||||||
@@ -230,11 +237,28 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Ok::<_, anyhow::Error>(())
|
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(
|
let tftp = TftpServer::new(
|
||||||
config.server.tftp_bind,
|
config.server.tftp_bind,
|
||||||
config.server.tftp_port,
|
config.server.tftp_port,
|
||||||
clients.clone(),
|
clients.clone(),
|
||||||
metrics.clone(),
|
metrics.clone(),
|
||||||
|
Some(tftp_dynamic),
|
||||||
);
|
);
|
||||||
let tftp_task = tokio::spawn(tftp.run());
|
let tftp_task = tokio::spawn(tftp.run());
|
||||||
|
|
||||||
@@ -248,6 +272,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
public_base_url.clone(),
|
public_base_url.clone(),
|
||||||
clients.clone(),
|
clients.clone(),
|
||||||
metrics.clone(),
|
metrics.clone(),
|
||||||
|
// v0.7.1: learned driver modes persist next to the other
|
||||||
|
// state files, so a machine walks the ladder once *ever*.
|
||||||
|
openpxe_dhcp_proxy::DriverEscalation::load_or_default(&config.paths.work_dir),
|
||||||
|
boot_rules.clone(),
|
||||||
);
|
);
|
||||||
tokio::spawn(s.run())
|
tokio::spawn(s.run())
|
||||||
}
|
}
|
||||||
@@ -421,6 +449,12 @@ struct NetworkInfo {
|
|||||||
nic_name: String,
|
nic_name: String,
|
||||||
subnet_mask: String,
|
subnet_mask: String,
|
||||||
gateway: String,
|
gateway: String,
|
||||||
|
/// v0.7.2: physical link summary for the Network tab — operstate,
|
||||||
|
/// negotiated speed/duplex, and the port's own MAC. Helps operators
|
||||||
|
/// in multi-NIC / trunked environments confirm *which* port the PXE
|
||||||
|
/// server actually answers on. Empty when sysfs isn't available
|
||||||
|
/// (non-Linux dev builds) or the NIC wasn't identified.
|
||||||
|
nic_link: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort population of the Network tab's read-only fields. We shell
|
/// Best-effort population of the Network tab's read-only fields. We shell
|
||||||
@@ -481,9 +515,44 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info.nic_link = detect_link_info(&info.nic_name);
|
||||||
|
|
||||||
info
|
info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.7.2: read the NIC's physical link details from sysfs. Every field
|
||||||
|
/// is optional — virtual NICs report no speed (`-1` or absent), and
|
||||||
|
/// non-Linux dev machines have no `/sys/class/net` at all — so the
|
||||||
|
/// result is whatever could be read, joined human-readably, or empty.
|
||||||
|
fn detect_link_info(nic: &str) -> String {
|
||||||
|
if nic.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let read = |file: &str| {
|
||||||
|
std::fs::read_to_string(format!("/sys/class/net/{nic}/{file}"))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
let state = read("operstate");
|
||||||
|
if !state.is_empty() {
|
||||||
|
parts.push(format!("link {state}"));
|
||||||
|
}
|
||||||
|
let speed = read("speed");
|
||||||
|
if !speed.is_empty() && speed != "-1" {
|
||||||
|
parts.push(format!("{speed} Mb/s"));
|
||||||
|
}
|
||||||
|
let duplex = read("duplex");
|
||||||
|
if !duplex.is_empty() && duplex != "unknown" {
|
||||||
|
parts.push(format!("{duplex} duplex"));
|
||||||
|
}
|
||||||
|
let mac = read("address");
|
||||||
|
if !mac.is_empty() {
|
||||||
|
parts.push(format!("port {mac}"));
|
||||||
|
}
|
||||||
|
parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
fn prefix_to_dotted(prefix: u8) -> String {
|
fn prefix_to_dotted(prefix: u8) -> String {
|
||||||
let prefix = prefix.min(32);
|
let prefix = prefix.min(32);
|
||||||
let mask: u32 = if prefix == 0 {
|
let mask: u32 = if prefix == 0 {
|
||||||
|
|||||||
@@ -14,4 +14,4 @@
|
|||||||
|
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|
||||||
pub use server::TftpServer;
|
pub use server::{DynamicAsset, TftpServer};
|
||||||
|
|||||||
@@ -32,11 +32,19 @@ const ERR_NOT_DEFINED: u16 = 0;
|
|||||||
const ERR_FILE_NOT_FOUND: u16 = 1;
|
const ERR_FILE_NOT_FOUND: u16 = 1;
|
||||||
const ERR_ILLEGAL_OP: u16 = 4;
|
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<dyn Fn(&str) -> Option<Vec<u8>> + Send + Sync>;
|
||||||
|
|
||||||
pub struct TftpServer {
|
pub struct TftpServer {
|
||||||
bind: IpAddr,
|
bind: IpAddr,
|
||||||
port: u16,
|
port: u16,
|
||||||
clients: Arc<ClientRegistry>,
|
clients: Arc<ClientRegistry>,
|
||||||
metrics: openpxe_core::Metrics,
|
metrics: openpxe_core::Metrics,
|
||||||
|
dynamic: Option<DynamicAsset>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TftpServer {
|
impl TftpServer {
|
||||||
@@ -45,12 +53,14 @@ impl TftpServer {
|
|||||||
port: u16,
|
port: u16,
|
||||||
clients: Arc<ClientRegistry>,
|
clients: Arc<ClientRegistry>,
|
||||||
metrics: openpxe_core::Metrics,
|
metrics: openpxe_core::Metrics,
|
||||||
|
dynamic: Option<DynamicAsset>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
bind,
|
bind,
|
||||||
port,
|
port,
|
||||||
clients,
|
clients,
|
||||||
metrics,
|
metrics,
|
||||||
|
dynamic,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,8 +82,11 @@ impl TftpServer {
|
|||||||
let clients = clients.clone();
|
let clients = clients.clone();
|
||||||
let metrics = metrics.clone();
|
let metrics = metrics.clone();
|
||||||
let bind_ip = self.bind;
|
let bind_ip = self.bind;
|
||||||
|
let dynamic = self.dynamic.clone();
|
||||||
tokio::spawn(async move {
|
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();
|
metrics.record_tftp_err();
|
||||||
tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}");
|
tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}");
|
||||||
}
|
}
|
||||||
@@ -88,6 +101,7 @@ async fn handle_rrq(
|
|||||||
bind_ip: IpAddr,
|
bind_ip: IpAddr,
|
||||||
clients: Arc<ClientRegistry>,
|
clients: Arc<ClientRegistry>,
|
||||||
metrics: openpxe_core::Metrics,
|
metrics: openpxe_core::Metrics,
|
||||||
|
dynamic: Option<DynamicAsset>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let Some(req) = parse_rrq(&packet) else {
|
let Some(req) = parse_rrq(&packet) else {
|
||||||
// Not a well-formed RRQ. A WRQ deserves an explicit refusal —
|
// Not a well-formed RRQ. A WRQ deserves an explicit refusal —
|
||||||
@@ -117,7 +131,15 @@ async fn handle_rrq(
|
|||||||
return Ok(());
|
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;
|
let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await;
|
||||||
tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404");
|
tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404");
|
||||||
clients.record(
|
clients.record(
|
||||||
|
|||||||
@@ -912,3 +912,14 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
|||||||
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
|
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
|
||||||
}
|
}
|
||||||
.modal-actions .submit { width: auto; padding: 8px 18px; }
|
.modal-actions .submit { width: auto; padding: 8px 18px; }
|
||||||
|
|
||||||
|
/* v0.7.2: a label.field directly followed by the card's action button
|
||||||
|
stacked its own 14px bottom margin onto the button's 16px top margin
|
||||||
|
(30px total) — visible on Queue "Launch for all waiting" and the
|
||||||
|
Network "Save". Collapse the doubled gap so every primary action sits
|
||||||
|
the same 16px below its form. */
|
||||||
|
.card .body > label.field:has(+ button) { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* v0.7.2: inline list filter (Available images / Unattended files). */
|
||||||
|
.list-search { padding: 14px 16px 0; }
|
||||||
|
.list-search input { width: 100%; }
|
||||||
|
|||||||
+185
-32
@@ -199,6 +199,51 @@
|
|||||||
})[k] || (k || 'Unknown');
|
})[k] || (k || 'Unknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.7.2: compact read-out of saved group rules — created from the
|
||||||
|
// unified "Pin MAC" form on the Hosts tab (a prefix or an architecture
|
||||||
|
// there saves a rule instead of a pin). First match wins, top to
|
||||||
|
// bottom. The boot-decision webhook remains available via the API
|
||||||
|
// (/api/boot-rules `webhook_url`) but no longer has a UI knob.
|
||||||
|
function groupRulesCard(cfg, targetOptions) {
|
||||||
|
const rules = (cfg && cfg.rules) || [];
|
||||||
|
if (!rules.length) return null;
|
||||||
|
const titleFor = id => {
|
||||||
|
const t = targetOptions.find(x => x.id === id);
|
||||||
|
return t ? t.title : id;
|
||||||
|
};
|
||||||
|
const modeLabel = {firmware:'Firmware NIC', builtin:'iPXE drivers', shim:'Secure Boot (shim)'};
|
||||||
|
const rows = rules.map((r, i) => el('tr', r.enabled === false ? {style:'opacity:.5'} : {}, [
|
||||||
|
el('td', {class:'mono'}, r.mac_prefix || el('span', {class:'tag'}, 'any MAC')),
|
||||||
|
el('td', {}, r.arch || el('span', {class:'tag'}, 'any arch')),
|
||||||
|
el('td', {}, r.target ? titleFor(r.target) : el('span', {class:'tag'}, '—')),
|
||||||
|
el('td', {}, r.driver_mode
|
||||||
|
? el('span', {class:'tag accent'}, modeLabel[r.driver_mode] || r.driver_mode)
|
||||||
|
: el('span', {class:'tag'}, 'auto')),
|
||||||
|
el('td', {}, r.note || ''),
|
||||||
|
el('td', {style:'text-align:right'},
|
||||||
|
el('button', {class:'danger', onclick: async () => {
|
||||||
|
if (!confirm('Remove this group rule?')) return;
|
||||||
|
const fresh = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
|
||||||
|
(fresh.rules = fresh.rules || []).splice(i, 1);
|
||||||
|
await putJSON('/api/boot-rules', fresh);
|
||||||
|
render('hosts');
|
||||||
|
}}, 'Remove')),
|
||||||
|
]));
|
||||||
|
return el('div', {class:'card'}, [
|
||||||
|
el('header', {}, [
|
||||||
|
el('h2', {}, 'Group rules'),
|
||||||
|
el('span', {class:'sub'}, 'first match wins · checked top to bottom'),
|
||||||
|
]),
|
||||||
|
el('table', {}, [
|
||||||
|
el('thead', {}, el('tr', {}, [
|
||||||
|
el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'),
|
||||||
|
el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},''),
|
||||||
|
])),
|
||||||
|
el('tbody', {}, rows),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// v0.5.2: build the shared "deployment profile" field group — auto
|
// v0.5.2: build the shared "deployment profile" field group — auto
|
||||||
// hostname, auto IP, and an unattended-file picker — reused by the
|
// hostname, auto IP, and an unattended-file picker — reused by the
|
||||||
// Hosts pin form and the Queue "Profile" modal. `files` is the
|
// Hosts pin form and the Queue "Profile" modal. `files` is the
|
||||||
@@ -394,6 +439,11 @@
|
|||||||
el('div', {class:'v'}, net.server_ip || '?'),
|
el('div', {class:'v'}, net.server_ip || '?'),
|
||||||
el('div', {class:'k'}, 'NIC name'),
|
el('div', {class:'k'}, 'NIC name'),
|
||||||
el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
|
el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
|
||||||
|
// v0.7.2: physical link details (operstate · speed · duplex ·
|
||||||
|
// port MAC) so the operator can confirm WHICH port answers
|
||||||
|
// PXE in multi-NIC / trunked environments.
|
||||||
|
el('div', {class:'k'}, 'Link'),
|
||||||
|
el('div', {class:'v'}, net.nic_link || '—'),
|
||||||
el('div', {class:'k'}, 'Subnet mask'),
|
el('div', {class:'k'}, 'Subnet mask'),
|
||||||
el('div', {class:'v'}, net.subnet_mask || '?'),
|
el('div', {class:'v'}, net.subnet_mask || '?'),
|
||||||
el('div', {class:'k'}, 'Gateway'),
|
el('div', {class:'k'}, 'Gateway'),
|
||||||
@@ -789,6 +839,12 @@
|
|||||||
render('storage');
|
render('storage');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v0.7.2: searchable haystack for the list filter — filename,
|
||||||
|
// detected family, category, and source all match.
|
||||||
|
const searchText = [
|
||||||
|
i.filename, familyLabel(i.introspection.family), i.category || '',
|
||||||
|
isSmb ? 'smb' : isNfs ? 'nfs' : 'local', i.id,
|
||||||
|
].join(' ').toLowerCase();
|
||||||
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
|
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
|
||||||
el('td', {}, [
|
el('td', {}, [
|
||||||
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
|
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
|
||||||
@@ -833,8 +889,24 @@
|
|||||||
}}, 'Remove'),
|
}}, 'Remove'),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
tr.dataset.search = searchText;
|
||||||
rowsAndEditors.push(tr, editorRow);
|
rowsAndEditors.push(tr, editorRow);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v0.7.2: client-side filter over the image table. Rows travel in
|
||||||
|
// (row, password-editor) pairs; filtering hides both, and an open
|
||||||
|
// editor stays closed for filtered-out rows.
|
||||||
|
const isoSearch = el('input', {type:'search', placeholder:'Filter images… (name, family, category, source)',
|
||||||
|
spellcheck:'false', oninput: () => {
|
||||||
|
const q = isoSearch.value.trim().toLowerCase();
|
||||||
|
for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) {
|
||||||
|
const row = rowsAndEditors[k];
|
||||||
|
const editor = rowsAndEditors[k + 1];
|
||||||
|
const show = !q || (row.dataset.search || '').includes(q);
|
||||||
|
row.style.display = show ? '' : 'none';
|
||||||
|
if (!show) editor.style.display = 'none';
|
||||||
|
}
|
||||||
|
}});
|
||||||
const isoTable = isos.length
|
const isoTable = isos.length
|
||||||
? el('table', {}, [
|
? el('table', {}, [
|
||||||
el('thead', {}, el('tr', {}, [
|
el('thead', {}, el('tr', {}, [
|
||||||
@@ -1172,7 +1244,10 @@
|
|||||||
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
|
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
|
||||||
|
|
||||||
const unattRows = unattendedFiles.length
|
const unattRows = unattendedFiles.length
|
||||||
? unattendedFiles.map(f => el('div', {class:'nfs-row'}, [
|
? unattendedFiles.map(f => el('div', {
|
||||||
|
class:'nfs-row',
|
||||||
|
'data-search': (f.filename + ' ' + unattendedKindLabel(f.kind) + ' ' + f.id).toLowerCase(),
|
||||||
|
}, [
|
||||||
el('span', {class:'dot ok'}),
|
el('span', {class:'dot ok'}),
|
||||||
el('div', {}, [
|
el('div', {}, [
|
||||||
el('div', {class:'id'}, [
|
el('div', {class:'id'}, [
|
||||||
@@ -1200,6 +1275,17 @@
|
|||||||
]),
|
]),
|
||||||
el('div', {class:'body'}, [
|
el('div', {class:'body'}, [
|
||||||
unattDrop, unattFile, unattMsg,
|
unattDrop, unattFile, unattMsg,
|
||||||
|
// v0.7.2: filter for big answer-file libraries.
|
||||||
|
unattendedFiles.length > 1 ? (() => {
|
||||||
|
const search = el('input', {type:'search', placeholder:'Filter files… (name, kind)',
|
||||||
|
spellcheck:'false', style:'margin-top:14px', oninput: () => {
|
||||||
|
const q = search.value.trim().toLowerCase();
|
||||||
|
unattRows.forEach(r => {
|
||||||
|
r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}});
|
||||||
|
return search;
|
||||||
|
})() : null,
|
||||||
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
|
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
|
||||||
el('p', {class:'msg', style:'margin-top:14px'},
|
el('p', {class:'msg', style:'margin-top:14px'},
|
||||||
'These answer files drive unattended installs. Attach one to a ' +
|
'These answer files drive unattended installs. Attach one to a ' +
|
||||||
@@ -1246,16 +1332,18 @@
|
|||||||
el('h2', {}, 'Available images'),
|
el('h2', {}, 'Available images'),
|
||||||
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
|
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
|
||||||
]),
|
]),
|
||||||
|
isos.length > 1 ? el('div', {class:'list-search'}, isoSearch) : null,
|
||||||
isoTable,
|
isoTable,
|
||||||
]),
|
]),
|
||||||
]), unattendedAdvanced]);
|
]), unattendedAdvanced]);
|
||||||
},
|
},
|
||||||
|
|
||||||
hosts: async () => {
|
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/hosts'), getJSON('/api/isos'),
|
||||||
getJSON('/api/boot-log').catch(() => ({ events: [] })),
|
getJSON('/api/boot-log').catch(() => ({ events: [] })),
|
||||||
getJSON('/api/unattended').catch(() => ({ files: [] })),
|
getJSON('/api/unattended').catch(() => ({ files: [] })),
|
||||||
|
getJSON('/api/boot-rules').catch(() => ({ rules: [], webhook_url: '' })),
|
||||||
]);
|
]);
|
||||||
const bootEvents = bootLogRes.events || [];
|
const bootEvents = bootLogRes.events || [];
|
||||||
const unattendedFiles = unattRes.files || [];
|
const unattendedFiles = unattRes.files || [];
|
||||||
@@ -1270,8 +1358,22 @@
|
|||||||
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
|
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
|
||||||
];
|
];
|
||||||
|
|
||||||
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff', spellcheck:'false'});
|
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff or prefix aa:bb:cc', spellcheck:'false'});
|
||||||
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
|
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
|
||||||
|
// v0.7.2: the former separate "Boot rules" card folded into this
|
||||||
|
// form. A full MAC with no architecture saves a per-host pin
|
||||||
|
// exactly as before; a MAC *prefix* and/or an architecture saves a
|
||||||
|
// first-match-wins group rule instead. Same form, one mental model.
|
||||||
|
const archSel = el('select', {}, [
|
||||||
|
['', 'any (this exact MAC)'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'],
|
||||||
|
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'],
|
||||||
|
].map(([v, t]) => el('option', {value: v}, t)));
|
||||||
|
// v0.7.1's boot-binary pin keeps its home here too (auto = let the
|
||||||
|
// escalation ladder learn; shim = known Secure Boot fleet).
|
||||||
|
const binSel = el('select', {}, [
|
||||||
|
['', 'auto (learn per machine)'], ['firmware', 'Firmware NIC'],
|
||||||
|
['builtin', 'iPXE drivers'], ['shim', 'Secure Boot (shim)'],
|
||||||
|
].map(([v, t]) => el('option', {value: v}, t)));
|
||||||
const targetSel = el('select', {},
|
const targetSel = el('select', {},
|
||||||
[el('option', {value:''}, '— choose a target —')]
|
[el('option', {value:''}, '— choose a target —')]
|
||||||
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
|
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
|
||||||
@@ -1284,21 +1386,40 @@
|
|||||||
// hostname/IP templated into the served answer file.
|
// hostname/IP templated into the served answer file.
|
||||||
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
|
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
|
||||||
|
|
||||||
|
const FULL_MAC = /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i;
|
||||||
const upsertBtn = el('button', {onclick: async () => {
|
const upsertBtn = el('button', {onclick: async () => {
|
||||||
if (!macInput.value || !targetSel.value) {
|
const mac = macInput.value.trim();
|
||||||
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
|
const isGroup = !!archSel.value || !!binSel.value || (mac !== '' && !FULL_MAC.test(mac));
|
||||||
|
if (!isGroup) {
|
||||||
|
// Exact-MAC pin — unchanged behavior.
|
||||||
|
if (!mac || !targetSel.value) {
|
||||||
|
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
|
||||||
|
}
|
||||||
|
const r = await postJSON('/api/hosts', Object.assign({
|
||||||
|
mac, target: targetSel.value, label: labelInput.value,
|
||||||
|
}, profileFields.read()));
|
||||||
|
if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; render('hosts'); }
|
||||||
|
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const r = await postJSON('/api/hosts', Object.assign({
|
// Group rule (prefix and/or architecture). Per-host profile
|
||||||
mac: macInput.value, target: targetSel.value, label: labelInput.value,
|
// fields don't apply to a group — they're per-machine values.
|
||||||
}, profileFields.read()));
|
if (!targetSel.value && !binSel.value) {
|
||||||
if (r.ok) {
|
msg.textContent = 'A group rule needs a target or a boot binary.'; msg.className = 'msg err'; return;
|
||||||
msg.textContent = 'Saved.'; msg.className = 'msg ok';
|
|
||||||
render('hosts');
|
|
||||||
} else {
|
|
||||||
const t = await r.text();
|
|
||||||
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
|
|
||||||
}
|
}
|
||||||
}}, 'Bind MAC to target');
|
const p = profileFields.read();
|
||||||
|
if (p.auto_hostname || p.auto_ip || p.unattended_file) {
|
||||||
|
msg.textContent = 'Auto-deploy fields are per-machine — clear them, or use a full MAC.'; msg.className = 'msg err'; return;
|
||||||
|
}
|
||||||
|
const cfg = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
|
||||||
|
(cfg.rules = cfg.rules || []).push({
|
||||||
|
mac_prefix: mac, arch: archSel.value, target: targetSel.value,
|
||||||
|
driver_mode: binSel.value, enabled: true, note: labelInput.value,
|
||||||
|
});
|
||||||
|
const r = await putJSON('/api/boot-rules', cfg);
|
||||||
|
if (r.ok) { msg.textContent = 'Group rule saved.'; msg.className = 'msg ok'; render('hosts'); }
|
||||||
|
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
||||||
|
}}, 'Bind to target');
|
||||||
|
|
||||||
const rows = hosts.map(h => {
|
const rows = hosts.map(h => {
|
||||||
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
|
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
|
||||||
@@ -1355,23 +1476,38 @@
|
|||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
|
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
|
||||||
el('div', {class:'body'}, [
|
el('div', {class:'body'}, [
|
||||||
el('div', {class:'form-row'}, [
|
el('div', {class:'form-row cols-3'}, [
|
||||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]),
|
el('label', {class:'field'}, [
|
||||||
|
el('span', {class:'name'}, 'MAC address or prefix'),
|
||||||
|
macInput,
|
||||||
|
el('span', {class:'hint'}, 'Full MAC pins one machine; a prefix (OUI) makes a group rule.'),
|
||||||
|
]),
|
||||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
|
||||||
|
el('label', {class:'field'}, [
|
||||||
|
el('span', {class:'name'}, 'Architecture (optional)'),
|
||||||
|
archSel,
|
||||||
|
el('span', {class:'hint'}, 'Selecting one makes a group rule for that firmware.'),
|
||||||
|
]),
|
||||||
el('label', {class:'field', style:'grid-column:1 / -1'}, [
|
el('label', {class:'field', style:'grid-column:1 / -1'}, [
|
||||||
el('span', {class:'name'}, 'Target'),
|
el('span', {class:'name'}, 'Target'),
|
||||||
targetSel,
|
targetSel,
|
||||||
el('span', {class:'hint'},
|
el('span', {class:'hint'},
|
||||||
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
|
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
|
||||||
]),
|
]),
|
||||||
|
el('label', {class:'field'}, [
|
||||||
|
el('span', {class:'name'}, 'Boot binary (optional)'),
|
||||||
|
binSel,
|
||||||
|
el('span', {class:'hint'}, 'Pin Secure Boot racks to "shim" — zero failed boot cycles.'),
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
el('div', {style:'margin-top:16px'}, profileFields.wrap),
|
el('div', {style:'margin-top:16px'}, profileFields.wrap),
|
||||||
upsertBtn, msg,
|
upsertBtn, msg,
|
||||||
el('p', {class:'msg', style:'margin-top:14px'},
|
el('p', {class:'msg', style:'margin-top:14px'},
|
||||||
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
|
'When a matching client requests boot.ipxe, OpenPXE short-circuits ' +
|
||||||
'short-circuits past the interactive menu and chains directly. ' +
|
'past the interactive menu and chains directly. Decision order: ' +
|
||||||
'If an unattended file is selected, the matching kernel argument ' +
|
'exact MAC pin → first matching group rule → menu. ' +
|
||||||
'is injected and the hostname/IP are templated into the answer file.'),
|
'If an unattended file is selected on a pin, the matching kernel ' +
|
||||||
|
'argument is injected and the hostname/IP are templated into the answer file.'),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
@@ -1381,6 +1517,7 @@
|
|||||||
]),
|
]),
|
||||||
table,
|
table,
|
||||||
]),
|
]),
|
||||||
|
groupRulesCard(rulesCfg, reserved.concat(targets)),
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
el('header', {}, [
|
el('header', {}, [
|
||||||
el('h2', {}, 'Host log'),
|
el('h2', {}, 'Host log'),
|
||||||
@@ -2057,9 +2194,24 @@
|
|||||||
el('div', {class:'about-hero'}, [
|
el('div', {class:'about-hero'}, [
|
||||||
el('h2', {}, 'OpenPXE'),
|
el('h2', {}, 'OpenPXE'),
|
||||||
el('p', {class:'lead'},
|
el('p', {class:'lead'},
|
||||||
'Air-gapped network PXE boot, container-native, that anyone can run. ' +
|
'The network-boot platform for modern infrastructure. Drop in an ISO ' +
|
||||||
'No CDN calls, no telemetry, no surprise external dependencies — ship ' +
|
'and every machine on your network — BIOS, UEFI, Secure Boot — can ' +
|
||||||
'the image once, run it forever.'),
|
'boot it, image from it, and install unattended. One container, one ' +
|
||||||
|
'static binary, nothing installed on clients, nothing leaving your network.'),
|
||||||
|
el('div', {style:'display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:18px 0'}, [
|
||||||
|
['Boot anything', 'Linux, Windows, hypervisors, rescue tools — uploaded ' +
|
||||||
|
'ISOs become menu entries automatically, served on demand from local ' +
|
||||||
|
'disk or your existing NFS, SMB, or SFTP libraries.'],
|
||||||
|
['Adapt to every machine', 'Per-machine boot intelligence: firmware quirks, ' +
|
||||||
|
'NIC driver fallback, and a Microsoft-signed Secure Boot chain are ' +
|
||||||
|
'negotiated automatically and remembered — no toggles, no client prep.'],
|
||||||
|
['Run it in production', 'SAML single sign-on, token-scoped answer files, ' +
|
||||||
|
'fleet routing rules, Wake-on-LAN, queued mass deployment, Prometheus ' +
|
||||||
|
'metrics. Built in Rust for boot infrastructure that cannot flinch.'],
|
||||||
|
].map(([h, body]) => el('div', {}, [
|
||||||
|
el('h3', {style:'margin:0 0 6px;font-size:13.5px'}, h),
|
||||||
|
el('p', {class:'msg', style:'font-size:12px;margin:0'}, body),
|
||||||
|
]))),
|
||||||
el('div', {class:'who'}, [
|
el('div', {class:'who'}, [
|
||||||
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
|
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
|
||||||
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
|
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
|
||||||
@@ -2070,15 +2222,16 @@
|
|||||||
]),
|
]),
|
||||||
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
|
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
|
||||||
el('p', {class:'msg', style:'margin-top:18px'},
|
el('p', {class:'msg', style:'margin-top:18px'},
|
||||||
'iPXE is an internal implementation detail. Everything the firmware ' +
|
'Private by design: no telemetry, no CDN calls, no runtime ' +
|
||||||
'executes is generated from the settings on these tabs — there is no ' +
|
'dependencies on the outside world. Air-gapped labs, customer sites ' +
|
||||||
'hand-written .ipxe path anywhere in this product.'),
|
'without internet, and locked-down OpenShift clusters run the same ' +
|
||||||
|
'image, the same way, indefinitely.'),
|
||||||
el('p', {class:'msg'},
|
el('p', {class:'msg'},
|
||||||
'Vision: a deployment-grade tool that works on first try in the most ' +
|
'Principled by default: OpenPXE never asks an operator to install ' +
|
||||||
'awkward environments — air-gapped labs, customer sites without ' +
|
'test-signed drivers, modify a client’s trust store, or weaken ' +
|
||||||
'internet, OpenShift clusters with strict SCCs — without ever asking ' +
|
'Secure Boot. Everything the firmware executes is generated from the ' +
|
||||||
'an operator to install drivers signed with test certificates or to ' +
|
'settings on these tabs — there are no hand-written boot scripts to ' +
|
||||||
'flip "testsigning" on a target machine.'),
|
'maintain and no internals to learn.'),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,19 @@ ARG RUST_VERSION=1.95
|
|||||||
# and serve as the baseline that the PNG-enabled x86_64/arm64 UEFI
|
# and serve as the baseline that the PNG-enabled x86_64/arm64 UEFI
|
||||||
# binaries from the `ipxe-build` stage overlay on top of.
|
# binaries from the `ipxe-build` stage overlay on top of.
|
||||||
FROM debian:12-slim AS fetch
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh
|
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
|
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 ##########
|
########## build PNG-enabled iPXE from source ##########
|
||||||
# v0.4.69: THE graphical-boot-menu unlock. iVentoy paints a PNG
|
# v0.4.69: THE graphical-boot-menu unlock. iVentoy paints a PNG
|
||||||
|
|||||||
Executable
+96
@@ -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 <repo-arch> <shim-pkg> <grub-pkg> <shim-out> <grub-out> <hard|soft>
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user