Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a71057fce6 | ||
|
|
29040e8a5a |
Generated
+10
-8
@@ -2836,7 +2836,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openpxe"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2858,7 +2858,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-core"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -2885,14 +2885,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-dhcp-proxy"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"dhcproto",
|
||||
"openpxe-core",
|
||||
"parking_lot",
|
||||
"serde_json",
|
||||
"socket2",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -2900,7 +2902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-http-api"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2936,7 +2938,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-ipxe-assets"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"openpxe-core",
|
||||
"rust-embed",
|
||||
@@ -2946,7 +2948,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-iso-store"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
@@ -2975,7 +2977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-tftp"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -2989,7 +2991,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-webui"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -41,7 +41,16 @@ pub struct BootRule {
|
||||
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,
|
||||
@@ -111,6 +120,7 @@ impl BootRulesStore {
|
||||
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();
|
||||
@@ -134,10 +144,40 @@ impl BootRulesStore {
|
||||
/// 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 || r.target.is_empty() {
|
||||
if !r.enabled {
|
||||
continue;
|
||||
}
|
||||
if !r.mac_prefix.is_empty() && !mac.starts_with(r.mac_prefix.as_str()) {
|
||||
@@ -151,7 +191,9 @@ impl BootRulesStore {
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
return Some(r.target.clone());
|
||||
if let Some(v) = extract(r) {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -189,11 +231,54 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -19,3 +19,8 @@ thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
bytes.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
|
||||
//! default — it's the most reliable choice for chainloading because the
|
||||
//! firmware just proved its network works by downloading the NBP. A minority
|
||||
//! of NICs have a missing or buggy firmware UNDI/SNP stack; those clients
|
||||
//! TFTP the binary fine, but then iPXE can't bring the link up, so the
|
||||
//! tell-tale second DHCP DISCOVER carrying the `iPXE` user-class never arrives
|
||||
//! and the machine eventually re-PXE-boots.
|
||||
//! firmware just proved its network works by downloading the NBP. Two
|
||||
//! classes of machine can't run it:
|
||||
//!
|
||||
//! We detect exactly that: a *fresh* firmware DISCOVER from a MAC whose
|
||||
//! previous firmware attempt was never confirmed by an iPXE handoff means the
|
||||
//! firmware-net build failed → escalate that MAC to [`DriverMode::Builtin`]
|
||||
//! (iPXE's own NIC drivers). The decision is sticky — once a MAC settles on a
|
||||
//! mode that completes the handoff, later boots go straight to it. There is no
|
||||
//! operator toggle; it just works, and the default (firmware) path is
|
||||
//! unchanged so hardware that already boots never regresses.
|
||||
//! * a minority of NICs have a missing or buggy firmware UNDI/SNP stack —
|
||||
//! they TFTP the binary fine but iPXE can't bring the link up;
|
||||
//! * Secure-Boot firmware downloads it fine but refuses to *execute* an
|
||||
//! unsigned image.
|
||||
//!
|
||||
//! Both look identical from here: the tell-tale second DHCP DISCOVER
|
||||
//! 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 parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 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.
|
||||
const SAME_BOOT_DEBOUNCE: Duration = Duration::from_secs(8);
|
||||
|
||||
/// Forget a MAC's state after this long with no activity, so a transient
|
||||
/// escalation doesn't pin a client to Builtin forever and the map stays
|
||||
/// bounded over a long-running deployment.
|
||||
/// Forget an *unpinned* MAC's state after this long with no activity, so
|
||||
/// a transient mid-walk state doesn't linger and the map stays bounded.
|
||||
/// Pinned (learned) entries are exempt — that's their whole point.
|
||||
const ENTRY_TTL: Duration = Duration::from_mins(30);
|
||||
|
||||
/// Hard cap on tracked MACs. Past this we evict the least-recently-seen
|
||||
/// entry — escalation is best-effort, never a memory-growth vector.
|
||||
/// entry (unpinned first) — escalation is best-effort, never a
|
||||
/// memory-growth vector.
|
||||
const MAX_ENTRIES: usize = 4096;
|
||||
|
||||
/// 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
|
||||
/// the previous attempt failed and we should escalate.
|
||||
awaiting_confirm: bool,
|
||||
/// Learned mode (v0.7.1): persisted to disk, exempt from the TTL.
|
||||
pinned: bool,
|
||||
last_seen: Instant,
|
||||
}
|
||||
|
||||
@@ -72,14 +94,68 @@ impl Default for Inner {
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DriverEscalation {
|
||||
inner: Mutex<Inner>,
|
||||
/// Persistence target for learned modes; `None` = ephemeral (tests).
|
||||
path: Option<Arc<PathBuf>>,
|
||||
}
|
||||
|
||||
impl DriverEscalation {
|
||||
/// Ephemeral instance (no persistence) — used by tests.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
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
|
||||
/// `mac`. `primary` is true for the main DHCP DISCOVER (:67) and false for
|
||||
/// the PXE Boot Server query (:4011); only the primary path drives
|
||||
@@ -91,82 +167,150 @@ impl DriverEscalation {
|
||||
|
||||
/// Record that `mac` completed the iPXE handoff (a DISCOVER carrying the
|
||||
/// `iPXE` user-class). The mode we last served worked, so stop awaiting
|
||||
/// confirmation and keep it sticky for next time.
|
||||
/// 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) {
|
||||
self.confirm_at(mac, Instant::now());
|
||||
}
|
||||
|
||||
fn decide_at(&self, mac: &str, primary: bool, now: Instant) -> DriverMode {
|
||||
let mut g = self.inner.lock();
|
||||
if now.duration_since(g.last_prune) >= PRUNE_INTERVAL {
|
||||
g.map
|
||||
.retain(|_, e| 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.
|
||||
if g.map
|
||||
.get(mac)
|
||||
.is_some_and(|e| now.duration_since(e.last_seen) >= ENTRY_TTL)
|
||||
{
|
||||
g.map.remove(mac);
|
||||
}
|
||||
let (mode, snapshot) = {
|
||||
let mut g = self.inner.lock();
|
||||
if now.duration_since(g.last_prune) >= PRUNE_INTERVAL {
|
||||
g.map
|
||||
.retain(|_, e| e.pinned || now.duration_since(e.last_seen) < ENTRY_TTL);
|
||||
g.last_prune = now;
|
||||
}
|
||||
// Inline staleness check: an unpinned MAC whose entry outlived
|
||||
// the TTL starts fresh even when the amortized sweep above
|
||||
// hasn't caught it yet. Pinned entries never go stale.
|
||||
if g.map
|
||||
.get(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) {
|
||||
None => {
|
||||
g.map.insert(
|
||||
mac.to_owned(),
|
||||
Entry {
|
||||
mode: DriverMode::Firmware,
|
||||
// Only the primary DISCOVER opens a confirmation window.
|
||||
awaiting_confirm: primary,
|
||||
last_seen: now,
|
||||
},
|
||||
);
|
||||
if g.map.len() > MAX_ENTRIES {
|
||||
evict_oldest(&mut g.map);
|
||||
}
|
||||
DriverMode::Firmware
|
||||
}
|
||||
Some(entry) => {
|
||||
let recent = now.duration_since(entry.last_seen) < SAME_BOOT_DEBOUNCE;
|
||||
if primary && !recent {
|
||||
// A genuinely new boot. If the previous attempt was never
|
||||
// confirmed, the build we served failed → climb one rung:
|
||||
// Firmware (firmware NIC stack) → Builtin (iPXE's own
|
||||
// drivers) → Shim (signed shim+GRUB, v0.7.0 — covers
|
||||
// Secure Boot firmware that downloads our unsigned iPXE
|
||||
// but refuses to execute it). Shim is terminal: a MAC
|
||||
// there stays until its entry TTLs out and resets.
|
||||
if entry.awaiting_confirm {
|
||||
entry.mode = match entry.mode {
|
||||
DriverMode::Firmware => DriverMode::Builtin,
|
||||
DriverMode::Builtin | DriverMode::Shim => DriverMode::Shim,
|
||||
};
|
||||
let mut newly_pinned = false;
|
||||
let mode = match g.map.get_mut(mac) {
|
||||
None => {
|
||||
g.map.insert(
|
||||
mac.to_owned(),
|
||||
Entry {
|
||||
mode: DriverMode::Firmware,
|
||||
// Only the primary DISCOVER opens a confirmation window.
|
||||
awaiting_confirm: primary,
|
||||
pinned: false,
|
||||
last_seen: now,
|
||||
},
|
||||
);
|
||||
if g.map.len() > MAX_ENTRIES {
|
||||
evict_oldest(&mut g.map);
|
||||
}
|
||||
entry.awaiting_confirm = true;
|
||||
DriverMode::Firmware
|
||||
}
|
||||
entry.last_seen = now;
|
||||
entry.mode
|
||||
}
|
||||
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 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) {
|
||||
let mut g = self.inner.lock();
|
||||
if let Some(e) = g.map.get_mut(mac) {
|
||||
let snapshot = {
|
||||
let mut g = self.inner.lock();
|
||||
let Some(e) = g.map.get_mut(mac) else {
|
||||
return;
|
||||
};
|
||||
e.awaiting_confirm = false;
|
||||
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>) {
|
||||
if let Some(oldest) = map
|
||||
.iter()
|
||||
.min_by_key(|(_, e)| e.last_seen)
|
||||
.map(|(k, _)| k.clone())
|
||||
{
|
||||
// Prefer evicting an unpinned entry; only touch learned modes when
|
||||
// the whole table is pinned (4096 learned machines — at that point
|
||||
// the operator has bigger questions than our memory bound).
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -174,6 +318,7 @@ fn evict_oldest(map: &mut HashMap<String, Entry>) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn firmware_first_then_escalates_on_unconfirmed_retry() {
|
||||
@@ -250,7 +395,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_entry_is_forgotten_and_resets_to_firmware() {
|
||||
fn stale_unpinned_entry_is_forgotten_and_resets_to_firmware() {
|
||||
let e = DriverEscalation::new();
|
||||
let t0 = Instant::now();
|
||||
assert_eq!(e.decide_at("dd", true, t0), DriverMode::Firmware);
|
||||
@@ -258,8 +403,92 @@ mod tests {
|
||||
e.decide_at("dd", true, t0 + Duration::from_mins(1)),
|
||||
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);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ use crate::escalation::DriverEscalation;
|
||||
use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
|
||||
use dhcproto::v4::{DhcpOption, Message, OptionCode};
|
||||
use dhcproto::{Decodable, Decoder, Encodable, Encoder};
|
||||
use openpxe_core::{ClientArch, ClientEvent, ClientRegistry, DriverMode, FirmwareClass};
|
||||
use openpxe_core::{
|
||||
BootRulesStore, ClientArch, ClientEvent, ClientRegistry, DriverMode, FirmwareClass,
|
||||
};
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::sync::Arc;
|
||||
@@ -19,12 +21,19 @@ pub struct DhcpProxyServer {
|
||||
public_base_url: String,
|
||||
clients: Arc<ClientRegistry>,
|
||||
metrics: openpxe_core::Metrics,
|
||||
/// Automatic per-MAC NIC driver-mode escalation (v0.6.1). Shared across
|
||||
/// the :67 and :4011 listener tasks via the server `Arc`.
|
||||
/// Automatic per-MAC NIC driver-mode escalation (v0.6.1; persistent
|
||||
/// 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,
|
||||
/// 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 {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
bind: IpAddr,
|
||||
dhcp_port: u16,
|
||||
@@ -33,6 +42,8 @@ impl DhcpProxyServer {
|
||||
public_base_url: String,
|
||||
clients: Arc<ClientRegistry>,
|
||||
metrics: openpxe_core::Metrics,
|
||||
escalation: DriverEscalation,
|
||||
rules: BootRulesStore,
|
||||
) -> Self {
|
||||
Self {
|
||||
bind,
|
||||
@@ -42,7 +53,8 @@ impl DhcpProxyServer {
|
||||
public_base_url,
|
||||
clients,
|
||||
metrics,
|
||||
escalation: DriverEscalation::new(),
|
||||
escalation,
|
||||
rules,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,9 +154,17 @@ impl DhcpProxyServer {
|
||||
self.escalation.mark_ipxe_success(&mac);
|
||||
DriverMode::Firmware // unused: this path serves the HTTP script
|
||||
}
|
||||
FirmwareClass::PxeClient | FirmwareClass::HttpClient => self
|
||||
.escalation
|
||||
.mode_for_firmware_attempt(&mac, label == "67"),
|
||||
FirmwareClass::PxeClient | FirmwareClass::HttpClient => {
|
||||
// v0.7.1: an operator rule pin wins over (and bypasses)
|
||||
// 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.
|
||||
FirmwareClass::Other => DriverMode::Firmware,
|
||||
};
|
||||
|
||||
@@ -2744,6 +2744,7 @@ async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
.strip_prefix("http://")
|
||||
.unwrap_or(&state.public_base_url),
|
||||
"nic_name": state.nic_name,
|
||||
"nic_link": state.nic_link,
|
||||
"subnet_mask": state.subnet_mask,
|
||||
"gateway": state.gateway,
|
||||
"dns_server": state.settings.snapshot().dns_server,
|
||||
|
||||
@@ -35,6 +35,24 @@ pub fn render_grub_menu(isos: &[IsoMeta], base_url: &str) -> String {
|
||||
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);
|
||||
@@ -139,6 +157,24 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -116,6 +116,10 @@ pub struct AppState {
|
||||
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
|
||||
/// interface couldn't be identified.
|
||||
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.
|
||||
pub subnet_mask: String,
|
||||
/// Default gateway IPv4 address.
|
||||
|
||||
@@ -135,6 +135,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
public_base_url: "http://127.0.0.1".into(),
|
||||
nic_name: "lo".into(),
|
||||
nic_link: String::new(),
|
||||
subnet_mask: "255.0.0.0".into(),
|
||||
gateway: "127.0.0.1".into(),
|
||||
};
|
||||
@@ -2751,3 +2752,23 @@ async fn arch_selective_rule_ignores_other_arches() {
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,6 +184,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
"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 {
|
||||
iso_store: iso_store.clone(),
|
||||
clients: clients.clone(),
|
||||
@@ -191,7 +195,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
queue: queue.clone(),
|
||||
hosts: hosts.clone(),
|
||||
boot_log: boot_log.clone(),
|
||||
boot_rules: openpxe_core::BootRulesStore::load_or_default(&config.paths.work_dir),
|
||||
boot_rules: boot_rules.clone(),
|
||||
boot_tokens: openpxe_core::BootTokens::new(),
|
||||
branding: branding.clone(),
|
||||
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
||||
@@ -211,6 +215,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
public_base_url: public_base_url.clone(),
|
||||
nic_name: net.nic_name,
|
||||
nic_link: net.nic_link,
|
||||
subnet_mask: net.subnet_mask,
|
||||
gateway: net.gateway,
|
||||
};
|
||||
@@ -267,6 +272,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
public_base_url.clone(),
|
||||
clients.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())
|
||||
}
|
||||
@@ -440,6 +449,12 @@ struct NetworkInfo {
|
||||
nic_name: String,
|
||||
subnet_mask: 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
|
||||
@@ -500,9 +515,44 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
|
||||
}
|
||||
}
|
||||
|
||||
info.nic_link = detect_link_info(&info.nic_name);
|
||||
|
||||
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 {
|
||||
let prefix = prefix.min(32);
|
||||
let mask: u32 = if prefix == 0 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
.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%; }
|
||||
|
||||
+174
-118
@@ -199,97 +199,47 @@
|
||||
})[k] || (k || 'Unknown');
|
||||
}
|
||||
|
||||
// v0.7.0: the Boot rules card — ordered first-match-wins rules
|
||||
// (MAC prefix / architecture → target) plus the optional
|
||||
// boot-decision webhook. Saved as one config because rule order
|
||||
// matters. With no rules and no webhook, behavior is identical to
|
||||
// before the feature existed.
|
||||
function bootRulesCard(cfg, targetOptions) {
|
||||
const archChoices = [
|
||||
['', 'any arch'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'],
|
||||
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'],
|
||||
];
|
||||
const rules = (cfg.rules || []).map(r => Object.assign({}, r));
|
||||
const tbody = el('tbody', {});
|
||||
const msg = el('div', {class:'msg'});
|
||||
const webhookInput = el('input', {type:'text', spellcheck:'false',
|
||||
placeholder:'http://automation.example/boot-decision (optional)',
|
||||
value: cfg.webhook_url || ''});
|
||||
|
||||
const targetSelect = (val) => el('select', {},
|
||||
[el('option', {value:''}, '— target —')]
|
||||
.concat(targetOptions.map(t =>
|
||||
el('option', Object.assign({value: t.id}, t.id === val ? {selected:''} : {}), t.title))));
|
||||
|
||||
const redraw = () => {
|
||||
tbody.innerHTML = '';
|
||||
if (!rules.length) {
|
||||
tbody.appendChild(el('tr', {}, el('td', {colspan:'6', class:'empty', style:'padding:14px'},
|
||||
'No rules. Add one to route whole groups of machines (an OUI, an architecture) to a target.')));
|
||||
}
|
||||
rules.forEach((r, i) => {
|
||||
const macIn = el('input', {type:'text', spellcheck:'false', placeholder:'aa:bb:cc (prefix)',
|
||||
value: r.mac_prefix || '', oninput: e => { r.mac_prefix = e.target.value; }});
|
||||
const archSel = el('select', {onchange: e => { r.arch = e.target.value; }},
|
||||
archChoices.map(([v, label]) =>
|
||||
el('option', Object.assign({value: v}, v === (r.arch || '') ? {selected:''} : {}), label)));
|
||||
const tgtSel = targetSelect(r.target || '');
|
||||
tgtSel.onchange = e => { r.target = e.target.value; };
|
||||
const noteIn = el('input', {type:'text', placeholder:'note',
|
||||
value: r.note || '', oninput: e => { r.note = e.target.value; }});
|
||||
const enabled = el('input', {type:'checkbox', onchange: e => { r.enabled = e.target.checked; }});
|
||||
enabled.checked = r.enabled !== false;
|
||||
tbody.appendChild(el('tr', {}, [
|
||||
el('td', {}, macIn),
|
||||
el('td', {}, archSel),
|
||||
el('td', {}, tgtSel),
|
||||
el('td', {}, noteIn),
|
||||
el('td', {style:'text-align:center'}, enabled),
|
||||
el('td', {style:'text-align:right'},
|
||||
el('button', {class:'danger', onclick: () => { rules.splice(i, 1); redraw(); }}, '✕')),
|
||||
]));
|
||||
});
|
||||
// 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;
|
||||
};
|
||||
redraw();
|
||||
|
||||
const addBtn = el('button', {class:'ghost', onclick: () => {
|
||||
rules.push({mac_prefix:'', arch:'', target:'', enabled:true, note:''});
|
||||
redraw();
|
||||
}}, '+ Add rule');
|
||||
const saveBtn = el('button', {onclick: async () => {
|
||||
const bad = rules.find(r => r.enabled !== false && !r.target);
|
||||
if (bad) { msg.textContent = 'Every enabled rule needs a target.'; msg.className = 'msg err'; return; }
|
||||
const r = await putJSON('/api/boot-rules', {rules, webhook_url: webhookInput.value.trim()});
|
||||
if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; }
|
||||
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
||||
}}, 'Save rules');
|
||||
|
||||
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', {}, 'Boot rules'),
|
||||
el('h2', {}, 'Group rules'),
|
||||
el('span', {class:'sub'}, 'first match wins · checked top to bottom'),
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('table', {}, [
|
||||
el('thead', {}, el('tr', {}, [
|
||||
el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'),
|
||||
el('th',{},'Note'), el('th',{},'On'), el('th',{},''),
|
||||
])),
|
||||
tbody,
|
||||
]),
|
||||
el('div', {style:'margin-top:12px'}, [addBtn, saveBtn]),
|
||||
el('label', {class:'field', style:'margin-top:16px;display:block'}, [
|
||||
el('span', {class:'name'}, 'Boot-decision webhook (optional)'),
|
||||
webhookInput,
|
||||
el('span', {class:'hint'},
|
||||
'When no pin or rule matches, OpenPXE GETs this URL with ?mac=…&arch=… ' +
|
||||
'A 200 reply of {"target": "<entry-id>"} chains to that target; anything ' +
|
||||
'else (404, timeout, error) falls through to the menu — a dead endpoint ' +
|
||||
'can never block PXE.'),
|
||||
]),
|
||||
msg,
|
||||
el('p', {class:'msg', style:'margin-top:10px'},
|
||||
'Decision order per boot: exact MAC pin → first matching rule → webhook → interactive menu.'),
|
||||
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),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -489,6 +439,11 @@
|
||||
el('div', {class:'v'}, net.server_ip || '?'),
|
||||
el('div', {class:'k'}, 'NIC name'),
|
||||
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:'v'}, net.subnet_mask || '?'),
|
||||
el('div', {class:'k'}, 'Gateway'),
|
||||
@@ -884,6 +839,12 @@
|
||||
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'}, [
|
||||
el('td', {}, [
|
||||
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
|
||||
@@ -928,8 +889,24 @@
|
||||
}}, 'Remove'),
|
||||
]),
|
||||
]);
|
||||
tr.dataset.search = searchText;
|
||||
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
|
||||
? el('table', {}, [
|
||||
el('thead', {}, el('tr', {}, [
|
||||
@@ -1267,7 +1244,10 @@
|
||||
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
|
||||
|
||||
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('div', {}, [
|
||||
el('div', {class:'id'}, [
|
||||
@@ -1295,6 +1275,17 @@
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
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('p', {class:'msg', style:'margin-top:14px'},
|
||||
'These answer files drive unattended installs. Attach one to a ' +
|
||||
@@ -1341,6 +1332,7 @@
|
||||
el('h2', {}, 'Available images'),
|
||||
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
|
||||
]),
|
||||
isos.length > 1 ? el('div', {class:'list-search'}, isoSearch) : null,
|
||||
isoTable,
|
||||
]),
|
||||
]), unattendedAdvanced]);
|
||||
@@ -1366,8 +1358,22 @@
|
||||
{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"'});
|
||||
// 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', {},
|
||||
[el('option', {value:''}, '— choose a target —')]
|
||||
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
|
||||
@@ -1380,21 +1386,40 @@
|
||||
// hostname/IP templated into the served answer file.
|
||||
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 () => {
|
||||
if (!macInput.value || !targetSel.value) {
|
||||
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
|
||||
const mac = macInput.value.trim();
|
||||
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({
|
||||
mac: macInput.value, target: targetSel.value, label: labelInput.value,
|
||||
}, profileFields.read()));
|
||||
if (r.ok) {
|
||||
msg.textContent = 'Saved.'; msg.className = 'msg ok';
|
||||
render('hosts');
|
||||
} else {
|
||||
const t = await r.text();
|
||||
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
|
||||
// Group rule (prefix and/or architecture). Per-host profile
|
||||
// fields don't apply to a group — they're per-machine values.
|
||||
if (!targetSel.value && !binSel.value) {
|
||||
msg.textContent = 'A group rule needs a target or a boot binary.'; msg.className = 'msg err'; return;
|
||||
}
|
||||
}}, '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 => {
|
||||
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
|
||||
@@ -1451,23 +1476,38 @@
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {class:'form-row'}, [
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]),
|
||||
el('div', {class:'form-row cols-3'}, [
|
||||
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'}, '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('span', {class:'name'}, 'Target'),
|
||||
targetSel,
|
||||
el('span', {class:'hint'},
|
||||
'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),
|
||||
upsertBtn, msg,
|
||||
el('p', {class:'msg', style:'margin-top:14px'},
|
||||
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
|
||||
'short-circuits past the interactive menu and chains directly. ' +
|
||||
'If an unattended file is selected, the matching kernel argument ' +
|
||||
'is injected and the hostname/IP are templated into the answer file.'),
|
||||
'When a matching client requests boot.ipxe, OpenPXE short-circuits ' +
|
||||
'past the interactive menu and chains directly. Decision order: ' +
|
||||
'exact MAC pin → first matching group rule → menu. ' +
|
||||
'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'}, [
|
||||
@@ -1477,7 +1517,7 @@
|
||||
]),
|
||||
table,
|
||||
]),
|
||||
bootRulesCard(rulesCfg, reserved.concat(targets)),
|
||||
groupRulesCard(rulesCfg, reserved.concat(targets)),
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Host log'),
|
||||
@@ -2154,9 +2194,24 @@
|
||||
el('div', {class:'about-hero'}, [
|
||||
el('h2', {}, 'OpenPXE'),
|
||||
el('p', {class:'lead'},
|
||||
'Air-gapped network PXE boot, container-native, that anyone can run. ' +
|
||||
'No CDN calls, no telemetry, no surprise external dependencies — ship ' +
|
||||
'the image once, run it forever.'),
|
||||
'The network-boot platform for modern infrastructure. Drop in an ISO ' +
|
||||
'and every machine on your network — BIOS, UEFI, Secure Boot — can ' +
|
||||
'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('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
|
||||
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
|
||||
@@ -2167,15 +2222,16 @@
|
||||
]),
|
||||
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
|
||||
el('p', {class:'msg', style:'margin-top:18px'},
|
||||
'iPXE is an internal implementation detail. Everything the firmware ' +
|
||||
'executes is generated from the settings on these tabs — there is no ' +
|
||||
'hand-written .ipxe path anywhere in this product.'),
|
||||
'Private by design: no telemetry, no CDN calls, no runtime ' +
|
||||
'dependencies on the outside world. Air-gapped labs, customer sites ' +
|
||||
'without internet, and locked-down OpenShift clusters run the same ' +
|
||||
'image, the same way, indefinitely.'),
|
||||
el('p', {class:'msg'},
|
||||
'Vision: a deployment-grade tool that works on first try in the most ' +
|
||||
'awkward environments — air-gapped labs, customer sites without ' +
|
||||
'internet, OpenShift clusters with strict SCCs — without ever asking ' +
|
||||
'an operator to install drivers signed with test certificates or to ' +
|
||||
'flip "testsigning" on a target machine.'),
|
||||
'Principled by default: OpenPXE never asks an operator to install ' +
|
||||
'test-signed drivers, modify a client’s trust store, or weaken ' +
|
||||
'Secure Boot. Everything the firmware executes is generated from the ' +
|
||||
'settings on these tabs — there are no hand-written boot scripts to ' +
|
||||
'maintain and no internals to learn.'),
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user