//! 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 `/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, /// Optional boot-decision webhook URL. When set, unmatched boots GET /// `?mac=&arch=` and a `200 {"target": ""}` /// 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, inner: Arc>, } 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::(&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 { 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 { 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 { 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( &self, mac: &str, arch: Option<&str>, extract: impl Fn(&BootRule) -> Option, ) -> Option { 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()); } }