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]>
370 lines
13 KiB
Rust
370 lines
13 KiB
Rust
//! Label-based boot rules + boot-decision webhook (v0.7.0).
|
|
//!
|
|
//! Generalizes [`crate::host_bindings::HostBindings`] (exact-MAC pins)
|
|
//! into ordered, first-match-wins rules over what the boot chain knows
|
|
//! about a client — MAC prefix (OUI or longer) and firmware
|
|
//! architecture — plus an optional outbound webhook so external
|
|
//! automation (CMDB, netbox, a shell script) can decide the boot target
|
|
//! per machine, pixiecore-style.
|
|
//!
|
|
//! Decision order in the boot script handler, most-specific first:
|
|
//! 1. exact per-MAC host binding (operator pin)
|
|
//! 2. first matching enabled rule here
|
|
//! 3. webhook, if configured (fail-open: timeout/error → menu)
|
|
//! 4. interactive menu
|
|
//!
|
|
//! With no rules and no webhook configured the behavior is byte-for-byte
|
|
//! what it was before this feature existed — no toggles to flip.
|
|
//!
|
|
//! Persisted to `<work_dir>/boot_rules.json` with the same "in-memory
|
|
//! authoritative, disk is a crash cache, corruption falls back to empty"
|
|
//! policy as the host bindings — a bad rules file must never block PXE.
|
|
|
|
use crate::host_bindings::normalize_mac;
|
|
use parking_lot::RwLock;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
/// One ordered rule. All present (non-empty) selectors must match —
|
|
/// empty selector fields match anything, so a rule with only `arch` set
|
|
/// applies to every client of that architecture.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BootRule {
|
|
/// Case-insensitive MAC prefix, `:`-separated (e.g. `dc:a6:32` for
|
|
/// an OUI, or longer). Empty = any MAC.
|
|
#[serde(default)]
|
|
pub mac_prefix: String,
|
|
/// Client architecture selector — matches `ClientArch::as_str()`
|
|
/// (`bios`, `uefi-x64`, `uefi-ia32`, `uefi-arm64`). Empty = any.
|
|
#[serde(default)]
|
|
pub arch: String,
|
|
/// Boot entry id (a `BootEntry::id`) or reserved menu name
|
|
/// (`_local`, `_queue`, …) to chain to when this rule matches.
|
|
/// May be empty for a rule that only pins a driver mode.
|
|
#[serde(default)]
|
|
pub target: String,
|
|
/// v0.7.1: optional first-boot binary pin — `""` (auto: let the
|
|
/// escalation ladder decide), `"firmware"`, `"builtin"`, or
|
|
/// `"shim"`. Lets an operator declare "this rack is all Secure
|
|
/// Boot → serve the signed chain immediately", skipping the
|
|
/// learn-by-failing walk entirely for known fleets.
|
|
#[serde(default)]
|
|
pub driver_mode: String,
|
|
/// Rules can be parked without deleting them.
|
|
#[serde(default = "default_true")]
|
|
pub enabled: bool,
|
|
/// Operator note shown in the UI (`"all Pi 4s"`, `"QA rack"`).
|
|
#[serde(default)]
|
|
pub note: String,
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
/// The whole persisted config: ordered rules + optional webhook.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct BootRulesConfig {
|
|
pub rules: Vec<BootRule>,
|
|
/// Optional boot-decision webhook URL. When set, unmatched boots GET
|
|
/// `<url>?mac=<mac>&arch=<arch>` and a `200 {"target": "<id>"}`
|
|
/// reply chains to that target. Anything else (404, timeout, bad
|
|
/// JSON) falls through to the menu. Empty = disabled.
|
|
pub webhook_url: String,
|
|
}
|
|
|
|
/// Store for the rules config. Cheap to clone; locks held briefly.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BootRulesStore {
|
|
path: Arc<PathBuf>,
|
|
inner: Arc<RwLock<BootRulesConfig>>,
|
|
}
|
|
|
|
impl BootRulesStore {
|
|
/// Load from `work_dir/boot_rules.json`, or start empty if absent /
|
|
/// unreadable.
|
|
#[must_use]
|
|
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
|
|
let path = work_dir.join("boot_rules.json");
|
|
let inner = match std::fs::read_to_string(&path) {
|
|
Ok(text) => match serde_json::from_str::<BootRulesConfig>(&text) {
|
|
Ok(cfg) => cfg,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: "openpxe::boot_rules",
|
|
"boot_rules.json present but unreadable ({e}); starting empty"
|
|
);
|
|
BootRulesConfig::default()
|
|
}
|
|
},
|
|
Err(_) => BootRulesConfig::default(),
|
|
};
|
|
Self {
|
|
path: Arc::new(path),
|
|
inner: Arc::new(RwLock::new(inner)),
|
|
}
|
|
}
|
|
|
|
/// Current config snapshot (for the API / UI).
|
|
#[must_use]
|
|
pub fn snapshot(&self) -> BootRulesConfig {
|
|
self.inner.read().clone()
|
|
}
|
|
|
|
/// Replace the whole config (the UI saves the full table at once —
|
|
/// rules are ordered, so partial updates would be ambiguous).
|
|
pub fn replace(&self, mut cfg: BootRulesConfig) {
|
|
for r in &mut cfg.rules {
|
|
r.mac_prefix = normalize_mac(&r.mac_prefix);
|
|
r.arch = r.arch.trim().to_ascii_lowercase();
|
|
r.target = r.target.trim().to_string();
|
|
r.driver_mode = r.driver_mode.trim().to_ascii_lowercase();
|
|
r.note = r.note.trim().to_string();
|
|
}
|
|
cfg.webhook_url = cfg.webhook_url.trim().to_string();
|
|
*self.inner.write() = cfg;
|
|
self.persist();
|
|
}
|
|
|
|
/// Webhook URL, when configured.
|
|
#[must_use]
|
|
pub fn webhook_url(&self) -> Option<String> {
|
|
let g = self.inner.read();
|
|
if g.webhook_url.is_empty() {
|
|
None
|
|
} else {
|
|
Some(g.webhook_url.clone())
|
|
}
|
|
}
|
|
|
|
/// First enabled rule matching `(mac, arch)`, in stored order.
|
|
/// `arch` is the `ClientArch::as_str()` form when the boot chain
|
|
/// passed one along, `None` otherwise (older chains).
|
|
#[must_use]
|
|
pub fn match_target(&self, mac: &str, arch: Option<&str>) -> Option<String> {
|
|
self.first_match(mac, arch, |r| {
|
|
(!r.target.is_empty()).then(|| r.target.clone())
|
|
})
|
|
}
|
|
|
|
/// v0.7.1: first enabled rule that pins a driver mode for `(mac,
|
|
/// arch)`. Consulted by the DHCP proxy *before* the automatic
|
|
/// escalation ladder — an operator who knows a rack is all Secure
|
|
/// Boot pins it to `shim` and those machines never walk the ladder.
|
|
/// Unknown mode strings are ignored (forward compatibility).
|
|
#[must_use]
|
|
pub fn driver_mode_hint(&self, mac: &str, arch: Option<&str>) -> Option<crate::DriverMode> {
|
|
self.first_match(mac, arch, |r| match r.driver_mode.as_str() {
|
|
"firmware" => Some(crate::DriverMode::Firmware),
|
|
"builtin" => Some(crate::DriverMode::Builtin),
|
|
"shim" => Some(crate::DriverMode::Shim),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// Shared rule-matching walk: returns the first `extract` result from
|
|
/// an enabled rule whose selectors match. Rules that match but yield
|
|
/// `None` from `extract` (e.g. no target set, or no driver mode set)
|
|
/// don't stop the walk — target rules and mode-pin rules coexist.
|
|
fn first_match<T>(
|
|
&self,
|
|
mac: &str,
|
|
arch: Option<&str>,
|
|
extract: impl Fn(&BootRule) -> Option<T>,
|
|
) -> Option<T> {
|
|
let mac = normalize_mac(mac);
|
|
let g = self.inner.read();
|
|
for r in &g.rules {
|
|
if !r.enabled {
|
|
continue;
|
|
}
|
|
if !r.mac_prefix.is_empty() && !mac.starts_with(r.mac_prefix.as_str()) {
|
|
continue;
|
|
}
|
|
if !r.arch.is_empty() {
|
|
// An arch-selective rule can only match when the chain
|
|
// told us the client's arch.
|
|
match arch {
|
|
Some(a) if a.eq_ignore_ascii_case(&r.arch) => {}
|
|
_ => continue,
|
|
}
|
|
}
|
|
if let Some(v) = extract(r) {
|
|
return Some(v);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn persist(&self) {
|
|
let snap = self.inner.read().clone();
|
|
let body = match serde_json::to_vec_pretty(&snap) {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
tracing::warn!(target: "openpxe::boot_rules", "serialize boot_rules.json: {e}");
|
|
return;
|
|
}
|
|
};
|
|
if let Some(parent) = self.path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
let tmp = self.path.with_extension("json.tmp");
|
|
if let Err(e) = std::fs::write(&tmp, body) {
|
|
tracing::warn!(target: "openpxe::boot_rules", "write boot_rules.json tmp: {e}");
|
|
return;
|
|
}
|
|
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
|
|
tracing::warn!(target: "openpxe::boot_rules", "rename boot_rules.json: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
fn rule(mac_prefix: &str, arch: &str, target: &str) -> BootRule {
|
|
BootRule {
|
|
mac_prefix: mac_prefix.into(),
|
|
arch: arch.into(),
|
|
target: target.into(),
|
|
driver_mode: String::new(),
|
|
enabled: true,
|
|
note: String::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn driver_mode_hint_pins_known_modes_and_ignores_unknown() {
|
|
let dir = tempdir().unwrap();
|
|
let s = BootRulesStore::load_or_default(dir.path());
|
|
let mut sb_rack = rule("aa:bb:cc", "", "");
|
|
sb_rack.driver_mode = "SHIM".into(); // normalized on replace
|
|
let mut weird = rule("11:22:33", "", "");
|
|
weird.driver_mode = "quantum".into(); // unknown → ignored
|
|
s.replace(BootRulesConfig {
|
|
rules: vec![sb_rack, weird],
|
|
webhook_url: String::new(),
|
|
});
|
|
assert_eq!(
|
|
s.driver_mode_hint("aa:bb:cc:00:00:01", None),
|
|
Some(crate::DriverMode::Shim)
|
|
);
|
|
assert_eq!(s.driver_mode_hint("11:22:33:00:00:01", None), None);
|
|
assert_eq!(s.driver_mode_hint("99:99:99:00:00:01", None), None);
|
|
}
|
|
|
|
#[test]
|
|
fn mode_pin_rule_does_not_shadow_later_target_rule() {
|
|
// A mode-only rule and a target rule can both apply to the same
|
|
// client: the mode pin must not consume the target walk.
|
|
let dir = tempdir().unwrap();
|
|
let s = BootRulesStore::load_or_default(dir.path());
|
|
let mut pin = rule("aa:bb", "", "");
|
|
pin.driver_mode = "builtin".into();
|
|
s.replace(BootRulesConfig {
|
|
rules: vec![pin, rule("aa:bb", "", "rack-image")],
|
|
webhook_url: String::new(),
|
|
});
|
|
assert_eq!(
|
|
s.driver_mode_hint("aa:bb:00:00:00:01", None),
|
|
Some(crate::DriverMode::Builtin)
|
|
);
|
|
assert_eq!(
|
|
s.match_target("aa:bb:00:00:00:01", None).as_deref(),
|
|
Some("rack-image")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_config_matches_nothing() {
|
|
let dir = tempdir().unwrap();
|
|
let s = BootRulesStore::load_or_default(dir.path());
|
|
assert!(s
|
|
.match_target("aa:bb:cc:dd:ee:ff", Some("uefi-x64"))
|
|
.is_none());
|
|
assert!(s.webhook_url().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn first_match_wins_in_order() {
|
|
let dir = tempdir().unwrap();
|
|
let s = BootRulesStore::load_or_default(dir.path());
|
|
s.replace(BootRulesConfig {
|
|
rules: vec![
|
|
rule("aa:bb:cc", "", "rack-image"),
|
|
rule("", "", "catch-all"),
|
|
],
|
|
webhook_url: String::new(),
|
|
});
|
|
assert_eq!(
|
|
s.match_target("AA-BB-CC-00-00-01", None).as_deref(),
|
|
Some("rack-image")
|
|
);
|
|
assert_eq!(
|
|
s.match_target("11:22:33:44:55:66", None).as_deref(),
|
|
Some("catch-all")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn arch_selector_requires_known_arch() {
|
|
let dir = tempdir().unwrap();
|
|
let s = BootRulesStore::load_or_default(dir.path());
|
|
s.replace(BootRulesConfig {
|
|
rules: vec![rule("", "uefi-arm64", "arm-image")],
|
|
webhook_url: String::new(),
|
|
});
|
|
assert_eq!(
|
|
s.match_target("aa:bb:cc:00:00:01", Some("uefi-arm64"))
|
|
.as_deref(),
|
|
Some("arm-image")
|
|
);
|
|
// Wrong arch, or arch unknown to the chain → no match.
|
|
assert!(s.match_target("aa:bb:cc:00:00:01", Some("bios")).is_none());
|
|
assert!(s.match_target("aa:bb:cc:00:00:01", None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_rules_are_skipped() {
|
|
let dir = tempdir().unwrap();
|
|
let s = BootRulesStore::load_or_default(dir.path());
|
|
let mut r = rule("", "", "x");
|
|
r.enabled = false;
|
|
s.replace(BootRulesConfig {
|
|
rules: vec![r],
|
|
webhook_url: String::new(),
|
|
});
|
|
assert!(s.match_target("aa:bb:cc:00:00:01", None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn config_round_trips_to_disk() {
|
|
let dir = tempdir().unwrap();
|
|
let s = BootRulesStore::load_or_default(dir.path());
|
|
s.replace(BootRulesConfig {
|
|
rules: vec![rule("DC-A6-32", "", "pi-image")],
|
|
webhook_url: " http://automation/boot ".into(),
|
|
});
|
|
drop(s);
|
|
let s2 = BootRulesStore::load_or_default(dir.path());
|
|
// Prefix was normalized on replace, webhook trimmed.
|
|
assert_eq!(
|
|
s2.match_target("dc:a6:32:01:02:03", None).as_deref(),
|
|
Some("pi-image")
|
|
);
|
|
assert_eq!(s2.webhook_url().as_deref(), Some("http://automation/boot"));
|
|
}
|
|
|
|
#[test]
|
|
fn corrupt_file_falls_back_to_empty() {
|
|
let dir = tempdir().unwrap();
|
|
std::fs::write(dir.path().join("boot_rules.json"), b"{nope").unwrap();
|
|
let s = BootRulesStore::load_or_default(dir.path());
|
|
assert!(s.snapshot().rules.is_empty());
|
|
}
|
|
}
|