//! Runtime-mutable settings, distinct from the static `Config`. //! //! Rationale: `Config` holds bind addresses, paths, and other things that //! can only reasonably change at process start. `Settings` holds everything //! the web UI can flip at runtime: timeouts, default boot action, Windows //! feature toggles, etc. Persisted to `/settings.json` so they //! survive pod restarts without requiring a ConfigMap edit. //! //! **Crucial property:** every UI-facing "feature flag" in here maps to a //! specific iPXE script-generation behavior elsewhere in the codebase. The //! user never writes iPXE; they toggle a setting and we translate. use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::Arc; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Settings { /// Seconds to wait at the top-level boot menu before falling through /// to `timeout_action`. Default 600s per the Phase 2 spec. pub boot_menu_timeout_secs: u32, /// What happens if the boot-menu timer hits zero with no selection. pub timeout_action: TimeoutAction, /// Master enable for Windows ISO support. When off, Windows ISOs are /// listed in the UI as "Windows — disabled" and not exposed in the /// PXE menu. Off by default: Windows support requires bundling Samba /// and wimlib in the runtime image (see deploy/docker/Dockerfile). pub windows_enabled: bool, /// SMB share hostname/IP the patched WinPE's startnet.cmd will /// `net use` against. Empty string = auto-fill with the public IP at /// script-generation time. pub smb_host_override: String, /// Global kernel-args append (added to every Linux entry's cmdline). /// Useful for things like `console=ttyS0,115200` on serial-only boxes. /// Do NOT accept raw iPXE script fragments here; this is literal kernel /// args only. pub extra_kernel_args: String, /// If true, the "Default → Boot from Local HDD" menu item is the /// pre-selected entry (and is what the timeout falls to if /// `timeout_action = LocalHdd`). pub default_local_hdd: bool, /// When a client hits the Gated Deployment item, how long (seconds) to /// hold it at the gate before giving up and falling back to the menu. /// 0 = forever. pub gate_wait_max_secs: u32, /// Optional DNS server advertised on the Network tab. Purely /// informational today — PXEForge does not run a DNS server, but /// operators expect to be able to record what the upstream DNS is. /// Empty string = unset (UI shows placeholder). pub dns_server: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum TimeoutAction { /// Sit at the menu forever (no fallthrough). Stay, /// Chain the "Boot from Local HDD" entry. LocalHdd, /// Put the client into the gate queue, waiting for operator assignment. #[default] GatedDeployment, } impl Default for Settings { fn default() -> Self { Self { boot_menu_timeout_secs: 600, timeout_action: TimeoutAction::GatedDeployment, windows_enabled: false, smb_host_override: String::new(), extra_kernel_args: String::new(), default_local_hdd: true, gate_wait_max_secs: 0, dns_server: String::new(), } } } #[derive(Debug)] pub struct SettingsStore { path: PathBuf, inner: RwLock, } impl SettingsStore { /// Load from `work_dir/settings.json`, or create with defaults if the /// file is missing/corrupt. Never fails — a bad settings file on disk /// is not a reason to refuse to start. pub fn load_or_default(work_dir: &Path) -> Arc { let path = work_dir.join("settings.json"); let initial = match std::fs::read_to_string(&path) { Ok(text) => match serde_json::from_str::(&text) { Ok(s) => s, Err(e) => { tracing::warn!( target: "pxeforge::settings", "settings.json present but unreadable ({e}); falling back to defaults" ); Settings::default() } }, Err(_) => Settings::default(), }; Arc::new(Self { path, inner: RwLock::new(initial) }) } #[must_use] pub fn snapshot(&self) -> Settings { self.inner.read().clone() } /// Atomically replace settings and persist. The caller supplies the full /// `Settings` struct — partial updates happen at the HTTP layer via /// merge-then-store. Persistence errors are logged but not returned; /// settings live in memory authoritatively and only SHOULD be on disk. pub fn replace(&self, new: Settings) { { let mut g = self.inner.write(); *g = new; } let snap = self.snapshot(); if let Err(e) = self.persist(&snap) { tracing::warn!(target: "pxeforge::settings", "failed to persist settings: {e}"); } } fn persist(&self, s: &Settings) -> std::io::Result<()> { let tmp = self.path.with_extension("json.tmp"); if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent)?; } let body = serde_json::to_vec_pretty(s) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; std::fs::write(&tmp, body)?; std::fs::rename(tmp, &self.path) } } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn defaults_roundtrip() { let dir = tempdir().unwrap(); let store = SettingsStore::load_or_default(dir.path()); let s = store.snapshot(); assert_eq!(s.boot_menu_timeout_secs, 600); assert_eq!(s.timeout_action, TimeoutAction::GatedDeployment); assert!(!s.windows_enabled); } #[test] fn replace_persists() { let dir = tempdir().unwrap(); let store = SettingsStore::load_or_default(dir.path()); let mut new = store.snapshot(); new.boot_menu_timeout_secs = 30; new.windows_enabled = true; store.replace(new); // Reload from disk. drop(store); let reloaded = SettingsStore::load_or_default(dir.path()); let s = reloaded.snapshot(); assert_eq!(s.boot_menu_timeout_secs, 30); assert!(s.windows_enabled); } #[test] fn corrupt_file_falls_back_to_default() { let dir = tempdir().unwrap(); std::fs::write(dir.path().join("settings.json"), b"{ not json }").unwrap(); let store = SettingsStore::load_or_default(dir.path()); assert_eq!(store.snapshot().boot_menu_timeout_secs, 600); } }