Compare commits

..
2 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 a71057fce6 v0.7.2: UI polish — list search, unified Hosts form, NIC link details, About refresh, spacing
Storage:
- Filter inputs for the Available images table (matches filename,
  detected family, category, source) and the Unattended files list
  (name, kind). Pure client-side; shown when there's more than one
  entry. Forty-image libraries are now navigable.

Hosts — one form, one mental model:
- The separate Boot rules card is gone. The Pin form gains
  'Architecture (optional)' next to Label (plus the v0.7.1 boot-binary
  pin): a full MAC with no architecture saves a per-host pin exactly as
  before; a MAC prefix and/or architecture saves a first-match-wins
  group rule. Saved rules render as a compact read-only 'Group rules'
  card with remove buttons.
- The boot-decision webhook keeps working via /api/boot-rules but no
  longer has a UI knob (operator feedback: not needed in the UI).
- Per-machine auto-deploy fields are rejected on group rules with a
  clear message (they're per-host values).

Network:
- New 'Link' row under NIC name: operstate · speed · duplex · port MAC,
  read from sysfs at startup (detect_link_info). Empty-degrades on
  non-Linux dev builds and virtual NICs. Confirms WHICH physical port
  answers PXE in multi-NIC/trunked environments.

About:
- Hero copy rewritten: positioning lead, three-pillar feature grid
  (Boot anything / Adapt to every machine / Run it in production), and
  the privacy + no-test-cert principles restated crisply.

Spacing:
- label.field:has(+ button) collapse fixes the doubled 30px gap above
  Queue 'Launch for all waiting' and Network 'Save' (now the same 16px
  as every other card action).

Validation: clippy clean, fmt clean, 299 workspace tests green, webui
syntax-checked. No protocol or boot-path changes in this release.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 21:47:48 -04:00
Miles WardandClaude Opus 4.8 29040e8a5a v0.7.1: walk the ladder once ever — persistent learned modes, rule pins, same-boot iPXE recovery
Answers the operational question 'can a machine try all three boot
binaries in one go?' The protocol can't carry three NBPs in one cycle
(one boot file per DHCP round, the Secure-Boot refusal happens after
handoff with no error report, and the broken-NIC case specifically needs
the firmware itself to load builtin-driver iPXE — GRUB's network rides
the same broken firmware stack). What we CAN do is make the walk a
once-per-machine-ever event and give operators a way to skip it:

- Learned driver modes persist (<work_dir>/driver_modes.json). A MAC
  that reaches the Shim rung, or confirms an iPXE handoff at Builtin,
  is pinned to disk: immune to the 30-min TTL, reloaded at startup.
  The file only carries exceptions — a healthy fleet never writes it.
  Corrupt file starts empty (standard crash-cache policy).
- Boot rules gain an optional driver_mode pin (auto/firmware/builtin/
  shim), consulted by the DHCP proxy BEFORE the escalation ladder:
  'this OUI is a Secure Boot rack -> serve shim immediately' = zero
  failed cycles. Mode-only rules coexist with target rules (a pin
  doesn't shadow a later target match). Editor column on Hosts tab.
- grub.cfg now tries to chainload all-drivers iPXE before showing the
  signed menu: with SB off the chainload succeeds and the client gets
  the full iPXE feature set back in the SAME boot (self-healing for
  mis-escalations, and the handoff then pins the working mode); with
  SB on, shim's verifier refuses it inline — no reboot — and the
  signed menu appears.

DhcpProxyServer now takes the escalation table + rules store from main
(persistence path comes from the configured work dir).

Validation: clippy clean, fmt clean, 299 workspace tests green (+9:
persistence round-trip across restart, Shim pin survives TTL, learned
Builtin survives TTL, corrupt-file recovery, default-mode-never-
persisted, rule-pin matching incl. unknown-mode tolerance and
pin/target coexistence, GRUB chainload-before-menu ordering, API
round-trip of the driver_mode field).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 21:16:22 -04:00
13 changed files with 733 additions and 213 deletions
Generated
+10 -8
View File
@@ -2836,7 +2836,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "openpxe" name = "openpxe"
version = "0.7.0" 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.7.0" 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.7.0" 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.7.0" 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.7.0" 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.7.0" 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.7.0" 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.7.0" version = "0.7.2"
[[package]] [[package]]
name = "p256" name = "p256"
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.7.0" 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"
+87 -2
View File
@@ -41,7 +41,16 @@ pub struct BootRule {
pub arch: String, pub arch: String,
/// Boot entry id (a `BootEntry::id`) or reserved menu name /// Boot entry id (a `BootEntry::id`) or reserved menu name
/// (`_local`, `_queue`, …) to chain to when this rule matches. /// (`_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, 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. /// Rules can be parked without deleting them.
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub enabled: bool, pub enabled: bool,
@@ -111,6 +120,7 @@ impl BootRulesStore {
r.mac_prefix = normalize_mac(&r.mac_prefix); r.mac_prefix = normalize_mac(&r.mac_prefix);
r.arch = r.arch.trim().to_ascii_lowercase(); r.arch = r.arch.trim().to_ascii_lowercase();
r.target = r.target.trim().to_string(); r.target = r.target.trim().to_string();
r.driver_mode = r.driver_mode.trim().to_ascii_lowercase();
r.note = r.note.trim().to_string(); r.note = r.note.trim().to_string();
} }
cfg.webhook_url = cfg.webhook_url.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). /// passed one along, `None` otherwise (older chains).
#[must_use] #[must_use]
pub fn match_target(&self, mac: &str, arch: Option<&str>) -> Option<String> { 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 mac = normalize_mac(mac);
let g = self.inner.read(); let g = self.inner.read();
for r in &g.rules { for r in &g.rules {
if !r.enabled || r.target.is_empty() { if !r.enabled {
continue; continue;
} }
if !r.mac_prefix.is_empty() && !mac.starts_with(r.mac_prefix.as_str()) { if !r.mac_prefix.is_empty() && !mac.starts_with(r.mac_prefix.as_str()) {
@@ -151,7 +191,9 @@ impl BootRulesStore {
_ => continue, _ => continue,
} }
} }
return Some(r.target.clone()); if let Some(v) = extract(r) {
return Some(v);
}
} }
None None
} }
@@ -189,11 +231,54 @@ mod tests {
mac_prefix: mac_prefix.into(), mac_prefix: mac_prefix.into(),
arch: arch.into(), arch: arch.into(),
target: target.into(), target: target.into(),
driver_mode: String::new(),
enabled: true, enabled: true,
note: String::new(), 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] #[test]
fn empty_config_matches_nothing() { fn empty_config_matches_nothing() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
+5
View File
@@ -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"
+265 -36
View File
@@ -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,28 +167,32 @@ 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 (mode, snapshot) = {
let mut g = self.inner.lock(); let mut g = self.inner.lock();
if now.duration_since(g.last_prune) >= PRUNE_INTERVAL { if now.duration_since(g.last_prune) >= PRUNE_INTERVAL {
g.map g.map
.retain(|_, e| now.duration_since(e.last_seen) < ENTRY_TTL); .retain(|_, e| e.pinned || now.duration_since(e.last_seen) < ENTRY_TTL);
g.last_prune = now; g.last_prune = now;
} }
// Inline staleness check: a MAC whose entry outlived the TTL starts // Inline staleness check: an unpinned MAC whose entry outlived
// fresh even when the amortized sweep above hasn't caught it yet. // the TTL starts fresh even when the amortized sweep above
// hasn't caught it yet. Pinned entries never go stale.
if g.map if g.map
.get(mac) .get(mac)
.is_some_and(|e| now.duration_since(e.last_seen) >= ENTRY_TTL) .is_some_and(|e| !e.pinned && now.duration_since(e.last_seen) >= ENTRY_TTL)
{ {
g.map.remove(mac); g.map.remove(mac);
} }
match g.map.get_mut(mac) { let mut newly_pinned = false;
let mode = match g.map.get_mut(mac) {
None => { None => {
g.map.insert( g.map.insert(
mac.to_owned(), mac.to_owned(),
@@ -120,6 +200,7 @@ impl DriverEscalation {
mode: DriverMode::Firmware, mode: DriverMode::Firmware,
// Only the primary DISCOVER opens a confirmation window. // Only the primary DISCOVER opens a confirmation window.
awaiting_confirm: primary, awaiting_confirm: primary,
pinned: false,
last_seen: now, last_seen: now,
}, },
); );
@@ -131,42 +212,105 @@ impl DriverEscalation {
Some(entry) => { Some(entry) => {
let recent = now.duration_since(entry.last_seen) < SAME_BOOT_DEBOUNCE; let recent = now.duration_since(entry.last_seen) < SAME_BOOT_DEBOUNCE;
if primary && !recent { if primary && !recent {
// A genuinely new boot. If the previous attempt was never // A genuinely new boot. If the previous attempt was
// confirmed, the build we served failed → climb one rung: // never confirmed, the build we served failed → climb
// Firmware (firmware NIC stack) → Builtin (iPXE's own // one rung: Firmware (firmware NIC stack) → Builtin
// drivers) → Shim (signed shim+GRUB, v0.7.0 — covers // (iPXE's own drivers) → Shim (signed shim+GRUB
// Secure Boot firmware that downloads our unsigned iPXE // covers Secure Boot firmware that downloads our
// but refuses to execute it). Shim is terminal: a MAC // unsigned iPXE but refuses to execute it). Shim is
// there stays until its entry TTLs out and resets. // 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 { if entry.awaiting_confirm {
entry.mode = match entry.mode { entry.mode = match entry.mode {
DriverMode::Firmware => DriverMode::Builtin, DriverMode::Firmware => DriverMode::Builtin,
DriverMode::Builtin | DriverMode::Shim => DriverMode::Shim, DriverMode::Builtin | DriverMode::Shim => DriverMode::Shim,
}; };
if entry.mode == DriverMode::Shim && !entry.pinned {
entry.pinned = true;
newly_pinned = true;
}
} }
entry.awaiting_confirm = true; entry.awaiting_confirm = true;
} }
entry.last_seen = now; entry.last_seen = now;
entry.mode 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 snapshot = {
let mut g = self.inner.lock(); let mut g = self.inner.lock();
if let Some(e) = g.map.get_mut(mac) { 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
// 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) .min_by_key(|(_, e)| e.last_seen)
.map(|(k, _)| k.clone()) .map(|(k, _)| k.clone())
{ };
if let Some(oldest) = pick(false).or_else(|| pick(true)) {
map.remove(&oldest); map.remove(&oldest);
} }
} }
@@ -174,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() {
@@ -250,7 +395,7 @@ mod tests {
} }
#[test] #[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 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);
@@ -258,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());
}
} }
+27 -7
View File
@@ -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,
}; };
+1
View File
@@ -2744,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,
+36
View File
@@ -35,6 +35,24 @@ pub fn render_grub_menu(isos: &[IsoMeta], base_url: &str) -> String {
let dev = grub_http_device(base); let dev = grub_http_device(base);
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "# OpenPXE — Secure Boot menu (signed shim+GRUB chain)"); 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 timeout=30");
let _ = writeln!(s, "set default=0"); let _ = writeln!(s, "set default=0");
let _ = writeln!(s); let _ = writeln!(s);
@@ -139,6 +157,24 @@ mod tests {
assert!(cfg.contains("Boot from local disk"), "{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] #[test]
fn sanboot_and_wimboot_entries_are_omitted() { fn sanboot_and_wimboot_entries_are_omitted() {
let mut iso = linux_iso(); let mut iso = linux_iso();
+4
View File
@@ -116,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.
+21
View File
@@ -135,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(),
}; };
@@ -2751,3 +2752,23 @@ async fn arch_selective_rule_ignores_other_arches() {
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
assert!(String::from_utf8_lossy(&b).contains("boot rule -> fake-alpine-linux")); 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)
);
}
+51 -1
View File
@@ -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,7 +195,7 @@ 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: openpxe_core::BootRulesStore::load_or_default(&config.paths.work_dir), boot_rules: boot_rules.clone(),
boot_tokens: openpxe_core::BootTokens::new(), 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(),
@@ -211,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,
}; };
@@ -267,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())
} }
@@ -440,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
@@ -500,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 {
+11
View File
@@ -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%; }
+167 -111
View File
@@ -199,97 +199,47 @@
})[k] || (k || 'Unknown'); })[k] || (k || 'Unknown');
} }
// v0.7.0: the Boot rules card — ordered first-match-wins rules // v0.7.2: compact read-out of saved group rules — created from the
// (MAC prefix / architecture → target) plus the optional // unified "Pin MAC" form on the Hosts tab (a prefix or an architecture
// boot-decision webhook. Saved as one config because rule order // there saves a rule instead of a pin). First match wins, top to
// matters. With no rules and no webhook, behavior is identical to // bottom. The boot-decision webhook remains available via the API
// before the feature existed. // (/api/boot-rules `webhook_url`) but no longer has a UI knob.
function bootRulesCard(cfg, targetOptions) { function groupRulesCard(cfg, targetOptions) {
const archChoices = [ const rules = (cfg && cfg.rules) || [];
['', 'any arch'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'], if (!rules.length) return null;
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'], const titleFor = id => {
]; const t = targetOptions.find(x => x.id === id);
const rules = (cfg.rules || []).map(r => Object.assign({}, r)); return t ? t.title : id;
const tbody = el('tbody', {});
const msg = el('div', {class:'msg'});
const webhookInput = el('input', {type:'text', spellcheck:'false',
placeholder:'http://automation.example/boot-decision (optional)',
value: cfg.webhook_url || ''});
const targetSelect = (val) => el('select', {},
[el('option', {value:''}, '— target —')]
.concat(targetOptions.map(t =>
el('option', Object.assign({value: t.id}, t.id === val ? {selected:''} : {}), t.title))));
const redraw = () => {
tbody.innerHTML = '';
if (!rules.length) {
tbody.appendChild(el('tr', {}, el('td', {colspan:'6', class:'empty', style:'padding:14px'},
'No rules. Add one to route whole groups of machines (an OUI, an architecture) to a target.')));
}
rules.forEach((r, i) => {
const macIn = el('input', {type:'text', spellcheck:'false', placeholder:'aa:bb:cc (prefix)',
value: r.mac_prefix || '', oninput: e => { r.mac_prefix = e.target.value; }});
const archSel = el('select', {onchange: e => { r.arch = e.target.value; }},
archChoices.map(([v, label]) =>
el('option', Object.assign({value: v}, v === (r.arch || '') ? {selected:''} : {}), label)));
const tgtSel = targetSelect(r.target || '');
tgtSel.onchange = e => { r.target = e.target.value; };
const noteIn = el('input', {type:'text', placeholder:'note',
value: r.note || '', oninput: e => { r.note = e.target.value; }});
const enabled = el('input', {type:'checkbox', onchange: e => { r.enabled = e.target.checked; }});
enabled.checked = r.enabled !== false;
tbody.appendChild(el('tr', {}, [
el('td', {}, macIn),
el('td', {}, archSel),
el('td', {}, tgtSel),
el('td', {}, noteIn),
el('td', {style:'text-align:center'}, enabled),
el('td', {style:'text-align:right'},
el('button', {class:'danger', onclick: () => { rules.splice(i, 1); redraw(); }}, '✕')),
]));
});
}; };
redraw(); const 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'} : {}, [
const addBtn = el('button', {class:'ghost', onclick: () => { el('td', {class:'mono'}, r.mac_prefix || el('span', {class:'tag'}, 'any MAC')),
rules.push({mac_prefix:'', arch:'', target:'', enabled:true, note:''}); el('td', {}, r.arch || el('span', {class:'tag'}, 'any arch')),
redraw(); el('td', {}, r.target ? titleFor(r.target) : el('span', {class:'tag'}, '—')),
}}, '+ Add rule'); el('td', {}, r.driver_mode
const saveBtn = el('button', {onclick: async () => { ? el('span', {class:'tag accent'}, modeLabel[r.driver_mode] || r.driver_mode)
const bad = rules.find(r => r.enabled !== false && !r.target); : el('span', {class:'tag'}, 'auto')),
if (bad) { msg.textContent = 'Every enabled rule needs a target.'; msg.className = 'msg err'; return; } el('td', {}, r.note || ''),
const r = await putJSON('/api/boot-rules', {rules, webhook_url: webhookInput.value.trim()}); el('td', {style:'text-align:right'},
if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; } el('button', {class:'danger', onclick: async () => {
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; } if (!confirm('Remove this group rule?')) return;
}}, 'Save rules'); 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'}, [ return el('div', {class:'card'}, [
el('header', {}, [ el('header', {}, [
el('h2', {}, 'Boot rules'), el('h2', {}, 'Group rules'),
el('span', {class:'sub'}, 'first match wins · checked top to bottom'), el('span', {class:'sub'}, 'first match wins · checked top to bottom'),
]), ]),
el('div', {class:'body'}, [
el('table', {}, [ el('table', {}, [
el('thead', {}, el('tr', {}, [ el('thead', {}, el('tr', {}, [
el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'), el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'),
el('th',{},'Note'), el('th',{},'On'), el('th',{},''), el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},''),
])), ])),
tbody, el('tbody', {}, rows),
]),
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.'),
]), ]),
]); ]);
} }
@@ -489,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'),
@@ -884,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'}, [
@@ -928,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', {}, [
@@ -1267,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'}, [
@@ -1295,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 ' +
@@ -1341,6 +1332,7 @@
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]);
@@ -1366,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)))
@@ -1380,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();
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; msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
} }
const r = await postJSON('/api/hosts', Object.assign({ const r = await postJSON('/api/hosts', Object.assign({
mac: macInput.value, target: targetSel.value, label: labelInput.value, mac, target: targetSel.value, label: labelInput.value,
}, profileFields.read())); }, profileFields.read()));
if (r.ok) { if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; render('hosts'); }
msg.textContent = 'Saved.'; msg.className = 'msg ok'; else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
render('hosts'); return;
} else {
const t = await r.text();
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
} }
}}, 'Bind MAC to target'); // 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;
}
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
@@ -1451,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'}, [
@@ -1477,7 +1517,7 @@
]), ]),
table, table,
]), ]),
bootRulesCard(rulesCfg, reserved.concat(targets)), 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'),
@@ -2154,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'),
@@ -2167,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 clients 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.'),
]), ]),
]); ]);